From 61e52a6dfa1dbd19f4225958570231eae910dff4 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Mon, 29 Jun 2026 16:53:51 +0300 Subject: [PATCH 1/4] feat(cursor): add Cursor as a third agent to the multi-agent installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect Cursor and install the Rogue plugin into it alongside Claude and Codex from the same one-liner. Cursor has no plugin CLI, so unlike claude/codex (native marketplace CLIs), the install is a file copy into ~/.cursor/plugins/local/rogue from the release tarball — mirroring the cursor repo's own installer. - plugins/cursor/: port of rogue-plugin-cursor (python3 pure-relay dispatcher, /api/v1/hooks/cursor, x-rogue-source: cursor). Drops the ported auto-update.sh (Cursor Team Marketplace is the native update path) and strips the /tmp debug-log block from rogue-hook.py. - .cursor-plugin/marketplace.json: Team-Marketplace import target. - install.sh / install.ps1: cursor detection (cursor on PATH or ~/.cursor), download+extract+copy install, python3 runtime warn. Windows included. - scripts/build-release.sh: emit versionless rogue-plugin-cursor.tar.gz. - README.md / CLAUDE.md: document the third agent and the copy-vs-CLI asymmetry. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RLk1XJQ1uWYSdn5Ro31JHv --- .cursor-plugin/marketplace.json | 25 +++ CLAUDE.md | 13 +- README.md | 17 +- install.ps1 | 47 ++++- install.sh | 73 +++++++- plugins/cursor/.cursor-plugin/plugin.json | 10 + plugins/cursor/commands/setup.md | 49 +++++ plugins/cursor/commands/status.md | 52 ++++++ plugins/cursor/hooks/hooks.json | 113 ++++++++++++ plugins/cursor/scripts/rogue-hook.py | 213 ++++++++++++++++++++++ plugins/cursor/scripts/setup.sh | 26 +++ scripts/build-release.sh | 28 +++ 12 files changed, 647 insertions(+), 19 deletions(-) create mode 100644 .cursor-plugin/marketplace.json create mode 100644 plugins/cursor/.cursor-plugin/plugin.json create mode 100644 plugins/cursor/commands/setup.md create mode 100644 plugins/cursor/commands/status.md create mode 100644 plugins/cursor/hooks/hooks.json create mode 100755 plugins/cursor/scripts/rogue-hook.py create mode 100755 plugins/cursor/scripts/setup.sh diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..22543fa --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/cursor-marketplace.json", + "name": "rogue-marketplace", + "version": "1.0.0", + "description": "Rogue Security extensions for Cursor", + "owner": { + "name": "Rogue Security", + "email": "support@rogue.security", + "url": "https://www.rogue.security" + }, + "plugins": [ + { + "name": "rogue-security", + "version": "1.0.1", + "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", + "author": { + "name": "Rogue Security", + "url": "https://www.rogue.security" + }, + "homepage": "https://docs.rogue.security/integrations/cursor", + "category": "security", + "source": "./plugins/cursor" + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index 9ef8ed3..4e9bdeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ There is no build step for the plugin itself: it's a directory of JSON + shell s **Cross-platform by dual dispatcher.** Every event ships TWO implementations — a POSIX-`sh` script (`hook.sh` & friends) for macOS/Linux/WSL and a PowerShell sibling (`hook.ps1` & friends) for **native Windows (no WSL, no Git Bash)**. `hooks.json` registers an `sh` entry and a PowerShell entry for each event; exactly one does real work per machine (see "The hook pattern"). When you change one dispatcher's behavior, change the other to match — keep `hook.sh` / `hook.ps1` in lockstep. -**This repo is now a multi-agent monorepo.** Besides the Claude plugin (`plugins/rogue/`, endpoint `/hooks/claude`) it also ships the **OpenAI Codex** plugin (`plugins/codex/`, endpoint `/hooks/openai`, family `openai`, surface `codex_cli`/`codex_app`). The one-line installer (`install.sh` / `install.ps1`) detects **every** supported agent on PATH (`claude`, `codex`) and installs the matching Rogue plugin into each, writing the shared `~/.rogue-env` once. +**This repo is now a multi-agent monorepo.** Besides the Claude plugin (`plugins/rogue/`, endpoint `/hooks/claude`) it also ships the **OpenAI Codex** plugin (`plugins/codex/`, endpoint `/hooks/openai`, family `openai`, surface `codex_cli`/`codex_app`) and the **Cursor** plugin (`plugins/cursor/`, endpoint `/hooks/cursor`, header `x-rogue-source: cursor`). The one-line installer (`install.sh` / `install.ps1`) detects **every** supported agent (`claude`, `codex`, `cursor`) and installs the matching Rogue plugin into each, writing the shared `~/.rogue-env` once. ### Codex plugin (`plugins/codex/`) Mirrors the Claude plugin with deliberate differences: @@ -21,12 +21,19 @@ Mirrors the Claude plugin with deliberate differences: - No `CLAUDE_CODE_ENTRYPOINT` gate (Codex doesn't set it; the hook only ever fires from Codex's own `hooks.json`). - **Hook trust**: Codex hashes the whole hook definition and skips untrusted command hooks until reviewed via `/hooks`. Keep `hooks.json` command strings (POSIX `command` + Windows `commandWindows`) **byte-identical forever**; mutate only `scripts/*` so trust survives updates. Setup/status commands document the one-time `/hooks` trust step. +### Cursor plugin (`plugins/cursor/`) +Mirrors the others with deliberate differences: +- **Single Python dispatcher.** `scripts/rogue-hook.py` is a PURE RELAY for all 18 Cursor events (`python3 ./scripts/rogue-hook.py `, paths cwd-relative — Cursor runs hooks from the plugin root). No `.sh`/`.ps1` pair: python3 is the cross-platform runtime. Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. +- **Manifest is `.cursor-plugin/plugin.json`**; the Cursor marketplace file is the repo-root `.cursor-plugin/marketplace.json` (source `./plugins/cursor`), kept separate from `.claude-plugin/` and `.agents/plugins/`. +- **No `auto-update.sh`.** The Cursor **Team Marketplace** (admin imports the repo via Dashboard) IS Cursor's native managed/auto-update path — we don't ship a script. Per-developer one-liner installs upgrade by re-running the installer. (When porting from `rogue-plugin-cursor`, the `auto-update.sh` sessionStart entry and the script are intentionally dropped, and the `/tmp/rogue-cursor-plugin-test.txt` debug-log block is stripped from `rogue-hook.py`.) +- **`commands/{setup,status}.md`**, not `skills/` — Cursor's slash-command format. + ### Multi-agent install (`install.sh` / `install.ps1`) -The single one-line installer detects every supported agent on PATH (`have_cmd claude` / `have_cmd codex`; PowerShell uses `Get-Command`), writes the shared `~/.rogue-env` **once** (`configure_credentials`), then runs each agent's marketplace+plugin install (`claude plugin install` / `codex plugin install rogue@rogue-marketplace` against the same monorepo). Adding an agent = one detect line + one `*_install_plugin` function. Codex prints the one-time `/hooks` trust reminder. +The single one-line installer detects every supported agent (`have_cmd claude` / `have_cmd codex` / `have_cmd cursor || [ -d ~/.cursor ]`; PowerShell uses `Get-Command` / `Test-Path`), writes the shared `~/.rogue-env` **once** (`configure_credentials`), then installs each. **Claude and Codex use their native plugin CLIs** (`claude plugin install` / `codex plugin add rogue@rogue-marketplace` against the same monorepo — git-clones the marketplace, no local files). **Cursor has NO plugin CLI** — `cursor_install_plugin` downloads the release tarball (`rogue-plugin-cursor.tar.gz`) and copies `plugins/cursor/` into `~/.cursor/plugins/local/rogue` (`%USERPROFILE%\.cursor\plugins\local\rogue` on Windows). This copy-vs-CLI asymmetry is load-bearing — preserve it. Adding a CLI agent = one detect line + one `*_install_plugin` function; Codex prints the one-time `/hooks` trust reminder, Cursor warns if `python3` is absent. ## Repo layout (load-bearing pieces) -- `.claude-plugin/marketplace.json` — marketplace manifest. Points at `./plugins/rogue`. +- `.claude-plugin/marketplace.json` — Claude marketplace manifest. Points at `./plugins/rogue`. (`.agents/plugins/marketplace.json` → `./plugins/codex`; `.cursor-plugin/marketplace.json` → `./plugins/cursor`, the Cursor Team-Marketplace import target.) - `plugins/rogue/.claude-plugin/plugin.json` — plugin manifest. **`version` here is the source of truth** — `build-release.sh` reads it, and `auto-update.sh` compares it against the latest GitHub release tag (`v${version}`). - `plugins/rogue/hooks/hooks.json` — 11 lifecycle hooks, all `type: "command"`. **Every event registers two entries** (an `sh` one and a PowerShell one) — see below. - `plugins/rogue/scripts/hook.sh` — POSIX-`sh` + `curl` dispatcher (macOS/Linux/WSL). Invoked via `sh` (NOT `bash`), so it is kept POSIX-clean (tested under `dash` via `tests/test_hook_sh.sh`). **Stands down** (emits `{}`, exits) under Git Bash (`uname` = MINGW/MSYS/CYGWIN) so the PowerShell entry owns native Windows. diff --git a/README.md b/README.md index f8b51b7..738d5cf 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,18 @@ Pass credentials via environment variables before the one-liner when running non $env:ROGUE_API_KEY='rsk_xxx'; $env:ROGUE_ACTOR_EMAIL='you@co.com'; iwr -useb https://raw.githubusercontent.com/qualifire-dev/rogue-plugins/main/install.ps1 | iex ``` -The installer adds the marketplace and installs the plugin via the Claude CLI -(`claude plugin marketplace add` + `claude plugin install`), validates and writes -your API key to `~/.rogue-env` (`%USERPROFILE%\.rogue-env` on Windows), and -confirms your actor identity. On macOS/Linux it also configures a `Rogue Security` -status badge below the prompt (🟢 connected / 🔴 not set up). +The one installer detects every supported coding agent and installs the matching +Rogue plugin into each — **Claude Code**, **OpenAI Codex**, and **Cursor** — writing +the shared `~/.rogue-env` (`%USERPROFILE%\.rogue-env` on Windows) once. Claude and +Codex install through their native plugin CLIs (`claude plugin install` / +`codex plugin add`); **Cursor has no plugin CLI**, so its plugin is copied into +`~/.cursor/plugins/local/rogue` from the release tarball. The installer validates and +writes your API key, confirms your actor identity, and on macOS/Linux configures a +`Rogue Security` status badge below the Claude prompt (🟢 connected / 🔴 not set up). + +For org-wide Cursor rollout, import this repo as a Cursor **Team Marketplace** +(Dashboard → marketplaces) — that is Cursor's native managed/auto-update path, separate +from the per-developer one-liner above. Native Windows support requires no WSL or Git Bash: every hook ships both a POSIX `sh` script and a PowerShell sibling, and exactly one runs per machine. diff --git a/install.ps1 b/install.ps1 index eb80733..f40682a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -70,11 +70,13 @@ try { Write-Host "" Write-Host "Rogue Security (Windows)" -ForegroundColor Cyan -# Detect every supported agent on PATH; at least one is required. +# Detect every supported agent; at least one is required. claude/codex ship a CLI +# on PATH; Cursor's `cursor` command is opt-in, so also accept %USERPROFILE%\.cursor. $hasClaude = [bool](Get-Command claude -ErrorAction SilentlyContinue) $hasCodex = [bool](Get-Command codex -ErrorAction SilentlyContinue) -if (-not ($hasClaude -or $hasCodex)) { - Die "No supported coding agent found on PATH (looked for: claude, codex). Install Claude Code (https://claude.com/code) or OpenAI Codex first." +$hasCursor = [bool](Get-Command cursor -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.cursor')) +if (-not ($hasClaude -or $hasCodex -or $hasCursor)) { + Die "No supported coding agent found (looked for: claude, codex, cursor). Install Claude Code (https://claude.com/code), OpenAI Codex, or Cursor (https://cursor.com) first." } # Claude shells out to git to clone the marketplace; git is required only for it. if ($hasClaude -and -not (Get-Command git -ErrorAction SilentlyContinue)) { @@ -216,6 +218,45 @@ if ($hasCodex) { Warn2 'Codex skips untrusted hooks - open /hooks in Codex and trust the Rogue entries once.' } +# Cursor has no plugin CLI: install is a file copy into +# %USERPROFILE%\.cursor\plugins\local\rogue. Download the release tarball, extract +# with `tar` (bundled in Windows 10+), and copy plugins\cursor into place. The Team +# Marketplace is the separate, admin-driven managed path; this does not touch it. +if ($hasCursor) { + Write-Host "" + Write-Host "Rogue Security - Cursor" -ForegroundColor Cyan + if (-not (Get-Command python -ErrorAction SilentlyContinue) -and -not (Get-Command python3 -ErrorAction SilentlyContinue)) { + Warn2 "python not found - the Cursor plugin's hooks need it at runtime. Install Python to activate Rogue in Cursor." + } + $asset = 'rogue-plugin-cursor.tar.gz' + if ($env:ROGUE_PLUGIN_VERSION) { + $url = "https://github.com/$PluginRepo/releases/download/$($env:ROGUE_PLUGIN_VERSION)/$asset" + } else { + $url = "https://github.com/$PluginRepo/releases/latest/download/$asset" + } + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("rogue-cursor-" + [System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tmp -Force | Out-Null + try { + Log "Downloading plugin $asset" + $tarball = Join-Path $tmp 'p.tar.gz' + Invoke-WebRequest -Uri $url -OutFile $tarball -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + & tar -xzf $tarball -C $tmp + if ($LASTEXITCODE -ne 0) { Die "Could not extract the Cursor plugin tarball (is 'tar' available?)." } + $src = Get-ChildItem -Path $tmp -Recurse -Directory -Filter 'cursor' | + Where-Object { Test-Path (Join-Path $_.FullName '.cursor-plugin\plugin.json') } | + Select-Object -First 1 + if (-not $src) { Die "Cursor plugin manifest missing in download." } + $dest = Join-Path $env:USERPROFILE '.cursor\plugins\local\rogue' + if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } + New-Item -ItemType Directory -Path $dest -Force | Out-Null + Copy-Item -Recurse -Force (Join-Path $src.FullName '*') $dest + Ok "Plugin installed -> $dest" + Warn2 'Fully quit and reopen Cursor, then run /rogue:status to verify.' + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } +} + Write-Host @" v Rogue Security installed. diff --git a/install.sh b/install.sh index f28dfda..fdc01f1 100755 --- a/install.sh +++ b/install.sh @@ -96,12 +96,17 @@ have_cmd() { command -v "$1" >/dev/null 2>&1; } # main() detects each agent via `have_cmd ` and runs its installer. Add an # agent = one detect line in main() + one `_install_plugin` function. # -# id label detect installer -# ──────── ───────────── ───────────────── ────────────── -# claude Claude Code command:claude install_claude ← implemented -# codex Codex CLI command:codex install_codex ← implemented -# gemini Gemini CLI command:gemini install_gemini (not yet) -# cursor Cursor command:cursor install_cursor (not yet) +# id label detect installer +# ──────── ───────────── ──────────────────── ────────────── +# claude Claude Code command:claude install_claude ← implemented +# codex Codex CLI command:codex install_codex ← implemented +# cursor Cursor command:cursor|~/.cursor install_cursor ← implemented +# gemini Gemini CLI command:gemini install_gemini (not yet) +# +# Claude and Codex install via their native plugin CLIs (which git-clone the +# marketplace). Cursor has NO plugin CLI — install is a file copy into +# ~/.cursor/plugins/local/rogue. So install_cursor downloads the release tarball +# and copies the plugin tree (see cursor_install_plugin). # ── Marketplace + plugin install (Claude) ───────────────────────────────────── claude_install_plugin() { @@ -159,6 +164,46 @@ codex_install_plugin() { fi } +# ── File-copy install (Cursor) ──────────────────────────────────────────────── +# Cursor has no plugin CLI and no marketplace-add command — the only programmatic +# install is dropping the plugin tree into ~/.cursor/plugins/local/. So we +# download the release tarball, extract it, and copy plugins/cursor/ into place +# (mirrors rogue-plugin-cursor/install.sh). Re-running overwrites — safe upgrade. +# The Team Marketplace (.cursor-plugin/marketplace.json) is the separate, admin- +# driven managed/auto-update path; this one-liner does not touch it. +CURSOR_INSTALL_DIR="$HOME/.cursor/plugins/local/${PLUGIN_NAME}" +cursor_install_plugin() { + local tmp asset url src + asset="rogue-plugin-cursor.tar.gz" + if [ -n "${ROGUE_PLUGIN_VERSION:-}" ]; then + url="https://github.com/${ROGUE_PLUGIN_REPO}/releases/download/${ROGUE_PLUGIN_VERSION}/${asset}" + else + url="https://github.com/${ROGUE_PLUGIN_REPO}/releases/latest/download/${asset}" + fi + + tmp="$(mktemp -d)" || die "Could not create a temp dir for the Cursor download." + # shellcheck disable=SC2064 + trap "rm -rf '$tmp'" RETURN + + note "Downloading plugin ${C_DIM}${asset}${C_RESET}" + curl -fsSL --max-time 60 -o "$tmp/p.tar.gz" "$url" \ + || die "Download failed from $url" + mkdir -p "$tmp/extract" + tar -xzf "$tmp/p.tar.gz" -C "$tmp/extract" \ + || die "Could not extract the Cursor plugin tarball." + + # The tarball stages a top dir (rogue-plugin-cursor/) containing plugins/cursor/. + src="$(find "$tmp/extract" -type d -path '*/plugins/cursor' | head -1)" + [ -n "$src" ] && [ -f "$src/.cursor-plugin/plugin.json" ] \ + || die "Cursor plugin manifest missing in download." + + mkdir -p "$(dirname "$CURSOR_INSTALL_DIR")" + rm -rf "$CURSOR_INSTALL_DIR" + mkdir -p "$CURSOR_INSTALL_DIR" + cp -R "$src/." "$CURSOR_INSTALL_DIR/" + ok "Plugin installed → ${C_DIM}$CURSOR_INSTALL_DIR${C_RESET}" +} + # ── Credentials ─────────────────────────────────────────────────────────────── # Validate the key AND register this install via /api/v1/hooks/status (the same # heartbeat the SessionStart hook calls). Echoes the HTTP status code (empty on @@ -396,6 +441,15 @@ install_codex() { note "Codex skips untrusted hooks — open ${C_DIM}/hooks${C_RESET} in Codex and trust the Rogue entries once." } +install_cursor() { + printf '\n%sRogue Security%s — Cursor\n' "$C_TEAL" "$C_RESET" >&2 + # The Cursor hooks dispatch through python3; warn (don't die) if it's absent so + # other detected agents still install. tar/curl (used above) are assumed present. + have_cmd python3 || warn "python3 not found — the Cursor plugin's hooks need it at runtime. Install python3 to activate Rogue in Cursor." + cursor_install_plugin + note "Fully quit and reopen Cursor, then run ${C_DIM}/rogue:status${C_RESET} to verify." +} + # ── CLI flags ───────────────────────────────────────────────────────────────── # Accepts `--flag=value` and `--flag value`. Sets the same globals the env knobs # do, so the rest of the script is flag-agnostic. CLI flags override env vars. @@ -426,11 +480,13 @@ parse_args() { main() { parse_args "$@" - # Detect every supported agent on PATH. + # Detect every supported agent. claude/codex ship a CLI on PATH; Cursor's + # `cursor` shell command is opt-in, so also accept the presence of ~/.cursor. agents="" have_cmd claude && agents="$agents claude" have_cmd codex && agents="$agents codex" - [ -n "$agents" ] || die "No supported coding agent found on PATH (looked for: claude, codex). Install Claude Code (https://claude.com/code) or OpenAI Codex first." + { have_cmd cursor || [ -d "$HOME/.cursor" ]; } && agents="$agents cursor" + [ -n "$agents" ] || die "No supported coding agent found (looked for: claude, codex, cursor). Install Claude Code (https://claude.com/code), OpenAI Codex, or Cursor (https://cursor.com) first." # Credentials once — every plugin reads the shared ~/.rogue-env. configure_credentials @@ -439,6 +495,7 @@ main() { case "$a" in claude) install_claude ;; codex) install_codex ;; + cursor) install_cursor ;; esac done diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json new file mode 100644 index 0000000..6903d5d --- /dev/null +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "Rogue Security", + "version": "1.0.1", + "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", + "author": { + "name": "rogue-security", + "url": "https://www.rogue.security" + }, + "homepage": "https://docs.rogue.security/integrations/cursor" +} diff --git a/plugins/cursor/commands/setup.md b/plugins/cursor/commands/setup.md new file mode 100644 index 0000000..ea28d11 --- /dev/null +++ b/plugins/cursor/commands/setup.md @@ -0,0 +1,49 @@ +--- +name: setup +description: Set up Rogue Security AIDR integration — configure API key, detect identity, verify connection +--- + +# Rogue Security Setup + +Help the user set up their Rogue Security AIDR integration for Cursor. Follow these steps in order. + +## Step 1: Check existing configuration + +Check if `~/.rogue-env` exists: `test -f ~/.rogue-env && echo exists || echo missing`. + +If it exists, tell the user and ask if they want to reconfigure. If not, continue. + +## Step 2: Get the API key + +Ask the user for their Rogue Security API key (starts with `rsk_`). If they don't have one, direct them to https://app.rogue.security/settings/api-keys. + +## Step 3: Validate the key + +Run: +```bash +curl -s -o /dev/null -w "%{http_code}" -H "x-rogue-api-key: " https://api.rogue.security/api/v1/hooks/ping +``` +Expect `200`. If not, the key is invalid — ask the user to try again. + +## Step 4: Detect identity + +```bash +git config --global user.email +git config --global user.name +``` +Show what was detected and ask if it's correct. + +## Step 5: Store credentials + +```bash +bash "${CURSOR_PLUGIN_ROOT}/scripts/setup.sh" "" "" "" +``` + +## Step 6: Final instructions + +Tell the user: + +1. Credentials are stored in `~/.rogue-env` (mode 600). +2. **Restart Cursor** (close all windows, reopen) — hooks read credentials at session start. +3. Run `/rogue:status` to verify the connection. +4. AIDR dashboard: https://app.rogue.security/aidr diff --git a/plugins/cursor/commands/status.md b/plugins/cursor/commands/status.md new file mode 100644 index 0000000..f385d89 --- /dev/null +++ b/plugins/cursor/commands/status.md @@ -0,0 +1,52 @@ +--- +name: status +description: Check Rogue Security AIDR connection, active rulesets, and configuration +--- + +# Rogue Security Status + +Verify the current Rogue Security integration. Sources credentials in order: `/etc/rogue/env` (MDM), `~/.rogue-env` (per-user). + +## Step 1: Source credentials and report what was found + +```bash +[ -r /etc/rogue/env ] && . /etc/rogue/env && echo " /etc/rogue/env (MDM)" +[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" && echo " $HOME/.rogue-env (per-user)" +[ -n "$ROGUE_API_KEY" ] && echo "API key resolved: ...${ROGUE_API_KEY: -4}" || { echo "API key: not resolved"; } +``` + +If `ROGUE_API_KEY` is empty, stop and tell the user to run `/rogue:setup`. + +## Step 2: Ping the API + +```bash +. "$HOME/.rogue-env" 2>/dev/null; [ -r /etc/rogue/env ] && . /etc/rogue/env +curl -s -w "\n%{http_code}" -H "x-rogue-api-key: $ROGUE_API_KEY" \ + "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/ping" +``` + +## Step 3: Fetch active config + +```bash +. "$HOME/.rogue-env" 2>/dev/null; [ -r /etc/rogue/env ] && . /etc/rogue/env +curl -s -H "x-rogue-api-key: $ROGUE_API_KEY" \ + "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/config" +``` + +Parse the JSON and show: mode (enforce/monitor), fail-open setting, active rulesets. + +## Step 4: Show identity + +```bash +. "$HOME/.rogue-env" 2>/dev/null +echo "Actor email: ${ROGUE_ACTOR_EMAIL:-(unset)}" +echo "Actor name: ${ROGUE_ACTOR_NAME:-(unset)}" +``` + +## Step 5: Summary + +Combine credential sources, connection status, and identity into one clean summary. If everything looks good, confirm the integration is active. Block/allow/ask policy is managed server-side — direct the user to the dashboard to view or change it. + +## Step 6: False-positive escape hatch + +Tell the user: prepend `rgx!` to a prompt to allow it through and mark the previous detection as a false positive in the dashboard. diff --git a/plugins/cursor/hooks/hooks.json b/plugins/cursor/hooks/hooks.json new file mode 100644 index 0000000..15a8fa8 --- /dev/null +++ b/plugins/cursor/hooks/hooks.json @@ -0,0 +1,113 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "python3 ./scripts/rogue-hook.py sessionStart", + "timeout": 120 + } + ], + "sessionEnd": [ + { + "command": "python3 ./scripts/rogue-hook.py sessionEnd", + "timeout": 120 + } + ], + "beforeSubmitPrompt": [ + { + "command": "python3 ./scripts/rogue-hook.py beforeSubmitPrompt", + "timeout": 120 + } + ], + "preToolUse": [ + { + "command": "python3 ./scripts/rogue-hook.py preToolUse", + "timeout": 120 + } + ], + "postToolUse": [ + { + "command": "python3 ./scripts/rogue-hook.py postToolUse", + "timeout": 120 + } + ], + "postToolUseFailure": [ + { + "command": "python3 ./scripts/rogue-hook.py postToolUseFailure", + "timeout": 120 + } + ], + "beforeShellExecution": [ + { + "command": "python3 ./scripts/rogue-hook.py beforeShellExecution", + "timeout": 120 + } + ], + "afterShellExecution": [ + { + "command": "python3 ./scripts/rogue-hook.py afterShellExecution", + "timeout": 120 + } + ], + "beforeMCPExecution": [ + { + "command": "python3 ./scripts/rogue-hook.py beforeMCPExecution", + "timeout": 120 + } + ], + "afterMCPExecution": [ + { + "command": "python3 ./scripts/rogue-hook.py afterMCPExecution", + "timeout": 120 + } + ], + "beforeReadFile": [ + { + "command": "python3 ./scripts/rogue-hook.py beforeReadFile", + "timeout": 120 + } + ], + "afterFileEdit": [ + { + "command": "python3 ./scripts/rogue-hook.py afterFileEdit", + "timeout": 120 + } + ], + "afterAgentResponse": [ + { + "command": "python3 ./scripts/rogue-hook.py afterAgentResponse", + "timeout": 120 + } + ], + "afterAgentThought": [ + { + "command": "python3 ./scripts/rogue-hook.py afterAgentThought", + "timeout": 120 + } + ], + "subagentStart": [ + { + "command": "python3 ./scripts/rogue-hook.py subagentStart", + "timeout": 120 + } + ], + "subagentStop": [ + { + "command": "python3 ./scripts/rogue-hook.py subagentStop", + "timeout": 120 + } + ], + "stop": [ + { + "command": "python3 ./scripts/rogue-hook.py stop", + "timeout": 120 + } + ], + "preCompact": [ + { + "command": "python3 ./scripts/rogue-hook.py preCompact", + "timeout": 120 + } + ] + } +} diff --git a/plugins/cursor/scripts/rogue-hook.py b/plugins/cursor/scripts/rogue-hook.py new file mode 100755 index 0000000..9d3762d --- /dev/null +++ b/plugins/cursor/scripts/rogue-hook.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +Rogue Security hook dispatcher for Cursor. + +Pass-through: every hook in hooks.json calls + python3 rogue-hook.py + +The dispatcher reads the Cursor event payload from stdin, POSTs it to +the Rogue AIDR backend, and relays the server's response verbatim to +stdout. The server returns the exact Cursor-native output JSON for that +event (e.g. {permission, user_message} for preToolUse, {continue, +user_message} for beforeSubmitPrompt) — the dispatcher does not reshape. + +Credential resolution order (later wins, then process env wins over all): + 1. ${CURSOR_PLUGIN_ROOT}/env (baked into a compiled customer plugin) + 2. /etc/rogue/env (MDM-provisioned) + 3. ~/.rogue-env (user / installer-written) + +All policy decisions (allow/ask/deny) are made by the server based on its +own configuration. The dispatcher forwards no client-side preference. + +Fail-open: missing API key, network failure, non-200, empty body, or +malformed JSON all result in `{}` on stdout — Cursor must never block +because Rogue infrastructure is unavailable. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import socket +import subprocess +import sys +import urllib.error +import urllib.request + +DEFAULT_BASE_URL = "https://api.rogue.security" +TIMEOUT_SECONDS = 10 + +def _default_cred_files() -> tuple[str, ...]: + # The compiled-plugin env file lives at the plugin root. Prefer + # CURSOR_PLUGIN_ROOT (set by Cursor when invoking hooks); fall back to + # script-relative resolution for direct invocations. + plugin_root = os.environ.get("CURSOR_PLUGIN_ROOT") or os.path.dirname( + os.path.dirname(os.path.abspath(__file__)) + ) + return ( + os.path.join(plugin_root, "env"), + "/etc/rogue/env", + os.path.expanduser("~/.rogue-env"), + ) + + +_CRED_FILES = _default_cred_files() +_FORWARDED_ENV_VARS = ( + "ROGUE_API_KEY", + "ROGUE_ACTOR_EMAIL", + "ROGUE_ACTOR_NAME", + "ROGUE_BASE_URL", +) + + +def _load_creds() -> dict: + """Parse `export KEY=value` lines from each env file. Process env wins.""" + out: dict[str, str] = {} + for path in _CRED_FILES: + if not os.path.isfile(path): + continue + try: + with open(path) as f: + for line in f: + m = re.match(r"\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$", line) + if not m: + continue + key, val = m.group(1), m.group(2).strip() + try: + parsed = next(iter(shlex.split(val)), val) + except ValueError: + parsed = val + out[key] = parsed + except OSError: + pass + for k in _FORWARDED_ENV_VARS: + if os.environ.get(k): + out[k] = os.environ[k] + return out + + +def _run_cmd(cmd: list[str]) -> str: + try: + return subprocess.run( + cmd, capture_output=True, text=True, timeout=2 + ).stdout.strip() + except Exception: + return "" + + +def _git_config(key: str) -> str: + return _run_cmd(["git", "config", "--global", key]) + + +def _whoami() -> str: + """Username — $USER/$USERNAME first, then the `whoami` command.""" + return ( + os.environ.get("USER") + or os.environ.get("USERNAME") + or _run_cmd(["whoami"]) + ) + + +def _hostname() -> str: + """Hostname — socket.gethostname() first, then the `hostname` command.""" + return socket.gethostname() or _run_cmd(["hostname"]) + + +def _resolve_actor(creds: dict) -> tuple[str, str]: + """Fallback chain: explicit creds → git config → whoami/hostname.""" + name = ( + creds.get("ROGUE_ACTOR_NAME") + or _git_config("user.name") + or _whoami() + ) + email = creds.get("ROGUE_ACTOR_EMAIL") or _git_config("user.email") + if not email: + user, host = _whoami(), _hostname() + if user and host: + email = f"{user}@{host}" + else: + email = user or host + return email, name + + +def _post(url: str, headers: dict, body: bytes) -> bytes: + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as resp: + return resp.read() or b"" + except ( + urllib.error.URLError, + urllib.error.HTTPError, + socket.timeout, + ConnectionError, + OSError, + ): + return b"" + + +def _emit_bytes(data: bytes) -> None: + """Validate that data is JSON and relay verbatim. Otherwise emit `{}`.""" + if not data: + sys.stdout.write("{}") + sys.stdout.flush() + return + try: + json.loads(data) + except (ValueError, json.JSONDecodeError): + sys.stdout.write("{}") + sys.stdout.flush() + return + sys.stdout.write(data.decode("utf-8", errors="replace")) + sys.stdout.flush() + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + sys.stdout.write("{}") + sys.stdout.flush() + return 0 + event = argv[1] + + creds = _load_creds() + api_key = creds.get("ROGUE_API_KEY", "") + if not api_key: + if event == "sessionStart": + sys.stdout.write( + json.dumps( + { + "additional_context": "Rogue Security plugin is installed but not configured. " + "Run /rogue:setup to connect your API key." + } + ) + ) + else: + sys.stdout.write("{}") + sys.stdout.flush() + return 0 + + base_url = creds.get("ROGUE_BASE_URL", DEFAULT_BASE_URL).rstrip("/") + actor_email, actor_name = _resolve_actor(creds) + + try: + payload = sys.stdin.read() or "{}" + except Exception: + payload = "{}" + + headers = { + "Content-Type": "application/json", + "x-rogue-api-key": api_key, + "x-rogue-event": event, + "x-rogue-actor-email": actor_email, + "x-rogue-actor-name": actor_name, + "x-rogue-source": "cursor", + } + + url = f"{base_url}/api/v1/hooks/cursor" + _emit_bytes(_post(url, headers, payload.encode("utf-8"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/plugins/cursor/scripts/setup.sh b/plugins/cursor/scripts/setup.sh new file mode 100755 index 0000000..045584d --- /dev/null +++ b/plugins/cursor/scripts/setup.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Rogue Security — credential storage helper +# Writes ~/.rogue-env (mode 600). Sourced by the dispatcher at hook fire time. +# +# Usage: setup.sh +set -euo pipefail + +API_KEY="${1:?Usage: setup.sh }" +ACTOR_EMAIL="${2:-}" +ACTOR_NAME="${3:-}" + +ENV_FILE="${ROGUE_ENV_FILE:-$HOME/.rogue-env}" + +umask 077 +: > "$ENV_FILE" +{ + printf '# Managed by the rogue Cursor plugin. Read by hook subprocesses at runtime.\n' + printf '# Delete this file to revoke credentials.\n' + printf 'export ROGUE_API_KEY=%q\n' "$API_KEY" + printf 'export ROGUE_ACTOR_EMAIL=%q\n' "$ACTOR_EMAIL" + printf 'export ROGUE_ACTOR_NAME=%q\n' "$ACTOR_NAME" +} >> "$ENV_FILE" +chmod 600 "$ENV_FILE" + +echo "OK" +echo "ENV_FILE=$ENV_FILE" diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 3b83aa3..fafb821 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -77,6 +77,34 @@ if [ -d "plugins/codex" ]; then rm -rf "$CXSTAGE" fi +# ── Cursor plugin tarball ─────────────────────────────────────────────────── +# Cursor has no plugin CLI, so the one-line installer downloads THIS tarball and +# copies plugins/cursor/ into ~/.cursor/plugins/local/rogue. Versionless name keeps +# the /releases/latest/download/ URL stable. Cross-platform by content (the hook is +# python3). The Team Marketplace imports the repo directly, not this tarball. +if [ -d "plugins/cursor" ]; then + CURSOR_VERSION=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[0-9][^"]*"' \ + plugins/cursor/.cursor-plugin/plugin.json 2>/dev/null | head -1 \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') && [ -n "$CURSOR_VERSION" ] || { + echo "✗ unable to read plugins/cursor/.cursor-plugin/plugin.json" >&2; exit 1 + } + echo "→ cursor plugin version: $CURSOR_VERSION" + CRSTAGE=$(mktemp -d) + CRTOP="$CRSTAGE/rogue-plugin-cursor" + mkdir -p "$CRTOP/plugins" "$CRTOP/.cursor-plugin" + cp .cursor-plugin/marketplace.json "$CRTOP/.cursor-plugin/" + cp -R plugins/cursor "$CRTOP/plugins/" + cp README.md LICENSE "$CRTOP/" 2>/dev/null || true + CROUT="$DIST/rogue-plugin-cursor.tar.gz" + tar -czf "$CROUT" \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + -C "$CRSTAGE" "rogue-plugin-cursor" + CRSIZE=$(wc -c < "$CROUT" | awk '{print $1}') + echo "✓ $CROUT ($CRSIZE bytes, version $CURSOR_VERSION)" + rm -rf "$CRSTAGE" +fi + echo "" echo "dist/:" ls -la "$DIST" From 9bfbb7abb55d8dc66a585882b65b675fc0f4e84e Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Mon, 29 Jun 2026 16:59:47 +0300 Subject: [PATCH 2/4] feat(install): add --claude/--codex/--cursor agent-selection flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default the installer detects and installs for every supported agent. These flags restrict the run to an explicit set. A selected CLI agent (claude/codex) still requires its binary on PATH — the installer dies with a clear message naming the missing one, validating all selections before any install. Cursor is a plain file copy, so --cursor installs regardless of detection (useful to pre-provision before first launch). PowerShell mirrors with -Claude/-Codex/-Cursor switches. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RLk1XJQ1uWYSdn5Ro31JHv --- README.md | 7 +++++++ install.ps1 | 40 ++++++++++++++++++++++++++++++++-------- install.sh | 37 ++++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 738d5cf..3203ed5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ Codex install through their native plugin CLIs (`claude plugin install` / writes your API key, confirms your actor identity, and on macOS/Linux configures a `Rogue Security` status badge below the Claude prompt (🟢 connected / 🔴 not set up). +To target specific agents instead of all detected ones, pass `--claude`, `--codex`, +and/or `--cursor` (PowerShell: `-Claude` / `-Codex` / `-Cursor`): + +```bash +curl -fsSL https://raw.githubusercontent.com/qualifire-dev/rogue-plugins/main/install.sh | bash -s -- --cursor +``` + For org-wide Cursor rollout, import this repo as a Cursor **Team Marketplace** (Dashboard → marketplaces) — that is Cursor's native managed/auto-update path, separate from the per-developer one-liner above. diff --git a/install.ps1 b/install.ps1 index f40682a..69e01c6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -31,6 +31,12 @@ Marketplace source repo (default: qualifire-dev/rogue-plugins). .PARAMETER NonInteractive Fail / skip prompts rather than ask for missing values. +.PARAMETER Claude + Install only for Claude Code (combine with -Codex/-Cursor to pick a set). +.PARAMETER Codex + Install only for OpenAI Codex. +.PARAMETER Cursor + Install only for Cursor. With no agent switch, every detected agent is installed. #> [CmdletBinding()] param( @@ -39,7 +45,10 @@ param( [string]$Name, [string]$BaseUrl, [string]$PluginRepo, - [switch]$NonInteractive + [switch]$NonInteractive, + [switch]$Claude, + [switch]$Codex, + [switch]$Cursor ) $ErrorActionPreference = 'Stop' @@ -70,13 +79,28 @@ try { Write-Host "" Write-Host "Rogue Security (Windows)" -ForegroundColor Cyan -# Detect every supported agent; at least one is required. claude/codex ship a CLI -# on PATH; Cursor's `cursor` command is opt-in, so also accept %USERPROFILE%\.cursor. -$hasClaude = [bool](Get-Command claude -ErrorAction SilentlyContinue) -$hasCodex = [bool](Get-Command codex -ErrorAction SilentlyContinue) -$hasCursor = [bool](Get-Command cursor -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.cursor')) -if (-not ($hasClaude -or $hasCodex -or $hasCursor)) { - Die "No supported coding agent found (looked for: claude, codex, cursor). Install Claude Code (https://claude.com/code), OpenAI Codex, or Cursor (https://cursor.com) first." +# Agent selection. -Claude/-Codex/-Cursor pick an explicit set; with none, auto-detect +# every supported agent. claude/codex ship a CLI on PATH; Cursor's `cursor` command is +# opt-in, so detection also accepts %USERPROFILE%\.cursor. An explicitly selected CLI +# agent still needs its binary; Cursor is a plain file copy, so it installs regardless. +$explicit = $Claude -or $Codex -or $Cursor +if ($explicit) { + $hasClaude = [bool]$Claude + $hasCodex = [bool]$Codex + $hasCursor = [bool]$Cursor + if ($hasClaude -and -not (Get-Command claude -ErrorAction SilentlyContinue)) { + Die "-Claude requested but the 'claude' CLI is not on PATH. Install Claude Code (https://claude.com/code) first." + } + if ($hasCodex -and -not (Get-Command codex -ErrorAction SilentlyContinue)) { + Die "-Codex requested but the 'codex' CLI is not on PATH. Install OpenAI Codex first." + } +} else { + $hasClaude = [bool](Get-Command claude -ErrorAction SilentlyContinue) + $hasCodex = [bool](Get-Command codex -ErrorAction SilentlyContinue) + $hasCursor = [bool](Get-Command cursor -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.cursor')) + if (-not ($hasClaude -or $hasCodex -or $hasCursor)) { + Die "No supported coding agent found (looked for: claude, codex, cursor). Install Claude Code (https://claude.com/code), OpenAI Codex, or Cursor (https://cursor.com) first." + } } # Claude shells out to git to clone the marketplace; git is required only for it. if ($hasClaude -and -not (Get-Command git -ErrorAction SilentlyContinue)) { diff --git a/install.sh b/install.sh index fdc01f1..175ac62 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,10 @@ # CLI flags (equivalent to the env knobs; pass after `bash -s --`): # curl -fsSL .../install.sh | bash -s -- --api-key="rg_xxx" --non-interactive # +# --claude install only for Claude Code (repeatable with the others) +# --codex install only for OpenAI Codex +# --cursor install only for Cursor +# (no agent flag = auto-detect and install for every agent found) # --api-key=KEY same as ROGUE_API_KEY # --actor-email=EMAIL same as ROGUE_ACTOR_EMAIL # --actor-name=NAME same as ROGUE_ACTOR_NAME @@ -50,6 +54,8 @@ SETTINGS_PATH="$CONFIG_DIR/settings.json" ENV_FILE="${ROGUE_ENV_FILE:-$HOME/.rogue-env}" NON_INTERACTIVE="${ROGUE_NON_INTERACTIVE:-0}" +# Explicit agent selection via --claude/--codex/--cursor. Empty = auto-detect all. +WANT="" # Bind the controlling terminal to fd 3 once, so prompts work even under # `curl | bash` (where stdin is the script, not the keyboard). If /dev/tty # can't be opened (no terminal, or a sandbox that reports "Device not @@ -467,6 +473,9 @@ parse_args() { --actor-name) [ -n "$val" ] || { val="$2"; shift; }; ROGUE_ACTOR_NAME="$val" ;; --plugin-repo) [ -n "$val" ] || { val="$2"; shift; }; ROGUE_PLUGIN_REPO="$val" ;; --base-url) [ -n "$val" ] || { val="$2"; shift; }; ROGUE_BASE_URL="$val" ;; + --claude) WANT="$WANT claude" ;; + --codex) WANT="$WANT codex" ;; + --cursor) WANT="$WANT cursor" ;; --non-interactive) NON_INTERACTIVE=1 ;; --no-statusline) ROGUE_NO_STATUSLINE=1 ;; -h|--help) usage; exit 0 ;; @@ -480,13 +489,27 @@ parse_args() { main() { parse_args "$@" - # Detect every supported agent. claude/codex ship a CLI on PATH; Cursor's - # `cursor` shell command is opt-in, so also accept the presence of ~/.cursor. - agents="" - have_cmd claude && agents="$agents claude" - have_cmd codex && agents="$agents codex" - { have_cmd cursor || [ -d "$HOME/.cursor" ]; } && agents="$agents cursor" - [ -n "$agents" ] || die "No supported coding agent found (looked for: claude, codex, cursor). Install Claude Code (https://claude.com/code), OpenAI Codex, or Cursor (https://cursor.com) first." + if [ -n "$WANT" ]; then + # Explicit selection (--claude/--codex/--cursor): install exactly these. A CLI + # agent still needs its binary (can't add a marketplace without it); Cursor is a + # plain file copy, so it installs regardless of detection. + agents="$WANT" + for a in $agents; do + case "$a" in + claude) have_cmd claude || die "--claude requested but the 'claude' CLI is not on PATH. Install Claude Code (https://claude.com/code) first." ;; + codex) have_cmd codex || die "--codex requested but the 'codex' CLI is not on PATH. Install OpenAI Codex first." ;; + cursor) : ;; + esac + done + else + # Auto-detect every supported agent. claude/codex ship a CLI on PATH; Cursor's + # `cursor` shell command is opt-in, so also accept the presence of ~/.cursor. + agents="" + have_cmd claude && agents="$agents claude" + have_cmd codex && agents="$agents codex" + { have_cmd cursor || [ -d "$HOME/.cursor" ]; } && agents="$agents cursor" + [ -n "$agents" ] || die "No supported coding agent found (looked for: claude, codex, cursor). Install Claude Code (https://claude.com/code), OpenAI Codex, or Cursor (https://cursor.com) first." + fi # Credentials once — every plugin reads the shared ~/.rogue-env. configure_credentials From 147bad0f687b9a0130ca88607ae30cdf41d9fb25 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Mon, 29 Jun 2026 17:17:47 +0300 Subject: [PATCH 3/4] fix(cursor): re-sync plugin with upstream (sh+ps1 dispatchers), add validate CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream rogue-plugin-cursor moved off the single python3 dispatcher to the Claude/Codex dual-dispatcher model. Re-port v1.0.3: - Replace scripts/rogue-hook.py with hook.sh + hook.ps1 (PURE RELAY) and add setup.ps1. hooks.json now registers an `sh ./scripts/hook.sh ` entry and a PowerShell entry (loads hook.ps1 via $env:CURSOR_PLUGIN_ROOT) per event, exactly-one-runs per machine. Endpoint /api/v1/hooks/cursor, x-rogue-source cursor unchanged. - Bump plugin.json + marketplace.json to 1.0.3. - install.sh / install.ps1: drop the now-stale python3 runtime warning — the Cursor runtime is the same sh/PowerShell stack running the installer. - CLAUDE.md: document the dual dispatcher (was "single Python dispatcher"). Add .github/workflows/validate.yml: on push/PR, lint all JSON, verify each plugin's manifest version matches its marketplace entry (claude/codex/cursor), and sh -n the shell dispatchers. Ported + generalized from upstream's release-time version-sync check so drift is caught before tagging. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RLk1XJQ1uWYSdn5Ro31JHv --- .cursor-plugin/marketplace.json | 2 +- .github/workflows/validate.yml | 68 +++++ CLAUDE.md | 8 +- install.ps1 | 5 +- install.sh | 6 +- plugins/cursor/.cursor-plugin/plugin.json | 2 +- plugins/cursor/commands/setup.md | 20 +- plugins/cursor/hooks/hooks.json | 108 ++++++-- plugins/cursor/scripts/hook.ps1 | 324 ++++++++++++++++++++++ plugins/cursor/scripts/hook.sh | 174 ++++++++++++ plugins/cursor/scripts/rogue-hook.py | 213 -------------- plugins/cursor/scripts/setup.ps1 | 50 ++++ 12 files changed, 733 insertions(+), 247 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 plugins/cursor/scripts/hook.ps1 create mode 100755 plugins/cursor/scripts/hook.sh delete mode 100755 plugins/cursor/scripts/rogue-hook.py create mode 100644 plugins/cursor/scripts/setup.ps1 diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 22543fa..b2cd4ed 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue-security", - "version": "1.0.1", + "version": "1.0.3", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "Rogue Security", diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..7321fa6 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,68 @@ +name: Validate + +# Catches the failure mode release.yml can't: a plugin's manifest version drifting +# out of sync with its marketplace entry, or a malformed manifest/hooks file landing +# on main. Runs on every push/PR — fast, no secrets, no release side effects. +# (Ported from qualifire-dev/rogue-plugin-cursor's release-time version check and +# generalized to all three plugins in this monorepo.) + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + manifests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate JSON is well-formed + run: | + set -euo pipefail + fail=0 + while IFS= read -r f; do + if ! jq empty "$f" 2>/dev/null; then + echo "::error file=$f::invalid JSON" + fail=1 + fi + done < <(git ls-files '*.json') + [ "$fail" = 0 ] || exit 1 + + - name: Plugin version <-> marketplace version in sync + run: | + set -euo pipefail + # plugin_manifest | marketplace_manifest | source-path key + rows=" + plugins/rogue/.claude-plugin/plugin.json|.claude-plugin/marketplace.json|./plugins/rogue + plugins/codex/.codex-plugin/plugin.json|.agents/plugins/marketplace.json|./plugins/codex + plugins/cursor/.cursor-plugin/plugin.json|.cursor-plugin/marketplace.json|./plugins/cursor + " + fail=0 + echo "$rows" | while IFS='|' read -r plugin market src; do + [ -n "${plugin:-}" ] || continue + [ -f "$plugin" ] || { echo "::error::missing $plugin"; exit 1; } + [ -f "$market" ] || { echo "::error::missing $market"; exit 1; } + pv=$(jq -r '.version' "$plugin") + mv=$(jq -r --arg s "$src" '.plugins[] | select(.source == $s) | .version' "$market") + if [ -z "$mv" ] || [ "$mv" = "null" ]; then + echo "::error file=$market::no plugin entry with source $src"; exit 1 + fi + if [ "$pv" != "$mv" ]; then + echo "::error::version drift — $plugin ($pv) != $market entry $src ($mv)"; exit 1 + fi + echo "ok: $src @ $pv" + done + + - name: Shell dispatchers parse (POSIX sh) + run: | + set -euo pipefail + fail=0 + while IFS= read -r f; do + if ! sh -n "$f"; then echo "::error file=$f::sh parse error"; fail=1; fi + done < <(git ls-files 'plugins/**/scripts/*.sh' 'install.sh' 'scripts/*.sh') + [ "$fail" = 0 ] || exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 4e9bdeb..d245b61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,10 +22,10 @@ Mirrors the Claude plugin with deliberate differences: - **Hook trust**: Codex hashes the whole hook definition and skips untrusted command hooks until reviewed via `/hooks`. Keep `hooks.json` command strings (POSIX `command` + Windows `commandWindows`) **byte-identical forever**; mutate only `scripts/*` so trust survives updates. Setup/status commands document the one-time `/hooks` trust step. ### Cursor plugin (`plugins/cursor/`) -Mirrors the others with deliberate differences: -- **Single Python dispatcher.** `scripts/rogue-hook.py` is a PURE RELAY for all 18 Cursor events (`python3 ./scripts/rogue-hook.py `, paths cwd-relative — Cursor runs hooks from the plugin root). No `.sh`/`.ps1` pair: python3 is the cross-platform runtime. Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. -- **Manifest is `.cursor-plugin/plugin.json`**; the Cursor marketplace file is the repo-root `.cursor-plugin/marketplace.json` (source `./plugins/cursor`), kept separate from `.claude-plugin/` and `.agents/plugins/`. -- **No `auto-update.sh`.** The Cursor **Team Marketplace** (admin imports the repo via Dashboard) IS Cursor's native managed/auto-update path — we don't ship a script. Per-developer one-liner installs upgrade by re-running the installer. (When porting from `rogue-plugin-cursor`, the `auto-update.sh` sessionStart entry and the script are intentionally dropped, and the `/tmp/rogue-cursor-plugin-test.txt` debug-log block is stripped from `rogue-hook.py`.) +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: +- **Dual dispatcher (sh + PowerShell), PURE RELAY.** Each of the 18 Cursor events registers two `hooks.json` entries — `sh ./scripts/hook.sh ` (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. +- **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/`. +- **No `auto-update.sh`.** The Cursor **Team Marketplace** (admin imports the repo via Dashboard) IS Cursor's native managed/auto-update path — we don't ship a script. Per-developer one-liner installs upgrade by re-running the installer. - **`commands/{setup,status}.md`**, not `skills/` — Cursor's slash-command format. ### Multi-agent install (`install.sh` / `install.ps1`) diff --git a/install.ps1 b/install.ps1 index 69e01c6..766ec16 100644 --- a/install.ps1 +++ b/install.ps1 @@ -249,9 +249,8 @@ if ($hasCodex) { if ($hasCursor) { Write-Host "" Write-Host "Rogue Security - Cursor" -ForegroundColor Cyan - if (-not (Get-Command python -ErrorAction SilentlyContinue) -and -not (Get-Command python3 -ErrorAction SilentlyContinue)) { - Warn2 "python not found - the Cursor plugin's hooks need it at runtime. Install Python to activate Rogue in Cursor." - } + # Cursor ships dual dispatchers (sh + PowerShell) like Claude/Codex; the runtime + # is the same shell stack, so no extra prerequisite check beyond tar (below). $asset = 'rogue-plugin-cursor.tar.gz' if ($env:ROGUE_PLUGIN_VERSION) { $url = "https://github.com/$PluginRepo/releases/download/$($env:ROGUE_PLUGIN_VERSION)/$asset" diff --git a/install.sh b/install.sh index 175ac62..f0e20d0 100755 --- a/install.sh +++ b/install.sh @@ -449,9 +449,9 @@ install_codex() { install_cursor() { printf '\n%sRogue Security%s — Cursor\n' "$C_TEAL" "$C_RESET" >&2 - # The Cursor hooks dispatch through python3; warn (don't die) if it's absent so - # other detected agents still install. tar/curl (used above) are assumed present. - have_cmd python3 || warn "python3 not found — the Cursor plugin's hooks need it at runtime. Install python3 to activate Rogue in Cursor." + # The Cursor plugin ships dual dispatchers (sh + PowerShell) like Claude/Codex — + # the matching runtime is the same shell running this installer, so no extra + # prerequisite check. tar/curl (used in cursor_install_plugin) are assumed present. cursor_install_plugin note "Fully quit and reopen Cursor, then run ${C_DIM}/rogue:status${C_RESET} to verify." } diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index 6903d5d..f57e7c4 100644 --- a/plugins/cursor/.cursor-plugin/plugin.json +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "Rogue Security", - "version": "1.0.1", + "version": "1.0.3", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "rogue-security", diff --git a/plugins/cursor/commands/setup.md b/plugins/cursor/commands/setup.md index ea28d11..c2eb5cf 100644 --- a/plugins/cursor/commands/setup.md +++ b/plugins/cursor/commands/setup.md @@ -7,9 +7,12 @@ description: Set up Rogue Security AIDR integration — configure API key, detec Help the user set up their Rogue Security AIDR integration for Cursor. Follow these steps in order. +**Pick the command variant for the user's OS.** Use the **macOS / Linux (bash)** commands by default; use the **Windows (PowerShell)** commands when the user is on native Windows (no WSL). On Windows, `${CURSOR_PLUGIN_ROOT}` is available as the `$env:CURSOR_PLUGIN_ROOT` environment variable. + ## Step 1: Check existing configuration -Check if `~/.rogue-env` exists: `test -f ~/.rogue-env && echo exists || echo missing`. +- macOS / Linux: `test -f ~/.rogue-env && echo exists || echo missing` +- Windows: `if (Test-Path "$env:USERPROFILE\.rogue-env") { 'exists' } else { 'missing' }` If it exists, tell the user and ask if they want to reconfigure. If not, continue. @@ -19,10 +22,14 @@ Ask the user for their Rogue Security API key (starts with `rsk_`). If they don' ## Step 3: Validate the key -Run: +- macOS / Linux: ```bash curl -s -o /dev/null -w "%{http_code}" -H "x-rogue-api-key: " https://api.rogue.security/api/v1/hooks/ping ``` +- Windows (PowerShell): +```powershell +try { (Invoke-WebRequest -Uri https://api.rogue.security/api/v1/hooks/ping -Headers @{ 'x-rogue-api-key' = '' } -UseBasicParsing -TimeoutSec 10).StatusCode } catch { $_.Exception.Response.StatusCode.value__ } +``` Expect `200`. If not, the key is invalid — ask the user to try again. ## Step 4: Detect identity @@ -31,19 +38,24 @@ Expect `200`. If not, the key is invalid — ask the user to try again. git config --global user.email git config --global user.name ``` -Show what was detected and ask if it's correct. +(`git config` works the same in both shells.) Show what was detected and ask if it's correct. ## Step 5: Store credentials +- macOS / Linux: ```bash bash "${CURSOR_PLUGIN_ROOT}/scripts/setup.sh" "" "" "" ``` +- Windows (PowerShell): +```powershell +powershell -NoProfile -File "$env:CURSOR_PLUGIN_ROOT\scripts\setup.ps1" "" "" "" +``` ## Step 6: Final instructions Tell the user: -1. Credentials are stored in `~/.rogue-env` (mode 600). +1. Credentials are stored in `~/.rogue-env` (mode 600) on macOS/Linux, or `%USERPROFILE%\.rogue-env` (restricted to your user) on Windows. 2. **Restart Cursor** (close all windows, reopen) — hooks read credentials at session start. 3. Run `/rogue:status` to verify the connection. 4. AIDR dashboard: https://app.rogue.security/aidr diff --git a/plugins/cursor/hooks/hooks.json b/plugins/cursor/hooks/hooks.json index 15a8fa8..9655c88 100644 --- a/plugins/cursor/hooks/hooks.json +++ b/plugins/cursor/hooks/hooks.json @@ -3,109 +3,181 @@ "hooks": { "sessionStart": [ { - "command": "python3 ./scripts/rogue-hook.py sessionStart", + "command": "sh ./scripts/hook.sh sessionStart", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) sessionStart\"", "timeout": 120 } ], "sessionEnd": [ { - "command": "python3 ./scripts/rogue-hook.py sessionEnd", + "command": "sh ./scripts/hook.sh sessionEnd", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) sessionEnd\"", "timeout": 120 } ], "beforeSubmitPrompt": [ { - "command": "python3 ./scripts/rogue-hook.py beforeSubmitPrompt", + "command": "sh ./scripts/hook.sh beforeSubmitPrompt", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) beforeSubmitPrompt\"", "timeout": 120 } ], "preToolUse": [ { - "command": "python3 ./scripts/rogue-hook.py preToolUse", + "command": "sh ./scripts/hook.sh preToolUse", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) preToolUse\"", "timeout": 120 } ], "postToolUse": [ { - "command": "python3 ./scripts/rogue-hook.py postToolUse", + "command": "sh ./scripts/hook.sh postToolUse", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) postToolUse\"", "timeout": 120 } ], "postToolUseFailure": [ { - "command": "python3 ./scripts/rogue-hook.py postToolUseFailure", + "command": "sh ./scripts/hook.sh postToolUseFailure", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) postToolUseFailure\"", "timeout": 120 } ], "beforeShellExecution": [ { - "command": "python3 ./scripts/rogue-hook.py beforeShellExecution", + "command": "sh ./scripts/hook.sh beforeShellExecution", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) beforeShellExecution\"", "timeout": 120 } ], "afterShellExecution": [ { - "command": "python3 ./scripts/rogue-hook.py afterShellExecution", + "command": "sh ./scripts/hook.sh afterShellExecution", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) afterShellExecution\"", "timeout": 120 } ], "beforeMCPExecution": [ { - "command": "python3 ./scripts/rogue-hook.py beforeMCPExecution", + "command": "sh ./scripts/hook.sh beforeMCPExecution", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) beforeMCPExecution\"", "timeout": 120 } ], "afterMCPExecution": [ { - "command": "python3 ./scripts/rogue-hook.py afterMCPExecution", + "command": "sh ./scripts/hook.sh afterMCPExecution", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) afterMCPExecution\"", "timeout": 120 } ], "beforeReadFile": [ { - "command": "python3 ./scripts/rogue-hook.py beforeReadFile", + "command": "sh ./scripts/hook.sh beforeReadFile", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) beforeReadFile\"", "timeout": 120 } ], "afterFileEdit": [ { - "command": "python3 ./scripts/rogue-hook.py afterFileEdit", + "command": "sh ./scripts/hook.sh afterFileEdit", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) afterFileEdit\"", "timeout": 120 } ], "afterAgentResponse": [ { - "command": "python3 ./scripts/rogue-hook.py afterAgentResponse", + "command": "sh ./scripts/hook.sh afterAgentResponse", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) afterAgentResponse\"", "timeout": 120 } ], "afterAgentThought": [ { - "command": "python3 ./scripts/rogue-hook.py afterAgentThought", + "command": "sh ./scripts/hook.sh afterAgentThought", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) afterAgentThought\"", "timeout": 120 } ], "subagentStart": [ { - "command": "python3 ./scripts/rogue-hook.py subagentStart", + "command": "sh ./scripts/hook.sh subagentStart", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) subagentStart\"", "timeout": 120 } ], "subagentStop": [ { - "command": "python3 ./scripts/rogue-hook.py subagentStop", + "command": "sh ./scripts/hook.sh subagentStop", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) subagentStop\"", "timeout": 120 } ], "stop": [ { - "command": "python3 ./scripts/rogue-hook.py stop", + "command": "sh ./scripts/hook.sh stop", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) stop\"", "timeout": 120 } ], "preCompact": [ { - "command": "python3 ./scripts/rogue-hook.py preCompact", + "command": "sh ./scripts/hook.sh preCompact", + "timeout": 120 + }, + { + "command": "powershell -NoProfile -NonInteractive -Command \"& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) preCompact\"", "timeout": 120 } ] diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 new file mode 100644 index 0000000..8d69d87 --- /dev/null +++ b/plugins/cursor/scripts/hook.ps1 @@ -0,0 +1,324 @@ +# Rogue Security hook dispatcher for Cursor — PowerShell implementation. +# +# Cross-platform sibling of hook.sh. hooks.json loads this WITHOUT -File so the +# PowerShell ExecutionPolicy never applies (running a scriptblock built from a +# string is not subject to policy, unlike invoking a .ps1 on disk — this also +# survives a GPO-enforced policy, which -ExecutionPolicy Bypass does not): +# +# powershell -NoProfile -NonInteractive -Command \ +# "& ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CURSOR_PLUGIN_ROOT 'scripts/hook.ps1')))) " +# +# CURSOR_PLUGIN_ROOT (the plugin root) is exposed as a process ENVIRONMENT +# VARIABLE, so PowerShell resolves $env:CURSOR_PLUGIN_ROOT at runtime via +# Join-Path — it must NOT be single-quoted (single quotes are literal in +# PowerShell and would never expand). Cursor runs this entry from a cwd that is +# NOT the plugin root, so a relative path would not resolve here. Join-Path also +# keeps the absolute path intact when it contains spaces. +# +# This script OWNS native Windows. It stands down on non-Windows (pwsh on +# macOS/Linux) because hook.sh runs there. +# +# Fail-open everywhere: missing API key, network error, non-200, empty body, or +# non-JSON response all yield `{}` on stdout, exit 0. +# +# Set ROGUE_DEBUG=1 (process/user env var) to emit diagnostics to stderr; +# Cursor shows stderr in its hook log without treating it as the response. +# +# Credential resolution (later file wins; process env wins over all), the +# Windows analogue of hook.sh's search: +# 1. ${CURSOR_PLUGIN_ROOT}\env (baked into a compiled customer plugin) +# 2. C:\ProgramData\rogue\env (MDM-provisioned; mirrors /etc/rogue/env) +# 3. %USERPROFILE%\.rogue-env (user / installer-written) + +param([string]$EventName = '') + +$ErrorActionPreference = 'SilentlyContinue' +# Invoke-WebRequest renders a progress bar that, when stdout/stderr is +# redirected (always true under a Cursor hook), can slow the call 10-50x or +# effectively hang it. Silencing progress is the standard fix. +$ProgressPreference = 'SilentlyContinue' + +function Write-Raw { + # Write raw UTF-8 bytes to stdout, bypassing [Console]::Out whose encoding + # may be a legacy codepage (e.g. CP437) that mangles non-ASCII output back + # into mojibake. Cursor reads the hook's stdout as UTF-8. + param([string]$Text) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) + $stdout = [Console]::OpenStandardOutput() + $stdout.Write($bytes, 0, $bytes.Length) + $stdout.Flush() +} +function Dbg { param([string]$Msg) if ($env:ROGUE_DEBUG) { [Console]::Error.WriteLine("[rogue] $Msg"); [Console]::Error.Flush() } } + +function Emit-Json { + param([string]$Data) + if (-not $Data) { Write-Raw '{}'; return } + Write-Raw $Data +} + +function ConvertFrom-ShellQuoted { + # Decode one shell "word" the way `hook.sh` would when it `source`s the env + # file, so values round-trip across both dispatchers. The env files are + # written either POSIX single-quoted with `'\''` escapes (install.ps1) or + # via bash `printf %q`, which emits backslash escapes and double quotes + # (install.sh). A naive outer-quote strip mangles values like O'Brien + # ('O'\''Brien') or "Your Name" (Your\ Name); this walks the string honoring + # single quotes, double quotes, and backslash escapes instead. + param([string]$Val) + if ($null -eq $Val) { return $Val } + $sb = [System.Text.StringBuilder]::new() + $i = 0; $n = $Val.Length + $state = 'normal' # normal | single | double + while ($i -lt $n) { + $c = $Val[$i] + switch ($state) { + 'single' { + if ($c -eq "'") { $state = 'normal' } else { [void]$sb.Append($c) } + } + 'double' { + if ($c -eq '"') { $state = 'normal' } + elseif ($c -eq '\' -and ($i + 1) -lt $n -and ('"\$`'.IndexOf($Val[$i+1]) -ge 0)) { + [void]$sb.Append($Val[$i+1]); $i++ + } else { [void]$sb.Append($c) } + } + default { + if ($c -eq "'") { $state = 'single' } + elseif ($c -eq '"') { $state = 'double' } + elseif ($c -eq '\' -and ($i + 1) -lt $n) { [void]$sb.Append($Val[$i+1]); $i++ } + else { [void]$sb.Append($c) } + } + } + $i++ + } + return $sb.ToString() +} + +function Repair-DoubleEncodedUtf8 { + # Cursor on non-UTF-8 Windows locales double-encodes assistant text + # (UTF-8 -> CP1252 -> UTF-8): e.g. "—" arrives as "â€"" and "'" as "’". + # We can't change the client's system locale, so repair it here: re-encode + # the string as CP1252 and decode as UTF-8, with BOTH steps STRICT (throw on + # any unmappable char / invalid byte). Genuine mojibake round-trips to valid + # UTF-8 and is repaired; already-correct text (café, 😀, plain ASCII) fails + # the strict round-trip and is returned unchanged — so this is a safe no-op + # for well-behaved clients. + param([string]$Text) + if (-not $Text) { return $Text } + try { + $cp1252 = [System.Text.Encoding]::GetEncoding(1252, + [System.Text.EncoderFallback]::ExceptionFallback, + [System.Text.DecoderFallback]::ExceptionFallback) + $strictUtf8 = [System.Text.UTF8Encoding]::new($false, $true) + $repaired = $strictUtf8.GetString($cp1252.GetBytes($Text)) + if ($repaired -ne $Text) { Dbg "repaired double-encoded UTF-8"; return $repaired } + } catch { Dbg "no double-encode repair (text already valid UTF-8)" } + return $Text +} + +# Test seam: dot-sourcing with ROGUE_PS_LIB_ONLY=1 loads the functions above +# (e.g. ConvertFrom-ShellQuoted) without running the dispatcher. Production +# never sets this, so the hook always runs its main body. +if ($env:ROGUE_PS_LIB_ONLY) { return } + +# Windows PowerShell 5.1 may negotiate only TLS 1.0/1.1 by default, which +# modern HTTPS endpoints reject ("Could not create SSL/TLS secure channel"). +# Add TLS 1.2 without clobbering any protocols already enabled. +try { + [Net.ServicePointManager]::SecurityProtocol = ` + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} catch {} + +# ── stand down on non-Windows (pwsh on macOS/Linux) ──────────────────────── +# $IsWindows exists only in PowerShell 6+. In 5.1 (Windows-only) it is $null, +# so guard on the version to avoid a false stand-down there. +if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { Write-Raw '{}'; exit 0 } + +if (-not $EventName) { Dbg "no event name -> {}"; Write-Raw '{}'; exit 0 } +Dbg "event=$EventName" + +# ── credential resolution (later file wins; process env wins over all) ───── +$creds = @{} +$pluginRoot = $env:CURSOR_PLUGIN_ROOT +if (-not $pluginRoot) { try { $pluginRoot = (Get-Location).Path } catch { $pluginRoot = '.' } } +Dbg "pluginRoot=$pluginRoot" + +$credFiles = @( + (Join-Path $pluginRoot 'env'), + 'C:\ProgramData\rogue\env', + (Join-Path $env:USERPROFILE '.rogue-env') +) +foreach ($f in $credFiles) { + if (-not $f) { continue } + if (-not (Test-Path -LiteralPath $f)) { Dbg "cred file absent: $f"; continue } + Dbg "cred file found: $f" + foreach ($line in (Get-Content -LiteralPath $f)) { + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { + $k = $Matches[1] + # Decode shell quoting/escaping so the value round-trips with the + # `source`-based parse in hook.sh (mirrors shlex.split). + $v = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $creds[$k] = $v + } + } +} +foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL') { + $val = [Environment]::GetEnvironmentVariable($k) + if ($val) { $creds[$k] = $val } +} + +$apiKey = $creds['ROGUE_API_KEY'] +if (-not $apiKey) { + Dbg "no API key after cred resolution -> fail-open" + if ($EventName -eq 'sessionStart') { + Write-Raw '{"additional_context": "Rogue Security plugin is installed but not configured. Run /rogue:setup to connect your API key."}' + } else { + Write-Raw '{}' + } + exit 0 +} +$keyTail = if ($apiKey.Length -ge 4) { $apiKey.Substring($apiKey.Length - 4) } else { '****' } +Dbg "apiKey present (tail $keyTail)" + +$baseUrl = $creds['ROGUE_BASE_URL'] +if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } +$baseUrl = $baseUrl.TrimEnd('/') + +# ── actor resolution: explicit creds → git config → username/hostname ────── +$actorName = $creds['ROGUE_ACTOR_NAME'] +if (-not $actorName) { try { $actorName = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} } +if (-not $actorName) { $actorName = $env:USERNAME } + +$actorEmail = $creds['ROGUE_ACTOR_EMAIL'] +if (-not $actorEmail) { try { $actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } +if (-not $actorEmail) { + if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } + elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } + else { $actorEmail = $env:COMPUTERNAME } +} + +# ── payload from stdin ───────────────────────────────────────────────────── +$payload = [Console]::In.ReadToEnd() +if (-not $payload) { $payload = '{}' } +# Cursor sends a UTF-8 payload, but the console often reads stdin under a legacy +# OEM codepage (observed in the field: IBM437), which mojibakes it — e.g. the +# leading UTF-8 BOM (bytes EF BB BF) decodes to "∩╗┐", not a single U+FEFF. +# Chasing per-codepage code points is futile, so instead round-trip the string +# back through the ACTUAL input encoding to recover the original bytes, then +# decode them as real UTF-8. CP437↔Unicode is a bijection, so this also fully +# recovers any non-ASCII prompt text. No-op when the console is already UTF-8. +Dbg "InputEncoding=$([Console]::InputEncoding.WebName) CP=$([Console]::InputEncoding.CodePage)" +try { + $raw = [Console]::InputEncoding.GetBytes($payload) + $payload = [System.Text.Encoding]::UTF8.GetString($raw) +} catch { Dbg "utf8 re-decode failed: $($_.Exception.Message)" } + +# After re-decoding, a UTF-8 BOM is a single U+FEFF char. Strip it: a +# BOM-prefixed body is invalid JSON and the API rejects it with HTTP 400. +$payload = $payload.TrimStart([char]0xFEFF) + +# Repair Cursor's UTF-8 -> CP1252 -> UTF-8 double-encoding of assistant text, +# which happens on clients with a non-UTF-8 Windows locale (out of our control). +$payload = Repair-DoubleEncodedUtf8 $payload + +# ── POST (fail-open) ─────────────────────────────────────────────────────── +$headers = @{ + 'x-rogue-api-key' = $apiKey + 'x-rogue-event' = $EventName + 'x-rogue-actor-email' = $actorEmail + 'x-rogue-actor-name' = $actorName + 'x-rogue-source' = 'cursor' +} + +$url = "$baseUrl/api/v1/hooks/cursor" +Dbg "POST $url actor=$actorEmail" +# Send an explicit UTF-8 byte array: Windows PowerShell 5.1's Invoke-WebRequest +# re-encodes a string body (commonly to Latin-1), which corrupts non-ASCII +# prompt content and can reintroduce a BOM. GetBytes() never emits a BOM. +$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) +$resp = '' +try { + $r = Invoke-WebRequest -Uri $url -Method Post ` + -Headers $headers -ContentType 'application/json' -Body $bodyBytes ` + -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop + Dbg "HTTP $($r.StatusCode), body length $($r.Content.Length)" + if ($r.StatusCode -eq 200) { + # Decode the body explicitly as UTF-8. Invoke-WebRequest's .Content + # mis-decodes as ISO-8859-1 when the server omits a charset, turning + # UTF-8 punctuation (— ') into mojibake (â€" ’). RawContentStream + # holds the original bytes. + try { $resp = [System.Text.Encoding]::UTF8.GetString($r.RawContentStream.ToArray()) } + catch { $resp = [string]$r.Content } + } +} catch { + Dbg "POST failed: $($_.Exception.Message)" + # On a 4xx/5xx the body usually explains why; surface it under ROGUE_DEBUG. + # PS7 stashes it in ErrorDetails.Message; PS5.1 needs the response stream. + $errBody = $null + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + $errBody = $_.ErrorDetails.Message + } elseif ($_.Exception.Response) { + try { + $stream = $_.Exception.Response.GetResponseStream() + $sr = New-Object System.IO.StreamReader($stream) + $errBody = $sr.ReadToEnd(); $sr.Close() + } catch {} + } + if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { + Dbg "error status: $([int]$_.Exception.Response.StatusCode)" + } + if ($errBody) { Dbg "error response body: $errBody" } + $resp = '' +} + +Emit-Json $resp + +# ── presence heartbeat (sessionStart only) ────────────────────────────────── +# POSTs /api/v1/hooks/status so this install shows in the dashboard's Coding +# Agents roster (Connected / version / host / user). Pure side-effect: response +# ignored, fully wrapped so it can never affect the already-emitted hook +# response. Runs AFTER Emit-Json; PowerShell has no reliable fire-and-forget +# across process exit, so this is a sync POST (10s cap) on sessionStart only. +# Creds/actor were already resolved above. +if ($EventName -eq 'sessionStart') { + try { + # Plugin version from the manifest. + $hbVer = 'unknown' + $hbPj = Join-Path $pluginRoot '.cursor-plugin/plugin.json' + if (Test-Path -LiteralPath $hbPj) { + try { + $v = (Get-Content -Raw -LiteralPath $hbPj | ConvertFrom-Json).version + if ($v -match '^[0-9]+\.[0-9]+\.[0-9]+') { $hbVer = $Matches[0] } + } catch { Dbg "plugin.json parse failed: $($_.Exception.Message)" } + } + $hbHost = $env:COMPUTERNAME + if (-not $hbHost) { $hbHost = 'unknown' } + + # `agent` is "cursor" (not a display label): the server keys its + # latest-version lookup (PLUGIN_REPOS) on this value, so the roster can + # flag outdated installs. + $hbBody = @{ + agent_family = 'cursor' + agent = 'cursor' + version = $hbVer + host = $hbHost + actor_email = $actorEmail + actor_name = $actorName + } | ConvertTo-Json -Compress + + $hbHeaders = @{ + 'x-rogue-api-key' = $apiKey + 'x-rogue-source' = 'cursor' + } + $hbUrl = "$baseUrl/api/v1/hooks/status" + Dbg "heartbeat POST $hbUrl ver=$hbVer host=$hbHost" + $hbBytes = [System.Text.Encoding]::UTF8.GetBytes($hbBody) + $r = Invoke-WebRequest -Uri $hbUrl -Method Post ` + -Headers $hbHeaders -ContentType 'application/json' -Body $hbBytes ` + -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop + Dbg "heartbeat HTTP $($r.StatusCode)" + } catch { + Dbg "heartbeat POST failed: $($_.Exception.Message)" + } +} + +exit 0 diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh new file mode 100755 index 0000000..7c8278c --- /dev/null +++ b/plugins/cursor/scripts/hook.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env sh +# Rogue Security hook dispatcher for Cursor — POSIX sh + curl implementation. +# +# Cross-platform sibling of hook.ps1. hooks.json fires BOTH an `sh` and a +# PowerShell entry for every event; exactly one does real work per machine: +# +# • macOS / Linux / WSL → this script runs (curl POST). +# • native Windows + Git Bash → this script STANDS DOWN (uname is MINGW/ +# MSYS/CYGWIN) so hook.ps1 owns Windows. +# • native Windows, no Git Bash → `sh` is not found → the entry fails to +# spawn (clean fail-open, no output); ps1 runs. +# +# Invoked via `sh`, NOT `bash`, on purpose: on Windows `bash` resolves to the +# WSL launcher stub (System32\bash.exe), which prints a UTF-16 "no installed +# distributions" notice that breaks Cursor's JSON parse of the hook output. +# There is no `sh.exe` stub, so `sh` cleanly "command not found"s on a bash-less +# Windows box. This script is kept POSIX-clean (tested under dash) as a result. +# +# The Git Bash stand-down matters because Git Bash's `~` maps to the Windows +# user profile — the SAME dir hook.ps1 reads — so without it both would POST. +# +# Pass-through: read the Cursor event payload from stdin, POST it to the Rogue +# AIDR backend, relay the server's response bytes verbatim. No client policy. +# +# Fail-open everywhere: missing API key, missing curl, network error, non-200, +# empty body all yield `{}` on stdout, exit 0. Cursor +# must never block because Rogue infrastructure is unavailable. +# +# Credential resolution (later file wins; process env wins over all): +# 1. ${CURSOR_PLUGIN_ROOT}/env (baked into a compiled customer plugin) +# 2. /etc/rogue/env (MDM-provisioned) +# 3. ~/.rogue-env (user / installer-written) + +event="${1:-}" + +emit() { + # Relay the server response to Cursor verbatim. We deliberately do NOT validate + # the JSON: a 200 from the Rogue API is always valid JSON, and if a malformed + # body ever slips through, Cursor ignores it AND logs the raw output — which is + # exactly what we want for debugging. Validating here would only let us swallow + # that signal (turning it into `{}`) for no gain. Empty body -> `{}`. + data="$1" + trimmed="${data#"${data%%[![:space:]]*}"}" # strip leading whitespace + [ -z "$trimmed" ] && { printf '{}'; return; } + printf '%s' "$data" +} + +# Diagnostics to stderr when ROGUE_DEBUG is set (Cursor logs stderr separately). +dbg() { [ -n "${ROGUE_DEBUG:-}" ] && printf '[rogue] %s\n' "$*" >&2; return 0; } + +# ── Git Bash stand-down: let hook.ps1 own native Windows ─────────────────── +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) dbg "Git Bash (uname) -> stand down"; printf '{}'; exit 0 ;; +esac + +[ -n "$event" ] || { printf '{}'; exit 0; } +dbg "event=$event" + +# ── credential resolution (later file wins; process env wins over all) ───── +_penv_ROGUE_API_KEY="${ROGUE_API_KEY:-}" +_penv_ROGUE_ACTOR_EMAIL="${ROGUE_ACTOR_EMAIL:-}" +_penv_ROGUE_ACTOR_NAME="${ROGUE_ACTOR_NAME:-}" +_penv_ROGUE_BASE_URL="${ROGUE_BASE_URL:-}" + +PLUGIN_ROOT="${CURSOR_PLUGIN_ROOT:-}" +if [ -z "$PLUGIN_ROOT" ]; then + PLUGIN_ROOT="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)" || PLUGIN_ROOT="" +fi + +# Env files are bash-quoted (`export KEY=value`, written via printf %q), so +# sourcing them is correct. +for _f in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do + if [ -n "$_f" ] && [ -r "$_f" ]; then dbg "cred file found: $_f"; . "$_f" 2>/dev/null + else dbg "cred file absent: $_f"; fi +done + +# process env wins over file values +[ -n "$_penv_ROGUE_API_KEY" ] && ROGUE_API_KEY="$_penv_ROGUE_API_KEY" +[ -n "$_penv_ROGUE_ACTOR_EMAIL" ] && ROGUE_ACTOR_EMAIL="$_penv_ROGUE_ACTOR_EMAIL" +[ -n "$_penv_ROGUE_ACTOR_NAME" ] && ROGUE_ACTOR_NAME="$_penv_ROGUE_ACTOR_NAME" +[ -n "$_penv_ROGUE_BASE_URL" ] && ROGUE_BASE_URL="$_penv_ROGUE_BASE_URL" + +API_KEY="${ROGUE_API_KEY:-}" +if [ -z "$API_KEY" ]; then + dbg "no API key after cred resolution -> fail-open" + if [ "$event" = "sessionStart" ]; then + printf '%s' '{"additional_context": "Rogue Security plugin is installed but not configured. Run /rogue:setup to connect your API key."}' + else + printf '{}' + fi + exit 0 +fi + +BASE_URL="${ROGUE_BASE_URL:-https://api.rogue.security}" +BASE_URL="${BASE_URL%/}" +dbg "apiKey present (tail $(printf '%s' "$API_KEY" | tail -c 4 2>/dev/null)) baseUrl=$BASE_URL" + +# ── actor resolution: explicit creds → git config → whoami/hostname ──────── +_git_cfg() { git config --global "$1" 2>/dev/null; } + +actor_name="${ROGUE_ACTOR_NAME:-}" +[ -n "$actor_name" ] || actor_name="$(_git_cfg user.name)" +[ -n "$actor_name" ] || actor_name="${USER:-${USERNAME:-$(whoami 2>/dev/null)}}" + +actor_email="${ROGUE_ACTOR_EMAIL:-}" +[ -n "$actor_email" ] || actor_email="$(_git_cfg user.email)" +if [ -z "$actor_email" ]; then + _u="${USER:-${USERNAME:-$(whoami 2>/dev/null)}}" + _h="$(hostname 2>/dev/null)" + if [ -n "$_u" ] && [ -n "$_h" ]; then actor_email="$_u@$_h" + else actor_email="${_u:-$_h}"; fi +fi + +# ── payload from stdin ───────────────────────────────────────────────────── +PAYLOAD="$(cat 2>/dev/null)" +[ -n "$PAYLOAD" ] || PAYLOAD='{}' +# Strip a leading UTF-8 BOM if present. Cursor on Windows prepends one to the +# hook payload (hook.ps1 handles it on the native path); a BOM-prefixed body is +# invalid JSON and the API rejects it with HTTP 400. No-op when absent. +_bom="$(printf '\357\273\277')" +PAYLOAD="${PAYLOAD#"$_bom"}" + +# ── POST (fail-open) ─────────────────────────────────────────────────────── +command -v curl >/dev/null 2>&1 || { dbg "curl not found -> {}"; printf '{}'; exit 0; } + +URL="$BASE_URL/api/v1/hooks/cursor" +dbg "POST $URL actor=$actor_email" +# -f makes curl emit nothing and exit non-zero on HTTP >= 400, giving us +# fail-open on non-200 for free. +RESP="$(printf '%s' "$PAYLOAD" | curl -fsS --max-time 10 -X POST \ + -H 'Content-Type: application/json' \ + -H "x-rogue-api-key: $API_KEY" \ + -H "x-rogue-event: $event" \ + -H "x-rogue-actor-email: $actor_email" \ + -H "x-rogue-actor-name: $actor_name" \ + -H 'x-rogue-source: cursor' \ + --data-binary @- "$URL" 2>/dev/null)"; _rc=$? +dbg "curl rc=$_rc resp_len=${#RESP}" +[ "$_rc" -eq 0 ] || RESP="" + +# ── presence heartbeat (sessionStart only, fire-and-forget) ──────────────── +# POSTs /api/v1/hooks/status so this install shows in the dashboard's Coding +# Agents roster (Connected / version / host / user). Pure side-effect: the POST +# runs in a detached double-fork with all fds redirected, so neither the relayed +# response below nor session start ever waits on it, and the response is +# ignored. Creds/actor were already resolved above. +if [ "$event" = "sessionStart" ]; then + # Plugin version from the manifest, without python/jq. + HB_VER="unknown" + HB_PJ="$PLUGIN_ROOT/.cursor-plugin/plugin.json" + if [ -r "$HB_PJ" ]; then + _v=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[0-9][^"]*"' "$HB_PJ" 2>/dev/null \ + | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + [ -n "$_v" ] && HB_VER="$_v" + fi + HB_HOST=$(hostname 2>/dev/null) || HB_HOST=unknown + [ -n "$HB_HOST" ] || HB_HOST=unknown + # `agent` is "cursor" (not a display label): the server keys its latest-version + # lookup (PLUGIN_REPOS) on this value, so the roster can flag outdated installs. + hb_esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } + HB_BODY=$(printf '{"agent_family":"cursor","agent":"cursor","version":"%s","host":"%s","actor_email":"%s","actor_name":"%s"}' \ + "$(hb_esc "$HB_VER")" "$(hb_esc "$HB_HOST")" "$(hb_esc "$actor_email")" "$(hb_esc "$actor_name")") + dbg "heartbeat POST $BASE_URL/api/v1/hooks/status ver=$HB_VER host=$HB_HOST" + ( curl -fsS --max-time 10 -X POST \ + -H 'Content-Type: application/json' \ + -H "x-rogue-api-key: $API_KEY" \ + -H 'x-rogue-source: cursor' \ + -d "$HB_BODY" \ + "$BASE_URL/api/v1/hooks/status" \ + /dev/null 2>&1 & ) +fi + +emit "$RESP" +exit 0 diff --git a/plugins/cursor/scripts/rogue-hook.py b/plugins/cursor/scripts/rogue-hook.py deleted file mode 100755 index 9d3762d..0000000 --- a/plugins/cursor/scripts/rogue-hook.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env python3 -""" -Rogue Security hook dispatcher for Cursor. - -Pass-through: every hook in hooks.json calls - python3 rogue-hook.py - -The dispatcher reads the Cursor event payload from stdin, POSTs it to -the Rogue AIDR backend, and relays the server's response verbatim to -stdout. The server returns the exact Cursor-native output JSON for that -event (e.g. {permission, user_message} for preToolUse, {continue, -user_message} for beforeSubmitPrompt) — the dispatcher does not reshape. - -Credential resolution order (later wins, then process env wins over all): - 1. ${CURSOR_PLUGIN_ROOT}/env (baked into a compiled customer plugin) - 2. /etc/rogue/env (MDM-provisioned) - 3. ~/.rogue-env (user / installer-written) - -All policy decisions (allow/ask/deny) are made by the server based on its -own configuration. The dispatcher forwards no client-side preference. - -Fail-open: missing API key, network failure, non-200, empty body, or -malformed JSON all result in `{}` on stdout — Cursor must never block -because Rogue infrastructure is unavailable. -""" - -from __future__ import annotations - -import json -import os -import re -import shlex -import socket -import subprocess -import sys -import urllib.error -import urllib.request - -DEFAULT_BASE_URL = "https://api.rogue.security" -TIMEOUT_SECONDS = 10 - -def _default_cred_files() -> tuple[str, ...]: - # The compiled-plugin env file lives at the plugin root. Prefer - # CURSOR_PLUGIN_ROOT (set by Cursor when invoking hooks); fall back to - # script-relative resolution for direct invocations. - plugin_root = os.environ.get("CURSOR_PLUGIN_ROOT") or os.path.dirname( - os.path.dirname(os.path.abspath(__file__)) - ) - return ( - os.path.join(plugin_root, "env"), - "/etc/rogue/env", - os.path.expanduser("~/.rogue-env"), - ) - - -_CRED_FILES = _default_cred_files() -_FORWARDED_ENV_VARS = ( - "ROGUE_API_KEY", - "ROGUE_ACTOR_EMAIL", - "ROGUE_ACTOR_NAME", - "ROGUE_BASE_URL", -) - - -def _load_creds() -> dict: - """Parse `export KEY=value` lines from each env file. Process env wins.""" - out: dict[str, str] = {} - for path in _CRED_FILES: - if not os.path.isfile(path): - continue - try: - with open(path) as f: - for line in f: - m = re.match(r"\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$", line) - if not m: - continue - key, val = m.group(1), m.group(2).strip() - try: - parsed = next(iter(shlex.split(val)), val) - except ValueError: - parsed = val - out[key] = parsed - except OSError: - pass - for k in _FORWARDED_ENV_VARS: - if os.environ.get(k): - out[k] = os.environ[k] - return out - - -def _run_cmd(cmd: list[str]) -> str: - try: - return subprocess.run( - cmd, capture_output=True, text=True, timeout=2 - ).stdout.strip() - except Exception: - return "" - - -def _git_config(key: str) -> str: - return _run_cmd(["git", "config", "--global", key]) - - -def _whoami() -> str: - """Username — $USER/$USERNAME first, then the `whoami` command.""" - return ( - os.environ.get("USER") - or os.environ.get("USERNAME") - or _run_cmd(["whoami"]) - ) - - -def _hostname() -> str: - """Hostname — socket.gethostname() first, then the `hostname` command.""" - return socket.gethostname() or _run_cmd(["hostname"]) - - -def _resolve_actor(creds: dict) -> tuple[str, str]: - """Fallback chain: explicit creds → git config → whoami/hostname.""" - name = ( - creds.get("ROGUE_ACTOR_NAME") - or _git_config("user.name") - or _whoami() - ) - email = creds.get("ROGUE_ACTOR_EMAIL") or _git_config("user.email") - if not email: - user, host = _whoami(), _hostname() - if user and host: - email = f"{user}@{host}" - else: - email = user or host - return email, name - - -def _post(url: str, headers: dict, body: bytes) -> bytes: - req = urllib.request.Request(url, data=body, headers=headers, method="POST") - try: - with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as resp: - return resp.read() or b"" - except ( - urllib.error.URLError, - urllib.error.HTTPError, - socket.timeout, - ConnectionError, - OSError, - ): - return b"" - - -def _emit_bytes(data: bytes) -> None: - """Validate that data is JSON and relay verbatim. Otherwise emit `{}`.""" - if not data: - sys.stdout.write("{}") - sys.stdout.flush() - return - try: - json.loads(data) - except (ValueError, json.JSONDecodeError): - sys.stdout.write("{}") - sys.stdout.flush() - return - sys.stdout.write(data.decode("utf-8", errors="replace")) - sys.stdout.flush() - - -def main(argv: list[str]) -> int: - if len(argv) < 2: - sys.stdout.write("{}") - sys.stdout.flush() - return 0 - event = argv[1] - - creds = _load_creds() - api_key = creds.get("ROGUE_API_KEY", "") - if not api_key: - if event == "sessionStart": - sys.stdout.write( - json.dumps( - { - "additional_context": "Rogue Security plugin is installed but not configured. " - "Run /rogue:setup to connect your API key." - } - ) - ) - else: - sys.stdout.write("{}") - sys.stdout.flush() - return 0 - - base_url = creds.get("ROGUE_BASE_URL", DEFAULT_BASE_URL).rstrip("/") - actor_email, actor_name = _resolve_actor(creds) - - try: - payload = sys.stdin.read() or "{}" - except Exception: - payload = "{}" - - headers = { - "Content-Type": "application/json", - "x-rogue-api-key": api_key, - "x-rogue-event": event, - "x-rogue-actor-email": actor_email, - "x-rogue-actor-name": actor_name, - "x-rogue-source": "cursor", - } - - url = f"{base_url}/api/v1/hooks/cursor" - _emit_bytes(_post(url, headers, payload.encode("utf-8"))) - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/plugins/cursor/scripts/setup.ps1 b/plugins/cursor/scripts/setup.ps1 new file mode 100644 index 0000000..3198157 --- /dev/null +++ b/plugins/cursor/scripts/setup.ps1 @@ -0,0 +1,50 @@ +# Rogue Security — credential storage helper (Windows / PowerShell). +# Mirrors setup.sh: writes %USERPROFILE%\.rogue-env, restricted to the current +# user, in the same `export KEY=value` shell-quoted format both dispatchers read. +# +# Usage: powershell -NoProfile -File setup.ps1 +param( + [Parameter(Mandatory = $true)][string]$ApiKey, + [string]$Email = '', + [string]$Name = '' +) + +$ErrorActionPreference = 'Stop' + +$EnvFile = if ($env:ROGUE_ENV_FILE) { $env:ROGUE_ENV_FILE } else { Join-Path $env:USERPROFILE '.rogue-env' } + +# Always POSIX single-quote so the value is safe whether the file is sourced by +# hook.sh or decoded by hook.ps1's ConvertFrom-ShellQuoted. Each ' becomes '\'' +# (close, escaped ', reopen); the PS literal "'\''" is exactly the 4 chars ' \ ' '. +function Format-EnvVal { + param([string]$Val) + return "'" + $Val.Replace("'", "'\''") + "'" +} + +$envLines = @( + '# Managed by the rogue Cursor plugin. Read by hook subprocesses at runtime.', + '# Delete this file to revoke credentials.', + "export ROGUE_API_KEY=$(Format-EnvVal $ApiKey)", + "export ROGUE_ACTOR_EMAIL=$(Format-EnvVal $Email)", + "export ROGUE_ACTOR_NAME=$(Format-EnvVal $Name)" +) + +$envDir = Split-Path $EnvFile +if ($envDir -and -not (Test-Path $envDir)) { + New-Item -ItemType Directory -Path $envDir -Force | Out-Null +} +Set-Content -Path $EnvFile -Value $envLines -Encoding UTF8 + +# Restrict the file to the current user only (best-effort, mirrors chmod 600). +try { + $acl = Get-Acl $EnvFile + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, + 'FullControl', 'Allow') + $acl.SetAccessRule($rule) + Set-Acl $EnvFile $acl +} catch {} + +Write-Output "OK" +Write-Output "ENV_FILE=$EnvFile" From 211dea7bfba525a4de130b0a7860500097dc8156 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Mon, 29 Jun 2026 17:23:24 +0300 Subject: [PATCH 4/4] fix(ci): pick shell parser by shebang in validate workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous step `sh -n`'d every *.sh, but install.sh / build-release.sh / the other tooling scripts are bash (arrays, RETURN traps) — dash choked on build-release.sh line 28 `COMMON_FILES=(...)`. Now parse bash-shebang scripts with `bash -n` and the POSIX runtime dispatchers with `dash -n`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RLk1XJQ1uWYSdn5Ro31JHv --- .github/workflows/validate.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7321fa6..2cdb6c6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -58,11 +58,15 @@ jobs: echo "ok: $src @ $pv" done - - name: Shell dispatchers parse (POSIX sh) + - name: Shell scripts parse run: | set -euo pipefail + # Parse each script with the interpreter its shebang names: the runtime + # hook dispatchers are POSIX sh (validate under dash), while install.sh / + # build-release.sh are bash (arrays, RETURN traps). Wrong parser = false fail. fail=0 while IFS= read -r f; do - if ! sh -n "$f"; then echo "::error file=$f::sh parse error"; fail=1; fi + 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') [ "$fail" = 0 ] || exit 1