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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ jobs:
run: |
set -euo pipefail
pwsh -NoProfile -File tests/test_hook_ps1.ps1
pwsh -NoProfile -File tests/test_git_identity_ps1.ps1
pwsh -NoProfile -File tests/test_hook_ps1_copilot.ps1
pwsh -NoProfile -File tests/test_hook_ps1_cursor.ps1
pwsh -NoProfile -File tests/test_hook_ps1_antigravity.ps1
Expand Down Expand Up @@ -400,6 +401,8 @@ jobs:
$ErrorActionPreference = 'Stop'
powershell -NoProfile -File tests/test_hook_ps1.ps1
if ($LASTEXITCODE -ne 0) { exit 1 }
powershell -NoProfile -File tests/test_git_identity_ps1.ps1
if ($LASTEXITCODE -ne 0) { exit 1 }
powershell -NoProfile -File tests/test_hook_ps1_copilot.ps1
if ($LASTEXITCODE -ne 0) { exit 1 }
powershell -NoProfile -File tests/test_hook_ps1_cursor.ps1
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ System-wide MDM deployment can drop the same exports into `/etc/rogue/env` —
hooks check that path first, and when it holds `ROGUE_API_KEY` they read no other
file. Values in the file in use override the process environment.

When the file in use carries no `ROGUE_ACTOR_*`, every hook resolves the actor
at fire time: `user.email` / `user.name` from
`${XDG_CONFIG_HOME:-~/.config}/git/config` then `~/.gitconfig` (read as files;
`git` itself is never run), then `<login>@<hostname>`. Every level is set by
the local user, so the actor is a self-reported label: authoritative
attribution is the API key's organization and the enrolled endpoint.

To revoke: `rm ~/.rogue-env` (per-user) or `sudo rm /etc/rogue/env` (MDM).

## False positive escape hatch
Expand Down
11 changes: 7 additions & 4 deletions docs/log-shipping.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@ POSTs `/api/v1/hooks/status` with `host` (`hostname`), `actor_email`, `actor_nam
and `agent_family`. A shipped log chunk carrying those same fields is attributable
with no `machine_id` at all — provided the shipper uses the *same* values, which is a
contract and not a coincidence: it **inherits** them from the caller rather than
running its own cascade. Cursor and Gemini keep their actor resolution inline
(shell locals / module locals) and their fallbacks differ from `actor.sh`'s, so an
independently-resolving shipper would produce a second identity for the same machine
and orphan the logs. See **The actor is passed IN** in
running its own cascade. Gemini keeps its actor resolution inline (module locals)
and the Claude bridge's `actor.sh` screens sandbox identities the others do not, so
an independently-resolving shipper could produce a second identity for the same
machine and orphan the logs. Every level of the cascade is set by the local user (env
file, `~/.gitconfig`, login), so `actor_email` is a self-reported label and never an
authenticated principal; the API key and the enrolled endpoint are what attribute a
row to an organization. See **The actor is passed IN** in
[plugin-log-shipper.md](plugin-log-shipper.md).

**Correction to an earlier version of this section**, which claimed the roster
Expand Down
36 changes: 13 additions & 23 deletions docs/plugin-log-shipper.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,24 +393,14 @@ to.

**Hard rule, and the most fragile thing in this document.** "The shipper resolves the
same cascade as the heartbeat, so the two cannot disagree" was hand-waving. Nothing
enforces it, and two of the six plugins already break it:

- **Cursor** resolves `actor_email` / `actor_name` as **shell locals** in
`plugins/cursor/scripts/hook.sh:147-158` — never exported, so a child process
inherits nothing.
- **Gemini** resolves them as **module locals** in `heartbeat.mjs:36-37` (a duplicate
of `hook.mjs`'s `resolveActor`), never placed in `process.env`.

And the cascades are **not** the same, so an independent re-resolve does not merely
risk drift, it produces it. On a machine with no `git config --global user.email`:

| | fallback | value |
|---|---|---|
| `scripts/actor.sh` (claude, codex, copilot, antigravity) | `hostname` | `amos-mbp` |
| Cursor `hook.sh:151-158` | `$USER@$(hostname)` | `amos@amos-mbp` |

Two identities for one machine, so the heartbeat's roster row and the shipper's
`log_source` row would never meet. Nothing errors; the logs just attach to nothing.
enforces it, and the cascades are not identical. Every sh bridge sources
`scripts/actor.sh` (Cursor included, synced from `scripts/shared/actor.sh`) and ends
at `<login>@<hostname>`, but `plugins/rogue`'s own `actor.sh` ranks
`CLAUDE_CODE_USER_EMAIL` above git and screens the Cowork sandbox identity, and
Gemini resolves in `shared.mjs` without touching `process.env`. A shipper that
re-resolved would sooner or later pick a different level than its caller did, and
the heartbeat's roster row and the shipper's `log_source` row would never meet.
Nothing errors; the logs just attach to nothing.

So the resolution order is:

Expand Down Expand Up @@ -1205,12 +1195,12 @@ Cases:
- **the shipper has no actor cascade of its own**: with `ROGUE_ACTOR_EMAIL` unset and
no `scripts/actor.sh` reachable, it **skips the file** and logs
`outcome=skip reason=no-actor` — assert it does *not* fall back to `hostname`,
`whoami` or `$USER@$(hostname)`. This is the regression test for the Cursor drift:
`hook.sh`'s fallback is `$USER@$(hostname)` where `actor.sh`'s is `hostname`, so any
private cascade produces a second identity for the same machine;
`whoami` or `$USER@$(hostname)`. Any private cascade is a second identity for the
same machine: the Claude bridge's screening alone guarantees the cascades differ;
- **every caller passes down what it resolved**: `cursor/scripts/hook.sh` prefixes the
invocation with `ROGUE_ACTOR_EMAIL=`/`ROGUE_ACTOR_NAME=` (its actor lives in plain
shell locals, so without that the child inherits nothing and skips),
invocation with `ROGUE_ACTOR_EMAIL=`/`ROGUE_ACTOR_NAME=` (`actor.sh` exports both,
so the prefix states the contract and covers an install whose `actor.sh` predates
that export),
`gemini/scripts/heartbeat.mjs` assigns them into `process.env` before importing the
shipper (`loadEnvFiles()` deliberately does not mutate `process.env`), and the five
PowerShell callers set them as `$env:` before spawning. A wiring assertion like
Expand Down
51 changes: 51 additions & 0 deletions plugins/antigravity/scripts/actor.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Outputs @{ Email; Name } for the bridges whose host supplies no identity
# (codex, cursor, copilot, antigravity, kiro). Twin of actor.sh, one cascade per
# field: env file -> git config files (scripts/git-identity.ps1, never git.exe)
# -> <login>@<host> / login -> marker "unknown", never blank. plugins/rogue keeps
# its own cascade in hook.ps1 (it screens the Cowork sandbox identity).
#
# Load as a scriptblock (running a .ps1 by path is subject to ExecutionPolicy):
# . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $lib)))
# $actor = Resolve-RogueSharedActor $creds $pluginRoot
# Windows PowerShell 5.1 compatible.

function Read-RogueGitIdentityFile {
param([string]$PluginRoot)
try {
$lib = Join-Path $PluginRoot 'scripts\git-identity.ps1'
if (Test-Path -LiteralPath $lib) {
$id = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $lib)))
if ($id) { return $id }
}
} catch {}
return @{ Email = ''; Name = '' }
}

function Resolve-RogueSharedActor {
param([hashtable]$Creds, [string]$PluginRoot)
if ($null -eq $Creds) { $Creds = @{} }
# Trimmed before the presence test: a whitespace-only ROGUE_ACTOR_* must fall
# through to the git/login cascade rather than ship as blank, and the stored
# value has to match what ship-logs.ps1 (which trims) sends for the same install.
$name = ([string]$Creds['ROGUE_ACTOR_NAME']).Trim()
$email = ([string]$Creds['ROGUE_ACTOR_EMAIL']).Trim()
if (-not $name -or -not $email) {
$git = Read-RogueGitIdentityFile $PluginRoot
if (-not $name) { $name = [string]$git.Name }
if (-not $email) { $email = [string]$git.Email }
}
# [Environment]::UserName reads the process token, so it still answers in the
# service contexts where USERNAME is unset; same for the DNS host name.
$login = [string]$env:USERNAME
if (-not $login) { $login = [string][Environment]::UserName }
if (-not $name) { $name = $login }
if (-not $email) {
$hostName = [string]$env:COMPUTERNAME
if (-not $hostName) { try { $hostName = [string][System.Net.Dns]::GetHostName() } catch {} }
if ($login -and $hostName) { $email = "$login@$hostName" }
elseif ($login) { $email = $login } else { $email = $hostName }
}
if (-not $name) { $name = 'unknown' }
if (-not $email) { $email = 'unknown' }
return @{ Email = $email; Name = $name }
}
41 changes: 36 additions & 5 deletions plugins/antigravity/scripts/actor.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
#!/usr/bin/env bash
# Sourceable. Resolves ROGUE_ACTOR_{EMAIL,NAME} from a cascade.
# Cascade: env → git --global → hostname/whoami.
# Cascade: env → git config files (scripts/git-identity.sh) → login@hostname / login
# → marker "unknown", never blank.
# Sourced by hook.sh and heartbeat.sh after PLUGIN_ROOT is set.

[ -n "${ROGUE_ACTOR_EMAIL:-}" ] || ROGUE_ACTOR_EMAIL="$(git config --global user.email 2>/dev/null)"
[ -n "${ROGUE_ACTOR_NAME:-}" ] || ROGUE_ACTOR_NAME="$(git config --global user.name 2>/dev/null)"
[ -n "${ROGUE_ACTOR_EMAIL:-}" ] || ROGUE_ACTOR_EMAIL="$(hostname 2>/dev/null)"
[ -n "${ROGUE_ACTOR_NAME:-}" ] || ROGUE_ACTOR_NAME="$(whoami 2>/dev/null)"
# Trimmed before the presence test: a whitespace-only ROGUE_ACTOR_* must fall
# through to the git/login cascade rather than ship as blank, and the stored value
# has to match what ship-logs.sh (which trims) sends for the same install.
# Parameter expansion only - this runs on every hook event, so no subshell.
_rogue_trim() {
_rogue_tv="$1"
while :; do case "$_rogue_tv" in [[:space:]]*) _rogue_tv="${_rogue_tv#?}" ;; *) break ;; esac; done
while :; do case "$_rogue_tv" in *[[:space:]]) _rogue_tv="${_rogue_tv%?}" ;; *) break ;; esac; done
}
_rogue_trim "${ROGUE_ACTOR_EMAIL:-}"; ROGUE_ACTOR_EMAIL="$_rogue_tv"
_rogue_trim "${ROGUE_ACTOR_NAME:-}"; ROGUE_ACTOR_NAME="$_rogue_tv"
unset _rogue_tv

if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then
ROGUE_GIT_EMAIL=""; ROGUE_GIT_NAME=""
if [ -r "${PLUGIN_ROOT:-}/scripts/git-identity.sh" ]; then
. "${PLUGIN_ROOT}/scripts/git-identity.sh"
rogue_git_identity
fi
[ -n "${ROGUE_ACTOR_EMAIL:-}" ] || ROGUE_ACTOR_EMAIL="$ROGUE_GIT_EMAIL"
[ -n "${ROGUE_ACTOR_NAME:-}" ] || ROGUE_ACTOR_NAME="$ROGUE_GIT_NAME"

_rogue_login="${USER:-${USERNAME:-$(whoami 2>/dev/null)}}"
_rogue_host="$(hostname 2>/dev/null)"
if [ -z "${ROGUE_ACTOR_EMAIL:-}" ]; then
if [ -n "$_rogue_login" ] && [ -n "$_rogue_host" ]; then ROGUE_ACTOR_EMAIL="$_rogue_login@$_rogue_host"
else ROGUE_ACTOR_EMAIL="${_rogue_login:-$_rogue_host}"; fi
fi
[ -n "${ROGUE_ACTOR_NAME:-}" ] || ROGUE_ACTOR_NAME="$_rogue_login"
unset _rogue_login _rogue_host
fi
: "${ROGUE_ACTOR_EMAIL:=unknown}"
: "${ROGUE_ACTOR_NAME:=unknown}"

export ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME
83 changes: 83 additions & 0 deletions plugins/antigravity/scripts/git-identity.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Outputs @{ Email; Name } from the global git config FILES. git.exe is never
# run (mirrors git-identity.sh: on a Mac without the Command Line Tools `git`
# opens the installer dialog, and one rule must hold on every platform).
#
# Invoke as a scriptblock and take its output:
# $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $lib)))
#
# $XDG_CONFIG_HOME/git/config, then ~/.gitconfig, a later value overriding an
# earlier one as git does, each file followed by its [include] path entries
# (one level; includeIf is not evaluated). Windows PowerShell 5.1 compatible.

function Resolve-RogueGitInclude {
param([string]$Inc, [string]$From, [string]$UserHome)
if ($Inc.StartsWith('~/') -or $Inc.StartsWith('~\')) { return (Join-Path $UserHome $Inc.Substring(2)) }
if ([System.IO.Path]::IsPathRooted($Inc)) { return $Inc }
return (Join-Path (Split-Path -Parent $From) $Inc)
}

function ConvertFrom-RogueGitValue {
# git syntax: a backslash escapes the next character, quotes toggle a region in
# which # and ; are literal, and a comment ends the value outside one.
param([string]$Raw)
$s = $Raw.Trim(); $sb = [System.Text.StringBuilder]::new(); $quoted = $false
for ($i = 0; $i -lt $s.Length; $i++) {
$c = $s[$i]
if ($c -eq '\' -and ($i + 1) -lt $s.Length) {
$i++
# git decodes \n, \t and \b as control characters. A control character
# cannot travel in an HTTP header value and would split the two-line scan
# output of git-identity.sh, so all three land as a space; every other
# escape is the literal character, as git reads it.
if ('n', 't', 'b' -contains $s[$i]) { [void]$sb.Append(' ') }
else { [void]$sb.Append($s[$i]) }
}
elseif ($c -eq '"') { $quoted = -not $quoted }
elseif (-not $quoted -and ($c -eq '#' -or $c -eq ';')) { break }
else { [void]$sb.Append($c) }
}
return $sb.ToString().Trim()
}

function Read-RogueGitConfig {
param([string]$Path, [string]$UserHome, [hashtable]$Id, [int]$Depth)
if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return }
# Per file, not per cascade: an unreadable XDG config must not stop ~/.gitconfig
# from being read, which is what the caller's single outer catch would do.
try { $lines = [System.IO.File]::ReadAllLines($Path) } catch { return }
$section = ''
foreach ($raw in $lines) {
$line = $raw.Trim()
if ($line -eq '' -or $line[0] -eq '#' -or $line[0] -eq ';') { continue }
if ($line[0] -eq '[') {
$section = ($line.Substring(1) -replace '[\]\s"].*$', '').ToLowerInvariant()
continue
}
$eq = $line.IndexOf('=')
if ($eq -lt 1) { continue }
$key = $line.Substring(0, $eq).Trim().ToLowerInvariant()
$val = ConvertFrom-RogueGitValue ($line.Substring($eq + 1))
if ($section -eq 'include' -and $key -eq 'path' -and $Depth -eq 0) {
Read-RogueGitConfig (Resolve-RogueGitInclude $val $Path $UserHome) $UserHome $Id 1
} elseif ($section -eq 'user' -and $val) {
if ($key -eq 'email') { $Id.Email = $val } elseif ($key -eq 'name') { $Id.Name = $val }
}
}
}

function Get-RogueGitIdentity {
$id = @{ Email = ''; Name = '' }
try {
$userHome = $env:HOME
if (-not $userHome) { $userHome = $env:USERPROFILE }
if (-not $userHome) { return $id }
$xdg = $env:XDG_CONFIG_HOME
if (-not $xdg) { $xdg = Join-Path $userHome '.config' }
foreach ($f in @([System.IO.Path]::Combine($xdg, 'git', 'config'), (Join-Path $userHome '.gitconfig'))) {
Read-RogueGitConfig $f $userHome $id 0
}
} catch {}
return $id
}

Get-RogueGitIdentity
78 changes: 78 additions & 0 deletions plugins/antigravity/scripts/git-identity.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env sh
# Sourceable (POSIX sh clean). Sets ROGUE_GIT_EMAIL / ROGUE_GIT_NAME from the
# global git config FILES. The git binary is never run: on a Mac without the
# Command Line Tools, `git` is a stub that opens the installer dialog.
#
# Same rule as git-identity.ps1 and gemini's shared.mjs: $XDG_CONFIG_HOME/git/config,
# then ~/.gitconfig, a later value overriding an earlier one as git does, each file
# followed by its [include] path entries (one level; includeIf is not evaluated).

# Print "E<email>" and "N<name>" (one line each) for the config files given as
# arguments, scanned in order. One awk process for both files and both keys: this
# runs on every hook event. awk reads stdin from /dev/null: an
# `[include] path = /dev/stdin` would otherwise drain the hook payload the bridge
# has not read yet. `bom` is passed in from the shell so its length is counted in
# whatever locale awk runs under.
_rogue_gitcfg_scan() {
awk -v home="$HOME" -v bom="$(printf '\357\273\277')" '
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s }
# git syntax: a backslash escapes the next character, quotes toggle a region in
# which # and ; are literal, and a comment ends the value outside one.
function value(s, out, i, c, q, n, e) {
s = trim(s); out = ""; q = 0; n = length(s)
for (i = 1; i <= n; i++) {
c = substr(s, i, 1)
# git decodes \n, \t and \b as control characters. A control character
# cannot travel in an HTTP header value and would split the two-line output
# below, so all three land as a space; every other escape is the literal
# character, as git reads it.
if (c == "\\" && i < n) {
i++; e = substr(s, i, 1)
out = out ((e == "n" || e == "t" || e == "b") ? " " : e)
}
else if (c == "\"") q = !q
else if (!q && (c == "#" || c == ";")) break
else out = out c
}
return trim(out)
}
function scan(file, depth, line, dir, sect, l, eq, k, v, inc) {
dir = (index(file, "/") ? file : "./" file); sub(/\/[^\/]*$/, "", dir)
while ((getline line < file) > 0) {
if (index(line, bom) == 1) line = substr(line, length(bom) + 1)
l = trim(line)
if (l == "" || l ~ /^[#;]/) continue
if (substr(l, 1, 1) == "[") {
sect = substr(l, 2); sub(/\].*$/, "", sect); sub(/[ \t"].*$/, "", sect)
sect = tolower(sect); continue
}
eq = index(l, "=")
if (eq == 0) continue
k = tolower(trim(substr(l, 1, eq - 1)))
v = value(substr(l, eq + 1))
if (sect == "include" && k == "path" && depth == 0) {
inc = v
if (inc ~ /^~\//) inc = home substr(inc, 2)
else if (inc !~ /^\//) inc = dir "/" inc
scan(inc, 1)
} else if (sect == "user" && v != "") {
if (k == "email") email = v
else if (k == "name") name = v
}
}
close(file)
}
BEGIN { for (i = 1; i < ARGC; i++) scan(ARGV[i], 0); print "E" email; print "N" name }
' "$@" </dev/null 2>/dev/null
}

rogue_git_identity() {
ROGUE_GIT_EMAIL=""
ROGUE_GIT_NAME=""
{ IFS= read -r ROGUE_GIT_EMAIL; IFS= read -r ROGUE_GIT_NAME; } <<EOF || :
$(_rogue_gitcfg_scan "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig")
EOF
ROGUE_GIT_EMAIL="${ROGUE_GIT_EMAIL#E}"
ROGUE_GIT_NAME="${ROGUE_GIT_NAME#N}"
return 0
}
Loading
Loading