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..7cd43e5 --- /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 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 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. 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. 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. + +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..439f5d3 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)) { Write-Raw (Get-FailOpenDefault); 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)) { 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 ` -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)) { Write-Raw (Get-FailOpenDefault); 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,12 @@ 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)) { 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)) { Write-Raw (Get-FailOpenDefault); exit 0 } +try { + 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 @@ -984,6 +995,7 @@ function Invoke-Main { # 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 091b0b5..4414d50 100755 --- a/plugins/antigravity/scripts/hook.sh +++ b/plugins/antigravity/scripts/hook.sh @@ -121,6 +121,7 @@ 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 [ -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 +134,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 @@ -656,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)" || { fail_open_default; exit 0; } +rogue_protection_current || { fail_open_default; exit 0; } } # Heartbeat, fired detached so the hook itself returns immediately regardless of @@ -783,8 +786,10 @@ post_and_relay() { fi fi +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:-}" \ -H "x-rogue-event: $EVENT" \ -H "x-rogue-actor-email: $ROGUE_ACTOR_EMAIL" \ -H "x-rogue-actor-name: $ROGUE_ACTOR_NAME" \ @@ -798,6 +803,7 @@ post_and_relay() { _code=$(printf '%s' "$_raw" | tail -n1) _resp=$(printf '%s' "$_raw" | sed '$d') +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 @@ -823,6 +829,12 @@ 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" ] || { fail_open_default; exit 0; } +. "${PLUGIN_ROOT}/scripts/protection.sh" +rogue_protection_init antigravity antigravity "${PLUGIN_ROOT}/scripts" "${SURFACE:-default}" +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 load_actor read_body diff --git a/plugins/antigravity/scripts/protection.ps1 b/plugins/antigravity/scripts/protection.ps1 new file mode 100644 index 0000000..42fb022 --- /dev/null +++ b/plugins/antigravity/scripts/protection.ps1 @@ -0,0 +1,235 @@ +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))) + 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 } + } + 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([int]$TimeoutSec=5) { + 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 -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 { } +} +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 -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 { + 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 -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() } + Send-RogueProtectionAck +} +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { + $root=$env:ROGUE_PROTECTION_DIR + 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" + 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 $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 {} + 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 } + $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 -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 + 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 $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 $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()) + $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 + $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) { + 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() } + } + 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 } + Start-RogueProtectionPoller + $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 Leave-RogueProtection([int]$TimeoutSec=5) { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck -TimeoutSec $TimeoutSec +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + 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 -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 + } + } + 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..3ffe5c9 --- /dev/null +++ b/plugins/antigravity/scripts/protection.sh @@ -0,0 +1,219 @@ +#!/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_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 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 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 1 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + 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") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + 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 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 1; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_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" ;; + [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 + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + return 0 +} +rogue_protection_start_poller() { + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + 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 +} +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 + 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 + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + 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 + 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..5f0723b 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 @@ -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=`, @@ -603,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 { @@ -624,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] } @@ -634,27 +637,28 @@ 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) + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) 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 {} + [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 ──────────────────────────────────────────────────────── @@ -814,7 +818,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 +844,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 +875,10 @@ 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 $script:stateRevision -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + return + } $script:runBytesSent = 0 $rotated = $false @@ -907,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 @@ -924,6 +933,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + 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 } + 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)) { @@ -934,7 +949,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 } @@ -945,6 +960,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 @@ -952,4 +968,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 1eb3152..ab78ba0 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 ──────────────────────────────────────────────────────── @@ -628,6 +629,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 +765,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}" @@ -782,8 +785,10 @@ 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:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -817,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 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then + 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 RUN_BYTES_SENT=0 _target_rotated=0 @@ -876,7 +886,7 @@ ship_log_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" \ @@ -890,6 +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" + 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; } @@ -902,12 +917,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..c4cc320 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,14 @@ if ($installError.Count) { Log "error=install-id $($installError -join ',')" } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── -$payload = [Console]::In.ReadToEnd() +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' -Version $pluginVersion +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } +try { + +$payload = Read-RogueProtectionInput +if (-not (Test-RogueProtectionCurrent)) { [Console]::Out.Write('{}'); exit 0 } if (-not $payload) { $payload = '{}' } try { $raw = [Console]::InputEncoding.GetBytes($payload) @@ -352,6 +360,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,8 +372,10 @@ 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 } Write-Raw $resp exit 0 +} finally { Leave-RogueProtection } diff --git a/plugins/codex/scripts/hook.sh b/plugins/codex/scripts/hook.sh index 517f6f7..c76f176 100755 --- a/plugins/codex/scripts/hook.sh +++ b/plugins/codex/scripts/hook.sh @@ -53,6 +53,7 @@ _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 [ -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 +65,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 +96,12 @@ 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; } +trap 'rogue_protection_leave' EXIT + if [ -z "${ROGUE_API_KEY:-}" ]; then log "outcome=unconfigured" echo '{}' @@ -146,8 +154,11 @@ 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" \ -H "x-rogue-agent: $ROGUE_INSTALL_AGENT" \ -H "x-rogue-host: $ROGUE_INSTALL_HOST" \ @@ -159,6 +170,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..42fb022 --- /dev/null +++ b/plugins/codex/scripts/protection.ps1 @@ -0,0 +1,235 @@ +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))) + 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 } + } + 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([int]$TimeoutSec=5) { + 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 -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 { } +} +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 -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 { + 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 -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() } + Send-RogueProtectionAck +} +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { + $root=$env:ROGUE_PROTECTION_DIR + 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" + 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 $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 {} + 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 } + $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 -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 + 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 $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 $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()) + $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 + $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) { + 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() } + } + 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 } + Start-RogueProtectionPoller + $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 Leave-RogueProtection([int]$TimeoutSec=5) { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck -TimeoutSec $TimeoutSec +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + 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 -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 + } + } + 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..3ffe5c9 --- /dev/null +++ b/plugins/codex/scripts/protection.sh @@ -0,0 +1,219 @@ +#!/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_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 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 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 1 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + 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") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + 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 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 1; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_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" ;; + [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 + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + return 0 +} +rogue_protection_start_poller() { + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + 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 +} +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 + 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 + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + 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 + 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..5f0723b 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 @@ -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=`, @@ -603,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 { @@ -624,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] } @@ -634,27 +637,28 @@ 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) + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) 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 {} + [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 ──────────────────────────────────────────────────────── @@ -814,7 +818,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 +844,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 +875,10 @@ 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 $script:stateRevision -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + return + } $script:runBytesSent = 0 $rotated = $false @@ -907,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 @@ -924,6 +933,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + 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 } + 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)) { @@ -934,7 +949,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 } @@ -945,6 +960,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 @@ -952,4 +968,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 1eb3152..ab78ba0 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 ──────────────────────────────────────────────────────── @@ -628,6 +629,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 +765,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}" @@ -782,8 +785,10 @@ 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:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -817,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 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then + 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 RUN_BYTES_SENT=0 _target_rotated=0 @@ -876,7 +886,7 @@ ship_log_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" \ @@ -890,6 +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" + 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; } @@ -902,12 +917,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..15dae8f 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,14 @@ if (-not $actorName) { $actorName = 'unknown' } if (-not $actorEmail) { $actorEmail = 'unknown' } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── -$payload = [Console]::In.ReadToEnd() +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 } if (-not $payload) { $payload = '{}' } try { $raw = [Console]::InputEncoding.GetBytes($payload) @@ -631,6 +639,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 +651,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 } @@ -659,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 b901f96..a3ef638 100755 --- a/plugins/copilot/scripts/hook.sh +++ b/plugins/copilot/scripts/hook.sh @@ -81,6 +81,7 @@ 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 [ -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 +98,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 +366,12 @@ 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; } +trap 'rogue_protection_leave' EXIT + if [ -z "${ROGUE_API_KEY:-}" ]; then log "outcome=unconfigured" if [ "$EVENT" = "sessionStart" ]; then @@ -387,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)" || { 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). reattribute_subagent @@ -441,6 +450,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" \ @@ -457,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' \ @@ -465,6 +476,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..42fb022 --- /dev/null +++ b/plugins/copilot/scripts/protection.ps1 @@ -0,0 +1,235 @@ +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))) + 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 } + } + 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([int]$TimeoutSec=5) { + 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 -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 { } +} +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 -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 { + 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 -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() } + Send-RogueProtectionAck +} +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { + $root=$env:ROGUE_PROTECTION_DIR + 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" + 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 $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 {} + 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 } + $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 -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 + 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 $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 $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()) + $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 + $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) { + 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() } + } + 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 } + Start-RogueProtectionPoller + $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 Leave-RogueProtection([int]$TimeoutSec=5) { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck -TimeoutSec $TimeoutSec +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + 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 -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 + } + } + 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..3ffe5c9 --- /dev/null +++ b/plugins/copilot/scripts/protection.sh @@ -0,0 +1,219 @@ +#!/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_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 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 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 1 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + 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") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + 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 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 1; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_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" ;; + [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 + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + return 0 +} +rogue_protection_start_poller() { + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + 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 +} +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 + 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 + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + 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 + 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..5f0723b 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 @@ -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=`, @@ -603,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 { @@ -624,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] } @@ -634,27 +637,28 @@ 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) + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) 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 {} + [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 ──────────────────────────────────────────────────────── @@ -814,7 +818,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 +844,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 +875,10 @@ 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 $script:stateRevision -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + return + } $script:runBytesSent = 0 $rotated = $false @@ -907,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 @@ -924,6 +933,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + 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 } + 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)) { @@ -934,7 +949,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 } @@ -945,6 +960,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 @@ -952,4 +968,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 1eb3152..ab78ba0 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 ──────────────────────────────────────────────────────── @@ -628,6 +629,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 +765,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}" @@ -782,8 +785,10 @@ 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:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -817,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 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then + 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 RUN_BYTES_SENT=0 _target_rotated=0 @@ -876,7 +886,7 @@ ship_log_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" \ @@ -890,6 +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" + 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; } @@ -902,12 +917,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..02759e1 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,14 @@ if (Test-Path -LiteralPath $pluginJson) { } # ── payload from stdin ───────────────────────────────────────────────────── -$payload = [Console]::In.ReadToEnd() +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' -Version $pluginVersion +if (-not (Enter-RogueProtection)) { [Console]::Out.Write('{}'); exit 0 } +try { + +$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 +948,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 +989,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)" } @@ -1123,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 a9627bf..77cd5c7 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -129,6 +129,7 @@ _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 [ -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 +146,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 +169,11 @@ 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; } +trap 'rogue_protection_leave' EXIT API_KEY="${ROGUE_API_KEY:-}" if [ -z "$API_KEY" ]; then dbg "no API key after cred resolution -> fail-open" @@ -224,7 +231,8 @@ else fi # ── payload from stdin ───────────────────────────────────────────────────── -PAYLOAD="$(cat 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 # hook payload (hook.ps1 handles it on the native path); a BOM-prefixed body is @@ -664,6 +672,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 +692,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 +752,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..42fb022 --- /dev/null +++ b/plugins/cursor/scripts/protection.ps1 @@ -0,0 +1,235 @@ +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))) + 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 } + } + 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([int]$TimeoutSec=5) { + 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 -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 { } +} +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 -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 { + 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 -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() } + Send-RogueProtectionAck +} +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { + $root=$env:ROGUE_PROTECTION_DIR + 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" + 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 $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 {} + 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 } + $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 -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 + 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 $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 $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()) + $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 + $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) { + 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() } + } + 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 } + Start-RogueProtectionPoller + $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 Leave-RogueProtection([int]$TimeoutSec=5) { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck -TimeoutSec $TimeoutSec +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + 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 -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 + } + } + 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..3ffe5c9 --- /dev/null +++ b/plugins/cursor/scripts/protection.sh @@ -0,0 +1,219 @@ +#!/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_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 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 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 1 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + 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") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + 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 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 1; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_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" ;; + [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 + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + return 0 +} +rogue_protection_start_poller() { + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + 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 +} +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 + 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 + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + 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 + 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..5f0723b 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 @@ -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=`, @@ -603,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 { @@ -624,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] } @@ -634,27 +637,28 @@ 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) + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) 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 {} + [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 ──────────────────────────────────────────────────────── @@ -814,7 +818,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 +844,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 +875,10 @@ 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 $script:stateRevision -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + return + } $script:runBytesSent = 0 $rotated = $false @@ -907,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 @@ -924,6 +933,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + 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 } + 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)) { @@ -934,7 +949,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 } @@ -945,6 +960,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 @@ -952,4 +968,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 1eb3152..ab78ba0 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 ──────────────────────────────────────────────────────── @@ -628,6 +629,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 +765,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}" @@ -782,8 +785,10 @@ 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:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -817,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 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then + 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 RUN_BYTES_SENT=0 _target_rotated=0 @@ -876,7 +886,7 @@ ship_log_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" \ @@ -890,6 +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" + 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; } @@ -902,12 +917,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..a8f7942 --- /dev/null +++ b/plugins/gemini/scripts/protection.mjs @@ -0,0 +1,168 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +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'; + +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); +}; +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; + } + 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 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 }); } + 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 (!validDecision(state)) 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 { + await this.fail(state); + } + } + } 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) { + 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; } + try { + 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); } + 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); + } 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 (error) { + const key=read(client.file('credential')); + if (!key) { + if (error.status === 404) { try { write(client.file('legacy-server'),'1'); } catch {} return undefined; } + 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; + } + } +} +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..123e422 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=`, @@ -440,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"); @@ -453,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 @@ -465,6 +468,7 @@ class Shipper { state.offset = 0; state.head = ""; state.size = 0; + state.revision = ""; } return state; } @@ -478,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) { @@ -506,7 +511,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 +643,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 }; @@ -650,7 +656,10 @@ 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)) { + await this.protection?.fail(); + return {offset, complete:false}; + } } return { offset, complete: true }; } @@ -690,6 +699,10 @@ class Shipper { const fileBytes = fileSize(filePath); const currentHead = firstLineFingerprint(filePath); this.runBytesSent = 0; + if (this.protection?.revision > 0 && state.revision !== String(this.protection.revision)) { + if (!this.writeState(stateKey, fileBytes, currentHead, fileBytes, normalizedPath)) await this.protection.fail(); + return; + } let offset = state.offset; let rotated = fileBytes < offset; @@ -732,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( @@ -754,6 +770,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 +788,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..3c41bba 100644 --- a/plugins/kiro/scripts/heartbeat.ps1 +++ b/plugins/kiro/scripts/heartbeat.ps1 @@ -345,8 +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 - 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 e6cabdf..515eceb 100755 --- a/plugins/kiro/scripts/heartbeat.sh +++ b/plugins/kiro/scripts/heartbeat.sh @@ -169,9 +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 - load_actor resolve_surface "${1:-}" - resolve_version # after the surface: install-id.sh keys the agent on it + resolve_version + . "${PLUGIN_ROOT}/scripts/protection.sh" + rogue_protection_init kiro kiro "${PLUGIN_ROOT}/scripts" + load_actor 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 3589c31..664afa6 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)) { 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,16 +434,25 @@ 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' -Version $script:pluginVersion +try { +if (-not (Enter-RogueProtection)) { exit 0 } + $payload = Read-KiroPayload + 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" exit 0 } - Resolve-KiroInstall Start-KiroHeartbeat Send-KiroRequest +if (-not (Test-RogueProtectionCurrent)) { exit 0 } Write-KiroDecision +} finally { Leave-RogueProtection -TimeoutSec 1 } } # 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 c82e547..2df4c15 100755 --- a/plugins/kiro/scripts/hook.sh +++ b/plugins/kiro/scripts/hook.sh @@ -93,6 +93,7 @@ initialize_context() { } rotate_log() { + 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 +103,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 @@ -208,8 +210,10 @@ 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:-}" \ -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 || { exit 0; } if [ "$RC" -ne 0 ] || [ "$CODE" != "200" ] || [ -z "$RESP" ]; then finish allow exit 0 @@ -279,10 +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 || { 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 new file mode 100644 index 0000000..42fb022 --- /dev/null +++ b/plugins/kiro/scripts/protection.ps1 @@ -0,0 +1,235 @@ +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))) + 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 } + } + 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([int]$TimeoutSec=5) { + 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 -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 { } +} +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 -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 { + 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 -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() } + Send-RogueProtectionAck +} +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { + $root=$env:ROGUE_PROTECTION_DIR + 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" + 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 $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 {} + 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 } + $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 -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 + 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 $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 $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()) + $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 + $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) { + 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() } + } + 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 } + Start-RogueProtectionPoller + $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 Leave-RogueProtection([int]$TimeoutSec=5) { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck -TimeoutSec $TimeoutSec +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + 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 -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 + } + } + 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..3ffe5c9 --- /dev/null +++ b/plugins/kiro/scripts/protection.sh @@ -0,0 +1,219 @@ +#!/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_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 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 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 1 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + 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") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + 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 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 1; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_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" ;; + [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 + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + return 0 +} +rogue_protection_start_poller() { + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + 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 +} +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 + 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 + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + 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 + 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..5f0723b 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 @@ -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=`, @@ -603,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 { @@ -624,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] } @@ -634,27 +637,28 @@ 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) + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) 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 {} + [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 ──────────────────────────────────────────────────────── @@ -814,7 +818,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 +844,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 +875,10 @@ 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 $script:stateRevision -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + return + } $script:runBytesSent = 0 $rotated = $false @@ -907,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 @@ -924,6 +933,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + 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 } + 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)) { @@ -934,7 +949,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 } @@ -945,6 +960,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 @@ -952,4 +968,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 1eb3152..ab78ba0 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 ──────────────────────────────────────────────────────── @@ -628,6 +629,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 +765,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}" @@ -782,8 +785,10 @@ 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:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -817,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 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then + 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 RUN_BYTES_SENT=0 _target_rotated=0 @@ -876,7 +886,7 @@ ship_log_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" \ @@ -890,6 +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" + 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; } @@ -902,12 +917,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..e84a543 100644 --- a/plugins/rogue/scripts/heartbeat.ps1 +++ b/plugins/rogue/scripts/heartbeat.ps1 @@ -148,6 +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('/') +# -- 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 -> @@ -174,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/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..6690a31 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,14 @@ if (-not $installAgent) { $installAgent = 'claude_code' } if ($installError.Count) { Log "error=install-id $($installError -join ',')" } # -- payload from stdin ----------------------------------------------------- -$payload = [Console]::In.ReadToEnd() +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 +try { +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 +533,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 +553,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) --------------------- @@ -626,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 99c96cc..57f661a 100644 --- a/plugins/rogue/scripts/hook.sh +++ b/plugins/rogue/scripts/hook.sh @@ -67,6 +67,7 @@ _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 [ -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 +79,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 +149,12 @@ _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; } +trap 'rogue_protection_leave' EXIT + if [ -z "${ROGUE_API_KEY:-}" ]; then log "outcome=unconfigured" echo '{}' @@ -163,8 +171,11 @@ 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" \ -H "x-rogue-agent: $ROGUE_INSTALL_AGENT" \ -H "x-rogue-host: $ROGUE_INSTALL_HOST" \ @@ -176,6 +187,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..42fb022 --- /dev/null +++ b/plugins/rogue/scripts/protection.ps1 @@ -0,0 +1,235 @@ +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))) + 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 } + } + 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([int]$TimeoutSec=5) { + 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 -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 { } +} +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 -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 { + 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 -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() } + Send-RogueProtectionAck +} +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { + $root=$env:ROGUE_PROTECTION_DIR + 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" + 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 $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 {} + 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 } + $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 -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 + 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 $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 $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()) + $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 + $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) { + 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() } + } + 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 } + Start-RogueProtectionPoller + $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 Leave-RogueProtection([int]$TimeoutSec=5) { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck -TimeoutSec $TimeoutSec +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + 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 -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 + } + } + 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..3ffe5c9 --- /dev/null +++ b/plugins/rogue/scripts/protection.sh @@ -0,0 +1,219 @@ +#!/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_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 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 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 1 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + 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") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + 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 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 1; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_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" ;; + [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 + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + return 0 +} +rogue_protection_start_poller() { + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + 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 +} +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 + 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 + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + 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 + 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..5f0723b 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 @@ -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=`, @@ -603,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 { @@ -624,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] } @@ -634,27 +637,28 @@ 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) + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) 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 {} + [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 ──────────────────────────────────────────────────────── @@ -814,7 +818,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 +844,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 +875,10 @@ 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 $script:stateRevision -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + return + } $script:runBytesSent = 0 $rotated = $false @@ -907,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 @@ -924,6 +933,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + 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 } + 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)) { @@ -934,7 +949,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 } @@ -945,6 +960,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 @@ -952,4 +968,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 1eb3152..ab78ba0 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 ──────────────────────────────────────────────────────── @@ -628,6 +629,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 +765,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}" @@ -782,8 +785,10 @@ 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:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -817,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 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then + 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 RUN_BYTES_SENT=0 _target_rotated=0 @@ -876,7 +886,7 @@ ship_log_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" \ @@ -890,6 +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" + 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; } @@ -902,12 +917,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..42fb022 --- /dev/null +++ b/scripts/shared/protection.ps1 @@ -0,0 +1,235 @@ +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))) + 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 } + } + 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([int]$TimeoutSec=5) { + 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 -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 { } +} +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 -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 { + 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 -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() } + Send-RogueProtectionAck +} +function Set-RogueProtectionDirectory([string]$Key, [string]$Slug) { + $root=$env:ROGUE_PROTECTION_DIR + 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" + 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 $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 {} + 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 } + $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 -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 + 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 $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 $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()) + $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 + $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) { + 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() } + } + 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 } + Start-RogueProtectionPoller + $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 Leave-RogueProtection([int]$TimeoutSec=5) { + if (-not $script:RPDirectory) { return } + Remove-Item -LiteralPath "$script:RPDirectory/active.$PID" -Force -ErrorAction SilentlyContinue + Send-RogueProtectionAck -TimeoutSec $TimeoutSec +} +function Enter-RogueProtection { + if (-not (Test-RogueProtectionCurrent)) { return $false } + 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 -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 + } + } + 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..3ffe5c9 --- /dev/null +++ b/scripts/shared/protection.sh @@ -0,0 +1,219 @@ +#!/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_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 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 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 1 + _rp_restore_status=$(printf '%s' "$_rp_restored" | tail -n 1) + 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") + ROGUE_PROTECTION_STATE=$_rp_previous + rogue_protection_now > "$ROGUE_PROTECTION_STATE/used" + 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 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 1; } + fi + _rp_nonce=$(cat "$ROGUE_PROTECTION_STATE/enrollment-nonce") + [ "${#_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" ;; + [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 + rm -f "$ROGUE_PROTECTION_STATE/enroll.lock" 2>/dev/null || true + fi + return 0 +} +rogue_protection_start_poller() { + if mkdir "$ROGUE_PROTECTION_STATE/poll.lock" 2>/dev/null; then + 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 +} +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 + 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 + ROGUE_PROTECTION_STATE=$2; ROGUE_PROTECTION_BASE=$3 + ROGUE_API_KEY=$(cat "$ROGUE_PROTECTION_STATE/credential") + 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 + 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..5f0723b 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 @@ -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=`, @@ -603,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 { @@ -624,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] } @@ -634,27 +637,28 @@ 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) + $destination = Join-Path $script:stateDir "$Key.state" + $tempFile = Join-Path $script:stateDir (".state-tmp-" + $PID) 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 {} + [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 ──────────────────────────────────────────────────────── @@ -814,7 +818,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 +844,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 +875,10 @@ 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 $script:stateRevision -ne [string]$script:RPRevision) { + Write-ShipState $script:stateKey $fileBytes $currentHead $fileBytes $normalizedPath + return + } $script:runBytesSent = 0 $rotated = $false @@ -907,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 @@ -924,6 +933,12 @@ function Invoke-Main { Initialize-Args Import-ShipEnv + 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 } + 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)) { @@ -934,7 +949,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 } @@ -945,6 +960,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 @@ -952,4 +968,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 1eb3152..ab78ba0 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 ──────────────────────────────────────────────────────── @@ -628,6 +629,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 +765,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}" @@ -782,8 +785,10 @@ 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:-}" \ -H 'Content-Type: application/json' \ --data-binary @"$_post_body_file" \ -o /dev/null -w '%{http_code}' 2>/dev/null) @@ -817,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 ] && [ "$STATE_REVISION" != "$ROGUE_PROTECTION_REVISION" ]; then + 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 RUN_BYTES_SENT=0 _target_rotated=0 @@ -876,7 +886,7 @@ ship_log_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" \ @@ -890,6 +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" + 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; } @@ -902,12 +917,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/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", () => { 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 432c5b3..41fe412 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({ @@ -44,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/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_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_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_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" diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index 484ba84..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 ?? ""); }); @@ -55,6 +58,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 @@ -86,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 ?? ""); }); @@ -399,6 +406,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_antigravity.sh b/tests/test_hook_sh_antigravity.sh index f42bef0..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 @@ -201,6 +206,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 +334,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" <&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 new file mode 100644 index 0000000..19e3c61 --- /dev/null +++ b/tests/test_protection.mjs @@ -0,0 +1,502 @@ +import { test, afterEach } 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'))); + 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); +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; + 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'); + 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(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('{}'); +}); +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,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); + 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); + 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); + 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 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("'","''"); + 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 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(!requests.slice(beforeAck).some(call=>call.key==='malformed_test_key')); + 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')}`); + 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,'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') && 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')); + }); + 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('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); + 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,''); + 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); + } + 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('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='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') && 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 () => { + 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()=>{ + 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),{}); + }); +} finally { + await stopPollers(); + 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..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 @@ -113,6 +114,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 ) }