Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand All @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>.log` → `~/.rogue/logs/<slug>.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:
Expand Down
105 changes: 105 additions & 0 deletions docs/hook-log-format.md
Original file line number Diff line number Diff line change
@@ -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 `<key>=` 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.
10 changes: 9 additions & 1 deletion docs/log-shipping-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/antigravity/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.23
1.0.24
27 changes: 22 additions & 5 deletions plugins/antigravity/scripts/hook.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -865,7 +883,6 @@ function Invoke-Main {
# appended base64 blob.
Resolve-Subagent

Resolve-Surface
Add-StoreReadForEvent
Add-TranscriptTail
Add-CapabilityFlag
Expand Down
20 changes: 16 additions & 4 deletions plugins/antigravity/scripts/hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
EVENT="" # hook event name, from $1
PLUGIN_ROOT="" # <root> of this plugin, derived from $0
BODY="" # the hook payload, as received then enriched
SURFACE="" # antigravity | antigravity_ide | antigravity_cli, or empty
Comment thread
amos-qualifire marked this conversation as resolved.
URL="" # where to POST it
SUBAGENT_ID="" # set by reattribute_subagent when this event is a subagent's
SUBAGENT_NAME=""
Expand Down Expand Up @@ -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'; }

Expand Down Expand Up @@ -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
}
Expand All @@ -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"
Comment on lines -652 to +658

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this is correct

if [ "$_surface" = "antigravity_ide" ]; then
case "$EVENT" in
# The pending prompt, before the model call that would consume it.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 12 additions & 1 deletion plugins/codex/scripts/heartbeat.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }

Expand Down
13 changes: 10 additions & 3 deletions plugins/codex/scripts/heartbeat.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'; }
Expand Down
Loading
Loading