From fe25d612d0d8d8ea5b0bc06219e8bf48f4c7142f Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Mon, 14 Sep 2026 23:51:43 +0300 Subject: [PATCH 01/14] feat(hooks): pause installed coding bridges from Fleet (FIRE-2137) --- .agents/plugins/marketplace.json | 4 +- .claude-plugin/marketplace.json | 2 +- .cursor-plugin/marketplace.json | 4 +- .github/plugin/marketplace.json | 4 +- .github/workflows/validate.yml | 5 + docs/protection-pause.md | 17 ++ plugins/antigravity/VERSION | 2 +- plugins/antigravity/scripts/heartbeat.ps1 | 2 + plugins/antigravity/scripts/heartbeat.sh | 2 + plugins/antigravity/scripts/hook.ps1 | 15 +- plugins/antigravity/scripts/hook.sh | 10 + plugins/antigravity/scripts/protection.ps1 | 179 +++++++++++++++++ plugins/antigravity/scripts/protection.sh | 163 +++++++++++++++ plugins/antigravity/scripts/ship-logs.ps1 | 15 +- plugins/antigravity/scripts/ship-logs.sh | 16 +- plugins/codex/.codex-plugin/plugin.json | 4 +- plugins/codex/scripts/heartbeat.ps1 | 2 + plugins/codex/scripts/heartbeat.sh | 3 + plugins/codex/scripts/hook.ps1 | 11 +- plugins/codex/scripts/hook.sh | 10 + plugins/codex/scripts/protection.ps1 | 179 +++++++++++++++++ plugins/codex/scripts/protection.sh | 163 +++++++++++++++ plugins/codex/scripts/ship-logs.ps1 | 15 +- plugins/codex/scripts/ship-logs.sh | 16 +- plugins/copilot/plugin.json | 8 +- plugins/copilot/scripts/heartbeat.ps1 | 2 + plugins/copilot/scripts/heartbeat.sh | 3 + plugins/copilot/scripts/hook.ps1 | 11 +- plugins/copilot/scripts/hook.sh | 10 + plugins/copilot/scripts/protection.ps1 | 179 +++++++++++++++++ plugins/copilot/scripts/protection.sh | 163 +++++++++++++++ plugins/copilot/scripts/ship-logs.ps1 | 15 +- plugins/copilot/scripts/ship-logs.sh | 16 +- plugins/cursor/.cursor-plugin/plugin.json | 4 +- plugins/cursor/scripts/hook.ps1 | 11 +- plugins/cursor/scripts/hook.sh | 10 + plugins/cursor/scripts/protection.ps1 | 179 +++++++++++++++++ plugins/cursor/scripts/protection.sh | 163 +++++++++++++++ plugins/cursor/scripts/ship-logs.ps1 | 15 +- plugins/cursor/scripts/ship-logs.sh | 16 +- plugins/gemini/gemini-extension.json | 4 +- plugins/gemini/scripts/heartbeat.mjs | 4 +- plugins/gemini/scripts/hook.mjs | 35 +++- plugins/gemini/scripts/protection.mjs | 146 ++++++++++++++ plugins/gemini/scripts/ship-logs.mjs | 18 +- plugins/kiro/VERSION | 2 +- plugins/kiro/plugin.json | 4 +- plugins/kiro/scripts/heartbeat.ps1 | 2 + plugins/kiro/scripts/heartbeat.sh | 2 + plugins/kiro/scripts/hook.ps1 | 24 ++- plugins/kiro/scripts/hook.sh | 10 + plugins/kiro/scripts/protection.ps1 | 179 +++++++++++++++++ plugins/kiro/scripts/protection.sh | 163 +++++++++++++++ plugins/kiro/scripts/ship-logs.ps1 | 15 +- plugins/kiro/scripts/ship-logs.sh | 16 +- plugins/rogue/.claude-plugin/plugin.json | 2 +- plugins/rogue/scripts/heartbeat.ps1 | 2 + plugins/rogue/scripts/heartbeat.sh | 3 + plugins/rogue/scripts/hook.ps1 | 11 +- plugins/rogue/scripts/hook.sh | 10 + plugins/rogue/scripts/protection.ps1 | 179 +++++++++++++++++ plugins/rogue/scripts/protection.sh | 163 +++++++++++++++ plugins/rogue/scripts/ship-logs.ps1 | 15 +- plugins/rogue/scripts/ship-logs.sh | 16 +- scripts/shared/protection.ps1 | 179 +++++++++++++++++ scripts/shared/protection.sh | 163 +++++++++++++++ scripts/shared/ship-logs.ps1 | 15 +- scripts/shared/ship-logs.sh | 16 +- scripts/sync-shared-scripts.sh | 2 + tests/mock_server.py | 4 + tests/ship_probe.mjs | 1 + tests/test_heartbeat_sh.sh | 8 +- tests/test_protection.mjs | 219 +++++++++++++++++++++ tests/test_ship_logs.sh | 4 + 74 files changed, 3211 insertions(+), 73 deletions(-) create mode 100644 docs/protection-pause.md create mode 100644 plugins/antigravity/scripts/protection.ps1 create mode 100644 plugins/antigravity/scripts/protection.sh create mode 100644 plugins/codex/scripts/protection.ps1 create mode 100644 plugins/codex/scripts/protection.sh create mode 100644 plugins/copilot/scripts/protection.ps1 create mode 100644 plugins/copilot/scripts/protection.sh create mode 100644 plugins/cursor/scripts/protection.ps1 create mode 100644 plugins/cursor/scripts/protection.sh create mode 100644 plugins/gemini/scripts/protection.mjs create mode 100644 plugins/kiro/scripts/protection.ps1 create mode 100644 plugins/kiro/scripts/protection.sh create mode 100644 plugins/rogue/scripts/protection.ps1 create mode 100644 plugins/rogue/scripts/protection.sh create mode 100644 scripts/shared/protection.ps1 create mode 100644 scripts/shared/protection.sh create mode 100644 tests/test_protection.mjs diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index d647385..57414fe 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -10,8 +10,8 @@ "plugins": [ { "name": "rogue", - "version": "1.0.3", - "description": "Rogue Security AIDR — real-time AI agent detection and response for OpenAI Codex", + "version": "1.0.4", + "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for OpenAI Codex", "author": { "name": "Rogue Security", "url": "https://www.rogue.security" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 89dd1d9..4faede5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue", - "version": "1.0.29", + "version": "1.0.30", "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for Claude Code", "author": { "name": "Rogue Security", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 7cf1317..ef15436 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,8 +11,8 @@ "plugins": [ { "name": "rogue-security", - "version": "1.1.5", - "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", + "version": "1.1.6", + "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for Cursor", "author": { "name": "Rogue Security", "url": "https://www.rogue.security" diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 89664f4..eeb6f52 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -10,8 +10,8 @@ "plugins": [ { "name": "rogue", - "version": "1.2.4", - "description": "Rogue Security AIDR — real-time AI agent detection and response for GitHub Copilot CLI", + "version": "1.2.5", + "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for GitHub Copilot CLI", "author": { "name": "Rogue Security", "url": "https://www.rogue.security" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 051a964..e4b11e8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -225,6 +225,11 @@ jobs: SH=dash sh tests/test_hook_logs.sh SH=bash bash tests/test_hook_logs.sh + - name: Installation pause contract + env: + ROGUE_TEST_PWSH: pwsh + run: node --test tests/test_protection.mjs + - name: Gemini dispatcher contract (node) # The only suite that exercises hook.mjs end to end against a real server, # and until now the only dispatcher suite CI never ran — the file existed diff --git a/docs/protection-pause.md b/docs/protection-pause.md new file mode 100644 index 0000000..5120645 --- /dev/null +++ b/docs/protection-pause.md @@ -0,0 +1,17 @@ +# Pause coding protection from Fleet + +Organization owners and admins can pause a supported coding installation from its existing Fleet detail sheet. AIDR pause stops Rogue hook payload collection, evaluation, blocking, local activity logs and log uploads. The host receives its normal allow response. Management requests continue so the client can receive a resume decision. + +Protocol 1 is included in the source versions Claude 1.0.30, Codex 1.0.4, Cursor 1.1.6, Copilot 1.2.5, Gemini 1.0.29, Antigravity 1.0.27 and Kiro 1.0.1. Release and deployment remain separate steps. Windows uses the PowerShell bridge; macOS and Linux use shell bridges, with Node for Gemini. + +Choose a reason and a duration of one hour, four hours, 24 hours or until resumed. Any organization admin may resume or change duration without another reason. AISPM discovery is controlled on the endpoint that runs scans. An independent API-key pause still applies after the installation is resumed. + +The bridge exchanges its provisioning key for an installation credential at `/api/v1/hooks/protection/enroll`. Identity is server-assigned and persists in the user's private protection directory. A hostname or email cannot claim an existing identity. Related host variants that use the same bridge configuration share that installation. Independent configurations remain separate. After provisioning-key rotation, the bridge restores its existing authenticated installation and reuses its saved decision and active-work tracking. An unavailable server postpones restoration without collecting activity under an unscoped key. + +The bridge fetches the current decision before collecting hook input and keeps a short-lived background poller while the installation is in use. Polling runs every 15 seconds; the delivery target is 60 seconds online. The server displays applied only after a current acknowledgement. Pending, offline, unsupported or failed are not proof that local work stopped. A state-storage failure blocks the current hook and reports a failure acknowledgement. A saved finite pause expires offline; an indefinite pause remains until confirmed resume. + +Registered installations store their hook logs inside their private protection directory and ship them with their own credential. The scoped log path takes precedence over a shared custom log destination. On resume, the shipper advances its own offsets past buffered content without modifying the source log. It sends only fresh subsequent entries. An endpoint's mixed-log diagnostic uploader cannot attribute these installations and must not bypass their pauses. + +Older plugins and native HTTP hooks without a local gate have limited coverage. Their API-key pause can suppress server work but cannot stop local collection or CPU use. Use an updated command bridge for local suspension. Existing activity already committed before a pause remains historical. + +Run `node --test tests/test_protection.mjs` for isolated HTTP and child-process checks. Set `ROGUE_TEST_PWSH=pwsh` to include the PowerShell gate. The test owns its credentials, files and polling processes; it never sends test activity to production. Signed native endpoint, Windows host and vendor application verification require their respective environments. diff --git a/plugins/antigravity/VERSION b/plugins/antigravity/VERSION index 8955a01..adb7b04 100644 --- a/plugins/antigravity/VERSION +++ b/plugins/antigravity/VERSION @@ -1 +1 @@ -1.0.26 +1.0.27 diff --git a/plugins/antigravity/scripts/heartbeat.ps1 b/plugins/antigravity/scripts/heartbeat.ps1 index 950b34b..c99f882 100644 --- a/plugins/antigravity/scripts/heartbeat.ps1 +++ b/plugins/antigravity/scripts/heartbeat.ps1 @@ -307,6 +307,8 @@ function Invoke-Main { Initialize-Beacon # after the env files are parsed so they can set the interval Assert-ApiKey # exits 0 when this install is not configured Resolve-BaseUrl + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') + $script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'antigravity' -Family 'antigravity' Resolve-Actor Resolve-Version Resolve-Surface diff --git a/plugins/antigravity/scripts/heartbeat.sh b/plugins/antigravity/scripts/heartbeat.sh index bbd1b7b..7f223a4 100644 --- a/plugins/antigravity/scripts/heartbeat.sh +++ b/plugins/antigravity/scripts/heartbeat.sh @@ -173,6 +173,8 @@ main() { locate_plugin_root load_env # sources the env files, then normalises the base URL require_api_key # exits 0 when this install is not configured + . "${PLUGIN_ROOT}/scripts/protection.sh" + rogue_protection_init antigravity antigravity "${PLUGIN_ROOT}/scripts" load_actor resolve_version resolve_surface "${1:-}" diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index c3b9dea..af01bf1 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -222,6 +222,7 @@ function Rotate-Log { function Log { param([string]$Msg) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } try { if (-not $logFile) { return } $dir = Split-Path $logFile @@ -310,7 +311,8 @@ function Resolve-Actor { # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── function Read-Payload { - $script:payload = [Console]::In.ReadToEnd() + $script:payload = Read-RogueProtectionInput + if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } if (-not $script:payload) { $script:payload = '{}' } try { $raw = [Console]::InputEncoding.GetBytes($script:payload) @@ -912,7 +914,9 @@ function Invoke-Post { $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) $resp = '' try { - $r = Invoke-WebRequest -Uri $url -Method Post ` + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and $null -ne $script:RPRevision) { $headers['x-rogue-activity-revision']=[string]$script:RPRevision } + $r = Invoke-WebRequest -Uri $url -Method Post ` -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop if ($r.StatusCode -eq 200) { @@ -922,7 +926,8 @@ function Invoke-Post { } catch { Dbg "POST failed: $($_.Exception.Message)"; $resp = '' } $respHead = if ($resp.Length -gt 400) { $resp.Substring(0, 400) } else { $resp } - Log "raw=$(Sanitize $respHead)" + if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } +Log "raw=$(Sanitize $respHead)" # Fail-open on transport error, any non-200, or an empty body: emit the # per-event default rather than relaying garbage as a decision. @@ -958,6 +963,10 @@ function Invoke-Main { Assert-ApiKey # exits before stdin is read when there is no key Resolve-Url Resolve-Actor +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$script:apiKey = Initialize-RogueProtection -Key $script:apiKey -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug 'antigravity' -Family 'antigravity' +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } + Read-Payload # Immediately after the payload, and BEFORE anything that logs or sends - the # same position hook.sh resolves it in. Every log line from here on carries the diff --git a/plugins/antigravity/scripts/hook.sh b/plugins/antigravity/scripts/hook.sh index 091b0b5..32398ed 100755 --- a/plugins/antigravity/scripts/hook.sh +++ b/plugins/antigravity/scripts/hook.sh @@ -121,6 +121,8 @@ load_env() { # periodic job on purpose: an UNCONFIGURED install writes a line per event and # never runs anything else, so a cap enforced anywhere else would not hold. rotate_log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" # and Number("00") do in the PowerShell and Node dispatchers. @@ -133,6 +135,7 @@ rotate_log() { } log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi # 0700 dir / 0600 file. The logged text is not only ours: it carries the # server's block reason, which quotes the content that tripped the rule - a # secret, a command, a slice of a prompt. Under the default umask the log @@ -785,6 +788,7 @@ post_and_relay() { _raw=$(printf '%s' "$BODY" | curl -sS -X POST "$URL" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H "x-rogue-event: $EVENT" \ -H "x-rogue-actor-email: $ROGUE_ACTOR_EMAIL" \ -H "x-rogue-actor-name: $ROGUE_ACTOR_NAME" \ @@ -798,6 +802,7 @@ post_and_relay() { _code=$(printf '%s' "$_raw" | tail -n1) _resp=$(printf '%s' "$_raw" | sed '$d') +rogue_protection_current || { printf '%s' '{"decision":"allow"}'; exit 0; } log "http=$_code rc=$_rc raw=$(sanitize "$_resp" | head -c 400)" # Fail-open on transport error, any non-200, or an empty body: emit the @@ -823,6 +828,11 @@ main() { stand_down_under_git_bash locate_plugin_root load_env # sources the env files, then every default derived from them +. "${PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init antigravity antigravity "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" +rogue_protection_enter || { printf '%s' '{"decision":"allow"}'; exit 0; } +trap 'rogue_protection_leave' EXIT + require_api_key # exits before stdin is read when there is no key load_actor read_body diff --git a/plugins/antigravity/scripts/protection.ps1 b/plugins/antigravity/scripts/protection.ps1 new file mode 100644 index 0000000..002d7d2 --- /dev/null +++ b/plugins/antigravity/scripts/protection.ps1 @@ -0,0 +1,179 @@ +param([string]$Poll, [string]$Base, [string]$ScriptDirectory=$PSScriptRoot) +$script:RPHelperDirectory=$ScriptDirectory +$script:RPDirectory=$null +$script:RPRevision=$null +$script:RPKey=$null + +function Write-RogueProtectionFile([string]$Path, [string]$Value) { + $temp = "$Path.$PID.tmp" + [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) + Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop +} +function Get-RogueProtectionState { + try { + $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) + foreach ($cap in 'aidr','aispm') { + if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } + } + return $value.decision + } catch { return $null } +} +function Test-RogueProtectionCurrent { + if (-not $script:RPDirectory) { return $true } + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return $false } + $state = Get-RogueProtectionState + return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) +} +function Send-RogueProtectionAck { + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } + $state = Get-RogueProtectionState + if (-not $state) { return } + foreach ($lease in @(Get-ChildItem -LiteralPath $script:RPDirectory -Filter 'active.*' -ErrorAction SilentlyContinue)) { + $owner = 0 + if ([int]::TryParse(($lease.Name -replace '^active\.',''), [ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue)) { return } + Remove-Item -LiteralPath $lease.FullName -ErrorAction SilentlyContinue + } + $identity = "$($state.revision):$($state.aidr.paused):$($state.aispm.paused)" + if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } + try { + $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress + $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Write-RogueProtectionFile "$script:RPDirectory/ack" $identity + } catch { } +} +function Update-RogueProtection { + if (-not $script:RPDirectory) { return } + try { + $lock = [IO.File]::Open("$script:RPDirectory/refresh.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch { return } + try { + Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $old = Get-RogueProtectionState + if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + try { + Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) + Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue + $script:RPPersistenceFailed=$false + } catch { + $script:RPPersistenceFailed=$true + try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + } + } catch { } finally { $lock.Dispose() } + Send-RogueProtectionAck +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + $root=$env:ROGUE_PROTECTION_DIR + if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + $hash=[Security.Cryptography.SHA256]::Create() + try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } + $script:RPDirectory=Join-Path $root "$Slug-default-$id" + if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue + if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + $credential="$script:RPDirectory/credential" + try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + if (-not (Test-Path -LiteralPath $credential)) { + foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { + if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } + $previousKey=Get-Content -LiteralPath (Join-Path $previous.FullName 'credential') -Raw -ErrorAction SilentlyContinue + if (-not $previousKey) { continue } + try { + $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress + $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($restored.apiKey -ne $previousKey) { continue } + Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName + $script:RPDirectory=$previous.FullName + $credential=Join-Path $script:RPDirectory 'credential' + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + break + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } + } + if (-not (Test-Path -LiteralPath $credential)) { + $enrollAttempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + try { + if (-not (Test-Path -LiteralPath $credential)) { + Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } + if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } + } + } catch { } finally { $lock.Dispose() } + } + if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + $script:RPKey=Get-Content -LiteralPath $credential -Raw + $attempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } + $state=Get-RogueProtectionState + $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } + $env:ROGUE_PROTECTION_STATE=$script:RPDirectory + $env:ROGUE_LOG_FILE=Join-Path $script:RPDirectory "$Slug.log" + $script:logFile=$env:ROGUE_LOG_FILE + return $script:RPKey +} +function Read-RogueProtectionInput { + $reader=[Console]::OpenStandardInput() + $buffer=New-Object byte[] 4096 + $text=New-Object IO.MemoryStream + while (Test-RogueProtectionCurrent) { + $pending=$reader.ReadAsync($buffer,0,$buffer.Length) + while (-not $pending.IsCompleted) { + if (-not (Test-RogueProtectionCurrent)) { return '' } + Start-Sleep -Milliseconds 100 + } + $count=$pending.GetAwaiter().GetResult() + if ($count -eq 0) { break } + if (-not (Test-RogueProtectionCurrent)) { return '' } + $text.Write($buffer,0,$count) + } + $bytes=$text.ToArray(); $text.Dispose() + if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } + return '' +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + return Test-RogueProtectionCurrent +} +if ($Poll) { + $script:RPDirectory=$Poll; $script:RPBase=$Base + $script:RPKey=Get-Content -LiteralPath "$Poll/credential" -Raw + while ($true) { + $used=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$Poll/used" -Raw -ErrorAction SilentlyContinue), [ref]$used) + $busy=@(Get-ChildItem -LiteralPath $Poll -Filter 'active.*' -ErrorAction SilentlyContinue | Where-Object { $owner=0; [int]::TryParse(($_.Name -replace '^active\.',''),[ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue) }).Count -gt 0 + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $used -ge 90 -and -not $busy) { break } + Update-RogueProtection + Start-Sleep -Seconds 15 + } +} diff --git a/plugins/antigravity/scripts/protection.sh b/plugins/antigravity/scripts/protection.sh new file mode 100644 index 0000000..45b6348 --- /dev/null +++ b/plugins/antigravity/scripts/protection.sh @@ -0,0 +1,163 @@ +#!/bin/sh +# Shared installation credential and pause gate. No activity payload is read here. +rogue_protection_now() { date +%s; } +rogue_protection_lock() { + ln -s "$$" "$1" 2>/dev/null && return 0 + _rp_owner=$(readlink "$1" 2>/dev/null) || return 1 + case "$_rp_owner" in *[!0-9]*|'') return 1 ;; esac + kill -0 "$_rp_owner" 2>/dev/null || rm -f "$1" + return 1 +} +rogue_protection_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +rogue_protection_load() { + [ -r "$ROGUE_PROTECTION_STATE/decision" ] || return 1 + IFS=' ' read -r RP_PROTOCOL RP_REV RP_AIDR RP_AIDR_EXP RP_AISPM RP_AISPM_EXP RP_SERVER RP_AIDR_REV RP_AISPM_REV RP_RECEIVED < "$ROGUE_PROTECTION_STATE/decision" + [ "$RP_PROTOCOL" = 1 ] || return 1 + RP_NOW=$(( $(rogue_protection_now) - RP_RECEIVED + RP_SERVER )) + [ "$RP_AIDR_EXP" -eq 0 ] || [ "$RP_AIDR_EXP" -gt "$RP_NOW" ] || RP_AIDR=0 + [ "$RP_AISPM_EXP" -eq 0 ] || [ "$RP_AISPM_EXP" -gt "$RP_NOW" ] || RP_AISPM=0 +} +rogue_protection_refresh() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_lock "$ROGUE_PROTECTION_STATE/refresh.lock" || return 0 + # A failed request is throttled too; an unavailable server must not cause a retry storm. + rogue_protection_now > "$ROGUE_PROTECTION_STATE/attempt" + _rp_decision=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'accept: text/tab-separated-values' "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/state" 2>/dev/null) || _rp_decision='' + case "$_rp_decision" in *[!0-9\ \ ]*|'') ;; *) + set -- $_rp_decision + if [ "$#" -eq 9 ] && [ "$1" = 1 ]; then + _rp_old=0 + rogue_protection_load && _rp_old=$RP_REV + if [ "$2" -ge "$_rp_old" ]; then + if printf '%s %s\n' "$*" "$(rogue_protection_now)" > "$ROGUE_PROTECTION_STATE/decision.tmp" && [ ! -d "$ROGUE_PROTECTION_STATE/decision" ] && mv -f "$ROGUE_PROTECTION_STATE/decision.tmp" "$ROGUE_PROTECTION_STATE/decision"; then + RP_PERSISTENCE_FAILED=0 + rm -f "$ROGUE_PROTECTION_STATE/persistence-failed" + else + RP_PERSISTENCE_FAILED=1 + touch "$ROGUE_PROTECTION_STATE/persistence-failed" 2>/dev/null || true + _rp_a=false; [ "$3" = 1 ] && _rp_a=true + _rp_s=false; [ "$5" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$2,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true + fi + fi + fi ;; + esac + rm -f "$ROGUE_PROTECTION_STATE/refresh.lock" 2>/dev/null || true + rogue_protection_ack +} +rogue_protection_busy() { + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + kill -0 "${_rp_lease##*.}" 2>/dev/null && return 0 + done + return 1 +} +rogue_protection_ack() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "$ROGUE_PROTECTION_STATE/persistence-failed" ] || return 0 + rogue_protection_load || return 0 + _rp_busy=0 + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + _rp_pid=${_rp_lease##*.} + if kill -0 "$_rp_pid" 2>/dev/null; then _rp_busy=1; else rm -f "$_rp_lease"; fi + done + [ "$_rp_busy" -eq 0 ] || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + _rp_ack="$RP_REV:$RP_AIDR:$RP_AISPM" + [ "$(cat "$ROGUE_PROTECTION_STATE/ack" 2>/dev/null)" != "$_rp_ack" ] || return 0 + if curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"applied\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1; then + printf '%s' "$_rp_ack" > "$ROGUE_PROTECTION_STATE/ack" + fi +} +rogue_protection_current() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "${ROGUE_PROTECTION_STATE:-}/persistence-failed" ] || return 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_load || return 1 + [ "$RP_AIDR" = 0 ] && [ "${ROGUE_PROTECTION_REVISION:-$RP_AIDR_REV}" = "$RP_AIDR_REV" ] +} +rogue_protection_leave() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rm -f "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_ack +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || return 0 + case "${ROGUE_PROTECTION_STATE:-}" in + */"$1-default-"*) + [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; + *) ROGUE_PROTECTION_STATE='' ;; + esac + ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) + case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) + if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; + esac + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do + [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue + [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue + _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue + [ -n "$_rp_previous_key" ] || continue + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue + (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + break + done + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" + _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac + if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" + else + _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) + if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi + fi + rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV + export ROGUE_PROTECTION_REVISION +} +rogue_protection_enter() { + rogue_protection_current || return 1 + [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_current +} +if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + rogue_protection_refresh + sleep 15 + done + rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid" + rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null || true +fi diff --git a/plugins/antigravity/scripts/ship-logs.ps1 b/plugins/antigravity/scripts/ship-logs.ps1 index ba62e78..dffeb83 100644 --- a/plugins/antigravity/scripts/ship-logs.ps1 +++ b/plugins/antigravity/scripts/ship-logs.ps1 @@ -122,6 +122,7 @@ function Write-ShipDebug { # the timestamp. "`n" keeps the line ending identical to the sh dispatchers'. function Write-ShipLog { param([string]$Message) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } # ALSO to stderr under ROGUE_DEBUG, and unconditionally - before the selfLogFile # gate below. The no-argument support invocation has no slug, so it has no log # file of its own to write to, and every failure reason (`http=`, @@ -814,7 +815,7 @@ function Send-ChunkRequest { try { $payload = [System.Text.Encoding]::UTF8.GetBytes($json) $response = Invoke-WebRequest -Uri $script:shipUrl -Method Post ` - -Headers @{ 'x-rogue-api-key' = $script:apiKey } ` + -Headers @{ 'x-rogue-api-key' = $script:apiKey; 'x-rogue-activity-revision' = if (Get-Variable RPRevision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:RPRevision } else { '' } } ` -ContentType 'application/json' -Body $payload ` -UseBasicParsing -TimeoutSec $HTTP_TIMEOUT -ErrorAction Stop $httpCode = [int]$response.StatusCode @@ -840,6 +841,7 @@ function Invoke-DrainFile { [string]$PersistHead, [int64]$PersistSize, [string]$NormalizedPath) $iteration = 0 while ($script:offset -lt $FileBytes) { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return $false } if ($script:runBytesSent -ge $script:maxRunBytes) { Write-ShipDebug 'run budget spent'; return $false } $iteration++ if ($iteration -gt $MAX_CHUNKS_PER_DRAIN) { Write-ShipDebug 'iteration guard'; return $false } @@ -870,6 +872,11 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) + return + } $script:runBytesSent = 0 $rotated = $false @@ -924,6 +931,10 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -934,7 +945,7 @@ function Invoke-Main { exit 0 } - $script:stateDir = Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' + $script:stateDir = if ($script:RPDirectory) { Join-Path $script:RPDirectory 'ship' } else { Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' } if (-not (Test-Path -LiteralPath $script:stateDir)) { New-Item -ItemType Directory -Path $script:stateDir -Force | Out-Null } diff --git a/plugins/antigravity/scripts/ship-logs.sh b/plugins/antigravity/scripts/ship-logs.sh index 1eb3152..f95e7a8 100644 --- a/plugins/antigravity/scripts/ship-logs.sh +++ b/plugins/antigravity/scripts/ship-logs.sh @@ -628,6 +628,7 @@ find_line_end() { # # (which is NOT always the bytes sent - an over-long line is skipped forward). # Returns non-zero when nothing was shipped and the offset must NOT move. ship_next_chunk() { # + rogue_protection_current || return 1 ADVANCE_BYTES=0 _chunk_source_file="${1:-}" _chunk_offset="${2:-0}" @@ -763,6 +764,7 @@ ship_oversize_line() { # # body is passed as --data-binary @file rather than -d, because a 1 MiB chunk is # ~1.4 MiB of base64 and macOS's ARG_MAX is 1 MiB for args plus environment. post_chunk() { # + rogue_protection_current || return 1 _post_chunk_file="${1:-}" _post_offset="${2:-0}" _post_bytes="${3:-0}" @@ -784,6 +786,7 @@ post_chunk() { # debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -839,6 +842,12 @@ ship_log_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" + release_lock + return 0 + fi RUN_BYTES_SENT=0 _target_rotated=0 @@ -890,6 +899,9 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + . "$PLUGIN_ROOT/scripts/protection.sh" + rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } @@ -902,12 +914,12 @@ main() { exit 0 fi - STATE_DIR="$HOME/.rogue/ship" + STATE_DIR="${ROGUE_PROTECTION_STATE:-$HOME/.rogue}/ship" mkdir -p "$STATE_DIR" 2>/dev/null [ -d "$STATE_DIR" ] || { debug "cannot create $STATE_DIR"; exit 0; } TMP_DIR=$(mktemp -d "$STATE_DIR/.tmp.XXXXXX" 2>/dev/null) || TMP_DIR="" [ -n "$TMP_DIR" ] || { debug 'cannot create a temp dir'; exit 0; } - trap 'cleanup' EXIT INT TERM + trap 'cleanup; rogue_protection_leave' EXIT INT TERM # A redirect, not a pipe: a `while` on the right of a pipe runs in a subshell in # POSIX sh, and log paths can contain spaces, so read them line by line. diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 51a3432..3375c45 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "rogue", - "version": "1.0.3", - "description": "Rogue Security AIDR — real-time AI agent detection and response for OpenAI Codex", + "version": "1.0.4", + "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for OpenAI Codex", "author": { "name": "Rogue Security", "url": "https://www.rogue.security" diff --git a/plugins/codex/scripts/heartbeat.ps1 b/plugins/codex/scripts/heartbeat.ps1 index b3d7207..35064a0 100644 --- a/plugins/codex/scripts/heartbeat.ps1 +++ b/plugins/codex/scripts/heartbeat.ps1 @@ -122,6 +122,8 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'codex' -Family 'openai' # ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── # env file → git config files → @ → unknown. A damaged install with diff --git a/plugins/codex/scripts/heartbeat.sh b/plugins/codex/scripts/heartbeat.sh index a0fbf03..0d29aca 100755 --- a/plugins/codex/scripts/heartbeat.sh +++ b/plugins/codex/scripts/heartbeat.sh @@ -56,6 +56,9 @@ fi # Family is the fixed enum "openai"; the surface rides the agent field. esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } +. "${PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init codex openai "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" + BODY=$(printf '{"agent_family":"openai","agent":"%s","version":"%s","host":"%s","actor_email":"%s","actor_name":"%s"}' \ "$(esc "${ROGUE_INSTALL_AGENT:-codex_cli}")" "$(esc "${ROGUE_INSTALL_VERSION:-unknown}")" \ "$(esc "${ROGUE_INSTALL_HOST:-unknown}")" \ diff --git a/plugins/codex/scripts/hook.ps1 b/plugins/codex/scripts/hook.ps1 index 5935b52..f7c4e66 100644 --- a/plugins/codex/scripts/hook.ps1 +++ b/plugins/codex/scripts/hook.ps1 @@ -137,6 +137,7 @@ function Rotate-Log { function Log { param([string]$Msg) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } try { if (-not $logFile) { return } $dir = Split-Path $logFile @@ -331,7 +332,12 @@ if ($installError.Count) { Log "error=install-id $($installError -join ',')" } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── -$payload = [Console]::In.ReadToEnd() +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'codex' -Family 'openai' +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } + +$payload = Read-RogueProtectionInput +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } if (-not $payload) { $payload = '{}' } try { $raw = [Console]::InputEncoding.GetBytes($payload) @@ -352,6 +358,8 @@ $headers = @{ $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) $resp = '' try { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and $null -ne $script:RPRevision) { $headers['x-rogue-activity-revision']=[string]$script:RPRevision } $r = Invoke-WebRequest -Uri $url -Method Post ` -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` -UseBasicParsing -TimeoutSec 8 -ErrorAction Stop @@ -362,6 +370,7 @@ try { } catch { Dbg "POST failed: $($_.Exception.Message)"; $resp = '' } $respHead = if ($resp.Length -gt 400) { $resp.Substring(0, 400) } else { $resp } +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } Log "raw=$(Sanitize $respHead)" if (-not $resp) { Write-Raw '{}'; exit 0 } diff --git a/plugins/codex/scripts/hook.sh b/plugins/codex/scripts/hook.sh index 517f6f7..919333b 100755 --- a/plugins/codex/scripts/hook.sh +++ b/plugins/codex/scripts/hook.sh @@ -53,6 +53,8 @@ _lcap="$ROGUE_LOG_MAX_BYTES" while [ "${_lcap#0}" != "$_lcap" ]; do _lcap="${_lcap#0}"; done if [ "${#_lcap}" -gt 18 ]; then ROGUE_LOG_MAX_BYTES=10485760; fi rotate_log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" # and Number("00") do in the PowerShell and Node dispatchers. @@ -64,6 +66,7 @@ rotate_log() { return 0 } log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi # 0700 dir / 0600 file. The logged text is not only ours: it carries the # server's block reason, which quotes the content that tripped the rule - a # secret, a command, a slice of a prompt. Under the default umask the log @@ -94,6 +97,11 @@ if [ -r "${PLUGIN_ROOT}/scripts/surface.sh" ]; then SURFACE=$(codex_surface_slug 2>/dev/null) fi +. "${PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init codex openai "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" +rogue_protection_enter || { printf '%s' '{}'; exit 0; } +trap 'rogue_protection_leave' EXIT + if [ -z "${ROGUE_API_KEY:-}" ]; then log "outcome=unconfigured" echo '{}' @@ -148,6 +156,7 @@ URL="${ROGUE_API_URL:-${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks # HTTP 200 so an error page (401/404/500) is never handed to Codex as a hook decision. RAW=$(curl -sS -X POST "$URL" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H "x-rogue-event: $EVENT" \ -H "x-rogue-agent: $ROGUE_INSTALL_AGENT" \ -H "x-rogue-host: $ROGUE_INSTALL_HOST" \ @@ -159,6 +168,7 @@ RAW=$(curl -sS -X POST "$URL" \ RC=$? CODE=$(printf '%s' "$RAW" | tail -n1) BODY=$(printf '%s' "$RAW" | sed '$d') +if ! rogue_protection_current; then printf '{}'; exit 0; fi log "outcome_raw=$(sanitize "$BODY" | head -c 400) http=$CODE rc=$RC" diff --git a/plugins/codex/scripts/protection.ps1 b/plugins/codex/scripts/protection.ps1 new file mode 100644 index 0000000..002d7d2 --- /dev/null +++ b/plugins/codex/scripts/protection.ps1 @@ -0,0 +1,179 @@ +param([string]$Poll, [string]$Base, [string]$ScriptDirectory=$PSScriptRoot) +$script:RPHelperDirectory=$ScriptDirectory +$script:RPDirectory=$null +$script:RPRevision=$null +$script:RPKey=$null + +function Write-RogueProtectionFile([string]$Path, [string]$Value) { + $temp = "$Path.$PID.tmp" + [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) + Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop +} +function Get-RogueProtectionState { + try { + $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) + foreach ($cap in 'aidr','aispm') { + if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } + } + return $value.decision + } catch { return $null } +} +function Test-RogueProtectionCurrent { + if (-not $script:RPDirectory) { return $true } + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return $false } + $state = Get-RogueProtectionState + return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) +} +function Send-RogueProtectionAck { + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } + $state = Get-RogueProtectionState + if (-not $state) { return } + foreach ($lease in @(Get-ChildItem -LiteralPath $script:RPDirectory -Filter 'active.*' -ErrorAction SilentlyContinue)) { + $owner = 0 + if ([int]::TryParse(($lease.Name -replace '^active\.',''), [ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue)) { return } + Remove-Item -LiteralPath $lease.FullName -ErrorAction SilentlyContinue + } + $identity = "$($state.revision):$($state.aidr.paused):$($state.aispm.paused)" + if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } + try { + $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress + $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Write-RogueProtectionFile "$script:RPDirectory/ack" $identity + } catch { } +} +function Update-RogueProtection { + if (-not $script:RPDirectory) { return } + try { + $lock = [IO.File]::Open("$script:RPDirectory/refresh.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch { return } + try { + Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $old = Get-RogueProtectionState + if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + try { + Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) + Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue + $script:RPPersistenceFailed=$false + } catch { + $script:RPPersistenceFailed=$true + try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + } + } catch { } finally { $lock.Dispose() } + Send-RogueProtectionAck +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + $root=$env:ROGUE_PROTECTION_DIR + if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + $hash=[Security.Cryptography.SHA256]::Create() + try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } + $script:RPDirectory=Join-Path $root "$Slug-default-$id" + if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue + if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + $credential="$script:RPDirectory/credential" + try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + if (-not (Test-Path -LiteralPath $credential)) { + foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { + if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } + $previousKey=Get-Content -LiteralPath (Join-Path $previous.FullName 'credential') -Raw -ErrorAction SilentlyContinue + if (-not $previousKey) { continue } + try { + $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress + $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($restored.apiKey -ne $previousKey) { continue } + Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName + $script:RPDirectory=$previous.FullName + $credential=Join-Path $script:RPDirectory 'credential' + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + break + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } + } + if (-not (Test-Path -LiteralPath $credential)) { + $enrollAttempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + try { + if (-not (Test-Path -LiteralPath $credential)) { + Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } + if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } + } + } catch { } finally { $lock.Dispose() } + } + if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + $script:RPKey=Get-Content -LiteralPath $credential -Raw + $attempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } + $state=Get-RogueProtectionState + $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } + $env:ROGUE_PROTECTION_STATE=$script:RPDirectory + $env:ROGUE_LOG_FILE=Join-Path $script:RPDirectory "$Slug.log" + $script:logFile=$env:ROGUE_LOG_FILE + return $script:RPKey +} +function Read-RogueProtectionInput { + $reader=[Console]::OpenStandardInput() + $buffer=New-Object byte[] 4096 + $text=New-Object IO.MemoryStream + while (Test-RogueProtectionCurrent) { + $pending=$reader.ReadAsync($buffer,0,$buffer.Length) + while (-not $pending.IsCompleted) { + if (-not (Test-RogueProtectionCurrent)) { return '' } + Start-Sleep -Milliseconds 100 + } + $count=$pending.GetAwaiter().GetResult() + if ($count -eq 0) { break } + if (-not (Test-RogueProtectionCurrent)) { return '' } + $text.Write($buffer,0,$count) + } + $bytes=$text.ToArray(); $text.Dispose() + if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } + return '' +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + return Test-RogueProtectionCurrent +} +if ($Poll) { + $script:RPDirectory=$Poll; $script:RPBase=$Base + $script:RPKey=Get-Content -LiteralPath "$Poll/credential" -Raw + while ($true) { + $used=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$Poll/used" -Raw -ErrorAction SilentlyContinue), [ref]$used) + $busy=@(Get-ChildItem -LiteralPath $Poll -Filter 'active.*' -ErrorAction SilentlyContinue | Where-Object { $owner=0; [int]::TryParse(($_.Name -replace '^active\.',''),[ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue) }).Count -gt 0 + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $used -ge 90 -and -not $busy) { break } + Update-RogueProtection + Start-Sleep -Seconds 15 + } +} diff --git a/plugins/codex/scripts/protection.sh b/plugins/codex/scripts/protection.sh new file mode 100644 index 0000000..45b6348 --- /dev/null +++ b/plugins/codex/scripts/protection.sh @@ -0,0 +1,163 @@ +#!/bin/sh +# Shared installation credential and pause gate. No activity payload is read here. +rogue_protection_now() { date +%s; } +rogue_protection_lock() { + ln -s "$$" "$1" 2>/dev/null && return 0 + _rp_owner=$(readlink "$1" 2>/dev/null) || return 1 + case "$_rp_owner" in *[!0-9]*|'') return 1 ;; esac + kill -0 "$_rp_owner" 2>/dev/null || rm -f "$1" + return 1 +} +rogue_protection_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +rogue_protection_load() { + [ -r "$ROGUE_PROTECTION_STATE/decision" ] || return 1 + IFS=' ' read -r RP_PROTOCOL RP_REV RP_AIDR RP_AIDR_EXP RP_AISPM RP_AISPM_EXP RP_SERVER RP_AIDR_REV RP_AISPM_REV RP_RECEIVED < "$ROGUE_PROTECTION_STATE/decision" + [ "$RP_PROTOCOL" = 1 ] || return 1 + RP_NOW=$(( $(rogue_protection_now) - RP_RECEIVED + RP_SERVER )) + [ "$RP_AIDR_EXP" -eq 0 ] || [ "$RP_AIDR_EXP" -gt "$RP_NOW" ] || RP_AIDR=0 + [ "$RP_AISPM_EXP" -eq 0 ] || [ "$RP_AISPM_EXP" -gt "$RP_NOW" ] || RP_AISPM=0 +} +rogue_protection_refresh() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_lock "$ROGUE_PROTECTION_STATE/refresh.lock" || return 0 + # A failed request is throttled too; an unavailable server must not cause a retry storm. + rogue_protection_now > "$ROGUE_PROTECTION_STATE/attempt" + _rp_decision=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'accept: text/tab-separated-values' "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/state" 2>/dev/null) || _rp_decision='' + case "$_rp_decision" in *[!0-9\ \ ]*|'') ;; *) + set -- $_rp_decision + if [ "$#" -eq 9 ] && [ "$1" = 1 ]; then + _rp_old=0 + rogue_protection_load && _rp_old=$RP_REV + if [ "$2" -ge "$_rp_old" ]; then + if printf '%s %s\n' "$*" "$(rogue_protection_now)" > "$ROGUE_PROTECTION_STATE/decision.tmp" && [ ! -d "$ROGUE_PROTECTION_STATE/decision" ] && mv -f "$ROGUE_PROTECTION_STATE/decision.tmp" "$ROGUE_PROTECTION_STATE/decision"; then + RP_PERSISTENCE_FAILED=0 + rm -f "$ROGUE_PROTECTION_STATE/persistence-failed" + else + RP_PERSISTENCE_FAILED=1 + touch "$ROGUE_PROTECTION_STATE/persistence-failed" 2>/dev/null || true + _rp_a=false; [ "$3" = 1 ] && _rp_a=true + _rp_s=false; [ "$5" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$2,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true + fi + fi + fi ;; + esac + rm -f "$ROGUE_PROTECTION_STATE/refresh.lock" 2>/dev/null || true + rogue_protection_ack +} +rogue_protection_busy() { + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + kill -0 "${_rp_lease##*.}" 2>/dev/null && return 0 + done + return 1 +} +rogue_protection_ack() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "$ROGUE_PROTECTION_STATE/persistence-failed" ] || return 0 + rogue_protection_load || return 0 + _rp_busy=0 + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + _rp_pid=${_rp_lease##*.} + if kill -0 "$_rp_pid" 2>/dev/null; then _rp_busy=1; else rm -f "$_rp_lease"; fi + done + [ "$_rp_busy" -eq 0 ] || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + _rp_ack="$RP_REV:$RP_AIDR:$RP_AISPM" + [ "$(cat "$ROGUE_PROTECTION_STATE/ack" 2>/dev/null)" != "$_rp_ack" ] || return 0 + if curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"applied\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1; then + printf '%s' "$_rp_ack" > "$ROGUE_PROTECTION_STATE/ack" + fi +} +rogue_protection_current() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "${ROGUE_PROTECTION_STATE:-}/persistence-failed" ] || return 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_load || return 1 + [ "$RP_AIDR" = 0 ] && [ "${ROGUE_PROTECTION_REVISION:-$RP_AIDR_REV}" = "$RP_AIDR_REV" ] +} +rogue_protection_leave() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rm -f "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_ack +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || return 0 + case "${ROGUE_PROTECTION_STATE:-}" in + */"$1-default-"*) + [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; + *) ROGUE_PROTECTION_STATE='' ;; + esac + ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) + case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) + if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; + esac + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do + [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue + [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue + _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue + [ -n "$_rp_previous_key" ] || continue + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue + (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + break + done + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" + _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac + if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" + else + _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) + if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi + fi + rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV + export ROGUE_PROTECTION_REVISION +} +rogue_protection_enter() { + rogue_protection_current || return 1 + [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_current +} +if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + rogue_protection_refresh + sleep 15 + done + rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid" + rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null || true +fi diff --git a/plugins/codex/scripts/ship-logs.ps1 b/plugins/codex/scripts/ship-logs.ps1 index ba62e78..dffeb83 100644 --- a/plugins/codex/scripts/ship-logs.ps1 +++ b/plugins/codex/scripts/ship-logs.ps1 @@ -122,6 +122,7 @@ function Write-ShipDebug { # the timestamp. "`n" keeps the line ending identical to the sh dispatchers'. function Write-ShipLog { param([string]$Message) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } # ALSO to stderr under ROGUE_DEBUG, and unconditionally - before the selfLogFile # gate below. The no-argument support invocation has no slug, so it has no log # file of its own to write to, and every failure reason (`http=`, @@ -814,7 +815,7 @@ function Send-ChunkRequest { try { $payload = [System.Text.Encoding]::UTF8.GetBytes($json) $response = Invoke-WebRequest -Uri $script:shipUrl -Method Post ` - -Headers @{ 'x-rogue-api-key' = $script:apiKey } ` + -Headers @{ 'x-rogue-api-key' = $script:apiKey; 'x-rogue-activity-revision' = if (Get-Variable RPRevision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:RPRevision } else { '' } } ` -ContentType 'application/json' -Body $payload ` -UseBasicParsing -TimeoutSec $HTTP_TIMEOUT -ErrorAction Stop $httpCode = [int]$response.StatusCode @@ -840,6 +841,7 @@ function Invoke-DrainFile { [string]$PersistHead, [int64]$PersistSize, [string]$NormalizedPath) $iteration = 0 while ($script:offset -lt $FileBytes) { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return $false } if ($script:runBytesSent -ge $script:maxRunBytes) { Write-ShipDebug 'run budget spent'; return $false } $iteration++ if ($iteration -gt $MAX_CHUNKS_PER_DRAIN) { Write-ShipDebug 'iteration guard'; return $false } @@ -870,6 +872,11 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) + return + } $script:runBytesSent = 0 $rotated = $false @@ -924,6 +931,10 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -934,7 +945,7 @@ function Invoke-Main { exit 0 } - $script:stateDir = Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' + $script:stateDir = if ($script:RPDirectory) { Join-Path $script:RPDirectory 'ship' } else { Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' } if (-not (Test-Path -LiteralPath $script:stateDir)) { New-Item -ItemType Directory -Path $script:stateDir -Force | Out-Null } diff --git a/plugins/codex/scripts/ship-logs.sh b/plugins/codex/scripts/ship-logs.sh index 1eb3152..f95e7a8 100644 --- a/plugins/codex/scripts/ship-logs.sh +++ b/plugins/codex/scripts/ship-logs.sh @@ -628,6 +628,7 @@ find_line_end() { # # (which is NOT always the bytes sent - an over-long line is skipped forward). # Returns non-zero when nothing was shipped and the offset must NOT move. ship_next_chunk() { # + rogue_protection_current || return 1 ADVANCE_BYTES=0 _chunk_source_file="${1:-}" _chunk_offset="${2:-0}" @@ -763,6 +764,7 @@ ship_oversize_line() { # # body is passed as --data-binary @file rather than -d, because a 1 MiB chunk is # ~1.4 MiB of base64 and macOS's ARG_MAX is 1 MiB for args plus environment. post_chunk() { # + rogue_protection_current || return 1 _post_chunk_file="${1:-}" _post_offset="${2:-0}" _post_bytes="${3:-0}" @@ -784,6 +786,7 @@ post_chunk() { # debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -839,6 +842,12 @@ ship_log_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" + release_lock + return 0 + fi RUN_BYTES_SENT=0 _target_rotated=0 @@ -890,6 +899,9 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + . "$PLUGIN_ROOT/scripts/protection.sh" + rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } @@ -902,12 +914,12 @@ main() { exit 0 fi - STATE_DIR="$HOME/.rogue/ship" + STATE_DIR="${ROGUE_PROTECTION_STATE:-$HOME/.rogue}/ship" mkdir -p "$STATE_DIR" 2>/dev/null [ -d "$STATE_DIR" ] || { debug "cannot create $STATE_DIR"; exit 0; } TMP_DIR=$(mktemp -d "$STATE_DIR/.tmp.XXXXXX" 2>/dev/null) || TMP_DIR="" [ -n "$TMP_DIR" ] || { debug 'cannot create a temp dir'; exit 0; } - trap 'cleanup' EXIT INT TERM + trap 'cleanup; rogue_protection_leave' EXIT INT TERM # A redirect, not a pipe: a `while` on the right of a pipe runs in a subshell in # POSIX sh, and log paths can contain spaces, so read them line by line. diff --git a/plugins/copilot/plugin.json b/plugins/copilot/plugin.json index 1a33089..24debae 100644 --- a/plugins/copilot/plugin.json +++ b/plugins/copilot/plugin.json @@ -1,7 +1,7 @@ { "name": "rogue", - "version": "1.2.4", - "description": "Rogue Security AIDR — real-time AI agent detection and response for GitHub Copilot CLI", + "version": "1.2.5", + "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for GitHub Copilot CLI", "author": { "name": "Rogue Security", "url": "https://www.rogue.security" @@ -10,5 +10,7 @@ "license": "MIT", "hooks": "hooks.json", "commands": "commands/", - "skills": ["skills/"] + "skills": [ + "skills/" + ] } diff --git a/plugins/copilot/scripts/heartbeat.ps1 b/plugins/copilot/scripts/heartbeat.ps1 index bbd6469..80aea51 100644 --- a/plugins/copilot/scripts/heartbeat.ps1 +++ b/plugins/copilot/scripts/heartbeat.ps1 @@ -124,6 +124,8 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'copilot' -Family 'copilot' # ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── # env file → git config files → @ → unknown. A damaged install with diff --git a/plugins/copilot/scripts/heartbeat.sh b/plugins/copilot/scripts/heartbeat.sh index f3ccfa4..efc6231 100755 --- a/plugins/copilot/scripts/heartbeat.sh +++ b/plugins/copilot/scripts/heartbeat.sh @@ -55,6 +55,9 @@ fi # Family is the fixed enum "copilot"; surface rides the agent field. esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } +. "${PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init copilot copilot "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" + BODY=$(printf '{"agent_family":"copilot","agent":"%s","version":"%s","host":"%s","actor_email":"%s","actor_name":"%s"}' \ "$(esc "${ROGUE_INSTALL_AGENT:-github_copilot}")" "$(esc "${ROGUE_INSTALL_VERSION:-unknown}")" \ "$(esc "${ROGUE_INSTALL_HOST:-unknown}")" \ diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index d293aaf..beb5d91 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -151,6 +151,7 @@ function Rotate-Log { function Log { param([string]$Msg) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } try { if (-not $logFile) { return } $dir = Split-Path $logFile @@ -347,7 +348,12 @@ if (-not $actorName) { $actorName = 'unknown' } if (-not $actorEmail) { $actorEmail = 'unknown' } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── -$payload = [Console]::In.ReadToEnd() +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'copilot' -Family 'copilot' +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } + +$payload = Read-RogueProtectionInput +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } if (-not $payload) { $payload = '{}' } try { $raw = [Console]::InputEncoding.GetBytes($payload) @@ -631,6 +637,8 @@ if ($subagentId -and ($subagentId -match '^[A-Za-z0-9_-]+$')) { $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) $resp = '' try { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and $null -ne $script:RPRevision) { $headers['x-rogue-activity-revision']=[string]$script:RPRevision } $r = Invoke-WebRequest -Uri $url -Method Post ` -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop @@ -641,6 +649,7 @@ try { } catch { Dbg "POST failed: $($_.Exception.Message)"; $resp = '' } $respHead = if ($resp.Length -gt 400) { $resp.Substring(0, 400) } else { $resp } +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } Log "raw=$(Sanitize $respHead)" if (-not $resp) { Write-Raw '{}'; exit 0 } diff --git a/plugins/copilot/scripts/hook.sh b/plugins/copilot/scripts/hook.sh index b901f96..5e85f47 100755 --- a/plugins/copilot/scripts/hook.sh +++ b/plugins/copilot/scripts/hook.sh @@ -81,6 +81,8 @@ if [ "${#_lcap}" -gt 18 ]; then ROGUE_LOG_MAX_BYTES=10485760; fi # NOTE: `_lsz` is not function-local (POSIX sh has no `local`) but is used # NOWHERE else in this file — unlike `_p`/`_n`, which are shared (see below). rotate_log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" # and Number("00") do in the PowerShell and Node dispatchers. @@ -97,6 +99,7 @@ rotate_log() { SURFACE="github_copilot" log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi # 0700 dir / 0600 file. The logged text is not only ours: it carries the # server's block reason, which quotes the content that tripped the rule - a # secret, a command, a slice of a prompt. Under the default umask the log @@ -364,6 +367,11 @@ reattribute_subagent() { # Not configured: emit the SessionStart hint (so the user knows to run setup) or a # clean allow for every other event. Never POST without a key. +. "${PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init copilot copilot "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" +rogue_protection_enter || { printf '%s' '{}'; exit 0; } +trap 'rogue_protection_leave' EXIT + if [ -z "${ROGUE_API_KEY:-}" ]; then log "outcome=unconfigured" if [ "$EVENT" = "sessionStart" ]; then @@ -441,6 +449,7 @@ fi # and neither is "do not send it". EVENT was captured at the top of the file, so # `set --` is free to rebuild the positional list here. set -- -H "x-rogue-api-key: $ROGUE_API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H "x-rogue-event: $EVENT" \ -H "x-rogue-agent: $ROGUE_INSTALL_AGENT" \ -H "x-rogue-host: $ROGUE_INSTALL_HOST" \ @@ -465,6 +474,7 @@ RC=$? CODE=$(printf '%s' "$RAW" | tail -n1) BODY=$(printf '%s' "$RAW" | sed '$d') +rogue_protection_current || { printf '%s' '{}'; exit 0; } log "http=$CODE rc=$RC raw=$(sanitize "$BODY" | head -c 400)" # Fail-open on transport error or any non-200: emit a clean allow. diff --git a/plugins/copilot/scripts/protection.ps1 b/plugins/copilot/scripts/protection.ps1 new file mode 100644 index 0000000..002d7d2 --- /dev/null +++ b/plugins/copilot/scripts/protection.ps1 @@ -0,0 +1,179 @@ +param([string]$Poll, [string]$Base, [string]$ScriptDirectory=$PSScriptRoot) +$script:RPHelperDirectory=$ScriptDirectory +$script:RPDirectory=$null +$script:RPRevision=$null +$script:RPKey=$null + +function Write-RogueProtectionFile([string]$Path, [string]$Value) { + $temp = "$Path.$PID.tmp" + [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) + Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop +} +function Get-RogueProtectionState { + try { + $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) + foreach ($cap in 'aidr','aispm') { + if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } + } + return $value.decision + } catch { return $null } +} +function Test-RogueProtectionCurrent { + if (-not $script:RPDirectory) { return $true } + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return $false } + $state = Get-RogueProtectionState + return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) +} +function Send-RogueProtectionAck { + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } + $state = Get-RogueProtectionState + if (-not $state) { return } + foreach ($lease in @(Get-ChildItem -LiteralPath $script:RPDirectory -Filter 'active.*' -ErrorAction SilentlyContinue)) { + $owner = 0 + if ([int]::TryParse(($lease.Name -replace '^active\.',''), [ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue)) { return } + Remove-Item -LiteralPath $lease.FullName -ErrorAction SilentlyContinue + } + $identity = "$($state.revision):$($state.aidr.paused):$($state.aispm.paused)" + if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } + try { + $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress + $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Write-RogueProtectionFile "$script:RPDirectory/ack" $identity + } catch { } +} +function Update-RogueProtection { + if (-not $script:RPDirectory) { return } + try { + $lock = [IO.File]::Open("$script:RPDirectory/refresh.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch { return } + try { + Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $old = Get-RogueProtectionState + if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + try { + Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) + Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue + $script:RPPersistenceFailed=$false + } catch { + $script:RPPersistenceFailed=$true + try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + } + } catch { } finally { $lock.Dispose() } + Send-RogueProtectionAck +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + $root=$env:ROGUE_PROTECTION_DIR + if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + $hash=[Security.Cryptography.SHA256]::Create() + try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } + $script:RPDirectory=Join-Path $root "$Slug-default-$id" + if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue + if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + $credential="$script:RPDirectory/credential" + try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + if (-not (Test-Path -LiteralPath $credential)) { + foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { + if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } + $previousKey=Get-Content -LiteralPath (Join-Path $previous.FullName 'credential') -Raw -ErrorAction SilentlyContinue + if (-not $previousKey) { continue } + try { + $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress + $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($restored.apiKey -ne $previousKey) { continue } + Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName + $script:RPDirectory=$previous.FullName + $credential=Join-Path $script:RPDirectory 'credential' + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + break + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } + } + if (-not (Test-Path -LiteralPath $credential)) { + $enrollAttempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + try { + if (-not (Test-Path -LiteralPath $credential)) { + Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } + if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } + } + } catch { } finally { $lock.Dispose() } + } + if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + $script:RPKey=Get-Content -LiteralPath $credential -Raw + $attempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } + $state=Get-RogueProtectionState + $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } + $env:ROGUE_PROTECTION_STATE=$script:RPDirectory + $env:ROGUE_LOG_FILE=Join-Path $script:RPDirectory "$Slug.log" + $script:logFile=$env:ROGUE_LOG_FILE + return $script:RPKey +} +function Read-RogueProtectionInput { + $reader=[Console]::OpenStandardInput() + $buffer=New-Object byte[] 4096 + $text=New-Object IO.MemoryStream + while (Test-RogueProtectionCurrent) { + $pending=$reader.ReadAsync($buffer,0,$buffer.Length) + while (-not $pending.IsCompleted) { + if (-not (Test-RogueProtectionCurrent)) { return '' } + Start-Sleep -Milliseconds 100 + } + $count=$pending.GetAwaiter().GetResult() + if ($count -eq 0) { break } + if (-not (Test-RogueProtectionCurrent)) { return '' } + $text.Write($buffer,0,$count) + } + $bytes=$text.ToArray(); $text.Dispose() + if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } + return '' +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + return Test-RogueProtectionCurrent +} +if ($Poll) { + $script:RPDirectory=$Poll; $script:RPBase=$Base + $script:RPKey=Get-Content -LiteralPath "$Poll/credential" -Raw + while ($true) { + $used=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$Poll/used" -Raw -ErrorAction SilentlyContinue), [ref]$used) + $busy=@(Get-ChildItem -LiteralPath $Poll -Filter 'active.*' -ErrorAction SilentlyContinue | Where-Object { $owner=0; [int]::TryParse(($_.Name -replace '^active\.',''),[ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue) }).Count -gt 0 + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $used -ge 90 -and -not $busy) { break } + Update-RogueProtection + Start-Sleep -Seconds 15 + } +} diff --git a/plugins/copilot/scripts/protection.sh b/plugins/copilot/scripts/protection.sh new file mode 100644 index 0000000..45b6348 --- /dev/null +++ b/plugins/copilot/scripts/protection.sh @@ -0,0 +1,163 @@ +#!/bin/sh +# Shared installation credential and pause gate. No activity payload is read here. +rogue_protection_now() { date +%s; } +rogue_protection_lock() { + ln -s "$$" "$1" 2>/dev/null && return 0 + _rp_owner=$(readlink "$1" 2>/dev/null) || return 1 + case "$_rp_owner" in *[!0-9]*|'') return 1 ;; esac + kill -0 "$_rp_owner" 2>/dev/null || rm -f "$1" + return 1 +} +rogue_protection_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +rogue_protection_load() { + [ -r "$ROGUE_PROTECTION_STATE/decision" ] || return 1 + IFS=' ' read -r RP_PROTOCOL RP_REV RP_AIDR RP_AIDR_EXP RP_AISPM RP_AISPM_EXP RP_SERVER RP_AIDR_REV RP_AISPM_REV RP_RECEIVED < "$ROGUE_PROTECTION_STATE/decision" + [ "$RP_PROTOCOL" = 1 ] || return 1 + RP_NOW=$(( $(rogue_protection_now) - RP_RECEIVED + RP_SERVER )) + [ "$RP_AIDR_EXP" -eq 0 ] || [ "$RP_AIDR_EXP" -gt "$RP_NOW" ] || RP_AIDR=0 + [ "$RP_AISPM_EXP" -eq 0 ] || [ "$RP_AISPM_EXP" -gt "$RP_NOW" ] || RP_AISPM=0 +} +rogue_protection_refresh() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_lock "$ROGUE_PROTECTION_STATE/refresh.lock" || return 0 + # A failed request is throttled too; an unavailable server must not cause a retry storm. + rogue_protection_now > "$ROGUE_PROTECTION_STATE/attempt" + _rp_decision=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'accept: text/tab-separated-values' "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/state" 2>/dev/null) || _rp_decision='' + case "$_rp_decision" in *[!0-9\ \ ]*|'') ;; *) + set -- $_rp_decision + if [ "$#" -eq 9 ] && [ "$1" = 1 ]; then + _rp_old=0 + rogue_protection_load && _rp_old=$RP_REV + if [ "$2" -ge "$_rp_old" ]; then + if printf '%s %s\n' "$*" "$(rogue_protection_now)" > "$ROGUE_PROTECTION_STATE/decision.tmp" && [ ! -d "$ROGUE_PROTECTION_STATE/decision" ] && mv -f "$ROGUE_PROTECTION_STATE/decision.tmp" "$ROGUE_PROTECTION_STATE/decision"; then + RP_PERSISTENCE_FAILED=0 + rm -f "$ROGUE_PROTECTION_STATE/persistence-failed" + else + RP_PERSISTENCE_FAILED=1 + touch "$ROGUE_PROTECTION_STATE/persistence-failed" 2>/dev/null || true + _rp_a=false; [ "$3" = 1 ] && _rp_a=true + _rp_s=false; [ "$5" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$2,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true + fi + fi + fi ;; + esac + rm -f "$ROGUE_PROTECTION_STATE/refresh.lock" 2>/dev/null || true + rogue_protection_ack +} +rogue_protection_busy() { + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + kill -0 "${_rp_lease##*.}" 2>/dev/null && return 0 + done + return 1 +} +rogue_protection_ack() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "$ROGUE_PROTECTION_STATE/persistence-failed" ] || return 0 + rogue_protection_load || return 0 + _rp_busy=0 + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + _rp_pid=${_rp_lease##*.} + if kill -0 "$_rp_pid" 2>/dev/null; then _rp_busy=1; else rm -f "$_rp_lease"; fi + done + [ "$_rp_busy" -eq 0 ] || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + _rp_ack="$RP_REV:$RP_AIDR:$RP_AISPM" + [ "$(cat "$ROGUE_PROTECTION_STATE/ack" 2>/dev/null)" != "$_rp_ack" ] || return 0 + if curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"applied\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1; then + printf '%s' "$_rp_ack" > "$ROGUE_PROTECTION_STATE/ack" + fi +} +rogue_protection_current() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "${ROGUE_PROTECTION_STATE:-}/persistence-failed" ] || return 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_load || return 1 + [ "$RP_AIDR" = 0 ] && [ "${ROGUE_PROTECTION_REVISION:-$RP_AIDR_REV}" = "$RP_AIDR_REV" ] +} +rogue_protection_leave() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rm -f "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_ack +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || return 0 + case "${ROGUE_PROTECTION_STATE:-}" in + */"$1-default-"*) + [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; + *) ROGUE_PROTECTION_STATE='' ;; + esac + ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) + case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) + if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; + esac + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do + [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue + [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue + _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue + [ -n "$_rp_previous_key" ] || continue + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue + (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + break + done + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" + _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac + if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" + else + _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) + if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi + fi + rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV + export ROGUE_PROTECTION_REVISION +} +rogue_protection_enter() { + rogue_protection_current || return 1 + [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_current +} +if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + rogue_protection_refresh + sleep 15 + done + rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid" + rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null || true +fi diff --git a/plugins/copilot/scripts/ship-logs.ps1 b/plugins/copilot/scripts/ship-logs.ps1 index ba62e78..dffeb83 100644 --- a/plugins/copilot/scripts/ship-logs.ps1 +++ b/plugins/copilot/scripts/ship-logs.ps1 @@ -122,6 +122,7 @@ function Write-ShipDebug { # the timestamp. "`n" keeps the line ending identical to the sh dispatchers'. function Write-ShipLog { param([string]$Message) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } # ALSO to stderr under ROGUE_DEBUG, and unconditionally - before the selfLogFile # gate below. The no-argument support invocation has no slug, so it has no log # file of its own to write to, and every failure reason (`http=`, @@ -814,7 +815,7 @@ function Send-ChunkRequest { try { $payload = [System.Text.Encoding]::UTF8.GetBytes($json) $response = Invoke-WebRequest -Uri $script:shipUrl -Method Post ` - -Headers @{ 'x-rogue-api-key' = $script:apiKey } ` + -Headers @{ 'x-rogue-api-key' = $script:apiKey; 'x-rogue-activity-revision' = if (Get-Variable RPRevision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:RPRevision } else { '' } } ` -ContentType 'application/json' -Body $payload ` -UseBasicParsing -TimeoutSec $HTTP_TIMEOUT -ErrorAction Stop $httpCode = [int]$response.StatusCode @@ -840,6 +841,7 @@ function Invoke-DrainFile { [string]$PersistHead, [int64]$PersistSize, [string]$NormalizedPath) $iteration = 0 while ($script:offset -lt $FileBytes) { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return $false } if ($script:runBytesSent -ge $script:maxRunBytes) { Write-ShipDebug 'run budget spent'; return $false } $iteration++ if ($iteration -gt $MAX_CHUNKS_PER_DRAIN) { Write-ShipDebug 'iteration guard'; return $false } @@ -870,6 +872,11 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) + return + } $script:runBytesSent = 0 $rotated = $false @@ -924,6 +931,10 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -934,7 +945,7 @@ function Invoke-Main { exit 0 } - $script:stateDir = Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' + $script:stateDir = if ($script:RPDirectory) { Join-Path $script:RPDirectory 'ship' } else { Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' } if (-not (Test-Path -LiteralPath $script:stateDir)) { New-Item -ItemType Directory -Path $script:stateDir -Force | Out-Null } diff --git a/plugins/copilot/scripts/ship-logs.sh b/plugins/copilot/scripts/ship-logs.sh index 1eb3152..f95e7a8 100644 --- a/plugins/copilot/scripts/ship-logs.sh +++ b/plugins/copilot/scripts/ship-logs.sh @@ -628,6 +628,7 @@ find_line_end() { # # (which is NOT always the bytes sent - an over-long line is skipped forward). # Returns non-zero when nothing was shipped and the offset must NOT move. ship_next_chunk() { # + rogue_protection_current || return 1 ADVANCE_BYTES=0 _chunk_source_file="${1:-}" _chunk_offset="${2:-0}" @@ -763,6 +764,7 @@ ship_oversize_line() { # # body is passed as --data-binary @file rather than -d, because a 1 MiB chunk is # ~1.4 MiB of base64 and macOS's ARG_MAX is 1 MiB for args plus environment. post_chunk() { # + rogue_protection_current || return 1 _post_chunk_file="${1:-}" _post_offset="${2:-0}" _post_bytes="${3:-0}" @@ -784,6 +786,7 @@ post_chunk() { # debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -839,6 +842,12 @@ ship_log_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" + release_lock + return 0 + fi RUN_BYTES_SENT=0 _target_rotated=0 @@ -890,6 +899,9 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + . "$PLUGIN_ROOT/scripts/protection.sh" + rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } @@ -902,12 +914,12 @@ main() { exit 0 fi - STATE_DIR="$HOME/.rogue/ship" + STATE_DIR="${ROGUE_PROTECTION_STATE:-$HOME/.rogue}/ship" mkdir -p "$STATE_DIR" 2>/dev/null [ -d "$STATE_DIR" ] || { debug "cannot create $STATE_DIR"; exit 0; } TMP_DIR=$(mktemp -d "$STATE_DIR/.tmp.XXXXXX" 2>/dev/null) || TMP_DIR="" [ -n "$TMP_DIR" ] || { debug 'cannot create a temp dir'; exit 0; } - trap 'cleanup' EXIT INT TERM + trap 'cleanup; rogue_protection_leave' EXIT INT TERM # A redirect, not a pipe: a `while` on the right of a pipe runs in a subshell in # POSIX sh, and log paths can contain spaces, so read them line by line. diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index 1d7ad53..b9303ed 100644 --- a/plugins/cursor/.cursor-plugin/plugin.json +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "Rogue Security", - "version": "1.1.5", - "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", + "version": "1.1.6", + "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for Cursor", "author": { "name": "rogue-security", "url": "https://www.rogue.security" diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index fb852bf..3eb01b2 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -217,6 +217,7 @@ function Rotate-Log { function Log { param([string]$Msg) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } try { if (-not $logFile) { return } $dir = Split-Path $logFile @@ -869,7 +870,12 @@ if (Test-Path -LiteralPath $pluginJson) { } # ── payload from stdin ───────────────────────────────────────────────────── -$payload = [Console]::In.ReadToEnd() +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'cursor' -Family 'cursor' +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } + +$payload = Read-RogueProtectionInput +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } if (-not $payload) { $payload = '{}' } # Cursor sends a UTF-8 payload, but the console often reads stdin under a legacy # OEM codepage (observed in the field: IBM437), which mojibakes it — e.g. the @@ -940,6 +946,8 @@ $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) $resp = '' $postError = '' try { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and $null -ne $script:RPRevision) { $headers['x-rogue-activity-revision']=[string]$script:RPRevision } $r = Invoke-WebRequest -Uri $url -Method Post ` -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop @@ -979,6 +987,7 @@ try { # (mirrors hook.sh's `log "rc=… raw=…"`). An empty `raw=` means fail-open: either # the request threw (then `error=` says why) or the server answered non-200. $respHead = if ($resp.Length -gt 400) { $resp.Substring(0, 400) } else { $resp } +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } if ($postError) { Log "raw=$(Sanitize $respHead) error=`"$(Sanitize $postError)`"" } else { Log "raw=$(Sanitize $respHead)" } diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index a9627bf..a3d95b4 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -129,6 +129,8 @@ _lcap="$ROGUE_LOG_MAX_BYTES" while [ "${_lcap#0}" != "$_lcap" ]; do _lcap="${_lcap#0}"; done if [ "${#_lcap}" -gt 18 ]; then ROGUE_LOG_MAX_BYTES=10485760; fi rotate_log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" # and Number("00") do in the PowerShell and Node dispatchers. @@ -145,6 +147,7 @@ rotate_log() { SURFACE="cursor" log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi # 0700 dir / 0600 file. The logged text is not only ours: it carries the # server's block reason, which quotes the content that tripped the rule - a # secret, a command, a slice of a prompt. Under the default umask the log @@ -167,6 +170,10 @@ log() { # can carry anything), and a raw newline or CR would forge extra log lines. sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } +. "${PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init cursor cursor "${PLUGIN_ROOT}/scripts" +rogue_protection_enter || { printf '{}'; exit 0; } +trap 'rogue_protection_leave' EXIT API_KEY="${ROGUE_API_KEY:-}" if [ -z "$API_KEY" ]; then dbg "no API key after cred resolution -> fail-open" @@ -664,6 +671,7 @@ dbg "POST $URL actor=$actor_email parent=${PARENT_ID:-none}" # together or not at all, and never on a main-agent event. set -- -H 'Content-Type: application/json' \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H "x-rogue-event: $event" \ -H "x-rogue-actor-email: $actor_email" \ -H "x-rogue-actor-name: $actor_name" \ @@ -683,6 +691,7 @@ dbg "curl rc=$_rc resp_len=${#RESP}" # Always log the raw response head so a relay/decision bug is diagnosable from # the hook log alone, without re-instrumenting the script. `-f` means a non-zero # rc is either a transport failure or an HTTP >= 400, and curl printed nothing. +rogue_protection_current || { printf '%s' '{}'; exit 0; } log "rc=$_rc raw=$(sanitize "$RESP" | head -c 400)" [ "$_rc" -eq 0 ] || RESP="" @@ -742,6 +751,7 @@ if [ -n "$hb_unthrottled" ]; then ( curl -fsS --max-time 10 -X POST \ -H 'Content-Type: application/json' \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H 'x-rogue-source: cursor' \ -d "$HB_BODY" \ "$BASE_URL/api/v1/hooks/status" \ diff --git a/plugins/cursor/scripts/protection.ps1 b/plugins/cursor/scripts/protection.ps1 new file mode 100644 index 0000000..002d7d2 --- /dev/null +++ b/plugins/cursor/scripts/protection.ps1 @@ -0,0 +1,179 @@ +param([string]$Poll, [string]$Base, [string]$ScriptDirectory=$PSScriptRoot) +$script:RPHelperDirectory=$ScriptDirectory +$script:RPDirectory=$null +$script:RPRevision=$null +$script:RPKey=$null + +function Write-RogueProtectionFile([string]$Path, [string]$Value) { + $temp = "$Path.$PID.tmp" + [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) + Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop +} +function Get-RogueProtectionState { + try { + $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) + foreach ($cap in 'aidr','aispm') { + if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } + } + return $value.decision + } catch { return $null } +} +function Test-RogueProtectionCurrent { + if (-not $script:RPDirectory) { return $true } + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return $false } + $state = Get-RogueProtectionState + return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) +} +function Send-RogueProtectionAck { + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } + $state = Get-RogueProtectionState + if (-not $state) { return } + foreach ($lease in @(Get-ChildItem -LiteralPath $script:RPDirectory -Filter 'active.*' -ErrorAction SilentlyContinue)) { + $owner = 0 + if ([int]::TryParse(($lease.Name -replace '^active\.',''), [ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue)) { return } + Remove-Item -LiteralPath $lease.FullName -ErrorAction SilentlyContinue + } + $identity = "$($state.revision):$($state.aidr.paused):$($state.aispm.paused)" + if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } + try { + $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress + $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Write-RogueProtectionFile "$script:RPDirectory/ack" $identity + } catch { } +} +function Update-RogueProtection { + if (-not $script:RPDirectory) { return } + try { + $lock = [IO.File]::Open("$script:RPDirectory/refresh.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch { return } + try { + Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $old = Get-RogueProtectionState + if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + try { + Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) + Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue + $script:RPPersistenceFailed=$false + } catch { + $script:RPPersistenceFailed=$true + try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + } + } catch { } finally { $lock.Dispose() } + Send-RogueProtectionAck +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + $root=$env:ROGUE_PROTECTION_DIR + if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + $hash=[Security.Cryptography.SHA256]::Create() + try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } + $script:RPDirectory=Join-Path $root "$Slug-default-$id" + if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue + if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + $credential="$script:RPDirectory/credential" + try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + if (-not (Test-Path -LiteralPath $credential)) { + foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { + if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } + $previousKey=Get-Content -LiteralPath (Join-Path $previous.FullName 'credential') -Raw -ErrorAction SilentlyContinue + if (-not $previousKey) { continue } + try { + $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress + $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($restored.apiKey -ne $previousKey) { continue } + Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName + $script:RPDirectory=$previous.FullName + $credential=Join-Path $script:RPDirectory 'credential' + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + break + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } + } + if (-not (Test-Path -LiteralPath $credential)) { + $enrollAttempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + try { + if (-not (Test-Path -LiteralPath $credential)) { + Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } + if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } + } + } catch { } finally { $lock.Dispose() } + } + if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + $script:RPKey=Get-Content -LiteralPath $credential -Raw + $attempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } + $state=Get-RogueProtectionState + $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } + $env:ROGUE_PROTECTION_STATE=$script:RPDirectory + $env:ROGUE_LOG_FILE=Join-Path $script:RPDirectory "$Slug.log" + $script:logFile=$env:ROGUE_LOG_FILE + return $script:RPKey +} +function Read-RogueProtectionInput { + $reader=[Console]::OpenStandardInput() + $buffer=New-Object byte[] 4096 + $text=New-Object IO.MemoryStream + while (Test-RogueProtectionCurrent) { + $pending=$reader.ReadAsync($buffer,0,$buffer.Length) + while (-not $pending.IsCompleted) { + if (-not (Test-RogueProtectionCurrent)) { return '' } + Start-Sleep -Milliseconds 100 + } + $count=$pending.GetAwaiter().GetResult() + if ($count -eq 0) { break } + if (-not (Test-RogueProtectionCurrent)) { return '' } + $text.Write($buffer,0,$count) + } + $bytes=$text.ToArray(); $text.Dispose() + if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } + return '' +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + return Test-RogueProtectionCurrent +} +if ($Poll) { + $script:RPDirectory=$Poll; $script:RPBase=$Base + $script:RPKey=Get-Content -LiteralPath "$Poll/credential" -Raw + while ($true) { + $used=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$Poll/used" -Raw -ErrorAction SilentlyContinue), [ref]$used) + $busy=@(Get-ChildItem -LiteralPath $Poll -Filter 'active.*' -ErrorAction SilentlyContinue | Where-Object { $owner=0; [int]::TryParse(($_.Name -replace '^active\.',''),[ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue) }).Count -gt 0 + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $used -ge 90 -and -not $busy) { break } + Update-RogueProtection + Start-Sleep -Seconds 15 + } +} diff --git a/plugins/cursor/scripts/protection.sh b/plugins/cursor/scripts/protection.sh new file mode 100644 index 0000000..45b6348 --- /dev/null +++ b/plugins/cursor/scripts/protection.sh @@ -0,0 +1,163 @@ +#!/bin/sh +# Shared installation credential and pause gate. No activity payload is read here. +rogue_protection_now() { date +%s; } +rogue_protection_lock() { + ln -s "$$" "$1" 2>/dev/null && return 0 + _rp_owner=$(readlink "$1" 2>/dev/null) || return 1 + case "$_rp_owner" in *[!0-9]*|'') return 1 ;; esac + kill -0 "$_rp_owner" 2>/dev/null || rm -f "$1" + return 1 +} +rogue_protection_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +rogue_protection_load() { + [ -r "$ROGUE_PROTECTION_STATE/decision" ] || return 1 + IFS=' ' read -r RP_PROTOCOL RP_REV RP_AIDR RP_AIDR_EXP RP_AISPM RP_AISPM_EXP RP_SERVER RP_AIDR_REV RP_AISPM_REV RP_RECEIVED < "$ROGUE_PROTECTION_STATE/decision" + [ "$RP_PROTOCOL" = 1 ] || return 1 + RP_NOW=$(( $(rogue_protection_now) - RP_RECEIVED + RP_SERVER )) + [ "$RP_AIDR_EXP" -eq 0 ] || [ "$RP_AIDR_EXP" -gt "$RP_NOW" ] || RP_AIDR=0 + [ "$RP_AISPM_EXP" -eq 0 ] || [ "$RP_AISPM_EXP" -gt "$RP_NOW" ] || RP_AISPM=0 +} +rogue_protection_refresh() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_lock "$ROGUE_PROTECTION_STATE/refresh.lock" || return 0 + # A failed request is throttled too; an unavailable server must not cause a retry storm. + rogue_protection_now > "$ROGUE_PROTECTION_STATE/attempt" + _rp_decision=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'accept: text/tab-separated-values' "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/state" 2>/dev/null) || _rp_decision='' + case "$_rp_decision" in *[!0-9\ \ ]*|'') ;; *) + set -- $_rp_decision + if [ "$#" -eq 9 ] && [ "$1" = 1 ]; then + _rp_old=0 + rogue_protection_load && _rp_old=$RP_REV + if [ "$2" -ge "$_rp_old" ]; then + if printf '%s %s\n' "$*" "$(rogue_protection_now)" > "$ROGUE_PROTECTION_STATE/decision.tmp" && [ ! -d "$ROGUE_PROTECTION_STATE/decision" ] && mv -f "$ROGUE_PROTECTION_STATE/decision.tmp" "$ROGUE_PROTECTION_STATE/decision"; then + RP_PERSISTENCE_FAILED=0 + rm -f "$ROGUE_PROTECTION_STATE/persistence-failed" + else + RP_PERSISTENCE_FAILED=1 + touch "$ROGUE_PROTECTION_STATE/persistence-failed" 2>/dev/null || true + _rp_a=false; [ "$3" = 1 ] && _rp_a=true + _rp_s=false; [ "$5" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$2,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true + fi + fi + fi ;; + esac + rm -f "$ROGUE_PROTECTION_STATE/refresh.lock" 2>/dev/null || true + rogue_protection_ack +} +rogue_protection_busy() { + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + kill -0 "${_rp_lease##*.}" 2>/dev/null && return 0 + done + return 1 +} +rogue_protection_ack() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "$ROGUE_PROTECTION_STATE/persistence-failed" ] || return 0 + rogue_protection_load || return 0 + _rp_busy=0 + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + _rp_pid=${_rp_lease##*.} + if kill -0 "$_rp_pid" 2>/dev/null; then _rp_busy=1; else rm -f "$_rp_lease"; fi + done + [ "$_rp_busy" -eq 0 ] || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + _rp_ack="$RP_REV:$RP_AIDR:$RP_AISPM" + [ "$(cat "$ROGUE_PROTECTION_STATE/ack" 2>/dev/null)" != "$_rp_ack" ] || return 0 + if curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"applied\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1; then + printf '%s' "$_rp_ack" > "$ROGUE_PROTECTION_STATE/ack" + fi +} +rogue_protection_current() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "${ROGUE_PROTECTION_STATE:-}/persistence-failed" ] || return 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_load || return 1 + [ "$RP_AIDR" = 0 ] && [ "${ROGUE_PROTECTION_REVISION:-$RP_AIDR_REV}" = "$RP_AIDR_REV" ] +} +rogue_protection_leave() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rm -f "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_ack +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || return 0 + case "${ROGUE_PROTECTION_STATE:-}" in + */"$1-default-"*) + [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; + *) ROGUE_PROTECTION_STATE='' ;; + esac + ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) + case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) + if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; + esac + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do + [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue + [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue + _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue + [ -n "$_rp_previous_key" ] || continue + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue + (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + break + done + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" + _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac + if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" + else + _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) + if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi + fi + rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV + export ROGUE_PROTECTION_REVISION +} +rogue_protection_enter() { + rogue_protection_current || return 1 + [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_current +} +if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + rogue_protection_refresh + sleep 15 + done + rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid" + rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null || true +fi diff --git a/plugins/cursor/scripts/ship-logs.ps1 b/plugins/cursor/scripts/ship-logs.ps1 index ba62e78..dffeb83 100644 --- a/plugins/cursor/scripts/ship-logs.ps1 +++ b/plugins/cursor/scripts/ship-logs.ps1 @@ -122,6 +122,7 @@ function Write-ShipDebug { # the timestamp. "`n" keeps the line ending identical to the sh dispatchers'. function Write-ShipLog { param([string]$Message) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } # ALSO to stderr under ROGUE_DEBUG, and unconditionally - before the selfLogFile # gate below. The no-argument support invocation has no slug, so it has no log # file of its own to write to, and every failure reason (`http=`, @@ -814,7 +815,7 @@ function Send-ChunkRequest { try { $payload = [System.Text.Encoding]::UTF8.GetBytes($json) $response = Invoke-WebRequest -Uri $script:shipUrl -Method Post ` - -Headers @{ 'x-rogue-api-key' = $script:apiKey } ` + -Headers @{ 'x-rogue-api-key' = $script:apiKey; 'x-rogue-activity-revision' = if (Get-Variable RPRevision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:RPRevision } else { '' } } ` -ContentType 'application/json' -Body $payload ` -UseBasicParsing -TimeoutSec $HTTP_TIMEOUT -ErrorAction Stop $httpCode = [int]$response.StatusCode @@ -840,6 +841,7 @@ function Invoke-DrainFile { [string]$PersistHead, [int64]$PersistSize, [string]$NormalizedPath) $iteration = 0 while ($script:offset -lt $FileBytes) { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return $false } if ($script:runBytesSent -ge $script:maxRunBytes) { Write-ShipDebug 'run budget spent'; return $false } $iteration++ if ($iteration -gt $MAX_CHUNKS_PER_DRAIN) { Write-ShipDebug 'iteration guard'; return $false } @@ -870,6 +872,11 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) + return + } $script:runBytesSent = 0 $rotated = $false @@ -924,6 +931,10 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -934,7 +945,7 @@ function Invoke-Main { exit 0 } - $script:stateDir = Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' + $script:stateDir = if ($script:RPDirectory) { Join-Path $script:RPDirectory 'ship' } else { Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' } if (-not (Test-Path -LiteralPath $script:stateDir)) { New-Item -ItemType Directory -Path $script:stateDir -Force | Out-Null } diff --git a/plugins/cursor/scripts/ship-logs.sh b/plugins/cursor/scripts/ship-logs.sh index 1eb3152..f95e7a8 100644 --- a/plugins/cursor/scripts/ship-logs.sh +++ b/plugins/cursor/scripts/ship-logs.sh @@ -628,6 +628,7 @@ find_line_end() { # # (which is NOT always the bytes sent - an over-long line is skipped forward). # Returns non-zero when nothing was shipped and the offset must NOT move. ship_next_chunk() { # + rogue_protection_current || return 1 ADVANCE_BYTES=0 _chunk_source_file="${1:-}" _chunk_offset="${2:-0}" @@ -763,6 +764,7 @@ ship_oversize_line() { # # body is passed as --data-binary @file rather than -d, because a 1 MiB chunk is # ~1.4 MiB of base64 and macOS's ARG_MAX is 1 MiB for args plus environment. post_chunk() { # + rogue_protection_current || return 1 _post_chunk_file="${1:-}" _post_offset="${2:-0}" _post_bytes="${3:-0}" @@ -784,6 +786,7 @@ post_chunk() { # debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -839,6 +842,12 @@ ship_log_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" + release_lock + return 0 + fi RUN_BYTES_SENT=0 _target_rotated=0 @@ -890,6 +899,9 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + . "$PLUGIN_ROOT/scripts/protection.sh" + rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } @@ -902,12 +914,12 @@ main() { exit 0 fi - STATE_DIR="$HOME/.rogue/ship" + STATE_DIR="${ROGUE_PROTECTION_STATE:-$HOME/.rogue}/ship" mkdir -p "$STATE_DIR" 2>/dev/null [ -d "$STATE_DIR" ] || { debug "cannot create $STATE_DIR"; exit 0; } TMP_DIR=$(mktemp -d "$STATE_DIR/.tmp.XXXXXX" 2>/dev/null) || TMP_DIR="" [ -n "$TMP_DIR" ] || { debug 'cannot create a temp dir'; exit 0; } - trap 'cleanup' EXIT INT TERM + trap 'cleanup; rogue_protection_leave' EXIT INT TERM # A redirect, not a pipe: a `while` on the right of a pipe runs in a subshell in # POSIX sh, and log paths can contain spaces, so read them line by line. diff --git a/plugins/gemini/gemini-extension.json b/plugins/gemini/gemini-extension.json index 91c4c64..45295e6 100644 --- a/plugins/gemini/gemini-extension.json +++ b/plugins/gemini/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.28", - "description": "Rogue Security AIDR — real-time AI agent detection and response for Gemini CLI", + "version": "1.0.29", + "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for Gemini CLI", "contextFileName": "GEMINI.md" } diff --git a/plugins/gemini/scripts/heartbeat.mjs b/plugins/gemini/scripts/heartbeat.mjs index 865bd31..8fb8839 100644 --- a/plugins/gemini/scripts/heartbeat.mjs +++ b/plugins/gemini/scripts/heartbeat.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { Protection } from "./protection.mjs"; // Rogue Security — Gemini CLI presence heartbeat. // // Usage: heartbeat.mjs [TriggerEvent] (default SessionStart) @@ -107,7 +108,8 @@ function claimBeaconSlot(env, unthrottled) { async function main() { const env = loadEnvFiles(); - const apiKey = env.ROGUE_API_KEY || ""; + const protection = await Protection.connect(env); + const apiKey = protection?.key || env.ROGUE_API_KEY || ""; if (!apiKey) return; // not configured → no-op // Same cascade hook.mjs runs, so the roster row and the event rows agree. diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index 76b0110..5b8f1c4 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { Protection } from "./protection.mjs"; +let protection; // Rogue Security — Gemini CLI hook dispatcher. // // Usage: node hook.mjs @@ -82,7 +84,7 @@ try { // single hook.log with no way to tell whose line was whose. Precedence: // explicit file → directory override → per-agent default. const LOG_DIR = ENV.ROGUE_LOG_DIR || path.join(HOME, ".rogue", "logs"); -const LOG_FILE = ENV.ROGUE_LOG_FILE || path.join(LOG_DIR, `${PROVIDER}.log`); +let LOG_FILE = ENV.ROGUE_LOG_FILE || path.join(LOG_DIR, `${PROVIDER}.log`); // Size cap. Over it, the current log is renamed to .1 (exactly one // generation kept, so worst case on disk is 2x this). A NUMERIC ZERO disables // rotation; a NON-NUMERIC value falls back to the default rather than disabling @@ -121,6 +123,7 @@ function rotateLog() { } } function log(msg) { + if (protection && !protection.current()) return; try { // 0700 dir / 0600 file, matching the `umask 077` the sh dispatchers use. // The log carries the server's block reason, which quotes the content that @@ -265,6 +268,7 @@ function isAgentTaggableEvent(parsed) { // Read a file as UTF-8, or null if it is missing, unreadable or over `maxBytes`. function readCapped(file, maxBytes) { + if (protection && !protection.current()) return null; try { if (fs.statSync(file).size > maxBytes) return null; return fs.readFileSync(file, "utf8"); @@ -408,11 +412,20 @@ function resolveSubagentId(parsed) { function readStdin() { return new Promise((resolve) => { const chunks = []; - process.stdin.on("data", (c) => chunks.push(c)); - process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - process.stdin.on("error", () => resolve("")); - // If nothing is piped, don't hang. - if (process.stdin.isTTY) resolve(""); + const finish = () => { + clearInterval(timer); + process.stdin.removeListener('data', data); + process.stdin.pause(); + const current=!protection || protection.current(); + resolve(current ? Buffer.concat(chunks).toString('utf8') : ''); + chunks.length=0; + }; + const data = (chunk) => { if (!protection || protection.current()) chunks.push(chunk); else finish(); }; + const timer=setInterval(() => { if (protection && !protection.current()) finish(); },250); + process.stdin.on('data',data); + process.stdin.once('end',finish); + process.stdin.once('error',finish); + if (process.stdin.isTTY) finish(); }); } @@ -420,7 +433,10 @@ async function main() { // Already merged at module load (the log destination depends on it) — reuse it // rather than re-reading the same three files. const env = ENV; - const apiKey = env.ROGUE_API_KEY || ""; + protection = await Protection.connect(env); + const apiKey = protection?.key || env.ROGUE_API_KEY || ""; + if (protection) LOG_FILE = protection.file("gemini.log"); + if (protection && !protection.enter()) return emit({}); // SessionStart: fire the detached roster heartbeat, then fall through to POST // the event like any other so it is captured for audit. SessionStart is @@ -462,6 +478,7 @@ async function main() { } const payload = await readStdin(); + if (protection && !protection.current()) return emit({}); const actor = resolveActor(env); const base = (env.ROGUE_BASE_URL || "https://api.rogue.security").replace( /\/+$/, @@ -490,11 +507,13 @@ async function main() { let bodyText = "{}"; try { + if (protection && !protection.current()) return emit({}); const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "x-rogue-api-key": apiKey, + ...(protection?.revision !== undefined ? {"x-rogue-activity-revision":String(protection.revision)} : {}), "x-rogue-event": EVENT, "x-rogue-actor-email": headerBytes(actor.email), "x-rogue-actor-name": headerBytes(actor.name), @@ -530,7 +549,7 @@ async function main() { bodyText = "{}"; } - return emit(bodyText); + return emit(!protection || protection.current() ? bodyText : {}); } main().catch((e) => { diff --git a/plugins/gemini/scripts/protection.mjs b/plugins/gemini/scripts/protection.mjs new file mode 100644 index 0000000..2cf0649 --- /dev/null +++ b/plugins/gemini/scripts/protection.mjs @@ -0,0 +1,146 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const read = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } }; +const alive = (pid) => { try { process.kill(Number(pid), 0); return Number(pid) > 0; } catch { return false; } }; +const write = (file, value) => { + const temp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(temp, value, { mode: 0o600 }); + fs.renameSync(temp, file); +}; +export class Protection { + constructor(directory, base, key) { this.directory=directory; this.base=base; this.key=key; this.revision=undefined; } + file(name) { return path.join(this.directory, name); } + state() { + try { + const saved = JSON.parse(read(this.file('state.json'))); + const now = Date.parse(saved.decision.serverTime) + Date.now() - saved.receivedAt; + for (const cap of ['aidr','aispm']) { + if (saved.decision[cap].expiresAt && Date.parse(saved.decision[cap].expiresAt) <= now) saved.decision[cap].paused=false; + } + return saved.decision; + } catch { return undefined; } + } + current() { + const state=this.state(); + if (this.persistenceFailed || fs.existsSync(this.file('persistence-failed'))) return false; + return !!state && (!state.aidr.paused && (this.revision === undefined || this.revision === state.aidr.revision)); + } + enter() { + if (!this.current()) return false; + write(this.file(`active.${process.pid}`), String(this.revision ?? 0)); + process.once('exit', () => { try { fs.unlinkSync(this.file(`active.${process.pid}`)); } catch {} }); + return this.current(); + } + async request(route, body, installationKey) { + const response=await fetch(`${this.base}/api/v1/hooks/protection/${route}`, { + method: body === undefined ? 'GET' : 'POST', headers: {'x-rogue-api-key': this.key, 'content-type':'application/json', ...(installationKey ? {'x-rogue-installation-key':installationKey} : {})}, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw Object.assign(new Error('Protection request failed'), { status:response.status }); + return response.json(); + } + async ack() { + const state=this.state(); + if (!state || this.persistenceFailed || fs.existsSync(this.file('persistence-failed'))) return; + for (const name of fs.readdirSync(this.directory).filter(name => name.startsWith('active.'))) { + if (alive(name.slice(7))) return; + fs.rmSync(this.file(name), { force:true }); + } + const id=`${state.revision}:${state.aidr.paused}:${state.aispm.paused}`; + if (read(this.file('ack')) === id) return; + await this.request('ack', { protocolVersion:1, revision:state.revision, status:'applied', aidrPaused:state.aidr.paused, aispmPaused:state.aispm.paused }); + write(this.file('ack'), id); + } + async refresh() { + const lock=this.file('refresh.lock'); + try { fs.writeFileSync(lock, String(process.pid), { flag:'wx', mode:0o600 }); } + catch { if (!alive(read(lock))) fs.rmSync(lock, {force:true}); return; } + try { + write(this.file('attempt'), String(Date.now())); + const state=await this.request('state'); + const previous=this.state(); + if (state.protocolVersion !== 1 || !Number.isSafeInteger(state.revision) || !state.aidr || !state.aispm) return; + if (!previous || state.revision >= previous.revision) { + try { + write(this.file('state.json'), JSON.stringify({ decision:state, receivedAt:Date.now() })); + fs.rmSync(this.file('persistence-failed'),{force:true}); this.persistenceFailed=false; + } catch { + this.persistenceFailed=true; + try { write(this.file('persistence-failed'),'1'); } catch {} + try { await this.request('ack',{protocolVersion:1,revision:state.revision,status:'failed',aidrPaused:state.aidr.paused,aispmPaused:state.aispm.paused,error:'state_persistence_failed'}); } catch {} + } + } + } catch {} finally { fs.rmSync(lock, {force:true}); } + try { await this.ack(); } catch {} + } + static async connect(env, slug='gemini', family='gemini') { + if (!env.ROGUE_API_KEY) return undefined; + const base=(env.ROGUE_BASE_URL || 'https://api.rogue.security').replace(/\/+$/, ''); + const hash=createHash('sha256').update(`${base}\n${env.ROGUE_API_KEY}`).digest('hex'); + const directory=path.join(env.ROGUE_PROTECTION_DIR || path.join(os.homedir(), '.rogue/protection'), `${slug}-default-${hash}`); + const client=new Protection(directory, base, env.ROGUE_API_KEY); + try { + fs.mkdirSync(directory, {recursive:true, mode:0o700}); + const linked=read(client.file('installation-directory')); + if (path.dirname(linked) === path.dirname(directory) && path.basename(linked).startsWith(`${slug}-default-`) && read(path.join(linked,'base')) === base) client.directory=linked; + let key=read(client.file('credential')); + try { write(client.file('base'), base); } catch {} + if (!key) { + const root=path.dirname(directory); + for (const name of fs.readdirSync(root).filter(name=>name.startsWith(`${slug}-default-`))) { + const previous=path.join(root,name); + if (previous === directory || read(path.join(previous,'base')) !== base) continue; + const oldKey=read(path.join(previous,'credential')); + if (!oldKey) continue; + try { + const restored=await client.request('enroll',{type:'coding_agent',name:slug,family,host:os.hostname(),version:env.ROGUE_INSTALL_VERSION || 'unknown'},oldKey); + if (restored.apiKey !== oldKey) continue; + key=oldKey; write(client.file('installation-directory'),previous); + client.directory=previous; + break; + } catch (error) { if (error.status !== 403 && error.status !== 401) return client; } + } + } + if (!key) { + if (Date.now()-Number(read(client.file('enroll-attempt'))) < 60000) return undefined; + const lock=client.file('enroll.lock'); + try { fs.writeFileSync(lock, String(process.pid), {flag:'wx', mode:0o600}); } + catch { if (!alive(read(lock))) fs.rmSync(lock, {force:true}); return client; } + try { + write(client.file('enroll-attempt'), String(Date.now())); + const enrolled=await client.request('enroll', {type:'coding_agent', name:slug, family, host:os.hostname(), version:env.ROGUE_INSTALL_VERSION || 'unknown'}); + key=enrolled.alreadyEnrolled ? env.ROGUE_API_KEY : enrolled.apiKey; + if (typeof key !== 'string' || !key) return undefined; + write(client.file('credential'), key); + } finally { fs.rmSync(lock, {force:true}); } + } + client.key=key; + try { write(client.file('used'), String(Date.now())); } catch {} + if (Date.now()-Number(read(client.file('attempt'))) >= 15000) await client.refresh(); + client.revision=client.state()?.aidr.revision; + const pollLock=client.file('poll-start.lock'); + let ownsPollLock=false; + try { fs.writeFileSync(pollLock,String(process.pid),{flag:'wx',mode:0o600}); ownsPollLock=true; } catch { if (!alive(read(pollLock))) fs.rmSync(pollLock,{force:true}); } + try { if (ownsPollLock && !alive(read(client.file('poll.pid')))) { + const child=spawn(process.execPath, [fileURLToPath(import.meta.url), '--poll', directory, base], {detached:true, stdio:'ignore'}); + write(client.file('poll.pid'), String(child.pid)); + child.unref(); + } } finally { if (ownsPollLock) fs.rmSync(pollLock,{force:true}); } + return client; + } catch { + const key=read(client.file('credential')); + if (!key) return undefined; + client.key=key; client.revision=client.state()?.aidr.revision; return client; + } + } +} +if (process.argv[2] === '--poll') { + const client=new Protection(process.argv[3], process.argv[4], read(path.join(process.argv[3], 'credential'))); + while (Date.now()-Number(read(client.file('used'))) < 90000 || fs.readdirSync(client.directory).some(name=>name.startsWith('active.') && alive(name.slice(7)))) { await client.refresh(); await sleep(15000); } +} diff --git a/plugins/gemini/scripts/ship-logs.mjs b/plugins/gemini/scripts/ship-logs.mjs index a4ee5fb..3ec4a48 100644 --- a/plugins/gemini/scripts/ship-logs.mjs +++ b/plugins/gemini/scripts/ship-logs.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { Protection } from "./protection.mjs"; // Rogue Security - hook-log shipper (Gemini CLI). Siblings: scripts/shared/ship-logs.sh // and scripts/shared/ship-logs.ps1, whose behaviour this mirrors exactly. // @@ -246,6 +247,7 @@ class Shipper { // rotation-under-us hazard firstLineFingerprint re-checks for. Rotation stays the // dispatcher's job. log(message) { + if (this.protection && !this.protection.current()) return; // ALSO to stderr under ROGUE_DEBUG, and unconditionally - before the selfLogFile // gate below. The no-argument support invocation has no slug, so it has no log // file of its own to write to, and every failure reason (`http=`, @@ -506,7 +508,7 @@ class Shipper { try { const response = await fetch(this.shipUrl, { method: "POST", - headers: { "Content-Type": "application/json", "x-rogue-api-key": this.apiKey }, + headers: { "Content-Type": "application/json", "x-rogue-api-key": this.apiKey, ...(this.protection?.revision !== undefined ? {"x-rogue-activity-revision":String(this.protection.revision)} : {}) }, body: JSON.stringify(body), signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), }); @@ -638,6 +640,7 @@ class Shipper { let offset = startOffset; let iteration = 0; while (offset < fileBytes) { + if (this.protection && !this.protection.current()) return { offset, complete: false }; if (this.runBytesSent >= this.maxRunBytes) { this.debug("run budget spent"); return { offset, complete: false }; @@ -690,6 +693,11 @@ class Shipper { const fileBytes = fileSize(filePath); const currentHead = firstLineFingerprint(filePath); this.runBytesSent = 0; + if (this.protection?.revision > 0 && (fs.existsSync(this.protection.file('ship-revision')) ? fs.readFileSync(this.protection.file('ship-revision'), 'utf8') : '') !== String(this.protection.revision)) { + this.writeState(stateKey, fileBytes, currentHead, fileBytes, normalizedPath); + fs.writeFileSync(this.protection.file('ship-revision'), String(this.protection.revision), {mode:0o600}); + return; + } let offset = state.offset; let rotated = fileBytes < offset; @@ -754,6 +762,12 @@ class Shipper { async run() { this.env = loadEnv(this.pluginRoot); + this.protection = await Protection.connect(this.env, this.shipperSlug, this.agentFamily); + if (this.protection) { + this.env.ROGUE_API_KEY=this.protection.key; + this.env.ROGUE_LOG_FILE=this.protection.file(`${this.shipperSlug}.log`); + if (!this.protection.enter()) return; + } this.resolveKnobs(); if (!this.apiKey) { this.debug("not configured -> no-op"); @@ -766,7 +780,7 @@ class Shipper { this.log("outcome=skip reason=no-actor"); return; } - this.stateDir = path.join(HOME, ".rogue", "ship"); + this.stateDir = this.protection ? this.protection.file("ship") : path.join(HOME, ".rogue", "ship"); try { fs.mkdirSync(this.stateDir, { recursive: true }); } catch { diff --git a/plugins/kiro/VERSION b/plugins/kiro/VERSION index 3eefcb9..7dea76e 100644 --- a/plugins/kiro/VERSION +++ b/plugins/kiro/VERSION @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/plugins/kiro/plugin.json b/plugins/kiro/plugin.json index 7a2ad8d..1f2aca7 100644 --- a/plugins/kiro/plugin.json +++ b/plugins/kiro/plugin.json @@ -1,7 +1,7 @@ { "name": "rogue", - "version": "1.0.0", - "description": "Rogue Security AIDR — real-time AI agent detection and response for Kiro (IDE, CLI, Crew)", + "version": "1.0.1", + "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for Kiro (IDE, CLI, Crew)", "author": { "name": "Rogue Security", "url": "https://www.rogue.security" diff --git a/plugins/kiro/scripts/heartbeat.ps1 b/plugins/kiro/scripts/heartbeat.ps1 index 4861c91..3e4e5ae 100644 --- a/plugins/kiro/scripts/heartbeat.ps1 +++ b/plugins/kiro/scripts/heartbeat.ps1 @@ -345,6 +345,8 @@ function Invoke-Main { Initialize-Beacon # after the env files are parsed so they can set the interval Assert-ApiKey # exits 0 when this install is not configured Resolve-BaseUrl + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') + $script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'kiro' -Family 'kiro' Resolve-Actor Resolve-Version Resolve-Surface diff --git a/plugins/kiro/scripts/heartbeat.sh b/plugins/kiro/scripts/heartbeat.sh index e6cabdf..8c2446a 100755 --- a/plugins/kiro/scripts/heartbeat.sh +++ b/plugins/kiro/scripts/heartbeat.sh @@ -169,6 +169,8 @@ main() { locate_plugin_root load_env # sources the env files, then normalises the base URL require_api_key # exits 0 when this install is not configured + . "${PLUGIN_ROOT}/scripts/protection.sh" + rogue_protection_init kiro kiro "${PLUGIN_ROOT}/scripts" load_actor resolve_surface "${1:-}" resolve_version # after the surface: install-id.sh keys the agent on it diff --git a/plugins/kiro/scripts/hook.ps1 b/plugins/kiro/scripts/hook.ps1 index 3589c31..ac6d2a2 100644 --- a/plugins/kiro/scripts/hook.ps1 +++ b/plugins/kiro/scripts/hook.ps1 @@ -123,6 +123,7 @@ function Rotate-Log { function Log { param([string]$Msg) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } try { if (-not $logFile) { return } $dir = Split-Path $logFile @@ -337,7 +338,18 @@ function Read-KiroPayload { param([System.IO.Stream]$InputStream = [Console]::OpenStandardInput()) $bytes = New-Object System.IO.MemoryStream try { - $InputStream.CopyTo($bytes) + $buffer=New-Object byte[] 4096 + while ($true) { + $pending=$InputStream.ReadAsync($buffer,0,$buffer.Length) + while (-not $pending.IsCompleted) { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return '{}' } + Start-Sleep -Milliseconds 100 + } + $count=$pending.GetAwaiter().GetResult() + if ($count -eq 0) { break } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return '{}' } + $bytes.Write($buffer,0,$count) + } $payload = (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes.ToArray()) } finally { $bytes.Dispose() } if (-not $payload) { $payload = '{}' } @@ -393,7 +405,9 @@ function Send-KiroRequest { $script:code = '000' $script:requestRc = 1 try { - $r = Invoke-WebRequest -Uri $url -Method Post ` + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and $null -ne $script:RPRevision) { $headers['x-rogue-activity-revision']=[string]$script:RPRevision } + $r = Invoke-WebRequest -Uri $url -Method Post ` -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` -UseBasicParsing -TimeoutSec $timeoutSec -ErrorAction Stop $script:requestRc = 0 @@ -420,7 +434,12 @@ function Write-KiroDecision { function Invoke-KiroHook { Initialize-KiroContext Resolve-KiroActor +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$script:apiKey = Initialize-RogueProtection -Key $script:apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'kiro' -Family 'kiro' +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } + $payload = Read-KiroPayload + if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } $payload = Add-KiroSessionId $payload $env:KIRO_SESSION_ID if (Test-KiroDuplicateAgentHook $triggerArg $payload) { Log "outcome=duplicate engine=3.0 trigger=$triggerArg" @@ -429,6 +448,7 @@ function Invoke-KiroHook { Resolve-KiroInstall Start-KiroHeartbeat Send-KiroRequest +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } Write-KiroDecision } diff --git a/plugins/kiro/scripts/hook.sh b/plugins/kiro/scripts/hook.sh index c82e547..334a248 100755 --- a/plugins/kiro/scripts/hook.sh +++ b/plugins/kiro/scripts/hook.sh @@ -93,6 +93,8 @@ initialize_context() { } rotate_log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 [ "$ROGUE_LOG_MAX_BYTES" -gt 0 ] || return 0 # `wc -c` not `stat`: BSD and GNU stat take different flags for file size. @@ -102,6 +104,7 @@ rotate_log() { return 0 } log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi # 0700 dir / 0600 file: the line carries the server's block reason, which # quotes the content that tripped the rule. ( umask 077 @@ -210,6 +213,7 @@ maybe_heartbeat() { post_request() { RAW=$(printf '%s' "$BODY" | curl -sS -X POST "$URL" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H "x-rogue-event: $EVENT" \ -H "x-rogue-agent: $ROGUE_INSTALL_AGENT" \ -H "x-rogue-host: ${ROGUE_INSTALL_HOST:-unknown}" \ @@ -246,6 +250,7 @@ block_reason() { } relay_decision() { + rogue_protection_current || { printf '%s' '{}'; exit 0; } if [ "$RC" -ne 0 ] || [ "$CODE" != "200" ] || [ -z "$RESP" ]; then finish allow exit 0 @@ -279,6 +284,11 @@ main() { locate_plugin_root load_env initialize_context +. "${PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init kiro kiro "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" +rogue_protection_enter || { printf '%s' '{}'; exit 0; } +trap 'rogue_protection_leave' EXIT + require_api_key load_identity resolve_request diff --git a/plugins/kiro/scripts/protection.ps1 b/plugins/kiro/scripts/protection.ps1 new file mode 100644 index 0000000..002d7d2 --- /dev/null +++ b/plugins/kiro/scripts/protection.ps1 @@ -0,0 +1,179 @@ +param([string]$Poll, [string]$Base, [string]$ScriptDirectory=$PSScriptRoot) +$script:RPHelperDirectory=$ScriptDirectory +$script:RPDirectory=$null +$script:RPRevision=$null +$script:RPKey=$null + +function Write-RogueProtectionFile([string]$Path, [string]$Value) { + $temp = "$Path.$PID.tmp" + [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) + Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop +} +function Get-RogueProtectionState { + try { + $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) + foreach ($cap in 'aidr','aispm') { + if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } + } + return $value.decision + } catch { return $null } +} +function Test-RogueProtectionCurrent { + if (-not $script:RPDirectory) { return $true } + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return $false } + $state = Get-RogueProtectionState + return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) +} +function Send-RogueProtectionAck { + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } + $state = Get-RogueProtectionState + if (-not $state) { return } + foreach ($lease in @(Get-ChildItem -LiteralPath $script:RPDirectory -Filter 'active.*' -ErrorAction SilentlyContinue)) { + $owner = 0 + if ([int]::TryParse(($lease.Name -replace '^active\.',''), [ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue)) { return } + Remove-Item -LiteralPath $lease.FullName -ErrorAction SilentlyContinue + } + $identity = "$($state.revision):$($state.aidr.paused):$($state.aispm.paused)" + if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } + try { + $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress + $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Write-RogueProtectionFile "$script:RPDirectory/ack" $identity + } catch { } +} +function Update-RogueProtection { + if (-not $script:RPDirectory) { return } + try { + $lock = [IO.File]::Open("$script:RPDirectory/refresh.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch { return } + try { + Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $old = Get-RogueProtectionState + if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + try { + Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) + Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue + $script:RPPersistenceFailed=$false + } catch { + $script:RPPersistenceFailed=$true + try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + } + } catch { } finally { $lock.Dispose() } + Send-RogueProtectionAck +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + $root=$env:ROGUE_PROTECTION_DIR + if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + $hash=[Security.Cryptography.SHA256]::Create() + try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } + $script:RPDirectory=Join-Path $root "$Slug-default-$id" + if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue + if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + $credential="$script:RPDirectory/credential" + try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + if (-not (Test-Path -LiteralPath $credential)) { + foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { + if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } + $previousKey=Get-Content -LiteralPath (Join-Path $previous.FullName 'credential') -Raw -ErrorAction SilentlyContinue + if (-not $previousKey) { continue } + try { + $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress + $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($restored.apiKey -ne $previousKey) { continue } + Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName + $script:RPDirectory=$previous.FullName + $credential=Join-Path $script:RPDirectory 'credential' + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + break + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } + } + if (-not (Test-Path -LiteralPath $credential)) { + $enrollAttempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + try { + if (-not (Test-Path -LiteralPath $credential)) { + Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } + if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } + } + } catch { } finally { $lock.Dispose() } + } + if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + $script:RPKey=Get-Content -LiteralPath $credential -Raw + $attempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } + $state=Get-RogueProtectionState + $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } + $env:ROGUE_PROTECTION_STATE=$script:RPDirectory + $env:ROGUE_LOG_FILE=Join-Path $script:RPDirectory "$Slug.log" + $script:logFile=$env:ROGUE_LOG_FILE + return $script:RPKey +} +function Read-RogueProtectionInput { + $reader=[Console]::OpenStandardInput() + $buffer=New-Object byte[] 4096 + $text=New-Object IO.MemoryStream + while (Test-RogueProtectionCurrent) { + $pending=$reader.ReadAsync($buffer,0,$buffer.Length) + while (-not $pending.IsCompleted) { + if (-not (Test-RogueProtectionCurrent)) { return '' } + Start-Sleep -Milliseconds 100 + } + $count=$pending.GetAwaiter().GetResult() + if ($count -eq 0) { break } + if (-not (Test-RogueProtectionCurrent)) { return '' } + $text.Write($buffer,0,$count) + } + $bytes=$text.ToArray(); $text.Dispose() + if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } + return '' +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + return Test-RogueProtectionCurrent +} +if ($Poll) { + $script:RPDirectory=$Poll; $script:RPBase=$Base + $script:RPKey=Get-Content -LiteralPath "$Poll/credential" -Raw + while ($true) { + $used=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$Poll/used" -Raw -ErrorAction SilentlyContinue), [ref]$used) + $busy=@(Get-ChildItem -LiteralPath $Poll -Filter 'active.*' -ErrorAction SilentlyContinue | Where-Object { $owner=0; [int]::TryParse(($_.Name -replace '^active\.',''),[ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue) }).Count -gt 0 + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $used -ge 90 -and -not $busy) { break } + Update-RogueProtection + Start-Sleep -Seconds 15 + } +} diff --git a/plugins/kiro/scripts/protection.sh b/plugins/kiro/scripts/protection.sh new file mode 100644 index 0000000..45b6348 --- /dev/null +++ b/plugins/kiro/scripts/protection.sh @@ -0,0 +1,163 @@ +#!/bin/sh +# Shared installation credential and pause gate. No activity payload is read here. +rogue_protection_now() { date +%s; } +rogue_protection_lock() { + ln -s "$$" "$1" 2>/dev/null && return 0 + _rp_owner=$(readlink "$1" 2>/dev/null) || return 1 + case "$_rp_owner" in *[!0-9]*|'') return 1 ;; esac + kill -0 "$_rp_owner" 2>/dev/null || rm -f "$1" + return 1 +} +rogue_protection_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +rogue_protection_load() { + [ -r "$ROGUE_PROTECTION_STATE/decision" ] || return 1 + IFS=' ' read -r RP_PROTOCOL RP_REV RP_AIDR RP_AIDR_EXP RP_AISPM RP_AISPM_EXP RP_SERVER RP_AIDR_REV RP_AISPM_REV RP_RECEIVED < "$ROGUE_PROTECTION_STATE/decision" + [ "$RP_PROTOCOL" = 1 ] || return 1 + RP_NOW=$(( $(rogue_protection_now) - RP_RECEIVED + RP_SERVER )) + [ "$RP_AIDR_EXP" -eq 0 ] || [ "$RP_AIDR_EXP" -gt "$RP_NOW" ] || RP_AIDR=0 + [ "$RP_AISPM_EXP" -eq 0 ] || [ "$RP_AISPM_EXP" -gt "$RP_NOW" ] || RP_AISPM=0 +} +rogue_protection_refresh() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_lock "$ROGUE_PROTECTION_STATE/refresh.lock" || return 0 + # A failed request is throttled too; an unavailable server must not cause a retry storm. + rogue_protection_now > "$ROGUE_PROTECTION_STATE/attempt" + _rp_decision=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'accept: text/tab-separated-values' "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/state" 2>/dev/null) || _rp_decision='' + case "$_rp_decision" in *[!0-9\ \ ]*|'') ;; *) + set -- $_rp_decision + if [ "$#" -eq 9 ] && [ "$1" = 1 ]; then + _rp_old=0 + rogue_protection_load && _rp_old=$RP_REV + if [ "$2" -ge "$_rp_old" ]; then + if printf '%s %s\n' "$*" "$(rogue_protection_now)" > "$ROGUE_PROTECTION_STATE/decision.tmp" && [ ! -d "$ROGUE_PROTECTION_STATE/decision" ] && mv -f "$ROGUE_PROTECTION_STATE/decision.tmp" "$ROGUE_PROTECTION_STATE/decision"; then + RP_PERSISTENCE_FAILED=0 + rm -f "$ROGUE_PROTECTION_STATE/persistence-failed" + else + RP_PERSISTENCE_FAILED=1 + touch "$ROGUE_PROTECTION_STATE/persistence-failed" 2>/dev/null || true + _rp_a=false; [ "$3" = 1 ] && _rp_a=true + _rp_s=false; [ "$5" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$2,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true + fi + fi + fi ;; + esac + rm -f "$ROGUE_PROTECTION_STATE/refresh.lock" 2>/dev/null || true + rogue_protection_ack +} +rogue_protection_busy() { + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + kill -0 "${_rp_lease##*.}" 2>/dev/null && return 0 + done + return 1 +} +rogue_protection_ack() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "$ROGUE_PROTECTION_STATE/persistence-failed" ] || return 0 + rogue_protection_load || return 0 + _rp_busy=0 + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + _rp_pid=${_rp_lease##*.} + if kill -0 "$_rp_pid" 2>/dev/null; then _rp_busy=1; else rm -f "$_rp_lease"; fi + done + [ "$_rp_busy" -eq 0 ] || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + _rp_ack="$RP_REV:$RP_AIDR:$RP_AISPM" + [ "$(cat "$ROGUE_PROTECTION_STATE/ack" 2>/dev/null)" != "$_rp_ack" ] || return 0 + if curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"applied\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1; then + printf '%s' "$_rp_ack" > "$ROGUE_PROTECTION_STATE/ack" + fi +} +rogue_protection_current() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "${ROGUE_PROTECTION_STATE:-}/persistence-failed" ] || return 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_load || return 1 + [ "$RP_AIDR" = 0 ] && [ "${ROGUE_PROTECTION_REVISION:-$RP_AIDR_REV}" = "$RP_AIDR_REV" ] +} +rogue_protection_leave() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rm -f "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_ack +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || return 0 + case "${ROGUE_PROTECTION_STATE:-}" in + */"$1-default-"*) + [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; + *) ROGUE_PROTECTION_STATE='' ;; + esac + ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) + case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) + if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; + esac + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do + [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue + [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue + _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue + [ -n "$_rp_previous_key" ] || continue + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue + (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + break + done + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" + _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac + if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" + else + _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) + if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi + fi + rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV + export ROGUE_PROTECTION_REVISION +} +rogue_protection_enter() { + rogue_protection_current || return 1 + [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_current +} +if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + rogue_protection_refresh + sleep 15 + done + rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid" + rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null || true +fi diff --git a/plugins/kiro/scripts/ship-logs.ps1 b/plugins/kiro/scripts/ship-logs.ps1 index ba62e78..dffeb83 100644 --- a/plugins/kiro/scripts/ship-logs.ps1 +++ b/plugins/kiro/scripts/ship-logs.ps1 @@ -122,6 +122,7 @@ function Write-ShipDebug { # the timestamp. "`n" keeps the line ending identical to the sh dispatchers'. function Write-ShipLog { param([string]$Message) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } # ALSO to stderr under ROGUE_DEBUG, and unconditionally - before the selfLogFile # gate below. The no-argument support invocation has no slug, so it has no log # file of its own to write to, and every failure reason (`http=`, @@ -814,7 +815,7 @@ function Send-ChunkRequest { try { $payload = [System.Text.Encoding]::UTF8.GetBytes($json) $response = Invoke-WebRequest -Uri $script:shipUrl -Method Post ` - -Headers @{ 'x-rogue-api-key' = $script:apiKey } ` + -Headers @{ 'x-rogue-api-key' = $script:apiKey; 'x-rogue-activity-revision' = if (Get-Variable RPRevision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:RPRevision } else { '' } } ` -ContentType 'application/json' -Body $payload ` -UseBasicParsing -TimeoutSec $HTTP_TIMEOUT -ErrorAction Stop $httpCode = [int]$response.StatusCode @@ -840,6 +841,7 @@ function Invoke-DrainFile { [string]$PersistHead, [int64]$PersistSize, [string]$NormalizedPath) $iteration = 0 while ($script:offset -lt $FileBytes) { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return $false } if ($script:runBytesSent -ge $script:maxRunBytes) { Write-ShipDebug 'run budget spent'; return $false } $iteration++ if ($iteration -gt $MAX_CHUNKS_PER_DRAIN) { Write-ShipDebug 'iteration guard'; return $false } @@ -870,6 +872,11 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) + return + } $script:runBytesSent = 0 $rotated = $false @@ -924,6 +931,10 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -934,7 +945,7 @@ function Invoke-Main { exit 0 } - $script:stateDir = Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' + $script:stateDir = if ($script:RPDirectory) { Join-Path $script:RPDirectory 'ship' } else { Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' } if (-not (Test-Path -LiteralPath $script:stateDir)) { New-Item -ItemType Directory -Path $script:stateDir -Force | Out-Null } diff --git a/plugins/kiro/scripts/ship-logs.sh b/plugins/kiro/scripts/ship-logs.sh index 1eb3152..f95e7a8 100644 --- a/plugins/kiro/scripts/ship-logs.sh +++ b/plugins/kiro/scripts/ship-logs.sh @@ -628,6 +628,7 @@ find_line_end() { # # (which is NOT always the bytes sent - an over-long line is skipped forward). # Returns non-zero when nothing was shipped and the offset must NOT move. ship_next_chunk() { # + rogue_protection_current || return 1 ADVANCE_BYTES=0 _chunk_source_file="${1:-}" _chunk_offset="${2:-0}" @@ -763,6 +764,7 @@ ship_oversize_line() { # # body is passed as --data-binary @file rather than -d, because a 1 MiB chunk is # ~1.4 MiB of base64 and macOS's ARG_MAX is 1 MiB for args plus environment. post_chunk() { # + rogue_protection_current || return 1 _post_chunk_file="${1:-}" _post_offset="${2:-0}" _post_bytes="${3:-0}" @@ -784,6 +786,7 @@ post_chunk() { # debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -839,6 +842,12 @@ ship_log_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" + release_lock + return 0 + fi RUN_BYTES_SENT=0 _target_rotated=0 @@ -890,6 +899,9 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + . "$PLUGIN_ROOT/scripts/protection.sh" + rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } @@ -902,12 +914,12 @@ main() { exit 0 fi - STATE_DIR="$HOME/.rogue/ship" + STATE_DIR="${ROGUE_PROTECTION_STATE:-$HOME/.rogue}/ship" mkdir -p "$STATE_DIR" 2>/dev/null [ -d "$STATE_DIR" ] || { debug "cannot create $STATE_DIR"; exit 0; } TMP_DIR=$(mktemp -d "$STATE_DIR/.tmp.XXXXXX" 2>/dev/null) || TMP_DIR="" [ -n "$TMP_DIR" ] || { debug 'cannot create a temp dir'; exit 0; } - trap 'cleanup' EXIT INT TERM + trap 'cleanup; rogue_protection_leave' EXIT INT TERM # A redirect, not a pipe: a `while` on the right of a pipe runs in a subshell in # POSIX sh, and log paths can contain spaces, so read them line by line. diff --git a/plugins/rogue/.claude-plugin/plugin.json b/plugins/rogue/.claude-plugin/plugin.json index 09d4bd0..8fa7c25 100644 --- a/plugins/rogue/.claude-plugin/plugin.json +++ b/plugins/rogue/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.29", + "version": "1.0.30", "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for Claude Code", "author": { "name": "Rogue Security", diff --git a/plugins/rogue/scripts/heartbeat.ps1 b/plugins/rogue/scripts/heartbeat.ps1 index 8d1794a..4cba1b2 100644 --- a/plugins/rogue/scripts/heartbeat.ps1 +++ b/plugins/rogue/scripts/heartbeat.ps1 @@ -148,6 +148,8 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'claude' -Family 'claude' # -- actor resolution: hook.ps1's Resolve-RogueActor ------------------------- # The ONE Claude cascade (env file -> CLAUDE_CODE_USER_EMAIL -> git config files -> diff --git a/plugins/rogue/scripts/heartbeat.sh b/plugins/rogue/scripts/heartbeat.sh index f060052..e8b373e 100755 --- a/plugins/rogue/scripts/heartbeat.sh +++ b/plugins/rogue/scripts/heartbeat.sh @@ -71,6 +71,9 @@ set -u # fixed enum value "claude". Backslash- and quote-escape each value so a # name/host with a " or \ can't break the JSON. esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } +. "${CLAUDE_PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init claude claude "${CLAUDE_PLUGIN_ROOT}/scripts" "${SURFACE:-default}" + BODY=$(printf '{"agent_family":"claude","agent":"%s","version":"%s","host":"%s","actor_email":"%s","actor_name":"%s"}' \ "$(esc "${ROGUE_INSTALL_AGENT:-claude_code}")" "$(esc "${ROGUE_INSTALL_VERSION:-unknown}")" \ "$(esc "${ROGUE_INSTALL_HOST:-unknown}")" \ diff --git a/plugins/rogue/scripts/hook.ps1 b/plugins/rogue/scripts/hook.ps1 index a149ef7..f0ade57 100644 --- a/plugins/rogue/scripts/hook.ps1 +++ b/plugins/rogue/scripts/hook.ps1 @@ -203,6 +203,7 @@ function Rotate-Log { function Log { param([string]$Msg) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } try { if (-not $logFile) { return } $dir = Split-Path $logFile @@ -493,7 +494,12 @@ if (-not $installAgent) { $installAgent = 'claude_code' } if ($installError.Count) { Log "error=install-id $($installError -join ',')" } # -- payload from stdin ----------------------------------------------------- -$payload = [Console]::In.ReadToEnd() +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'claude' -Family 'claude' +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } + +$payload = Read-RogueProtectionInput +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } if (-not $payload) { $payload = '{}' } # Claude Code sends a UTF-8 payload, but the console often reads stdin under a # legacy OEM codepage (e.g. IBM437), which mojibakes it. Round-trip back through @@ -525,6 +531,8 @@ Dbg "POST $url actor=$actorEmail" $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) $resp = '' try { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and $null -ne $script:RPRevision) { $headers['x-rogue-activity-revision']=[string]$script:RPRevision } $r = Invoke-WebRequest -Uri $url -Method Post ` -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop @@ -543,6 +551,7 @@ try { # Always log the raw response so block-detection bugs are diagnosable from the log # alone (mirrors hook.sh). $respHead = if ($resp.Length -gt 400) { $resp.Substring(0, 400) } else { $resp } +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } Log "raw=$(Sanitize $respHead)" # -- block detection (mirrors hook.sh's pure-text scan) --------------------- diff --git a/plugins/rogue/scripts/hook.sh b/plugins/rogue/scripts/hook.sh index 99c96cc..1623922 100644 --- a/plugins/rogue/scripts/hook.sh +++ b/plugins/rogue/scripts/hook.sh @@ -67,6 +67,8 @@ _lcap="$ROGUE_LOG_MAX_BYTES" while [ "${_lcap#0}" != "$_lcap" ]; do _lcap="${_lcap#0}"; done if [ "${#_lcap}" -gt 18 ]; then ROGUE_LOG_MAX_BYTES=10485760; fi rotate_log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" # and Number("00") do in the PowerShell and Node dispatchers. @@ -78,6 +80,7 @@ rotate_log() { return 0 } log() { + if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi # 0700 dir / 0600 file. The logged text is not only ours: it carries the # server's block reason, which quotes the content that tripped the rule - a # secret, a command, a slice of a prompt. Under the default umask the log @@ -147,6 +150,11 @@ _rogue_want_alert() { return 0 } +. "${CLAUDE_PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init claude claude "${CLAUDE_PLUGIN_ROOT}/scripts" "${SURFACE:-default}" +rogue_protection_enter || { printf '%s' '{}'; exit 0; } +trap 'rogue_protection_leave' EXIT + if [ -z "${ROGUE_API_KEY:-}" ]; then log "outcome=unconfigured" echo '{}' @@ -165,6 +173,7 @@ fi RESP=$(curl -sS -X POST "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/claude" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H "x-rogue-event: $EVENT" \ -H "x-rogue-agent: $ROGUE_INSTALL_AGENT" \ -H "x-rogue-host: $ROGUE_INSTALL_HOST" \ @@ -176,6 +185,7 @@ RESP=$(curl -sS -X POST "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/ho # Always log raw response so block-detection bugs are diagnosable from # ~/.rogue/logs/claude.log alone, without re-instrumenting the script. +rogue_protection_current || { printf '%s' '{}'; exit 0; } log "raw=$(sanitize "$RESP" | head -c 400)" # Pure-shell block detection. We deliberately do NOT use python3 — on a fresh diff --git a/plugins/rogue/scripts/protection.ps1 b/plugins/rogue/scripts/protection.ps1 new file mode 100644 index 0000000..002d7d2 --- /dev/null +++ b/plugins/rogue/scripts/protection.ps1 @@ -0,0 +1,179 @@ +param([string]$Poll, [string]$Base, [string]$ScriptDirectory=$PSScriptRoot) +$script:RPHelperDirectory=$ScriptDirectory +$script:RPDirectory=$null +$script:RPRevision=$null +$script:RPKey=$null + +function Write-RogueProtectionFile([string]$Path, [string]$Value) { + $temp = "$Path.$PID.tmp" + [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) + Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop +} +function Get-RogueProtectionState { + try { + $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) + foreach ($cap in 'aidr','aispm') { + if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } + } + return $value.decision + } catch { return $null } +} +function Test-RogueProtectionCurrent { + if (-not $script:RPDirectory) { return $true } + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return $false } + $state = Get-RogueProtectionState + return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) +} +function Send-RogueProtectionAck { + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } + $state = Get-RogueProtectionState + if (-not $state) { return } + foreach ($lease in @(Get-ChildItem -LiteralPath $script:RPDirectory -Filter 'active.*' -ErrorAction SilentlyContinue)) { + $owner = 0 + if ([int]::TryParse(($lease.Name -replace '^active\.',''), [ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue)) { return } + Remove-Item -LiteralPath $lease.FullName -ErrorAction SilentlyContinue + } + $identity = "$($state.revision):$($state.aidr.paused):$($state.aispm.paused)" + if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } + try { + $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress + $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Write-RogueProtectionFile "$script:RPDirectory/ack" $identity + } catch { } +} +function Update-RogueProtection { + if (-not $script:RPDirectory) { return } + try { + $lock = [IO.File]::Open("$script:RPDirectory/refresh.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch { return } + try { + Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $old = Get-RogueProtectionState + if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + try { + Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) + Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue + $script:RPPersistenceFailed=$false + } catch { + $script:RPPersistenceFailed=$true + try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + } + } catch { } finally { $lock.Dispose() } + Send-RogueProtectionAck +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + $root=$env:ROGUE_PROTECTION_DIR + if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + $hash=[Security.Cryptography.SHA256]::Create() + try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } + $script:RPDirectory=Join-Path $root "$Slug-default-$id" + if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue + if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + $credential="$script:RPDirectory/credential" + try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + if (-not (Test-Path -LiteralPath $credential)) { + foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { + if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } + $previousKey=Get-Content -LiteralPath (Join-Path $previous.FullName 'credential') -Raw -ErrorAction SilentlyContinue + if (-not $previousKey) { continue } + try { + $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress + $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($restored.apiKey -ne $previousKey) { continue } + Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName + $script:RPDirectory=$previous.FullName + $credential=Join-Path $script:RPDirectory 'credential' + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + break + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } + } + if (-not (Test-Path -LiteralPath $credential)) { + $enrollAttempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + try { + if (-not (Test-Path -LiteralPath $credential)) { + Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } + if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } + } + } catch { } finally { $lock.Dispose() } + } + if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + $script:RPKey=Get-Content -LiteralPath $credential -Raw + $attempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } + $state=Get-RogueProtectionState + $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } + $env:ROGUE_PROTECTION_STATE=$script:RPDirectory + $env:ROGUE_LOG_FILE=Join-Path $script:RPDirectory "$Slug.log" + $script:logFile=$env:ROGUE_LOG_FILE + return $script:RPKey +} +function Read-RogueProtectionInput { + $reader=[Console]::OpenStandardInput() + $buffer=New-Object byte[] 4096 + $text=New-Object IO.MemoryStream + while (Test-RogueProtectionCurrent) { + $pending=$reader.ReadAsync($buffer,0,$buffer.Length) + while (-not $pending.IsCompleted) { + if (-not (Test-RogueProtectionCurrent)) { return '' } + Start-Sleep -Milliseconds 100 + } + $count=$pending.GetAwaiter().GetResult() + if ($count -eq 0) { break } + if (-not (Test-RogueProtectionCurrent)) { return '' } + $text.Write($buffer,0,$count) + } + $bytes=$text.ToArray(); $text.Dispose() + if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } + return '' +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + return Test-RogueProtectionCurrent +} +if ($Poll) { + $script:RPDirectory=$Poll; $script:RPBase=$Base + $script:RPKey=Get-Content -LiteralPath "$Poll/credential" -Raw + while ($true) { + $used=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$Poll/used" -Raw -ErrorAction SilentlyContinue), [ref]$used) + $busy=@(Get-ChildItem -LiteralPath $Poll -Filter 'active.*' -ErrorAction SilentlyContinue | Where-Object { $owner=0; [int]::TryParse(($_.Name -replace '^active\.',''),[ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue) }).Count -gt 0 + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $used -ge 90 -and -not $busy) { break } + Update-RogueProtection + Start-Sleep -Seconds 15 + } +} diff --git a/plugins/rogue/scripts/protection.sh b/plugins/rogue/scripts/protection.sh new file mode 100644 index 0000000..45b6348 --- /dev/null +++ b/plugins/rogue/scripts/protection.sh @@ -0,0 +1,163 @@ +#!/bin/sh +# Shared installation credential and pause gate. No activity payload is read here. +rogue_protection_now() { date +%s; } +rogue_protection_lock() { + ln -s "$$" "$1" 2>/dev/null && return 0 + _rp_owner=$(readlink "$1" 2>/dev/null) || return 1 + case "$_rp_owner" in *[!0-9]*|'') return 1 ;; esac + kill -0 "$_rp_owner" 2>/dev/null || rm -f "$1" + return 1 +} +rogue_protection_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +rogue_protection_load() { + [ -r "$ROGUE_PROTECTION_STATE/decision" ] || return 1 + IFS=' ' read -r RP_PROTOCOL RP_REV RP_AIDR RP_AIDR_EXP RP_AISPM RP_AISPM_EXP RP_SERVER RP_AIDR_REV RP_AISPM_REV RP_RECEIVED < "$ROGUE_PROTECTION_STATE/decision" + [ "$RP_PROTOCOL" = 1 ] || return 1 + RP_NOW=$(( $(rogue_protection_now) - RP_RECEIVED + RP_SERVER )) + [ "$RP_AIDR_EXP" -eq 0 ] || [ "$RP_AIDR_EXP" -gt "$RP_NOW" ] || RP_AIDR=0 + [ "$RP_AISPM_EXP" -eq 0 ] || [ "$RP_AISPM_EXP" -gt "$RP_NOW" ] || RP_AISPM=0 +} +rogue_protection_refresh() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_lock "$ROGUE_PROTECTION_STATE/refresh.lock" || return 0 + # A failed request is throttled too; an unavailable server must not cause a retry storm. + rogue_protection_now > "$ROGUE_PROTECTION_STATE/attempt" + _rp_decision=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'accept: text/tab-separated-values' "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/state" 2>/dev/null) || _rp_decision='' + case "$_rp_decision" in *[!0-9\ \ ]*|'') ;; *) + set -- $_rp_decision + if [ "$#" -eq 9 ] && [ "$1" = 1 ]; then + _rp_old=0 + rogue_protection_load && _rp_old=$RP_REV + if [ "$2" -ge "$_rp_old" ]; then + if printf '%s %s\n' "$*" "$(rogue_protection_now)" > "$ROGUE_PROTECTION_STATE/decision.tmp" && [ ! -d "$ROGUE_PROTECTION_STATE/decision" ] && mv -f "$ROGUE_PROTECTION_STATE/decision.tmp" "$ROGUE_PROTECTION_STATE/decision"; then + RP_PERSISTENCE_FAILED=0 + rm -f "$ROGUE_PROTECTION_STATE/persistence-failed" + else + RP_PERSISTENCE_FAILED=1 + touch "$ROGUE_PROTECTION_STATE/persistence-failed" 2>/dev/null || true + _rp_a=false; [ "$3" = 1 ] && _rp_a=true + _rp_s=false; [ "$5" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$2,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true + fi + fi + fi ;; + esac + rm -f "$ROGUE_PROTECTION_STATE/refresh.lock" 2>/dev/null || true + rogue_protection_ack +} +rogue_protection_busy() { + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + kill -0 "${_rp_lease##*.}" 2>/dev/null && return 0 + done + return 1 +} +rogue_protection_ack() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "$ROGUE_PROTECTION_STATE/persistence-failed" ] || return 0 + rogue_protection_load || return 0 + _rp_busy=0 + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + _rp_pid=${_rp_lease##*.} + if kill -0 "$_rp_pid" 2>/dev/null; then _rp_busy=1; else rm -f "$_rp_lease"; fi + done + [ "$_rp_busy" -eq 0 ] || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + _rp_ack="$RP_REV:$RP_AIDR:$RP_AISPM" + [ "$(cat "$ROGUE_PROTECTION_STATE/ack" 2>/dev/null)" != "$_rp_ack" ] || return 0 + if curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"applied\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1; then + printf '%s' "$_rp_ack" > "$ROGUE_PROTECTION_STATE/ack" + fi +} +rogue_protection_current() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "${ROGUE_PROTECTION_STATE:-}/persistence-failed" ] || return 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_load || return 1 + [ "$RP_AIDR" = 0 ] && [ "${ROGUE_PROTECTION_REVISION:-$RP_AIDR_REV}" = "$RP_AIDR_REV" ] +} +rogue_protection_leave() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rm -f "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_ack +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || return 0 + case "${ROGUE_PROTECTION_STATE:-}" in + */"$1-default-"*) + [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; + *) ROGUE_PROTECTION_STATE='' ;; + esac + ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) + case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) + if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; + esac + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do + [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue + [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue + _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue + [ -n "$_rp_previous_key" ] || continue + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue + (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + break + done + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" + _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac + if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" + else + _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) + if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi + fi + rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV + export ROGUE_PROTECTION_REVISION +} +rogue_protection_enter() { + rogue_protection_current || return 1 + [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_current +} +if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + rogue_protection_refresh + sleep 15 + done + rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid" + rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null || true +fi diff --git a/plugins/rogue/scripts/ship-logs.ps1 b/plugins/rogue/scripts/ship-logs.ps1 index ba62e78..dffeb83 100644 --- a/plugins/rogue/scripts/ship-logs.ps1 +++ b/plugins/rogue/scripts/ship-logs.ps1 @@ -122,6 +122,7 @@ function Write-ShipDebug { # the timestamp. "`n" keeps the line ending identical to the sh dispatchers'. function Write-ShipLog { param([string]$Message) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } # ALSO to stderr under ROGUE_DEBUG, and unconditionally - before the selfLogFile # gate below. The no-argument support invocation has no slug, so it has no log # file of its own to write to, and every failure reason (`http=`, @@ -814,7 +815,7 @@ function Send-ChunkRequest { try { $payload = [System.Text.Encoding]::UTF8.GetBytes($json) $response = Invoke-WebRequest -Uri $script:shipUrl -Method Post ` - -Headers @{ 'x-rogue-api-key' = $script:apiKey } ` + -Headers @{ 'x-rogue-api-key' = $script:apiKey; 'x-rogue-activity-revision' = if (Get-Variable RPRevision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:RPRevision } else { '' } } ` -ContentType 'application/json' -Body $payload ` -UseBasicParsing -TimeoutSec $HTTP_TIMEOUT -ErrorAction Stop $httpCode = [int]$response.StatusCode @@ -840,6 +841,7 @@ function Invoke-DrainFile { [string]$PersistHead, [int64]$PersistSize, [string]$NormalizedPath) $iteration = 0 while ($script:offset -lt $FileBytes) { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return $false } if ($script:runBytesSent -ge $script:maxRunBytes) { Write-ShipDebug 'run budget spent'; return $false } $iteration++ if ($iteration -gt $MAX_CHUNKS_PER_DRAIN) { Write-ShipDebug 'iteration guard'; return $false } @@ -870,6 +872,11 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) + return + } $script:runBytesSent = 0 $rotated = $false @@ -924,6 +931,10 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -934,7 +945,7 @@ function Invoke-Main { exit 0 } - $script:stateDir = Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' + $script:stateDir = if ($script:RPDirectory) { Join-Path $script:RPDirectory 'ship' } else { Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' } if (-not (Test-Path -LiteralPath $script:stateDir)) { New-Item -ItemType Directory -Path $script:stateDir -Force | Out-Null } diff --git a/plugins/rogue/scripts/ship-logs.sh b/plugins/rogue/scripts/ship-logs.sh index 1eb3152..f95e7a8 100644 --- a/plugins/rogue/scripts/ship-logs.sh +++ b/plugins/rogue/scripts/ship-logs.sh @@ -628,6 +628,7 @@ find_line_end() { # # (which is NOT always the bytes sent - an over-long line is skipped forward). # Returns non-zero when nothing was shipped and the offset must NOT move. ship_next_chunk() { # + rogue_protection_current || return 1 ADVANCE_BYTES=0 _chunk_source_file="${1:-}" _chunk_offset="${2:-0}" @@ -763,6 +764,7 @@ ship_oversize_line() { # # body is passed as --data-binary @file rather than -d, because a 1 MiB chunk is # ~1.4 MiB of base64 and macOS's ARG_MAX is 1 MiB for args plus environment. post_chunk() { # + rogue_protection_current || return 1 _post_chunk_file="${1:-}" _post_offset="${2:-0}" _post_bytes="${3:-0}" @@ -784,6 +786,7 @@ post_chunk() { # debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -839,6 +842,12 @@ ship_log_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" + release_lock + return 0 + fi RUN_BYTES_SENT=0 _target_rotated=0 @@ -890,6 +899,9 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + . "$PLUGIN_ROOT/scripts/protection.sh" + rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } @@ -902,12 +914,12 @@ main() { exit 0 fi - STATE_DIR="$HOME/.rogue/ship" + STATE_DIR="${ROGUE_PROTECTION_STATE:-$HOME/.rogue}/ship" mkdir -p "$STATE_DIR" 2>/dev/null [ -d "$STATE_DIR" ] || { debug "cannot create $STATE_DIR"; exit 0; } TMP_DIR=$(mktemp -d "$STATE_DIR/.tmp.XXXXXX" 2>/dev/null) || TMP_DIR="" [ -n "$TMP_DIR" ] || { debug 'cannot create a temp dir'; exit 0; } - trap 'cleanup' EXIT INT TERM + trap 'cleanup; rogue_protection_leave' EXIT INT TERM # A redirect, not a pipe: a `while` on the right of a pipe runs in a subshell in # POSIX sh, and log paths can contain spaces, so read them line by line. diff --git a/scripts/shared/protection.ps1 b/scripts/shared/protection.ps1 new file mode 100644 index 0000000..002d7d2 --- /dev/null +++ b/scripts/shared/protection.ps1 @@ -0,0 +1,179 @@ +param([string]$Poll, [string]$Base, [string]$ScriptDirectory=$PSScriptRoot) +$script:RPHelperDirectory=$ScriptDirectory +$script:RPDirectory=$null +$script:RPRevision=$null +$script:RPKey=$null + +function Write-RogueProtectionFile([string]$Path, [string]$Value) { + $temp = "$Path.$PID.tmp" + [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) + Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop +} +function Get-RogueProtectionState { + try { + $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) + foreach ($cap in 'aidr','aispm') { + if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } + } + return $value.decision + } catch { return $null } +} +function Test-RogueProtectionCurrent { + if (-not $script:RPDirectory) { return $true } + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return $false } + $state = Get-RogueProtectionState + return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) +} +function Send-RogueProtectionAck { + if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } + $state = Get-RogueProtectionState + if (-not $state) { return } + foreach ($lease in @(Get-ChildItem -LiteralPath $script:RPDirectory -Filter 'active.*' -ErrorAction SilentlyContinue)) { + $owner = 0 + if ([int]::TryParse(($lease.Name -replace '^active\.',''), [ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue)) { return } + Remove-Item -LiteralPath $lease.FullName -ErrorAction SilentlyContinue + } + $identity = "$($state.revision):$($state.aidr.paused):$($state.aispm.paused)" + if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } + try { + $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress + $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Write-RogueProtectionFile "$script:RPDirectory/ack" $identity + } catch { } +} +function Update-RogueProtection { + if (-not $script:RPDirectory) { return } + try { + $lock = [IO.File]::Open("$script:RPDirectory/refresh.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch { return } + try { + Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $old = Get-RogueProtectionState + if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + try { + Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) + Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue + $script:RPPersistenceFailed=$false + } catch { + $script:RPPersistenceFailed=$true + try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + } + } catch { } finally { $lock.Dispose() } + Send-RogueProtectionAck +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + $root=$env:ROGUE_PROTECTION_DIR + if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + $hash=[Security.Cryptography.SHA256]::Create() + try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } + $script:RPDirectory=Join-Path $root "$Slug-default-$id" + if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue + if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + $credential="$script:RPDirectory/credential" + try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + if (-not (Test-Path -LiteralPath $credential)) { + foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { + if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } + $previousKey=Get-Content -LiteralPath (Join-Path $previous.FullName 'credential') -Raw -ErrorAction SilentlyContinue + if (-not $previousKey) { continue } + try { + $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress + $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($restored.apiKey -ne $previousKey) { continue } + Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName + $script:RPDirectory=$previous.FullName + $credential=Join-Path $script:RPDirectory 'credential' + try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} + break + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } + } + if (-not (Test-Path -LiteralPath $credential)) { + $enrollAttempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + try { + if (-not (Test-Path -LiteralPath $credential)) { + Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } + if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } + } + } catch { } finally { $lock.Dispose() } + } + if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + $script:RPKey=Get-Content -LiteralPath $credential -Raw + $attempt=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } + $state=Get-RogueProtectionState + $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } + $env:ROGUE_PROTECTION_STATE=$script:RPDirectory + $env:ROGUE_LOG_FILE=Join-Path $script:RPDirectory "$Slug.log" + $script:logFile=$env:ROGUE_LOG_FILE + return $script:RPKey +} +function Read-RogueProtectionInput { + $reader=[Console]::OpenStandardInput() + $buffer=New-Object byte[] 4096 + $text=New-Object IO.MemoryStream + while (Test-RogueProtectionCurrent) { + $pending=$reader.ReadAsync($buffer,0,$buffer.Length) + while (-not $pending.IsCompleted) { + if (-not (Test-RogueProtectionCurrent)) { return '' } + Start-Sleep -Milliseconds 100 + } + $count=$pending.GetAwaiter().GetResult() + if ($count -eq 0) { break } + if (-not (Test-RogueProtectionCurrent)) { return '' } + $text.Write($buffer,0,$count) + } + $bytes=$text.ToArray(); $text.Dispose() + if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } + return '' +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + return Test-RogueProtectionCurrent +} +if ($Poll) { + $script:RPDirectory=$Poll; $script:RPBase=$Base + $script:RPKey=Get-Content -LiteralPath "$Poll/credential" -Raw + while ($true) { + $used=0L + $null=[long]::TryParse((Get-Content -LiteralPath "$Poll/used" -Raw -ErrorAction SilentlyContinue), [ref]$used) + $busy=@(Get-ChildItem -LiteralPath $Poll -Filter 'active.*' -ErrorAction SilentlyContinue | Where-Object { $owner=0; [int]::TryParse(($_.Name -replace '^active\.',''),[ref]$owner) -and (Get-Process -Id $owner -ErrorAction SilentlyContinue) }).Count -gt 0 + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $used -ge 90 -and -not $busy) { break } + Update-RogueProtection + Start-Sleep -Seconds 15 + } +} diff --git a/scripts/shared/protection.sh b/scripts/shared/protection.sh new file mode 100644 index 0000000..45b6348 --- /dev/null +++ b/scripts/shared/protection.sh @@ -0,0 +1,163 @@ +#!/bin/sh +# Shared installation credential and pause gate. No activity payload is read here. +rogue_protection_now() { date +%s; } +rogue_protection_lock() { + ln -s "$$" "$1" 2>/dev/null && return 0 + _rp_owner=$(readlink "$1" 2>/dev/null) || return 1 + case "$_rp_owner" in *[!0-9]*|'') return 1 ;; esac + kill -0 "$_rp_owner" 2>/dev/null || rm -f "$1" + return 1 +} +rogue_protection_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +rogue_protection_load() { + [ -r "$ROGUE_PROTECTION_STATE/decision" ] || return 1 + IFS=' ' read -r RP_PROTOCOL RP_REV RP_AIDR RP_AIDR_EXP RP_AISPM RP_AISPM_EXP RP_SERVER RP_AIDR_REV RP_AISPM_REV RP_RECEIVED < "$ROGUE_PROTECTION_STATE/decision" + [ "$RP_PROTOCOL" = 1 ] || return 1 + RP_NOW=$(( $(rogue_protection_now) - RP_RECEIVED + RP_SERVER )) + [ "$RP_AIDR_EXP" -eq 0 ] || [ "$RP_AIDR_EXP" -gt "$RP_NOW" ] || RP_AIDR=0 + [ "$RP_AISPM_EXP" -eq 0 ] || [ "$RP_AISPM_EXP" -gt "$RP_NOW" ] || RP_AISPM=0 +} +rogue_protection_refresh() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_lock "$ROGUE_PROTECTION_STATE/refresh.lock" || return 0 + # A failed request is throttled too; an unavailable server must not cause a retry storm. + rogue_protection_now > "$ROGUE_PROTECTION_STATE/attempt" + _rp_decision=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'accept: text/tab-separated-values' "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/state" 2>/dev/null) || _rp_decision='' + case "$_rp_decision" in *[!0-9\ \ ]*|'') ;; *) + set -- $_rp_decision + if [ "$#" -eq 9 ] && [ "$1" = 1 ]; then + _rp_old=0 + rogue_protection_load && _rp_old=$RP_REV + if [ "$2" -ge "$_rp_old" ]; then + if printf '%s %s\n' "$*" "$(rogue_protection_now)" > "$ROGUE_PROTECTION_STATE/decision.tmp" && [ ! -d "$ROGUE_PROTECTION_STATE/decision" ] && mv -f "$ROGUE_PROTECTION_STATE/decision.tmp" "$ROGUE_PROTECTION_STATE/decision"; then + RP_PERSISTENCE_FAILED=0 + rm -f "$ROGUE_PROTECTION_STATE/persistence-failed" + else + RP_PERSISTENCE_FAILED=1 + touch "$ROGUE_PROTECTION_STATE/persistence-failed" 2>/dev/null || true + _rp_a=false; [ "$3" = 1 ] && _rp_a=true + _rp_s=false; [ "$5" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$2,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true + fi + fi + fi ;; + esac + rm -f "$ROGUE_PROTECTION_STATE/refresh.lock" 2>/dev/null || true + rogue_protection_ack +} +rogue_protection_busy() { + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + kill -0 "${_rp_lease##*.}" 2>/dev/null && return 0 + done + return 1 +} +rogue_protection_ack() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "$ROGUE_PROTECTION_STATE/persistence-failed" ] || return 0 + rogue_protection_load || return 0 + _rp_busy=0 + for _rp_lease in "$ROGUE_PROTECTION_STATE"/active.*; do + [ -f "$_rp_lease" ] || continue + _rp_pid=${_rp_lease##*.} + if kill -0 "$_rp_pid" 2>/dev/null; then _rp_busy=1; else rm -f "$_rp_lease"; fi + done + [ "$_rp_busy" -eq 0 ] || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + _rp_ack="$RP_REV:$RP_AIDR:$RP_AISPM" + [ "$(cat "$ROGUE_PROTECTION_STATE/ack" 2>/dev/null)" != "$_rp_ack" ] || return 0 + if curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"applied\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1; then + printf '%s' "$_rp_ack" > "$ROGUE_PROTECTION_STATE/ack" + fi +} +rogue_protection_current() { + [ "${RP_PERSISTENCE_FAILED:-0}" = 0 ] && [ ! -e "${ROGUE_PROTECTION_STATE:-}/persistence-failed" ] || return 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rogue_protection_load || return 1 + [ "$RP_AIDR" = 0 ] && [ "${ROGUE_PROTECTION_REVISION:-$RP_AIDR_REV}" = "$RP_AIDR_REV" ] +} +rogue_protection_leave() { + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || return 0 + rm -f "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_ack +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) + [ -n "$_rp_hash" ] || return 0 + case "${ROGUE_PROTECTION_STATE:-}" in + */"$1-default-"*) + [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; + *) ROGUE_PROTECTION_STATE='' ;; + esac + ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) + case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) + if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; + esac + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do + [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue + [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue + _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue + [ -n "$_rp_previous_key" ] || continue + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue + (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + break + done + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then + _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" + _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') + case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac + if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" + else + _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) + if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi + fi + rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV + export ROGUE_PROTECTION_REVISION +} +rogue_protection_enter() { + rogue_protection_current || return 1 + [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + rogue_protection_current +} +if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + rogue_protection_refresh + sleep 15 + done + rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid" + rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null || true +fi diff --git a/scripts/shared/ship-logs.ps1 b/scripts/shared/ship-logs.ps1 index ba62e78..dffeb83 100644 --- a/scripts/shared/ship-logs.ps1 +++ b/scripts/shared/ship-logs.ps1 @@ -122,6 +122,7 @@ function Write-ShipDebug { # the timestamp. "`n" keeps the line ending identical to the sh dispatchers'. function Write-ShipLog { param([string]$Message) + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return } # ALSO to stderr under ROGUE_DEBUG, and unconditionally - before the selfLogFile # gate below. The no-argument support invocation has no slug, so it has no log # file of its own to write to, and every failure reason (`http=`, @@ -814,7 +815,7 @@ function Send-ChunkRequest { try { $payload = [System.Text.Encoding]::UTF8.GetBytes($json) $response = Invoke-WebRequest -Uri $script:shipUrl -Method Post ` - -Headers @{ 'x-rogue-api-key' = $script:apiKey } ` + -Headers @{ 'x-rogue-api-key' = $script:apiKey; 'x-rogue-activity-revision' = if (Get-Variable RPRevision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:RPRevision } else { '' } } ` -ContentType 'application/json' -Body $payload ` -UseBasicParsing -TimeoutSec $HTTP_TIMEOUT -ErrorAction Stop $httpCode = [int]$response.StatusCode @@ -840,6 +841,7 @@ function Invoke-DrainFile { [string]$PersistHead, [int64]$PersistSize, [string]$NormalizedPath) $iteration = 0 while ($script:offset -lt $FileBytes) { + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { return $false } if ($script:runBytesSent -ge $script:maxRunBytes) { Write-ShipDebug 'run budget spent'; return $false } $iteration++ if ($iteration -gt $MAX_CHUNKS_PER_DRAIN) { Write-ShipDebug 'iteration guard'; return $false } @@ -870,6 +872,11 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) + return + } $script:runBytesSent = 0 $rotated = $false @@ -924,6 +931,10 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -934,7 +945,7 @@ function Invoke-Main { exit 0 } - $script:stateDir = Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' + $script:stateDir = if ($script:RPDirectory) { Join-Path $script:RPDirectory 'ship' } else { Join-Path (Join-Path (Get-UserHome) '.rogue') 'ship' } if (-not (Test-Path -LiteralPath $script:stateDir)) { New-Item -ItemType Directory -Path $script:stateDir -Force | Out-Null } diff --git a/scripts/shared/ship-logs.sh b/scripts/shared/ship-logs.sh index 1eb3152..f95e7a8 100644 --- a/scripts/shared/ship-logs.sh +++ b/scripts/shared/ship-logs.sh @@ -628,6 +628,7 @@ find_line_end() { # # (which is NOT always the bytes sent - an over-long line is skipped forward). # Returns non-zero when nothing was shipped and the offset must NOT move. ship_next_chunk() { # + rogue_protection_current || return 1 ADVANCE_BYTES=0 _chunk_source_file="${1:-}" _chunk_offset="${2:-0}" @@ -763,6 +764,7 @@ ship_oversize_line() { # # body is passed as --data-binary @file rather than -d, because a 1 MiB chunk is # ~1.4 MiB of base64 and macOS's ARG_MAX is 1 MiB for args plus environment. post_chunk() { # + rogue_protection_current || return 1 _post_chunk_file="${1:-}" _post_offset="${2:-0}" _post_bytes="${3:-0}" @@ -784,6 +786,7 @@ post_chunk() { # debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -839,6 +842,12 @@ ship_log_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" + release_lock + return 0 + fi RUN_BYTES_SENT=0 _target_rotated=0 @@ -890,6 +899,9 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + . "$PLUGIN_ROOT/scripts/protection.sh" + rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } @@ -902,12 +914,12 @@ main() { exit 0 fi - STATE_DIR="$HOME/.rogue/ship" + STATE_DIR="${ROGUE_PROTECTION_STATE:-$HOME/.rogue}/ship" mkdir -p "$STATE_DIR" 2>/dev/null [ -d "$STATE_DIR" ] || { debug "cannot create $STATE_DIR"; exit 0; } TMP_DIR=$(mktemp -d "$STATE_DIR/.tmp.XXXXXX" 2>/dev/null) || TMP_DIR="" [ -n "$TMP_DIR" ] || { debug 'cannot create a temp dir'; exit 0; } - trap 'cleanup' EXIT INT TERM + trap 'cleanup; rogue_protection_leave' EXIT INT TERM # A redirect, not a pipe: a `while` on the right of a pipe runs in a subshell in # POSIX sh, and log paths can contain spaces, so read them line by line. diff --git a/scripts/sync-shared-scripts.sh b/scripts/sync-shared-scripts.sh index 45e23c9..0c3c4f7 100644 --- a/scripts/sync-shared-scripts.sh +++ b/scripts/sync-shared-scripts.sh @@ -35,6 +35,8 @@ SRC="$REPO/scripts/shared" # plugins/rogue keeps its own (it ranks CLAUDE_CODE_USER_EMAIL above git and # screens the Cowork sandbox identity). ROWS=( + "protection.sh|rogue codex cursor copilot antigravity kiro" + "protection.ps1|rogue codex cursor copilot antigravity kiro" "ship-logs.sh|rogue codex cursor copilot antigravity kiro" "ship-logs.ps1|rogue codex cursor copilot antigravity kiro" "beacon.sh|rogue codex cursor copilot antigravity kiro" diff --git a/tests/mock_server.py b/tests/mock_server.py index 432c5b3..9b27758 100644 --- a/tests/mock_server.py +++ b/tests/mock_server.py @@ -22,6 +22,10 @@ class Handler(http.server.BaseHTTPRequestHandler): def do_POST(self): length = int(self.headers.get("Content-Length", 0)) body_in = self.rfile.read(length) + if self.path.startswith("/api/v1/hooks/protection/"): + self.send_response(404) + self.end_headers() + return # Record headers + body so the test can inspect them. with open(HEADERS_PATH, "w") as f: json.dump({ diff --git a/tests/ship_probe.mjs b/tests/ship_probe.mjs index 0cc9a75..4c5210e 100644 --- a/tests/ship_probe.mjs +++ b/tests/ship_probe.mjs @@ -16,6 +16,7 @@ const cap = process.env.CAP || "."; const code = Number(process.env.FAKE_CODE || 200); globalThis.fetch = async (_url, opts) => { + if (String(_url).includes('/hooks/protection/')) return {status:404, ok:false}; let n = 0; while (fs.existsSync(path.join(cap, `body.${n}`))) n++; try { diff --git a/tests/test_heartbeat_sh.sh b/tests/test_heartbeat_sh.sh index 97948d3..a7c8bf2 100644 --- a/tests/test_heartbeat_sh.sh +++ b/tests/test_heartbeat_sh.sh @@ -36,13 +36,13 @@ mkdir -p "$TMPROOT/bin" "$ROOT/.claude-plugin" "$ROOT/scripts" "$HOME_SB" # absent. So a tree without it does not test a weaker throttle - it tests no throttle, # and every case below passes while asserting nothing. That is exactly what happened # the first time this suite met the extracted library. -cp "$REPO/plugins/rogue/scripts/heartbeat.sh" \ +cp "$REPO/plugins/rogue/scripts/protection.sh" "$REPO/plugins/rogue/scripts/heartbeat.sh" \ "$REPO/plugins/rogue/scripts/surface.sh" \ "$REPO/plugins/rogue/scripts/beacon.sh" \ "$REPO/plugins/rogue/scripts/env-file.sh" \ "$REPO/plugins/rogue/scripts/actor.sh" "$ROOT/scripts/" echo '{"version":"9.9.9"}' > "$ROOT/.claude-plugin/plugin.json" -printf '#!/bin/sh\necho POST >> "$SB_CALLS"\nexit 0\n' > "$TMPROOT/bin/curl" +printf '#!/bin/sh\ncase "$*" in *hooks/protection/*) exit 22 ;; esac\necho POST >> "$SB_CALLS"\nexit 0\n' > "$TMPROOT/bin/curl" chmod +x "$TMPROOT/bin/curl" echo 'export ROGUE_API_KEY=k' > "$HOME_SB/.rogue-env" @@ -222,11 +222,11 @@ echo "── the kiro heartbeat reports family kiro and the surface it was given KIRO_ROOT="$TMPROOT/kiro" KIRO_HOME="$TMPROOT/kiro-home" mkdir -p "$KIRO_ROOT/scripts" "$KIRO_HOME" "$TMPROOT/kiro-bin" -cp "$REPO/plugins/kiro/scripts/heartbeat.sh" "$REPO/plugins/kiro/scripts/beacon.sh" \ +cp "$REPO/plugins/kiro/scripts/protection.sh" "$REPO/plugins/kiro/scripts/heartbeat.sh" "$REPO/plugins/kiro/scripts/beacon.sh" \ "$REPO/plugins/kiro/scripts/actor.sh" "$REPO/plugins/kiro/scripts/install-id.sh" \ "$REPO/plugins/kiro/scripts/kiro-host.sh" "$REPO/plugins/kiro/scripts/env-file.sh" "$KIRO_ROOT/scripts/" echo '{"name":"rogue","version":"9.9.9"}' > "$KIRO_ROOT/plugin.json" -printf '#!/bin/sh\nprintf "%%s\\n" "$*" >> "$SB_CALLS"\nexit 0\n' > "$TMPROOT/kiro-bin/curl" +printf '#!/bin/sh\ncase "$*" in *hooks/protection/*) exit 22 ;; esac\nprintf "%%s\\n" "$*" >> "$SB_CALLS"\nexit 0\n' > "$TMPROOT/kiro-bin/curl" chmod +x "$TMPROOT/kiro-bin/curl" # kiro-cli 2.21.0's shape: `--version` prints "kiro-cli ", and # `settings chat.defaultAgent` prints the value (quoted, as the real CLI does) diff --git a/tests/test_protection.mjs b/tests/test_protection.mjs new file mode 100644 index 0000000..94e10cb --- /dev/null +++ b/tests/test_protection.mjs @@ -0,0 +1,219 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import {createHash} from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +const repo=path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const temp=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rogue-protection-test-'))); +const fixturePlugins=path.join(temp,'plugins'); +fs.cpSync(path.join(repo,'plugins'),fixturePlugins,{recursive:true}); +function isolateEnv(directory) { + for (const entry of fs.readdirSync(directory,{withFileTypes:true})) { + const file=path.join(directory,entry.name); + if (entry.isDirectory()) isolateEnv(file); + else if (entry.name.endsWith('.sh')) fs.writeFileSync(file,fs.readFileSync(file,'utf8').replaceAll('/etc/rogue/env',path.join(temp,'empty-env')).replaceAll('$HOME/.rogue-env',path.join(temp,'empty-env'))); + } +} +isolateEnv(fixturePlugins); +let revision=1, paused=true, available=true; +const requests=[]; +const server=http.createServer(async (req,res) => { + let body=''; for await (const chunk of req) body+=chunk; + requests.push({path:req.url, body, key:req.headers["x-rogue-api-key"], installationKey:req.headers["x-rogue-installation-key"]}); + if (!available) { res.statusCode=503; res.end('{}'); return; } + res.setHeader('content-type','application/json'); + if (req.url.endsWith('/enroll')) { res.end(JSON.stringify({apiKey:'installation_test_key', installation:{id:'test',type:'coding_agent',role:'coding'}})); return; } + if (req.url.endsWith('/state')) { + if (req.headers.accept === 'text/tab-separated-values') { res.setHeader('content-type','text/tab-separated-values'); res.end([1,revision,Number(paused),0,0,0,Math.floor(Date.now()/1000),revision,0].join('\t')); return; } + res.end(JSON.stringify({protocolVersion:1,revision,serverTime:new Date().toISOString(),aidr:{paused,expiresAt:null,revision},aispm:{paused:false,expiresAt:null,revision:0}})); return; + } + if (req.url.endsWith('/ack')) { res.end('{"success":true}'); return; } + res.end('{}'); +}); +await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); +const base=`http://127.0.0.1:${server.address().port}`; +function run(command,args,env, input) { + return new Promise((resolve,reject) => { + const child=spawn(command,args,{cwd:repo,env:{...process.env,...env},stdio:['pipe','pipe','pipe']}); + let out='',err=''; child.stdout.on('data',b=>out+=b); child.stderr.on('data',b=>err+=b); + if (input !== undefined) child.stdin.end(input); + const timeout=setTimeout(()=>{child.kill();reject(new Error(`Hook read stdin while paused: ${args[0]}`));},10000); + child.once('exit',code=>{clearTimeout(timeout);resolve({code,out,err});}); + }); +} +try { + await test('every Unix bridge exits before reading paused stdin or sending an activity payload', async () => { + for (const [plugin,slug,family] of [['rogue','claude','claude'],['codex','codex','openai'],['cursor','cursor','cursor'],['copilot','copilot','copilot'],['antigravity','antigravity','antigravity'],['kiro','kiro','kiro'],['gemini','gemini','gemini']]) { + const root=path.join(fixturePlugins,plugin); + const start=requests.length; + const env={ROGUE_API_KEY:`provision_${slug}`,ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,CLAUDE_PLUGIN_ROOT:root,CODEX_PLUGIN_ROOT:root,CURSOR_PLUGIN_ROOT:root,PLUGIN_ROOT:root,CLAUDE_CODE_ENTRYPOINT:'cli',ROGUE_DEBUG:'',ROGUE_LOG_DIR:temp}; + const event=({cursor:'beforeShellExecution',copilot:'preToolUse',antigravity:'PreInvocation',gemini:'BeforeTool'})[plugin] || 'PreToolUse'; + const result=await run(plugin==='gemini'?process.execPath:'bash',[path.join(root,'scripts',plugin==='gemini'?'hook.mjs':'hook.sh'),event,'kiro_cli'],env); + assert.equal(result.code,0,`${plugin}: ${result.err}`); + assert.deepEqual(JSON.parse(result.out || '{}'),plugin==='antigravity'?{decision:'allow'}:{},plugin); + const calls=requests.slice(start); + assert(calls.some(call=>call.path.endsWith('/state')),`${plugin} did not fetch its pause decision`); + assert(calls.every(call=>call.path.includes('/hooks/protection/')),`${family} sent an activity payload`); + } + }); + await test('resume discards queued log bytes and ships only fresh content', async () => { + const root=path.join(fixturePlugins,'codex'); + const directory=path.join(temp,fs.readdirSync(temp).find(name=>name.startsWith('codex-default-'))); + const log=path.join(directory,'codex.log'); + fs.writeFileSync(log, 'before\npaused-canary\n'); + revision=2; paused=false; + fs.writeFileSync(path.join(directory,'attempt'),'0'); + const env={ROGUE_API_KEY:'provision_codex',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,ROGUE_ACTOR_EMAIL:'qa@example.test',ROGUE_ACTOR_NAME:'QA',ROGUE_SHIP_MIN_INTERVAL:'0',ROGUE_DEBUG:'1'}; + const start=requests.length; + const args=[path.join(root,'scripts/ship-logs.sh'),root,'codex','1.0.0','openai']; + const resumed=await run('sh',args,env,''); + assert.equal(resumed.code,0,resumed.err); + assert(!requests.slice(start).some(call=>call.path.endsWith('/logs')), resumed.err); + fs.appendFileSync(log,'fresh-canary\n'); + const fresh=await run('sh',args,env,''); + assert.equal(fresh.code,0,fresh.err); + const uploads=requests.slice(start).filter(call=>call.path.endsWith('/logs')); + assert(uploads.length > 0, fresh.err); + for (const upload of uploads) { + assert.equal(upload.key,'installation_test_key'); + const content=Buffer.from(JSON.parse(upload.body).content_b64,'base64').toString(); + assert(!content.includes('paused-canary')); + } + assert(uploads.some(upload=>Buffer.from(JSON.parse(upload.body).content_b64,'base64').toString().includes('fresh-canary'))); + assert.equal(fs.readFileSync(log,'utf8'),'before\npaused-canary\nfresh-canary\n'); + revision=3; paused=true; + fs.writeFileSync(path.join(directory,'attempt'),'0'); + await run('bash',[path.join(root,'scripts/hook.sh'),'PreToolUse'],{...env,PLUGIN_ROOT:root,CODEX_PLUGIN_ROOT:root}); + }); + await test('Gemini resume drops queued logs and sends fresh logs under the installation key', async () => { + const root=path.join(fixturePlugins,'gemini'); + const directory=path.join(temp,`gemini-default-${createHash('sha256').update(`${base}\nprovision_gemini`).digest('hex')}`); + const log=path.join(directory,'gemini.log'); + fs.writeFileSync(log,'paused-node-canary\n'); + revision++; paused=false; + fs.writeFileSync(path.join(directory,'attempt'),'0'); + const env={ROGUE_DEBUG:'1',ROGUE_API_KEY:'provision_gemini',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,ROGUE_ACTOR_EMAIL:'qa@example.test',ROGUE_ACTOR_NAME:'QA',ROGUE_SHIP_MIN_INTERVAL:'0'}; + const args=[path.join(root,'scripts/ship-logs.mjs'),root,'gemini','1.0.0','gemini']; + const start=requests.length; + const output=await run(process.execPath,args,env,''); assert.equal(output.code,0,output.err); + assert(!requests.slice(start).some(call=>call.path.endsWith('/logs'))); + fs.appendFileSync(log,'fresh-node-canary\n'); + const freshOutput=await run(process.execPath,args,env,''); assert.equal(freshOutput.code,0,freshOutput.err); + const uploads=requests.slice(start).filter(call=>call.path.endsWith('/logs')); + assert(uploads.length>0, output.err + freshOutput.err + JSON.stringify({files:fs.readdirSync(directory),state:fs.readFileSync(path.join(directory,"state.json"),"utf8"),paths:requests.slice(start).map(call=>call.path)})); + assert(uploads.every(call=>call.key==='installation_test_key' && !Buffer.from(JSON.parse(call.body).content_b64,'base64').toString().includes('paused-node-canary'))); + assert(uploads.some(call=>Buffer.from(JSON.parse(call.body).content_b64,'base64').toString().includes('fresh-node-canary'))); + }); + await test('PowerShell gate persists the same pause contract', {skip:!process.env.ROGUE_TEST_PWSH}, async () => { + paused=true; revision++; + const helper=path.join(repo,'scripts/shared/protection.ps1').replaceAll("'","''"); + const probe=path.join(temp,'probe.ps1'); + fs.writeFileSync(probe, `$ErrorActionPreference='Stop'\n. '${helper}'\n$key=Initialize-RogueProtection -Key 'ps_test_key' -BaseUrl '${base}' -Slug 'ps-test' -Family 'claude'\nif (Enter-RogueProtection) { throw ('Paused work started; directory=' + $script:RPDirectory + '; saved=' + (Get-Content -LiteralPath ($script:RPDirectory + '/state.json') -Raw -ErrorAction SilentlyContinue)) }\n[Console]::Out.Write('paused')\n`); + const result=await run(process.env.ROGUE_TEST_PWSH,['-NoProfile','-File',probe],{ROGUE_PROTECTION_DIR:temp},''); + assert.equal(result.code,0,result.err + JSON.stringify(requests.slice(-5).map(r=>({path:r.path,body:r.body})))); assert.equal(result.out,'paused'); + }); + await test('Gemini cancels buffered stdin without sending paused activity', async () => { + available=true; paused=false; revision++; + const root=path.join(fixturePlugins,'gemini'); + const start=requests.length; + const child=spawn(process.execPath,[path.join(root,'scripts/hook.mjs'),'BeforeTool'],{cwd:repo,env:{...process.env,ROGUE_API_KEY:'buffered_gemini',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,ROGUE_DEBUG:''},stdio:['pipe','pipe','pipe']}); + let out=''; child.stdout.on('data',b=>out+=b); child.stderr.resume(); + const exit=new Promise(resolve=>child.once('exit',resolve)); + child.stdin.write(JSON.stringify({session_id:'buffered-canary',hook_event_name:'BeforeTool',tool_name:'run_shell_command',tool_input:{command:'paused-buffer-canary'}})); + let directory; + for(let attempt=0;attempt<100;attempt++) { + directory=fs.readdirSync(temp).map(name=>path.join(temp,name)).find(dir=>fs.existsSync(path.join(dir,`active.${child.pid}`))); + if(directory) break; + await new Promise(resolve=>setTimeout(resolve,25)); + } + assert(directory,'hook did not enter guarded input collection'); + paused=true; revision++; + fs.writeFileSync(path.join(directory,'state.json'),JSON.stringify({decision:{protocolVersion:1,revision,serverTime:new Date().toISOString(),aidr:{paused:true,expiresAt:null,revision},aispm:{paused:false,expiresAt:null,revision:0}},receivedAt:Date.now()})); + const timeout=setTimeout(()=>child.kill(),3000); + assert.equal(await exit,0); clearTimeout(timeout); + assert.deepEqual(JSON.parse(out || '{}'),{}); + assert(!requests.slice(start).some(call=>call.path.endsWith('/hooks/gemini'))); + }); + await test('Gemini keeps a saved pause when bookkeeping cannot be written',async()=>{ + const root=path.join(fixturePlugins,'gemini'); + const directory=path.join(temp,`gemini-default-${createHash('sha256').update(`${base}\nprovision_gemini`).digest('hex')}`); + const saved=JSON.parse(fs.readFileSync(path.join(directory,'state.json'),'utf8')); + saved.decision.aidr.paused=true; saved.decision.aidr.expiresAt=null; + fs.writeFileSync(path.join(directory,'state.json'),JSON.stringify(saved)); + fs.rmSync(path.join(directory,'used'),{force:true}); fs.mkdirSync(path.join(directory,'used')); + available=false; + const start=requests.length; + const result=await run(process.execPath,[path.join(root,'scripts/hook.mjs'),'BeforeTool'],{ROGUE_API_KEY:'provision_gemini',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp}); + assert.equal(result.code,0,result.err); assert.deepEqual(JSON.parse(result.out || '{}'),{}); + assert(!requests.slice(start).some(call=>call.path.endsWith('/hooks/gemini'))); + fs.rmdirSync(path.join(directory,'used')); available=true; + }); + await test('changed provisioning credentials restore the existing installation',async()=>{ + paused=true; revision++; + const root=path.join(fixturePlugins,'codex'); + const start=requests.length; + const result=await run('sh',[path.join(root,'scripts/hook.sh'),'PreToolUse'],{ROGUE_API_KEY:'rotated_codex',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,PLUGIN_ROOT:root}); + assert.equal(result.code,0,result.err); + assert(requests.slice(start).some(call=>call.path.endsWith('/enroll') && call.key==='rotated_codex' && call.installationKey==='installation_test_key')); + assert(!requests.slice(start).some(call=>call.path.endsWith('/hooks/openai'))); + }); + await test('rotation reuses live leases before acknowledging a pause',async()=>{ + available=true; paused=true; revision++; + const {Protection}=await import(path.join(fixturePlugins,'gemini/scripts/protection.mjs')); + const directory=path.join(temp,`gemini-default-${createHash('sha256').update(`${base}\nprovision_gemini`).digest('hex')}`); + fs.writeFileSync(path.join(directory,`active.${process.pid}`),'0'); + fs.writeFileSync(path.join(directory,'attempt'),'0'); + fs.rmSync(path.join(directory,'ack'),{force:true}); + const start=requests.length; + const client=await Protection.connect({ROGUE_API_KEY:'rotated_lease_gemini',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp}); + assert.equal(client.directory,directory); + assert(!requests.slice(start).some(call=>call.path.endsWith('/ack') && JSON.parse(call.body).status==='applied')); + fs.rmSync(path.join(directory,`active.${process.pid}`)); + await client.ack(); + assert(requests.slice(start).some(call=>call.path.endsWith('/ack') && JSON.parse(call.body).status==='applied')); + }); + await test('a failed state save blocks work and reports failure rather than applied',async()=>{ + available=true; paused=true; revision++; + const {Protection}=await import(path.join(fixturePlugins,'gemini/scripts/protection.mjs')); + const directory=path.join(temp,'persist-failure'); fs.mkdirSync(directory); + fs.mkdirSync(path.join(directory,'state.json')); + const client=new Protection(directory,base,'installation_test_key'); + const start=requests.length; + await client.refresh(); + assert.equal(client.current(),false); + const acks=requests.slice(start).filter(call=>call.path.endsWith('/ack')).map(call=>JSON.parse(call.body)); + assert(acks.some(ack=>ack.status==='failed' && ack.error==='state_persistence_failed')); + assert(!acks.some(ack=>ack.status==='applied')); + }); + await test('offline key rotation never falls back to unscoped collection',async()=>{ + available=false; + for (const [plugin,key] of [['codex','offline_rotated_codex'],['gemini','offline_rotated_gemini']]) { + const root=path.join(fixturePlugins,plugin); + const start=requests.length; + const result=await run(plugin==='gemini'?process.execPath:'sh',[path.join(root,'scripts',plugin==='gemini'?'hook.mjs':'hook.sh'),plugin==='gemini'?'BeforeTool':'PreToolUse'],{ROGUE_API_KEY:key,ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,PLUGIN_ROOT:root}); + assert.equal(result.code,0,result.err); + assert.deepEqual(JSON.parse(result.out || '{}'),{}); + assert(requests.slice(start).every(call=>call.path.includes('/hooks/protection/'))); + } + available=true; + }); + await test('a new shell invocation honors persisted pause while offline',async()=>{ + available=false; + const root=path.join(fixturePlugins,'codex'); + const env={ROGUE_API_KEY:'provision_codex',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,PLUGIN_ROOT:root,CODEX_PLUGIN_ROOT:root,ROGUE_LOG_DIR:temp}; + const result=await run('bash',[path.join(root,'scripts/hook.sh'),'PreToolUse'],env); + assert.equal(result.code,0); assert.deepEqual(JSON.parse(result.out),{}); + }); +} finally { + for (const dir of fs.readdirSync(temp)) { + for (const name of ['poll.pid','poll.lock/pid']) { + try { const pid=Number(fs.readFileSync(path.join(temp,dir,name),'utf8')); if (pid>0) process.kill(pid); } catch {} + } + } + server.closeAllConnections(); await new Promise(resolve=>server.close(resolve)); + fs.rmSync(temp,{recursive:true,force:true}); +} diff --git a/tests/test_ship_logs.sh b/tests/test_ship_logs.sh index 86969b9..73b782b 100644 --- a/tests/test_ship_logs.sh +++ b/tests/test_ship_logs.sh @@ -113,6 +113,10 @@ ship_as() { # [VAR=val …] export ROGUE_SHIP_MAX_BYTES='' ROGUE_SHIP_MAX_RUN_BYTES='' ROGUE_SHIP_MAX_LINE_BYTES='' for kv in "$@"; do export "${kv?}"; done _root="${SHIP_ROOT:-$REPO/plugins/$_p}" + if [ -n "${SHIP_ROOT:-}" ]; then + mkdir -p "$_root/scripts" + cp "$REPO/scripts/shared/protection.sh" "$_root/scripts/protection.sh" + fi if [ "$_s" = "-" ]; then "$SH" "$REPO/plugins/$_p/scripts/ship-logs.sh" else "$SH" "$REPO/plugins/$_p/scripts/ship-logs.sh" "$_root" "$_s" "$_v" "$_fam"; fi ) } From cc6dc13ef672665a0a07dcac5ce5f7e0c6ad2ae1 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Tue, 15 Sep 2026 00:36:09 +0300 Subject: [PATCH 02/14] fix(protection): cancel input and preserve enrollment retries (FIRE-2137) --- docs/protection-pause.md | 4 +- plugins/antigravity/scripts/hook.ps1 | 3 + plugins/antigravity/scripts/hook.sh | 6 +- plugins/antigravity/scripts/protection.ps1 | 45 ++++++++++-- plugins/antigravity/scripts/protection.sh | 42 +++++++++-- plugins/antigravity/scripts/ship-logs.ps1 | 39 +++++----- plugins/antigravity/scripts/ship-logs.sh | 21 +++--- plugins/codex/scripts/hook.ps1 | 5 +- plugins/codex/scripts/hook.sh | 6 +- plugins/codex/scripts/protection.ps1 | 45 ++++++++++-- plugins/codex/scripts/protection.sh | 42 +++++++++-- plugins/codex/scripts/ship-logs.ps1 | 39 +++++----- plugins/codex/scripts/ship-logs.sh | 21 +++--- plugins/copilot/scripts/hook.ps1 | 3 + plugins/copilot/scripts/hook.sh | 6 +- plugins/copilot/scripts/protection.ps1 | 45 ++++++++++-- plugins/copilot/scripts/protection.sh | 42 +++++++++-- plugins/copilot/scripts/ship-logs.ps1 | 39 +++++----- plugins/copilot/scripts/ship-logs.sh | 21 +++--- plugins/cursor/scripts/hook.ps1 | 5 +- plugins/cursor/scripts/hook.sh | 5 +- plugins/cursor/scripts/protection.ps1 | 45 ++++++++++-- plugins/cursor/scripts/protection.sh | 42 +++++++++-- plugins/cursor/scripts/ship-logs.ps1 | 39 +++++----- plugins/cursor/scripts/ship-logs.sh | 21 +++--- plugins/gemini/scripts/protection.mjs | 18 +++-- plugins/gemini/scripts/ship-logs.mjs | 14 ++-- plugins/kiro/scripts/hook.ps1 | 11 +-- plugins/kiro/scripts/hook.sh | 10 +-- plugins/kiro/scripts/protection.ps1 | 45 ++++++++++-- plugins/kiro/scripts/protection.sh | 42 +++++++++-- plugins/kiro/scripts/ship-logs.ps1 | 39 +++++----- plugins/kiro/scripts/ship-logs.sh | 21 +++--- plugins/rogue/scripts/hook.ps1 | 5 +- plugins/rogue/scripts/hook.sh | 6 +- plugins/rogue/scripts/protection.ps1 | 45 ++++++++++-- plugins/rogue/scripts/protection.sh | 42 +++++++++-- plugins/rogue/scripts/ship-logs.ps1 | 39 +++++----- plugins/rogue/scripts/ship-logs.sh | 21 +++--- scripts/shared/protection.ps1 | 45 ++++++++++-- scripts/shared/protection.sh | 42 +++++++++-- scripts/shared/ship-logs.ps1 | 39 +++++----- scripts/shared/ship-logs.sh | 21 +++--- tests/test_hook_mjs.mjs | 2 + tests/test_hook_sh_kiro.sh | 5 +- tests/test_protection.mjs | 83 ++++++++++++++++++++++ tests/test_ship_logs.sh | 1 + 47 files changed, 897 insertions(+), 330 deletions(-) diff --git a/docs/protection-pause.md b/docs/protection-pause.md index 5120645..a806f11 100644 --- a/docs/protection-pause.md +++ b/docs/protection-pause.md @@ -6,11 +6,11 @@ Protocol 1 is included in the source versions Claude 1.0.30, Codex 1.0.4, Cursor Choose a reason and a duration of one hour, four hours, 24 hours or until resumed. Any organization admin may resume or change duration without another reason. AISPM discovery is controlled on the endpoint that runs scans. An independent API-key pause still applies after the installation is resumed. -The bridge exchanges its provisioning key for an installation credential at `/api/v1/hooks/protection/enroll`. Identity is server-assigned and persists in the user's private protection directory. A hostname or email cannot claim an existing identity. Related host variants that use the same bridge configuration share that installation. Independent configurations remain separate. After provisioning-key rotation, the bridge restores its existing authenticated installation and reuses its saved decision and active-work tracking. An unavailable server postpones restoration without collecting activity under an unscoped key. +The bridge exchanges its provisioning key for an installation credential at `/api/v1/hooks/protection/enroll`. A private random enrollment nonce makes retries return the same installation after a lost response. Initial enrollment outages stop collection until registration succeeds; a confirmed unsupported legacy server retains the previous bridge behavior. Identity is server-assigned and persists in the user's private protection directory. A hostname or email cannot claim an existing identity. Related host variants that use the same bridge configuration share that installation. Independent configurations remain separate. After provisioning-key rotation, the bridge restores its existing authenticated installation and reuses its saved decision and active-work tracking. An unavailable server postpones restoration without collecting activity under an unscoped key. The bridge fetches the current decision before collecting hook input and keeps a short-lived background poller while the installation is in use. Polling runs every 15 seconds; the delivery target is 60 seconds online. The server displays applied only after a current acknowledgement. Pending, offline, unsupported or failed are not proof that local work stopped. A state-storage failure blocks the current hook and reports a failure acknowledgement. A saved finite pause expires offline; an indefinite pause remains until confirmed resume. -Registered installations store their hook logs inside their private protection directory and ship them with their own credential. The scoped log path takes precedence over a shared custom log destination. On resume, the shipper advances its own offsets past buffered content without modifying the source log. It sends only fresh subsequent entries. An endpoint's mixed-log diagnostic uploader cannot attribute these installations and must not bypass their pauses. +Registered installations store their hook logs inside their private protection directory and ship them with their own credential. The scoped log path takes precedence over a shared custom log destination. On resume, the shipper saves its discard offset and protection revision together, so a failed write cannot enable replay. It advances past buffered content without modifying the source log. It sends only fresh subsequent entries. An endpoint's mixed-log diagnostic uploader cannot attribute these installations and must not bypass their pauses. Older plugins and native HTTP hooks without a local gate have limited coverage. Their API-key pause can suppress server work but cannot stop local collection or CPU use. Use an updated command bridge for local suspension. Existing activity already committed before a pause remains historical. diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index af01bf1..894aa2e 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -963,9 +963,11 @@ function Invoke-Main { Assert-ApiKey # exits before stdin is read when there is no key Resolve-Url Resolve-Actor +if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') $script:apiKey = Initialize-RogueProtection -Key $script:apiKey -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug 'antigravity' -Family 'antigravity' if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } +try { Read-Payload # Immediately after the payload, and BEFORE anything that logs or sends - the @@ -993,6 +995,7 @@ if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{"decision":"allow"}') # This script MUST always exit 0: a block is carried in the relayed JSON body # on stdout, never in the exit code. exit 0 +} finally { Leave-RogueProtection } } # Test seam: dot-sourcing with ROGUE_PS_LIB_ONLY=1 loads the functions above diff --git a/plugins/antigravity/scripts/hook.sh b/plugins/antigravity/scripts/hook.sh index 32398ed..239a1ed 100755 --- a/plugins/antigravity/scripts/hook.sh +++ b/plugins/antigravity/scripts/hook.sh @@ -121,7 +121,6 @@ load_env() { # periodic job on purpose: an UNCONFIGURED install writes a line per event and # never runs anything else, so a cap enforced anywhere else would not hold. rotate_log() { - if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" @@ -659,7 +658,8 @@ load_install_id() { # Buffer stdin so we can enrich it (PreInvocation/PostInvocation/Stop) before # POSTing. read_body() { - BODY="$(cat)" + BODY="$(rogue_protection_read_input)" +rogue_protection_current || { printf '%s' '{"decision":"allow"}'; exit 0; } } # Heartbeat, fired detached so the hook itself returns immediately regardless of @@ -786,6 +786,7 @@ post_and_relay() { fi fi +rogue_protection_current || { printf '%s' '{"decision":"allow"}'; exit 0; } _raw=$(printf '%s' "$BODY" | curl -sS -X POST "$URL" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -828,6 +829,7 @@ main() { stand_down_under_git_bash locate_plugin_root load_env # sources the env files, then every default derived from them +[ -r "${PLUGIN_ROOT}/scripts/protection.sh" ] || { printf '%s' '{"decision":"allow"}'; exit 0; } . "${PLUGIN_ROOT}/scripts/protection.sh" rogue_protection_init antigravity antigravity "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" rogue_protection_enter || { printf '%s' '{"decision":"allow"}'; exit 0; } diff --git a/plugins/antigravity/scripts/protection.ps1 b/plugins/antigravity/scripts/protection.ps1 index 002d7d2..a8b9a1c 100644 --- a/plugins/antigravity/scripts/protection.ps1 +++ b/plugins/antigravity/scripts/protection.ps1 @@ -7,11 +7,21 @@ $script:RPKey=$null function Write-RogueProtectionFile([string]$Path, [string]$Value) { $temp = "$Path.$PID.tmp" [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) - Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temp, $Path, [NullString]::Value) } + else { [IO.File]::Move($temp, $Path) } +} +function Test-RogueProtectionDecision($Decision) { + if ($null -eq $Decision -or $Decision.protocolVersion -ne 1 -or $Decision.revision -isnot [long] -and $Decision.revision -isnot [int] -or $Decision.revision -lt 0) { return $false } + foreach ($cap in 'aidr','aispm') { + $value=$Decision.$cap + if ($null -eq $value -or $value.paused -isnot [bool] -or ($value.revision -isnot [long] -and $value.revision -isnot [int]) -or $value.revision -lt 0) { return $false } + } + return $true } function Get-RogueProtectionState { try { $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + if (-not (Test-RogueProtectionDecision $value.decision)) { return $null } $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) foreach ($cap in 'aidr','aispm') { if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } @@ -51,7 +61,7 @@ function Update-RogueProtection { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState - if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue @@ -102,19 +112,24 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue + if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } + $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } - } catch { } finally { $lock.Dispose() } + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + } finally { $lock.Dispose() } } - if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) @@ -160,9 +175,25 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } +function Leave-RogueProtection { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck +} function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } - if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + if ($script:RPDirectory) { + try { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + return $false + } + } return Test-RogueProtectionCurrent } if ($Poll) { diff --git a/plugins/antigravity/scripts/protection.sh b/plugins/antigravity/scripts/protection.sh index 45b6348..36946d7 100644 --- a/plugins/antigravity/scripts/protection.sh +++ b/plugins/antigravity/scripts/protection.sh @@ -121,16 +121,22 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" - _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE @@ -146,9 +152,37 @@ rogue_protection_init() { rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } +rogue_protection_fail() { + RP_PERSISTENCE_FAILED=1 + rogue_protection_load || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true +} +rogue_protection_read_input() ( + rogue_protection_current || exit 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || { cat; exit; } + umask 077 + _rp_input=$(mktemp "$ROGUE_PROTECTION_STATE/input.XXXXXX") || exit 1 + exec 3<&0 + cat <&3 > "$_rp_input" & + _rp_reader=$! + ( + while kill -0 "$_rp_reader" 2>/dev/null; do + if ! rogue_protection_current || ! kill -0 "$$" 2>/dev/null; then kill "$_rp_reader" 2>/dev/null; exit; fi + sleep 0.2 + done + ) >&2 & + _rp_watch=$! + trap 'kill "$_rp_reader" "$_rp_watch" 2>/dev/null; rm -f "$_rp_input"' EXIT + wait "$_rp_reader" || exit 1 + kill "$_rp_watch" 2>/dev/null + rogue_protection_current || exit 1 + cat "$_rp_input" +) rogue_protection_enter() { rogue_protection_current || return 1 - [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && ! printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$"; then rogue_protection_fail; return 1; fi rogue_protection_current } if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then diff --git a/plugins/antigravity/scripts/ship-logs.ps1 b/plugins/antigravity/scripts/ship-logs.ps1 index dffeb83..c7d3b44 100644 --- a/plugins/antigravity/scripts/ship-logs.ps1 +++ b/plugins/antigravity/scripts/ship-logs.ps1 @@ -92,8 +92,8 @@ $script:targetFamily = '' $script:stateKey = '' $script:offset = 0 $script:stateHead = '' -$script:stateSize = 0 -$script:statePath = '' +$script:stateSize = 0; $script:stateRevision = '' +$script:statePath = ''; $script:stateRevision = '' $script:advanceBytes = 0 $script:runBytesSent = 0 $script:lineLength = 0 @@ -604,7 +604,7 @@ function Unlock-StateKey { # ── stage 7: state ───────────────────────────────────────────────────────── function Read-ShipState { param([string]$Key, [string]$NormalizedPath) - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = '' + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = ''; $script:stateRevision = '' $stateFile = Join-Path $script:stateDir "$Key.state" if (-not (Test-Path -LiteralPath $stateFile)) { return } try { @@ -625,6 +625,8 @@ function Read-ShipState { } elseif ($line -match '^size=(.*)$') { $rawSize = $Matches[1] if ($rawSize -match '^[0-9]+$') { $script:stateSize = [int64]$rawSize } else { $script:stateSize = 0 } + } elseif ($line -match '^revision=(.*)$') { + $script:stateRevision = $Matches[1] } elseif ($line -match '^path=(.*)$') { $script:statePath = $Matches[1] } @@ -635,27 +637,18 @@ function Read-ShipState { # shipper at a different file holding the previous file's offset. if ($script:statePath -and $script:statePath -ne $NormalizedPath) { Write-ShipDebug "state path mismatch ($($script:statePath) != $NormalizedPath) -> treating state as absent" - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0 + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:stateRevision = '' } } -# Write-to-temp-then-move, so a crash mid-write cannot leave a half-written offset. -# The temp sits in the SAME directory as the destination. The destination is removed -# first: `Move-Item -Force` onto an existing file is not reliable on Windows -# PowerShell 5.1, and under -ErrorAction SilentlyContinue a failure there would -# silently freeze the offset forever. +# Commit the discard offset and its pause revision in one file replacement. function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) - try { - $destination = Join-Path $script:stateDir "$Key.state" - $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [System.IO.File]::WriteAllText( - $tempFile, - "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`n", - (New-Object System.Text.UTF8Encoding($false))) - Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tempFile -Destination $destination -Force -ErrorAction SilentlyContinue - } catch {} + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -872,9 +865,8 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path - if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and $script:stateRevision -ne [string]$script:RPRevision) { Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath - Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) return } $script:runBytesSent = 0 @@ -931,10 +923,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } + try { Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -956,6 +950,7 @@ function Invoke-Main { Ship-LogFile $target } exit 0 + } finally { Leave-RogueProtection } } # The ROGUE_PS_LIB_ONLY seam: load the helpers WITHOUT running the shipper, so @@ -963,4 +958,4 @@ function Invoke-Main { # main body stands down). Every pure helper is defined ABOVE this line. if ($env:ROGUE_PS_LIB_ONLY) { return } -Invoke-Main +try { Invoke-Main } catch { exit 0 } diff --git a/plugins/antigravity/scripts/ship-logs.sh b/plugins/antigravity/scripts/ship-logs.sh index f95e7a8..4c0471f 100644 --- a/plugins/antigravity/scripts/ship-logs.sh +++ b/plugins/antigravity/scripts/ship-logs.sh @@ -99,7 +99,7 @@ MAX_RUN_BYTES=10485760 MAX_LINE_BYTES=4194304 # per-target working state TARGET_BASENAME=""; TARGET_FAMILY=""; STATE_KEY="" -OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" +OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" ADVANCE_BYTES=0; RUN_BYTES_SENT=0 LINE_LENGTH=0; LINE_SEARCH_HIT_EOF=0 @@ -536,7 +536,7 @@ cleanup() { # ── stage 7: state ───────────────────────────────────────────────────────── read_state() { # - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" _state_file="$STATE_DIR/${1:-}.state" [ -r "$_state_file" ] || return 0 while IFS= read -r _state_line; do @@ -550,6 +550,7 @@ read_state() { # _state_value="${_state_line#size=}" case "$_state_value" in ''|*[!0-9]*) _state_value=0 ;; esac STATE_SIZE="$_state_value" ;; + revision=*) STATE_REVISION="${_state_line#revision=}" ;; path=*) STATE_PATH="${_state_line#path=}" ;; esac done < "$_state_file" @@ -560,7 +561,7 @@ read_state() { # # directly is cheaper and clearer. if [ -n "$STATE_PATH" ] && [ "$STATE_PATH" != "${2:-}" ]; then debug "state path mismatch ($STATE_PATH != ${2:-}) -> treating state as absent" - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0 + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_REVISION="" fi return 0 } @@ -569,10 +570,10 @@ read_state() { # # The temp lives in the SAME directory as the destination, or the mv is not atomic. write_state() { # _state_tmp_file="$STATE_DIR/.state-tmp-$$" - printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\n' \ - "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" > "$_state_tmp_file" 2>/dev/null || return 0 + printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\nrevision=%s\n' \ + "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" "${ROGUE_PROTECTION_REVISION:-}" > "$_state_tmp_file" 2>/dev/null || return 1 + [ ! -d "$STATE_DIR/${1:-}.state" ] || return 1 mv -f "$_state_tmp_file" "$STATE_DIR/${1:-}.state" 2>/dev/null - return 0 } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -784,6 +785,7 @@ post_chunk() { # printf '"}' } > "$_post_body_file" 2>/dev/null debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" + rogue_protection_current || return 1 _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -820,7 +822,7 @@ drain_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") - if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" - printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" release_lock return 0 fi @@ -899,9 +900,11 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" rogue_protection_enter || exit 0 + trap 'rogue_protection_leave' EXIT resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/codex/scripts/hook.ps1 b/plugins/codex/scripts/hook.ps1 index f7c4e66..c4cc320 100644 --- a/plugins/codex/scripts/hook.ps1 +++ b/plugins/codex/scripts/hook.ps1 @@ -332,9 +332,11 @@ if ($installError.Count) { Log "error=install-id $($installError -join ',')" } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── +if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { [Console]::Out.Write('{}'); exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') -$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'codex' -Family 'openai' +$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'codex' -Family 'openai' -Version $pluginVersion if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } +try { $payload = Read-RogueProtectionInput if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } @@ -376,3 +378,4 @@ Log "raw=$(Sanitize $respHead)" if (-not $resp) { Write-Raw '{}'; exit 0 } Write-Raw $resp exit 0 +} finally { Leave-RogueProtection } diff --git a/plugins/codex/scripts/hook.sh b/plugins/codex/scripts/hook.sh index 919333b..c76f176 100755 --- a/plugins/codex/scripts/hook.sh +++ b/plugins/codex/scripts/hook.sh @@ -53,7 +53,6 @@ _lcap="$ROGUE_LOG_MAX_BYTES" while [ "${_lcap#0}" != "$_lcap" ]; do _lcap="${_lcap#0}"; done if [ "${#_lcap}" -gt 18 ]; then ROGUE_LOG_MAX_BYTES=10485760; fi rotate_log() { - if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" @@ -97,6 +96,7 @@ if [ -r "${PLUGIN_ROOT}/scripts/surface.sh" ]; then SURFACE=$(codex_surface_slug 2>/dev/null) fi +[ -r "${PLUGIN_ROOT}/scripts/protection.sh" ] || { printf '%s' '{}'; exit 0; } . "${PLUGIN_ROOT}/scripts/protection.sh" rogue_protection_init codex openai "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" rogue_protection_enter || { printf '%s' '{}'; exit 0; } @@ -154,7 +154,9 @@ URL="${ROGUE_API_URL:-${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks # Capture body + HTTP status. -w appends a final line ""; on any curl/transport # failure curl exits non-zero and the code is 000. We relay the body ONLY on a clean # HTTP 200 so an error page (401/404/500) is never handed to Codex as a hook decision. -RAW=$(curl -sS -X POST "$URL" \ +BODY="$(rogue_protection_read_input)" +rogue_protection_current || { printf '%s' '{}'; exit 0; } +RAW=$(printf '%s' "$BODY" | curl -sS -X POST "$URL" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H "x-rogue-event: $EVENT" \ diff --git a/plugins/codex/scripts/protection.ps1 b/plugins/codex/scripts/protection.ps1 index 002d7d2..a8b9a1c 100644 --- a/plugins/codex/scripts/protection.ps1 +++ b/plugins/codex/scripts/protection.ps1 @@ -7,11 +7,21 @@ $script:RPKey=$null function Write-RogueProtectionFile([string]$Path, [string]$Value) { $temp = "$Path.$PID.tmp" [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) - Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temp, $Path, [NullString]::Value) } + else { [IO.File]::Move($temp, $Path) } +} +function Test-RogueProtectionDecision($Decision) { + if ($null -eq $Decision -or $Decision.protocolVersion -ne 1 -or $Decision.revision -isnot [long] -and $Decision.revision -isnot [int] -or $Decision.revision -lt 0) { return $false } + foreach ($cap in 'aidr','aispm') { + $value=$Decision.$cap + if ($null -eq $value -or $value.paused -isnot [bool] -or ($value.revision -isnot [long] -and $value.revision -isnot [int]) -or $value.revision -lt 0) { return $false } + } + return $true } function Get-RogueProtectionState { try { $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + if (-not (Test-RogueProtectionDecision $value.decision)) { return $null } $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) foreach ($cap in 'aidr','aispm') { if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } @@ -51,7 +61,7 @@ function Update-RogueProtection { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState - if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue @@ -102,19 +112,24 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue + if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } + $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } - } catch { } finally { $lock.Dispose() } + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + } finally { $lock.Dispose() } } - if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) @@ -160,9 +175,25 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } +function Leave-RogueProtection { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck +} function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } - if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + if ($script:RPDirectory) { + try { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + return $false + } + } return Test-RogueProtectionCurrent } if ($Poll) { diff --git a/plugins/codex/scripts/protection.sh b/plugins/codex/scripts/protection.sh index 45b6348..36946d7 100644 --- a/plugins/codex/scripts/protection.sh +++ b/plugins/codex/scripts/protection.sh @@ -121,16 +121,22 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" - _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE @@ -146,9 +152,37 @@ rogue_protection_init() { rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } +rogue_protection_fail() { + RP_PERSISTENCE_FAILED=1 + rogue_protection_load || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true +} +rogue_protection_read_input() ( + rogue_protection_current || exit 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || { cat; exit; } + umask 077 + _rp_input=$(mktemp "$ROGUE_PROTECTION_STATE/input.XXXXXX") || exit 1 + exec 3<&0 + cat <&3 > "$_rp_input" & + _rp_reader=$! + ( + while kill -0 "$_rp_reader" 2>/dev/null; do + if ! rogue_protection_current || ! kill -0 "$$" 2>/dev/null; then kill "$_rp_reader" 2>/dev/null; exit; fi + sleep 0.2 + done + ) >&2 & + _rp_watch=$! + trap 'kill "$_rp_reader" "$_rp_watch" 2>/dev/null; rm -f "$_rp_input"' EXIT + wait "$_rp_reader" || exit 1 + kill "$_rp_watch" 2>/dev/null + rogue_protection_current || exit 1 + cat "$_rp_input" +) rogue_protection_enter() { rogue_protection_current || return 1 - [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && ! printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$"; then rogue_protection_fail; return 1; fi rogue_protection_current } if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then diff --git a/plugins/codex/scripts/ship-logs.ps1 b/plugins/codex/scripts/ship-logs.ps1 index dffeb83..c7d3b44 100644 --- a/plugins/codex/scripts/ship-logs.ps1 +++ b/plugins/codex/scripts/ship-logs.ps1 @@ -92,8 +92,8 @@ $script:targetFamily = '' $script:stateKey = '' $script:offset = 0 $script:stateHead = '' -$script:stateSize = 0 -$script:statePath = '' +$script:stateSize = 0; $script:stateRevision = '' +$script:statePath = ''; $script:stateRevision = '' $script:advanceBytes = 0 $script:runBytesSent = 0 $script:lineLength = 0 @@ -604,7 +604,7 @@ function Unlock-StateKey { # ── stage 7: state ───────────────────────────────────────────────────────── function Read-ShipState { param([string]$Key, [string]$NormalizedPath) - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = '' + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = ''; $script:stateRevision = '' $stateFile = Join-Path $script:stateDir "$Key.state" if (-not (Test-Path -LiteralPath $stateFile)) { return } try { @@ -625,6 +625,8 @@ function Read-ShipState { } elseif ($line -match '^size=(.*)$') { $rawSize = $Matches[1] if ($rawSize -match '^[0-9]+$') { $script:stateSize = [int64]$rawSize } else { $script:stateSize = 0 } + } elseif ($line -match '^revision=(.*)$') { + $script:stateRevision = $Matches[1] } elseif ($line -match '^path=(.*)$') { $script:statePath = $Matches[1] } @@ -635,27 +637,18 @@ function Read-ShipState { # shipper at a different file holding the previous file's offset. if ($script:statePath -and $script:statePath -ne $NormalizedPath) { Write-ShipDebug "state path mismatch ($($script:statePath) != $NormalizedPath) -> treating state as absent" - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0 + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:stateRevision = '' } } -# Write-to-temp-then-move, so a crash mid-write cannot leave a half-written offset. -# The temp sits in the SAME directory as the destination. The destination is removed -# first: `Move-Item -Force` onto an existing file is not reliable on Windows -# PowerShell 5.1, and under -ErrorAction SilentlyContinue a failure there would -# silently freeze the offset forever. +# Commit the discard offset and its pause revision in one file replacement. function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) - try { - $destination = Join-Path $script:stateDir "$Key.state" - $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [System.IO.File]::WriteAllText( - $tempFile, - "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`n", - (New-Object System.Text.UTF8Encoding($false))) - Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tempFile -Destination $destination -Force -ErrorAction SilentlyContinue - } catch {} + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -872,9 +865,8 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path - if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and $script:stateRevision -ne [string]$script:RPRevision) { Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath - Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) return } $script:runBytesSent = 0 @@ -931,10 +923,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } + try { Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -956,6 +950,7 @@ function Invoke-Main { Ship-LogFile $target } exit 0 + } finally { Leave-RogueProtection } } # The ROGUE_PS_LIB_ONLY seam: load the helpers WITHOUT running the shipper, so @@ -963,4 +958,4 @@ function Invoke-Main { # main body stands down). Every pure helper is defined ABOVE this line. if ($env:ROGUE_PS_LIB_ONLY) { return } -Invoke-Main +try { Invoke-Main } catch { exit 0 } diff --git a/plugins/codex/scripts/ship-logs.sh b/plugins/codex/scripts/ship-logs.sh index f95e7a8..4c0471f 100644 --- a/plugins/codex/scripts/ship-logs.sh +++ b/plugins/codex/scripts/ship-logs.sh @@ -99,7 +99,7 @@ MAX_RUN_BYTES=10485760 MAX_LINE_BYTES=4194304 # per-target working state TARGET_BASENAME=""; TARGET_FAMILY=""; STATE_KEY="" -OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" +OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" ADVANCE_BYTES=0; RUN_BYTES_SENT=0 LINE_LENGTH=0; LINE_SEARCH_HIT_EOF=0 @@ -536,7 +536,7 @@ cleanup() { # ── stage 7: state ───────────────────────────────────────────────────────── read_state() { # - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" _state_file="$STATE_DIR/${1:-}.state" [ -r "$_state_file" ] || return 0 while IFS= read -r _state_line; do @@ -550,6 +550,7 @@ read_state() { # _state_value="${_state_line#size=}" case "$_state_value" in ''|*[!0-9]*) _state_value=0 ;; esac STATE_SIZE="$_state_value" ;; + revision=*) STATE_REVISION="${_state_line#revision=}" ;; path=*) STATE_PATH="${_state_line#path=}" ;; esac done < "$_state_file" @@ -560,7 +561,7 @@ read_state() { # # directly is cheaper and clearer. if [ -n "$STATE_PATH" ] && [ "$STATE_PATH" != "${2:-}" ]; then debug "state path mismatch ($STATE_PATH != ${2:-}) -> treating state as absent" - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0 + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_REVISION="" fi return 0 } @@ -569,10 +570,10 @@ read_state() { # # The temp lives in the SAME directory as the destination, or the mv is not atomic. write_state() { # _state_tmp_file="$STATE_DIR/.state-tmp-$$" - printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\n' \ - "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" > "$_state_tmp_file" 2>/dev/null || return 0 + printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\nrevision=%s\n' \ + "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" "${ROGUE_PROTECTION_REVISION:-}" > "$_state_tmp_file" 2>/dev/null || return 1 + [ ! -d "$STATE_DIR/${1:-}.state" ] || return 1 mv -f "$_state_tmp_file" "$STATE_DIR/${1:-}.state" 2>/dev/null - return 0 } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -784,6 +785,7 @@ post_chunk() { # printf '"}' } > "$_post_body_file" 2>/dev/null debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" + rogue_protection_current || return 1 _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -820,7 +822,7 @@ drain_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") - if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" - printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" release_lock return 0 fi @@ -899,9 +900,11 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" rogue_protection_enter || exit 0 + trap 'rogue_protection_leave' EXIT resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index beb5d91..15dae8f 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -348,9 +348,11 @@ if (-not $actorName) { $actorName = 'unknown' } if (-not $actorEmail) { $actorEmail = 'unknown' } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── +if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { [Console]::Out.Write('{}'); exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') $apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'copilot' -Family 'copilot' if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } +try { $payload = Read-RogueProtectionInput if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } @@ -668,3 +670,4 @@ if ($EventName -eq 'userPromptSubmitted' -and Write-Raw $resp exit 0 +} finally { Leave-RogueProtection } diff --git a/plugins/copilot/scripts/hook.sh b/plugins/copilot/scripts/hook.sh index 5e85f47..eb98843 100755 --- a/plugins/copilot/scripts/hook.sh +++ b/plugins/copilot/scripts/hook.sh @@ -81,7 +81,6 @@ if [ "${#_lcap}" -gt 18 ]; then ROGUE_LOG_MAX_BYTES=10485760; fi # NOTE: `_lsz` is not function-local (POSIX sh has no `local`) but is used # NOWHERE else in this file — unlike `_p`/`_n`, which are shared (see below). rotate_log() { - if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" @@ -367,6 +366,7 @@ reattribute_subagent() { # Not configured: emit the SessionStart hint (so the user knows to run setup) or a # clean allow for every other event. Never POST without a key. +[ -r "${PLUGIN_ROOT}/scripts/protection.sh" ] || { printf '%s' '{}'; exit 0; } . "${PLUGIN_ROOT}/scripts/protection.sh" rogue_protection_init copilot copilot "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" rogue_protection_enter || { printf '%s' '{}'; exit 0; } @@ -395,7 +395,8 @@ fi URL="${ROGUE_API_URL:-${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/copilot}" # Buffer stdin so we can enrich it (agentStop/subagentStop) before POSTing. -BODY="$(cat)" +BODY="$(rogue_protection_read_input)" +rogue_protection_current || { printf '%s' '{}'; exit 0; } # Re-attribute a subagent's event to its parent session BEFORE any tail # augmentation (a subagent agentStop has no transcriptPath, so augment no-ops). reattribute_subagent @@ -466,6 +467,7 @@ case "$SUBAGENT_ID" in ;; esac +rogue_protection_current || { printf '%s' '{}'; exit 0; } RAW=$(printf '%s' "$BODY" | curl -sS -X POST "$URL" \ "$@" \ -H 'Content-Type: application/json' \ diff --git a/plugins/copilot/scripts/protection.ps1 b/plugins/copilot/scripts/protection.ps1 index 002d7d2..a8b9a1c 100644 --- a/plugins/copilot/scripts/protection.ps1 +++ b/plugins/copilot/scripts/protection.ps1 @@ -7,11 +7,21 @@ $script:RPKey=$null function Write-RogueProtectionFile([string]$Path, [string]$Value) { $temp = "$Path.$PID.tmp" [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) - Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temp, $Path, [NullString]::Value) } + else { [IO.File]::Move($temp, $Path) } +} +function Test-RogueProtectionDecision($Decision) { + if ($null -eq $Decision -or $Decision.protocolVersion -ne 1 -or $Decision.revision -isnot [long] -and $Decision.revision -isnot [int] -or $Decision.revision -lt 0) { return $false } + foreach ($cap in 'aidr','aispm') { + $value=$Decision.$cap + if ($null -eq $value -or $value.paused -isnot [bool] -or ($value.revision -isnot [long] -and $value.revision -isnot [int]) -or $value.revision -lt 0) { return $false } + } + return $true } function Get-RogueProtectionState { try { $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + if (-not (Test-RogueProtectionDecision $value.decision)) { return $null } $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) foreach ($cap in 'aidr','aispm') { if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } @@ -51,7 +61,7 @@ function Update-RogueProtection { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState - if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue @@ -102,19 +112,24 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue + if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } + $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } - } catch { } finally { $lock.Dispose() } + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + } finally { $lock.Dispose() } } - if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) @@ -160,9 +175,25 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } +function Leave-RogueProtection { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck +} function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } - if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + if ($script:RPDirectory) { + try { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + return $false + } + } return Test-RogueProtectionCurrent } if ($Poll) { diff --git a/plugins/copilot/scripts/protection.sh b/plugins/copilot/scripts/protection.sh index 45b6348..36946d7 100644 --- a/plugins/copilot/scripts/protection.sh +++ b/plugins/copilot/scripts/protection.sh @@ -121,16 +121,22 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" - _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE @@ -146,9 +152,37 @@ rogue_protection_init() { rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } +rogue_protection_fail() { + RP_PERSISTENCE_FAILED=1 + rogue_protection_load || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true +} +rogue_protection_read_input() ( + rogue_protection_current || exit 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || { cat; exit; } + umask 077 + _rp_input=$(mktemp "$ROGUE_PROTECTION_STATE/input.XXXXXX") || exit 1 + exec 3<&0 + cat <&3 > "$_rp_input" & + _rp_reader=$! + ( + while kill -0 "$_rp_reader" 2>/dev/null; do + if ! rogue_protection_current || ! kill -0 "$$" 2>/dev/null; then kill "$_rp_reader" 2>/dev/null; exit; fi + sleep 0.2 + done + ) >&2 & + _rp_watch=$! + trap 'kill "$_rp_reader" "$_rp_watch" 2>/dev/null; rm -f "$_rp_input"' EXIT + wait "$_rp_reader" || exit 1 + kill "$_rp_watch" 2>/dev/null + rogue_protection_current || exit 1 + cat "$_rp_input" +) rogue_protection_enter() { rogue_protection_current || return 1 - [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && ! printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$"; then rogue_protection_fail; return 1; fi rogue_protection_current } if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then diff --git a/plugins/copilot/scripts/ship-logs.ps1 b/plugins/copilot/scripts/ship-logs.ps1 index dffeb83..c7d3b44 100644 --- a/plugins/copilot/scripts/ship-logs.ps1 +++ b/plugins/copilot/scripts/ship-logs.ps1 @@ -92,8 +92,8 @@ $script:targetFamily = '' $script:stateKey = '' $script:offset = 0 $script:stateHead = '' -$script:stateSize = 0 -$script:statePath = '' +$script:stateSize = 0; $script:stateRevision = '' +$script:statePath = ''; $script:stateRevision = '' $script:advanceBytes = 0 $script:runBytesSent = 0 $script:lineLength = 0 @@ -604,7 +604,7 @@ function Unlock-StateKey { # ── stage 7: state ───────────────────────────────────────────────────────── function Read-ShipState { param([string]$Key, [string]$NormalizedPath) - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = '' + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = ''; $script:stateRevision = '' $stateFile = Join-Path $script:stateDir "$Key.state" if (-not (Test-Path -LiteralPath $stateFile)) { return } try { @@ -625,6 +625,8 @@ function Read-ShipState { } elseif ($line -match '^size=(.*)$') { $rawSize = $Matches[1] if ($rawSize -match '^[0-9]+$') { $script:stateSize = [int64]$rawSize } else { $script:stateSize = 0 } + } elseif ($line -match '^revision=(.*)$') { + $script:stateRevision = $Matches[1] } elseif ($line -match '^path=(.*)$') { $script:statePath = $Matches[1] } @@ -635,27 +637,18 @@ function Read-ShipState { # shipper at a different file holding the previous file's offset. if ($script:statePath -and $script:statePath -ne $NormalizedPath) { Write-ShipDebug "state path mismatch ($($script:statePath) != $NormalizedPath) -> treating state as absent" - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0 + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:stateRevision = '' } } -# Write-to-temp-then-move, so a crash mid-write cannot leave a half-written offset. -# The temp sits in the SAME directory as the destination. The destination is removed -# first: `Move-Item -Force` onto an existing file is not reliable on Windows -# PowerShell 5.1, and under -ErrorAction SilentlyContinue a failure there would -# silently freeze the offset forever. +# Commit the discard offset and its pause revision in one file replacement. function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) - try { - $destination = Join-Path $script:stateDir "$Key.state" - $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [System.IO.File]::WriteAllText( - $tempFile, - "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`n", - (New-Object System.Text.UTF8Encoding($false))) - Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tempFile -Destination $destination -Force -ErrorAction SilentlyContinue - } catch {} + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -872,9 +865,8 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path - if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and $script:stateRevision -ne [string]$script:RPRevision) { Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath - Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) return } $script:runBytesSent = 0 @@ -931,10 +923,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } + try { Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -956,6 +950,7 @@ function Invoke-Main { Ship-LogFile $target } exit 0 + } finally { Leave-RogueProtection } } # The ROGUE_PS_LIB_ONLY seam: load the helpers WITHOUT running the shipper, so @@ -963,4 +958,4 @@ function Invoke-Main { # main body stands down). Every pure helper is defined ABOVE this line. if ($env:ROGUE_PS_LIB_ONLY) { return } -Invoke-Main +try { Invoke-Main } catch { exit 0 } diff --git a/plugins/copilot/scripts/ship-logs.sh b/plugins/copilot/scripts/ship-logs.sh index f95e7a8..4c0471f 100644 --- a/plugins/copilot/scripts/ship-logs.sh +++ b/plugins/copilot/scripts/ship-logs.sh @@ -99,7 +99,7 @@ MAX_RUN_BYTES=10485760 MAX_LINE_BYTES=4194304 # per-target working state TARGET_BASENAME=""; TARGET_FAMILY=""; STATE_KEY="" -OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" +OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" ADVANCE_BYTES=0; RUN_BYTES_SENT=0 LINE_LENGTH=0; LINE_SEARCH_HIT_EOF=0 @@ -536,7 +536,7 @@ cleanup() { # ── stage 7: state ───────────────────────────────────────────────────────── read_state() { # - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" _state_file="$STATE_DIR/${1:-}.state" [ -r "$_state_file" ] || return 0 while IFS= read -r _state_line; do @@ -550,6 +550,7 @@ read_state() { # _state_value="${_state_line#size=}" case "$_state_value" in ''|*[!0-9]*) _state_value=0 ;; esac STATE_SIZE="$_state_value" ;; + revision=*) STATE_REVISION="${_state_line#revision=}" ;; path=*) STATE_PATH="${_state_line#path=}" ;; esac done < "$_state_file" @@ -560,7 +561,7 @@ read_state() { # # directly is cheaper and clearer. if [ -n "$STATE_PATH" ] && [ "$STATE_PATH" != "${2:-}" ]; then debug "state path mismatch ($STATE_PATH != ${2:-}) -> treating state as absent" - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0 + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_REVISION="" fi return 0 } @@ -569,10 +570,10 @@ read_state() { # # The temp lives in the SAME directory as the destination, or the mv is not atomic. write_state() { # _state_tmp_file="$STATE_DIR/.state-tmp-$$" - printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\n' \ - "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" > "$_state_tmp_file" 2>/dev/null || return 0 + printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\nrevision=%s\n' \ + "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" "${ROGUE_PROTECTION_REVISION:-}" > "$_state_tmp_file" 2>/dev/null || return 1 + [ ! -d "$STATE_DIR/${1:-}.state" ] || return 1 mv -f "$_state_tmp_file" "$STATE_DIR/${1:-}.state" 2>/dev/null - return 0 } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -784,6 +785,7 @@ post_chunk() { # printf '"}' } > "$_post_body_file" 2>/dev/null debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" + rogue_protection_current || return 1 _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -820,7 +822,7 @@ drain_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") - if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" - printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" release_lock return 0 fi @@ -899,9 +900,11 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" rogue_protection_enter || exit 0 + trap 'rogue_protection_leave' EXIT resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 3eb01b2..02759e1 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -870,9 +870,11 @@ if (Test-Path -LiteralPath $pluginJson) { } # ── payload from stdin ───────────────────────────────────────────────────── +if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { [Console]::Out.Write('{}'); exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') -$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'cursor' -Family 'cursor' +$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'cursor' -Family 'cursor' -Version $pluginVersion if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } +try { $payload = Read-RogueProtectionInput if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } @@ -1132,3 +1134,4 @@ if ($null -ne $hbUnthrottled) { } exit 0 +} finally { Leave-RogueProtection } diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index a3d95b4..3e09a71 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -129,7 +129,6 @@ _lcap="$ROGUE_LOG_MAX_BYTES" while [ "${_lcap#0}" != "$_lcap" ]; do _lcap="${_lcap#0}"; done if [ "${#_lcap}" -gt 18 ]; then ROGUE_LOG_MAX_BYTES=10485760; fi rotate_log() { - if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" @@ -170,6 +169,7 @@ log() { # can carry anything), and a raw newline or CR would forge extra log lines. sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } +[ -r "${PLUGIN_ROOT}/scripts/protection.sh" ] || { printf '%s' '{}'; exit 0; } . "${PLUGIN_ROOT}/scripts/protection.sh" rogue_protection_init cursor cursor "${PLUGIN_ROOT}/scripts" rogue_protection_enter || { printf '{}'; exit 0; } @@ -231,7 +231,8 @@ else fi # ── payload from stdin ───────────────────────────────────────────────────── -PAYLOAD="$(cat 2>/dev/null)" +PAYLOAD="$(rogue_protection_read_input 2>/dev/null)" +rogue_protection_current || { printf '%s' '{}'; exit 0; } [ -n "$PAYLOAD" ] || PAYLOAD='{}' # Strip a leading UTF-8 BOM if present. Cursor on Windows prepends one to the # hook payload (hook.ps1 handles it on the native path); a BOM-prefixed body is diff --git a/plugins/cursor/scripts/protection.ps1 b/plugins/cursor/scripts/protection.ps1 index 002d7d2..a8b9a1c 100644 --- a/plugins/cursor/scripts/protection.ps1 +++ b/plugins/cursor/scripts/protection.ps1 @@ -7,11 +7,21 @@ $script:RPKey=$null function Write-RogueProtectionFile([string]$Path, [string]$Value) { $temp = "$Path.$PID.tmp" [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) - Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temp, $Path, [NullString]::Value) } + else { [IO.File]::Move($temp, $Path) } +} +function Test-RogueProtectionDecision($Decision) { + if ($null -eq $Decision -or $Decision.protocolVersion -ne 1 -or $Decision.revision -isnot [long] -and $Decision.revision -isnot [int] -or $Decision.revision -lt 0) { return $false } + foreach ($cap in 'aidr','aispm') { + $value=$Decision.$cap + if ($null -eq $value -or $value.paused -isnot [bool] -or ($value.revision -isnot [long] -and $value.revision -isnot [int]) -or $value.revision -lt 0) { return $false } + } + return $true } function Get-RogueProtectionState { try { $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + if (-not (Test-RogueProtectionDecision $value.decision)) { return $null } $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) foreach ($cap in 'aidr','aispm') { if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } @@ -51,7 +61,7 @@ function Update-RogueProtection { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState - if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue @@ -102,19 +112,24 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue + if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } + $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } - } catch { } finally { $lock.Dispose() } + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + } finally { $lock.Dispose() } } - if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) @@ -160,9 +175,25 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } +function Leave-RogueProtection { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck +} function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } - if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + if ($script:RPDirectory) { + try { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + return $false + } + } return Test-RogueProtectionCurrent } if ($Poll) { diff --git a/plugins/cursor/scripts/protection.sh b/plugins/cursor/scripts/protection.sh index 45b6348..36946d7 100644 --- a/plugins/cursor/scripts/protection.sh +++ b/plugins/cursor/scripts/protection.sh @@ -121,16 +121,22 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" - _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE @@ -146,9 +152,37 @@ rogue_protection_init() { rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } +rogue_protection_fail() { + RP_PERSISTENCE_FAILED=1 + rogue_protection_load || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true +} +rogue_protection_read_input() ( + rogue_protection_current || exit 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || { cat; exit; } + umask 077 + _rp_input=$(mktemp "$ROGUE_PROTECTION_STATE/input.XXXXXX") || exit 1 + exec 3<&0 + cat <&3 > "$_rp_input" & + _rp_reader=$! + ( + while kill -0 "$_rp_reader" 2>/dev/null; do + if ! rogue_protection_current || ! kill -0 "$$" 2>/dev/null; then kill "$_rp_reader" 2>/dev/null; exit; fi + sleep 0.2 + done + ) >&2 & + _rp_watch=$! + trap 'kill "$_rp_reader" "$_rp_watch" 2>/dev/null; rm -f "$_rp_input"' EXIT + wait "$_rp_reader" || exit 1 + kill "$_rp_watch" 2>/dev/null + rogue_protection_current || exit 1 + cat "$_rp_input" +) rogue_protection_enter() { rogue_protection_current || return 1 - [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && ! printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$"; then rogue_protection_fail; return 1; fi rogue_protection_current } if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then diff --git a/plugins/cursor/scripts/ship-logs.ps1 b/plugins/cursor/scripts/ship-logs.ps1 index dffeb83..c7d3b44 100644 --- a/plugins/cursor/scripts/ship-logs.ps1 +++ b/plugins/cursor/scripts/ship-logs.ps1 @@ -92,8 +92,8 @@ $script:targetFamily = '' $script:stateKey = '' $script:offset = 0 $script:stateHead = '' -$script:stateSize = 0 -$script:statePath = '' +$script:stateSize = 0; $script:stateRevision = '' +$script:statePath = ''; $script:stateRevision = '' $script:advanceBytes = 0 $script:runBytesSent = 0 $script:lineLength = 0 @@ -604,7 +604,7 @@ function Unlock-StateKey { # ── stage 7: state ───────────────────────────────────────────────────────── function Read-ShipState { param([string]$Key, [string]$NormalizedPath) - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = '' + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = ''; $script:stateRevision = '' $stateFile = Join-Path $script:stateDir "$Key.state" if (-not (Test-Path -LiteralPath $stateFile)) { return } try { @@ -625,6 +625,8 @@ function Read-ShipState { } elseif ($line -match '^size=(.*)$') { $rawSize = $Matches[1] if ($rawSize -match '^[0-9]+$') { $script:stateSize = [int64]$rawSize } else { $script:stateSize = 0 } + } elseif ($line -match '^revision=(.*)$') { + $script:stateRevision = $Matches[1] } elseif ($line -match '^path=(.*)$') { $script:statePath = $Matches[1] } @@ -635,27 +637,18 @@ function Read-ShipState { # shipper at a different file holding the previous file's offset. if ($script:statePath -and $script:statePath -ne $NormalizedPath) { Write-ShipDebug "state path mismatch ($($script:statePath) != $NormalizedPath) -> treating state as absent" - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0 + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:stateRevision = '' } } -# Write-to-temp-then-move, so a crash mid-write cannot leave a half-written offset. -# The temp sits in the SAME directory as the destination. The destination is removed -# first: `Move-Item -Force` onto an existing file is not reliable on Windows -# PowerShell 5.1, and under -ErrorAction SilentlyContinue a failure there would -# silently freeze the offset forever. +# Commit the discard offset and its pause revision in one file replacement. function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) - try { - $destination = Join-Path $script:stateDir "$Key.state" - $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [System.IO.File]::WriteAllText( - $tempFile, - "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`n", - (New-Object System.Text.UTF8Encoding($false))) - Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tempFile -Destination $destination -Force -ErrorAction SilentlyContinue - } catch {} + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -872,9 +865,8 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path - if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and $script:stateRevision -ne [string]$script:RPRevision) { Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath - Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) return } $script:runBytesSent = 0 @@ -931,10 +923,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } + try { Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -956,6 +950,7 @@ function Invoke-Main { Ship-LogFile $target } exit 0 + } finally { Leave-RogueProtection } } # The ROGUE_PS_LIB_ONLY seam: load the helpers WITHOUT running the shipper, so @@ -963,4 +958,4 @@ function Invoke-Main { # main body stands down). Every pure helper is defined ABOVE this line. if ($env:ROGUE_PS_LIB_ONLY) { return } -Invoke-Main +try { Invoke-Main } catch { exit 0 } diff --git a/plugins/cursor/scripts/ship-logs.sh b/plugins/cursor/scripts/ship-logs.sh index f95e7a8..4c0471f 100644 --- a/plugins/cursor/scripts/ship-logs.sh +++ b/plugins/cursor/scripts/ship-logs.sh @@ -99,7 +99,7 @@ MAX_RUN_BYTES=10485760 MAX_LINE_BYTES=4194304 # per-target working state TARGET_BASENAME=""; TARGET_FAMILY=""; STATE_KEY="" -OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" +OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" ADVANCE_BYTES=0; RUN_BYTES_SENT=0 LINE_LENGTH=0; LINE_SEARCH_HIT_EOF=0 @@ -536,7 +536,7 @@ cleanup() { # ── stage 7: state ───────────────────────────────────────────────────────── read_state() { # - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" _state_file="$STATE_DIR/${1:-}.state" [ -r "$_state_file" ] || return 0 while IFS= read -r _state_line; do @@ -550,6 +550,7 @@ read_state() { # _state_value="${_state_line#size=}" case "$_state_value" in ''|*[!0-9]*) _state_value=0 ;; esac STATE_SIZE="$_state_value" ;; + revision=*) STATE_REVISION="${_state_line#revision=}" ;; path=*) STATE_PATH="${_state_line#path=}" ;; esac done < "$_state_file" @@ -560,7 +561,7 @@ read_state() { # # directly is cheaper and clearer. if [ -n "$STATE_PATH" ] && [ "$STATE_PATH" != "${2:-}" ]; then debug "state path mismatch ($STATE_PATH != ${2:-}) -> treating state as absent" - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0 + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_REVISION="" fi return 0 } @@ -569,10 +570,10 @@ read_state() { # # The temp lives in the SAME directory as the destination, or the mv is not atomic. write_state() { # _state_tmp_file="$STATE_DIR/.state-tmp-$$" - printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\n' \ - "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" > "$_state_tmp_file" 2>/dev/null || return 0 + printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\nrevision=%s\n' \ + "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" "${ROGUE_PROTECTION_REVISION:-}" > "$_state_tmp_file" 2>/dev/null || return 1 + [ ! -d "$STATE_DIR/${1:-}.state" ] || return 1 mv -f "$_state_tmp_file" "$STATE_DIR/${1:-}.state" 2>/dev/null - return 0 } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -784,6 +785,7 @@ post_chunk() { # printf '"}' } > "$_post_body_file" 2>/dev/null debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" + rogue_protection_current || return 1 _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -820,7 +822,7 @@ drain_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") - if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" - printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" release_lock return 0 fi @@ -899,9 +900,11 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" rogue_protection_enter || exit 0 + trap 'rogue_protection_leave' EXIT resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/gemini/scripts/protection.mjs b/plugins/gemini/scripts/protection.mjs index 2cf0649..5735b07 100644 --- a/plugins/gemini/scripts/protection.mjs +++ b/plugins/gemini/scripts/protection.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { setTimeout as sleep } from 'node:timers/promises'; @@ -108,15 +108,18 @@ export class Protection { } } if (!key) { - if (Date.now()-Number(read(client.file('enroll-attempt'))) < 60000) return undefined; + if (Date.now()-Number(read(client.file('enroll-attempt'))) < 60000) return read(client.file('legacy-server')) ? undefined : client; const lock=client.file('enroll.lock'); try { fs.writeFileSync(lock, String(process.pid), {flag:'wx', mode:0o600}); } catch { if (!alive(read(lock))) fs.rmSync(lock, {force:true}); return client; } try { write(client.file('enroll-attempt'), String(Date.now())); - const enrolled=await client.request('enroll', {type:'coding_agent', name:slug, family, host:os.hostname(), version:env.ROGUE_INSTALL_VERSION || 'unknown'}); + let enrollmentNonce=read(client.file('enrollment-nonce')); + if (!enrollmentNonce) { enrollmentNonce=randomUUID(); write(client.file('enrollment-nonce'),enrollmentNonce); } + fs.rmSync(client.file('legacy-server'),{force:true}); + const enrolled=await client.request('enroll', {enrollmentNonce,type:'coding_agent', name:slug, family, host:os.hostname(), version:env.ROGUE_INSTALL_VERSION || 'unknown'}); key=enrolled.alreadyEnrolled ? env.ROGUE_API_KEY : enrolled.apiKey; - if (typeof key !== 'string' || !key) return undefined; + if (typeof key !== 'string' || !key) return client; write(client.file('credential'), key); } finally { fs.rmSync(lock, {force:true}); } } @@ -133,9 +136,12 @@ export class Protection { child.unref(); } } finally { if (ownsPollLock) fs.rmSync(pollLock,{force:true}); } return client; - } catch { + } catch (error) { const key=read(client.file('credential')); - if (!key) return undefined; + if (!key) { + if (error.status === 404) { try { write(client.file('legacy-server'),'1'); } catch {} return undefined; } + return client; + } client.key=key; client.revision=client.state()?.aidr.revision; return client; } } diff --git a/plugins/gemini/scripts/ship-logs.mjs b/plugins/gemini/scripts/ship-logs.mjs index 3ec4a48..c397b89 100644 --- a/plugins/gemini/scripts/ship-logs.mjs +++ b/plugins/gemini/scripts/ship-logs.mjs @@ -442,7 +442,7 @@ class Shipper { } readState(stateKey, normalizedPath) { - const state = { offset: 0, head: "", size: 0, path: "" }; + const state = { offset: 0, head: "", size: 0, path: "", revision: "" }; let text; try { text = fs.readFileSync(path.join(this.stateDir, `${stateKey}.state`), "utf8"); @@ -455,6 +455,7 @@ class Shipper { if (field[1] === "offset") state.offset = /^[0-9]+$/.test(field[2]) ? Number(field[2]) : 0; else if (field[1] === "head") state.head = field[2]; else if (field[1] === "size") state.size = /^[0-9]+$/.test(field[2]) ? Number(field[2]) : 0; + else if (field[1] === "revision") state.revision = field[2]; else if (field[1] === "path") state.path = field[2]; } // The key is a BASENAME, so /a/claude.log and /b/claude.log key alike: changing @@ -467,6 +468,7 @@ class Shipper { state.offset = 0; state.head = ""; state.size = 0; + state.revision = ""; } return state; } @@ -480,10 +482,11 @@ class Shipper { const tempFile = path.join(this.stateDir, `.state-tmp-${process.pid}`); fs.writeFileSync( tempFile, - `offset=${offset}\nhead=${head}\nsize=${size}\npath=${normalizedPath}\n`, + `offset=${offset}\nhead=${head}\nsize=${size}\npath=${normalizedPath}\nrevision=${this.protection?.revision ?? ""}\n`, ); fs.renameSync(tempFile, destination); - } catch {} + return true; + } catch { return false; } } async sendChunkRequest(bytes, offset, count, rotated) { @@ -653,7 +656,7 @@ class Shipper { if (advanceBytes <= 0) return { offset, complete: false }; offset += advanceBytes; this.runBytesSent += advanceBytes; - this.writeState(stateKey, offset, persistHead, persistSize, normalizedPath); + if (!this.writeState(stateKey, offset, persistHead, persistSize, normalizedPath)) return {offset, complete:false}; } return { offset, complete: true }; } @@ -693,9 +696,8 @@ class Shipper { const fileBytes = fileSize(filePath); const currentHead = firstLineFingerprint(filePath); this.runBytesSent = 0; - if (this.protection?.revision > 0 && (fs.existsSync(this.protection.file('ship-revision')) ? fs.readFileSync(this.protection.file('ship-revision'), 'utf8') : '') !== String(this.protection.revision)) { + if (this.protection?.revision > 0 && state.revision !== String(this.protection.revision)) { this.writeState(stateKey, fileBytes, currentHead, fileBytes, normalizedPath); - fs.writeFileSync(this.protection.file('ship-revision'), String(this.protection.revision), {mode:0o600}); return; } let offset = state.offset; diff --git a/plugins/kiro/scripts/hook.ps1 b/plugins/kiro/scripts/hook.ps1 index ac6d2a2..0ab348b 100644 --- a/plugins/kiro/scripts/hook.ps1 +++ b/plugins/kiro/scripts/hook.ps1 @@ -405,7 +405,7 @@ function Send-KiroRequest { $script:code = '000' $script:requestRc = 1 try { - if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { exit 0 } if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and $null -ne $script:RPRevision) { $headers['x-rogue-activity-revision']=[string]$script:RPRevision } $r = Invoke-WebRequest -Uri $url -Method Post ` -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` @@ -434,12 +434,14 @@ function Write-KiroDecision { function Invoke-KiroHook { Initialize-KiroContext Resolve-KiroActor +if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') $script:apiKey = Initialize-RogueProtection -Key $script:apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'kiro' -Family 'kiro' -if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } +if (-not (Enter-RogueProtection)) { exit 0 } +try { $payload = Read-KiroPayload - if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } + if (-not (Test-RogueProtectionCurrent)) { exit 0 } $payload = Add-KiroSessionId $payload $env:KIRO_SESSION_ID if (Test-KiroDuplicateAgentHook $triggerArg $payload) { Log "outcome=duplicate engine=3.0 trigger=$triggerArg" @@ -448,8 +450,9 @@ if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } Resolve-KiroInstall Start-KiroHeartbeat Send-KiroRequest -if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } +if (-not (Test-RogueProtectionCurrent)) { exit 0 } Write-KiroDecision +} finally { Leave-RogueProtection } } # Dot-sourcing through the test seam defines every function without running it. diff --git a/plugins/kiro/scripts/hook.sh b/plugins/kiro/scripts/hook.sh index 334a248..2df4c15 100755 --- a/plugins/kiro/scripts/hook.sh +++ b/plugins/kiro/scripts/hook.sh @@ -93,7 +93,6 @@ initialize_context() { } rotate_log() { - if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 [ "$ROGUE_LOG_MAX_BYTES" -gt 0 ] || return 0 @@ -211,6 +210,7 @@ maybe_heartbeat() { } post_request() { +rogue_protection_current || { exit 0; } RAW=$(printf '%s' "$BODY" | curl -sS -X POST "$URL" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -250,7 +250,7 @@ block_reason() { } relay_decision() { - rogue_protection_current || { printf '%s' '{}'; exit 0; } + rogue_protection_current || { exit 0; } if [ "$RC" -ne 0 ] || [ "$CODE" != "200" ] || [ -z "$RESP" ]; then finish allow exit 0 @@ -284,15 +284,17 @@ main() { locate_plugin_root load_env initialize_context +[ -r "${PLUGIN_ROOT}/scripts/protection.sh" ] || { exit 0; } . "${PLUGIN_ROOT}/scripts/protection.sh" rogue_protection_init kiro kiro "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" -rogue_protection_enter || { printf '%s' '{}'; exit 0; } +rogue_protection_enter || { exit 0; } trap 'rogue_protection_leave' EXIT require_api_key load_identity resolve_request - BODY="$(inject_session_id "$(cat)")" + BODY="$(inject_session_id "$(rogue_protection_read_input)")" +rogue_protection_current || { exit 0; } skip_duplicate maybe_heartbeat post_request diff --git a/plugins/kiro/scripts/protection.ps1 b/plugins/kiro/scripts/protection.ps1 index 002d7d2..a8b9a1c 100644 --- a/plugins/kiro/scripts/protection.ps1 +++ b/plugins/kiro/scripts/protection.ps1 @@ -7,11 +7,21 @@ $script:RPKey=$null function Write-RogueProtectionFile([string]$Path, [string]$Value) { $temp = "$Path.$PID.tmp" [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) - Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temp, $Path, [NullString]::Value) } + else { [IO.File]::Move($temp, $Path) } +} +function Test-RogueProtectionDecision($Decision) { + if ($null -eq $Decision -or $Decision.protocolVersion -ne 1 -or $Decision.revision -isnot [long] -and $Decision.revision -isnot [int] -or $Decision.revision -lt 0) { return $false } + foreach ($cap in 'aidr','aispm') { + $value=$Decision.$cap + if ($null -eq $value -or $value.paused -isnot [bool] -or ($value.revision -isnot [long] -and $value.revision -isnot [int]) -or $value.revision -lt 0) { return $false } + } + return $true } function Get-RogueProtectionState { try { $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + if (-not (Test-RogueProtectionDecision $value.decision)) { return $null } $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) foreach ($cap in 'aidr','aispm') { if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } @@ -51,7 +61,7 @@ function Update-RogueProtection { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState - if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue @@ -102,19 +112,24 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue + if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } + $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } - } catch { } finally { $lock.Dispose() } + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + } finally { $lock.Dispose() } } - if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) @@ -160,9 +175,25 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } +function Leave-RogueProtection { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck +} function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } - if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + if ($script:RPDirectory) { + try { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + return $false + } + } return Test-RogueProtectionCurrent } if ($Poll) { diff --git a/plugins/kiro/scripts/protection.sh b/plugins/kiro/scripts/protection.sh index 45b6348..36946d7 100644 --- a/plugins/kiro/scripts/protection.sh +++ b/plugins/kiro/scripts/protection.sh @@ -121,16 +121,22 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" - _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE @@ -146,9 +152,37 @@ rogue_protection_init() { rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } +rogue_protection_fail() { + RP_PERSISTENCE_FAILED=1 + rogue_protection_load || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true +} +rogue_protection_read_input() ( + rogue_protection_current || exit 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || { cat; exit; } + umask 077 + _rp_input=$(mktemp "$ROGUE_PROTECTION_STATE/input.XXXXXX") || exit 1 + exec 3<&0 + cat <&3 > "$_rp_input" & + _rp_reader=$! + ( + while kill -0 "$_rp_reader" 2>/dev/null; do + if ! rogue_protection_current || ! kill -0 "$$" 2>/dev/null; then kill "$_rp_reader" 2>/dev/null; exit; fi + sleep 0.2 + done + ) >&2 & + _rp_watch=$! + trap 'kill "$_rp_reader" "$_rp_watch" 2>/dev/null; rm -f "$_rp_input"' EXIT + wait "$_rp_reader" || exit 1 + kill "$_rp_watch" 2>/dev/null + rogue_protection_current || exit 1 + cat "$_rp_input" +) rogue_protection_enter() { rogue_protection_current || return 1 - [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && ! printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$"; then rogue_protection_fail; return 1; fi rogue_protection_current } if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then diff --git a/plugins/kiro/scripts/ship-logs.ps1 b/plugins/kiro/scripts/ship-logs.ps1 index dffeb83..c7d3b44 100644 --- a/plugins/kiro/scripts/ship-logs.ps1 +++ b/plugins/kiro/scripts/ship-logs.ps1 @@ -92,8 +92,8 @@ $script:targetFamily = '' $script:stateKey = '' $script:offset = 0 $script:stateHead = '' -$script:stateSize = 0 -$script:statePath = '' +$script:stateSize = 0; $script:stateRevision = '' +$script:statePath = ''; $script:stateRevision = '' $script:advanceBytes = 0 $script:runBytesSent = 0 $script:lineLength = 0 @@ -604,7 +604,7 @@ function Unlock-StateKey { # ── stage 7: state ───────────────────────────────────────────────────────── function Read-ShipState { param([string]$Key, [string]$NormalizedPath) - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = '' + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = ''; $script:stateRevision = '' $stateFile = Join-Path $script:stateDir "$Key.state" if (-not (Test-Path -LiteralPath $stateFile)) { return } try { @@ -625,6 +625,8 @@ function Read-ShipState { } elseif ($line -match '^size=(.*)$') { $rawSize = $Matches[1] if ($rawSize -match '^[0-9]+$') { $script:stateSize = [int64]$rawSize } else { $script:stateSize = 0 } + } elseif ($line -match '^revision=(.*)$') { + $script:stateRevision = $Matches[1] } elseif ($line -match '^path=(.*)$') { $script:statePath = $Matches[1] } @@ -635,27 +637,18 @@ function Read-ShipState { # shipper at a different file holding the previous file's offset. if ($script:statePath -and $script:statePath -ne $NormalizedPath) { Write-ShipDebug "state path mismatch ($($script:statePath) != $NormalizedPath) -> treating state as absent" - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0 + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:stateRevision = '' } } -# Write-to-temp-then-move, so a crash mid-write cannot leave a half-written offset. -# The temp sits in the SAME directory as the destination. The destination is removed -# first: `Move-Item -Force` onto an existing file is not reliable on Windows -# PowerShell 5.1, and under -ErrorAction SilentlyContinue a failure there would -# silently freeze the offset forever. +# Commit the discard offset and its pause revision in one file replacement. function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) - try { - $destination = Join-Path $script:stateDir "$Key.state" - $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [System.IO.File]::WriteAllText( - $tempFile, - "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`n", - (New-Object System.Text.UTF8Encoding($false))) - Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tempFile -Destination $destination -Force -ErrorAction SilentlyContinue - } catch {} + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -872,9 +865,8 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path - if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and $script:stateRevision -ne [string]$script:RPRevision) { Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath - Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) return } $script:runBytesSent = 0 @@ -931,10 +923,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } + try { Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -956,6 +950,7 @@ function Invoke-Main { Ship-LogFile $target } exit 0 + } finally { Leave-RogueProtection } } # The ROGUE_PS_LIB_ONLY seam: load the helpers WITHOUT running the shipper, so @@ -963,4 +958,4 @@ function Invoke-Main { # main body stands down). Every pure helper is defined ABOVE this line. if ($env:ROGUE_PS_LIB_ONLY) { return } -Invoke-Main +try { Invoke-Main } catch { exit 0 } diff --git a/plugins/kiro/scripts/ship-logs.sh b/plugins/kiro/scripts/ship-logs.sh index f95e7a8..4c0471f 100644 --- a/plugins/kiro/scripts/ship-logs.sh +++ b/plugins/kiro/scripts/ship-logs.sh @@ -99,7 +99,7 @@ MAX_RUN_BYTES=10485760 MAX_LINE_BYTES=4194304 # per-target working state TARGET_BASENAME=""; TARGET_FAMILY=""; STATE_KEY="" -OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" +OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" ADVANCE_BYTES=0; RUN_BYTES_SENT=0 LINE_LENGTH=0; LINE_SEARCH_HIT_EOF=0 @@ -536,7 +536,7 @@ cleanup() { # ── stage 7: state ───────────────────────────────────────────────────────── read_state() { # - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" _state_file="$STATE_DIR/${1:-}.state" [ -r "$_state_file" ] || return 0 while IFS= read -r _state_line; do @@ -550,6 +550,7 @@ read_state() { # _state_value="${_state_line#size=}" case "$_state_value" in ''|*[!0-9]*) _state_value=0 ;; esac STATE_SIZE="$_state_value" ;; + revision=*) STATE_REVISION="${_state_line#revision=}" ;; path=*) STATE_PATH="${_state_line#path=}" ;; esac done < "$_state_file" @@ -560,7 +561,7 @@ read_state() { # # directly is cheaper and clearer. if [ -n "$STATE_PATH" ] && [ "$STATE_PATH" != "${2:-}" ]; then debug "state path mismatch ($STATE_PATH != ${2:-}) -> treating state as absent" - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0 + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_REVISION="" fi return 0 } @@ -569,10 +570,10 @@ read_state() { # # The temp lives in the SAME directory as the destination, or the mv is not atomic. write_state() { # _state_tmp_file="$STATE_DIR/.state-tmp-$$" - printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\n' \ - "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" > "$_state_tmp_file" 2>/dev/null || return 0 + printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\nrevision=%s\n' \ + "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" "${ROGUE_PROTECTION_REVISION:-}" > "$_state_tmp_file" 2>/dev/null || return 1 + [ ! -d "$STATE_DIR/${1:-}.state" ] || return 1 mv -f "$_state_tmp_file" "$STATE_DIR/${1:-}.state" 2>/dev/null - return 0 } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -784,6 +785,7 @@ post_chunk() { # printf '"}' } > "$_post_body_file" 2>/dev/null debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" + rogue_protection_current || return 1 _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -820,7 +822,7 @@ drain_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") - if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" - printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" release_lock return 0 fi @@ -899,9 +900,11 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" rogue_protection_enter || exit 0 + trap 'rogue_protection_leave' EXIT resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/rogue/scripts/hook.ps1 b/plugins/rogue/scripts/hook.ps1 index f0ade57..f249417 100644 --- a/plugins/rogue/scripts/hook.ps1 +++ b/plugins/rogue/scripts/hook.ps1 @@ -494,9 +494,11 @@ if (-not $installAgent) { $installAgent = 'claude_code' } if ($installError.Count) { Log "error=install-id $($installError -join ',')" } # -- payload from stdin ----------------------------------------------------- +if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { [Console]::Out.Write('{}'); exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') -$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'claude' -Family 'claude' +$apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'claude' -Family 'claude' -Version $pluginVersion if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } +try { $payload = Read-RogueProtectionInput if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } @@ -635,3 +637,4 @@ if ($fireAlert) { } catch { Log "alert_error=$(Sanitize $_.Exception.Message)" } } exit 0 +} finally { Leave-RogueProtection } diff --git a/plugins/rogue/scripts/hook.sh b/plugins/rogue/scripts/hook.sh index 1623922..57f661a 100644 --- a/plugins/rogue/scripts/hook.sh +++ b/plugins/rogue/scripts/hook.sh @@ -67,7 +67,6 @@ _lcap="$ROGUE_LOG_MAX_BYTES" while [ "${_lcap#0}" != "$_lcap" ]; do _lcap="${_lcap#0}"; done if [ "${#_lcap}" -gt 18 ]; then ROGUE_LOG_MAX_BYTES=10485760; fi rotate_log() { - if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi if command -v rogue_protection_current >/dev/null 2>&1 && ! rogue_protection_current; then return 0; fi [ -f "$ROGUE_LOG_FILE" ] || return 0 # Arithmetic, not a glob: "00" must mean zero here exactly as [int64]"00" @@ -150,6 +149,7 @@ _rogue_want_alert() { return 0 } +[ -r "${CLAUDE_PLUGIN_ROOT}/scripts/protection.sh" ] || { printf '%s' '{}'; exit 0; } . "${CLAUDE_PLUGIN_ROOT}/scripts/protection.sh" rogue_protection_init claude claude "${CLAUDE_PLUGIN_ROOT}/scripts" "${SURFACE:-default}" rogue_protection_enter || { printf '%s' '{}'; exit 0; } @@ -171,7 +171,9 @@ fi # reports itself imprecisely to the fleet roster. [ -n "${ROGUE_INSTALL_ID_ERROR:-}" ] && log "error=install-id $ROGUE_INSTALL_ID_ERROR" -RESP=$(curl -sS -X POST "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/claude" \ +BODY="$(rogue_protection_read_input)" +rogue_protection_current || { printf '%s' '{}'; exit 0; } +RESP=$(printf '%s' "$BODY" | curl -sS -X POST "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/claude" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ -H "x-rogue-event: $EVENT" \ diff --git a/plugins/rogue/scripts/protection.ps1 b/plugins/rogue/scripts/protection.ps1 index 002d7d2..a8b9a1c 100644 --- a/plugins/rogue/scripts/protection.ps1 +++ b/plugins/rogue/scripts/protection.ps1 @@ -7,11 +7,21 @@ $script:RPKey=$null function Write-RogueProtectionFile([string]$Path, [string]$Value) { $temp = "$Path.$PID.tmp" [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) - Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temp, $Path, [NullString]::Value) } + else { [IO.File]::Move($temp, $Path) } +} +function Test-RogueProtectionDecision($Decision) { + if ($null -eq $Decision -or $Decision.protocolVersion -ne 1 -or $Decision.revision -isnot [long] -and $Decision.revision -isnot [int] -or $Decision.revision -lt 0) { return $false } + foreach ($cap in 'aidr','aispm') { + $value=$Decision.$cap + if ($null -eq $value -or $value.paused -isnot [bool] -or ($value.revision -isnot [long] -and $value.revision -isnot [int]) -or $value.revision -lt 0) { return $false } + } + return $true } function Get-RogueProtectionState { try { $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + if (-not (Test-RogueProtectionDecision $value.decision)) { return $null } $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) foreach ($cap in 'aidr','aispm') { if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } @@ -51,7 +61,7 @@ function Update-RogueProtection { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState - if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue @@ -102,19 +112,24 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue + if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } + $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } - } catch { } finally { $lock.Dispose() } + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + } finally { $lock.Dispose() } } - if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) @@ -160,9 +175,25 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } +function Leave-RogueProtection { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck +} function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } - if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + if ($script:RPDirectory) { + try { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + return $false + } + } return Test-RogueProtectionCurrent } if ($Poll) { diff --git a/plugins/rogue/scripts/protection.sh b/plugins/rogue/scripts/protection.sh index 45b6348..36946d7 100644 --- a/plugins/rogue/scripts/protection.sh +++ b/plugins/rogue/scripts/protection.sh @@ -121,16 +121,22 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" - _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE @@ -146,9 +152,37 @@ rogue_protection_init() { rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } +rogue_protection_fail() { + RP_PERSISTENCE_FAILED=1 + rogue_protection_load || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true +} +rogue_protection_read_input() ( + rogue_protection_current || exit 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || { cat; exit; } + umask 077 + _rp_input=$(mktemp "$ROGUE_PROTECTION_STATE/input.XXXXXX") || exit 1 + exec 3<&0 + cat <&3 > "$_rp_input" & + _rp_reader=$! + ( + while kill -0 "$_rp_reader" 2>/dev/null; do + if ! rogue_protection_current || ! kill -0 "$$" 2>/dev/null; then kill "$_rp_reader" 2>/dev/null; exit; fi + sleep 0.2 + done + ) >&2 & + _rp_watch=$! + trap 'kill "$_rp_reader" "$_rp_watch" 2>/dev/null; rm -f "$_rp_input"' EXIT + wait "$_rp_reader" || exit 1 + kill "$_rp_watch" 2>/dev/null + rogue_protection_current || exit 1 + cat "$_rp_input" +) rogue_protection_enter() { rogue_protection_current || return 1 - [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && ! printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$"; then rogue_protection_fail; return 1; fi rogue_protection_current } if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then diff --git a/plugins/rogue/scripts/ship-logs.ps1 b/plugins/rogue/scripts/ship-logs.ps1 index dffeb83..c7d3b44 100644 --- a/plugins/rogue/scripts/ship-logs.ps1 +++ b/plugins/rogue/scripts/ship-logs.ps1 @@ -92,8 +92,8 @@ $script:targetFamily = '' $script:stateKey = '' $script:offset = 0 $script:stateHead = '' -$script:stateSize = 0 -$script:statePath = '' +$script:stateSize = 0; $script:stateRevision = '' +$script:statePath = ''; $script:stateRevision = '' $script:advanceBytes = 0 $script:runBytesSent = 0 $script:lineLength = 0 @@ -604,7 +604,7 @@ function Unlock-StateKey { # ── stage 7: state ───────────────────────────────────────────────────────── function Read-ShipState { param([string]$Key, [string]$NormalizedPath) - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = '' + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = ''; $script:stateRevision = '' $stateFile = Join-Path $script:stateDir "$Key.state" if (-not (Test-Path -LiteralPath $stateFile)) { return } try { @@ -625,6 +625,8 @@ function Read-ShipState { } elseif ($line -match '^size=(.*)$') { $rawSize = $Matches[1] if ($rawSize -match '^[0-9]+$') { $script:stateSize = [int64]$rawSize } else { $script:stateSize = 0 } + } elseif ($line -match '^revision=(.*)$') { + $script:stateRevision = $Matches[1] } elseif ($line -match '^path=(.*)$') { $script:statePath = $Matches[1] } @@ -635,27 +637,18 @@ function Read-ShipState { # shipper at a different file holding the previous file's offset. if ($script:statePath -and $script:statePath -ne $NormalizedPath) { Write-ShipDebug "state path mismatch ($($script:statePath) != $NormalizedPath) -> treating state as absent" - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0 + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:stateRevision = '' } } -# Write-to-temp-then-move, so a crash mid-write cannot leave a half-written offset. -# The temp sits in the SAME directory as the destination. The destination is removed -# first: `Move-Item -Force` onto an existing file is not reliable on Windows -# PowerShell 5.1, and under -ErrorAction SilentlyContinue a failure there would -# silently freeze the offset forever. +# Commit the discard offset and its pause revision in one file replacement. function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) - try { - $destination = Join-Path $script:stateDir "$Key.state" - $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [System.IO.File]::WriteAllText( - $tempFile, - "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`n", - (New-Object System.Text.UTF8Encoding($false))) - Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tempFile -Destination $destination -Force -ErrorAction SilentlyContinue - } catch {} + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -872,9 +865,8 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path - if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and $script:stateRevision -ne [string]$script:RPRevision) { Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath - Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) return } $script:runBytesSent = 0 @@ -931,10 +923,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } + try { Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -956,6 +950,7 @@ function Invoke-Main { Ship-LogFile $target } exit 0 + } finally { Leave-RogueProtection } } # The ROGUE_PS_LIB_ONLY seam: load the helpers WITHOUT running the shipper, so @@ -963,4 +958,4 @@ function Invoke-Main { # main body stands down). Every pure helper is defined ABOVE this line. if ($env:ROGUE_PS_LIB_ONLY) { return } -Invoke-Main +try { Invoke-Main } catch { exit 0 } diff --git a/plugins/rogue/scripts/ship-logs.sh b/plugins/rogue/scripts/ship-logs.sh index f95e7a8..4c0471f 100644 --- a/plugins/rogue/scripts/ship-logs.sh +++ b/plugins/rogue/scripts/ship-logs.sh @@ -99,7 +99,7 @@ MAX_RUN_BYTES=10485760 MAX_LINE_BYTES=4194304 # per-target working state TARGET_BASENAME=""; TARGET_FAMILY=""; STATE_KEY="" -OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" +OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" ADVANCE_BYTES=0; RUN_BYTES_SENT=0 LINE_LENGTH=0; LINE_SEARCH_HIT_EOF=0 @@ -536,7 +536,7 @@ cleanup() { # ── stage 7: state ───────────────────────────────────────────────────────── read_state() { # - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" _state_file="$STATE_DIR/${1:-}.state" [ -r "$_state_file" ] || return 0 while IFS= read -r _state_line; do @@ -550,6 +550,7 @@ read_state() { # _state_value="${_state_line#size=}" case "$_state_value" in ''|*[!0-9]*) _state_value=0 ;; esac STATE_SIZE="$_state_value" ;; + revision=*) STATE_REVISION="${_state_line#revision=}" ;; path=*) STATE_PATH="${_state_line#path=}" ;; esac done < "$_state_file" @@ -560,7 +561,7 @@ read_state() { # # directly is cheaper and clearer. if [ -n "$STATE_PATH" ] && [ "$STATE_PATH" != "${2:-}" ]; then debug "state path mismatch ($STATE_PATH != ${2:-}) -> treating state as absent" - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0 + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_REVISION="" fi return 0 } @@ -569,10 +570,10 @@ read_state() { # # The temp lives in the SAME directory as the destination, or the mv is not atomic. write_state() { # _state_tmp_file="$STATE_DIR/.state-tmp-$$" - printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\n' \ - "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" > "$_state_tmp_file" 2>/dev/null || return 0 + printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\nrevision=%s\n' \ + "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" "${ROGUE_PROTECTION_REVISION:-}" > "$_state_tmp_file" 2>/dev/null || return 1 + [ ! -d "$STATE_DIR/${1:-}.state" ] || return 1 mv -f "$_state_tmp_file" "$STATE_DIR/${1:-}.state" 2>/dev/null - return 0 } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -784,6 +785,7 @@ post_chunk() { # printf '"}' } > "$_post_body_file" 2>/dev/null debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" + rogue_protection_current || return 1 _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -820,7 +822,7 @@ drain_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") - if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" - printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" release_lock return 0 fi @@ -899,9 +900,11 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" rogue_protection_enter || exit 0 + trap 'rogue_protection_leave' EXIT resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/scripts/shared/protection.ps1 b/scripts/shared/protection.ps1 index 002d7d2..a8b9a1c 100644 --- a/scripts/shared/protection.ps1 +++ b/scripts/shared/protection.ps1 @@ -7,11 +7,21 @@ $script:RPKey=$null function Write-RogueProtectionFile([string]$Path, [string]$Value) { $temp = "$Path.$PID.tmp" [IO.File]::WriteAllText($temp, $Value, (New-Object Text.UTF8Encoding($false))) - Move-Item -LiteralPath $temp -Destination $Path -Force -ErrorAction Stop + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temp, $Path, [NullString]::Value) } + else { [IO.File]::Move($temp, $Path) } +} +function Test-RogueProtectionDecision($Decision) { + if ($null -eq $Decision -or $Decision.protocolVersion -ne 1 -or $Decision.revision -isnot [long] -and $Decision.revision -isnot [int] -or $Decision.revision -lt 0) { return $false } + foreach ($cap in 'aidr','aispm') { + $value=$Decision.$cap + if ($null -eq $value -or $value.paused -isnot [bool] -or ($value.revision -isnot [long] -and $value.revision -isnot [int]) -or $value.revision -lt 0) { return $false } + } + return $true } function Get-RogueProtectionState { try { $value = Get-Content -LiteralPath "$script:RPDirectory/state.json" -Raw | ConvertFrom-Json + if (-not (Test-RogueProtectionDecision $value.decision)) { return $null } $serverNow = ([DateTimeOffset]$value.decision.serverTime).AddSeconds(([DateTimeOffset]::UtcNow - ([DateTimeOffset]$value.receivedAt)).TotalSeconds) foreach ($cap in 'aidr','aispm') { if ($value.decision.$cap.expiresAt -and ([DateTimeOffset]$value.decision.$cap.expiresAt) -le $serverNow) { $value.decision.$cap.paused = $false } @@ -51,7 +61,7 @@ function Update-RogueProtection { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState - if ($state.protocolVersion -eq 1 -and ($null -eq $old -or $state.revision -ge $old.revision)) { + if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{ decision=$state; receivedAt=[DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 8 -Compress) Remove-Item -LiteralPath "$script:RPDirectory/persistence-failed" -Force -ErrorAction SilentlyContinue @@ -102,19 +112,24 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { $script:RPDirectory=$null; return $Key } + if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $body=@{ type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue + if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } + $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } - } catch { } finally { $lock.Dispose() } + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + } finally { $lock.Dispose() } } - if (-not (Test-Path -LiteralPath $credential)) { $script:RPDirectory=$null; return $Key } + if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) @@ -160,9 +175,25 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } +function Leave-RogueProtection { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck +} function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } - if ($script:RPDirectory) { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + if ($script:RPDirectory) { + try { Write-RogueProtectionFile "$script:RPDirectory/active.$PID" ([string]$script:RPRevision) } + catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + return $false + } + } return Test-RogueProtectionCurrent } if ($Poll) { diff --git a/scripts/shared/protection.sh b/scripts/shared/protection.sh index 45b6348..36946d7 100644 --- a/scripts/shared/protection.sh +++ b/scripts/shared/protection.sh @@ -121,16 +121,22 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" - _rp_response=$(curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' + if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then ROGUE_PROTECTION_STATE=''; return 0; fi + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE @@ -146,9 +152,37 @@ rogue_protection_init() { rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } +rogue_protection_fail() { + RP_PERSISTENCE_FAILED=1 + rogue_protection_load || return 0 + _rp_a=false; [ "$RP_AIDR" = 1 ] && _rp_a=true + _rp_s=false; [ "$RP_AISPM" = 1 ] && _rp_s=true + curl -fsS --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"protocolVersion\":1,\"revision\":$RP_REV,\"status\":\"failed\",\"aidrPaused\":$_rp_a,\"aispmPaused\":$_rp_s,\"error\":\"state_persistence_failed\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/ack" >/dev/null 2>&1 || true +} +rogue_protection_read_input() ( + rogue_protection_current || exit 1 + [ -n "${ROGUE_PROTECTION_STATE:-}" ] || { cat; exit; } + umask 077 + _rp_input=$(mktemp "$ROGUE_PROTECTION_STATE/input.XXXXXX") || exit 1 + exec 3<&0 + cat <&3 > "$_rp_input" & + _rp_reader=$! + ( + while kill -0 "$_rp_reader" 2>/dev/null; do + if ! rogue_protection_current || ! kill -0 "$$" 2>/dev/null; then kill "$_rp_reader" 2>/dev/null; exit; fi + sleep 0.2 + done + ) >&2 & + _rp_watch=$! + trap 'kill "$_rp_reader" "$_rp_watch" 2>/dev/null; rm -f "$_rp_input"' EXIT + wait "$_rp_reader" || exit 1 + kill "$_rp_watch" 2>/dev/null + rogue_protection_current || exit 1 + cat "$_rp_input" +) rogue_protection_enter() { rogue_protection_current || return 1 - [ -z "${ROGUE_PROTECTION_STATE:-}" ] || printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$" + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && ! printf '%s' "${ROGUE_PROTECTION_REVISION:-0}" > "$ROGUE_PROTECTION_STATE/active.$$"; then rogue_protection_fail; return 1; fi rogue_protection_current } if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then diff --git a/scripts/shared/ship-logs.ps1 b/scripts/shared/ship-logs.ps1 index dffeb83..c7d3b44 100644 --- a/scripts/shared/ship-logs.ps1 +++ b/scripts/shared/ship-logs.ps1 @@ -92,8 +92,8 @@ $script:targetFamily = '' $script:stateKey = '' $script:offset = 0 $script:stateHead = '' -$script:stateSize = 0 -$script:statePath = '' +$script:stateSize = 0; $script:stateRevision = '' +$script:statePath = ''; $script:stateRevision = '' $script:advanceBytes = 0 $script:runBytesSent = 0 $script:lineLength = 0 @@ -604,7 +604,7 @@ function Unlock-StateKey { # ── stage 7: state ───────────────────────────────────────────────────────── function Read-ShipState { param([string]$Key, [string]$NormalizedPath) - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = '' + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:statePath = ''; $script:stateRevision = '' $stateFile = Join-Path $script:stateDir "$Key.state" if (-not (Test-Path -LiteralPath $stateFile)) { return } try { @@ -625,6 +625,8 @@ function Read-ShipState { } elseif ($line -match '^size=(.*)$') { $rawSize = $Matches[1] if ($rawSize -match '^[0-9]+$') { $script:stateSize = [int64]$rawSize } else { $script:stateSize = 0 } + } elseif ($line -match '^revision=(.*)$') { + $script:stateRevision = $Matches[1] } elseif ($line -match '^path=(.*)$') { $script:statePath = $Matches[1] } @@ -635,27 +637,18 @@ function Read-ShipState { # shipper at a different file holding the previous file's offset. if ($script:statePath -and $script:statePath -ne $NormalizedPath) { Write-ShipDebug "state path mismatch ($($script:statePath) != $NormalizedPath) -> treating state as absent" - $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0 + $script:offset = 0; $script:stateHead = ''; $script:stateSize = 0; $script:stateRevision = '' } } -# Write-to-temp-then-move, so a crash mid-write cannot leave a half-written offset. -# The temp sits in the SAME directory as the destination. The destination is removed -# first: `Move-Item -Force` onto an existing file is not reliable on Windows -# PowerShell 5.1, and under -ErrorAction SilentlyContinue a failure there would -# silently freeze the offset forever. +# Commit the discard offset and its pause revision in one file replacement. function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) - try { - $destination = Join-Path $script:stateDir "$Key.state" - $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [System.IO.File]::WriteAllText( - $tempFile, - "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`n", - (New-Object System.Text.UTF8Encoding($false))) - Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tempFile -Destination $destination -Force -ErrorAction SilentlyContinue - } catch {} + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -872,9 +865,8 @@ function Ship-LogFile { Read-ShipState $script:stateKey $normalizedPath $fileBytes = Get-FileLength $Path $currentHead = Get-FirstLineFingerprint $Path - if ($script:RPDirectory -and $script:RPRevision -gt 0 -and (Get-Content -LiteralPath "$script:RPDirectory/ship-revision" -Raw -ErrorAction SilentlyContinue) -ne [string]$script:RPRevision) { + if ($script:RPDirectory -and $script:RPRevision -gt 0 -and $script:stateRevision -ne [string]$script:RPRevision) { Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath - Write-RogueProtectionFile "$script:RPDirectory/ship-revision" ([string]$script:RPRevision) return } $script:runBytesSent = 0 @@ -931,10 +923,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } + try { Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { @@ -956,6 +950,7 @@ function Invoke-Main { Ship-LogFile $target } exit 0 + } finally { Leave-RogueProtection } } # The ROGUE_PS_LIB_ONLY seam: load the helpers WITHOUT running the shipper, so @@ -963,4 +958,4 @@ function Invoke-Main { # main body stands down). Every pure helper is defined ABOVE this line. if ($env:ROGUE_PS_LIB_ONLY) { return } -Invoke-Main +try { Invoke-Main } catch { exit 0 } diff --git a/scripts/shared/ship-logs.sh b/scripts/shared/ship-logs.sh index f95e7a8..4c0471f 100644 --- a/scripts/shared/ship-logs.sh +++ b/scripts/shared/ship-logs.sh @@ -99,7 +99,7 @@ MAX_RUN_BYTES=10485760 MAX_LINE_BYTES=4194304 # per-target working state TARGET_BASENAME=""; TARGET_FAMILY=""; STATE_KEY="" -OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" +OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" ADVANCE_BYTES=0; RUN_BYTES_SENT=0 LINE_LENGTH=0; LINE_SEARCH_HIT_EOF=0 @@ -536,7 +536,7 @@ cleanup() { # ── stage 7: state ───────────────────────────────────────────────────────── read_state() { # - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH="" + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_PATH=""; STATE_REVISION="" _state_file="$STATE_DIR/${1:-}.state" [ -r "$_state_file" ] || return 0 while IFS= read -r _state_line; do @@ -550,6 +550,7 @@ read_state() { # _state_value="${_state_line#size=}" case "$_state_value" in ''|*[!0-9]*) _state_value=0 ;; esac STATE_SIZE="$_state_value" ;; + revision=*) STATE_REVISION="${_state_line#revision=}" ;; path=*) STATE_PATH="${_state_line#path=}" ;; esac done < "$_state_file" @@ -560,7 +561,7 @@ read_state() { # # directly is cheaper and clearer. if [ -n "$STATE_PATH" ] && [ "$STATE_PATH" != "${2:-}" ]; then debug "state path mismatch ($STATE_PATH != ${2:-}) -> treating state as absent" - OFFSET=0; STATE_HEAD=""; STATE_SIZE=0 + OFFSET=0; STATE_HEAD=""; STATE_SIZE=0; STATE_REVISION="" fi return 0 } @@ -569,10 +570,10 @@ read_state() { # # The temp lives in the SAME directory as the destination, or the mv is not atomic. write_state() { # _state_tmp_file="$STATE_DIR/.state-tmp-$$" - printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\n' \ - "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" > "$_state_tmp_file" 2>/dev/null || return 0 + printf 'offset=%s\nhead=%s\nsize=%s\npath=%s\nrevision=%s\n' \ + "${2:-0}" "${3:-}" "${4:-0}" "${5:-}" "${ROGUE_PROTECTION_REVISION:-}" > "$_state_tmp_file" 2>/dev/null || return 1 + [ ! -d "$STATE_DIR/${1:-}.state" ] || return 1 mv -f "$_state_tmp_file" "$STATE_DIR/${1:-}.state" 2>/dev/null - return 0 } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -784,6 +785,7 @@ post_chunk() { # printf '"}' } > "$_post_body_file" 2>/dev/null debug "POST $SHIP_URL file=$TARGET_BASENAME offset=$_post_offset bytes=$_post_bytes rotated=$_post_rotated_json" + rogue_protection_current || return 1 _post_http_code=$(curl -sS --max-time "$HTTP_TIMEOUT" -X POST "$SHIP_URL" \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -820,7 +822,7 @@ drain_file() { # read_state "$STATE_KEY" "$_target_abs_path" _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") - if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$(cat "$ROGUE_PROTECTION_STATE/ship-revision" 2>/dev/null)" != "$ROGUE_PROTECTION_REVISION" ]; then + if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" - printf '%s' "$ROGUE_PROTECTION_REVISION" > "$ROGUE_PROTECTION_STATE/ship-revision" release_lock return 0 fi @@ -899,9 +900,11 @@ main() { stand_down_on_git_bash parse_args "$@" load_env + [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" rogue_protection_enter || exit 0 + trap 'rogue_protection_leave' EXIT resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index 484ba84..0454e25 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -55,6 +55,7 @@ function startServer(status, body) { return new Promise((resolve) => { const seen = {}; const server = http.createServer((req, res) => { + if (req.url.includes("/hooks/protection/")) { req.resume(); res.writeHead(404); res.end("{}"); return; } // SessionStart and AfterAgent also spawn the DETACHED heartbeat (hook.mjs // fireHeartbeat), which POSTs to /api/v1/hooks/status and, riding along // inside it, the log shipper, which POSTs to /api/v1/hooks/logs. Both race @@ -399,6 +400,7 @@ function startCollectingServer(status, body) { return new Promise((resolve) => { const requests = []; const server = http.createServer((req, res) => { + if (req.url.includes("/hooks/protection/")) { req.resume(); res.writeHead(404); res.end("{}"); return; } let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { diff --git a/tests/test_hook_sh_kiro.sh b/tests/test_hook_sh_kiro.sh index 1e24428..23921b0 100755 --- a/tests/test_hook_sh_kiro.sh +++ b/tests/test_hook_sh_kiro.sh @@ -281,7 +281,10 @@ assert_fail_open "timeout" # ── Case 12: server down (connection refused) → fail-open ─────────────────── stop_mock run_bridge PreToolUse kiro_cli "$FIX/cli3-PreToolUse-execute_bash.json" -assert_fail_open "connection refused" +assert_eq "$LAST_RC" "0" "connection refused: exit 0" +assert_eq "$(cat "$OUT_FILE")$(cat "$ERR_FILE")" "" "connection refused: silent allow" +[ ! -f "$LAST_HOME/kiro.log" ] || { echo "FAIL: collected activity before enrollment" >&2; exit 1; } +rm -rf "$LAST_HOME" KEEP_HOME=0 # ── Case 13: unconfigured (no API key) → fail-open, no request ────────────── diff --git a/tests/test_protection.mjs b/tests/test_protection.mjs index 94e10cb..3ca4105 100644 --- a/tests/test_protection.mjs +++ b/tests/test_protection.mjs @@ -201,6 +201,89 @@ try { } available=true; }); + await test('every shell bridge cancels open stdin when its persisted decision changes', async () => { + available=true; paused=false; revision=30; + for (const plugin of ['rogue','codex','cursor','copilot','antigravity','kiro']) { + const slug=plugin==='rogue'?'claude':plugin; + const root=path.join(fixturePlugins,plugin); + const directory=path.join(temp,`${slug}-default-${createHash('sha256').update(`${base}\nprovision_${slug}`).digest('hex')}`); + fs.mkdirSync(directory,{recursive:true}); + fs.writeFileSync(path.join(directory,'decision'),`1 30 0 0 0 0 ${Math.floor(Date.now()/1000)} 30 0 ${Math.floor(Date.now()/1000)}\n`); + fs.writeFileSync(path.join(directory,'attempt'),String(Math.floor(Date.now()/1000))); + const env={...process.env,ROGUE_API_KEY:`provision_${slug}`,ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,PLUGIN_ROOT:root,CLAUDE_PLUGIN_ROOT:root,CODEX_PLUGIN_ROOT:root,CURSOR_PLUGIN_ROOT:root,ROGUE_ACTOR_EMAIL:'qa@example.test'}; + const event=({cursor:'beforeShellExecution',copilot:'preToolUse',antigravity:'PreInvocation'})[plugin] || 'PreToolUse'; + const child=spawn('sh',[path.join(root,'scripts/hook.sh'),event,'kiro_cli'],{env,stdio:['pipe','pipe','pipe']}); + let output=''; child.stdout.on('data',b=>output+=b); child.stderr.resume(); + child.stdin.write('{"private":"must-not-upload"'); + const start=requests.length; + const change=setTimeout(()=>fs.writeFileSync(path.join(directory,'decision'),`1 31 1 0 0 0 ${Math.floor(Date.now()/1000)} 31 0 ${Math.floor(Date.now()/1000)}\n`),400); + const timeout=setTimeout(()=>child.kill(),3000); + const code=await new Promise(resolve=>child.once('exit',resolve)); + clearTimeout(change);clearTimeout(timeout); + assert.equal(code,0,plugin); + assert.deepEqual(JSON.parse(output || '{}'),plugin==='antigravity'?{decision:'allow'}:{},plugin); + assert(!requests.slice(start).some(call=>!call.path.includes('/hooks/protection/') && call.body.includes('must-not-upload')),plugin); + assert(!fs.readdirSync(directory).some(name=>name.startsWith('input.')),plugin); + } + paused=true; revision=31; + }); + await test('shell lease persistence failure rejects entry and reports failed', async () => { + const directory=path.join(temp,'lease-failure'); fs.mkdirSync(directory); + const now=Math.floor(Date.now()/1000); + fs.writeFileSync(path.join(directory,'decision'),`1 31 0 0 0 0 ${now} 31 0 ${now}\n`); + const start=requests.length; + const script=`. '${path.join(repo,'scripts/shared/protection.sh')}'; mkdir "$ROGUE_PROTECTION_STATE/active.$$"; if rogue_protection_enter; then exit 9; fi`; + const result=await run('sh',['-c',script],{ROGUE_PROTECTION_STATE:directory,ROGUE_PROTECTION_BASE:base,ROGUE_API_KEY:'installation_test_key'},''); + assert.equal(result.code,0,result.err); + assert(requests.slice(start).some(call=>call.path.endsWith('/ack') && JSON.parse(call.body).status==='failed'),JSON.stringify({result,calls:requests.slice(start)})); + }); + await test('PowerShell rejects malformed decisions and lease write failures', {skip:!process.env.ROGUE_TEST_PWSH}, async () => { + const directory=path.join(temp,'ps-lease-failure'); fs.mkdirSync(directory); + fs.writeFileSync(path.join(directory,'state.json'),JSON.stringify({decision:{protocolVersion:1,revision:31,serverTime:new Date().toISOString()},receivedAt:new Date().toISOString()})); + const script=`. '${path.join(repo,'scripts/shared/protection.ps1')}'; $script:RPDirectory='${directory}'; $script:RPBase='${base}'; $script:RPKey='installation_test_key'; if (Test-RogueProtectionCurrent) {exit 8}; $decision=@{protocolVersion=1;revision=31;serverTime=[DateTimeOffset]::UtcNow.ToString('o');aidr=@{paused=$false;revision=31};aispm=@{paused=$false;revision=0}}; Write-RogueProtectionFile "$script:RPDirectory/state.json" (@{decision=$decision;receivedAt=[DateTimeOffset]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 8); if (-not (Test-RogueProtectionCurrent)) { throw "valid active decision rejected: $(Get-Content -Raw \"$script:RPDirectory/state.json\")" }; $null=New-Item -ItemType Directory "$script:RPDirectory/active.$PID"; if (Enter-RogueProtection) {exit 9}`; + const start=requests.length; + const result=await run(process.env.ROGUE_TEST_PWSH,['-NoProfile','-Command',script],{},''); + assert.equal(result.code,0,result.err); + assert(requests.slice(start).some(call=>call.path.endsWith('/ack') && JSON.parse(call.body).status==='failed'),JSON.stringify({result,calls:requests.slice(start)})); + }); + await test('failed discard offset writes cannot certify a new shipping revision', async () => { + available=true; paused=false; revision=40; + for (const slug of ['codex','gemini']) { + const root=path.join(fixturePlugins,slug); + const directory=path.join(temp,`${slug}-default-${createHash('sha256').update(`${base}\nprovision_${slug}`).digest('hex')}`); + const ship=path.join(directory,'ship');fs.mkdirSync(ship,{recursive:true}); + const state=path.join(ship,`${slug}.state`); + fs.rmSync(state,{recursive:true,force:true});fs.mkdirSync(state); + fs.writeFileSync(path.join(directory,`${slug}.log`),'paused-canary\n'); + fs.writeFileSync(path.join(directory,'attempt'),'0'); + const env={ROGUE_API_KEY:`provision_${slug}`,ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,ROGUE_ACTOR_EMAIL:'qa@example.test',ROGUE_ACTOR_NAME:'QA',ROGUE_SHIP_MIN_INTERVAL:'0'}; + const args=[path.join(root,`scripts/ship-logs.${slug==='gemini'?'mjs':'sh'}`),root,slug,'1.0.0',slug==='codex'?'openai':'gemini']; + const start=requests.length; + await run(slug==='gemini'?process.execPath:'sh',args,env,''); + fs.rmSync(state,{recursive:true}); + await run(slug==='gemini'?process.execPath:'sh',args,env,''); + assert(!requests.slice(start).some(call=>call.path.endsWith('/logs')),slug); + assert(fs.readFileSync(state,'utf8').includes('revision=40'),slug); + } + paused=true;revision=41; + const directory=path.join(temp,`codex-default-${createHash('sha256').update(`${base}\nprovision_codex`).digest('hex')}`); + fs.writeFileSync(path.join(directory,'attempt'),'0'); + const root=path.join(fixturePlugins,'codex'); + await run('sh',[path.join(root,'scripts/hook.sh'),'PreToolUse'],{ROGUE_API_KEY:'provision_codex',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,PLUGIN_ROOT:root}); + }); + await test('an unavailable enrollment never enables unscoped activity on retry', async () => { + available=false; + for (const slug of ['codex','gemini']) { + const root=path.join(fixturePlugins,slug); + const env={ROGUE_API_KEY:`unavailable_${slug}`,ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,PLUGIN_ROOT:root,CODEX_PLUGIN_ROOT:root}; + const start=requests.length; + for (let attempt=0;attempt<2;attempt++) { + const result=await run(slug==='gemini'?process.execPath:'sh',[path.join(root,`scripts/hook.${slug==='gemini'?'mjs':'sh'}`),slug==='gemini'?'BeforeTool':'PreToolUse'],env); + assert.equal(result.code,0,result.err);assert.deepEqual(JSON.parse(result.out || '{}'),{}); + } + assert(requests.slice(start).every(call=>call.path.includes('/hooks/protection/'))); + } + }); await test('a new shell invocation honors persisted pause while offline',async()=>{ available=false; const root=path.join(fixturePlugins,'codex'); diff --git a/tests/test_ship_logs.sh b/tests/test_ship_logs.sh index 73b782b..59d66d2 100644 --- a/tests/test_ship_logs.sh +++ b/tests/test_ship_logs.sh @@ -51,6 +51,7 @@ SH_PLUGINS='rogue codex cursor copilot antigravity kiro' mkdir -p "$T/bin" cat > "$T/bin/curl" <<'STUB' #!/bin/sh +for a in "$@"; do case "$a" in */hooks/protection/*) printf '\n404'; exit 0 ;; esac; done n=0 while [ -e "$CAP/body.$n" ]; do n=$((n + 1)); done for a in "$@"; do From ed2af038aa84126d062f5f8ba530037eda1fb8df Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Tue, 15 Sep 2026 00:47:57 +0300 Subject: [PATCH 03/14] test(protection): model unsupported enrollment in the log receiver (FIRE-2137) --- tests/e2e_receiver.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_receiver.mjs b/tests/e2e_receiver.mjs index 3e1c90c..928e19f 100644 --- a/tests/e2e_receiver.mjs +++ b/tests/e2e_receiver.mjs @@ -59,6 +59,7 @@ function recordEnvelope(kind, envelope) { } const server = http.createServer((req, res) => { + if (req.url?.includes("/hooks/protection/")) { req.resume(); res.writeHead(404, { "Content-Type": "application/json" }); res.end("{}"); return; } const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { From a4ddbc25677a596855150b3b435ea92769786916 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Tue, 15 Sep 2026 00:59:21 +0300 Subject: [PATCH 04/14] fix(coding): handle legacy Windows enrollment and isolate test state (FIRE-2137) --- plugins/antigravity/scripts/protection.ps1 | 18 +++++++++++------- plugins/codex/scripts/protection.ps1 | 18 +++++++++++------- plugins/copilot/scripts/protection.ps1 | 18 +++++++++++------- plugins/cursor/scripts/protection.ps1 | 18 +++++++++++------- plugins/kiro/scripts/protection.ps1 | 18 +++++++++++------- plugins/rogue/scripts/protection.ps1 | 18 +++++++++++------- scripts/shared/protection.ps1 | 18 +++++++++++------- tests/e2e_ship_logs.ps1 | 2 ++ tests/mock_server.py | 1 + tests/test_protection.mjs | 14 +++++++++++++- 10 files changed, 93 insertions(+), 50 deletions(-) diff --git a/plugins/antigravity/scripts/protection.ps1 b/plugins/antigravity/scripts/protection.ps1 index a8b9a1c..875904f 100644 --- a/plugins/antigravity/scripts/protection.ps1 +++ b/plugins/antigravity/scripts/protection.ps1 @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -59,7 +59,7 @@ function Update-RogueProtection { } catch { return } try { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $state = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { @@ -70,7 +70,7 @@ function Update-RogueProtection { $script:RPPersistenceFailed=$true try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } } } catch { } finally { $lock.Dispose() } @@ -81,7 +81,11 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } $script:RPBase=$BaseUrl.TrimEnd('/') $root=$env:ROGUE_PROTECTION_DIR - if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + if (-not $root) { + $profilePath=$env:USERPROFILE + if (-not $profilePath) { $profilePath=[Environment]::GetFolderPath('UserProfile') } + $root=Join-Path $profilePath '.rogue/protection' + } $hash=[Security.Cryptography.SHA256]::Create() try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" @@ -99,7 +103,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $previousKey) { continue } try { $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress - $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $restored=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName @@ -121,7 +125,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue - $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } @@ -189,7 +193,7 @@ function Enter-RogueProtection { $state=Get-RogueProtectionState if ($state) { $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } return $false } diff --git a/plugins/codex/scripts/protection.ps1 b/plugins/codex/scripts/protection.ps1 index a8b9a1c..875904f 100644 --- a/plugins/codex/scripts/protection.ps1 +++ b/plugins/codex/scripts/protection.ps1 @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -59,7 +59,7 @@ function Update-RogueProtection { } catch { return } try { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $state = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { @@ -70,7 +70,7 @@ function Update-RogueProtection { $script:RPPersistenceFailed=$true try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } } } catch { } finally { $lock.Dispose() } @@ -81,7 +81,11 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } $script:RPBase=$BaseUrl.TrimEnd('/') $root=$env:ROGUE_PROTECTION_DIR - if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + if (-not $root) { + $profilePath=$env:USERPROFILE + if (-not $profilePath) { $profilePath=[Environment]::GetFolderPath('UserProfile') } + $root=Join-Path $profilePath '.rogue/protection' + } $hash=[Security.Cryptography.SHA256]::Create() try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" @@ -99,7 +103,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $previousKey) { continue } try { $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress - $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $restored=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName @@ -121,7 +125,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue - $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } @@ -189,7 +193,7 @@ function Enter-RogueProtection { $state=Get-RogueProtectionState if ($state) { $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } return $false } diff --git a/plugins/copilot/scripts/protection.ps1 b/plugins/copilot/scripts/protection.ps1 index a8b9a1c..875904f 100644 --- a/plugins/copilot/scripts/protection.ps1 +++ b/plugins/copilot/scripts/protection.ps1 @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -59,7 +59,7 @@ function Update-RogueProtection { } catch { return } try { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $state = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { @@ -70,7 +70,7 @@ function Update-RogueProtection { $script:RPPersistenceFailed=$true try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } } } catch { } finally { $lock.Dispose() } @@ -81,7 +81,11 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } $script:RPBase=$BaseUrl.TrimEnd('/') $root=$env:ROGUE_PROTECTION_DIR - if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + if (-not $root) { + $profilePath=$env:USERPROFILE + if (-not $profilePath) { $profilePath=[Environment]::GetFolderPath('UserProfile') } + $root=Join-Path $profilePath '.rogue/protection' + } $hash=[Security.Cryptography.SHA256]::Create() try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" @@ -99,7 +103,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $previousKey) { continue } try { $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress - $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $restored=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName @@ -121,7 +125,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue - $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } @@ -189,7 +193,7 @@ function Enter-RogueProtection { $state=Get-RogueProtectionState if ($state) { $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } return $false } diff --git a/plugins/cursor/scripts/protection.ps1 b/plugins/cursor/scripts/protection.ps1 index a8b9a1c..875904f 100644 --- a/plugins/cursor/scripts/protection.ps1 +++ b/plugins/cursor/scripts/protection.ps1 @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -59,7 +59,7 @@ function Update-RogueProtection { } catch { return } try { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $state = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { @@ -70,7 +70,7 @@ function Update-RogueProtection { $script:RPPersistenceFailed=$true try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } } } catch { } finally { $lock.Dispose() } @@ -81,7 +81,11 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } $script:RPBase=$BaseUrl.TrimEnd('/') $root=$env:ROGUE_PROTECTION_DIR - if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + if (-not $root) { + $profilePath=$env:USERPROFILE + if (-not $profilePath) { $profilePath=[Environment]::GetFolderPath('UserProfile') } + $root=Join-Path $profilePath '.rogue/protection' + } $hash=[Security.Cryptography.SHA256]::Create() try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" @@ -99,7 +103,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $previousKey) { continue } try { $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress - $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $restored=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName @@ -121,7 +125,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue - $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } @@ -189,7 +193,7 @@ function Enter-RogueProtection { $state=Get-RogueProtectionState if ($state) { $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } return $false } diff --git a/plugins/kiro/scripts/protection.ps1 b/plugins/kiro/scripts/protection.ps1 index a8b9a1c..875904f 100644 --- a/plugins/kiro/scripts/protection.ps1 +++ b/plugins/kiro/scripts/protection.ps1 @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -59,7 +59,7 @@ function Update-RogueProtection { } catch { return } try { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $state = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { @@ -70,7 +70,7 @@ function Update-RogueProtection { $script:RPPersistenceFailed=$true try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } } } catch { } finally { $lock.Dispose() } @@ -81,7 +81,11 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } $script:RPBase=$BaseUrl.TrimEnd('/') $root=$env:ROGUE_PROTECTION_DIR - if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + if (-not $root) { + $profilePath=$env:USERPROFILE + if (-not $profilePath) { $profilePath=[Environment]::GetFolderPath('UserProfile') } + $root=Join-Path $profilePath '.rogue/protection' + } $hash=[Security.Cryptography.SHA256]::Create() try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" @@ -99,7 +103,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $previousKey) { continue } try { $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress - $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $restored=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName @@ -121,7 +125,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue - $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } @@ -189,7 +193,7 @@ function Enter-RogueProtection { $state=Get-RogueProtectionState if ($state) { $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } return $false } diff --git a/plugins/rogue/scripts/protection.ps1 b/plugins/rogue/scripts/protection.ps1 index a8b9a1c..875904f 100644 --- a/plugins/rogue/scripts/protection.ps1 +++ b/plugins/rogue/scripts/protection.ps1 @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -59,7 +59,7 @@ function Update-RogueProtection { } catch { return } try { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $state = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { @@ -70,7 +70,7 @@ function Update-RogueProtection { $script:RPPersistenceFailed=$true try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } } } catch { } finally { $lock.Dispose() } @@ -81,7 +81,11 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } $script:RPBase=$BaseUrl.TrimEnd('/') $root=$env:ROGUE_PROTECTION_DIR - if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + if (-not $root) { + $profilePath=$env:USERPROFILE + if (-not $profilePath) { $profilePath=[Environment]::GetFolderPath('UserProfile') } + $root=Join-Path $profilePath '.rogue/protection' + } $hash=[Security.Cryptography.SHA256]::Create() try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" @@ -99,7 +103,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $previousKey) { continue } try { $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress - $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $restored=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName @@ -121,7 +125,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue - $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } @@ -189,7 +193,7 @@ function Enter-RogueProtection { $state=Get-RogueProtectionState if ($state) { $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } return $false } diff --git a/scripts/shared/protection.ps1 b/scripts/shared/protection.ps1 index a8b9a1c..875904f 100644 --- a/scripts/shared/protection.ps1 +++ b/scripts/shared/protection.ps1 @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -59,7 +59,7 @@ function Update-RogueProtection { } catch { return } try { Write-RogueProtectionFile "$script:RPDirectory/attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) - $state = Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 + $state = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/state" -Headers @{'x-rogue-api-key'=$script:RPKey} -TimeoutSec 5 $old = Get-RogueProtectionState if ((Test-RogueProtectionDecision $state) -and ($null -eq $old -or $state.revision -ge $old.revision)) { try { @@ -70,7 +70,7 @@ function Update-RogueProtection { $script:RPPersistenceFailed=$true try { Write-RogueProtectionFile "$script:RPDirectory/persistence-failed" '1' } catch {} $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } } } catch { } finally { $lock.Dispose() } @@ -81,7 +81,11 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } $script:RPBase=$BaseUrl.TrimEnd('/') $root=$env:ROGUE_PROTECTION_DIR - if (-not $root) { $root=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.rogue/protection' } + if (-not $root) { + $profilePath=$env:USERPROFILE + if (-not $profilePath) { $profilePath=[Environment]::GetFolderPath('UserProfile') } + $root=Join-Path $profilePath '.rogue/protection' + } $hash=[Security.Cryptography.SHA256]::Create() try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" @@ -99,7 +103,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $previousKey) { continue } try { $body=@{type='coding_agent';name=$Slug;family=$Family;host=[Environment]::MachineName;version=$Version} | ConvertTo-Json -Compress - $restored=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $restored=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key;'x-rogue-installation-key'=$previousKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName @@ -121,7 +125,7 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue - $enrolled=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } @@ -189,7 +193,7 @@ function Enter-RogueProtection { $state=Get-RogueProtectionState if ($state) { $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=$state.aidr.paused;aispmPaused=$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress - try { $null=Invoke-RestMethod -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} } return $false } diff --git a/tests/e2e_ship_logs.ps1 b/tests/e2e_ship_logs.ps1 index 31e2a3c..6d88bb8 100644 --- a/tests/e2e_ship_logs.ps1 +++ b/tests/e2e_ship_logs.ps1 @@ -361,6 +361,8 @@ $snippetRoot = Join-Path $sandbox 'plugintree' New-Item -ItemType Directory -Path (Join-Path $snippetRoot 'scripts') -Force | Out-Null Copy-Item -LiteralPath (Join-Path $repo 'plugins\rogue\scripts\ship-logs.ps1') ` -Destination (Join-Path $snippetRoot 'scripts\ship-logs.ps1') -Force +Copy-Item -LiteralPath (Join-Path $repo 'plugins\rogue\scripts\protection.ps1') ` + -Destination (Join-Path $snippetRoot 'scripts\protection.ps1') -Force Copy-Item -LiteralPath (Join-Path $repo 'plugins\rogue\scripts\env-file.ps1') ` -Destination (Join-Path $snippetRoot 'scripts\env-file.ps1') -Force # `e2e-key` because that is what the receiver accepts (E2E_API_KEY above); the point diff --git a/tests/mock_server.py b/tests/mock_server.py index 9b27758..41fe412 100644 --- a/tests/mock_server.py +++ b/tests/mock_server.py @@ -48,4 +48,5 @@ def log_message(self, *_): # silence default access log if __name__ == "__main__": port = int(sys.argv[1]) + http.server.HTTPServer.allow_reuse_address = True http.server.HTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/tests/test_protection.mjs b/tests/test_protection.mjs index 3ca4105..d1e0040 100644 --- a/tests/test_protection.mjs +++ b/tests/test_protection.mjs @@ -19,11 +19,12 @@ function isolateEnv(directory) { } } isolateEnv(fixturePlugins); -let revision=1, paused=true, available=true; +let revision=1, paused=true, available=true, legacy=false; const requests=[]; const server=http.createServer(async (req,res) => { let body=''; for await (const chunk of req) body+=chunk; requests.push({path:req.url, body, key:req.headers["x-rogue-api-key"], installationKey:req.headers["x-rogue-installation-key"]}); + if (legacy && req.url.includes('/hooks/protection/')) { res.statusCode=404; res.end('{}'); return; } if (!available) { res.statusCode=503; res.end('{}'); return; } res.setHeader('content-type','application/json'); if (req.url.endsWith('/enroll')) { res.end(JSON.stringify({apiKey:'installation_test_key', installation:{id:'test',type:'coding_agent',role:'coding'}})); return; } @@ -108,6 +109,17 @@ try { assert(uploads.every(call=>call.key==='installation_test_key' && !Buffer.from(JSON.parse(call.body).content_b64,'base64').toString().includes('paused-node-canary'))); assert(uploads.some(call=>Buffer.from(JSON.parse(call.body).content_b64,'base64').toString().includes('fresh-node-canary'))); }); + await test('PowerShell legacy enrollment works with the shipper error preference', {skip:!process.env.ROGUE_TEST_PWSH}, async () => { + legacy=true; + try { + const profile=path.join(temp,'ps-legacy-profile'); fs.mkdirSync(profile); + const script=`$ErrorActionPreference='SilentlyContinue'; . '${path.join(repo,'scripts/shared/protection.ps1')}'; $key=Initialize-RogueProtection -Key 'legacy_key' -BaseUrl '${base}' -Slug 'claude' -Family 'claude'; if ($key -ne 'legacy_key' -or -not (Enter-RogueProtection)) {exit 9}; Write-Output 'allowed'`; + const result=await run(process.env.ROGUE_TEST_PWSH,['-NoProfile','-Command',script],{USERPROFILE:profile,ROGUE_PROTECTION_DIR:''},''); + assert.equal(result.code,0,result.err); assert.match(result.out,/allowed/); + const root=path.join(profile,'.rogue/protection'); + assert(fs.readdirSync(root).some(name=>fs.existsSync(path.join(root,name,'legacy-server')))); + } finally {legacy=false;} + }); await test('PowerShell gate persists the same pause contract', {skip:!process.env.ROGUE_TEST_PWSH}, async () => { paused=true; revision++; const helper=path.join(repo,'scripts/shared/protection.ps1').replaceAll("'","''"); From 9b5fbe256aba92a432dad736a2465eeb00ba8df8 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Tue, 15 Sep 2026 01:02:15 +0300 Subject: [PATCH 05/14] fix(coding): resolve protection helper from supplied plugin root (FIRE-2137) --- plugins/antigravity/scripts/ship-logs.ps1 | 6 +++--- plugins/codex/scripts/ship-logs.ps1 | 6 +++--- plugins/copilot/scripts/ship-logs.ps1 | 6 +++--- plugins/cursor/scripts/ship-logs.ps1 | 6 +++--- plugins/kiro/scripts/ship-logs.ps1 | 6 +++--- plugins/rogue/scripts/ship-logs.ps1 | 6 +++--- scripts/shared/ship-logs.ps1 | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/plugins/antigravity/scripts/ship-logs.ps1 b/plugins/antigravity/scripts/ship-logs.ps1 index c7d3b44..a5fb907 100644 --- a/plugins/antigravity/scripts/ship-logs.ps1 +++ b/plugins/antigravity/scripts/ship-logs.ps1 @@ -923,9 +923,9 @@ function Invoke-Main { Initialize-Args Import-ShipEnv - if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') - $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if (-not (Test-Path -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } try { diff --git a/plugins/codex/scripts/ship-logs.ps1 b/plugins/codex/scripts/ship-logs.ps1 index c7d3b44..a5fb907 100644 --- a/plugins/codex/scripts/ship-logs.ps1 +++ b/plugins/codex/scripts/ship-logs.ps1 @@ -923,9 +923,9 @@ function Invoke-Main { Initialize-Args Import-ShipEnv - if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') - $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if (-not (Test-Path -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } try { diff --git a/plugins/copilot/scripts/ship-logs.ps1 b/plugins/copilot/scripts/ship-logs.ps1 index c7d3b44..a5fb907 100644 --- a/plugins/copilot/scripts/ship-logs.ps1 +++ b/plugins/copilot/scripts/ship-logs.ps1 @@ -923,9 +923,9 @@ function Invoke-Main { Initialize-Args Import-ShipEnv - if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') - $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if (-not (Test-Path -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } try { diff --git a/plugins/cursor/scripts/ship-logs.ps1 b/plugins/cursor/scripts/ship-logs.ps1 index c7d3b44..a5fb907 100644 --- a/plugins/cursor/scripts/ship-logs.ps1 +++ b/plugins/cursor/scripts/ship-logs.ps1 @@ -923,9 +923,9 @@ function Invoke-Main { Initialize-Args Import-ShipEnv - if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') - $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if (-not (Test-Path -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } try { diff --git a/plugins/kiro/scripts/ship-logs.ps1 b/plugins/kiro/scripts/ship-logs.ps1 index c7d3b44..a5fb907 100644 --- a/plugins/kiro/scripts/ship-logs.ps1 +++ b/plugins/kiro/scripts/ship-logs.ps1 @@ -923,9 +923,9 @@ function Invoke-Main { Initialize-Args Import-ShipEnv - if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') - $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if (-not (Test-Path -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } try { diff --git a/plugins/rogue/scripts/ship-logs.ps1 b/plugins/rogue/scripts/ship-logs.ps1 index c7d3b44..a5fb907 100644 --- a/plugins/rogue/scripts/ship-logs.ps1 +++ b/plugins/rogue/scripts/ship-logs.ps1 @@ -923,9 +923,9 @@ function Invoke-Main { Initialize-Args Import-ShipEnv - if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') - $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if (-not (Test-Path -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } try { diff --git a/scripts/shared/ship-logs.ps1 b/scripts/shared/ship-logs.ps1 index c7d3b44..a5fb907 100644 --- a/scripts/shared/ship-logs.ps1 +++ b/scripts/shared/ship-logs.ps1 @@ -923,9 +923,9 @@ function Invoke-Main { Initialize-Args Import-ShipEnv - if (-not (Test-Path -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $script:PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $script:PluginRoot 'scripts') - $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $script:ShipperSlug -Family $script:AgentFamily + if (-not (Test-Path -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') + $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } if (-not (Enter-RogueProtection)) { exit 0 } try { From 9681fbfe646311508b08982ee8212f14acb282fe Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Tue, 15 Sep 2026 02:24:37 +0300 Subject: [PATCH 06/14] fix(hooks): preserve pause state during recovery failures (FIRE-2137) --- plugins/antigravity/scripts/hook.ps1 | 10 +++--- plugins/antigravity/scripts/hook.sh | 12 +++---- plugins/antigravity/scripts/protection.ps1 | 18 ++++++---- plugins/antigravity/scripts/protection.sh | 8 +++-- plugins/antigravity/scripts/ship-logs.ps1 | 18 +++++++--- plugins/antigravity/scripts/ship-logs.sh | 2 +- plugins/codex/scripts/protection.ps1 | 18 ++++++---- plugins/codex/scripts/protection.sh | 8 +++-- plugins/codex/scripts/ship-logs.ps1 | 18 +++++++--- plugins/codex/scripts/ship-logs.sh | 2 +- plugins/copilot/scripts/protection.ps1 | 18 ++++++---- plugins/copilot/scripts/protection.sh | 8 +++-- plugins/copilot/scripts/ship-logs.ps1 | 18 +++++++--- plugins/copilot/scripts/ship-logs.sh | 2 +- plugins/cursor/scripts/hook.sh | 2 +- plugins/cursor/scripts/protection.ps1 | 18 ++++++---- plugins/cursor/scripts/protection.sh | 8 +++-- plugins/cursor/scripts/ship-logs.ps1 | 18 +++++++--- plugins/cursor/scripts/ship-logs.sh | 2 +- plugins/gemini/scripts/protection.mjs | 8 +++-- plugins/kiro/scripts/hook.ps1 | 2 +- plugins/kiro/scripts/protection.ps1 | 18 ++++++---- plugins/kiro/scripts/protection.sh | 8 +++-- plugins/kiro/scripts/ship-logs.ps1 | 18 +++++++--- plugins/kiro/scripts/ship-logs.sh | 2 +- plugins/rogue/scripts/protection.ps1 | 18 ++++++---- plugins/rogue/scripts/protection.sh | 8 +++-- plugins/rogue/scripts/ship-logs.ps1 | 18 +++++++--- plugins/rogue/scripts/ship-logs.sh | 2 +- scripts/shared/protection.ps1 | 18 ++++++---- scripts/shared/protection.sh | 8 +++-- scripts/shared/ship-logs.ps1 | 18 +++++++--- scripts/shared/ship-logs.sh | 2 +- tests/test_hook_sh_antigravity.sh | 2 ++ tests/test_protection.mjs | 41 ++++++++++++++++++++-- 35 files changed, 282 insertions(+), 117 deletions(-) diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index 894aa2e..439f5d3 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -312,7 +312,7 @@ function Resolve-Actor { # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── function Read-Payload { $script:payload = Read-RogueProtectionInput - if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } + if (-not (Test-RogueProtectionCurrent)) { Write-Raw (Get-FailOpenDefault); exit 0 } if (-not $script:payload) { $script:payload = '{}' } try { $raw = [Console]::InputEncoding.GetBytes($script:payload) @@ -914,7 +914,7 @@ function Invoke-Post { $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) $resp = '' try { - if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } + if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and -not (Test-RogueProtectionCurrent)) { Write-Raw (Get-FailOpenDefault); exit 0 } if ((Get-Command Test-RogueProtectionCurrent -ErrorAction SilentlyContinue) -and $null -ne $script:RPRevision) { $headers['x-rogue-activity-revision']=[string]$script:RPRevision } $r = Invoke-WebRequest -Uri $url -Method Post ` -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` @@ -926,7 +926,7 @@ function Invoke-Post { } catch { Dbg "POST failed: $($_.Exception.Message)"; $resp = '' } $respHead = if ($resp.Length -gt 400) { $resp.Substring(0, 400) } else { $resp } - if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } + if (-not (Test-RogueProtectionCurrent)) { Write-Raw (Get-FailOpenDefault); exit 0 } Log "raw=$(Sanitize $respHead)" # Fail-open on transport error, any non-200, or an empty body: emit the @@ -963,10 +963,10 @@ function Invoke-Main { Assert-ApiKey # exits before stdin is read when there is no key Resolve-Url Resolve-Actor -if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } +if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { Write-Raw (Get-FailOpenDefault); exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') $script:apiKey = Initialize-RogueProtection -Key $script:apiKey -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug 'antigravity' -Family 'antigravity' -if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{"decision":"allow"}'); exit 0 } +if (-not (Enter-RogueProtection)) { Write-Raw (Get-FailOpenDefault); exit 0 } try { Read-Payload diff --git a/plugins/antigravity/scripts/hook.sh b/plugins/antigravity/scripts/hook.sh index 239a1ed..4414d50 100755 --- a/plugins/antigravity/scripts/hook.sh +++ b/plugins/antigravity/scripts/hook.sh @@ -658,8 +658,8 @@ load_install_id() { # Buffer stdin so we can enrich it (PreInvocation/PostInvocation/Stop) before # POSTing. read_body() { - BODY="$(rogue_protection_read_input)" -rogue_protection_current || { printf '%s' '{"decision":"allow"}'; exit 0; } + BODY="$(rogue_protection_read_input)" || { fail_open_default; exit 0; } +rogue_protection_current || { fail_open_default; exit 0; } } # Heartbeat, fired detached so the hook itself returns immediately regardless of @@ -786,7 +786,7 @@ post_and_relay() { fi fi -rogue_protection_current || { printf '%s' '{"decision":"allow"}'; exit 0; } +rogue_protection_current || { fail_open_default; exit 0; } _raw=$(printf '%s' "$BODY" | curl -sS -X POST "$URL" \ -H "x-rogue-api-key: $ROGUE_API_KEY" \ -H "x-rogue-activity-revision: ${ROGUE_PROTECTION_REVISION:-}" \ @@ -803,7 +803,7 @@ rogue_protection_current || { printf '%s' '{"decision":"allow"}'; exit 0; } _code=$(printf '%s' "$_raw" | tail -n1) _resp=$(printf '%s' "$_raw" | sed '$d') -rogue_protection_current || { printf '%s' '{"decision":"allow"}'; exit 0; } +rogue_protection_current || { fail_open_default; exit 0; } log "http=$_code rc=$_rc raw=$(sanitize "$_resp" | head -c 400)" # Fail-open on transport error, any non-200, or an empty body: emit the @@ -829,10 +829,10 @@ main() { stand_down_under_git_bash locate_plugin_root load_env # sources the env files, then every default derived from them -[ -r "${PLUGIN_ROOT}/scripts/protection.sh" ] || { printf '%s' '{"decision":"allow"}'; exit 0; } +[ -r "${PLUGIN_ROOT}/scripts/protection.sh" ] || { fail_open_default; exit 0; } . "${PLUGIN_ROOT}/scripts/protection.sh" rogue_protection_init antigravity antigravity "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" -rogue_protection_enter || { printf '%s' '{"decision":"allow"}'; exit 0; } +rogue_protection_enter || { fail_open_default; exit 0; } trap 'rogue_protection_leave' EXIT require_api_key # exits before stdin is read when there is no key diff --git a/plugins/antigravity/scripts/protection.ps1 b/plugins/antigravity/scripts/protection.ps1 index 875904f..df66a6d 100644 --- a/plugins/antigravity/scripts/protection.ps1 +++ b/plugins/antigravity/scripts/protection.ps1 @@ -35,7 +35,7 @@ function Test-RogueProtectionCurrent { $state = Get-RogueProtectionState return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) } -function Send-RogueProtectionAck { +function Send-RogueProtectionAck([int]$TimeoutSec=5) { if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } $state = Get-RogueProtectionState if (-not $state) { return } @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec $TimeoutSec Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -116,7 +116,8 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } + $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { @@ -124,13 +125,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress - Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } } catch { - if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + if ($_.Exception.Response) { + if ([int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + else { Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue } + } } finally { $lock.Dispose() } } if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } @@ -179,10 +183,10 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } -function Leave-RogueProtection { +function Leave-RogueProtection([int]$TimeoutSec=5) { if (-not $script:RPDirectory) { return } Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue - Send-RogueProtectionAck + Send-RogueProtectionAck -TimeoutSec $TimeoutSec } function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } diff --git a/plugins/antigravity/scripts/protection.sh b/plugins/antigravity/scripts/protection.sh index 36946d7..405aba5 100644 --- a/plugins/antigravity/scripts/protection.sh +++ b/plugins/antigravity/scripts/protection.sh @@ -121,7 +121,8 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then @@ -130,7 +131,10 @@ rogue_protection_init() { _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' - if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi + case "$(printf '%s' "$_rp_response" | tail -n 1)" in + 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; + [1-5]??) rm -f "$ROGUE_PROTECTION_STATE/legacy-server" ;; + esac _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi diff --git a/plugins/antigravity/scripts/ship-logs.ps1 b/plugins/antigravity/scripts/ship-logs.ps1 index a5fb907..cd046d0 100644 --- a/plugins/antigravity/scripts/ship-logs.ps1 +++ b/plugins/antigravity/scripts/ship-logs.ps1 @@ -646,9 +646,19 @@ function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) $destination = Join-Path $script:stateDir "$Key.state" $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) - if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } - else { [IO.File]::Move($tempFile, $destination) } + try { + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } + } catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + throw + } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -906,7 +916,7 @@ function Ship-LogFile { Write-ShipState $script:stateKey 0 $currentHead $fileBytes $normalizedPath } - [void](Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath) + if (-not (Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath)) { return } } finally { # try/finally, so an early return still releases the lock. Unlock-StateKey diff --git a/plugins/antigravity/scripts/ship-logs.sh b/plugins/antigravity/scripts/ship-logs.sh index 4c0471f..c954e7f 100644 --- a/plugins/antigravity/scripts/ship-logs.sh +++ b/plugins/antigravity/scripts/ship-logs.sh @@ -845,7 +845,7 @@ ship_log_file() { # _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then - write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } release_lock return 0 fi diff --git a/plugins/codex/scripts/protection.ps1 b/plugins/codex/scripts/protection.ps1 index 875904f..df66a6d 100644 --- a/plugins/codex/scripts/protection.ps1 +++ b/plugins/codex/scripts/protection.ps1 @@ -35,7 +35,7 @@ function Test-RogueProtectionCurrent { $state = Get-RogueProtectionState return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) } -function Send-RogueProtectionAck { +function Send-RogueProtectionAck([int]$TimeoutSec=5) { if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } $state = Get-RogueProtectionState if (-not $state) { return } @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec $TimeoutSec Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -116,7 +116,8 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } + $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { @@ -124,13 +125,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress - Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } } catch { - if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + if ($_.Exception.Response) { + if ([int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + else { Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue } + } } finally { $lock.Dispose() } } if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } @@ -179,10 +183,10 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } -function Leave-RogueProtection { +function Leave-RogueProtection([int]$TimeoutSec=5) { if (-not $script:RPDirectory) { return } Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue - Send-RogueProtectionAck + Send-RogueProtectionAck -TimeoutSec $TimeoutSec } function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } diff --git a/plugins/codex/scripts/protection.sh b/plugins/codex/scripts/protection.sh index 36946d7..405aba5 100644 --- a/plugins/codex/scripts/protection.sh +++ b/plugins/codex/scripts/protection.sh @@ -121,7 +121,8 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then @@ -130,7 +131,10 @@ rogue_protection_init() { _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' - if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi + case "$(printf '%s' "$_rp_response" | tail -n 1)" in + 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; + [1-5]??) rm -f "$ROGUE_PROTECTION_STATE/legacy-server" ;; + esac _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi diff --git a/plugins/codex/scripts/ship-logs.ps1 b/plugins/codex/scripts/ship-logs.ps1 index a5fb907..cd046d0 100644 --- a/plugins/codex/scripts/ship-logs.ps1 +++ b/plugins/codex/scripts/ship-logs.ps1 @@ -646,9 +646,19 @@ function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) $destination = Join-Path $script:stateDir "$Key.state" $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) - if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } - else { [IO.File]::Move($tempFile, $destination) } + try { + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } + } catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + throw + } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -906,7 +916,7 @@ function Ship-LogFile { Write-ShipState $script:stateKey 0 $currentHead $fileBytes $normalizedPath } - [void](Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath) + if (-not (Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath)) { return } } finally { # try/finally, so an early return still releases the lock. Unlock-StateKey diff --git a/plugins/codex/scripts/ship-logs.sh b/plugins/codex/scripts/ship-logs.sh index 4c0471f..c954e7f 100644 --- a/plugins/codex/scripts/ship-logs.sh +++ b/plugins/codex/scripts/ship-logs.sh @@ -845,7 +845,7 @@ ship_log_file() { # _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then - write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } release_lock return 0 fi diff --git a/plugins/copilot/scripts/protection.ps1 b/plugins/copilot/scripts/protection.ps1 index 875904f..df66a6d 100644 --- a/plugins/copilot/scripts/protection.ps1 +++ b/plugins/copilot/scripts/protection.ps1 @@ -35,7 +35,7 @@ function Test-RogueProtectionCurrent { $state = Get-RogueProtectionState return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) } -function Send-RogueProtectionAck { +function Send-RogueProtectionAck([int]$TimeoutSec=5) { if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } $state = Get-RogueProtectionState if (-not $state) { return } @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec $TimeoutSec Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -116,7 +116,8 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } + $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { @@ -124,13 +125,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress - Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } } catch { - if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + if ($_.Exception.Response) { + if ([int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + else { Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue } + } } finally { $lock.Dispose() } } if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } @@ -179,10 +183,10 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } -function Leave-RogueProtection { +function Leave-RogueProtection([int]$TimeoutSec=5) { if (-not $script:RPDirectory) { return } Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue - Send-RogueProtectionAck + Send-RogueProtectionAck -TimeoutSec $TimeoutSec } function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } diff --git a/plugins/copilot/scripts/protection.sh b/plugins/copilot/scripts/protection.sh index 36946d7..405aba5 100644 --- a/plugins/copilot/scripts/protection.sh +++ b/plugins/copilot/scripts/protection.sh @@ -121,7 +121,8 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then @@ -130,7 +131,10 @@ rogue_protection_init() { _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' - if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi + case "$(printf '%s' "$_rp_response" | tail -n 1)" in + 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; + [1-5]??) rm -f "$ROGUE_PROTECTION_STATE/legacy-server" ;; + esac _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi diff --git a/plugins/copilot/scripts/ship-logs.ps1 b/plugins/copilot/scripts/ship-logs.ps1 index a5fb907..cd046d0 100644 --- a/plugins/copilot/scripts/ship-logs.ps1 +++ b/plugins/copilot/scripts/ship-logs.ps1 @@ -646,9 +646,19 @@ function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) $destination = Join-Path $script:stateDir "$Key.state" $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) - if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } - else { [IO.File]::Move($tempFile, $destination) } + try { + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } + } catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + throw + } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -906,7 +916,7 @@ function Ship-LogFile { Write-ShipState $script:stateKey 0 $currentHead $fileBytes $normalizedPath } - [void](Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath) + if (-not (Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath)) { return } } finally { # try/finally, so an early return still releases the lock. Unlock-StateKey diff --git a/plugins/copilot/scripts/ship-logs.sh b/plugins/copilot/scripts/ship-logs.sh index 4c0471f..c954e7f 100644 --- a/plugins/copilot/scripts/ship-logs.sh +++ b/plugins/copilot/scripts/ship-logs.sh @@ -845,7 +845,7 @@ ship_log_file() { # _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then - write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } release_lock return 0 fi diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 3e09a71..77cd5c7 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -231,7 +231,7 @@ else fi # ── payload from stdin ───────────────────────────────────────────────────── -PAYLOAD="$(rogue_protection_read_input 2>/dev/null)" +PAYLOAD="$(rogue_protection_read_input 2>/dev/null)" || { printf '%s' '{}'; exit 0; } rogue_protection_current || { printf '%s' '{}'; exit 0; } [ -n "$PAYLOAD" ] || PAYLOAD='{}' # Strip a leading UTF-8 BOM if present. Cursor on Windows prepends one to the diff --git a/plugins/cursor/scripts/protection.ps1 b/plugins/cursor/scripts/protection.ps1 index 875904f..df66a6d 100644 --- a/plugins/cursor/scripts/protection.ps1 +++ b/plugins/cursor/scripts/protection.ps1 @@ -35,7 +35,7 @@ function Test-RogueProtectionCurrent { $state = Get-RogueProtectionState return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) } -function Send-RogueProtectionAck { +function Send-RogueProtectionAck([int]$TimeoutSec=5) { if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } $state = Get-RogueProtectionState if (-not $state) { return } @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec $TimeoutSec Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -116,7 +116,8 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } + $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { @@ -124,13 +125,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress - Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } } catch { - if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + if ($_.Exception.Response) { + if ([int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + else { Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue } + } } finally { $lock.Dispose() } } if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } @@ -179,10 +183,10 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } -function Leave-RogueProtection { +function Leave-RogueProtection([int]$TimeoutSec=5) { if (-not $script:RPDirectory) { return } Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue - Send-RogueProtectionAck + Send-RogueProtectionAck -TimeoutSec $TimeoutSec } function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } diff --git a/plugins/cursor/scripts/protection.sh b/plugins/cursor/scripts/protection.sh index 36946d7..405aba5 100644 --- a/plugins/cursor/scripts/protection.sh +++ b/plugins/cursor/scripts/protection.sh @@ -121,7 +121,8 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then @@ -130,7 +131,10 @@ rogue_protection_init() { _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' - if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi + case "$(printf '%s' "$_rp_response" | tail -n 1)" in + 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; + [1-5]??) rm -f "$ROGUE_PROTECTION_STATE/legacy-server" ;; + esac _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi diff --git a/plugins/cursor/scripts/ship-logs.ps1 b/plugins/cursor/scripts/ship-logs.ps1 index a5fb907..cd046d0 100644 --- a/plugins/cursor/scripts/ship-logs.ps1 +++ b/plugins/cursor/scripts/ship-logs.ps1 @@ -646,9 +646,19 @@ function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) $destination = Join-Path $script:stateDir "$Key.state" $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) - if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } - else { [IO.File]::Move($tempFile, $destination) } + try { + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } + } catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + throw + } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -906,7 +916,7 @@ function Ship-LogFile { Write-ShipState $script:stateKey 0 $currentHead $fileBytes $normalizedPath } - [void](Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath) + if (-not (Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath)) { return } } finally { # try/finally, so an early return still releases the lock. Unlock-StateKey diff --git a/plugins/cursor/scripts/ship-logs.sh b/plugins/cursor/scripts/ship-logs.sh index 4c0471f..c954e7f 100644 --- a/plugins/cursor/scripts/ship-logs.sh +++ b/plugins/cursor/scripts/ship-logs.sh @@ -845,7 +845,7 @@ ship_log_file() { # _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then - write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } release_lock return 0 fi diff --git a/plugins/gemini/scripts/protection.mjs b/plugins/gemini/scripts/protection.mjs index 5735b07..327f408 100644 --- a/plugins/gemini/scripts/protection.mjs +++ b/plugins/gemini/scripts/protection.mjs @@ -108,7 +108,8 @@ export class Protection { } } if (!key) { - if (Date.now()-Number(read(client.file('enroll-attempt'))) < 60000) return read(client.file('legacy-server')) ? undefined : client; + const elapsed=Date.now()-Number(read(client.file('enroll-attempt'))); + if (elapsed >= 0 && elapsed < 60000) return read(client.file('legacy-server')) ? undefined : client; const lock=client.file('enroll.lock'); try { fs.writeFileSync(lock, String(process.pid), {flag:'wx', mode:0o600}); } catch { if (!alive(read(lock))) fs.rmSync(lock, {force:true}); return client; } @@ -116,8 +117,8 @@ export class Protection { write(client.file('enroll-attempt'), String(Date.now())); let enrollmentNonce=read(client.file('enrollment-nonce')); if (!enrollmentNonce) { enrollmentNonce=randomUUID(); write(client.file('enrollment-nonce'),enrollmentNonce); } - fs.rmSync(client.file('legacy-server'),{force:true}); const enrolled=await client.request('enroll', {enrollmentNonce,type:'coding_agent', name:slug, family, host:os.hostname(), version:env.ROGUE_INSTALL_VERSION || 'unknown'}); + fs.rmSync(client.file('legacy-server'),{force:true}); key=enrolled.alreadyEnrolled ? env.ROGUE_API_KEY : enrolled.apiKey; if (typeof key !== 'string' || !key) return client; write(client.file('credential'), key); @@ -140,7 +141,8 @@ export class Protection { const key=read(client.file('credential')); if (!key) { if (error.status === 404) { try { write(client.file('legacy-server'),'1'); } catch {} return undefined; } - return client; + if (error.status) fs.rmSync(client.file('legacy-server'),{force:true}); + return read(client.file('legacy-server')) ? undefined : client; } client.key=key; client.revision=client.state()?.aidr.revision; return client; } diff --git a/plugins/kiro/scripts/hook.ps1 b/plugins/kiro/scripts/hook.ps1 index 0ab348b..1b3630f 100644 --- a/plugins/kiro/scripts/hook.ps1 +++ b/plugins/kiro/scripts/hook.ps1 @@ -452,7 +452,7 @@ try { Send-KiroRequest if (-not (Test-RogueProtectionCurrent)) { exit 0 } Write-KiroDecision -} finally { Leave-RogueProtection } +} finally { Leave-RogueProtection -TimeoutSec 1 } } # Dot-sourcing through the test seam defines every function without running it. diff --git a/plugins/kiro/scripts/protection.ps1 b/plugins/kiro/scripts/protection.ps1 index 875904f..df66a6d 100644 --- a/plugins/kiro/scripts/protection.ps1 +++ b/plugins/kiro/scripts/protection.ps1 @@ -35,7 +35,7 @@ function Test-RogueProtectionCurrent { $state = Get-RogueProtectionState return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) } -function Send-RogueProtectionAck { +function Send-RogueProtectionAck([int]$TimeoutSec=5) { if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } $state = Get-RogueProtectionState if (-not $state) { return } @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec $TimeoutSec Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -116,7 +116,8 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } + $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { @@ -124,13 +125,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress - Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } } catch { - if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + if ($_.Exception.Response) { + if ([int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + else { Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue } + } } finally { $lock.Dispose() } } if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } @@ -179,10 +183,10 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } -function Leave-RogueProtection { +function Leave-RogueProtection([int]$TimeoutSec=5) { if (-not $script:RPDirectory) { return } Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue - Send-RogueProtectionAck + Send-RogueProtectionAck -TimeoutSec $TimeoutSec } function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } diff --git a/plugins/kiro/scripts/protection.sh b/plugins/kiro/scripts/protection.sh index 36946d7..405aba5 100644 --- a/plugins/kiro/scripts/protection.sh +++ b/plugins/kiro/scripts/protection.sh @@ -121,7 +121,8 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then @@ -130,7 +131,10 @@ rogue_protection_init() { _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' - if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi + case "$(printf '%s' "$_rp_response" | tail -n 1)" in + 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; + [1-5]??) rm -f "$ROGUE_PROTECTION_STATE/legacy-server" ;; + esac _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi diff --git a/plugins/kiro/scripts/ship-logs.ps1 b/plugins/kiro/scripts/ship-logs.ps1 index a5fb907..cd046d0 100644 --- a/plugins/kiro/scripts/ship-logs.ps1 +++ b/plugins/kiro/scripts/ship-logs.ps1 @@ -646,9 +646,19 @@ function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) $destination = Join-Path $script:stateDir "$Key.state" $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) - if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } - else { [IO.File]::Move($tempFile, $destination) } + try { + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } + } catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + throw + } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -906,7 +916,7 @@ function Ship-LogFile { Write-ShipState $script:stateKey 0 $currentHead $fileBytes $normalizedPath } - [void](Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath) + if (-not (Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath)) { return } } finally { # try/finally, so an early return still releases the lock. Unlock-StateKey diff --git a/plugins/kiro/scripts/ship-logs.sh b/plugins/kiro/scripts/ship-logs.sh index 4c0471f..c954e7f 100644 --- a/plugins/kiro/scripts/ship-logs.sh +++ b/plugins/kiro/scripts/ship-logs.sh @@ -845,7 +845,7 @@ ship_log_file() { # _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then - write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } release_lock return 0 fi diff --git a/plugins/rogue/scripts/protection.ps1 b/plugins/rogue/scripts/protection.ps1 index 875904f..df66a6d 100644 --- a/plugins/rogue/scripts/protection.ps1 +++ b/plugins/rogue/scripts/protection.ps1 @@ -35,7 +35,7 @@ function Test-RogueProtectionCurrent { $state = Get-RogueProtectionState return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) } -function Send-RogueProtectionAck { +function Send-RogueProtectionAck([int]$TimeoutSec=5) { if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } $state = Get-RogueProtectionState if (-not $state) { return } @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec $TimeoutSec Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -116,7 +116,8 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } + $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { @@ -124,13 +125,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress - Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } } catch { - if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + if ($_.Exception.Response) { + if ([int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + else { Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue } + } } finally { $lock.Dispose() } } if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } @@ -179,10 +183,10 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } -function Leave-RogueProtection { +function Leave-RogueProtection([int]$TimeoutSec=5) { if (-not $script:RPDirectory) { return } Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue - Send-RogueProtectionAck + Send-RogueProtectionAck -TimeoutSec $TimeoutSec } function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } diff --git a/plugins/rogue/scripts/protection.sh b/plugins/rogue/scripts/protection.sh index 36946d7..405aba5 100644 --- a/plugins/rogue/scripts/protection.sh +++ b/plugins/rogue/scripts/protection.sh @@ -121,7 +121,8 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then @@ -130,7 +131,10 @@ rogue_protection_init() { _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' - if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi + case "$(printf '%s' "$_rp_response" | tail -n 1)" in + 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; + [1-5]??) rm -f "$ROGUE_PROTECTION_STATE/legacy-server" ;; + esac _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi diff --git a/plugins/rogue/scripts/ship-logs.ps1 b/plugins/rogue/scripts/ship-logs.ps1 index a5fb907..cd046d0 100644 --- a/plugins/rogue/scripts/ship-logs.ps1 +++ b/plugins/rogue/scripts/ship-logs.ps1 @@ -646,9 +646,19 @@ function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) $destination = Join-Path $script:stateDir "$Key.state" $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) - if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } - else { [IO.File]::Move($tempFile, $destination) } + try { + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } + } catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + throw + } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -906,7 +916,7 @@ function Ship-LogFile { Write-ShipState $script:stateKey 0 $currentHead $fileBytes $normalizedPath } - [void](Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath) + if (-not (Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath)) { return } } finally { # try/finally, so an early return still releases the lock. Unlock-StateKey diff --git a/plugins/rogue/scripts/ship-logs.sh b/plugins/rogue/scripts/ship-logs.sh index 4c0471f..c954e7f 100644 --- a/plugins/rogue/scripts/ship-logs.sh +++ b/plugins/rogue/scripts/ship-logs.sh @@ -845,7 +845,7 @@ ship_log_file() { # _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then - write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } release_lock return 0 fi diff --git a/scripts/shared/protection.ps1 b/scripts/shared/protection.ps1 index 875904f..df66a6d 100644 --- a/scripts/shared/protection.ps1 +++ b/scripts/shared/protection.ps1 @@ -35,7 +35,7 @@ function Test-RogueProtectionCurrent { $state = Get-RogueProtectionState return ($null -ne $state) -and ((-not $state.aidr.paused) -and ($null -eq $script:RPRevision -or $state.aidr.revision -eq $script:RPRevision)) } -function Send-RogueProtectionAck { +function Send-RogueProtectionAck([int]$TimeoutSec=5) { if ($script:RPPersistenceFailed -or (Test-Path -LiteralPath "$script:RPDirectory/persistence-failed")) { return } $state = Get-RogueProtectionState if (-not $state) { return } @@ -48,7 +48,7 @@ function Send-RogueProtectionAck { if ((Get-Content -LiteralPath "$script:RPDirectory/ack" -Raw -ErrorAction SilentlyContinue) -eq $identity) { return } try { $body = @{ protocolVersion=1; revision=$state.revision; status='applied'; aidrPaused=[bool]$state.aidr.paused; aispmPaused=[bool]$state.aispm.paused } | ConvertTo-Json -Compress - $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 + $null = Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec $TimeoutSec Write-RogueProtectionFile "$script:RPDirectory/ack" $identity } catch { } } @@ -116,7 +116,8 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } + $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } try { if (-not (Test-Path -LiteralPath $credential)) { @@ -124,13 +125,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu $nonce=Get-Content -LiteralPath "$script:RPDirectory/enrollment-nonce" -Raw -ErrorAction SilentlyContinue if (-not $nonce) { $nonce=[Guid]::NewGuid().ToString('N'); Write-RogueProtectionFile "$script:RPDirectory/enrollment-nonce" $nonce } $body=@{ enrollmentNonce=$nonce; type='coding_agent'; name=$Slug; family=$Family; host=[Environment]::MachineName; version=$Version } | ConvertTo-Json -Compress - Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue $enrolled=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/enroll" -Method Post -Headers @{'x-rogue-api-key'=$Key} -ContentType 'application/json' -Body $body -TimeoutSec 5 + Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue if ($enrolled.alreadyEnrolled) { $enrolled | Add-Member -NotePropertyName apiKey -NotePropertyValue $Key -Force } if ($enrolled.apiKey) { Write-RogueProtectionFile $credential $enrolled.apiKey } } } catch { - if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + if ($_.Exception.Response) { + if ([int]$_.Exception.Response.StatusCode -eq 404) { try { Write-RogueProtectionFile "$script:RPDirectory/legacy-server" '1' } catch {} } + else { Remove-Item -LiteralPath "$script:RPDirectory/legacy-server" -Force -ErrorAction SilentlyContinue } + } } finally { $lock.Dispose() } } if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } @@ -179,10 +183,10 @@ function Read-RogueProtectionInput { if (Test-RogueProtectionCurrent) { return [Console]::InputEncoding.GetString($bytes) } return '' } -function Leave-RogueProtection { +function Leave-RogueProtection([int]$TimeoutSec=5) { if (-not $script:RPDirectory) { return } Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue - Send-RogueProtectionAck + Send-RogueProtectionAck -TimeoutSec $TimeoutSec } function Enter-RogueProtection { if (-not (Test-RogueProtectionCurrent)) { return $false } diff --git a/scripts/shared/protection.sh b/scripts/shared/protection.sh index 36946d7..405aba5 100644 --- a/scripts/shared/protection.sh +++ b/scripts/shared/protection.sh @@ -121,7 +121,8 @@ rogue_protection_init() { fi if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 - if [ $(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then @@ -130,7 +131,10 @@ rogue_protection_init() { _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' - if [ "$(printf '%s' "$_rp_response" | tail -n 1)" = 404 ]; then touch "$ROGUE_PROTECTION_STATE/legacy-server"; else rm -f "$ROGUE_PROTECTION_STATE/legacy-server"; fi + case "$(printf '%s' "$_rp_response" | tail -n 1)" in + 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; + [1-5]??) rm -f "$ROGUE_PROTECTION_STATE/legacy-server" ;; + esac _rp_key=$(printf '%s' "$_rp_response" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') case "$_rp_response" in *'"alreadyEnrolled":true'*) _rp_key=$ROGUE_API_KEY ;; esac if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi diff --git a/scripts/shared/ship-logs.ps1 b/scripts/shared/ship-logs.ps1 index a5fb907..cd046d0 100644 --- a/scripts/shared/ship-logs.ps1 +++ b/scripts/shared/ship-logs.ps1 @@ -646,9 +646,19 @@ function Write-ShipState { param([string]$Key, [int64]$Offset, [string]$Head, [int64]$Size, [string]$Path) $destination = Join-Path $script:stateDir "$Key.state" $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) - [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) - if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } - else { [IO.File]::Move($tempFile, $destination) } + try { + [IO.File]::WriteAllText($tempFile, "offset=$Offset`nhead=$Head`nsize=$Size`npath=$Path`nrevision=$script:RPRevision`n", (New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists($destination)) { [IO.File]::Replace($tempFile, $destination, [NullString]::Value) } + else { [IO.File]::Move($tempFile, $destination) } + } catch { + $script:RPPersistenceFailed=$true + $state=Get-RogueProtectionState + if ($state) { + $body=@{protocolVersion=1;revision=$state.revision;status='failed';aidrPaused=[bool]$state.aidr.paused;aispmPaused=[bool]$state.aispm.paused;error='state_persistence_failed'} | ConvertTo-Json -Compress + try { $null=Invoke-RestMethod -ErrorAction Stop -Uri "$script:RPBase/api/v1/hooks/protection/ack" -Method Post -Headers @{'x-rogue-api-key'=$script:RPKey} -ContentType 'application/json' -Body $body -TimeoutSec 5 } catch {} + } + throw + } } # ── stage 8: chunks ──────────────────────────────────────────────────────── @@ -906,7 +916,7 @@ function Ship-LogFile { Write-ShipState $script:stateKey 0 $currentHead $fileBytes $normalizedPath } - [void](Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath) + if (-not (Invoke-DrainFile $Path $fileBytes 0 $currentHead $currentHead $fileBytes $normalizedPath)) { return } } finally { # try/finally, so an early return still releases the lock. Unlock-StateKey diff --git a/scripts/shared/ship-logs.sh b/scripts/shared/ship-logs.sh index 4c0471f..c954e7f 100644 --- a/scripts/shared/ship-logs.sh +++ b/scripts/shared/ship-logs.sh @@ -845,7 +845,7 @@ ship_log_file() { # _target_file_bytes=$(file_size "$_target_file") _target_head=$(first_line_fingerprint "$_target_file") if [ -n "${ROGUE_PROTECTION_STATE:-}" ] && [ "${ROGUE_PROTECTION_REVISION:-0}" -gt 0 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then - write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" "$_target_file_bytes" "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } release_lock return 0 fi diff --git a/tests/test_hook_sh_antigravity.sh b/tests/test_hook_sh_antigravity.sh index f42bef0..959d11c 100755 --- a/tests/test_hook_sh_antigravity.sh +++ b/tests/test_hook_sh_antigravity.sh @@ -201,6 +201,7 @@ mkdir -p "$STAGE/scripts" cp "$HOOK" "$STAGE/scripts/hook.sh" cp "$ACTOR" "$STAGE/scripts/actor.sh" cp "$INSTALL_ID" "$STAGE/scripts/install-id.sh" +cp "$REPO/plugins/antigravity/scripts/protection.sh" "$STAGE/scripts/protection.sh" MARKER="$STAGE/heartbeat-fired" # The stub records BOTH arguments: the heartbeat is told which surface fired it # (three products share one install and only the hook can tell them apart, from the @@ -328,6 +329,7 @@ mkdir -p "$STAGE/scripts" cp "$HOOK" "$STAGE/scripts/hook.sh" cp "$ACTOR" "$STAGE/scripts/actor.sh" cp "$INSTALL_ID" "$STAGE/scripts/install-id.sh" +cp "$REPO/plugins/antigravity/scripts/protection.sh" "$STAGE/scripts/protection.sh" MARKER="$STAGE/heartbeat-fired" cat > "$STAGE/scripts/heartbeat.sh" < { let body=''; for await (const chunk of req) body+=chunk; requests.push({path:req.url, body, key:req.headers["x-rogue-api-key"], installationKey:req.headers["x-rogue-installation-key"]}); + if (disconnect) { req.socket.destroy(); return; } if (legacy && req.url.includes('/hooks/protection/')) { res.statusCode=404; res.end('{}'); return; } if (!available) { res.statusCode=503; res.end('{}'); return; } res.setHeader('content-type','application/json'); @@ -55,7 +56,7 @@ try { const event=({cursor:'beforeShellExecution',copilot:'preToolUse',antigravity:'PreInvocation',gemini:'BeforeTool'})[plugin] || 'PreToolUse'; const result=await run(plugin==='gemini'?process.execPath:'bash',[path.join(root,'scripts',plugin==='gemini'?'hook.mjs':'hook.sh'),event,'kiro_cli'],env); assert.equal(result.code,0,`${plugin}: ${result.err}`); - assert.deepEqual(JSON.parse(result.out || '{}'),plugin==='antigravity'?{decision:'allow'}:{},plugin); + assert.deepEqual(JSON.parse(result.out || '{}'),{},plugin); const calls=requests.slice(start); assert(calls.some(call=>call.path.endsWith('/state')),`${plugin} did not fetch its pause decision`); assert(calls.every(call=>call.path.includes('/hooks/protection/')),`${family} sent an activity payload`); @@ -233,7 +234,7 @@ try { const code=await new Promise(resolve=>child.once('exit',resolve)); clearTimeout(change);clearTimeout(timeout); assert.equal(code,0,plugin); - assert.deepEqual(JSON.parse(output || '{}'),plugin==='antigravity'?{decision:'allow'}:{},plugin); + assert.deepEqual(JSON.parse(output || '{}'),{},plugin); assert(!requests.slice(start).some(call=>!call.path.includes('/hooks/protection/') && call.body.includes('must-not-upload')),plugin); assert(!fs.readdirSync(directory).some(name=>name.startsWith('input.')),plugin); } @@ -283,6 +284,40 @@ try { const root=path.join(fixturePlugins,'codex'); await run('sh',[path.join(root,'scripts/hook.sh'),'PreToolUse'],{ROGUE_API_KEY:'provision_codex',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,PLUGIN_ROOT:root}); }); + await test('legacy enrollment survives transport failure and retries a future timestamp', async () => { + const {Protection}=await import(path.join(fixturePlugins,'gemini/scripts/protection.mjs')); + for (const language of ['sh','node', ...(process.env.ROGUE_TEST_PWSH ? ['ps'] : [])]) { + const isolated=path.join(temp,`legacy-${language}`); fs.mkdirSync(isolated); + const env={ROGUE_API_KEY:'legacy_key',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:isolated}; + const invoke=async () => { + if (language==='node') { assert.equal(await Protection.connect(env),undefined); return; } + const script=language==='sh' + ? `. '${path.join(repo,'scripts/shared/protection.sh')}'; rogue_protection_init codex openai '${path.join(repo,'plugins/codex/scripts')}'; [ -z "$ROGUE_PROTECTION_STATE" ]` + : `. '${path.join(repo,'scripts/shared/protection.ps1')}'; $null=Initialize-RogueProtection -Key 'legacy_key' -BaseUrl '${base}' -Slug codex -Family openai; if ($script:RPDirectory) { exit 9 }`; + const result=await run(language==='sh'?'sh':process.env.ROGUE_TEST_PWSH,language==='sh'?['-c',script]:['-NoProfile','-Command',script],env,''); + assert.equal(result.code,0,`${language}: ${result.err}`); + }; + legacy=true; available=true; await invoke(); + const directory=path.join(isolated,fs.readdirSync(isolated)[0]); + fs.writeFileSync(path.join(directory,'enroll-attempt'),String(language==='node'?Date.now()+3600000:Math.floor(Date.now()/1000)+3600)); + disconnect=true; legacy=false; + const start=requests.length; await invoke(); + assert(requests.slice(start).some(call=>call.path.endsWith('/enroll')),language); + assert(fs.existsSync(path.join(directory,'legacy-server')),language); + disconnect=false; + } + }); + await test('PowerShell checkpoint failure reports failed and suppresses applied ACK', {skip:!process.env.ROGUE_TEST_PWSH}, async () => { + const directory=path.join(temp,'ps-checkpoint'); fs.mkdirSync(directory); + fs.mkdirSync(path.join(directory,'broken.state')); + fs.writeFileSync(path.join(directory,'state.json'),JSON.stringify({decision:{protocolVersion:1,revision:50,serverTime:new Date().toISOString(),aidr:{paused:false,revision:50},aispm:{paused:false,revision:0}},receivedAt:new Date().toISOString()})); + const script=`$env:ROGUE_PS_LIB_ONLY='1'; . '${path.join(repo,'scripts/shared/ship-logs.ps1')}'; . '${path.join(repo,'scripts/shared/protection.ps1')}'; $script:stateDir='${directory}'; $script:RPDirectory='${directory}'; $script:RPBase='${base}'; $script:RPKey='installation_test_key'; $script:RPRevision=50; try { Write-ShipState broken 10 head 10 log; exit 9 } catch {}; Send-RogueProtectionAck; if (-not $script:RPPersistenceFailed) { exit 8 }`; + const start=requests.length; + const result=await run(process.env.ROGUE_TEST_PWSH,['-NoProfile','-Command',script],{},''); + assert.equal(result.code,0,result.err); + const acks=requests.slice(start).filter(call=>call.path.endsWith('/ack')).map(call=>JSON.parse(call.body)); + assert(acks.some(ack=>ack.status==='failed')); assert(!acks.some(ack=>ack.status==='applied')); + }); await test('an unavailable enrollment never enables unscoped activity on retry', async () => { available=false; for (const slug of ['codex','gemini']) { From 05bfe295839ec4ed0ec7014d64aa79c98cf930c8 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Tue, 15 Sep 2026 02:26:54 +0300 Subject: [PATCH 07/14] test(hooks): isolate Antigravity heartbeat fixtures (FIRE-2137) --- tests/test_hook_sh_antigravity.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_hook_sh_antigravity.sh b/tests/test_hook_sh_antigravity.sh index 959d11c..f8acb7b 100755 --- a/tests/test_hook_sh_antigravity.sh +++ b/tests/test_hook_sh_antigravity.sh @@ -9,7 +9,11 @@ set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" -HOOK="$REPO/plugins/antigravity/scripts/hook.sh" +DISPATCH_FIXTURE="$(mktemp -d)" +cp -R "$REPO/plugins/antigravity/." "$DISPATCH_FIXTURE/" +# Heartbeat launch behavior is tested below with a marker-writing stub. +printf '#!/bin/sh\nexit 0\n' > "$DISPATCH_FIXTURE/scripts/heartbeat.sh" +HOOK="$DISPATCH_FIXTURE/scripts/hook.sh" ACTOR="$REPO/plugins/antigravity/scripts/actor.sh" INSTALL_ID="$REPO/plugins/antigravity/scripts/install-id.sh" SH="${TEST_SH:-sh}" @@ -22,6 +26,7 @@ OUT_FILE="$(mktemp)" cleanup() { [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true rm -f "$ENV_FILE" "$HEADERS_FILE" "$OUT_FILE" + rm -rf "$DISPATCH_FIXTURE" } trap cleanup EXIT From c8f085c603cf41794c54a617c31386015a421194 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Tue, 15 Sep 2026 02:36:05 +0300 Subject: [PATCH 08/14] fix(hooks): release shipper leases when entry is canceled (FIRE-2137) --- plugins/antigravity/scripts/ship-logs.ps1 | 2 +- plugins/codex/scripts/ship-logs.ps1 | 2 +- plugins/copilot/scripts/ship-logs.ps1 | 2 +- plugins/cursor/scripts/ship-logs.ps1 | 2 +- plugins/kiro/scripts/ship-logs.ps1 | 2 +- plugins/rogue/scripts/ship-logs.ps1 | 2 +- scripts/shared/ship-logs.ps1 | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/antigravity/scripts/ship-logs.ps1 b/plugins/antigravity/scripts/ship-logs.ps1 index cd046d0..5f0723b 100644 --- a/plugins/antigravity/scripts/ship-logs.ps1 +++ b/plugins/antigravity/scripts/ship-logs.ps1 @@ -937,8 +937,8 @@ function Invoke-Main { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } - if (-not (Enter-RogueProtection)) { exit 0 } try { + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { diff --git a/plugins/codex/scripts/ship-logs.ps1 b/plugins/codex/scripts/ship-logs.ps1 index cd046d0..5f0723b 100644 --- a/plugins/codex/scripts/ship-logs.ps1 +++ b/plugins/codex/scripts/ship-logs.ps1 @@ -937,8 +937,8 @@ function Invoke-Main { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } - if (-not (Enter-RogueProtection)) { exit 0 } try { + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { diff --git a/plugins/copilot/scripts/ship-logs.ps1 b/plugins/copilot/scripts/ship-logs.ps1 index cd046d0..5f0723b 100644 --- a/plugins/copilot/scripts/ship-logs.ps1 +++ b/plugins/copilot/scripts/ship-logs.ps1 @@ -937,8 +937,8 @@ function Invoke-Main { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } - if (-not (Enter-RogueProtection)) { exit 0 } try { + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { diff --git a/plugins/cursor/scripts/ship-logs.ps1 b/plugins/cursor/scripts/ship-logs.ps1 index cd046d0..5f0723b 100644 --- a/plugins/cursor/scripts/ship-logs.ps1 +++ b/plugins/cursor/scripts/ship-logs.ps1 @@ -937,8 +937,8 @@ function Invoke-Main { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } - if (-not (Enter-RogueProtection)) { exit 0 } try { + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { diff --git a/plugins/kiro/scripts/ship-logs.ps1 b/plugins/kiro/scripts/ship-logs.ps1 index cd046d0..5f0723b 100644 --- a/plugins/kiro/scripts/ship-logs.ps1 +++ b/plugins/kiro/scripts/ship-logs.ps1 @@ -937,8 +937,8 @@ function Invoke-Main { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } - if (-not (Enter-RogueProtection)) { exit 0 } try { + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { diff --git a/plugins/rogue/scripts/ship-logs.ps1 b/plugins/rogue/scripts/ship-logs.ps1 index cd046d0..5f0723b 100644 --- a/plugins/rogue/scripts/ship-logs.ps1 +++ b/plugins/rogue/scripts/ship-logs.ps1 @@ -937,8 +937,8 @@ function Invoke-Main { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } - if (-not (Enter-RogueProtection)) { exit 0 } try { + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { diff --git a/scripts/shared/ship-logs.ps1 b/scripts/shared/ship-logs.ps1 index cd046d0..5f0723b 100644 --- a/scripts/shared/ship-logs.ps1 +++ b/scripts/shared/ship-logs.ps1 @@ -937,8 +937,8 @@ function Invoke-Main { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $PluginRoot 'scripts') $script:creds['ROGUE_API_KEY'] = Initialize-RogueProtection -Key $script:creds['ROGUE_API_KEY'] -BaseUrl $script:creds['ROGUE_BASE_URL'] -Slug $ShipperSlug -Family $AgentFamily -Version $ShipperVersion if ($script:RPDirectory) { $script:creds['ROGUE_LOG_FILE']=$env:ROGUE_LOG_FILE } - if (-not (Enter-RogueProtection)) { exit 0 } try { + if (-not (Enter-RogueProtection)) { exit 0 } Resolve-Knobs if (-not $script:apiKey) { Write-ShipDebug 'not configured -> no-op'; exit 0 } if (-not (Resolve-ShipActor)) { From ab82f2bec5e7898b886ee0ca5b78c2c2a52b8007 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Tue, 15 Sep 2026 02:38:25 +0300 Subject: [PATCH 09/14] test(hooks): handle background writes during Gemini cleanup (FIRE-2137) --- tests/test_hook_mjs.mjs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index 0454e25..da63fef 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -27,7 +27,7 @@ function freshHome() { // Run hook.mjs with `payload` on stdin and `env` overrides; resolve stdout. // `prepareHome(home)` may seed the throwaway HOME (env file, git config) first. function runHook(event, payload, env, prepareHome) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const home = freshHome(); if (prepareHome) prepareHome(home); const child = spawn(process.execPath, [HOOK, event], { @@ -40,9 +40,12 @@ function runHook(event, payload, env, prepareHome) { }); let out = ""; child.stdout.on("data", (c) => (out += c)); + child.on("error", reject); child.on("close", () => { - fs.rmSync(home, { recursive: true, force: true }); + try { + fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); resolve(out); + } catch (error) { reject(error); } }); child.stdin.end(payload ?? ""); }); @@ -87,20 +90,23 @@ function startServer(status, body) { // Same as runHook, but keeps the sandbox long enough to read the hook log back. // The log is the whole subject of the surface tests below, and runHook deletes it. function runHookReadLog(event, payload, env) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const home = freshHome(); const child = spawn(process.execPath, [HOOK, event], { env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, ...env }, }); let out = ""; child.stdout.on("data", (c) => (out += c)); + child.on("error", reject); child.on("close", () => { + try { const logFile = path.join(home, ".rogue", "logs", "gemini.log"); const lines = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8").split("\n").filter(Boolean) : []; - fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); resolve({ out, lines }); + } catch (error) { reject(error); } }); child.stdin.end(payload ?? ""); }); From d7db5236e04fe3d7bd0a169b7cd566af74cb54bd Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Thu, 17 Sep 2026 11:13:06 +0300 Subject: [PATCH 10/14] refactor(hooks): separate protection initialization steps (FIRE-2137) --- plugins/antigravity/scripts/protection.ps1 | 67 ++++++++++++++-------- plugins/antigravity/scripts/protection.sh | 55 +++++++++++------- plugins/codex/scripts/protection.ps1 | 67 ++++++++++++++-------- plugins/codex/scripts/protection.sh | 55 +++++++++++------- plugins/copilot/scripts/protection.ps1 | 67 ++++++++++++++-------- plugins/copilot/scripts/protection.sh | 55 +++++++++++------- plugins/cursor/scripts/protection.ps1 | 67 ++++++++++++++-------- plugins/cursor/scripts/protection.sh | 55 +++++++++++------- plugins/kiro/scripts/protection.ps1 | 67 ++++++++++++++-------- plugins/kiro/scripts/protection.sh | 55 +++++++++++------- plugins/rogue/scripts/protection.ps1 | 67 ++++++++++++++-------- plugins/rogue/scripts/protection.sh | 55 +++++++++++------- scripts/shared/protection.ps1 | 67 ++++++++++++++-------- scripts/shared/protection.sh | 55 +++++++++++------- tests/test_protection.mjs | 4 +- 15 files changed, 541 insertions(+), 317 deletions(-) diff --git a/plugins/antigravity/scripts/protection.ps1 b/plugins/antigravity/scripts/protection.ps1 index df66a6d..42fb022 100644 --- a/plugins/antigravity/scripts/protection.ps1 +++ b/plugins/antigravity/scripts/protection.ps1 @@ -76,10 +76,7 @@ function Update-RogueProtection { } catch { } finally { $lock.Dispose() } Send-RogueProtectionAck } -function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { - if (-not $Key) { return $Key } - if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } - $script:RPBase=$BaseUrl.TrimEnd('/') +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { $root=$env:ROGUE_PROTECTION_DIR if (-not $root) { $profilePath=$env:USERPROFILE @@ -90,12 +87,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } - try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $false } $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} - $credential="$script:RPDirectory/credential" try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + return $true +} +function Restore-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $root=Split-Path $script:RPDirectory -Parent + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } @@ -107,18 +108,21 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName - $credential=Join-Path $script:RPDirectory 'credential' try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} break - } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $false } } } } + return $true +} +function Register-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt - if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } - try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $false } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $false } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) @@ -137,26 +141,39 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu } } finally { $lock.Dispose() } } + return $true +} +function Start-RogueProtectionPoller { + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + if (-not (Set-RogueProtectionDirectory $Key $Slug)) { return $Key } + if (-not (Restore-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + if (-not (Register-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } - $pollLock=$null - try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} - try { - $pollPid=0 - $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) - if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { - $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' - $escape={param($s) "'" + $s.Replace("'", "''") + "'"} - $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" - $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) - $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } - if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } - try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } - } - } finally { if ($pollLock) { $pollLock.Dispose() } } + Start-RogueProtectionPoller $state=Get-RogueProtectionState $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } $env:ROGUE_PROTECTION_STATE=$script:RPDirectory diff --git a/plugins/antigravity/scripts/protection.sh b/plugins/antigravity/scripts/protection.sh index 405aba5..d88ca1f 100644 --- a/plugins/antigravity/scripts/protection.sh +++ b/plugins/antigravity/scripts/protection.sh @@ -81,36 +81,35 @@ rogue_protection_leave() { rm -f "$ROGUE_PROTECTION_STATE/active.$$" rogue_protection_ack } -rogue_protection_init() { - # Arguments: log slug, agent family, script directory, optional surface. - [ -n "${ROGUE_API_KEY:-}" ] || return 0 - ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} - ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} +rogue_protection_select_directory() { _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) - [ -n "$_rp_hash" ] || return 0 + [ -n "$_rp_hash" ] || return 1 case "${ROGUE_PROTECTION_STATE:-}" in */"$1-default-"*) [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; *) ROGUE_PROTECTION_STATE='' ;; esac ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" - (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 1 _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; esac rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + return 0 +} +rogue_protection_restore_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue [ -n "$_rp_previous_key" ] || continue - _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 1 _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) - case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 1 ;; esac _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") @@ -119,17 +118,20 @@ rogue_protection_init() { break done fi + return 0 +} +rogue_protection_enroll_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) - if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 1; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 1 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then - (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } fi _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") - [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' case "$(printf '%s' "$_rp_response" | tail -n 1)" in 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; @@ -140,19 +142,32 @@ rogue_protection_init() { if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" - export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE - _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 - [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + return 0 +} +rogue_protection_start_poller() { if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then - nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + nohup sh "$1/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" else _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi fi +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + rogue_protection_select_directory "$1" || return 0 + rogue_protection_restore_installation "$1" "$2" || return 0 + rogue_protection_enroll_installation "$1" "$2" || return 0 + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + rogue_protection_start_poller "$3" rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } diff --git a/plugins/codex/scripts/protection.ps1 b/plugins/codex/scripts/protection.ps1 index df66a6d..42fb022 100644 --- a/plugins/codex/scripts/protection.ps1 +++ b/plugins/codex/scripts/protection.ps1 @@ -76,10 +76,7 @@ function Update-RogueProtection { } catch { } finally { $lock.Dispose() } Send-RogueProtectionAck } -function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { - if (-not $Key) { return $Key } - if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } - $script:RPBase=$BaseUrl.TrimEnd('/') +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { $root=$env:ROGUE_PROTECTION_DIR if (-not $root) { $profilePath=$env:USERPROFILE @@ -90,12 +87,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } - try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $false } $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} - $credential="$script:RPDirectory/credential" try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + return $true +} +function Restore-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $root=Split-Path $script:RPDirectory -Parent + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } @@ -107,18 +108,21 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName - $credential=Join-Path $script:RPDirectory 'credential' try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} break - } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $false } } } } + return $true +} +function Register-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt - if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } - try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $false } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $false } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) @@ -137,26 +141,39 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu } } finally { $lock.Dispose() } } + return $true +} +function Start-RogueProtectionPoller { + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + if (-not (Set-RogueProtectionDirectory $Key $Slug)) { return $Key } + if (-not (Restore-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + if (-not (Register-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } - $pollLock=$null - try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} - try { - $pollPid=0 - $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) - if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { - $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' - $escape={param($s) "'" + $s.Replace("'", "''") + "'"} - $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" - $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) - $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } - if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } - try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } - } - } finally { if ($pollLock) { $pollLock.Dispose() } } + Start-RogueProtectionPoller $state=Get-RogueProtectionState $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } $env:ROGUE_PROTECTION_STATE=$script:RPDirectory diff --git a/plugins/codex/scripts/protection.sh b/plugins/codex/scripts/protection.sh index 405aba5..d88ca1f 100644 --- a/plugins/codex/scripts/protection.sh +++ b/plugins/codex/scripts/protection.sh @@ -81,36 +81,35 @@ rogue_protection_leave() { rm -f "$ROGUE_PROTECTION_STATE/active.$$" rogue_protection_ack } -rogue_protection_init() { - # Arguments: log slug, agent family, script directory, optional surface. - [ -n "${ROGUE_API_KEY:-}" ] || return 0 - ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} - ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} +rogue_protection_select_directory() { _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) - [ -n "$_rp_hash" ] || return 0 + [ -n "$_rp_hash" ] || return 1 case "${ROGUE_PROTECTION_STATE:-}" in */"$1-default-"*) [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; *) ROGUE_PROTECTION_STATE='' ;; esac ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" - (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 1 _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; esac rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + return 0 +} +rogue_protection_restore_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue [ -n "$_rp_previous_key" ] || continue - _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 1 _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) - case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 1 ;; esac _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") @@ -119,17 +118,20 @@ rogue_protection_init() { break done fi + return 0 +} +rogue_protection_enroll_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) - if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 1; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 1 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then - (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } fi _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") - [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' case "$(printf '%s' "$_rp_response" | tail -n 1)" in 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; @@ -140,19 +142,32 @@ rogue_protection_init() { if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" - export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE - _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 - [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + return 0 +} +rogue_protection_start_poller() { if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then - nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + nohup sh "$1/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" else _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi fi +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + rogue_protection_select_directory "$1" || return 0 + rogue_protection_restore_installation "$1" "$2" || return 0 + rogue_protection_enroll_installation "$1" "$2" || return 0 + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + rogue_protection_start_poller "$3" rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } diff --git a/plugins/copilot/scripts/protection.ps1 b/plugins/copilot/scripts/protection.ps1 index df66a6d..42fb022 100644 --- a/plugins/copilot/scripts/protection.ps1 +++ b/plugins/copilot/scripts/protection.ps1 @@ -76,10 +76,7 @@ function Update-RogueProtection { } catch { } finally { $lock.Dispose() } Send-RogueProtectionAck } -function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { - if (-not $Key) { return $Key } - if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } - $script:RPBase=$BaseUrl.TrimEnd('/') +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { $root=$env:ROGUE_PROTECTION_DIR if (-not $root) { $profilePath=$env:USERPROFILE @@ -90,12 +87,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } - try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $false } $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} - $credential="$script:RPDirectory/credential" try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + return $true +} +function Restore-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $root=Split-Path $script:RPDirectory -Parent + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } @@ -107,18 +108,21 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName - $credential=Join-Path $script:RPDirectory 'credential' try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} break - } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $false } } } } + return $true +} +function Register-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt - if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } - try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $false } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $false } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) @@ -137,26 +141,39 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu } } finally { $lock.Dispose() } } + return $true +} +function Start-RogueProtectionPoller { + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + if (-not (Set-RogueProtectionDirectory $Key $Slug)) { return $Key } + if (-not (Restore-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + if (-not (Register-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } - $pollLock=$null - try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} - try { - $pollPid=0 - $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) - if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { - $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' - $escape={param($s) "'" + $s.Replace("'", "''") + "'"} - $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" - $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) - $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } - if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } - try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } - } - } finally { if ($pollLock) { $pollLock.Dispose() } } + Start-RogueProtectionPoller $state=Get-RogueProtectionState $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } $env:ROGUE_PROTECTION_STATE=$script:RPDirectory diff --git a/plugins/copilot/scripts/protection.sh b/plugins/copilot/scripts/protection.sh index 405aba5..d88ca1f 100644 --- a/plugins/copilot/scripts/protection.sh +++ b/plugins/copilot/scripts/protection.sh @@ -81,36 +81,35 @@ rogue_protection_leave() { rm -f "$ROGUE_PROTECTION_STATE/active.$$" rogue_protection_ack } -rogue_protection_init() { - # Arguments: log slug, agent family, script directory, optional surface. - [ -n "${ROGUE_API_KEY:-}" ] || return 0 - ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} - ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} +rogue_protection_select_directory() { _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) - [ -n "$_rp_hash" ] || return 0 + [ -n "$_rp_hash" ] || return 1 case "${ROGUE_PROTECTION_STATE:-}" in */"$1-default-"*) [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; *) ROGUE_PROTECTION_STATE='' ;; esac ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" - (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 1 _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; esac rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + return 0 +} +rogue_protection_restore_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue [ -n "$_rp_previous_key" ] || continue - _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 1 _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) - case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 1 ;; esac _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") @@ -119,17 +118,20 @@ rogue_protection_init() { break done fi + return 0 +} +rogue_protection_enroll_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) - if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 1; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 1 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then - (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } fi _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") - [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' case "$(printf '%s' "$_rp_response" | tail -n 1)" in 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; @@ -140,19 +142,32 @@ rogue_protection_init() { if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" - export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE - _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 - [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + return 0 +} +rogue_protection_start_poller() { if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then - nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + nohup sh "$1/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" else _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi fi +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + rogue_protection_select_directory "$1" || return 0 + rogue_protection_restore_installation "$1" "$2" || return 0 + rogue_protection_enroll_installation "$1" "$2" || return 0 + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + rogue_protection_start_poller "$3" rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } diff --git a/plugins/cursor/scripts/protection.ps1 b/plugins/cursor/scripts/protection.ps1 index df66a6d..42fb022 100644 --- a/plugins/cursor/scripts/protection.ps1 +++ b/plugins/cursor/scripts/protection.ps1 @@ -76,10 +76,7 @@ function Update-RogueProtection { } catch { } finally { $lock.Dispose() } Send-RogueProtectionAck } -function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { - if (-not $Key) { return $Key } - if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } - $script:RPBase=$BaseUrl.TrimEnd('/') +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { $root=$env:ROGUE_PROTECTION_DIR if (-not $root) { $profilePath=$env:USERPROFILE @@ -90,12 +87,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } - try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $false } $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} - $credential="$script:RPDirectory/credential" try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + return $true +} +function Restore-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $root=Split-Path $script:RPDirectory -Parent + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } @@ -107,18 +108,21 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName - $credential=Join-Path $script:RPDirectory 'credential' try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} break - } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $false } } } } + return $true +} +function Register-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt - if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } - try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $false } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $false } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) @@ -137,26 +141,39 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu } } finally { $lock.Dispose() } } + return $true +} +function Start-RogueProtectionPoller { + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + if (-not (Set-RogueProtectionDirectory $Key $Slug)) { return $Key } + if (-not (Restore-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + if (-not (Register-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } - $pollLock=$null - try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} - try { - $pollPid=0 - $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) - if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { - $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' - $escape={param($s) "'" + $s.Replace("'", "''") + "'"} - $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" - $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) - $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } - if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } - try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } - } - } finally { if ($pollLock) { $pollLock.Dispose() } } + Start-RogueProtectionPoller $state=Get-RogueProtectionState $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } $env:ROGUE_PROTECTION_STATE=$script:RPDirectory diff --git a/plugins/cursor/scripts/protection.sh b/plugins/cursor/scripts/protection.sh index 405aba5..d88ca1f 100644 --- a/plugins/cursor/scripts/protection.sh +++ b/plugins/cursor/scripts/protection.sh @@ -81,36 +81,35 @@ rogue_protection_leave() { rm -f "$ROGUE_PROTECTION_STATE/active.$$" rogue_protection_ack } -rogue_protection_init() { - # Arguments: log slug, agent family, script directory, optional surface. - [ -n "${ROGUE_API_KEY:-}" ] || return 0 - ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} - ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} +rogue_protection_select_directory() { _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) - [ -n "$_rp_hash" ] || return 0 + [ -n "$_rp_hash" ] || return 1 case "${ROGUE_PROTECTION_STATE:-}" in */"$1-default-"*) [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; *) ROGUE_PROTECTION_STATE='' ;; esac ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" - (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 1 _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; esac rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + return 0 +} +rogue_protection_restore_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue [ -n "$_rp_previous_key" ] || continue - _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 1 _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) - case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 1 ;; esac _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") @@ -119,17 +118,20 @@ rogue_protection_init() { break done fi + return 0 +} +rogue_protection_enroll_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) - if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 1; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 1 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then - (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } fi _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") - [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' case "$(printf '%s' "$_rp_response" | tail -n 1)" in 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; @@ -140,19 +142,32 @@ rogue_protection_init() { if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" - export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE - _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 - [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + return 0 +} +rogue_protection_start_poller() { if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then - nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + nohup sh "$1/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" else _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi fi +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + rogue_protection_select_directory "$1" || return 0 + rogue_protection_restore_installation "$1" "$2" || return 0 + rogue_protection_enroll_installation "$1" "$2" || return 0 + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + rogue_protection_start_poller "$3" rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } diff --git a/plugins/kiro/scripts/protection.ps1 b/plugins/kiro/scripts/protection.ps1 index df66a6d..42fb022 100644 --- a/plugins/kiro/scripts/protection.ps1 +++ b/plugins/kiro/scripts/protection.ps1 @@ -76,10 +76,7 @@ function Update-RogueProtection { } catch { } finally { $lock.Dispose() } Send-RogueProtectionAck } -function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { - if (-not $Key) { return $Key } - if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } - $script:RPBase=$BaseUrl.TrimEnd('/') +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { $root=$env:ROGUE_PROTECTION_DIR if (-not $root) { $profilePath=$env:USERPROFILE @@ -90,12 +87,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } - try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $false } $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} - $credential="$script:RPDirectory/credential" try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + return $true +} +function Restore-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $root=Split-Path $script:RPDirectory -Parent + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } @@ -107,18 +108,21 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName - $credential=Join-Path $script:RPDirectory 'credential' try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} break - } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $false } } } } + return $true +} +function Register-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt - if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } - try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $false } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $false } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) @@ -137,26 +141,39 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu } } finally { $lock.Dispose() } } + return $true +} +function Start-RogueProtectionPoller { + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + if (-not (Set-RogueProtectionDirectory $Key $Slug)) { return $Key } + if (-not (Restore-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + if (-not (Register-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } - $pollLock=$null - try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} - try { - $pollPid=0 - $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) - if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { - $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' - $escape={param($s) "'" + $s.Replace("'", "''") + "'"} - $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" - $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) - $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } - if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } - try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } - } - } finally { if ($pollLock) { $pollLock.Dispose() } } + Start-RogueProtectionPoller $state=Get-RogueProtectionState $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } $env:ROGUE_PROTECTION_STATE=$script:RPDirectory diff --git a/plugins/kiro/scripts/protection.sh b/plugins/kiro/scripts/protection.sh index 405aba5..d88ca1f 100644 --- a/plugins/kiro/scripts/protection.sh +++ b/plugins/kiro/scripts/protection.sh @@ -81,36 +81,35 @@ rogue_protection_leave() { rm -f "$ROGUE_PROTECTION_STATE/active.$$" rogue_protection_ack } -rogue_protection_init() { - # Arguments: log slug, agent family, script directory, optional surface. - [ -n "${ROGUE_API_KEY:-}" ] || return 0 - ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} - ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} +rogue_protection_select_directory() { _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) - [ -n "$_rp_hash" ] || return 0 + [ -n "$_rp_hash" ] || return 1 case "${ROGUE_PROTECTION_STATE:-}" in */"$1-default-"*) [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; *) ROGUE_PROTECTION_STATE='' ;; esac ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" - (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 1 _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; esac rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + return 0 +} +rogue_protection_restore_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue [ -n "$_rp_previous_key" ] || continue - _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 1 _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) - case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 1 ;; esac _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") @@ -119,17 +118,20 @@ rogue_protection_init() { break done fi + return 0 +} +rogue_protection_enroll_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) - if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 1; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 1 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then - (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } fi _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") - [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' case "$(printf '%s' "$_rp_response" | tail -n 1)" in 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; @@ -140,19 +142,32 @@ rogue_protection_init() { if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" - export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE - _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 - [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + return 0 +} +rogue_protection_start_poller() { if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then - nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + nohup sh "$1/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" else _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi fi +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + rogue_protection_select_directory "$1" || return 0 + rogue_protection_restore_installation "$1" "$2" || return 0 + rogue_protection_enroll_installation "$1" "$2" || return 0 + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + rogue_protection_start_poller "$3" rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } diff --git a/plugins/rogue/scripts/protection.ps1 b/plugins/rogue/scripts/protection.ps1 index df66a6d..42fb022 100644 --- a/plugins/rogue/scripts/protection.ps1 +++ b/plugins/rogue/scripts/protection.ps1 @@ -76,10 +76,7 @@ function Update-RogueProtection { } catch { } finally { $lock.Dispose() } Send-RogueProtectionAck } -function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { - if (-not $Key) { return $Key } - if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } - $script:RPBase=$BaseUrl.TrimEnd('/') +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { $root=$env:ROGUE_PROTECTION_DIR if (-not $root) { $profilePath=$env:USERPROFILE @@ -90,12 +87,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } - try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $false } $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} - $credential="$script:RPDirectory/credential" try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + return $true +} +function Restore-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $root=Split-Path $script:RPDirectory -Parent + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } @@ -107,18 +108,21 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName - $credential=Join-Path $script:RPDirectory 'credential' try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} break - } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $false } } } } + return $true +} +function Register-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt - if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } - try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $false } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $false } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) @@ -137,26 +141,39 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu } } finally { $lock.Dispose() } } + return $true +} +function Start-RogueProtectionPoller { + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + if (-not (Set-RogueProtectionDirectory $Key $Slug)) { return $Key } + if (-not (Restore-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + if (-not (Register-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } - $pollLock=$null - try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} - try { - $pollPid=0 - $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) - if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { - $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' - $escape={param($s) "'" + $s.Replace("'", "''") + "'"} - $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" - $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) - $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } - if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } - try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } - } - } finally { if ($pollLock) { $pollLock.Dispose() } } + Start-RogueProtectionPoller $state=Get-RogueProtectionState $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } $env:ROGUE_PROTECTION_STATE=$script:RPDirectory diff --git a/plugins/rogue/scripts/protection.sh b/plugins/rogue/scripts/protection.sh index 405aba5..d88ca1f 100644 --- a/plugins/rogue/scripts/protection.sh +++ b/plugins/rogue/scripts/protection.sh @@ -81,36 +81,35 @@ rogue_protection_leave() { rm -f "$ROGUE_PROTECTION_STATE/active.$$" rogue_protection_ack } -rogue_protection_init() { - # Arguments: log slug, agent family, script directory, optional surface. - [ -n "${ROGUE_API_KEY:-}" ] || return 0 - ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} - ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} +rogue_protection_select_directory() { _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) - [ -n "$_rp_hash" ] || return 0 + [ -n "$_rp_hash" ] || return 1 case "${ROGUE_PROTECTION_STATE:-}" in */"$1-default-"*) [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; *) ROGUE_PROTECTION_STATE='' ;; esac ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" - (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 1 _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; esac rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + return 0 +} +rogue_protection_restore_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue [ -n "$_rp_previous_key" ] || continue - _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 1 _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) - case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 1 ;; esac _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") @@ -119,17 +118,20 @@ rogue_protection_init() { break done fi + return 0 +} +rogue_protection_enroll_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) - if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 1; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 1 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then - (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } fi _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") - [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' case "$(printf '%s' "$_rp_response" | tail -n 1)" in 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; @@ -140,19 +142,32 @@ rogue_protection_init() { if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" - export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE - _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 - [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + return 0 +} +rogue_protection_start_poller() { if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then - nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + nohup sh "$1/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" else _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi fi +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + rogue_protection_select_directory "$1" || return 0 + rogue_protection_restore_installation "$1" "$2" || return 0 + rogue_protection_enroll_installation "$1" "$2" || return 0 + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + rogue_protection_start_poller "$3" rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } diff --git a/scripts/shared/protection.ps1 b/scripts/shared/protection.ps1 index df66a6d..42fb022 100644 --- a/scripts/shared/protection.ps1 +++ b/scripts/shared/protection.ps1 @@ -76,10 +76,7 @@ function Update-RogueProtection { } catch { } finally { $lock.Dispose() } Send-RogueProtectionAck } -function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { - if (-not $Key) { return $Key } - if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } - $script:RPBase=$BaseUrl.TrimEnd('/') +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { $root=$env:ROGUE_PROTECTION_DIR if (-not $root) { $profilePath=$env:USERPROFILE @@ -90,12 +87,16 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu try { $id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$script:RPBase`n$Key")))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } $script:RPDirectory=Join-Path $root "$Slug-default-$id" if ($env:ROGUE_PROTECTION_STATE -and (Split-Path $env:ROGUE_PROTECTION_STATE -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath (Join-Path $env:ROGUE_PROTECTION_STATE 'credential') -Raw -ErrorAction SilentlyContinue) -eq $Key) { $script:RPDirectory=$env:ROGUE_PROTECTION_STATE } - try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $Key } + try { $null=[IO.Directory]::CreateDirectory($script:RPDirectory) } catch { $script:RPDirectory=$null; return $false } $linked=Get-Content -LiteralPath "$script:RPDirectory/installation-directory" -Raw -ErrorAction SilentlyContinue if ($linked -and (Split-Path $linked -Parent) -eq $root -and (Split-Path $linked -Leaf).StartsWith("$Slug-default-") -and (Get-Content -LiteralPath "$linked/base" -Raw -ErrorAction SilentlyContinue) -eq $script:RPBase) { $script:RPDirectory=$linked } try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} - $credential="$script:RPDirectory/credential" try { Write-RogueProtectionFile "$script:RPDirectory/base" $script:RPBase } catch {} + return $true +} +function Restore-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $root=Split-Path $script:RPDirectory -Parent + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { foreach ($previous in @(Get-ChildItem -LiteralPath $root -Directory -Filter "$Slug-default-*" -ErrorAction SilentlyContinue)) { if ($previous.FullName -eq $script:RPDirectory -or (Get-Content -LiteralPath (Join-Path $previous.FullName 'base') -Raw -ErrorAction SilentlyContinue) -ne $script:RPBase) { continue } @@ -107,18 +108,21 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu if ($restored.apiKey -ne $previousKey) { continue } Write-RogueProtectionFile "$script:RPDirectory/installation-directory" $previous.FullName $script:RPDirectory=$previous.FullName - $credential=Join-Path $script:RPDirectory 'credential' try { Write-RogueProtectionFile "$script:RPDirectory/used" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) } catch {} break - } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $Key } } + } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -notin 401,403) { return $false } } } } + return $true +} +function Register-RogueProtectionInstallation([string]$Key, [string]$Slug, [string]$Family, [string]$Version) { + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { $enrollAttempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/enroll-attempt" -Raw -ErrorAction SilentlyContinue), [ref]$enrollAttempt) $enrollElapsed=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $enrollAttempt - if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } - try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $Key } + if ($enrollElapsed -ge 0 -and $enrollElapsed -lt 60) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $false } + try { $lock=[IO.File]::Open("$script:RPDirectory/enroll.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { return $false } try { if (-not (Test-Path -LiteralPath $credential)) { Write-RogueProtectionFile "$script:RPDirectory/enroll-attempt" ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) @@ -137,26 +141,39 @@ function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slu } } finally { $lock.Dispose() } } + return $true +} +function Start-RogueProtectionPoller { + $pollLock=$null + try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} + try { + $pollPid=0 + $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) + if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { + $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' + $escape={param($s) "'" + $s.Replace("'", "''") + "'"} + $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } + if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } + try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } + } + } finally { if ($pollLock) { $pollLock.Dispose() } } +} +function Initialize-RogueProtection([string]$Key, [string]$BaseUrl, [string]$Slug, [string]$Family, [string]$Surface='default', [string]$Version='unknown') { + if (-not $Key) { return $Key } + if (-not $BaseUrl) { $BaseUrl='https://api.rogue.security' } + $script:RPBase=$BaseUrl.TrimEnd('/') + if (-not (Set-RogueProtectionDirectory $Key $Slug)) { return $Key } + if (-not (Restore-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + if (-not (Register-RogueProtectionInstallation $Key $Slug $Family $Version)) { return $Key } + $credential=Join-Path $script:RPDirectory 'credential' if (-not (Test-Path -LiteralPath $credential)) { if (Test-Path -LiteralPath "$script:RPDirectory/legacy-server") { $script:RPDirectory=$null }; return $Key } $script:RPKey=Get-Content -LiteralPath $credential -Raw $attempt=0L $null=[long]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/attempt" -Raw -ErrorAction SilentlyContinue), [ref]$attempt) if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $attempt -ge 15) { Update-RogueProtection } - $pollLock=$null - try { $pollLock=[IO.File]::Open("$script:RPDirectory/poll-start.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch {} - try { - $pollPid=0 - $null=[int]::TryParse((Get-Content -LiteralPath "$script:RPDirectory/poll.pid" -Raw -ErrorAction SilentlyContinue), [ref]$pollPid) - if ($pollLock -and -not ($pollPid -and (Get-Process -Id $pollPid -ErrorAction SilentlyContinue))) { - $scriptFile=Join-Path $script:RPHelperDirectory 'protection.ps1' - $escape={param($s) "'" + $s.Replace("'", "''") + "'"} - $command="& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $(& $escape $scriptFile)))) -Poll $(& $escape $script:RPDirectory) -Base $(& $escape $script:RPBase)" - $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) - $params=@{ FilePath=(Get-Process -Id $PID).Path; ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$encoded); PassThru=$true } - if ($env:OS -eq 'Windows_NT') { $params.WindowStyle='Hidden' } - try { $child=Start-Process @params; Write-RogueProtectionFile "$script:RPDirectory/poll.pid" $child.Id.ToString() } catch { } - } - } finally { if ($pollLock) { $pollLock.Dispose() } } + Start-RogueProtectionPoller $state=Get-RogueProtectionState $script:RPRevision=if ($state) { $state.aidr.revision } else { $null } $env:ROGUE_PROTECTION_STATE=$script:RPDirectory diff --git a/scripts/shared/protection.sh b/scripts/shared/protection.sh index 405aba5..d88ca1f 100644 --- a/scripts/shared/protection.sh +++ b/scripts/shared/protection.sh @@ -81,36 +81,35 @@ rogue_protection_leave() { rm -f "$ROGUE_PROTECTION_STATE/active.$$" rogue_protection_ack } -rogue_protection_init() { - # Arguments: log slug, agent family, script directory, optional surface. - [ -n "${ROGUE_API_KEY:-}" ] || return 0 - ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} - ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} +rogue_protection_select_directory() { _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | shasum -a 256 2>/dev/null | cut -d' ' -f1) [ -n "$_rp_hash" ] || _rp_hash=$(printf '%s\n%s' "$ROGUE_PROTECTION_BASE" "$ROGUE_API_KEY" | sha256sum 2>/dev/null | cut -d' ' -f1) - [ -n "$_rp_hash" ] || return 0 + [ -n "$_rp_hash" ] || return 1 case "${ROGUE_PROTECTION_STATE:-}" in */"$1-default-"*) [ "$(cat "$ROGUE_PROTECTION_STATE/credential" 2>/dev/null)" = "$ROGUE_API_KEY" ] || ROGUE_PROTECTION_STATE='' ;; *) ROGUE_PROTECTION_STATE='' ;; esac ROGUE_PROTECTION_STATE="${ROGUE_PROTECTION_STATE:-${ROGUE_PROTECTION_DIR:-$HOME/.rogue/protection}/$1-default-$_rp_hash}" - (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 0 + (umask 077; mkdir -p "$ROGUE_PROTECTION_STATE") || return 1 _rp_link=$(cat "$ROGUE_PROTECTION_STATE/installation-directory" 2>/dev/null) case "$_rp_link" in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*) if [ "$(cat "$_rp_link/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] && [ -s "$_rp_link/credential" ]; then ROGUE_PROTECTION_STATE=$_rp_link; fi ;; esac rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" printf '%s' "$ROGUE_PROTECTION_BASE" > "$ROGUE_PROTECTION_STATE/base" + return 0 +} +rogue_protection_restore_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then for _rp_previous in "${ROGUE_PROTECTION_STATE%/*}/$1-default-"*; do [ "$_rp_previous" != "$ROGUE_PROTECTION_STATE" ] || continue [ "$(cat "$_rp_previous/base" 2>/dev/null)" = "$ROGUE_PROTECTION_BASE" ] || continue _rp_previous_key=$(cat "$_rp_previous/credential" 2>/dev/null) || continue [ -n "$_rp_previous_key" ] || continue - _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 0 + _rp_restored=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H "x-rogue-installation-key: $_rp_previous_key" -H 'content-type: application/json' --data "{\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"unknown\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || return 1 _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) - case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 0 ;; esac + case "$_rp_restore_status" in 401|403) continue ;; 2??) ;; *) return 1 ;; esac _rp_restored_key=$(printf '%s' "$_rp_restored" | sed -n 's/.*"apiKey":"\([A-Za-z0-9_-][A-Za-z0-9_-]*\)".*/\1/p') [ "$_rp_restored_key" = "$_rp_previous_key" ] || continue (umask 077; printf '%s' "$_rp_previous" > "$ROGUE_PROTECTION_STATE/installation-directory") @@ -119,17 +118,20 @@ rogue_protection_init() { break done fi + return 0 +} +rogue_protection_enroll_installation() { if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then _rp_enroll_attempt=$(cat "$ROGUE_PROTECTION_STATE/enroll-attempt" 2>/dev/null) || _rp_enroll_attempt=0 _rp_elapsed=$(( $(rogue_protection_now) - ${_rp_enroll_attempt:-0} )) - if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 0 + if [ "$_rp_elapsed" -ge 0 ] && [ "$_rp_elapsed" -lt 60 ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 1; fi + rogue_protection_lock "$ROGUE_PROTECTION_STATE/enroll.lock" || return 1 rogue_protection_now > "$ROGUE_PROTECTION_STATE/enroll-attempt" if [ ! -s "$ROGUE_PROTECTION_STATE/enrollment-nonce" ]; then - (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + (umask 077; od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" && mv "$ROGUE_PROTECTION_STATE/enrollment-nonce.tmp" "$ROGUE_PROTECTION_STATE/enrollment-nonce") || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } fi _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") - [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 0; } + [ "${#_rp_nonce}" -eq 64 ] || { rm -f "$ROGUE_PROTECTION_STATE/enroll.lock"; return 1; } _rp_response=$(curl -sS -w '\n%{http_code}' --max-time 5 -H "x-rogue-api-key: $ROGUE_API_KEY" -H 'content-type: application/json' --data "{\"enrollmentNonce\":\"$_rp_nonce\",\"type\":\"coding_agent\",\"name\":\"$1\",\"family\":\"$2\",\"host\":\"$(rogue_protection_escape "$(hostname)")\",\"version\":\"$(rogue_protection_escape "${ROGUE_INSTALL_VERSION:-unknown}")\"}" "$ROGUE_PROTECTION_BASE/api/v1/hooks/protection/enroll" 2>/dev/null) || _rp_response='' case "$(printf '%s' "$_rp_response" | tail -n 1)" in 404) touch "$ROGUE_PROTECTION_STATE/legacy-server" ;; @@ -140,19 +142,32 @@ rogue_protection_init() { if [ -n "$_rp_key" ]; then (umask 077; printf '%s' "$_rp_key" > "$ROGUE_PROTECTION_STATE/credential.tmp"; mv "$ROGUE_PROTECTION_STATE/credential.tmp" "$ROGUE_PROTECTION_STATE/credential"); fi rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true fi - if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi - ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" - export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE - _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 - [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + return 0 +} +rogue_protection_start_poller() { if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then - nohup sh "$3/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & + nohup sh "$1/protection.sh" --poll "$ROGUE_PROTECTION_STATE" "$ROGUE_PROTECTION_BASE" /dev/null 2>&1 & printf '%s' "$!" > "$ROGUE_PROTECTION_STATE/poll.lock/pid" else _rp_poll_pid=$(cat "$ROGUE_PROTECTION_STATE/poll.lock/pid" 2>/dev/null) if [ -n "$_rp_poll_pid" ] && ! kill -0 "$_rp_poll_pid" 2>/dev/null; then rm -f "$ROGUE_PROTECTION_STATE/poll.lock/pid"; rmdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; fi fi +} +rogue_protection_init() { + # Arguments: log slug, agent family, script directory, optional surface. + [ -n "${ROGUE_API_KEY:-}" ] || return 0 + ROGUE_PROTECTION_BASE=${ROGUE_BASE_URL:-https://api.rogue.security} + ROGUE_PROTECTION_BASE=${ROGUE_PROTECTION_BASE%/} + rogue_protection_select_directory "$1" || return 0 + rogue_protection_restore_installation "$1" "$2" || return 0 + rogue_protection_enroll_installation "$1" "$2" || return 0 + if [ ! -s "$ROGUE_PROTECTION_STATE/credential" ]; then [ ! -f "$ROGUE_PROTECTION_STATE/legacy-server" ] || ROGUE_PROTECTION_STATE=''; return 0; fi + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + ROGUE_LOG_FILE="$ROGUE_PROTECTION_STATE/$1.log" + export ROGUE_API_KEY ROGUE_PROTECTION_STATE ROGUE_PROTECTION_BASE ROGUE_LOG_FILE + _rp_attempt=$(cat "$ROGUE_PROTECTION_STATE/attempt" 2>/dev/null) || _rp_attempt=0 + [ $(( $(rogue_protection_now) - ${_rp_attempt:-0} )) -lt 15 ] || rogue_protection_refresh + rogue_protection_start_poller "$3" rogue_protection_load && ROGUE_PROTECTION_REVISION=$RP_AIDR_REV export ROGUE_PROTECTION_REVISION } diff --git a/tests/test_protection.mjs b/tests/test_protection.mjs index ba84749..09d2881 100644 --- a/tests/test_protection.mjs +++ b/tests/test_protection.mjs @@ -311,11 +311,11 @@ try { const directory=path.join(temp,'ps-checkpoint'); fs.mkdirSync(directory); fs.mkdirSync(path.join(directory,'broken.state')); fs.writeFileSync(path.join(directory,'state.json'),JSON.stringify({decision:{protocolVersion:1,revision:50,serverTime:new Date().toISOString(),aidr:{paused:false,revision:50},aispm:{paused:false,revision:0}},receivedAt:new Date().toISOString()})); - const script=`$env:ROGUE_PS_LIB_ONLY='1'; . '${path.join(repo,'scripts/shared/ship-logs.ps1')}'; . '${path.join(repo,'scripts/shared/protection.ps1')}'; $script:stateDir='${directory}'; $script:RPDirectory='${directory}'; $script:RPBase='${base}'; $script:RPKey='installation_test_key'; $script:RPRevision=50; try { Write-ShipState broken 10 head 10 log; exit 9 } catch {}; Send-RogueProtectionAck; if (-not $script:RPPersistenceFailed) { exit 8 }`; + const script=`$env:ROGUE_PS_LIB_ONLY='1'; . '${path.join(repo,'scripts/shared/ship-logs.ps1')}'; . '${path.join(repo,'scripts/shared/protection.ps1')}'; $script:stateDir='${directory}'; $script:RPDirectory='${directory}'; $script:RPBase='${base}'; $script:RPKey='checkpoint_test_key'; $script:RPRevision=50; try { Write-ShipState broken 10 head 10 log; exit 9 } catch {}; Send-RogueProtectionAck; if (-not $script:RPPersistenceFailed) { exit 8 }`; const start=requests.length; const result=await run(process.env.ROGUE_TEST_PWSH,['-NoProfile','-Command',script],{},''); assert.equal(result.code,0,result.err); - const acks=requests.slice(start).filter(call=>call.path.endsWith('/ack')).map(call=>JSON.parse(call.body)); + const acks=requests.slice(start).filter(call=>call.path.endsWith('/ack') && call.key==='checkpoint_test_key').map(call=>JSON.parse(call.body)); assert(acks.some(ack=>ack.status==='failed')); assert(!acks.some(ack=>ack.status==='applied')); }); await test('an unavailable enrollment never enables unscoped activity on retry', async () => { From 6dc20d601a52024c0dedddb7b83579cef93386e8 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Thu, 17 Sep 2026 14:56:22 +0300 Subject: [PATCH 11/14] test(hooks): reconcile pause fixtures with credential precedence (FIRE-2137) --- tests/test_env_first_found.mjs | 1 + tests/test_protection.mjs | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_env_first_found.mjs b/tests/test_env_first_found.mjs index 2fb0856..689e6bf 100644 --- a/tests/test_env_first_found.mjs +++ b/tests/test_env_first_found.mjs @@ -184,6 +184,7 @@ test("hook.mjs sends the machine file's key, not the user file's or the process const sb = sandbox(); const seen = {}; const server = http.createServer((req, res) => { + if ((req.url || "").includes("/hooks/protection/")) { req.resume(); res.writeHead(404); res.end("{}"); return; } if ((req.url || "").endsWith("/hooks/gemini")) seen.key = req.headers["x-rogue-api-key"]; req.on("data", () => {}); req.on("end", () => { diff --git a/tests/test_protection.mjs b/tests/test_protection.mjs index 09d2881..8748ed6 100644 --- a/tests/test_protection.mjs +++ b/tests/test_protection.mjs @@ -16,6 +16,7 @@ function isolateEnv(directory) { const file=path.join(directory,entry.name); if (entry.isDirectory()) isolateEnv(file); else if (entry.name.endsWith('.sh')) fs.writeFileSync(file,fs.readFileSync(file,'utf8').replaceAll('/etc/rogue/env',path.join(temp,'empty-env')).replaceAll('$HOME/.rogue-env',path.join(temp,'empty-env'))); + else if (entry.name.endsWith('.mjs')) fs.writeFileSync(file,fs.readFileSync(file,'utf8').replaceAll('"/etc/rogue/env"',JSON.stringify(path.join(temp,'empty-env')))); } } isolateEnv(fixturePlugins); @@ -40,7 +41,7 @@ await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); const base=`http://127.0.0.1:${server.address().port}`; function run(command,args,env, input) { return new Promise((resolve,reject) => { - const child=spawn(command,args,{cwd:repo,env:{...process.env,...env},stdio:['pipe','pipe','pipe']}); + const child=spawn(command,args,{cwd:repo,env:{...process.env,HOME:temp,USERPROFILE:temp,...env},stdio:['pipe','pipe','pipe']}); let out='',err=''; child.stdout.on('data',b=>out+=b); child.stderr.on('data',b=>err+=b); if (input !== undefined) child.stdin.end(input); const timeout=setTimeout(()=>{child.kill();reject(new Error(`Hook read stdin while paused: ${args[0]}`));},10000); @@ -332,9 +333,14 @@ try { } }); await test('a new shell invocation honors persisted pause while offline',async()=>{ - available=false; + paused=true; revision++; const root=path.join(fixturePlugins,'codex'); const env={ROGUE_API_KEY:'provision_codex',ROGUE_BASE_URL:base,ROGUE_PROTECTION_DIR:temp,PLUGIN_ROOT:root,CODEX_PLUGIN_ROOT:root,ROGUE_LOG_DIR:temp}; + const directory=path.join(temp,fs.readdirSync(temp).find(name=>name.startsWith('codex-default-'))); + fs.writeFileSync(path.join(directory,'attempt'),'0'); + const online=await run('bash',[path.join(root,'scripts/hook.sh'),'PreToolUse'],env); + assert.equal(online.code,0); assert.deepEqual(JSON.parse(online.out),{}); + available=false; const result=await run('bash',[path.join(root,'scripts/hook.sh'),'PreToolUse'],env); assert.equal(result.code,0); assert.deepEqual(JSON.parse(result.out),{}); }); From c71e1736209414bd42db937c4ebc95acee1a099e Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Thu, 17 Sep 2026 15:01:29 +0300 Subject: [PATCH 12/14] test(hooks): use legacy receiver for configured log paths (FIRE-2137) --- tests/test_hook_logs.sh | 57 ++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/tests/test_hook_logs.sh b/tests/test_hook_logs.sh index 47c07f2..5919499 100755 --- a/tests/test_hook_logs.sh +++ b/tests/test_hook_logs.sh @@ -6,10 +6,8 @@ # duplicated six times (the repo has no shared library) and a copy/paste drift in # any one of them is invisible until someone reads a customer's log. # -# Everything here runs on the UNCONFIGURED path (ROGUE_API_KEY empty), so no mock -# server and no network are needed: a dispatcher with no key still logs -# `outcome=unconfigured` and fails open. HOME is redirected per case so the -# developer's real ~/.rogue is never touched. +# Most cases use an empty API key; the env-file case uses the local test receiver. +# HOME is redirected per case so the developer's real ~/.rogue is never touched. # # Run under dash as well as bash: `sh tests/test_hook_logs.sh`. # Set SH=dash to force a specific shell for the dispatchers under test. @@ -22,9 +20,14 @@ TMPROOT="$(mktemp -d "${TMPDIR:-/tmp}/rogue-logtest.XXXXXX")" # Split, not one trap for all three: a bare `trap 'rm -rf …' INT` runs the handler # and then CONTINUES with the next statement, so a Ctrl-C would delete the fixtures # and let every remaining case run against them. -trap 'rm -rf "$TMPROOT"' EXIT -trap 'rm -rf "$TMPROOT"; exit 130' INT -trap 'rm -rf "$TMPROOT"; exit 143' TERM +RECEIVER_PID="" +cleanup() { + if [ -n "$RECEIVER_PID" ]; then kill "$RECEIVER_PID" 2>/dev/null; wait "$RECEIVER_PID" 2>/dev/null; fi + rm -rf "$TMPROOT" +} +trap 'cleanup' EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM pass() { echo " ok: $1"; } fail() { echo "FAIL: $1"; FAILS=$((FAILS + 1)); } @@ -267,22 +270,30 @@ done echo echo "== ~/.rogue-env can relocate the log (env FILE, not just process env)" -# Only the env file holding ROGUE_API_KEY is read, so the case stages one (with a -# dead local base URL, so the configured dispatcher POSTs nowhere real), and every -# dispatcher resolves its log destination AFTER reading that file. The -# process-env cases above cannot catch a dispatcher that reads its own environment -# too early: `plugins/gemini/scripts/hook.mjs` did exactly that (module-level -# consts, while loadEnvFiles() runs later and returns a merged object WITHOUT -# mutating process.env), so ~/.rogue-env was silently ignored there. -for slug in $SLUGS; do - home="$TMPROOT/envfile-$slug"; mkdir -p "$home/custom" - printf 'export ROGUE_API_KEY=k\nexport ROGUE_BASE_URL=http://127.0.0.1:1\nexport ROGUE_LOG_DIR=%s\n' \ - "$home/custom" > "$home/.rogue-env" - chmod 600 "$home/.rogue-env" - fire "$slug" "$home" - got=$(find "$home" -name '*.log' 2>/dev/null | sed "s|^$home||" | sort | tr '\n' ' ') - check "$slug honors ROGUE_LOG_DIR from ~/.rogue-env" "/custom/$slug.log " "$got" -done +# A legacy-server response permits logging without weakening enrollment failure handling. +if command -v node >/dev/null 2>&1; then + recv="$TMPROOT/receiver" + node "$REPO/tests/e2e_receiver.mjs" "$recv" >"$TMPROOT/receiver.out" 2>&1 & + RECEIVER_PID=$! + waited=0 + while [ ! -s "$recv/port" ]; do + waited=$((waited + 1)) + [ "$waited" -gt 100 ] && { fail "the receiver never started"; exit 1; } + sleep 0.1 + done + base="http://127.0.0.1:$(cat "$recv/port")" + for slug in $SLUGS; do + home="$TMPROOT/envfile-$slug"; mkdir -p "$home/custom" + printf 'export ROGUE_API_KEY=e2e-key\nexport ROGUE_BASE_URL=%s\nexport ROGUE_LOG_DIR=%s\n' \ + "$base" "$home/custom" > "$home/.rogue-env" + chmod 600 "$home/.rogue-env" + fire "$slug" "$home" + got=$(find "$home" -name '*.log' 2>/dev/null | sed "s|^$home||" | sort | tr '\n' ' ') + check "$slug honors ROGUE_LOG_DIR from ~/.rogue-env" "/custom/$slug.log " "$got" + done +else + echo "NOTE: node not found — skipping the env-file log-location case" +fi echo echo "== a zero-padded zero cap (00) disables rotation, like 0" From fc5fdf8affb9c8ad275e27bafc23a16e634458fa Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Thu, 17 Sep 2026 15:29:43 +0300 Subject: [PATCH 13/14] fix(hooks): reject malformed Gemini pause decisions (FIRE-2137) --- docs/protection-pause.md | 6 ++-- plugins/gemini/scripts/protection.mjs | 12 +++++++- tests/test_protection.mjs | 41 +++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/protection-pause.md b/docs/protection-pause.md index a806f11..511b8e2 100644 --- a/docs/protection-pause.md +++ b/docs/protection-pause.md @@ -1,14 +1,14 @@ # Pause coding protection from Fleet -Organization owners and admins can pause a supported coding installation from its existing Fleet detail sheet. AIDR pause stops Rogue hook payload collection, evaluation, blocking, local activity logs and log uploads. The host receives its normal allow response. Management requests continue so the client can receive a resume decision. +Organization owners and admins can pause a supported coding installation from its Fleet row menu. AIDR pause stops Rogue hook payload collection, evaluation, blocking, local activity logs and log uploads. The host receives its normal allow response. Management requests continue so the client can receive a resume decision. Protocol 1 is included in the source versions Claude 1.0.30, Codex 1.0.4, Cursor 1.1.6, Copilot 1.2.5, Gemini 1.0.29, Antigravity 1.0.27 and Kiro 1.0.1. Release and deployment remain separate steps. Windows uses the PowerShell bridge; macOS and Linux use shell bridges, with Node for Gemini. -Choose a reason and a duration of one hour, four hours, 24 hours or until resumed. Any organization admin may resume or change duration without another reason. AISPM discovery is controlled on the endpoint that runs scans. An independent API-key pause still applies after the installation is resumed. +Choose a duration of one hour, four hours, 24 hours or until resumed. The reason is optional. Any organization admin may resume or change duration without another reason. AISPM discovery is controlled on the endpoint that runs scans. An independent API-key pause still applies after the installation is resumed. The bridge exchanges its provisioning key for an installation credential at `/api/v1/hooks/protection/enroll`. A private random enrollment nonce makes retries return the same installation after a lost response. Initial enrollment outages stop collection until registration succeeds; a confirmed unsupported legacy server retains the previous bridge behavior. Identity is server-assigned and persists in the user's private protection directory. A hostname or email cannot claim an existing identity. Related host variants that use the same bridge configuration share that installation. Independent configurations remain separate. After provisioning-key rotation, the bridge restores its existing authenticated installation and reuses its saved decision and active-work tracking. An unavailable server postpones restoration without collecting activity under an unscoped key. -The bridge fetches the current decision before collecting hook input and keeps a short-lived background poller while the installation is in use. Polling runs every 15 seconds; the delivery target is 60 seconds online. The server displays applied only after a current acknowledgement. Pending, offline, unsupported or failed are not proof that local work stopped. A state-storage failure blocks the current hook and reports a failure acknowledgement. A saved finite pause expires offline; an indefinite pause remains until confirmed resume. +The bridge fetches the current decision before collecting hook input and keeps a short-lived background poller while the installation is in use. Polling runs every 15 seconds; the delivery target is 60 seconds online. The server displays applied only after a current acknowledgement. Pending, offline, unsupported or failed are not proof that local work stopped. A state-storage failure blocks the current hook and reports a failure acknowledgement. A saved finite pause expires offline; an indefinite pause remains until confirmed resume. Gemini validates both downloaded and saved decisions. An invalid response leaves the last valid decision in place; without a valid saved decision, the bridge does not collect or upload activity. Registered installations store their hook logs inside their private protection directory and ship them with their own credential. The scoped log path takes precedence over a shared custom log destination. On resume, the shipper saves its discard offset and protection revision together, so a failed write cannot enable replay. It advances past buffered content without modifying the source log. It sends only fresh subsequent entries. An endpoint's mixed-log diagnostic uploader cannot attribute these installations and must not bypass their pauses. diff --git a/plugins/gemini/scripts/protection.mjs b/plugins/gemini/scripts/protection.mjs index 327f408..a2170d7 100644 --- a/plugins/gemini/scripts/protection.mjs +++ b/plugins/gemini/scripts/protection.mjs @@ -13,12 +13,22 @@ const write = (file, value) => { fs.writeFileSync(temp, value, { mode: 0o600 }); fs.renameSync(temp, file); }; +const validDecision = (decision) => decision?.protocolVersion === 1 + && Number.isSafeInteger(decision.revision) && decision.revision >= 0 + && typeof decision.serverTime === 'string' && Number.isFinite(Date.parse(decision.serverTime)) + && ['aidr', 'aispm'].every(cap => { + const value = decision[cap]; + return typeof value?.paused === 'boolean' + && Number.isSafeInteger(value.revision) && value.revision >= 0 + && (value.expiresAt === null || (typeof value.expiresAt === 'string' && Number.isFinite(Date.parse(value.expiresAt)))); + }); export class Protection { constructor(directory, base, key) { this.directory=directory; this.base=base; this.key=key; this.revision=undefined; } file(name) { return path.join(this.directory, name); } state() { try { const saved = JSON.parse(read(this.file('state.json'))); + if (!validDecision(saved?.decision) || !Number.isFinite(saved.receivedAt) || saved.receivedAt < 0) return undefined; const now = Date.parse(saved.decision.serverTime) + Date.now() - saved.receivedAt; for (const cap of ['aidr','aispm']) { if (saved.decision[cap].expiresAt && Date.parse(saved.decision[cap].expiresAt) <= now) saved.decision[cap].paused=false; @@ -65,7 +75,7 @@ export class Protection { write(this.file('attempt'), String(Date.now())); const state=await this.request('state'); const previous=this.state(); - if (state.protocolVersion !== 1 || !Number.isSafeInteger(state.revision) || !state.aidr || !state.aispm) return; + if (!validDecision(state)) return; if (!previous || state.revision >= previous.revision) { try { write(this.file('state.json'), JSON.stringify({ decision:state, receivedAt:Date.now() })); diff --git a/tests/test_protection.mjs b/tests/test_protection.mjs index 8748ed6..756811b 100644 --- a/tests/test_protection.mjs +++ b/tests/test_protection.mjs @@ -20,7 +20,7 @@ function isolateEnv(directory) { } } isolateEnv(fixturePlugins); -let revision=1, paused=true, available=true, legacy=false, disconnect=false; +let revision=1, paused=true, available=true, legacy=false, disconnect=false, malformedDecision; const requests=[]; const server=http.createServer(async (req,res) => { let body=''; for await (const chunk of req) body+=chunk; @@ -32,7 +32,7 @@ const server=http.createServer(async (req,res) => { if (req.url.endsWith('/enroll')) { res.end(JSON.stringify({apiKey:'installation_test_key', installation:{id:'test',type:'coding_agent',role:'coding'}})); return; } if (req.url.endsWith('/state')) { if (req.headers.accept === 'text/tab-separated-values') { res.setHeader('content-type','text/tab-separated-values'); res.end([1,revision,Number(paused),0,0,0,Math.floor(Date.now()/1000),revision,0].join('\t')); return; } - res.end(JSON.stringify({protocolVersion:1,revision,serverTime:new Date().toISOString(),aidr:{paused,expiresAt:null,revision},aispm:{paused:false,expiresAt:null,revision:0}})); return; + res.end(JSON.stringify(malformedDecision ?? {protocolVersion:1,revision,serverTime:new Date().toISOString(),aidr:{paused,expiresAt:null,revision},aispm:{paused:false,expiresAt:null,revision:0}})); return; } if (req.url.endsWith('/ack')) { res.end('{"success":true}'); return; } res.end('{}'); @@ -152,6 +152,43 @@ try { assert.deepEqual(JSON.parse(out || '{}'),{}); assert(!requests.slice(start).some(call=>call.path.endsWith('/hooks/gemini'))); }); + await test('Gemini rejects malformed cached and remote decisions without clearing a valid pause',async()=>{ + const {Protection}=await import(path.join(fixturePlugins,'gemini/scripts/protection.mjs')); + const directory=path.join(temp,'malformed-decisions'); fs.mkdirSync(directory); + const client=new Protection(directory,base,'malformed_test_key'); + const decision={protocolVersion:1,revision,serverTime:new Date().toISOString(),aidr:{paused:true,expiresAt:null,revision},aispm:{paused:false,expiresAt:null,revision:0}}; + const saved=JSON.stringify({decision,receivedAt:Date.now()}); + const cases=[ + {...decision,aidr:{...decision.aidr,paused:null}}, + {...decision,aidr:{...decision.aidr,paused:'false'}}, + {...decision,aidr:{}}, + {...decision,aidr:{...decision.aidr,revision:-1}}, + {...decision,revision:1.5}, + {...decision,revision:-1}, + {...decision,serverTime:'invalid'}, + {...decision,aidr:{...decision.aidr,expiresAt:'invalid'}}, + {...decision,aispm:null}, + ]; + try { + for (const invalid of cases) { + fs.writeFileSync(client.file('state.json'),JSON.stringify({decision:invalid,receivedAt:Date.now()})); + assert.equal(client.current(),false); + assert.equal(client.enter(),false); + const beforeAck=requests.length; await client.ack(); + assert.equal(requests.length,beforeAck); + fs.writeFileSync(client.file('state.json'),saved); + malformedDecision=invalid; + await client.refresh(); + assert.equal(fs.readFileSync(client.file('state.json'),'utf8'),saved); + assert.equal(client.current(),false); + assert(!requests.slice(beforeAck).some(call=>call.path.endsWith('/ack'))); + } + fs.writeFileSync(client.file('state.json'),JSON.stringify({decision:{...decision,aidr:{...decision.aidr,paused:false}},receivedAt:'invalid'})); + assert.equal(client.current(),false); + fs.writeFileSync(client.file('state.json'),JSON.stringify({decision:{...decision,aidr:{...decision.aidr,expiresAt:new Date(Date.now()-1000).toISOString()}},receivedAt:Date.now()})); + assert.equal(client.current(),true,'a valid expired pause still resumes'); + } finally {malformedDecision=undefined;} + }); await test('Gemini keeps a saved pause when bookkeeping cannot be written',async()=>{ const root=path.join(fixturePlugins,'gemini'); const directory=path.join(temp,`gemini-default-${createHash('sha256').update(`${base}\nprovision_gemini`).digest('hex')}`); From d9e099571803793f3540ae16989acd6962929eba Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Thu, 17 Sep 2026 15:56:43 +0300 Subject: [PATCH 14/14] fix(hooks): handle protection failure paths (FIRE-2137) --- docs/protection-pause.md | 2 +- plugins/antigravity/scripts/protection.sh | 5 +- plugins/antigravity/scripts/ship-logs.sh | 6 +- plugins/codex/scripts/protection.sh | 5 +- plugins/codex/scripts/ship-logs.sh | 6 +- plugins/copilot/scripts/hook.sh | 2 +- plugins/copilot/scripts/protection.sh | 5 +- plugins/copilot/scripts/ship-logs.sh | 6 +- plugins/cursor/scripts/protection.sh | 5 +- plugins/cursor/scripts/ship-logs.sh | 6 +- plugins/gemini/scripts/protection.mjs | 10 +- plugins/gemini/scripts/ship-logs.mjs | 12 +- plugins/kiro/scripts/heartbeat.ps1 | 11 +- plugins/kiro/scripts/heartbeat.sh | 4 +- plugins/kiro/scripts/hook.ps1 | 6 +- plugins/kiro/scripts/protection.sh | 5 +- plugins/kiro/scripts/ship-logs.sh | 6 +- plugins/rogue/scripts/heartbeat.ps1 | 25 +++-- plugins/rogue/scripts/hook.ps1 | 2 +- plugins/rogue/scripts/protection.sh | 5 +- plugins/rogue/scripts/ship-logs.sh | 6 +- scripts/shared/protection.sh | 5 +- scripts/shared/ship-logs.sh | 6 +- tests/test_protection.mjs | 128 ++++++++++++++++++++-- 24 files changed, 215 insertions(+), 64 deletions(-) diff --git a/docs/protection-pause.md b/docs/protection-pause.md index 511b8e2..7cd43e5 100644 --- a/docs/protection-pause.md +++ b/docs/protection-pause.md @@ -10,7 +10,7 @@ The bridge exchanges its provisioning key for an installation credential at `/ap The bridge fetches the current decision before collecting hook input and keeps a short-lived background poller while the installation is in use. Polling runs every 15 seconds; the delivery target is 60 seconds online. The server displays applied only after a current acknowledgement. Pending, offline, unsupported or failed are not proof that local work stopped. A state-storage failure blocks the current hook and reports a failure acknowledgement. A saved finite pause expires offline; an indefinite pause remains until confirmed resume. Gemini validates both downloaded and saved decisions. An invalid response leaves the last valid decision in place; without a valid saved decision, the bridge does not collect or upload activity. -Registered installations store their hook logs inside their private protection directory and ship them with their own credential. The scoped log path takes precedence over a shared custom log destination. On resume, the shipper saves its discard offset and protection revision together, so a failed write cannot enable replay. It advances past buffered content without modifying the source log. It sends only fresh subsequent entries. An endpoint's mixed-log diagnostic uploader cannot attribute these installations and must not bypass their pauses. +Registered installations store their hook logs inside their private protection directory and ship them with their own credential. The scoped log path takes precedence over a shared custom log destination. On resume, the shipper saves its discard offset and protection revision together, so a failed write cannot enable replay. Checkpoint failures stop that shipping run and send a failure acknowledgement. It advances past buffered content without modifying the source log. It sends only fresh subsequent entries. An endpoint's mixed-log diagnostic uploader cannot attribute these installations and must not bypass their pauses. Older plugins and native HTTP hooks without a local gate have limited coverage. Their API-key pause can suppress server work but cannot stop local collection or CPU use. Use an updated command bridge for local suspension. Existing activity already committed before a pause remains historical. diff --git a/plugins/antigravity/scripts/protection.sh b/plugins/antigravity/scripts/protection.sh index d88ca1f..3ffe5c9 100644 --- a/plugins/antigravity/scripts/protection.sh +++ b/plugins/antigravity/scripts/protection.sh @@ -207,7 +207,10 @@ rogue_protection_enter() { if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + while :; do + _rp_used=$(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null) + case "$_rp_used" in ''|*[!0-9]*) _rp_used=0 ;; esac + [ $(( $(rogue_protection_now) - _rp_used )) -lt 90 ] || rogue_protection_busy || break rogue_protection_refresh sleep 15 done diff --git a/plugins/antigravity/scripts/ship-logs.sh b/plugins/antigravity/scripts/ship-logs.sh index c954e7f..ab78ba0 100644 --- a/plugins/antigravity/scripts/ship-logs.sh +++ b/plugins/antigravity/scripts/ship-logs.sh @@ -822,7 +822,7 @@ drain_file() { # fi fi OFFSET=0 - write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } fi drain_file "$_target_file" "$_target_file_bytes" 0 "$_target_head" \ @@ -903,8 +903,8 @@ main() { [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" - rogue_protection_enter || exit 0 trap 'rogue_protection_leave' EXIT + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/codex/scripts/protection.sh b/plugins/codex/scripts/protection.sh index d88ca1f..3ffe5c9 100644 --- a/plugins/codex/scripts/protection.sh +++ b/plugins/codex/scripts/protection.sh @@ -207,7 +207,10 @@ rogue_protection_enter() { if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + while :; do + _rp_used=$(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null) + case "$_rp_used" in ''|*[!0-9]*) _rp_used=0 ;; esac + [ $(( $(rogue_protection_now) - _rp_used )) -lt 90 ] || rogue_protection_busy || break rogue_protection_refresh sleep 15 done diff --git a/plugins/codex/scripts/ship-logs.sh b/plugins/codex/scripts/ship-logs.sh index c954e7f..ab78ba0 100644 --- a/plugins/codex/scripts/ship-logs.sh +++ b/plugins/codex/scripts/ship-logs.sh @@ -822,7 +822,7 @@ drain_file() { # fi fi OFFSET=0 - write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } fi drain_file "$_target_file" "$_target_file_bytes" 0 "$_target_head" \ @@ -903,8 +903,8 @@ main() { [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" - rogue_protection_enter || exit 0 trap 'rogue_protection_leave' EXIT + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/copilot/scripts/hook.sh b/plugins/copilot/scripts/hook.sh index eb98843..a3ef638 100755 --- a/plugins/copilot/scripts/hook.sh +++ b/plugins/copilot/scripts/hook.sh @@ -395,7 +395,7 @@ fi URL="${ROGUE_API_URL:-${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/copilot}" # Buffer stdin so we can enrich it (agentStop/subagentStop) before POSTing. -BODY="$(rogue_protection_read_input)" +BODY="$(rogue_protection_read_input)" || { printf '%s' '{}'; exit 0; } rogue_protection_current || { printf '%s' '{}'; exit 0; } # Re-attribute a subagent's event to its parent session BEFORE any tail # augmentation (a subagent agentStop has no transcriptPath, so augment no-ops). diff --git a/plugins/copilot/scripts/protection.sh b/plugins/copilot/scripts/protection.sh index d88ca1f..3ffe5c9 100644 --- a/plugins/copilot/scripts/protection.sh +++ b/plugins/copilot/scripts/protection.sh @@ -207,7 +207,10 @@ rogue_protection_enter() { if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + while :; do + _rp_used=$(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null) + case "$_rp_used" in ''|*[!0-9]*) _rp_used=0 ;; esac + [ $(( $(rogue_protection_now) - _rp_used )) -lt 90 ] || rogue_protection_busy || break rogue_protection_refresh sleep 15 done diff --git a/plugins/copilot/scripts/ship-logs.sh b/plugins/copilot/scripts/ship-logs.sh index c954e7f..ab78ba0 100644 --- a/plugins/copilot/scripts/ship-logs.sh +++ b/plugins/copilot/scripts/ship-logs.sh @@ -822,7 +822,7 @@ drain_file() { # fi fi OFFSET=0 - write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } fi drain_file "$_target_file" "$_target_file_bytes" 0 "$_target_head" \ @@ -903,8 +903,8 @@ main() { [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" - rogue_protection_enter || exit 0 trap 'rogue_protection_leave' EXIT + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/cursor/scripts/protection.sh b/plugins/cursor/scripts/protection.sh index d88ca1f..3ffe5c9 100644 --- a/plugins/cursor/scripts/protection.sh +++ b/plugins/cursor/scripts/protection.sh @@ -207,7 +207,10 @@ rogue_protection_enter() { if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + while :; do + _rp_used=$(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null) + case "$_rp_used" in ''|*[!0-9]*) _rp_used=0 ;; esac + [ $(( $(rogue_protection_now) - _rp_used )) -lt 90 ] || rogue_protection_busy || break rogue_protection_refresh sleep 15 done diff --git a/plugins/cursor/scripts/ship-logs.sh b/plugins/cursor/scripts/ship-logs.sh index c954e7f..ab78ba0 100644 --- a/plugins/cursor/scripts/ship-logs.sh +++ b/plugins/cursor/scripts/ship-logs.sh @@ -822,7 +822,7 @@ drain_file() { # fi fi OFFSET=0 - write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } fi drain_file "$_target_file" "$_target_file_bytes" 0 "$_target_head" \ @@ -903,8 +903,8 @@ main() { [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" - rogue_protection_enter || exit 0 trap 'rogue_protection_leave' EXIT + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/gemini/scripts/protection.mjs b/plugins/gemini/scripts/protection.mjs index a2170d7..a8f7942 100644 --- a/plugins/gemini/scripts/protection.mjs +++ b/plugins/gemini/scripts/protection.mjs @@ -67,6 +67,12 @@ export class Protection { await this.request('ack', { protocolVersion:1, revision:state.revision, status:'applied', aidrPaused:state.aidr.paused, aispmPaused:state.aispm.paused }); write(this.file('ack'), id); } + async fail(state = this.state()) { + this.persistenceFailed=true; + try { write(this.file('persistence-failed'),'1'); } catch {} + if (!state) return; + try { await this.request('ack',{protocolVersion:1,revision:state.revision,status:'failed',aidrPaused:state.aidr.paused,aispmPaused:state.aispm.paused,error:'state_persistence_failed'}); } catch {} + } async refresh() { const lock=this.file('refresh.lock'); try { fs.writeFileSync(lock, String(process.pid), { flag:'wx', mode:0o600 }); } @@ -81,9 +87,7 @@ export class Protection { write(this.file('state.json'), JSON.stringify({ decision:state, receivedAt:Date.now() })); fs.rmSync(this.file('persistence-failed'),{force:true}); this.persistenceFailed=false; } catch { - this.persistenceFailed=true; - try { write(this.file('persistence-failed'),'1'); } catch {} - try { await this.request('ack',{protocolVersion:1,revision:state.revision,status:'failed',aidrPaused:state.aidr.paused,aispmPaused:state.aispm.paused,error:'state_persistence_failed'}); } catch {} + await this.fail(state); } } } catch {} finally { fs.rmSync(lock, {force:true}); } diff --git a/plugins/gemini/scripts/ship-logs.mjs b/plugins/gemini/scripts/ship-logs.mjs index c397b89..123e422 100644 --- a/plugins/gemini/scripts/ship-logs.mjs +++ b/plugins/gemini/scripts/ship-logs.mjs @@ -656,7 +656,10 @@ class Shipper { if (advanceBytes <= 0) return { offset, complete: false }; offset += advanceBytes; this.runBytesSent += advanceBytes; - if (!this.writeState(stateKey, offset, persistHead, persistSize, normalizedPath)) return {offset, complete:false}; + if (!this.writeState(stateKey, offset, persistHead, persistSize, normalizedPath)) { + await this.protection?.fail(); + return {offset, complete:false}; + } } return { offset, complete: true }; } @@ -697,7 +700,7 @@ class Shipper { const currentHead = firstLineFingerprint(filePath); this.runBytesSent = 0; if (this.protection?.revision > 0 && state.revision !== String(this.protection.revision)) { - this.writeState(stateKey, fileBytes, currentHead, fileBytes, normalizedPath); + if (!this.writeState(stateKey, fileBytes, currentHead, fileBytes, normalizedPath)) await this.protection.fail(); return; } let offset = state.offset; @@ -742,7 +745,10 @@ class Shipper { } } offset = 0; - this.writeState(stateKey, 0, currentHead, fileBytes, normalizedPath); + if (!this.writeState(stateKey, 0, currentHead, fileBytes, normalizedPath)) { + await this.protection?.fail(); + return; + } } await this.drainFile( diff --git a/plugins/kiro/scripts/heartbeat.ps1 b/plugins/kiro/scripts/heartbeat.ps1 index 3e4e5ae..3c41bba 100644 --- a/plugins/kiro/scripts/heartbeat.ps1 +++ b/plugins/kiro/scripts/heartbeat.ps1 @@ -345,10 +345,15 @@ function Invoke-Main { Initialize-Beacon # after the env files are parsed so they can set the interval Assert-ApiKey # exits 0 when this install is not configured Resolve-BaseUrl - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') - $script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'kiro' -Family 'kiro' - Resolve-Actor Resolve-Version + $protectionPath = Join-Path $pluginRoot 'scripts/protection.ps1' + if (-not (Test-Path -LiteralPath $protectionPath -PathType Leaf)) { exit 0 } + try { + $protectionText = Get-Content -Raw -LiteralPath $protectionPath -ErrorAction Stop + . ([scriptblock]::Create($protectionText)) -ScriptDirectory (Join-Path $pluginRoot 'scripts') + } catch { exit 0 } + $script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'kiro' -Family 'kiro' -Version $script:ver + Resolve-Actor Resolve-Surface Send-Heartbeat # claims the beacon slot, THEN asks Kiro for its version Start-LogShipper diff --git a/plugins/kiro/scripts/heartbeat.sh b/plugins/kiro/scripts/heartbeat.sh index 8c2446a..515eceb 100755 --- a/plugins/kiro/scripts/heartbeat.sh +++ b/plugins/kiro/scripts/heartbeat.sh @@ -169,11 +169,11 @@ main() { locate_plugin_root load_env # sources the env files, then normalises the base URL require_api_key # exits 0 when this install is not configured + resolve_surface "${1:-}" + resolve_version . "${PLUGIN_ROOT}/scripts/protection.sh" rogue_protection_init kiro kiro "${PLUGIN_ROOT}/scripts" load_actor - resolve_surface "${1:-}" - resolve_version # after the surface: install-id.sh keys the agent on it load_beacon # after load_env, so the library sees the interval knob post_heartbeat # claims the beacon slot, THEN asks Kiro for its version ship_logs diff --git a/plugins/kiro/scripts/hook.ps1 b/plugins/kiro/scripts/hook.ps1 index 1b3630f..664afa6 100644 --- a/plugins/kiro/scripts/hook.ps1 +++ b/plugins/kiro/scripts/hook.ps1 @@ -434,11 +434,12 @@ function Write-KiroDecision { function Invoke-KiroHook { Initialize-KiroContext Resolve-KiroActor + Resolve-KiroInstall if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') -$script:apiKey = Initialize-RogueProtection -Key $script:apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'kiro' -Family 'kiro' -if (-not (Enter-RogueProtection)) { exit 0 } +$script:apiKey = Initialize-RogueProtection -Key $script:apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'kiro' -Family 'kiro' -Version $script:pluginVersion try { +if (-not (Enter-RogueProtection)) { exit 0 } $payload = Read-KiroPayload if (-not (Test-RogueProtectionCurrent)) { exit 0 } @@ -447,7 +448,6 @@ try { Log "outcome=duplicate engine=3.0 trigger=$triggerArg" exit 0 } - Resolve-KiroInstall Start-KiroHeartbeat Send-KiroRequest if (-not (Test-RogueProtectionCurrent)) { exit 0 } diff --git a/plugins/kiro/scripts/protection.sh b/plugins/kiro/scripts/protection.sh index d88ca1f..3ffe5c9 100644 --- a/plugins/kiro/scripts/protection.sh +++ b/plugins/kiro/scripts/protection.sh @@ -207,7 +207,10 @@ rogue_protection_enter() { if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + while :; do + _rp_used=$(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null) + case "$_rp_used" in ''|*[!0-9]*) _rp_used=0 ;; esac + [ $(( $(rogue_protection_now) - _rp_used )) -lt 90 ] || rogue_protection_busy || break rogue_protection_refresh sleep 15 done diff --git a/plugins/kiro/scripts/ship-logs.sh b/plugins/kiro/scripts/ship-logs.sh index c954e7f..ab78ba0 100644 --- a/plugins/kiro/scripts/ship-logs.sh +++ b/plugins/kiro/scripts/ship-logs.sh @@ -822,7 +822,7 @@ drain_file() { # fi fi OFFSET=0 - write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } fi drain_file "$_target_file" "$_target_file_bytes" 0 "$_target_head" \ @@ -903,8 +903,8 @@ main() { [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" - rogue_protection_enter || exit 0 trap 'rogue_protection_leave' EXIT + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/plugins/rogue/scripts/heartbeat.ps1 b/plugins/rogue/scripts/heartbeat.ps1 index 4cba1b2..e84a543 100644 --- a/plugins/rogue/scripts/heartbeat.ps1 +++ b/plugins/rogue/scripts/heartbeat.ps1 @@ -148,8 +148,21 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') -$script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'claude' -Family 'claude' +# -- plugin version (regex from manifest, no python) ------------------------ +$ver = 'unknown' +$pj = Join-Path $pluginRoot '.claude-plugin\plugin.json' +if (Test-Path -LiteralPath $pj) { + $m = [regex]::Match((Get-Content -Raw -LiteralPath $pj), '"version"\s*:\s*"([0-9]+\.[0-9]+\.[0-9]+)') + if ($m.Success) { $ver = $m.Groups[1].Value } +} + +$protectionPath = Join-Path $pluginRoot 'scripts/protection.ps1' +if (-not (Test-Path -LiteralPath $protectionPath -PathType Leaf)) { exit 0 } +try { + $protectionText = Get-Content -Raw -LiteralPath $protectionPath -ErrorAction Stop + . ([scriptblock]::Create($protectionText)) -ScriptDirectory (Join-Path $pluginRoot 'scripts') +} catch { exit 0 } +$script:apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $baseUrl -Slug 'claude' -Family 'claude' -Version $ver # -- actor resolution: hook.ps1's Resolve-RogueActor ------------------------- # The ONE Claude cascade (env file -> CLAUDE_CODE_USER_EMAIL -> git config files -> @@ -176,14 +189,6 @@ if ($actor) { $actorEmail = [string]$actor.Email; $actorName = [string]$actor.Na if (-not $actorEmail) { $actorEmail = 'unknown' } if (-not $actorName) { $actorName = 'unknown' } -# -- plugin version (regex from manifest, no python) ------------------------ -$ver = 'unknown' -$pj = Join-Path $pluginRoot '.claude-plugin\plugin.json' -if (Test-Path -LiteralPath $pj) { - $m = [regex]::Match((Get-Content -Raw -LiteralPath $pj), '"version"\s*:\s*"([0-9]+\.[0-9]+\.[0-9]+)') - if ($m.Success) { $ver = $m.Groups[1].Value } -} - # -- agent display label from entrypoint (family is the fixed enum "claude") - # One table, in scripts/surface.ps1, shared with hook.ps1 - which stamps the # matching SLUG on each log line and sends this same id as x-rogue-agent. Two diff --git a/plugins/rogue/scripts/hook.ps1 b/plugins/rogue/scripts/hook.ps1 index f249417..6690a31 100644 --- a/plugins/rogue/scripts/hook.ps1 +++ b/plugins/rogue/scripts/hook.ps1 @@ -497,8 +497,8 @@ if ($installError.Count) { Log "error=install-id $($installError -join ',')" } if (-not (Test-Path -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1') -PathType Leaf)) { [Console]::Out.Write('{}'); exit 0 } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/protection.ps1')))) -ScriptDirectory (Join-Path $pluginRoot 'scripts') $apiKey = Initialize-RogueProtection -Key $apiKey -BaseUrl $creds['ROGUE_BASE_URL'] -Slug 'claude' -Family 'claude' -Version $pluginVersion -if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } try { +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } $payload = Read-RogueProtectionInput if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } diff --git a/plugins/rogue/scripts/protection.sh b/plugins/rogue/scripts/protection.sh index d88ca1f..3ffe5c9 100644 --- a/plugins/rogue/scripts/protection.sh +++ b/plugins/rogue/scripts/protection.sh @@ -207,7 +207,10 @@ rogue_protection_enter() { if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + while :; do + _rp_used=$(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null) + case "$_rp_used" in ''|*[!0-9]*) _rp_used=0 ;; esac + [ $(( $(rogue_protection_now) - _rp_used )) -lt 90 ] || rogue_protection_busy || break rogue_protection_refresh sleep 15 done diff --git a/plugins/rogue/scripts/ship-logs.sh b/plugins/rogue/scripts/ship-logs.sh index c954e7f..ab78ba0 100644 --- a/plugins/rogue/scripts/ship-logs.sh +++ b/plugins/rogue/scripts/ship-logs.sh @@ -822,7 +822,7 @@ drain_file() { # fi fi OFFSET=0 - write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } fi drain_file "$_target_file" "$_target_file_bytes" 0 "$_target_head" \ @@ -903,8 +903,8 @@ main() { [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" - rogue_protection_enter || exit 0 trap 'rogue_protection_leave' EXIT + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/scripts/shared/protection.sh b/scripts/shared/protection.sh index d88ca1f..3ffe5c9 100644 --- a/scripts/shared/protection.sh +++ b/scripts/shared/protection.sh @@ -207,7 +207,10 @@ rogue_protection_enter() { if [ "${0##*/}" = protection.sh ] && [ "${1:-}" = --poll ]; then ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") - while [ $(( $(rogue_protection_now) - $(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null || echo 0) )) -lt 90 ] || rogue_protection_busy; do + while :; do + _rp_used=$(cat "$ROGUE_PROTECTION_STATE/used" 2>/dev/null) + case "$_rp_used" in ''|*[!0-9]*) _rp_used=0 ;; esac + [ $(( $(rogue_protection_now) - _rp_used )) -lt 90 ] || rogue_protection_busy || break rogue_protection_refresh sleep 15 done diff --git a/scripts/shared/ship-logs.sh b/scripts/shared/ship-logs.sh index c954e7f..ab78ba0 100644 --- a/scripts/shared/ship-logs.sh +++ b/scripts/shared/ship-logs.sh @@ -822,7 +822,7 @@ drain_file() { # fi fi OFFSET=0 - write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" + write_state "$STATE_KEY" 0 "$_target_head" "$_target_file_bytes" "$_target_abs_path" || { rogue_protection_fail; release_lock; return 0; } fi drain_file "$_target_file" "$_target_file_bytes" 0 "$_target_head" \ @@ -903,8 +903,8 @@ main() { [ -r "$PLUGIN_ROOT/scripts/protection.sh" ] || exit 0 . "$PLUGIN_ROOT/scripts/protection.sh" rogue_protection_init "$SHIPPER_SLUG" "$AGENT_FAMILY" "$PLUGIN_ROOT/scripts" - rogue_protection_enter || exit 0 trap 'rogue_protection_leave' EXIT + rogue_protection_enter || exit 0 resolve_knobs [ -n "$API_KEY" ] || { debug 'not configured -> no-op'; exit 0; } command -v curl >/dev/null 2>&1 || { log 'outcome=fail reason=no-curl'; exit 0; } diff --git a/tests/test_protection.mjs b/tests/test_protection.mjs index 756811b..19e3c61 100644 --- a/tests/test_protection.mjs +++ b/tests/test_protection.mjs @@ -1,4 +1,4 @@ -import { test } from 'node:test'; +import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; @@ -48,7 +48,115 @@ function run(command,args,env, input) { child.once('exit',code=>{clearTimeout(timeout);resolve({code,out,err});}); }); } +async function stopPollers(directory = temp) { + for (const entry of fs.readdirSync(directory,{withFileTypes:true})) { + if (!entry.isDirectory() || entry.name === 'plugins') continue; + const child=path.join(directory,entry.name); + for (const name of ['poll.pid','poll.lock/pid']) { + const file=path.join(child,name); + if (!fs.existsSync(file)) continue; + const pid=Number(fs.readFileSync(file,'utf8')); + if (pid>0) { + try { process.kill(pid); } catch {} + for (let attempt=0;attempt<100;attempt++) { + try { process.kill(pid,0); } catch { break; } + await new Promise(resolve=>setTimeout(resolve,20)); + assert(attempt<99,`test poller ${pid} did not stop`); + } + } + fs.rmSync(file,{force:true}); + } + fs.rmSync(path.join(child,'poll.lock'),{recursive:true,force:true}); + await stopPollers(child); + } +} +afterEach(async () => { await stopPollers(); }); try { + await test('Copilot allows without posting when its guarded input reader fails', async () => { + const root=path.join(temp,'copilot-read-failure'); fs.cpSync(path.join(fixturePlugins,'copilot'),root,{recursive:true}); + fs.writeFileSync(path.join(root,'scripts/protection.sh'),`rogue_protection_init() { :; } +rogue_protection_enter() { return 0; } +rogue_protection_current() { return 0; } +rogue_protection_leave() { :; } +rogue_protection_read_input() { return 1; } +`); + const start=requests.length; + const result=await run('sh',[path.join(root,'scripts/hook.sh'),'preToolUse'],{PLUGIN_ROOT:root,COPILOT_PLUGIN_ROOT:root,ROGUE_API_KEY:'reader_test_key',ROGUE_BASE_URL:base},'{"toolName":"shell","toolArgs":{"command":"must-not-post"}}'); + assert.equal(result.code,0,result.err); assert.deepEqual(JSON.parse(result.out),{}); + assert(!requests.slice(start).some(call=>call.key==='reader_test_key')); + }); + await test('an empty or invalid poller timestamp exits cleanly and releases its lock', async () => { + for (const used of ['', 'invalid']) { + const directory=fs.mkdtempSync(path.join(temp,'empty-used-')); + fs.mkdirSync(path.join(directory,'poll.lock')); + fs.writeFileSync(path.join(directory,'poll.lock/pid'),'0'); + fs.writeFileSync(path.join(directory,'credential'),'empty_used_key'); + fs.writeFileSync(path.join(directory,'used'),used); + const result=await run('dash',[path.join(repo,'scripts/shared/protection.sh'),'--poll',directory,base],{},''); + assert.equal(result.code,0,result.err); assert.equal(result.err,''); + assert(!fs.existsSync(path.join(directory,'poll.lock'))); + } + }); + await test('shell shipper releases a lease when entry loses a pause race', async () => { + const root=path.join(temp,'ship-entry-failure'); fs.cpSync(path.join(fixturePlugins,'codex'),root,{recursive:true}); + const lease=path.join(root,'lease'); + fs.writeFileSync(path.join(root,'scripts/protection.sh'),`rogue_protection_init() { :; } +rogue_protection_enter() { touch '${lease}'; return 1; } +rogue_protection_leave() { rm -f '${lease}'; touch '${lease}.left'; } +`); + const result=await run('sh',[path.join(root,'scripts/ship-logs.sh'),root,'codex','1.0.0','openai'],{},''); + assert.equal(result.code,0,result.err); assert(!fs.existsSync(lease)); assert(fs.existsSync(`${lease}.left`)); + }); + await test('shell drain failure reports a failed ACK and stops before another chunk', async () => { + const directory=path.join(temp,'drain-failure'); fs.mkdirSync(directory); + const now=Math.floor(Date.now()/1000); + fs.writeFileSync(path.join(directory,'decision'),`1 31 0 0 0 0 ${now} 31 0 ${now}\n`); + const library=path.join(directory,'ship-library.sh'); + fs.writeFileSync(library,fs.readFileSync(path.join(repo,'scripts/shared/ship-logs.sh'),'utf8').replace(/main "\$@"\s*$/,'')); + const script=`. '${library}'; . '${path.join(repo,'scripts/shared/protection.sh')}'; OFFSET=0; RUN_BYTES_SENT=0; MAX_RUN_BYTES=100; MAX_CHUNKS_PER_DRAIN=10; STATE_KEY=test; ship_next_chunk() { ADVANCE_BYTES=1; }; write_state() { return 1; }; if drain_file log 10 0 head head 10 log; then exit 9; fi; [ "$OFFSET" = 1 ] && [ "$RP_PERSISTENCE_FAILED" = 1 ]; rogue_protection_ack`; + const start=requests.length; + const result=await run('sh',['-c',script],{ROGUE_PROTECTION_STATE:directory,ROGUE_PROTECTION_BASE:base,ROGUE_API_KEY:'drain_failure_key'},''); + assert.equal(result.code,0,result.err); + const acks=requests.slice(start).filter(call=>call.key==='drain_failure_key' && call.path.endsWith('/ack')).map(call=>JSON.parse(call.body)); + assert.deepEqual(acks.map(ack=>[ack.revision,ack.status,ack.error]),[[31,'failed','state_persistence_failed']]); + }); + await test('PowerShell entrypoints resolve versions, tolerate missing helpers, and release failed entry leases', {skip:!process.env.ROGUE_TEST_PWSH}, async () => { + for (const [slug,entry] of [['kiro','heartbeat'],['rogue','heartbeat'],['kiro','hook'],['rogue','hook']]) { + const root=path.join(temp,`${slug}-${entry}-entrypoint`); fs.cpSync(path.join(fixturePlugins,slug),root,{recursive:true}); + const script=path.join(root,`scripts/${entry}.ps1`); + fs.writeFileSync(script,fs.readFileSync(script,'utf8').replaceAll('$PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows','$false')); + const helper=path.join(root,'scripts/protection.ps1'); + const versionFile=path.join(root,'enrolled-version'); const lease=path.join(root,'lease'); + const env={KIRO_PLUGIN_ROOT:root,CLAUDE_PLUGIN_ROOT:root,CLAUDE_CODE_ENTRYPOINT:'cli',ROGUE_API_KEY:'entrypoint_test_key',ROGUE_BASE_URL:base,ROGUE_ACTOR_EMAIL:'qa@example.test',ROGUE_ACTOR_NAME:'QA',ROGUE_PS_LIB_ONLY:'',ProgramData:path.join(temp,'empty-machine')}; + if (entry==='heartbeat') { + for (const invalid of [null, 'function {']) { + fs.rmSync(helper,{force:true}); if (invalid!==null) fs.writeFileSync(helper,invalid); + const start=requests.length; + const result=await run(process.env.ROGUE_TEST_PWSH,['-NoProfile','-File',script,...(entry==='hook'?['PreToolUse']:[])],env,'{}'); + assert.equal(result.code,0,`${slug}: ${result.err}`); assert.equal(result.err,''); + assert(!requests.slice(start).some(call=>call.key==='entrypoint_test_key')); + } + } + fs.writeFileSync(helper,`param($ScriptDirectory) +function Initialize-RogueProtection { param($Key,$BaseUrl,$Slug,$Family,$Version); [IO.File]::WriteAllText('${versionFile}',[string]$Version); ${entry==='heartbeat'?'exit 0':'return $Key'} } +function Enter-RogueProtection { [IO.File]::WriteAllText('${lease}','active'); return $false } +function Leave-RogueProtection { param($TimeoutSec); [IO.File]::Delete('${lease}'); [IO.File]::WriteAllText('${lease}.left','left') } +`); + const result=await run(process.env.ROGUE_TEST_PWSH,['-NoProfile','-File',script,...(entry==='hook'?['PreToolUse']:[])],env,'{}'); + assert.equal(result.code,0,`${slug}/${entry}: ${result.err}`); + const manifest=path.join(root,slug==='rogue'?'.claude-plugin/plugin.json':'plugin.json'); + assert.equal(fs.readFileSync(versionFile,'utf8'),JSON.parse(fs.readFileSync(manifest,'utf8')).version,`${slug}/${entry}`); + if (entry==='hook') { assert(!fs.existsSync(lease)); assert(fs.existsSync(`${lease}.left`)); } + } + }); + await test('Kiro shell heartbeat resolves its version before enrollment', async () => { + const root=path.join(temp,'kiro-heartbeat-version'); fs.cpSync(path.join(fixturePlugins,'kiro'),root,{recursive:true}); + const versionFile=path.join(root,'enrolled-version'); + fs.writeFileSync(path.join(root,'scripts/protection.sh'),`rogue_protection_init() { printf '%s' "$ROGUE_INSTALL_VERSION" > '${versionFile}'; exit 0; }\n`); + const result=await run('sh',[path.join(root,'scripts/heartbeat.sh'),'kiro_cli'],{KIRO_PLUGIN_ROOT:root,PLUGIN_ROOT:root,ROGUE_API_KEY:'heartbeat_version_key',ROGUE_BASE_URL:base},''); + assert.equal(result.code,0,result.err); + assert.equal(fs.readFileSync(versionFile,'utf8'),JSON.parse(fs.readFileSync(path.join(root,'plugin.json'),'utf8')).version); + }); await test('every Unix bridge exits before reading paused stdin or sending an activity payload', async () => { for (const [plugin,slug,family] of [['rogue','claude','claude'],['codex','codex','openai'],['cursor','cursor','cursor'],['copilot','copilot','copilot'],['antigravity','antigravity','antigravity'],['kiro','kiro','kiro'],['gemini','gemini','gemini']]) { const root=path.join(fixturePlugins,plugin); @@ -175,7 +283,7 @@ try { assert.equal(client.current(),false); assert.equal(client.enter(),false); const beforeAck=requests.length; await client.ack(); - assert.equal(requests.length,beforeAck); + assert(!requests.slice(beforeAck).some(call=>call.key==='malformed_test_key')); fs.writeFileSync(client.file('state.json'),saved); malformedDecision=invalid; await client.refresh(); @@ -232,11 +340,11 @@ try { const {Protection}=await import(path.join(fixturePlugins,'gemini/scripts/protection.mjs')); const directory=path.join(temp,'persist-failure'); fs.mkdirSync(directory); fs.mkdirSync(path.join(directory,'state.json')); - const client=new Protection(directory,base,'installation_test_key'); + const client=new Protection(directory,base,'state_save_failure_key'); const start=requests.length; await client.refresh(); assert.equal(client.current(),false); - const acks=requests.slice(start).filter(call=>call.path.endsWith('/ack')).map(call=>JSON.parse(call.body)); + const acks=requests.slice(start).filter(call=>call.path.endsWith('/ack') && call.key==='state_save_failure_key').map(call=>JSON.parse(call.body)); assert(acks.some(ack=>ack.status==='failed' && ack.error==='state_persistence_failed')); assert(!acks.some(ack=>ack.status==='applied')); }); @@ -311,7 +419,13 @@ try { const args=[path.join(root,`scripts/ship-logs.${slug==='gemini'?'mjs':'sh'}`),root,slug,'1.0.0',slug==='codex'?'openai':'gemini']; const start=requests.length; await run(slug==='gemini'?process.execPath:'sh',args,env,''); + const acks=requests.slice(start).filter(call=>call.path.endsWith('/ack')).map(call=>JSON.parse(call.body)); + const failed=acks.findIndex(ack=>ack.revision===40 && ack.status==='failed' && ack.error==='state_persistence_failed'); + assert(failed>=0,`${slug} must report its checkpoint failure`); + assert(!acks.slice(failed+1).some(ack=>ack.status==='applied'),slug); + await stopPollers(); fs.rmSync(state,{recursive:true}); + fs.writeFileSync(path.join(directory,'attempt'),'0'); await run(slug==='gemini'?process.execPath:'sh',args,env,''); assert(!requests.slice(start).some(call=>call.path.endsWith('/logs')),slug); assert(fs.readFileSync(state,'utf8').includes('revision=40'),slug); @@ -382,11 +496,7 @@ try { assert.equal(result.code,0); assert.deepEqual(JSON.parse(result.out),{}); }); } finally { - for (const dir of fs.readdirSync(temp)) { - for (const name of ['poll.pid','poll.lock/pid']) { - try { const pid=Number(fs.readFileSync(path.join(temp,dir,name),'utf8')); if (pid>0) process.kill(pid); } catch {} - } - } + await stopPollers(); server.closeAllConnections(); await new Promise(resolve=>server.close(resolve)); fs.rmSync(temp,{recursive:true,force:true}); }