diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index ae7987f..04fcb6b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -1,6 +1,6 @@ { "name": "rogue-marketplace", - "version": "1.0.0", + "version": "1.0.1", "description": "Rogue Security extensions for OpenAI Codex", "owner": { "name": "Qualifire (Rogue Security)", @@ -10,7 +10,7 @@ "plugins": [ { "name": "rogue", - "version": "1.0.0", + "version": "1.0.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for OpenAI Codex", "author": { "name": "Rogue Security", diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3737d2e..059cbad 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-marketplace.json", "name": "rogue-marketplace", - "version": "1.0.23", + "version": "1.0.24", "description": "Rogue Security extensions for Claude Code", "owner": { "name": "Qualifire (Rogue Security)", @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue", - "version": "1.0.23", + "version": "1.0.24", "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 1289bea..25e2409 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue-security", - "version": "1.1.0", + "version": "1.1.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "Rogue Security", diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 7d709cd..685a32f 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "rogue-copilot", - "version": "1.2.0", + "version": "1.2.1", "description": "Rogue Security extensions for GitHub Copilot CLI", "owner": { "name": "Qualifire (Rogue Security)", @@ -10,7 +10,7 @@ "plugins": [ { "name": "rogue", - "version": "1.2.0", + "version": "1.2.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for GitHub Copilot CLI", "author": { "name": "Rogue Security", diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b8104fc..43c38b9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -80,7 +80,12 @@ jobs: while IFS= read -r f; do if head -1 "$f" | grep -q bash; then chk="bash -n"; else chk="dash -n"; fi if ! $chk "$f"; then echo "::error file=$f::$chk parse error"; fail=1; fi - done < <(git ls-files 'plugins/**/scripts/*.sh' 'install.sh' 'scripts/*.sh' 'scripts/shared/*.sh') + # tests/manual/*.sh is here because nothing else in CI reads it: those + # scripts are run by hand against a live CLI, so a parse error sits + # undiscovered until someone reaches for the harness - which is exactly + # when they are least able to debug it. The automated suites under tests/ + # are covered by being executed. + done < <(git ls-files 'plugins/**/scripts/*.sh' 'install.sh' 'scripts/*.sh' 'scripts/shared/*.sh' 'tests/manual/*.sh') [ "$fail" = 0 ] || exit 1 - name: Command and skill snippets parse diff --git a/CLAUDE.md b/CLAUDE.md index 948b6e5..d63a8ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,9 +109,11 @@ Every event registers two entries — an `sh` one and a PowerShell one — point Line format, identical across all six dispatchers: ``` -2026-08-11T11:26:16Z provider=claude event=PreToolUse outcome=unconfigured +2026-08-11T11:26:16Z provider=claude surface=cli event=PreToolUse outcome=unconfigured ``` +- **`surface=` is OPTIONAL and sits between `provider=` and `event=`.** One file per agent family means every surface of that family appends to it — CLI, Desktop and Cowork all write `claude.log` — so the line has to say which one wrote it. Closed vocabulary, per plugin: `cli`/`desktop`/`cowork`, `codex_cli`/`codex_app`, `antigravity`/`antigravity_ide`/`antigravity_cli`, `cursor`, `github_copilot`, `gemini_cli`. **Absent** when the surface cannot be determined — never `surface=` and never `surface=unknown`, either of which a reader scanning `key=` tokens cannot tell from a real value. In practice that means lines from a version older than rogue 1.0.24 / codex 1.0.1 / cursor 1.1.1 / copilot 1.2.1 / antigravity 1.0.24 / gemini 1.0.25, and antigravity events whose payload has no `transcriptPath`. **Each plugin resolves it ONCE, from the same signal its heartbeat uses** — `plugins/{rogue,codex}/scripts/surface.{sh,ps1}` are shared tables read by both hook and heartbeat, and antigravity's hook passes its single resolution to its own heartbeat. Never add a second detection path: a line and the roster row for one session naming different surfaces is worse than a line naming none. Full table: [docs/hook-log-format.md](docs/hook-log-format.md). + - **`provider=` is the agent slug, which is also the file's basename.** The two are kept equal on purpose so a merged grep and a file listing use one vocabulary. It is deliberately **NOT** the heartbeat's `agent_family`/`agent` (the server keys its roster and version lookup on those): five of six coincide, but Codex's family is `openai` while its slug is `codex`, and the roster labels are `gemini_cli` / `github_copilot` where the slugs are `gemini` / `copilot`. Don't "align" them. - **Path precedence**: `ROGUE_LOG_FILE` (exact path, back-compat) → `ROGUE_LOG_DIR/.log` → `~/.rogue/logs/.log`. Prefer `ROGUE_LOG_DIR` when relocating: `~/.rogue-env` is **shared by every plugin**, so a `ROGUE_LOG_FILE` set there re-collapses all six into one file. - **All three knobs come from the same env-file chain as the credentials** — bundled `${PLUGIN_ROOT}/env` → `/etc/rogue/env` / `C:\ProgramData\rogue\env` (MDM) → `~/.rogue-env`, then process env wins. This is load-bearing on **every** dispatcher, so keep it that way when editing: diff --git a/docs/hook-log-format.md b/docs/hook-log-format.md new file mode 100644 index 0000000..4949fbc --- /dev/null +++ b/docs/hook-log-format.md @@ -0,0 +1,105 @@ +# The hook log line + +Every Rogue plugin's hook dispatcher appends **one line per invocation** to its own +file under `~/.rogue/logs/` (`%USERPROFILE%\.rogue\logs\` on Windows) — +`claude.log`, `codex.log`, `cursor.log`, `gemini.log`, `copilot.log`, +`antigravity.log`. + +``` +2026-08-13T07:02:05Z provider=claude surface=cli event=PreToolUse outcome=allow +``` + +Fields are `key=value`, separated by single spaces, in a fixed order. A reader +finds a value by scanning from `=` to the next space, so **no value contains a +space or an `=`**. + +| position | token | always present | notes | +| --- | --- | --- | --- | +| 1 | timestamp | yes | UTC ISO-8601, second precision, no fractional part | +| 2 | `provider=` | yes | the agent slug, which is also the file's basename | +| 3 | `surface=` | **no — optional** | which surface of that agent wrote the line | +| 4 | `event=` | yes | the vendor's own event name, verbatim casing | +| 5+ | free-form | yes | `outcome=`, `raw=`, `reason=`, … per dispatcher | + +## `surface=` — which surface wrote the line + +There is **one log file per agent family per machine**, and every surface of that +family appends to it. Claude Code launched from the CLI, from the Desktop app and a +Cowork session all write into the same `claude.log`. The heartbeat knows the +surface, but it describes *one session*, while a log file holds lines from many +sessions across many surfaces — so the surface has to be stamped on each line as it +is written. + +### Vocabulary + +Lowercase, no spaces, no `=`. A **closed list**: the token is one of these strings +and nothing else — never a path, a user name, a host name, or a window title. + +| plugin (`provider=`) | `surface=` | how it is determined | +| --- | --- | --- | +| `claude` | `cli` | `CLAUDE_CODE_ENTRYPOINT` is set and matches nothing below | +| `claude` | `desktop` | `CLAUDE_CODE_ENTRYPOINT` contains `desktop` | +| `claude` | `cowork` | `CLAUDE_CODE_ENTRYPOINT` contains `cowork` | +| `codex` | `codex_cli` | `ROGUE_CODEX_SURFACE` unset, `codex_cli`, or unrecognised | +| `codex` | `codex_app` | `ROGUE_CODEX_SURFACE=codex_app` (the installer pins it) | +| `antigravity` | `antigravity` | the event's `transcriptPath` is under `…/antigravity/…` | +| `antigravity` | `antigravity_ide` | `transcriptPath` under `…/antigravity-ide/…` | +| `antigravity` | `antigravity_cli` | `transcriptPath` under `…/antigravity-cli/…` | +| `cursor` | `cursor` | single surface — a constant | +| `copilot` | `github_copilot` | single surface — a constant | +| `gemini` | `gemini_cli` | single surface — a constant | + +Each plugin resolves this **once**, from the signal its heartbeat already uses, and +the two read one shared table: + +- `plugins/rogue/scripts/surface.sh` / `.ps1` — `hook` takes the slug, `heartbeat` + takes the display label, from the same `case`. +- `plugins/codex/scripts/surface.sh` / `.ps1` — one read of `ROGUE_CODEX_SURFACE`, + validated against the closed list, feeding the log token, the `x-rogue-agent` + header and the heartbeat alike. +- Antigravity resolves it from the payload once per invocation and passes the same + value to its log line, its heartbeat and its per-surface enrichment. + +A second, independent way to decide the surface would be worse than no token at +all: a line and the roster row for the same session could then name different +surfaces. + +### The token is OPTIONAL + +It is **absent** when the surface cannot be determined. There is no +`surface=unknown` and no empty `surface=` — either would be indistinguishable from +a real value to a reader scanning `key=` tokens. + +In practice it is absent in two cases: + +1. **Lines written by a plugin version older than the ones below.** Old lines are + never rewritten; they have no token, permanently. +2. **An antigravity event whose payload carries no `transcriptPath`** — including + every line written before the payload is read, such as `outcome=unconfigured` on + a machine with no API key. `transcriptPath` is the only reliable signal (three + Antigravity products share one install, so a filesystem probe cannot tell which + is running), and guessing is worse than omitting. + +The other five plugins determine their surface on every line they write. + +### First version that ships it + +| plugin | version | +| --- | --- | +| rogue (Claude Code) | 1.0.24 | +| codex | 1.0.1 | +| cursor | 1.1.1 | +| copilot | 1.2.1 | +| antigravity | 1.0.24 | +| gemini | 1.0.25 | + +### Guarantees + +- The three dispatchers of a plugin (POSIX `sh`, PowerShell, and Node for Gemini) + emit **the same token for the same event** — asserted by + `tests/test_hook_logs.sh`, `tests/test_hook_logs.ps1` and + `tests/test_hook_mjs.mjs`. +- Resolving a surface can never block a session, change an allow/deny outcome, or + write to stderr. Every resolution is guarded; a failure yields an empty slug, + which yields no token. +- The upload envelope is unchanged. This is a per-line change only. diff --git a/docs/log-shipping-backend.md b/docs/log-shipping-backend.md index 3fff11f..d493adb 100644 --- a/docs/log-shipping-backend.md +++ b/docs/log-shipping-backend.md @@ -83,9 +83,17 @@ Four properties of the client that the server design has to respect: The format is phase 1's, identical across all six dispatchers: ```text -2026-08-11T11:26:16Z provider=claude event=PreToolUse outcome=unconfigured +2026-08-11T11:26:16Z provider=claude surface=cli event=PreToolUse outcome=unconfigured ``` +- **`surface=` is OPTIONAL, and sits between `provider=` and `event=`.** It names + which surface of that agent family wrote the line — `cli` / `desktop` / `cowork` + for `claude`, `codex_cli` / `codex_app`, `antigravity` / `antigravity_ide` / + `antigravity_cli`, and a constant for the single-surface plugins. It is absent on + every line written before the versions listed in + [hook-log-format.md](hook-log-format.md), and absent whenever the surface could + not be determined; there is no `surface=unknown` and no empty value. Treat the + token as optional and define a behaviour for lines that lack it. - **`provider=` is the per-line source of truth for attribution, not `log_file` and not `agent_family`.** A support upload (`ROGUE_SHIP_ALL=1`, which the no-argument form implies) carries several agents' files in one run, and an install that sets diff --git a/plugins/antigravity/VERSION b/plugins/antigravity/VERSION index 154b9fc..79728fe 100644 --- a/plugins/antigravity/VERSION +++ b/plugins/antigravity/VERSION @@ -1 +1 @@ -1.0.23 +1.0.24 diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index c89bda9..222abf5 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -91,6 +91,13 @@ function ConvertFrom-ShellQuoted { # reading a script variable from a function is implicit, but ASSIGNING one needs # the `$script:` prefix or the write lands in a function-local copy and silently # vanishes. Every write to shared state below is therefore `$script:`-qualified. +# Which SURFACE of Antigravity wrote each line - antigravity, antigravity_ide or +# antigravity_cli. Resolved from the payload's transcriptPath, the ONLY reliable +# signal (three products share one install, so a filesystem probe cannot tell which +# is running), and the same value the heartbeat reports. Empty for an event whose +# payload carries no transcriptPath, and for every line written before the payload +# is read: the token is then OMITTED, never `surface=` and never `surface=unknown`. +$script:surface = '' $script:logFile = '' # resolved in Initialize-Logging $script:logMaxBytes = 10485760 # ditto; the default stands until then $creds = @{} # credential files + process env, by Import-Credentials @@ -226,9 +233,12 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # Empty slug -> empty string, so the line is byte-identical to what an + # older version wrote. Optional means optional. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=antigravity event=$EventName $Msg`n", + "$stamp provider=antigravity$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } @@ -339,10 +349,11 @@ function Invoke-Heartbeat { # Pass the surface along: only the hook can know it (three products share # one install, and the event's transcriptPath names which state dir it # lives in). Mirrors hook.sh's `heartbeat.sh "$_hb_agent"`. + # Resolve-Surface already did this, off the same transcriptPath, so reuse it + # rather than re-matching: one resolution feeds the roster agent, the log + # token and the IDE-only enrichment branch, and they cannot disagree. $hbArgs = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$hbPath) - $hbTp = [regex]::Match($payload, '"transcriptPath"\s*:\s*"([^"]*)"').Groups[1].Value - $hbAgent = Get-AntigravitySurface $hbTp - if ($hbAgent) { $hbArgs += @('-Agent', $hbAgent) } + if ($script:surface) { $hbArgs += @('-Agent', $script:surface) } Start-Process -FilePath 'powershell' ` -ArgumentList $hbArgs ` -WindowStyle Hidden -ErrorAction Stop @@ -705,6 +716,9 @@ function Add-StoreRead { function Resolve-Surface { $script:payloadTp = Get-PayloadTranscriptPath $payload $script:isIdeSurface = $script:payloadTp -like '*/antigravity-ide/*' + # Same path, same table: the log token, the heartbeat's roster agent and the + # IDE-only enrichment branch all come from this one resolution. + $script:surface = [string](Get-AntigravitySurface $script:payloadTp) } function Add-StoreReadForEvent { @@ -857,6 +871,10 @@ function Invoke-Main { Resolve-Url Resolve-Actor Read-Payload + # Immediately after the payload, and BEFORE anything that logs - the same + # position hook.sh resolves it in. Every log line written from here on carries + # the surface, so the two dispatchers emit the same token for the same event. + Resolve-Surface Invoke-Heartbeat Initialize-SubagentDirs @@ -865,7 +883,6 @@ function Invoke-Main { # appended base64 blob. Resolve-Subagent - Resolve-Surface Add-StoreReadForEvent Add-TranscriptTail Add-CapabilityFlag diff --git a/plugins/antigravity/scripts/hook.sh b/plugins/antigravity/scripts/hook.sh index 2d11b3a..1903672 100755 --- a/plugins/antigravity/scripts/hook.sh +++ b/plugins/antigravity/scripts/hook.sh @@ -44,6 +44,7 @@ EVENT="" # hook event name, from $1 PLUGIN_ROOT="" # of this plugin, derived from $0 BODY="" # the hook payload, as received then enriched +SURFACE="" # antigravity | antigravity_ide | antigravity_cli, or empty URL="" # where to POST it SUBAGENT_ID="" # set by reattribute_subagent when this event is a subagent's SUBAGENT_NAME="" @@ -135,8 +136,13 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=antigravity event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # `${SURFACE:+ surface=$SURFACE}` expands to NOTHING when the slug is empty - + # which is the normal case for an event whose payload carries no transcriptPath, + # and for every line written before read_body. Never `surface=`, never + # `surface=unknown`. + printf '%s provider=antigravity%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } @@ -634,7 +640,7 @@ maybe_heartbeat() { [ "$EVENT" = "PreInvocation" ] || return 0 case "$BODY" in *'"invocationNum":0'*|*'"invocationNum": 0'*) - _hb_agent=$(surface_from_transcript "$(json_field transcriptPath "$BODY")") + _hb_agent="$SURFACE" ( nohup sh "${PLUGIN_ROOT}/scripts/heartbeat.sh" "$_hb_agent" >/dev/null 2>&1 & ) ;; esac } @@ -649,7 +655,7 @@ maybe_heartbeat() { # so the wait was pure latency on the event that blocks the developer. The # prompt comes from the conversation store instead. enrich_body() { - _surface=$(surface_from_transcript "$(json_field transcriptPath "$BODY")") + _surface="$SURFACE" if [ "$_surface" = "antigravity_ide" ]; then case "$EVENT" in # The pending prompt, before the model call that would consume it. @@ -736,6 +742,12 @@ main() { require_api_key # exits before stdin is read when there is no key load_actor read_body + # ONE resolution, three consumers: the log token, the heartbeat's roster agent + # and enrich_body's IDE-only branch. transcriptPath is the only reliable signal + # (three products share one install, so a filesystem probe cannot tell which is + # running), and it is absent from some events - which is exactly why the log + # token is optional. Empty here means the line carries no surface= at all. + SURFACE=$(surface_from_transcript "$(json_field transcriptPath "$BODY")") maybe_heartbeat # Re-attribute BEFORE enriching: augment_with_transcript re-closes the JSON diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 7c8df47..3142a9e 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.0", + "version": "1.0.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for OpenAI Codex", "author": { "name": "Rogue Security", diff --git a/plugins/codex/scripts/heartbeat.ps1 b/plugins/codex/scripts/heartbeat.ps1 index d83c865..72df4ce 100644 --- a/plugins/codex/scripts/heartbeat.ps1 +++ b/plugins/codex/scripts/heartbeat.ps1 @@ -88,7 +88,18 @@ if (Test-Path -LiteralPath $pj) { } # Family is the fixed enum "openai"; surface rides the agent field. -$agent = $creds['ROGUE_CODEX_SURFACE']; if (-not $agent) { $agent = 'codex_cli' } +# One table, in scripts/surface.ps1, shared with hook.ps1 - which stamps the same +# slug on each log line and sends it as x-rogue-agent. The literal is a last-resort +# guard for a damaged install, not a second copy of the mapping. +$agent = '' +try { + $surfaceLib = Join-Path $pluginRoot 'scripts\surface.ps1' + if (Test-Path -LiteralPath $surfaceLib) { + . $surfaceLib + $agent = [string](Get-CodexSurfaceSlug $creds) + } +} catch { $agent = '' } +if (-not $agent) { $agent = 'codex_cli' } $host_ = $env:COMPUTERNAME; if (-not $host_) { try { $host_ = [System.Net.Dns]::GetHostName() } catch { $host_ = 'unknown' } } diff --git a/plugins/codex/scripts/heartbeat.sh b/plugins/codex/scripts/heartbeat.sh index ddc7499..1528bc4 100755 --- a/plugins/codex/scripts/heartbeat.sh +++ b/plugins/codex/scripts/heartbeat.sh @@ -30,9 +30,16 @@ if [ -r "$PJ" ]; then [ -n "$v" ] && VER="$v" fi -# Family is the fixed enum "openai"; surface (codex_app|codex_cli) rides the -# agent field. Installer pins ROGUE_CODEX_SURFACE; default codex_cli. -AGENT="${ROGUE_CODEX_SURFACE:-codex_cli}" +# Family is the fixed enum "openai"; the surface (codex_app|codex_cli) rides the +# agent field. One table, in scripts/surface.sh, shared with hook.sh - which stamps +# the same slug on each log line and sends it as x-rogue-agent. The literal is a +# last-resort guard for a damaged install, not a second copy of the mapping. +AGENT="" +if [ -r "${PLUGIN_ROOT:-}/scripts/surface.sh" ]; then + . "${PLUGIN_ROOT}/scripts/surface.sh" + AGENT=$(codex_surface_slug 2>/dev/null) +fi +[ -n "$AGENT" ] || AGENT="codex_cli" HOST=$(hostname 2>/dev/null || echo unknown) esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } diff --git a/plugins/codex/scripts/hook.ps1 b/plugins/codex/scripts/hook.ps1 index 77cc3a4..6a3f4f8 100644 --- a/plugins/codex/scripts/hook.ps1 +++ b/plugins/codex/scripts/hook.ps1 @@ -70,6 +70,11 @@ function ConvertFrom-ShellQuoted { # would make the log shipper and the dispatcher disagree on the path. # Declared (not resolved) at file scope so the ROGUE_PS_LIB_ONLY seam below can # dot-source the helpers, and so Log is safe to call before initialisation. +# Which SURFACE of Codex wrote each line - codex_app or codex_cli. Resolved from +# the credential map (scripts/surface.ps1), the same table heartbeat.ps1 reads, so a +# log line and the roster row for one session cannot name different surfaces. Empty +# OMITS the token; it is never written as `surface=` or `surface=unknown`. +$script:surface = '' $script:logFile = $null $script:logMaxBytes = 10485760 @@ -143,9 +148,12 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # Empty slug -> empty string, so the line is byte-identical to what an + # older version wrote. Optional means optional. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=codex event=$EventName $Msg`n", + "$stamp provider=codex$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } @@ -190,6 +198,18 @@ foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BAS # Logging is initialised HERE - after the credential files are parsed, so they can # relocate the log - but BEFORE the API-key check below, so an unconfigured # install still records `outcome=unconfigured`. +# Before the first Log call, so even an unconfigured install stamps the surface. +# Guarded on both sides: a damaged install with no surface.ps1, or a resolver that +# throws, leaves the slug empty and the token is omitted - logging must never change +# the hook's outcome. +try { + $surfaceLib = Join-Path $pluginRoot 'scripts\surface.ps1' + if (Test-Path -LiteralPath $surfaceLib) { + . $surfaceLib + $script:surface = [string](Get-CodexSurfaceSlug $creds) + } +} catch { $script:surface = '' } + Initialize-Logging $creds Dbg "logFile=$logFile cap=$logMaxBytes" @@ -202,7 +222,12 @@ if (-not $apiKey) { exit 0 } -$surface = $creds['ROGUE_CODEX_SURFACE']; if (-not $surface) { $surface = 'codex_cli' } +# Reuse the slug resolved above, so the header, the log token and the roster row are +# one value. The fallback covers a damaged install where surface.ps1 was missing: +# the header has always carried a surface and must keep carrying one, where the log +# token is optional. +$surface = $script:surface +if (-not $surface) { $surface = 'codex_cli' } # URL: explicit ROGUE_API_URL wins, else base + path. $url = $creds['ROGUE_API_URL'] diff --git a/plugins/codex/scripts/hook.sh b/plugins/codex/scripts/hook.sh index 01982a1..c43671c 100755 --- a/plugins/codex/scripts/hook.sh +++ b/plugins/codex/scripts/hook.sh @@ -68,11 +68,26 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=codex event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # `${SURFACE:+ surface=$SURFACE}` expands to NOTHING when the slug is empty, so + # an undetermined surface leaves the line exactly as older versions wrote it - + # never `surface=` and never `surface=unknown`. + printf '%s provider=codex%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } +# Which SURFACE of Codex wrote this - codex_app or codex_cli. Resolved HERE, above +# the first log() call, so even an unconfigured install stamps it; the same value is +# reused for the x-rogue-agent header below, and heartbeat.sh reads the same table +# (scripts/surface.sh) so a log line and the roster row for one session cannot name +# different surfaces. Guarded: no surface.sh, no token - never a broken hook. +SURFACE="" +if [ -r "${PLUGIN_ROOT}/scripts/surface.sh" ]; then + . "${PLUGIN_ROOT}/scripts/surface.sh" + SURFACE=$(codex_surface_slug 2>/dev/null) +fi + if [ -z "${ROGUE_API_KEY:-}" ]; then log "outcome=unconfigured" echo '{}' @@ -81,9 +96,11 @@ fi . "${PLUGIN_ROOT}/scripts/actor.sh" -# Surface label (codex_app | codex_cli). Codex sets no app/cli entrypoint var, so -# the installer pins ROGUE_CODEX_SURFACE per surface; default to codex_cli. -SURFACE="${ROGUE_CODEX_SURFACE:-codex_cli}" +# SURFACE is already resolved above (scripts/surface.sh) and is reused verbatim for +# the header, so the header, the log token and the roster row are one value. The +# guard covers a damaged install where surface.sh was missing: the header has always +# carried a surface and must keep carrying one, where the log token is optional. +[ -n "$SURFACE" ] || SURFACE="codex_cli" URL="${ROGUE_API_URL:-${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/openai}" diff --git a/plugins/codex/scripts/surface.ps1 b/plugins/codex/scripts/surface.ps1 new file mode 100644 index 0000000..d0c585a --- /dev/null +++ b/plugins/codex/scripts/surface.ps1 @@ -0,0 +1,26 @@ +# Codex's SURFACE - the PowerShell half of surface.sh, kept in lockstep with it. +# +# hook.ps1 sends it as the x-rogue-agent header AND stamps it on each log line +# heartbeat.ps1 sends it as the roster agent +# +# Codex exposes no app/cli entrypoint variable of its own, so the installer pins +# ROGUE_CODEX_SURFACE per surface and everything reads that one value. +# +# slug | when +# -----------|------------------------------------------------------- +# codex_app | ROGUE_CODEX_SURFACE=codex_app +# codex_cli | ROGUE_CODEX_SURFACE=codex_cli, unset, or ANYTHING ELSE +# +# THE CLOSED LIST IS ENFORCED HERE. The value comes from an env file, so it is +# whatever someone wrote there: a space or an '=' would break the log line's +# key=value shape, and arbitrary text is exactly what this token must never carry. +# +# Takes the resolved credential map rather than reading $env: directly - the +# variable normally lives in ~/.rogue-env, which the process environment has not +# seen. Dot-sourced; Windows PowerShell 5.1 compatible. + +function Get-CodexSurfaceSlug { + param([hashtable]$Creds = @{}) + if ([string]$Creds['ROGUE_CODEX_SURFACE'] -eq 'codex_app') { return 'codex_app' } + return 'codex_cli' +} diff --git a/plugins/codex/scripts/surface.sh b/plugins/codex/scripts/surface.sh new file mode 100644 index 0000000..3196b58 --- /dev/null +++ b/plugins/codex/scripts/surface.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Codex's SURFACE — one table, three consumers. +# +# hook.sh sends it as the x-rogue-agent header AND stamps it on each log line +# heartbeat.sh sends it as the roster agent +# +# Codex exposes no app/cli entrypoint variable of its own, so the installer pins +# ROGUE_CODEX_SURFACE per surface and everything reads that one value. Keeping the +# read in one place is what stops a log line and the roster row for the same +# session from naming different surfaces. +# +# slug | when +# -----------|------------------------------------------------------- +# codex_app | ROGUE_CODEX_SURFACE=codex_app +# codex_cli | ROGUE_CODEX_SURFACE=codex_cli, unset, or ANYTHING ELSE +# +# THE CLOSED LIST IS ENFORCED HERE, and that is the point of the function rather +# than a bare `${ROGUE_CODEX_SURFACE:-codex_cli}`. The variable comes from an env +# file, so its value is whatever someone wrote there: a value with a space or an +# `=` would break the log line's `key=value` shape, and an arbitrary string on a +# line or in a roster row is exactly the kind of uncontrolled content this token +# must never carry. Anything unrecognised is treated as unset. +# +# Sourced, not executed. + +codex_surface_slug() { + case "${ROGUE_CODEX_SURFACE:-}" in + codex_app) printf 'codex_app' ;; + *) printf 'codex_cli' ;; + esac + return 0 +} diff --git a/plugins/copilot/plugin.json b/plugins/copilot/plugin.json index 2208561..d4a0e6b 100644 --- a/plugins/copilot/plugin.json +++ b/plugins/copilot/plugin.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.2.0", + "version": "1.2.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for GitHub Copilot CLI", "author": { "name": "Rogue Security", diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index 5ad6ed6..85b9f62 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -85,6 +85,10 @@ function ConvertFrom-ShellQuoted { # would make the log shipper and the dispatcher disagree on the path. # Declared (not resolved) at file scope so the ROGUE_PS_LIB_ONLY seam below can # dot-source the helpers, and so Log is safe to call before initialisation. +# The one surface this plugin has. A closed-vocabulary slug, lowercase, no space +# and no '=', so a reader finds the value by scanning to the next 'key=' token. It +# matches what heartbeat reports as the roster agent for this plugin. +$script:surface = 'github_copilot' $script:logFile = $null $script:logMaxBytes = 10485760 @@ -158,9 +162,13 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # A constant: this plugin has exactly one surface, so there is nothing to + # detect and nothing that can fail. Written through the same conditional as + # the multi-surface plugins so all six dispatchers share one emit shape. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=copilot event=$EventName $Msg`n", + "$stamp provider=copilot$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } diff --git a/plugins/copilot/scripts/hook.sh b/plugins/copilot/scripts/hook.sh index 48f4ff8..60c7afb 100755 --- a/plugins/copilot/scripts/hook.sh +++ b/plugins/copilot/scripts/hook.sh @@ -83,6 +83,11 @@ rotate_log() { [ "$_lsz" -ge "$ROGUE_LOG_MAX_BYTES" ] && mv -f "$ROGUE_LOG_FILE" "$ROGUE_LOG_FILE.1" 2>/dev/null return 0 } +# The one surface this plugin has. A closed-vocabulary slug, lowercase, no space +# and no `=`, so a reader finds the value by scanning to the next `key=` token. It +# matches what heartbeat reports as the roster agent for this plugin. +SURFACE="github_copilot" + log() { # 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 @@ -94,8 +99,13 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=copilot event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # SURFACE is a constant here: this plugin has exactly one surface, so there is + # nothing to detect and nothing that can fail. It is still written through the + # same `${SURFACE:+ …}` expansion as the multi-surface plugins so all six + # dispatchers share one emit shape. + printf '%s provider=copilot%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index a6ff757..a8a8d8c 100644 --- a/plugins/cursor/.cursor-plugin/plugin.json +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "Rogue Security", - "version": "1.1.0", + "version": "1.1.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "rogue-security", diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index c1950b4..3317c66 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -139,6 +139,10 @@ function Repair-DoubleEncodedUtf8 { # would make the log shipper and the dispatcher disagree on the path. # Declared (not resolved) at file scope so the ROGUE_PS_LIB_ONLY seam below can # dot-source the helpers, and so Log is safe to call before initialisation. +# The one surface this plugin has. A closed-vocabulary slug, lowercase, no space +# and no '=', so a reader finds the value by scanning to the next 'key=' token. It +# matches what heartbeat reports as the roster agent for this plugin. +$script:surface = 'cursor' $script:logFile = $null $script:logMaxBytes = 10485760 @@ -214,9 +218,13 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # A constant: this plugin has exactly one surface, so there is nothing to + # detect and nothing that can fail. Written through the same conditional as + # the multi-surface plugins so all six dispatchers share one emit shape. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=cursor event=$EventName $Msg`n", + "$stamp provider=cursor$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 88e21f8..ad46da1 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -128,6 +128,11 @@ rotate_log() { [ "$_lsz" -ge "$ROGUE_LOG_MAX_BYTES" ] && mv -f "$ROGUE_LOG_FILE" "$ROGUE_LOG_FILE.1" 2>/dev/null return 0 } +# The one surface this plugin has. A closed-vocabulary slug, lowercase, no space +# and no `=`, so a reader finds the value by scanning to the next `key=` token. It +# matches what heartbeat reports as the roster agent for this plugin. +SURFACE="cursor" + log() { # 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 @@ -139,8 +144,13 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=cursor event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$event" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # SURFACE is a constant here: this plugin has exactly one surface, so there is + # nothing to detect and nothing that can fail. It is still written through the + # same `${SURFACE:+ …}` expansion as the multi-surface plugins so all six + # dispatchers share one emit shape. + printf '%s provider=cursor%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$event" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } # Strip control characters: the logged text is SERVER-CONTROLLED (a block reason # can carry anything), and a raw newline or CR would forge extra log lines. diff --git a/plugins/gemini/gemini-extension.json b/plugins/gemini/gemini-extension.json index de1b029..6f5862b 100644 --- a/plugins/gemini/gemini-extension.json +++ b/plugins/gemini/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.24", + "version": "1.0.25", "description": "Rogue Security AIDR — real-time AI agent detection and response for Gemini CLI", "contextFileName": "GEMINI.md" } diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index 0c6938f..6f2164f 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -30,6 +30,14 @@ const EVENT = process.argv[2] || "unknown"; // ("gemini_cli", which the server keys its version lookup on) — see // heartbeat.mjs. Keep the two independent. const PROVIDER = "gemini"; +// The one surface this extension has - Gemini CLI. A closed-vocabulary slug, +// lowercase, no space and no "=", so a reader finds the value by scanning to the +// next "key=" token, and it matches what heartbeat.mjs reports as the roster agent. +// A constant here: there is nothing to detect and nothing that can fail. It is +// still emitted through the same conditional the multi-surface plugins use, so all +// six dispatchers share one emit shape and an empty value omits the token entirely +// rather than writing "surface=" or "surface=unknown". +const SURFACE = "gemini_cli"; // ── Emit + exit ──────────────────────────────────────────────────────────── // stdout must be ONLY the final JSON object. Always exit 0 — a blocking verdict @@ -111,7 +119,12 @@ function log(msg) { fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true, mode: 0o700 }); rotateLog(); const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z"); - fs.appendFileSync(LOG_FILE, `${ts} provider=${PROVIDER} event=${EVENT} ${msg}\n`, { mode: 0o600 }); + const surfaceToken = SURFACE ? ` surface=${SURFACE}` : ""; + fs.appendFileSync( + LOG_FILE, + `${ts} provider=${PROVIDER}${surfaceToken} event=${EVENT} ${msg}\n`, + { mode: 0o600 }, + ); } catch { /* logging is best-effort */ } diff --git a/plugins/rogue/.claude-plugin/plugin.json b/plugins/rogue/.claude-plugin/plugin.json index 6d13bcb..9d944d0 100644 --- a/plugins/rogue/.claude-plugin/plugin.json +++ b/plugins/rogue/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.23", + "version": "1.0.24", "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 8996c3e..f770464 100644 --- a/plugins/rogue/scripts/heartbeat.ps1 +++ b/plugins/rogue/scripts/heartbeat.ps1 @@ -98,10 +98,20 @@ if (Test-Path -LiteralPath $pj) { } # -- agent display label from entrypoint (family is the fixed enum "claude") - -$ep = ([string]$env:CLAUDE_CODE_ENTRYPOINT).ToLower() -if ($ep -like '*cowork*') { $agent = 'Claude Cowork' } -elseif ($ep -like '*desktop*') { $agent = 'Claude Code - Desktop' } -else { $agent = 'Claude Code - CLI' } +# One table, in scripts/surface.ps1, shared with hook.ps1 - which stamps the +# matching SLUG on each log line. Two copies of this mapping would eventually +# drift, and a log line naming a different surface than the roster row for the same +# session is worse than a line that names none. The literal below is a last-resort +# guard for a damaged install, not a second copy of the mapping. +$agent = '' +try { + $surfaceLib = Join-Path $pluginRoot 'scripts\surface.ps1' + if (Test-Path -LiteralPath $surfaceLib) { + . $surfaceLib + $agent = [string](Get-RogueSurfaceLabel) + } +} catch { $agent = '' } +if (-not $agent) { $agent = 'Claude Code - CLI' } $host_ = $env:COMPUTERNAME; if (-not $host_) { try { $host_ = [System.Net.Dns]::GetHostName() } catch { $host_ = 'unknown' } } diff --git a/plugins/rogue/scripts/heartbeat.sh b/plugins/rogue/scripts/heartbeat.sh index cb04050..27e934a 100755 --- a/plugins/rogue/scripts/heartbeat.sh +++ b/plugins/rogue/scripts/heartbeat.sh @@ -53,11 +53,16 @@ fi # x-rogue-agent header (free-form display label), derived from # CLAUDE_CODE_ENTRYPOINT (the same var hook.sh uses to tell GUI from cli). # Unknown → CLI. -case "$(printf '%s' "${CLAUDE_CODE_ENTRYPOINT:-}" | tr '[:upper:]' '[:lower:]')" in - *cowork*) AGENT="Claude Cowork" ;; - *desktop*) AGENT="Claude Code - Desktop" ;; - *) AGENT="Claude Code - CLI" ;; -esac +# One table, in scripts/surface.sh, shared with hook.sh - which stamps the matching +# SLUG on each log line. A `case` copied into both would eventually drift, and a log +# line naming a different surface than the roster row for the same session is worse +# than a line that names none. The literal below is a last-resort guard for a +# damaged install (missing file), not a second copy of the mapping. +if [ -r "${CLAUDE_PLUGIN_ROOT:-}/scripts/surface.sh" ]; then + . "${CLAUDE_PLUGIN_ROOT}/scripts/surface.sh" + AGENT=$(rogue_surface_label 2>/dev/null) +fi +[ -n "${AGENT:-}" ] || AGENT="Claude Code - CLI" # POST /api/v1/hooks/status (GET route is gone). The former x-rogue-agent-* # headers now ride the JSON body; x-rogue-api-key stays a header. Backslash- and diff --git a/plugins/rogue/scripts/hook.ps1 b/plugins/rogue/scripts/hook.ps1 index a077f0b..b71f7ed 100644 --- a/plugins/rogue/scripts/hook.ps1 +++ b/plugins/rogue/scripts/hook.ps1 @@ -132,6 +132,13 @@ function Repair-DoubleEncodedUtf8 { # would make the log shipper and the dispatcher disagree on the path. # Declared (not resolved) at file scope so the ROGUE_PS_LIB_ONLY seam below can # dot-source the helpers, and so Log is safe to call before initialisation. +# Which SURFACE of Claude wrote each line - cli, desktop or cowork. One file per +# agent family means every surface on the machine appends to the same claude.log, +# and nothing on the line said which one. The mapping is shared with heartbeat.ps1 +# (scripts/surface.ps1) so a line and the roster row for the same session can never +# name different surfaces. An empty value OMITS the token; it is never written as +# `surface=` or `surface=unknown`. +$script:surface = '' $script:logFile = $null $script:logMaxBytes = 10485760 @@ -205,9 +212,12 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # Empty slug -> empty string, so the line is byte-identical to what an + # older version wrote. Optional means optional. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=claude event=$EventName $Msg`n", + "$stamp provider=claude$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } @@ -242,6 +252,18 @@ $pluginRoot = $env:CLAUDE_PLUGIN_ROOT if (-not $pluginRoot) { try { $pluginRoot = (Get-Location).Path } catch { $pluginRoot = '.' } } Dbg "pluginRoot=$pluginRoot" +# Resolve the surface before the first Log call. Guarded on both sides: a damaged +# install with no surface.ps1, or a resolver that throws, leaves the slug empty and +# the token is simply omitted - logging must never change the hook's outcome. +try { + $surfaceLib = Join-Path $pluginRoot 'scripts\surface.ps1' + if (Test-Path -LiteralPath $surfaceLib) { + . $surfaceLib + $script:surface = [string](Get-RogueSurfaceSlug) + } +} catch { $script:surface = '' } +Dbg "surface=$($script:surface)" + # -- credential resolution (later file wins; process env wins over all) ----- $creds = @{} $credFiles = @( diff --git a/plugins/rogue/scripts/hook.sh b/plugins/rogue/scripts/hook.sh index 48825a4..ce45d30 100644 --- a/plugins/rogue/scripts/hook.sh +++ b/plugins/rogue/scripts/hook.sh @@ -18,6 +18,20 @@ esac [ -z "${CLAUDE_CODE_ENTRYPOINT:-}" ] && echo '{}' && exit 0 +# Which SURFACE of Claude wrote this line - cli, desktop or cowork. One file per +# agent family means every surface on the machine appends to the same claude.log, +# and nothing on the line said which one. The mapping is shared with heartbeat.sh +# (see scripts/surface.sh) precisely so the line and the roster row it belongs to +# can never name different surfaces. Sourcing is guarded and the result may be +# empty; an empty SURFACE omits the token rather than writing surface=unknown. +# Unreachable here in practice: the gate above already returned when the +# entrypoint was unset, and any non-empty value maps to a slug. +SURFACE="" +if [ -r "${CLAUDE_PLUGIN_ROOT}/scripts/surface.sh" ]; then + . "${CLAUDE_PLUGIN_ROOT}/scripts/surface.sh" + SURFACE=$(rogue_surface_slug 2>/dev/null) +fi + # Log destination — ONE FILE PER AGENT. Every Rogue plugin shares ~/.rogue, so a # machine running Claude Code + Codex + Cursor + … used to interleave all of them # into a single hook.log with no way to tell whose line was whose. Precedence: @@ -67,8 +81,12 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=claude event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # `${SURFACE:+ surface=$SURFACE}` expands to NOTHING when the slug is empty, + # so an undetermined surface leaves the line exactly as older versions wrote + # it - the token is optional, never `surface=` and never `surface=unknown`. + printf '%s provider=claude%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } diff --git a/plugins/rogue/scripts/surface.ps1 b/plugins/rogue/scripts/surface.ps1 new file mode 100644 index 0000000..c1ad324 --- /dev/null +++ b/plugins/rogue/scripts/surface.ps1 @@ -0,0 +1,44 @@ +# Claude's SURFACE, derived from CLAUDE_CODE_ENTRYPOINT - the PowerShell half of +# surface.sh, and kept in lockstep with it. +# +# hook.ps1 stamps the SLUG on every log line (surface=cli) +# heartbeat.ps1 sends the LABEL as the roster agent (Claude Code - CLI) +# +# They must never disagree: a log line and the roster row it belongs to would then +# name different surfaces for one session, which is worse than the log saying +# nothing at all. Add a surface in surface.sh AND here. +# +# slug | label | when +# ---------|-----------------------|-------------------------------------- +# cowork | Claude Cowork | entrypoint contains "cowork" +# desktop | Claude Code - Desktop | entrypoint contains "desktop" +# cli | Claude Code - CLI | any other NON-EMPTY entrypoint +# (none) | Claude Code - CLI | entrypoint empty or unset +# +# Slugs are lowercase, contain no space and no '=', so a reader can find the value +# by scanning to the next 'key=' token. They are a closed set: never a path, a user +# name, a host, or anything else read off the environment. +# +# An EMPTY entrypoint yields an EMPTY slug and the caller omits the token entirely - +# the log never says surface=unknown. It cannot arise on the logging path (both +# dispatchers exit before their first log line when the variable is unset). The +# LABEL side still defaults to the CLI, because the roster field is required. +# +# Dot-sourced, so it defines functions and does nothing else. Windows PowerShell +# 5.1 compatible. + +function Get-RogueSurfaceSlug { + $entryPoint = ([string]$env:CLAUDE_CODE_ENTRYPOINT).ToLower() + if (-not $entryPoint) { return '' } + if ($entryPoint -like '*cowork*') { return 'cowork' } + if ($entryPoint -like '*desktop*') { return 'desktop' } + return 'cli' +} + +function Get-RogueSurfaceLabel { + switch (Get-RogueSurfaceSlug) { + 'cowork' { return 'Claude Cowork' } + 'desktop' { return 'Claude Code - Desktop' } + default { return 'Claude Code - CLI' } + } +} diff --git a/plugins/rogue/scripts/surface.sh b/plugins/rogue/scripts/surface.sh new file mode 100644 index 0000000..773a506 --- /dev/null +++ b/plugins/rogue/scripts/surface.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Claude's SURFACE, derived from CLAUDE_CODE_ENTRYPOINT — one table, two consumers. +# +# hook.sh stamps the SLUG on every log line (surface=cli) +# heartbeat.sh sends the LABEL as the roster agent (Claude Code - CLI) +# +# They must never disagree: a log line and the roster row it belongs to would then +# name different surfaces for one session, which is worse than the log saying +# nothing at all. That is the whole reason this mapping lives in its own file +# instead of as a `case` copy-pasted into both scripts. Add a surface HERE. +# +# slug | label | when +# ---------|-----------------------|-------------------------------------- +# cowork | Claude Cowork | entrypoint contains "cowork" +# desktop | Claude Code - Desktop | entrypoint contains "desktop" +# cli | Claude Code - CLI | any other NON-EMPTY entrypoint +# (none) | Claude Code - CLI | entrypoint empty or unset +# +# Slugs are lowercase, contain no space and no `=`, so a reader can find the value +# by scanning to the next `key=` token. They are a closed set: never a path, a user +# name, a host, or anything else read off the environment. +# +# An EMPTY entrypoint yields an EMPTY slug, and the caller then omits the token +# entirely — the log never says `surface=unknown`. It cannot actually arise on the +# logging path (hook.sh and hook.ps1 both `exit 0` before their first log line when +# the variable is unset), so the omission is a guarantee about a case that does not +# occur rather than a state to design around. The LABEL side still defaults to the +# CLI, because the roster field is required and must carry something. +# +# Sourced, not executed: it defines functions and returns. + +rogue_surface_slug() { + case "$(printf '%s' "${CLAUDE_CODE_ENTRYPOINT:-}" | tr '[:upper:]' '[:lower:]')" in + '') ;; + *cowork*) printf 'cowork' ;; + *desktop*) printf 'desktop' ;; + *) printf 'cli' ;; + esac + return 0 +} + +rogue_surface_label() { + case "$(rogue_surface_slug)" in + cowork) printf 'Claude Cowork' ;; + desktop) printf 'Claude Code - Desktop' ;; + *) printf 'Claude Code - CLI' ;; + esac + return 0 +} diff --git a/tests/log_probe.ps1 b/tests/log_probe.ps1 index c6cac20..7ad633c 100644 --- a/tests/log_probe.ps1 +++ b/tests/log_probe.ps1 @@ -23,7 +23,12 @@ param( # ship transcript tails and subagent names base64-encoded. [string]$CredsB64 = '', # base64 of a JSON object of ROGUE_* values [int]$SeedBytes = 0, # >0: pre-fill the log - [string]$SeedPrevious = '' # non-empty: also create .1 + [string]$SeedPrevious = '', # non-empty: also create .1 + # '' = leave the dispatcher's own value alone; '' = force it + # EMPTY. A literal '' cannot be used for the second case: Windows PowerShell 5.1 + # DROPS an empty native-command argument, so `-Surface ''` reaches this script as + # a bare `-Surface` and fails to bind ("Missing an argument for parameter"). + [string]$Surface = '' # override $script:surface before Log ) # Parse BEFORE dot-sourcing, and never name this parameter `$Creds`: dot-sourcing @@ -31,6 +36,14 @@ param( # file-scope `$creds = @{}`. PowerShell variable names are case-INSENSITIVE, so # that assignment would silently overwrite our own parameter with an empty # hashtable and every override in it would vanish. +# Snapshot BEFORE the dot-source, for exactly the reason spelled out above: the +# dispatcher declares a file-scope `$script:surface`, PowerShell variable names are +# case-INSENSITIVE, and dot-sourcing therefore overwrites this script's own +# `$Surface` parameter with the dispatcher's value. Read back afterwards it always +# equalled the default the dispatcher had just set, so the override silently did +# nothing - the same trap `$Creds` -> `$map` avoids one line below. +$surfaceOverride = $Surface + $map = @{} $credsJson = '{}' if ($CredsB64) { @@ -55,6 +68,17 @@ if ($SeedBytes -gt 0) { # -NoNewline so the byte count is exact; rotation compares against Length. Set-Content -LiteralPath $logFile -Value ('s' * $SeedBytes) -NoNewline } +# The surface is resolved in the MAIN BODY (below the seam), which never runs here, +# so each dispatcher's file-scope default is what a probe sees: the constant for a +# single-surface plugin, empty for one that detects per session. `-Surface` sets it +# explicitly so the EMIT shape - token present, token omitted - is testable; the +# resolution itself is covered separately, against surface.ps1 and against the +# main body's wiring. +if ($surfaceOverride -ne '') { + $script:surface = if ($surfaceOverride -eq '') { '' } else { $surfaceOverride } +} +"SURFACE=$($script:surface)" + Log 'outcome=probe' $lines = @(Get-Content -LiteralPath $logFile -ErrorAction SilentlyContinue) diff --git a/tests/manual/local_api.sh b/tests/manual/local_api.sh new file mode 100755 index 0000000..17d81e5 --- /dev/null +++ b/tests/manual/local_api.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash +# Point THIS machine's Claude Code at the plugin WORKING TREE and a LOCAL Rogue API, +# so you can exercise uncommitted plugin changes against a backend you control. +# +# bash tests/manual/local_api.sh up [--url URL] [--key KEY] [--ship] +# bash tests/manual/local_api.sh sync # re-push working-tree edits, no reinstall +# bash tests/manual/local_api.sh status # what is installed, where it points +# bash tests/manual/local_api.sh check # assert the log looks like this branch +# bash tests/manual/local_api.sh probe # one headless session, no interactive restart +# bash tests/manual/local_api.sh down # undo everything `up` did +# +# This is the sibling of live_session.sh. That script is a self-contained, asserting +# run against a fake node receiver and it installs from `git archive HEAD`. This one +# installs the WORKING TREE and leaves it installed, so your normal interactive +# sessions drive it and your own API sees the traffic. +# +# WHAT `up` CHANGES ON YOUR MACHINE (all of it undone by `down`) +# +# * Adds a marketplace `rogue-localdev` sourced from a copy of your working tree, +# and installs `rogue@rogue-localdev` at user scope. +# * DISABLES `rogue@rogue-marketplace` if you have it, so exactly one plugin +# answers each event. Two enabled copies would double every POST and write two +# log lines per event, which makes the log unreadable for exactly the thing you +# are trying to observe. +# * Replaces `~/.rogue-env`, keeping the original at `~/.rogue-env.localdev-backup`. +# It has to be that file and not the process environment: `hook.sh` sources the +# env files AFTER the process environment is already set, with no save/restore, +# so `~/.rogue-env`'s ROGUE_API_KEY beats anything you export. (`hook.ps1` and +# `hook.mjs` apply process env last and do not have this behaviour - an +# sh-dispatcher divergence from the documented precedence, tracked separately.) +# +# ~/.rogue-env IS SHARED BY ALL SIX PLUGINS. While this is up, any Codex, Cursor, +# Gemini, Copilot or Antigravity install on this machine also talks to your local +# API and authenticates with the local key. That is usually what you want for a +# backend test; it is not what you want left running. +# +# * Nothing else. $HOME stays real, the log stays at its real path (~/.rogue/logs/ +# claude.log) so /rogue:status reads the same file you are watching. +set -u + +REPO="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +MARKET_NAME=rogue-localdev +MARKET_DIR="$HOME/.rogue-localdev/market" +ENV_FILE="$HOME/.rogue-env" +ENV_BACKUP="$HOME/.rogue-env.localdev-backup" +STATE="$HOME/.rogue-localdev/state" +LOG="${ROGUE_LOG_FILE:-$HOME/.rogue/logs/claude.log}" + +DEFAULT_URL=http://localhost:8000 +DEFAULT_KEY=localdev-key + +say() { printf '%s\n' "$*"; } +die() { printf '%s\n' "$*" >&2; exit 1; } +rule() { printf '\n── %s %s\n' "$1" "$(printf '─%.0s' $(seq 1 $((70 - ${#1}))))"; } + +command -v claude >/dev/null 2>&1 || die "need the \`claude\` CLI on PATH" + +# The version the marketplace will advertise, read the same way build-release.sh +# does. It becomes the cache directory name, which `sync` has to find again. +plugin_version() { + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$REPO/plugins/rogue/.claude-plugin/plugin.json" | head -1 +} + +install_path() { + node -e ' + const fs = require("fs"), os = require("os"); + const f = os.homedir() + "/.claude/plugins/installed_plugins.json"; + try { + const p = JSON.parse(fs.readFileSync(f, "utf8")).plugins["rogue@" + process.env.MK]; + if (p && p.length) process.stdout.write(p[p.length - 1].installPath || ""); + } catch (e) {} + ' 2>/dev/null +} + +# ── copy the working tree into the marketplace ──────────────────────────────── +# Only the two paths a Claude marketplace install reads: the manifest and the +# plugin. Copying the whole repo would drag .git along for no benefit. +stage_tree() { + rm -rf "$MARKET_DIR" + mkdir -p "$MARKET_DIR/.claude-plugin" || die "cannot write $MARKET_DIR" + cp "$REPO/.claude-plugin/marketplace.json" "$MARKET_DIR/.claude-plugin/marketplace.json" + mkdir -p "$MARKET_DIR/plugins" + # -R, not `cp -a`: BSD cp has no -a. Follows the same portability rule as the + # dispatchers (wc -c over stat, etc). + cp -R "$REPO/plugins/rogue" "$MARKET_DIR/plugins/rogue" + # Rename the marketplace so it cannot collide with a real `rogue-marketplace` + # entry. The plugin inside is still called `rogue`, hence every claude command + # below is marketplace-qualified. + sed -i.bak "s/\"name\": *\"rogue-marketplace\"/\"name\": \"$MARKET_NAME\"/" \ + "$MARKET_DIR/.claude-plugin/marketplace.json" + rm -f "$MARKET_DIR/.claude-plugin/marketplace.json.bak" +} + +cmd_up() { + URL="$DEFAULT_URL"; KEY="$DEFAULT_KEY"; SHIP=0 + while [ $# -gt 0 ]; do + case "$1" in + --url) URL="${2:?--url needs a value}"; shift 2 ;; + --key) KEY="${2:?--key needs a value}"; shift 2 ;; + --ship) SHIP=1; shift ;; + *) die "unknown argument: $1" ;; + esac + done + + case "$URL" in + # The brackets are ESCAPED. `http://[::1]:*` is a glob bracket expression, not + # the literal IPv6 loopback - it matches one character from the set, so the arm + # never fires for the address it names and the URL falls through to `die`. + http://localhost:*|http://127.0.0.1:*|http://\[::1\]:*) ;; + *) die "refusing to point at a non-local URL ($URL). This rewrites ~/.rogue-env + for EVERY Rogue plugin on the machine; a typo here sends your sessions' + prompts and tool calls somewhere you did not intend." ;; + esac + + rule "the local API" + if curl -sS -o /dev/null --max-time 3 "$URL" 2>/dev/null; then + say " something is listening on $URL" + else + say " WARNING: nothing answered at $URL yet." + say " Continuing anyway - start it before you run a session. Every hook fails" + say " open, so an unreachable API costs you nothing but an outcome=fail line." + fi + + rule "install the working tree" + stage_tree + say " staged $(plugin_version) from $REPO -> $MARKET_DIR" + claude plugin marketplace add "$MARKET_DIR" --scope user 2>&1 | sed 's/^/ /' + claude plugin install "rogue@$MARKET_NAME" --scope user 2>&1 | sed 's/^/ /' + MK="$MARKET_NAME" ; export MK + IP="$(install_path)" + [ -n "$IP" ] || die " the install produced no record - check the output above" + say " installed to $IP" + + # Exactly one plugin per event. Recorded so `down` only re-enables what it + # actually disabled. + mkdir -p "$(dirname "$STATE")" + : > "$STATE" + if claude plugin list 2>/dev/null | grep -q 'rogue@rogue-marketplace'; then + if claude plugin disable rogue@rogue-marketplace --scope user >/dev/null 2>&1; then + say " disabled rogue@rogue-marketplace (it would double every event)" + echo "disabled_prod=1" >> "$STATE" + fi + fi + + rule "credentials" + if [ -f "$ENV_FILE" ]; then + # Never clobber a real backup: a second `up` without a `down` would otherwise + # overwrite the saved original with the localdev one and lose it for good. + if [ -f "$ENV_BACKUP" ]; then + say " $ENV_BACKUP already exists - keeping it, not re-backing-up" + else + cp "$ENV_FILE" "$ENV_BACKUP" && say " backed up $ENV_FILE -> $ENV_BACKUP" + echo "backed_up=1" >> "$STATE" + fi + fi + ( umask 077 + { + echo "# Written by tests/manual/local_api.sh - LOCAL DEVELOPMENT ONLY." + echo "# Restore the original with: bash tests/manual/local_api.sh down" + echo "export ROGUE_API_KEY=$KEY" + echo "export ROGUE_BASE_URL=$URL" + echo "export ROGUE_ACTOR_EMAIL=${ROGUE_ACTOR_EMAIL:-localdev@rogue.security}" + echo "export ROGUE_ACTOR_NAME=${ROGUE_ACTOR_NAME:-Local Dev}" + [ "$SHIP" = 1 ] && echo "export ROGUE_SHIP_LOGS=1" + [ "$SHIP" = 1 ] && echo "export ROGUE_SHIP_MIN_INTERVAL=0" + } > "$ENV_FILE" + ) + say " wrote $ENV_FILE (mode 600) -> $URL" + [ "$SHIP" = 1 ] && say " log shipping ON (your API must serve POST /api/v1/hooks/logs)" + + rule "next" + say " 1. Start your local API on $URL" + say " 2. RESTART Claude Code - a plugin is loaded at session start, so this" + say " session is still running the old copy." + say " 3. Use it normally, then: bash tests/manual/local_api.sh check" + say + say " Edited a script since? Re-push it without a reinstall:" + say " bash tests/manual/local_api.sh sync" +} + +# `sync` exists because the install COPIES the tree - editing plugins/rogue in the +# repo does nothing until the copy is refreshed. Refreshing the installed copy in +# place beats uninstall/reinstall: the install record, the version and the enabled +# state all stay put, and a running session picks up the next hook invocation +# immediately (each event spawns a fresh `sh hook.sh`, nothing is cached in-process). +cmd_sync() { + MK="$MARKET_NAME"; export MK + IP="$(install_path)" + [ -n "$IP" ] || die "rogue@$MARKET_NAME is not installed - run \`up\` first" + [ -d "$IP" ] || die "the install record points at $IP, which does not exist" + stage_tree + # Copy CONTENTS, so files deleted in the working tree since the install still + # linger - acceptable, and far safer than rm -rf on a path read out of a JSON file. + cp -R "$MARKET_DIR/plugins/rogue/." "$IP/" || die "copy into $IP failed" + say "synced $REPO/plugins/rogue -> $IP" + say "Takes effect on the next hook invocation. No restart needed for script edits." + say "hooks.json changes DO need a restart (the event registration is read once)." +} + +cmd_status() { + MK="$MARKET_NAME"; export MK + rule "plugins" + claude plugin list 2>/dev/null | grep -i rogue | sed 's/^/ /' || say " (none)" + IP="$(install_path)" + [ -n "$IP" ] && say " localdev install path: $IP" + if [ -n "$IP" ] && [ -f "$IP/scripts/surface.sh" ]; then + say " the installed copy HAS scripts/surface.sh (this branch's change)" + elif [ -n "$IP" ]; then + say " the installed copy has NO scripts/surface.sh - it predates this branch," + say " or \`sync\` has not run since you switched branches" + fi + rule "credentials" + if [ -f "$ENV_FILE" ]; then + sed 's/^export ROGUE_API_KEY=.*/export ROGUE_API_KEY=/' "$ENV_FILE" | sed 's/^/ /' + else + say " no $ENV_FILE" + fi + [ -f "$ENV_BACKUP" ] && say " original saved at $ENV_BACKUP" + rule "the log" + say " $LOG" + [ -f "$LOG" ] && tail -8 "$LOG" | sed 's/^/ /' || say " (nothing yet)" +} + +# What this branch is supposed to have changed, asserted against real lines that a +# real session wrote. Deliberately narrow: it reads the log only, so it says nothing +# about what the API did with the request - that is your backend's to check. +cmd_check() { + [ -f "$LOG" ] || die "no $LOG yet - run a session first" + rule "lines this run wrote" + tail -12 "$LOG" | sed 's/^/ /' + + rule "verdict" + fails=0 + ok() { say " ok: $1"; } + bad() { say " FAIL: $1"; fails=$((fails + 1)); } + + recent="$(tail -40 "$LOG")" + total="$(printf '%s\n' "$recent" | grep -c 'provider=claude')" + tagged="$(printf '%s\n' "$recent" | grep -c 'provider=claude surface=')" + + [ "$total" -gt 0 ] && ok "the hooks are firing ($total recent lines)" \ + || bad "no provider=claude lines at all - the plugin is not loaded" + + if [ "$tagged" = "$total" ] && [ "$total" -gt 0 ]; then + ok "every recent line carries surface= ($tagged/$total)" + else + bad "only $tagged of $total recent lines carry surface= - a stale install, or \`sync\` has not run" + fi + + # Position is part of the contract the backend parses on: provider, then surface, + # then event. A token in the wrong place is worse than a missing one. + # + # EVERY tagged line, not any: the obvious `grep -q ''` passes as + # soon as ONE line is right, so a run where most lines carry the token in the + # wrong place still reported ok. Count the tagged lines that do NOT match instead. + misplaced="$(printf '%s\n' "$recent" | grep 'surface=' \ + | grep -vc 'provider=claude surface=[a-z_]* event=')" + [ "${misplaced:-0}" = 0 ] && ok "surface= sits between provider= and event= on every tagged line" \ + || bad "$misplaced tagged line(s) do not have surface= between provider= and event=" + + # The closed list. Anything else means something leaked into the token. + stray="$(printf '%s\n' "$recent" | sed -n 's/.*surface=\([^ ]*\).*/\1/p' \ + | grep -vc '^\(cli\|desktop\|cowork\)$')" + [ "${stray:-0}" = 0 ] && ok "every slug is from claude's closed list" \ + || bad "$stray line(s) carry a slug outside cli/desktop/cowork" + + # The two things that must never appear, per the spec. Spelled as if/else rather + # than `grep -q … && bad … || ok …`: in that form the `||` arm also runs whenever + # the `&&` arm returns non-zero, so a future edit to bad() would silently report + # both a failure and an ok for the same line. + if printf '%s\n' "$recent" | grep -q 'surface=unknown'; then + bad "a line says surface=unknown" + else + ok "no surface=unknown" + fi + # `surface=` followed by a space OR end-of-line. Anchoring on the space alone + # missed an empty token at the end of a line. + if printf '%s\n' "$recent" | grep -qE 'surface=( |$)'; then + bad "a line has an empty surface=" + else + ok "no empty surface=" + fi + + # Did the API answer? An Unauthorized here is a backend-side key mismatch, not a + # plugin failure - the plugin relayed exactly what it got. + rule "what your API answered" + printf '%s\n' "$recent" | grep 'raw=' | tail -4 | sed 's/^/ /' || say " (no raw= lines)" + + say + [ "$fails" = 0 ] && say "LOCAL API CHECK PASSED" || say "$fails failure(s)" + return "$fails" +} + +# A headless probe, for when you do not want to restart your editor to see whether +# the API is wired up. Claude Code did NOT run plugin-provided hooks in a `claude -p` +# run on 2.1.223, so this registers the INSTALLED plugin's own hooks.json through +# --settings - the same trick, and the same caveat, as live_session.sh. +cmd_probe() { + MK="$MARKET_NAME"; export MK + IP="$(install_path)" + [ -n "$IP" ] || die "rogue@$MARKET_NAME is not installed - run \`up\` first" + command -v node >/dev/null 2>&1 || die "need node for this subcommand" + SETTINGS="$HOME/.rogue-localdev/hooks-settings.json" + mkdir -p "$(dirname "$SETTINGS")" + ROGUE_LOCAL_ROOT="$IP" ROGUE_LOCAL_OUT="$SETTINGS" node -e ' + const fs = require("fs"); + const root = process.env.ROGUE_LOCAL_ROOT, out = process.env.ROGUE_LOCAL_OUT; + const hooks = JSON.parse(fs.readFileSync(root + "/hooks/hooks.json", "utf8")).hooks; + // The only edit: expand ${CLAUDE_PLUGIN_ROOT}, which Claude Code substitutes for + // a plugin hook but not for a settings hook. + const expanded = JSON.parse( + JSON.stringify(hooks).replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, root)); + fs.writeFileSync(out, JSON.stringify({ hooks: expanded }, null, 2)); + console.log(" registered " + Object.keys(expanded).length + " events from " + root); + ' || die "could not build the settings file" + rule "one headless session" + ( cd "$HOME/.rogue-localdev" || exit 1 + env CLAUDE_PLUGIN_ROOT="$IP" \ + claude --settings "$SETTINGS" --output-format text \ + -p 'Run the shell command `echo rogue-localdev-probe` and then reply with just the word done.' \ + 2>&1 | sed 's/^/ claude: /' ) + cmd_check +} + +cmd_down() { + rule "undo" + claude plugin uninstall "rogue@$MARKET_NAME" --scope user >/dev/null 2>&1 \ + && say " uninstalled rogue@$MARKET_NAME" + claude plugin marketplace remove "$MARKET_NAME" >/dev/null 2>&1 \ + && say " removed the $MARKET_NAME marketplace" + # Uninstalling leaves the extracted tree in the cache, and that leftover is not + # inert: it is a NEWER copy than your real install, so anything resolving the + # plugin root by "newest under the cache" - the last-resort layer of the + # /rogue:status support snippet - would pick this one. + if [ -d "$HOME/.claude/plugins/cache/$MARKET_NAME" ]; then + rm -rf "$HOME/.claude/plugins/cache/$MARKET_NAME" && say " removed its plugin cache" + fi + if [ -f "$STATE" ] && grep -q '^disabled_prod=1' "$STATE"; then + claude plugin enable rogue@rogue-marketplace --scope user >/dev/null 2>&1 \ + && say " re-enabled rogue@rogue-marketplace" + fi + if [ -f "$ENV_BACKUP" ]; then + mv "$ENV_BACKUP" "$ENV_FILE" && say " restored $ENV_FILE from the backup" + else + say " no backup to restore - $ENV_FILE still points at the local API." + say " Run /rogue:setup to write your real credentials back." + fi + rm -rf "$HOME/.rogue-localdev" + say " removed the staging directory" + say + say " RESTART Claude Code to unload the plugin." +} + +case "${1:-}" in + up) shift; cmd_up "$@" ;; + sync) cmd_sync ;; + status) cmd_status ;; + check) cmd_check ;; + probe) cmd_probe ;; + down) cmd_down ;; + *) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' ; exit 1 ;; +esac diff --git a/tests/test_hook_logs.ps1 b/tests/test_hook_logs.ps1 index b4d6284..1a28317 100644 --- a/tests/test_hook_logs.ps1 +++ b/tests/test_hook_logs.ps1 @@ -53,6 +53,7 @@ function Invoke-Probe { [hashtable]$Creds = @{}, [int]$SeedBytes = 0, [string]$SeedPrevious = '', + [string]$Surface = '', # override the dispatcher's $script:surface [switch]$NoUserProfile # prove the USERPROFILE -> $HOME fallback ) @@ -82,6 +83,10 @@ function Invoke-Probe { [System.Text.Encoding]::UTF8.GetBytes(($Creds | ConvertTo-Json -Compress))), '-SeedBytes', $SeedBytes) if ($SeedPrevious) { $argv += @('-SeedPrevious', $SeedPrevious) } + # Appended only when set: Windows PowerShell 5.1 DROPS an empty native-command + # argument, which would shift every later positional and is why the sibling + # arguments above are conditional too. + if ($Surface -ne '') { $argv += @('-Surface', $Surface) } $out = & (Get-Process -Id $PID).Path @argv 2>$null } finally { foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } @@ -180,7 +185,11 @@ foreach ($c in $cases) { $f = Invoke-Probe -Case $c -CaseHome $h # A UTC ISO-8601 second-precision stamp with no fractional part: the shipper's # line splitter and the backend's parser both key off it. - $want = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug) event=$($c.event) outcome=probe$" + # `surface=` is OPTIONAL, so the shape allows it and the dedicated section + # below pins which plugin emits which slug. Through the seam a single-surface + # plugin shows its constant while a per-session one is still empty, and both + # are well-formed lines. + $want = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug)( surface=[a-z_]+)? event=$($c.event) outcome=probe$" # The FIRST physical line, not $f['LAST']: a dispatcher that emitted a # leading blank line (or any preamble) would still put a well-formed record # last and pass. The probe fires one event, so line 0 IS that record. @@ -192,6 +201,61 @@ foreach ($c in $cases) { else { Pass "$($c.slug) log has no UTF-8 BOM (head: $($f['HEAD']))" } } +Write-Host "" +Write-Host "== the optional surface token" +# Each dispatcher's file-scope default is what a probe sees: a single-surface plugin +# hard-codes its slug, while one that detects per session leaves it empty until the +# MAIN BODY resolves it (below the seam, so never here). Both are asserted, then the +# emit shape is forced both ways with -Surface. +$surfaceDefaults = @{ + claude = ''; codex = ''; antigravity = '' # resolved per session in the main body + cursor = 'cursor'; copilot = 'github_copilot' +} +foreach ($c in $cases) { + $h = New-CaseHome "surface-$($c.slug)"; $homes += $h + $f = Invoke-Probe -Case $c -CaseHome $h + Check "$($c.slug) file-scope surface default" $surfaceDefaults[$c.slug] $f['SURFACE'] + + # A resolved surface lands directly after provider= and before event=, so a + # reader scanning for the next `key=` gets the whole value. + $f = Invoke-Probe -Case $c -CaseHome $h -Surface 'desktop' + $want = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug) surface=desktop event=$($c.event) outcome=probe$" + if (@([System.IO.File]::ReadAllLines($f['LOGFILE']))[-1] -match $want) { + Pass "$($c.slug) stamps surface= between provider= and event=" + } else { Fail "$($c.slug) surface placement [$($f['LAST'])]" } + + # An UNDETERMINED surface omits the whole token. `surface=` with nothing after + # it, or `surface=unknown`, would both be worse than saying nothing: a reader + # cannot tell either from a real value, and every line written by a version + # before this one has no token at all. + $f = Invoke-Probe -Case $c -CaseHome $h -Surface '' # see log_probe.ps1: 5.1 drops an empty argument + $wantBare = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug) event=$($c.event) outcome=probe$" + if (@([System.IO.File]::ReadAllLines($f['LOGFILE']))[-1] -match $wantBare) { + Pass "$($c.slug) omits the token when the surface is unknown" + } else { Fail "$($c.slug) emitted a placeholder surface [$($f['LAST'])]" } +} + +Write-Host "" +Write-Host "== claude's surface table is ONE table, shared with the heartbeat" +# The whole point of plugins/rogue/scripts/surface.ps1: hook.ps1 stamps the slug and +# heartbeat.ps1 sends the label, and a copy in each would eventually drift - leaving +# a log line and the roster row for one session naming different surfaces. Mirrors +# the sh half in tests/test_hook_logs.sh. +. (Join-Path $repo 'plugins/rogue/scripts/surface.ps1') +foreach ($row in @( + @{ ep = 'cli'; slug = 'cli'; label = 'Claude Code - CLI' }, + @{ ep = 'desktop'; slug = 'desktop'; label = 'Claude Code - Desktop' }, + @{ ep = 'cowork'; slug = 'cowork'; label = 'Claude Cowork' }, + @{ ep = 'CLI'; slug = 'cli'; label = 'Claude Code - CLI' }, + @{ ep = 'vscode-extension'; slug = 'cli'; label = 'Claude Code - CLI' }, + @{ ep = ''; slug = ''; label = 'Claude Code - CLI' } +)) { + $env:CLAUDE_CODE_ENTRYPOINT = $row.ep + Check "entrypoint '$($row.ep)' -> slug '$($row.slug)'" $row.slug ([string](Get-RogueSurfaceSlug)) + Check "entrypoint '$($row.ep)' -> label '$($row.label)'" $row.label ([string](Get-RogueSurfaceLabel)) +} +Remove-Item Env:CLAUDE_CODE_ENTRYPOINT -ErrorAction SilentlyContinue + Write-Host "" Write-Host "== rotation at the cap, including over an EXISTING .1" foreach ($c in $cases) { diff --git a/tests/test_hook_logs.sh b/tests/test_hook_logs.sh index 12e1da8..cf50702 100755 --- a/tests/test_hook_logs.sh +++ b/tests/test_hook_logs.sh @@ -98,27 +98,95 @@ for slug in $SLUGS; do check "$slug writes only ~/.rogue/logs/$slug.log" "/.rogue/logs/$slug.log " "$(tree_of "$home")" done +# The surface each dispatcher stamps on an UNCONFIGURED probe, i.e. with the +# harness's own environment. Empty means "no token on that line", which is a real +# expected value and not a gap: +# claude - CLAUDE_CODE_ENTRYPOINT is set by `fire` below, as the real client does +# codex - ROGUE_CODEX_SURFACE unset defaults to codex_cli, matching heartbeat.sh +# cursor / copilot / gemini - single-surface plugins, so a constant +# antigravity - resolved from the payload's transcriptPath, and the unconfigured +# path exits BEFORE stdin is read, so there is nothing to resolve from. The +# token is optional precisely so this line can simply omit it. +surface_for() { + case "$1" in + claude) echo cli ;; + codex) echo codex_cli ;; + cursor) echo cursor ;; + copilot) echo github_copilot ;; + gemini) echo gemini_cli ;; + antigravity) echo '' ;; + esac +} + echo -echo "== line format: ' provider= event= …'" +echo "== line format: ' provider= [surface=] event= …'" for slug in $SLUGS; do home="$TMPROOT/fmt-$slug"; mkdir -p "$home" ev=$(event_for "$slug") + sf=$(surface_for "$slug") fire "$slug" "$home" line=$(cat "$home/.rogue/logs/$slug.log" 2>/dev/null) # Timestamp must be a UTC ISO-8601 second-precision stamp, no fractional part: - # the shipper's line splitter and the backend's parser both key off it. + # the shipper's line splitter and the backend's parser both key off it. The + # surface token sits directly after provider= and before event=, so a reader + # scanning for the next `key=` finds the whole value. case "$line" in - [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z" provider=$slug event=$ev "*) - pass "$slug line is ' provider=$slug event=$ev …'" ;; + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z" provider=$slug${sf:+ surface=$sf} event=$ev "*) + pass "$slug line is ' provider=$slug${sf:+ surface=$sf} event=$ev …'" ;; *) fail "$slug line shape [$line]" ;; esac + # An undetermined surface omits the WHOLE token. `surface=` with nothing after + # it, or `surface=unknown`, would both be worse than saying nothing - a reader + # cannot tell either from a real value. + case "$line" in + *"surface="*) + if [ -n "$sf" ]; then pass "$slug stamped surface=$sf" + else fail "$slug emitted a surface token with no surface to report [$line]"; fi ;; + *) + if [ -n "$sf" ]; then fail "$slug lost its surface token [$line]" + else pass "$slug omits the token when the surface is unknown"; fi ;; + esac + case "$line" in + *surface=unknown*|*"surface= "*) fail "$slug wrote a placeholder surface [$line]" ;; + *) pass "$slug never writes a placeholder surface" ;; + esac case "$line" in *outcome=unconfigured*) pass "$slug logs outcome=unconfigured with no API key" ;; *) fail "$slug missing outcome=unconfigured [$line]" ;; esac done +echo +echo "== the surface token tracks the SURFACE, not the plugin" +# claude is the only plugin whose surface varies per session, and all three of its +# surfaces write to the SAME claude.log - which is the entire reason the token +# exists. The mapping is shared with heartbeat.sh (plugins/rogue/scripts/surface.sh), +# so these slugs and the roster labels cannot drift apart. +# Indexed, not named after the entrypoint: macOS is case-insensitive, so `cli` and +# `CLI` would share one sandbox and the second case would read the first's line. +_sfi=0 +for pair in 'cli:cli' 'desktop:desktop' 'cowork:cowork' 'vscode-extension:cli' 'CLI:cli'; do + ep=${pair%%:*}; want=${pair#*:} + _sfi=$((_sfi + 1)) + home="$TMPROOT/sf-$_sfi"; mkdir -p "$home" + ( cd "$REPO" || exit 1 + export HOME="$home" ROGUE_API_KEY='' ROGUE_LOG_FILE='' ROGUE_LOG_DIR='' ROGUE_LOG_MAX_BYTES='' + export CLAUDE_CODE_ENTRYPOINT="$ep" CLAUDE_PLUGIN_ROOT="$REPO/plugins/rogue" + printf '{}' | "$SH" "$REPO/plugins/rogue/scripts/hook.sh" PreToolUse >/dev/null 2>&1 ) + got=$(sed -n 's/.*provider=claude surface=\([a-z_]*\) event=.*/\1/p' "$home/.rogue/logs/claude.log" 2>/dev/null) + check "CLAUDE_CODE_ENTRYPOINT=$ep stamps surface=$want" "$want" "$got" +done + +# The heartbeat's roster label for the same entrypoint, from the same table. If +# these two ever disagree, a log line and the roster row for one session name +# different surfaces - worse than the line naming none. +for pair in 'cli:Claude Code - CLI' 'desktop:Claude Code - Desktop' 'cowork:Claude Cowork'; do + ep=${pair%%:*}; want=${pair#*:} + got=$(CLAUDE_CODE_ENTRYPOINT="$ep" "$SH" -c '. "$1"; rogue_surface_label' _ "$REPO/plugins/rogue/scripts/surface.sh" 2>/dev/null) + check "...and heartbeat reports \"$want\" for it" "$want" "$got" +done + echo echo "== ROGUE_LOG_DIR relocates, keeping the per-agent basename" for slug in $SLUGS; do diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index 9e2bb36..4078be9 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -66,6 +66,66 @@ 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) => { + 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("close", () => { + 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 }); + resolve({ out, lines }); + }); + child.stdin.end(payload ?? ""); + }); +} + +// One file per agent family means every surface of that family appends to the same +// gemini.log, and until this token nothing on the line said which one wrote it. +// Gemini has exactly ONE surface, so the value is a constant - but it must still +// appear, be spelled the way the heartbeat spells it, and sit where the sh and +// PowerShell dispatchers put it, or a reader cannot use one rule for all six. +test("every line carries surface=gemini_cli, between provider= and event=", async () => { + const { lines } = await runHookReadLog("BeforeTool", "{}", {}); + assert.equal(lines.length, 1); + assert.match( + lines[0], + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=gemini surface=gemini_cli event=BeforeTool outcome=unconfigured$/, + ); +}); + +test("the surface slug is what heartbeat.mjs reports as the roster agent", async () => { + // Two files, one vocabulary. If these drift, a log line and the roster row for + // the same install name different surfaces - worse than the line naming none. + const heartbeat = fs.readFileSync( + path.join(path.dirname(HOOK), "heartbeat.mjs"), + "utf8", + ); + assert.match(heartbeat, /agent:\s*"gemini_cli"/); + const hook = fs.readFileSync(HOOK, "utf8"); + assert.match(hook, /const SURFACE = "gemini_cli";/); +}); + +test("the token is emitted through the optional form, never as a placeholder", async () => { + // The other five dispatchers omit the whole token when they cannot determine a + // surface. Gemini always can, so the guard here is that the emit is written in + // that same conditional form rather than pasted into the template - a constant + // that is later made conditional must not start writing `surface=` or + // `surface=unknown`, both of which a reader cannot tell from a real value. + const hook = fs.readFileSync(HOOK, "utf8"); + assert.match(hook, /SURFACE \? ` surface=\$\{SURFACE\}` : ""/); + const { lines } = await runHookReadLog("BeforeTool", "{}", {}); + assert.doesNotMatch(lines[0], /surface=unknown|surface=(\s|$)/); +}); + test("no API key → fail-open {}", async () => { const out = await runHook("BeforeAgent", '{"prompt":"hi"}', {}); assert.equal(out, "{}"); diff --git a/tests/test_hook_sh_antigravity.sh b/tests/test_hook_sh_antigravity.sh index f43476c..176b06e 100755 --- a/tests/test_hook_sh_antigravity.sh +++ b/tests/test_hook_sh_antigravity.sh @@ -196,14 +196,20 @@ EOF chmod +x "$STAGE/scripts/hook.sh" "$STAGE/scripts/heartbeat.sh" # Run the staged hook.sh (PLUGIN_ROOT resolves to $STAGE) and echo its exit code. +# The hook log goes to a path OUTSIDE the per-run sandbox, so an assertion can read +# the line back after the run (the sandbox is deleted below). Truncated per call so +# each case reads only its own output. +LAST_LOG="$(mktemp -d)/antigravity.log" + run_staged() { local tmp_home rc tmp_home="$(mktemp -d)" cp "$ENV_FILE" "$tmp_home/.rogue-env" + : > "$LAST_LOG" set +e HOME="$tmp_home" \ ROGUE_API_KEY='' ROGUE_ACTOR_EMAIL='' ROGUE_ACTOR_NAME='' ROGUE_BASE_URL='' \ - ROGUE_LOG_FILE="$tmp_home/hook.log" \ + ROGUE_LOG_FILE="$LAST_LOG" \ "$SH" "$STAGE/scripts/hook.sh" "$1" <<< "$2" > "$OUT_FILE" rc=$? set -e @@ -243,6 +249,14 @@ for surface in antigravity_cli antigravity_ide antigravity; do assert_eq "$rc" "0" "$surface heartbeat exits 0" for _ in $(seq 1 30); do [ -s "$MARKER" ] && break; sleep 0.1; done assert_eq "$(cat "$MARKER" 2>/dev/null)" "$surface" "heartbeat is told the $surface surface" + # The SAME resolution stamps the log line. One value, two consumers: if these + # ever disagree, a line and the roster row for one session name different + # surfaces - worse than the line naming none. (The unconfigured path has no + # payload to resolve from and correctly emits no token at all; that case is + # covered in tests/test_hook_logs.sh.) + logged=$(sed -n 's/.*provider=antigravity surface=\([a-z_]*\) event=.*/\1/p' \ + "$LAST_LOG" 2>/dev/null | tail -1) + assert_eq "$logged" "$surface" "the log line is stamped surface=$surface" done rm -rf "$STAGE"