diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..b2cd4ed --- /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.3", + "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/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..2cdb6c6 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,72 @@ +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 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 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 diff --git a/CLAUDE.md b/CLAUDE.md index 9ef8ed3..d245b61 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/`) +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`) -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..3203ed5 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,25 @@ 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). + +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. 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..766ec16 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,11 +79,28 @@ try { Write-Host "" Write-Host "Rogue Security (Windows)" -ForegroundColor Cyan -# Detect every supported agent on PATH; at least one is required. -$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." +# 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)) { @@ -216,6 +242,44 @@ 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 + # 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" + } 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..f0e20d0 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 @@ -96,12 +102,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 +170,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 +447,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 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." +} + # ── 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. @@ -413,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 ;; @@ -426,11 +489,27 @@ parse_args() { main() { parse_args "$@" - # Detect every supported agent on PATH. - 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." + 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 @@ -439,6 +518,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..f57e7c4 --- /dev/null +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "Rogue Security", + "version": "1.0.3", + "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..c2eb5cf --- /dev/null +++ b/plugins/cursor/commands/setup.md @@ -0,0 +1,61 @@ +--- +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. + +**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 + +- 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. + +## 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 + +- 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 + +```bash +git config --global user.email +git config --global user.name +``` +(`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) 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/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..9655c88 --- /dev/null +++ b/plugins/cursor/hooks/hooks.json @@ -0,0 +1,185 @@ +{ + "version": 1, + "hooks": { + "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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/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" 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"