Skip to content
Merged
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
23 changes: 21 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ jobs:
steps:
- uses: actions/checkout@v4

# Pinned so the Gemini dispatcher is actually exercised: tests/test_hook_logs.sh
# SKIPS its gemini leg when `node` is missing, which would pass silently.
- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Validate JSON is well-formed
run: |
set -euo pipefail
Expand Down Expand Up @@ -72,6 +78,17 @@ jobs:
done < <(git ls-files 'plugins/**/scripts/*.sh' 'install.sh' 'scripts/*.sh')
[ "$fail" = 0 ] || exit 1

- name: Hook-log contract (sh + mjs dispatchers)
# The per-agent log file, line format and rotation policy are duplicated
# across six dispatchers (no shared library), so drift in one of them is
# otherwise invisible. Runs on the unconfigured path — no mock server, no
# network. Run twice: `dash` is what Claude Code actually invokes hooks
# with, `bash` is what a developer runs locally.
run: |
set -euo pipefail
SH=dash sh tests/test_hook_logs.sh
SH=bash bash tests/test_hook_logs.sh

- name: PowerShell scripts parse
# The .ps1 dispatchers are the Windows half of every plugin and cannot be
# run on a dev Mac (no pwsh) — this is the only gate that ever parses
Expand All @@ -92,9 +109,11 @@ jobs:
exit $bad'

- name: PowerShell unit tests
# Both load their dispatcher through the ROGUE_PS_LIB_ONLY seam, so the
# functions run on Linux even though the main body stands down there.
# All of these load their dispatcher through the ROGUE_PS_LIB_ONLY seam, so
# the functions run on Linux even though the main body stands down there.
run: |
set -euo pipefail
pwsh -NoProfile -File tests/test_hook_ps1.ps1
pwsh -NoProfile -File tests/test_hook_ps1_copilot.ps1
pwsh -NoProfile -File tests/test_hook_ps1_antigravity.ps1
pwsh -NoProfile -File tests/test_hook_logs.ps1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
32 changes: 31 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Mirrors the Claude plugin with deliberate differences:

### Cursor plugin (`plugins/cursor/`)
A near-verbatim port of `qualifire-dev/rogue-plugin-cursor`'s `plugins/rogue/` (keep it in sync — re-pull on upstream changes). Mirrors the Claude/Codex dual-dispatcher with Cursor-native wiring:
- **Logs like the other plugins.** It used to write nothing at all (only `ROGUE_DEBUG` stderr, which Cursor buries in its own per-session log and `/rogue:status` cannot read), so it had zero durable observability. Both dispatchers now append one line per invocation to `~/.rogue/logs/cursor.log` — same format, precedence and rotation as everyone else (see **The hook log**).
- **Dual dispatcher (sh + PowerShell), relay + ONE enrichment.** Each of the 18 Cursor events registers two `hooks.json` entries — `sh ./scripts/hook.sh <event>` (cwd-relative; Cursor runs hooks from the plugin root) and a PowerShell entry that loads `scripts/hook.ps1` via `$env:CURSOR_PLUGIN_ROOT`. Exactly one runs per machine (same arbitration as Claude). Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. `setup.sh` / `setup.ps1` write it.
- **File pre-image (`preToolUse` only).** The one thing the dispatchers add to the vendor payload: on `preToolUse` with `tool_name` Write/Edit and an absolute `file_path`, they read that file (still PRE-edit at that point) and append `"rogueFilePreImageB64"`, using the same jq-or-string-concat duality and fail-open rules as Copilot's `augment_with_agent_tag`. **Every file qualifies except recognized binary extensions** (`_is_binary_path` / `Test-RogueBinaryPath`: images, fonts, archives, media, compiled artifacts, office documents, databases), whose base64 is pure payload with no text to compare; an unknown extension counts as text. Budget for it: on a file write this roughly doubles the request body, since the payload already carries the post-edit content. Cursor's `preToolUse` carries the full post-edit content and no pre-edit state, so the payload alone cannot say what the edit changed. **A missing file yields an EMPTY pre-image, and that is the create signal** — no Cursor payload field distinguishes a create from an overwrite (`old_string` is `""` for any pure insertion). **Over ~256 KB it sends NO pre-image**, never a truncated one: a partial pre-image misrepresents the file's pre-edit state instead of admitting we don't know it. Multi-hunk edits need no special handling: Cursor emits one full cycle per hunk, so the file on disk is already the correct per-hunk baseline. Field extraction prefers `jq` (it understands nesting and unescaping; `file_path` sits under `tool_input`) and falls back to a text scan only when jq is absent.
- **Manifest is `.cursor-plugin/plugin.json`** (version is source of truth); the Cursor marketplace file is the repo-root `.cursor-plugin/marketplace.json` (source `./plugins/cursor`, plugin version must match plugin.json — enforced by `.github/workflows/validate.yml`), kept separate from `.claude-plugin/` and `.agents/plugins/`.
Expand Down Expand Up @@ -99,7 +100,7 @@ Every event registers two entries — an `sh` one and a PowerShell one — point
- Backgrounding (`( nohup ... & )`) only inside a single-quoted `sh -c '...'` wrapper — a single-quoted string is one inert token to PS 5.1, whereas a bare `&` is a parse error. Inside those single quotes use `"$CLAUDE_PLUGIN_ROOT"` (the env var, exported to every hook process), not the `${CLAUDE_PLUGIN_ROOT}` placeholder — sh doesn't expand placeholders inside single quotes.
- The `shell: "powershell"` hook field is NOT a platform gate: on a Mac without pwsh it throws a visible "no PowerShell executable found" error. Don't use it.

`scripts/hook.sh <EventName>` is the orchestrator: stands down under Git Bash, sources env files, fail-opens on missing API key, sources `scripts/actor.sh` for actor resolution and `scripts/install-id.sh` for install identity, POSTs stdin to `/api/v1/hooks/claude` with the `x-rogue-*` headers, parses the response for a block decision (log-only), and prints the API response to stdout. `hook.ps1` does the same on Windows. Logs every invocation to `$ROGUE_LOG_FILE` (default `~/.rogue/hook.log` / `%USERPROFILE%\.rogue\hook.log`); the logged `reason` is sanitized of control characters to prevent log forgery from server-controlled text.
`scripts/hook.sh <EventName>` is the orchestrator: stands down under Git Bash, sources env files, fail-opens on missing API key, sources `scripts/actor.sh` for actor resolution and `scripts/install-id.sh` for install identity, POSTs stdin to `/api/v1/hooks/claude` with the `x-rogue-*` headers, parses the response for a block decision (log-only), and prints the API response to stdout. `hook.ps1` does the same on Windows. Logs every invocation to `$ROGUE_LOG_FILE` (default `~/.rogue/logs/claude.log` / `%USERPROFILE%\.rogue\logs\claude.log`) — see **The hook log** below; the logged `reason` is sanitized of control characters to prevent log forgery from server-controlled text.

### Fleet-liveness headers (every plugin, every event)

Expand All @@ -114,6 +115,33 @@ Two rules follow, and both are load-bearing:

Older installs send none of the three; the backend falls back to refreshing whatever rows that actor already has, so a mixed fleet degrades rather than duplicating.

## The hook log

**One file per agent, under `~/.rogue/logs/`** (`%USERPROFILE%\.rogue\logs\` on Windows): `claude.log`, `codex.log`, `cursor.log`, `gemini.log`, `copilot.log`, `antigravity.log`. All six plugins share `~/.rogue`, so before this split a machine with several coding agents interleaved every dispatcher into one `hook.log` with no reliable way to tell whose line was whose — and only three of the six stamped a `provider=` token at all. Cursor wrote **nothing**; it now logs like the rest.

Line format, identical across all six dispatchers:

```
2026-08-11T11:26:16Z provider=claude event=PreToolUse outcome=unconfigured
```

- **`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:
- `hook.sh` resolves them *after* sourcing the files (antigravity: inside `load_env`).
- The PowerShell dispatchers resolve them in **`Initialize-Logging $creds`**, called *after* the credential map is built and *before* the API-key check (so an unconfigured install still logs `outcome=unconfigured`). The log vars are in each dispatcher's process-env override list so process env still wins. Reading `$env:ROGUE_LOG_DIR` directly at file scope — which is what this used to do — silently ignores every env file.
- `hook.mjs` calls `loadEnvFiles()` at **module load**, because the log destination is derived from it. `loadEnvFiles()` returns a merged object and deliberately does **not** mutate `process.env`, so anything reading `process.env` directly ignores the files. This was a real bug: Gemini ignored `ROGUE_LOG_DIR` from `~/.rogue-env`.
- `tests/test_hook_logs.{sh,ps1}` assert this — the sh half writes a real `~/.rogue-env`; the ps half asserts both the behaviour (`Initialize-Logging` honors the map) and the wiring (log vars listed, init called with an argument, after the creds map).
- **Rotation** is size-capped at `ROGUE_LOG_MAX_BYTES` (default 10 MiB). Over the cap the current file is renamed to `<file>.1` — exactly one generation kept, so worst case on disk is 2x the cap per agent. Semantics are identical in all three languages and each has a trap worth knowing:
- **A numeric zero disables rotation; a non-numeric value falls back to the default** (a typo must never leave a log growing unbounded). Zero-padding counts as zero: sh uses `[ "$cap" -gt 0 ]` (an earlier `case` glob matched only a bare `0`, so `00` read as positive and rotated on *every* write), PowerShell `[int64]'00'`, Node `Number("00")`.
- **A value too wide for a 64-bit integer also falls back to the default**, and each language got there differently: sh clamps at 18 digits because dash answers `[ "$cap" -gt 0 ]` with `Illegal number` on stderr and a FALSE (rotation off, log unbounded), Node uses `Number.isSafeInteger` because `Number()` yields `Infinity` (same outcome), and PowerShell uses `[int64]::TryParse` because the plain cast errors — harmlessly, since `$ErrorActionPreference = 'SilentlyContinue'` swallows it and the default survives, but by accident rather than by design.
- It lives **inside `log()`, on the write path**, not in a periodic job: an unconfigured install writes `outcome=unconfigured` once per event and never runs anything else, so a cap enforced anywhere else would not hold.
- Size is read with `wc -c`, not `stat` (BSD and GNU take different flags).
- PowerShell deletes `<file>.1` **before** the `Move-Item`: `-Force` onto an existing destination is unreliable on Windows PowerShell 5.1, and under `-ErrorAction SilentlyContinue` a failure there would silently stop all further rotation.
- **The PowerShell dispatchers write with `[System.IO.File]::AppendAllText` and an explicit BOM-less UTF-8, never `Add-Content -Encoding UTF8`** — on Windows PowerShell 5.1 that switch emits a UTF-8 BOM when it creates the file, so the first line of every new *and every rotated* log would start with `EF BB BF` and break any parser anchored on the timestamp. They also write `` `n `` (LF), matching the sh dispatchers, so one line format covers both platforms. Both test halves assert the first bytes are not a BOM.
- **The log is owner-only — 0700 dir, 0600 file.** The line carries the server's block reason, which quotes the content that tripped the rule (a secret, a command, a slice of a prompt), so a default-umask 0644 log would hand it to every other account on the box. The sh dispatchers wrap `log()` in `( umask 077 … )`; `hook.mjs` passes `mode` to `mkdirSync`/`appendFileSync`. Both only affect what that call **creates** — a 0644 log written by an older version keeps its mode. There is no PowerShell counterpart on purpose: another standard user cannot read `%USERPROFILE%` to begin with.
- Everything logged is best-effort and wrapped: a full disk, a permission error, or a Windows file-sharing violation is swallowed. **Logging never affects the hook's decision or exit code.**

### Exactly-one-runs (cross-platform arbitration)

Claude Code runs **all** entries for an event (each under the platform's hook shell — see the polyglot rules above). The two entries are arranged so exactly one does real work per machine, and the other exits 0 silently:
Expand Down Expand Up @@ -154,4 +182,6 @@ Invariants to preserve when editing hooks (apply to **both** dispatchers — kee
- The `SessionStart` event has **four separate hook groups** (auto-update kick-off, heartbeat kick-off, unconfigured-warning, API POST). They run independently so a failure in one doesn't suppress the others. auto-update/heartbeat are detached (`sh -c '( nohup ... & )'` on sh; `Start-Process -WindowStyle Hidden` on PowerShell) with short timeouts — the hook returns immediately. Don't "fix" the short timeout.
- Release tarballs deliberately omit BOTH the version and an OS suffix from the filename, so `/releases/latest/download/rogue-plugin-claude.tar.gz` stays stable. The package is cross-platform by content (ships `.sh` and `.ps1`).
- `install.sh` / `install.ps1` install via the Claude CLI marketplace (git clone), NOT by downloading the tarball. The tarball exists for `compile-customer-plugin.sh` (MDM bundles).
- Log rotation is checked on **every** `log()` call rather than by a scheduled job, which looks wasteful (one `wc -c` per hook). It is the only placement that holds: an unconfigured install runs nothing but the dispatcher, and that dispatcher logs a line per event.
- The hook log's `provider=` token intentionally differs from the heartbeat's `agent_family`/`agent` for Codex, Gemini and Copilot. It tracks the log **file name**, not the server's roster vocabulary — see **The hook log**.
- `rgx!` prompt prefix is a server-side convention (false-positive escape hatch). The plugin itself doesn't parse it — the API does.
12 changes: 9 additions & 3 deletions plugins/antigravity/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,15 @@ Three orderings inside `main` are load-bearing; keep them if you touch it:
must emit nothing before any env sourcing, actor resolution or POST, or a
machine running both handlers double-POSTs and double-decides.
2. **Env sourcing precedes every default derived from it.** `load_env` computes
`ROGUE_LOG_FILE`, `DB_PROMPT_MODE`, `MISS_DIR`, `BRAIN_DIR`, `SUBMAP_DIR` and
the URL *after* reading the env files. Hoisting any of them back to file scope
silently freezes the built-in default and ignores the user's `~/.rogue-env`.
`ROGUE_LOG_DIR`, `ROGUE_LOG_FILE`, `ROGUE_LOG_MAX_BYTES`, `DB_PROMPT_MODE`,
`MISS_DIR`, `BRAIN_DIR`, `SUBMAP_DIR` and the URL *after* reading the env
files. Hoisting any of them back to file scope silently freezes the built-in
default and ignores the user's `~/.rogue-env`. `hook.ps1` mirrors this: its
`Invoke-Main` runs **`Import-Credentials` before `Initialize-Logging $script:creds`**
so the same env files can relocate the log on Windows, and both still precede
`Assert-ApiKey` so an unconfigured machine records `outcome=unconfigured`.
Swapping those two back would silently ignore every env file. See the root
`CLAUDE.md` section "The hook log".
3. **The API-key check precedes reading stdin.** An unconfigured machine exits
without consuming the payload.

Expand Down
6 changes: 5 additions & 1 deletion plugins/antigravity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ Upgrades: re-run the one-line installer.
## Verify

Run `/status`. You should see HTTP 200 against the ping endpoint, your active
rulesets, and a tail of recent hook activity (`~/.rogue/hook.log`).
rulesets, and a tail of recent hook activity (`~/.rogue/logs/antigravity.log` —
each Rogue plugin logs to its own file, capped at 10 MiB with one `.1` rotation
kept). `ROGUE_LOG_MAX_BYTES` overrides that cap and `0` turns rotation off;
`ROGUE_LOG_FILE` / `ROGUE_LOG_DIR` relocate the log. All three are read from
`~/.rogue-env` (or `/etc/rogue/env`), with the process environment winning.

## Uninstall

Expand Down
Loading
Loading