From e6a91c84dfeff2e5295384d8017626a9aeb03fbc Mon Sep 17 00:00:00 2001 From: Yuval Date: Fri, 11 Sep 2026 03:28:54 +0300 Subject: [PATCH 1/7] feat(plugins): resolve the actor at hook time in every bridge, git read as a file (FIRE-2117) Every bridge (Claude, Codex, Copilot, Antigravity, Kiro, Cursor on sh and PowerShell; Gemini on Node) now falls back from the env file to the git identity and then to @, so a machine env file that carries no actor still attributes events to a person. The git identity comes from the config files through one shared reader per language (scripts/shared/git-identity.sh, .ps1, and gemini's shared.mjs): $XDG_CONFIG_HOME/git/config then ~/.gitconfig, later wins, one level of [include] path. The git binary is never run, so a Mac without the Command Line Tools can no longer be shown the installer dialog by a hook. Gemini's hook and heartbeat share one resolveActor; the Claude status skill reuses hook.ps1's Resolve-RogueActor instead of its own git-calling copy. Co-Authored-By: Claude Fable 5.1 --- plugins/antigravity/scripts/actor.sh | 25 ++++- plugins/antigravity/scripts/git-identity.ps1 | 59 ++++++++++ plugins/antigravity/scripts/git-identity.sh | 56 ++++++++++ plugins/antigravity/scripts/heartbeat.ps1 | 21 ++-- plugins/antigravity/scripts/hook.ps1 | 21 ++-- plugins/antigravity/scripts/hook.sh | 2 +- plugins/codex/scripts/actor.sh | 25 ++++- plugins/codex/scripts/git-identity.ps1 | 59 ++++++++++ plugins/codex/scripts/git-identity.sh | 56 ++++++++++ plugins/codex/scripts/heartbeat.ps1 | 21 ++-- plugins/codex/scripts/hook.ps1 | 21 ++-- plugins/copilot/scripts/actor.sh | 25 ++++- plugins/copilot/scripts/git-identity.ps1 | 59 ++++++++++ plugins/copilot/scripts/git-identity.sh | 56 ++++++++++ plugins/copilot/scripts/heartbeat.ps1 | 21 ++-- plugins/copilot/scripts/hook.ps1 | 21 ++-- plugins/cursor/scripts/git-identity.ps1 | 59 ++++++++++ plugins/cursor/scripts/git-identity.sh | 56 ++++++++++ plugins/cursor/scripts/hook.ps1 | 28 +++-- plugins/cursor/scripts/hook.sh | 20 ++-- plugins/gemini/scripts/heartbeat.mjs | 14 +-- plugins/gemini/scripts/hook.mjs | 23 +--- plugins/gemini/scripts/shared.mjs | 78 ++++++++++++-- plugins/kiro/scripts/actor.sh | 25 ++++- plugins/kiro/scripts/git-identity.ps1 | 59 ++++++++++ plugins/kiro/scripts/git-identity.sh | 56 ++++++++++ plugins/kiro/scripts/heartbeat.ps1 | 21 ++-- plugins/kiro/scripts/hook.ps1 | 23 ++-- plugins/rogue/scripts/actor.sh | 44 ++++++-- plugins/rogue/scripts/git-identity.ps1 | 59 ++++++++++ plugins/rogue/scripts/git-identity.sh | 56 ++++++++++ plugins/rogue/scripts/heartbeat.ps1 | 46 ++++---- plugins/rogue/scripts/heartbeat.sh | 13 +-- plugins/rogue/scripts/hook.ps1 | 108 +++++++++++-------- plugins/rogue/skills/status/SKILL.md | 34 ++---- scripts/shared/actor.sh | 25 ++++- scripts/shared/git-identity.ps1 | 59 ++++++++++ scripts/shared/git-identity.sh | 56 ++++++++++ scripts/sync-shared-scripts.sh | 2 + 39 files changed, 1272 insertions(+), 240 deletions(-) create mode 100644 plugins/antigravity/scripts/git-identity.ps1 create mode 100644 plugins/antigravity/scripts/git-identity.sh create mode 100644 plugins/codex/scripts/git-identity.ps1 create mode 100644 plugins/codex/scripts/git-identity.sh create mode 100644 plugins/copilot/scripts/git-identity.ps1 create mode 100644 plugins/copilot/scripts/git-identity.sh create mode 100644 plugins/cursor/scripts/git-identity.ps1 create mode 100644 plugins/cursor/scripts/git-identity.sh create mode 100644 plugins/kiro/scripts/git-identity.ps1 create mode 100644 plugins/kiro/scripts/git-identity.sh create mode 100644 plugins/rogue/scripts/git-identity.ps1 create mode 100644 plugins/rogue/scripts/git-identity.sh create mode 100644 scripts/shared/git-identity.ps1 create mode 100644 scripts/shared/git-identity.sh diff --git a/plugins/antigravity/scripts/actor.sh b/plugins/antigravity/scripts/actor.sh index 7a2f09b..ee69bdd 100755 --- a/plugins/antigravity/scripts/actor.sh +++ b/plugins/antigravity/scripts/actor.sh @@ -1,10 +1,25 @@ #!/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. +# 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)" +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 export ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME diff --git a/plugins/antigravity/scripts/git-identity.ps1 b/plugins/antigravity/scripts/git-identity.ps1 new file mode 100644 index 0000000..0830bd9 --- /dev/null +++ b/plugins/antigravity/scripts/git-identity.ps1 @@ -0,0 +1,59 @@ +# 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 Read-RogueGitConfig { + param([string]$Path, [string]$UserHome, [hashtable]$Id, [int]$Depth) + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $section = '' + foreach ($raw in [System.IO.File]::ReadAllLines($Path)) { + $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 = $line.Substring($eq + 1).Trim() + if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } + else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + 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 diff --git a/plugins/antigravity/scripts/git-identity.sh b/plugins/antigravity/scripts/git-identity.sh new file mode 100644 index 0000000..8fd8597 --- /dev/null +++ b/plugins/antigravity/scripts/git-identity.sh @@ -0,0 +1,56 @@ +#!/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 the last value of [$2] $3 in git config file $1 and its includes. +_rogue_gitcfg_value() { + [ -r "$1" ] || return 0 + awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function value(s) { + s = trim(s) + if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } + sub(/[ \t]*[#;].*$/, "", s); return trim(s) + } + function scan(file, depth, line, sect, l, eq, k, v, inc) { + while ((getline line < file) > 0) { + 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 == section && k == key && v != "") found = v + } + close(file) + } + BEGIN { scan(main, 0); if (found != "") print found } + ' 2>/dev/null +} + +rogue_git_identity() { + ROGUE_GIT_EMAIL="" + ROGUE_GIT_NAME="" + for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) + [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) + [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" + done + unset _rogue_gc _rogue_gv + return 0 +} diff --git a/plugins/antigravity/scripts/heartbeat.ps1 b/plugins/antigravity/scripts/heartbeat.ps1 index e319766..5c5e30f 100644 --- a/plugins/antigravity/scripts/heartbeat.ps1 +++ b/plugins/antigravity/scripts/heartbeat.ps1 @@ -158,14 +158,23 @@ function Resolve-BaseUrl { $script:baseUrl = $script:baseUrl.TrimEnd('/') } -# ── actor resolution (mirrors actor.sh) ──────────────────────────────────── +# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME function Resolve-Actor { - $script:actorName = $creds['ROGUE_ACTOR_NAME'] - if (-not $script:actorName) { try { $script:actorName = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} } - if (-not $script:actorName) { $script:actorName = $env:USERNAME } - + $script:actorName = $creds['ROGUE_ACTOR_NAME'] $script:actorEmail = $creds['ROGUE_ACTOR_EMAIL'] - if (-not $script:actorEmail) { try { $script:actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } + if (-not $script:actorName -or -not $script:actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $script:pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $script:actorName) { $script:actorName = [string]$gitId.Name } + if (-not $script:actorEmail) { $script:actorEmail = [string]$gitId.Email } + } + } + if (-not $script:actorName) { $script:actorName = $env:USERNAME } if (-not $script:actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $script:actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $script:actorEmail = $env:USERNAME } else { $script:actorEmail = $env:COMPUTERNAME } diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index 25b549b..3a8a399 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -290,14 +290,23 @@ function Resolve-Url { } } -# ── actor resolution (mirrors actor.sh) ──────────────────────────────────── +# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME function Resolve-Actor { - $script:actorName = $creds['ROGUE_ACTOR_NAME'] - if (-not $script:actorName) { try { $script:actorName = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} } - if (-not $script:actorName) { $script:actorName = $env:USERNAME } - + $script:actorName = $creds['ROGUE_ACTOR_NAME'] $script:actorEmail = $creds['ROGUE_ACTOR_EMAIL'] - if (-not $script:actorEmail) { try { $script:actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } + if (-not $script:actorName -or -not $script:actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $script:pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $script:actorName) { $script:actorName = [string]$gitId.Name } + if (-not $script:actorEmail) { $script:actorEmail = [string]$gitId.Email } + } + } + if (-not $script:actorName) { $script:actorName = $env:USERNAME } if (-not $script:actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $script:actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $script:actorEmail = $env:USERNAME } else { $script:actorEmail = $env:COMPUTERNAME } diff --git a/plugins/antigravity/scripts/hook.sh b/plugins/antigravity/scripts/hook.sh index 4ac45a9..091b0b5 100755 --- a/plugins/antigravity/scripts/hook.sh +++ b/plugins/antigravity/scripts/hook.sh @@ -621,7 +621,7 @@ require_api_key() { } # ROGUE_ACTOR_EMAIL / ROGUE_ACTOR_NAME, resolved by the shared cascade -# (env → git config --global → hostname/whoami). +# (env → git config files → login@hostname / login). load_actor() { [ -r "${PLUGIN_ROOT}/scripts/actor.sh" ] && . "${PLUGIN_ROOT}/scripts/actor.sh" return 0 diff --git a/plugins/codex/scripts/actor.sh b/plugins/codex/scripts/actor.sh index 7a2f09b..ee69bdd 100755 --- a/plugins/codex/scripts/actor.sh +++ b/plugins/codex/scripts/actor.sh @@ -1,10 +1,25 @@ #!/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. +# 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)" +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 export ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME diff --git a/plugins/codex/scripts/git-identity.ps1 b/plugins/codex/scripts/git-identity.ps1 new file mode 100644 index 0000000..0830bd9 --- /dev/null +++ b/plugins/codex/scripts/git-identity.ps1 @@ -0,0 +1,59 @@ +# 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 Read-RogueGitConfig { + param([string]$Path, [string]$UserHome, [hashtable]$Id, [int]$Depth) + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $section = '' + foreach ($raw in [System.IO.File]::ReadAllLines($Path)) { + $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 = $line.Substring($eq + 1).Trim() + if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } + else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + 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 diff --git a/plugins/codex/scripts/git-identity.sh b/plugins/codex/scripts/git-identity.sh new file mode 100644 index 0000000..8fd8597 --- /dev/null +++ b/plugins/codex/scripts/git-identity.sh @@ -0,0 +1,56 @@ +#!/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 the last value of [$2] $3 in git config file $1 and its includes. +_rogue_gitcfg_value() { + [ -r "$1" ] || return 0 + awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function value(s) { + s = trim(s) + if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } + sub(/[ \t]*[#;].*$/, "", s); return trim(s) + } + function scan(file, depth, line, sect, l, eq, k, v, inc) { + while ((getline line < file) > 0) { + 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 == section && k == key && v != "") found = v + } + close(file) + } + BEGIN { scan(main, 0); if (found != "") print found } + ' 2>/dev/null +} + +rogue_git_identity() { + ROGUE_GIT_EMAIL="" + ROGUE_GIT_NAME="" + for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) + [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) + [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" + done + unset _rogue_gc _rogue_gv + return 0 +} diff --git a/plugins/codex/scripts/heartbeat.ps1 b/plugins/codex/scripts/heartbeat.ps1 index 8dc1c59..b2aa434 100644 --- a/plugins/codex/scripts/heartbeat.ps1 +++ b/plugins/codex/scripts/heartbeat.ps1 @@ -123,13 +123,22 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -# ── actor resolution (mirrors actor.sh) ──────────────────────────────────── -$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 } - +# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME +$actorName = $creds['ROGUE_ACTOR_NAME'] $actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorEmail) { try { $actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } +if (-not $actorName -or -not $actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $actorName) { $actorName = [string]$gitId.Name } + if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } + } +} +if (-not $actorName) { $actorName = $env:USERNAME } if (-not $actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } else { $actorEmail = $env:COMPUTERNAME } diff --git a/plugins/codex/scripts/hook.ps1 b/plugins/codex/scripts/hook.ps1 index f8ee877..42f0da4 100644 --- a/plugins/codex/scripts/hook.ps1 +++ b/plugins/codex/scripts/hook.ps1 @@ -244,13 +244,22 @@ if (-not $url) { $url = "$($baseUrl.TrimEnd('/'))/api/v1/hooks/openai" } -# ── actor resolution (mirrors actor.sh) ──────────────────────────────────── -$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 } - +# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME +$actorName = $creds['ROGUE_ACTOR_NAME'] $actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorEmail) { try { $actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } +if (-not $actorName -or -not $actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $actorName) { $actorName = [string]$gitId.Name } + if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } + } +} +if (-not $actorName) { $actorName = $env:USERNAME } if (-not $actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } else { $actorEmail = $env:COMPUTERNAME } diff --git a/plugins/copilot/scripts/actor.sh b/plugins/copilot/scripts/actor.sh index 7a2f09b..ee69bdd 100755 --- a/plugins/copilot/scripts/actor.sh +++ b/plugins/copilot/scripts/actor.sh @@ -1,10 +1,25 @@ #!/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. +# 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)" +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 export ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME diff --git a/plugins/copilot/scripts/git-identity.ps1 b/plugins/copilot/scripts/git-identity.ps1 new file mode 100644 index 0000000..0830bd9 --- /dev/null +++ b/plugins/copilot/scripts/git-identity.ps1 @@ -0,0 +1,59 @@ +# 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 Read-RogueGitConfig { + param([string]$Path, [string]$UserHome, [hashtable]$Id, [int]$Depth) + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $section = '' + foreach ($raw in [System.IO.File]::ReadAllLines($Path)) { + $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 = $line.Substring($eq + 1).Trim() + if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } + else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + 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 diff --git a/plugins/copilot/scripts/git-identity.sh b/plugins/copilot/scripts/git-identity.sh new file mode 100644 index 0000000..8fd8597 --- /dev/null +++ b/plugins/copilot/scripts/git-identity.sh @@ -0,0 +1,56 @@ +#!/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 the last value of [$2] $3 in git config file $1 and its includes. +_rogue_gitcfg_value() { + [ -r "$1" ] || return 0 + awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function value(s) { + s = trim(s) + if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } + sub(/[ \t]*[#;].*$/, "", s); return trim(s) + } + function scan(file, depth, line, sect, l, eq, k, v, inc) { + while ((getline line < file) > 0) { + 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 == section && k == key && v != "") found = v + } + close(file) + } + BEGIN { scan(main, 0); if (found != "") print found } + ' 2>/dev/null +} + +rogue_git_identity() { + ROGUE_GIT_EMAIL="" + ROGUE_GIT_NAME="" + for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) + [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) + [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" + done + unset _rogue_gc _rogue_gv + return 0 +} diff --git a/plugins/copilot/scripts/heartbeat.ps1 b/plugins/copilot/scripts/heartbeat.ps1 index 7d4b2d8..0202fff 100644 --- a/plugins/copilot/scripts/heartbeat.ps1 +++ b/plugins/copilot/scripts/heartbeat.ps1 @@ -125,13 +125,22 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -# ── actor resolution (mirrors actor.sh) ──────────────────────────────────── -$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 } - +# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME +$actorName = $creds['ROGUE_ACTOR_NAME'] $actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorEmail) { try { $actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } +if (-not $actorName -or -not $actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $actorName) { $actorName = [string]$gitId.Name } + if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } + } +} +if (-not $actorName) { $actorName = $env:USERNAME } if (-not $actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } else { $actorEmail = $env:COMPUTERNAME } diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index efdfa3f..a5d0f5a 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -330,13 +330,22 @@ if (-not $url) { $url = "$($baseUrl.TrimEnd('/'))/api/v1/hooks/copilot" } -# ── actor resolution (mirrors actor.sh) ──────────────────────────────────── -$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 } - +# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME +$actorName = $creds['ROGUE_ACTOR_NAME'] $actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorEmail) { try { $actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } +if (-not $actorName -or -not $actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $PluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $actorName) { $actorName = [string]$gitId.Name } + if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } + } +} +if (-not $actorName) { $actorName = $env:USERNAME } if (-not $actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } else { $actorEmail = $env:COMPUTERNAME } diff --git a/plugins/cursor/scripts/git-identity.ps1 b/plugins/cursor/scripts/git-identity.ps1 new file mode 100644 index 0000000..0830bd9 --- /dev/null +++ b/plugins/cursor/scripts/git-identity.ps1 @@ -0,0 +1,59 @@ +# 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 Read-RogueGitConfig { + param([string]$Path, [string]$UserHome, [hashtable]$Id, [int]$Depth) + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $section = '' + foreach ($raw in [System.IO.File]::ReadAllLines($Path)) { + $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 = $line.Substring($eq + 1).Trim() + if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } + else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + 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 diff --git a/plugins/cursor/scripts/git-identity.sh b/plugins/cursor/scripts/git-identity.sh new file mode 100644 index 0000000..8fd8597 --- /dev/null +++ b/plugins/cursor/scripts/git-identity.sh @@ -0,0 +1,56 @@ +#!/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 the last value of [$2] $3 in git config file $1 and its includes. +_rogue_gitcfg_value() { + [ -r "$1" ] || return 0 + awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function value(s) { + s = trim(s) + if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } + sub(/[ \t]*[#;].*$/, "", s); return trim(s) + } + function scan(file, depth, line, sect, l, eq, k, v, inc) { + while ((getline line < file) > 0) { + 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 == section && k == key && v != "") found = v + } + close(file) + } + BEGIN { scan(main, 0); if (found != "") print found } + ' 2>/dev/null +} + +rogue_git_identity() { + ROGUE_GIT_EMAIL="" + ROGUE_GIT_NAME="" + for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) + [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) + [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" + done + unset _rogue_gc _rogue_gv + return 0 +} diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 40dc644..abaeddb 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -817,13 +817,22 @@ $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 } - +# ── actor resolution: explicit creds → git config files → username/hostname ─ +$actorName = $creds['ROGUE_ACTOR_NAME'] $actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorEmail) { try { $actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } +if (-not $actorName -or -not $actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $actorName) { $actorName = [string]$gitId.Name } + if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } + } +} +if (-not $actorName) { $actorName = $env:USERNAME } if (-not $actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } @@ -1092,10 +1101,9 @@ if ($null -ne $hbUnthrottled) { # alone, a long session's log never left the disk. # # Every value travels as an environment variable, so the command is a constant - # with nothing to escape. The actor is passed in, never re-resolved: Cursor's - # cascade ends at "$env:USERNAME@$env:COMPUTERNAME" where actor.sh ends at the - # hostname, so a second cascade would key the log's source row differently - # from the roster row just posted. + # with nothing to escape. The actor is passed in, never re-resolved: a second + # cascade could key the log's source row differently from the roster row just + # posted. $shipScript = Join-Path $pluginRoot 'scripts\ship-logs.ps1' if (Test-Path -LiteralPath $shipScript) { try { diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 6d48b6a..a670887 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -183,15 +183,21 @@ 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 resolution: explicit creds → git config files → whoami/hostname ── +# The git identity is read from the config files (scripts/git-identity.sh), never +# by running git: on a Mac without the Command Line Tools `git` opens the installer. +ROGUE_GIT_EMAIL=""; ROGUE_GIT_NAME="" +if { [ -z "${ROGUE_ACTOR_NAME:-}" ] || [ -z "${ROGUE_ACTOR_EMAIL:-}" ]; } && [ -r "$PLUGIN_ROOT/scripts/git-identity.sh" ]; then + . "$PLUGIN_ROOT/scripts/git-identity.sh" + rogue_git_identity +fi actor_name="${ROGUE_ACTOR_NAME:-}" -[ -n "$actor_name" ] || actor_name="$(_git_cfg user.name)" +[ -n "$actor_name" ] || actor_name="$ROGUE_GIT_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)" +[ -n "$actor_email" ] || actor_email="$ROGUE_GIT_EMAIL" if [ -z "$actor_email" ]; then _u="${USER:-${USERNAME:-$(whoami 2>/dev/null)}}" _h="$(hostname 2>/dev/null)" @@ -782,9 +788,9 @@ if [ -n "$hb_unthrottled" ]; then # The actor MUST be passed explicitly. Unlike the other plugins, which get it # from actor.sh (which exports), this dispatcher resolves the actor into plain # shell LOCALS - so without this prefix the child would inherit nothing, find no - # identity, and skip. It also must not re-resolve: Cursor's own cascade ends at - # "$USER@$(hostname)" where actor.sh ends at `hostname`, so a re-resolve here - # would key the log's source row differently from the roster row just posted. + # identity, and skip. It also must not re-resolve: a second cascade (rogue's + # actor.sh screens sandbox identities, for one) could key the log's source row + # differently from the roster row just posted. if [ -r "$PLUGIN_ROOT/scripts/ship-logs.sh" ]; then ( ROGUE_ACTOR_EMAIL="$actor_email" ROGUE_ACTOR_NAME="$actor_name" \ sh "$PLUGIN_ROOT/scripts/ship-logs.sh" \ diff --git a/plugins/gemini/scripts/heartbeat.mjs b/plugins/gemini/scripts/heartbeat.mjs index 7a56538..865bd31 100644 --- a/plugins/gemini/scripts/heartbeat.mjs +++ b/plugins/gemini/scripts/heartbeat.mjs @@ -31,7 +31,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { EXT_ROOT, loadEnvFiles, gitConfig, installId } from "./shared.mjs"; +import { EXT_ROOT, loadEnvFiles, resolveActor, installId } from "./shared.mjs"; // ── beacon throttle ───────────────────────────────────────────────────────── // A NUMERIC ZERO DISABLES the throttle; a non-numeric value falls back to the @@ -110,16 +110,8 @@ async function main() { const apiKey = env.ROGUE_API_KEY || ""; if (!apiKey) return; // not configured → no-op - const email = - env.ROGUE_ACTOR_EMAIL || gitConfig("user.email") || os.hostname() || ""; - let name = env.ROGUE_ACTOR_NAME || gitConfig("user.name"); - if (!name) { - try { - name = os.userInfo().username; - } catch { - name = ""; - } - } + // Same cascade hook.mjs runs, so the roster row and the event rows agree. + const { email, name } = resolveActor(env); const base = (env.ROGUE_BASE_URL || "https://api.rogue.security").replace( /\/+$/, diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index 41ebaf6..3090142 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -15,7 +15,7 @@ // One cross-platform script replaces the sh + PowerShell dual-dispatcher used by // the Claude/Codex/Cursor plugins: Gemini CLI guarantees Node 20+ on PATH (every // install method requires it; Homebrew declares `node` as a dependency), so we -// use Node built-ins only (global fetch, node:fs/os/path/child_process) — no +// use Node built-ins only (global fetch, node:fs/path/child_process) — no // curl, no jq, no dependencies, no build step. // // Fail-open by design: any missing key / network error / bad response prints @@ -23,7 +23,6 @@ // hook contract; everything else goes to the log file. import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; import { @@ -31,7 +30,7 @@ import { SCRIPT_DIR, SURFACE, loadEnvFiles, - gitConfig, + resolveActor, installId, } from "./shared.mjs"; @@ -143,24 +142,6 @@ function log(msg) { } } -// Actor cascade (mirrors scripts/actor.sh): env → git --global → host/user. -function resolveActor(env) { - const email = - env.ROGUE_ACTOR_EMAIL || - gitConfig("user.email") || - os.hostname() || - "unknown"; - let name = env.ROGUE_ACTOR_NAME || gitConfig("user.name"); - if (!name) { - try { - name = os.userInfo().username; - } catch { - name = "unknown"; - } - } - return { email, name: name || "unknown" }; -} - // ── Detached heartbeat (SessionStart + AfterAgent) ────────────────────────── // Fire-and-forget so it never adds latency to session start or to a turn. // diff --git a/plugins/gemini/scripts/shared.mjs b/plugins/gemini/scripts/shared.mjs index 9c95016..88599ef 100644 --- a/plugins/gemini/scripts/shared.mjs +++ b/plugins/gemini/scripts/shared.mjs @@ -11,7 +11,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { execFileSync } from "node:child_process"; // shared.mjs sits in /scripts/ — the same directory as hook.mjs and // heartbeat.mjs — so these constants match the callers' original values. @@ -142,15 +141,76 @@ export function installId() { return { host, version, agent: SURFACE, error }; } -export function gitConfig(key) { +// ── Git identity from the 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 scripts/shared/git-identity.sh +// and .ps1: $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). +function gitConfigValue(raw) { + const v = raw.trim(); + if (v.startsWith('"')) return v.slice(1).replace(/".*$/, ""); + return v.replace(/\s*[#;].*$/, "").trim(); +} + +function readGitConfig(file, id, depth) { + let text; + try { + text = fs.readFileSync(file, "utf8"); + } catch { + return; + } + let section = ""; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line[0] === "#" || line[0] === ";") continue; + if (line[0] === "[") { + section = line.slice(1).replace(/[\]\s"].*$/, "").toLowerCase(); + continue; + } + const eq = line.indexOf("="); + if (eq < 1) continue; + const key = line.slice(0, eq).trim().toLowerCase(); + const val = gitConfigValue(line.slice(eq + 1)); + if (section === "include" && key === "path" && depth === 0) { + const inc = val.startsWith("~/") + ? path.join(HOME, val.slice(2)) + : path.resolve(path.dirname(file), val); + readGitConfig(inc, id, 1); + } else if (section === "user" && val) { + if (key === "email") id.email = val; + else if (key === "name") id.name = val; + } + } +} + +export function gitIdentity() { + const id = { email: "", name: "" }; + const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, ".config"); + for (const f of [path.join(xdg, "git", "config"), path.join(HOME, ".gitconfig")]) { + readGitConfig(f, id, 0); + } + return id; +} + +// Actor cascade, one implementation for hook.mjs and heartbeat.mjs so the event +// row and the roster row can never carry different identities: +// env file → git config files → @ / . +export function resolveActor(env) { + let email = env.ROGUE_ACTOR_EMAIL || ""; + let name = env.ROGUE_ACTOR_NAME || ""; + if (!email || !name) { + const git = gitIdentity(); + email = email || git.email; + name = name || git.name; + } + let login = ""; try { - return execFileSync("git", ["config", "--global", key], { - timeout: 2000, - stdio: ["ignore", "pipe", "ignore"], - }) - .toString() - .trim(); + login = os.userInfo().username || ""; } catch { - return ""; + /* no passwd entry for this uid */ } + const host = os.hostname() || ""; + if (!email) email = login && host ? `${login}@${host}` : login || host; + return { email: email || "unknown", name: name || login || "unknown" }; } diff --git a/plugins/kiro/scripts/actor.sh b/plugins/kiro/scripts/actor.sh index 7a2f09b..ee69bdd 100755 --- a/plugins/kiro/scripts/actor.sh +++ b/plugins/kiro/scripts/actor.sh @@ -1,10 +1,25 @@ #!/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. +# 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)" +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 export ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME diff --git a/plugins/kiro/scripts/git-identity.ps1 b/plugins/kiro/scripts/git-identity.ps1 new file mode 100644 index 0000000..0830bd9 --- /dev/null +++ b/plugins/kiro/scripts/git-identity.ps1 @@ -0,0 +1,59 @@ +# 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 Read-RogueGitConfig { + param([string]$Path, [string]$UserHome, [hashtable]$Id, [int]$Depth) + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $section = '' + foreach ($raw in [System.IO.File]::ReadAllLines($Path)) { + $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 = $line.Substring($eq + 1).Trim() + if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } + else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + 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 diff --git a/plugins/kiro/scripts/git-identity.sh b/plugins/kiro/scripts/git-identity.sh new file mode 100644 index 0000000..8fd8597 --- /dev/null +++ b/plugins/kiro/scripts/git-identity.sh @@ -0,0 +1,56 @@ +#!/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 the last value of [$2] $3 in git config file $1 and its includes. +_rogue_gitcfg_value() { + [ -r "$1" ] || return 0 + awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function value(s) { + s = trim(s) + if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } + sub(/[ \t]*[#;].*$/, "", s); return trim(s) + } + function scan(file, depth, line, sect, l, eq, k, v, inc) { + while ((getline line < file) > 0) { + 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 == section && k == key && v != "") found = v + } + close(file) + } + BEGIN { scan(main, 0); if (found != "") print found } + ' 2>/dev/null +} + +rogue_git_identity() { + ROGUE_GIT_EMAIL="" + ROGUE_GIT_NAME="" + for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) + [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) + [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" + done + unset _rogue_gc _rogue_gv + return 0 +} diff --git a/plugins/kiro/scripts/heartbeat.ps1 b/plugins/kiro/scripts/heartbeat.ps1 index a5d4cfd..6126187 100644 --- a/plugins/kiro/scripts/heartbeat.ps1 +++ b/plugins/kiro/scripts/heartbeat.ps1 @@ -155,14 +155,23 @@ function Resolve-BaseUrl { $script:baseUrl = $script:baseUrl.TrimEnd('/') } -# ── actor resolution (mirrors actor.sh) ──────────────────────────────────── +# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME function Resolve-Actor { - $script:actorName = $creds['ROGUE_ACTOR_NAME'] - if (-not $script:actorName) { try { $script:actorName = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} } - if (-not $script:actorName) { $script:actorName = $env:USERNAME } - + $script:actorName = $creds['ROGUE_ACTOR_NAME'] $script:actorEmail = $creds['ROGUE_ACTOR_EMAIL'] - if (-not $script:actorEmail) { try { $script:actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } + if (-not $script:actorName -or -not $script:actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $script:pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $script:actorName) { $script:actorName = [string]$gitId.Name } + if (-not $script:actorEmail) { $script:actorEmail = [string]$gitId.Email } + } + } + if (-not $script:actorName) { $script:actorName = $env:USERNAME } if (-not $script:actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $script:actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $script:actorEmail = $env:USERNAME } else { $script:actorEmail = $env:COMPUTERNAME } diff --git a/plugins/kiro/scripts/hook.ps1 b/plugins/kiro/scripts/hook.ps1 index e25972e..77bc8fe 100644 --- a/plugins/kiro/scripts/hook.ps1 +++ b/plugins/kiro/scripts/hook.ps1 @@ -316,14 +316,23 @@ function Initialize-KiroContext { } function Resolve-KiroActor { - # -- actor resolution (mirrors actor.sh) ------------------------------------- - $script:actorName = $creds['ROGUE_ACTOR_NAME'] - if (-not $actorName) { try { $script:actorName = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} } - if (-not $actorName) { $script:actorName = $env:USERNAME } - + # -- actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME + $script:actorName = $creds['ROGUE_ACTOR_NAME'] $script:actorEmail = $creds['ROGUE_ACTOR_EMAIL'] - if (-not $actorEmail) { try { $script:actorEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } - if (-not $actorEmail) { + if (-not $script:actorName -or -not $script:actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $script:pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + if (-not $script:actorName) { $script:actorName = [string]$gitId.Name } + if (-not $script:actorEmail) { $script:actorEmail = [string]$gitId.Email } + } + } + if (-not $script:actorName) { $script:actorName = $env:USERNAME } + if (-not $script:actorEmail) { if ($env:USERNAME -and $env:COMPUTERNAME) { $script:actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } elseif ($env:USERNAME) { $script:actorEmail = $env:USERNAME } else { $script:actorEmail = $env:COMPUTERNAME } } diff --git a/plugins/rogue/scripts/actor.sh b/plugins/rogue/scripts/actor.sh index e4d6e1d..3a76d3c 100644 --- a/plugins/rogue/scripts/actor.sh +++ b/plugins/rogue/scripts/actor.sh @@ -3,13 +3,17 @@ # ROGUE_ACTOR_{EMAIL,NAME} from a cascade. # # Cascade (first NON-SYNTHETIC candidate wins): -# EMAIL: $ROGUE_ACTOR_EMAIL → $CLAUDE_CODE_USER_EMAIL → git --global user.email -# → marker "unknown@" (plain "unknown" with no hostname) +# EMAIL: $ROGUE_ACTOR_EMAIL → $CLAUDE_CODE_USER_EMAIL → git config file user.email +# → "@" (marker "unknown" for a missing/synthetic part) # NAME: $ROGUE_ACTOR_NAME → local-part of $CLAUDE_CODE_USER_EMAIL -# → git --global user.name → whoami → marker "unknown" +# → git config file user.name → login → marker "unknown" # -# CLAUDE_CODE_USER_EMAIL (the authenticated user, set by the Claude host) now -# ranks ABOVE `git config`. In Claude Cowork the agent runs as unix user `claude` +# The git identity comes from the config FILES (scripts/git-identity.sh), never +# from the git binary: on a Mac without the Command Line Tools `git` is a stub +# that opens the installer dialog. +# +# CLAUDE_CODE_USER_EMAIL (the authenticated user, set by the Claude host) ranks +# ABOVE the git identity. In Claude Cowork the agent runs as unix user `claude` # in a sandbox whose git identity is Anthropic's synthetic one # (user.name=Claude / user.email=noreply@anthropic.com), so a git-first cascade # reported every Cowork user as "Claude". On a normal dev machine there is no @@ -41,6 +45,20 @@ _rogue_is_synthetic() { return 1 } +# -- git identity (both fields at once; the files are read only when needed) -- +_rogue_git_loaded=0 +_rogue_load_git() { + [ "$_rogue_git_loaded" = 1 ] && return 0 + _rogue_git_loaded=1 + ROGUE_GIT_EMAIL=""; ROGUE_GIT_NAME="" + _rogue_root="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}" + if [ -r "$_rogue_root/scripts/git-identity.sh" ]; then + . "$_rogue_root/scripts/git-identity.sh" + rogue_git_identity + fi + unset _rogue_root +} + # -- email ------------------------------------------------------------------ _rogue_email="${ROGUE_ACTOR_EMAIL:-}" _rogue_is_synthetic "$_rogue_email" && _rogue_email="" @@ -50,16 +68,19 @@ if [ -z "$_rogue_email" ]; then _rogue_is_synthetic "$_rogue_email" && _rogue_email="" fi if [ -z "$_rogue_email" ]; then - _rogue_email=$(git config --global user.email 2>/dev/null) + _rogue_load_git + _rogue_email="$ROGUE_GIT_EMAIL" _rogue_is_synthetic "$_rogue_email" && _rogue_email="" fi if [ -z "$_rogue_email" ]; then + _rogue_login="${USER:-${USERNAME:-$(whoami 2>/dev/null)}}" + _rogue_is_synthetic "$_rogue_login" && _rogue_login="unknown" _rogue_host=$(hostname 2>/dev/null) _rogue_is_synthetic "$_rogue_host" && _rogue_host="" if [ -n "$_rogue_host" ]; then - _rogue_email="unknown@$_rogue_host" + _rogue_email="$_rogue_login@$_rogue_host" else - _rogue_email="unknown" + _rogue_email="$_rogue_login" fi fi @@ -80,11 +101,12 @@ if [ -z "$_rogue_name" ]; then _rogue_is_synthetic "$_rogue_name" && _rogue_name="" fi if [ -z "$_rogue_name" ]; then - _rogue_name=$(git config --global user.name 2>/dev/null) + _rogue_load_git + _rogue_name="$ROGUE_GIT_NAME" _rogue_is_synthetic "$_rogue_name" && _rogue_name="" fi if [ -z "$_rogue_name" ]; then - _rogue_name=$(whoami 2>/dev/null) + _rogue_name="${USER:-${USERNAME:-$(whoami 2>/dev/null)}}" _rogue_is_synthetic "$_rogue_name" && _rogue_name="" fi [ -n "$_rogue_name" ] || _rogue_name="unknown" @@ -93,4 +115,4 @@ ROGUE_ACTOR_EMAIL="$_rogue_email" ROGUE_ACTOR_NAME="$_rogue_name" export ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME -unset _rogue_v _rogue_email _rogue_name _rogue_host _rogue_hostmail +unset _rogue_v _rogue_email _rogue_name _rogue_host _rogue_hostmail _rogue_login _rogue_git_loaded diff --git a/plugins/rogue/scripts/git-identity.ps1 b/plugins/rogue/scripts/git-identity.ps1 new file mode 100644 index 0000000..0830bd9 --- /dev/null +++ b/plugins/rogue/scripts/git-identity.ps1 @@ -0,0 +1,59 @@ +# 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 Read-RogueGitConfig { + param([string]$Path, [string]$UserHome, [hashtable]$Id, [int]$Depth) + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $section = '' + foreach ($raw in [System.IO.File]::ReadAllLines($Path)) { + $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 = $line.Substring($eq + 1).Trim() + if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } + else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + 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 diff --git a/plugins/rogue/scripts/git-identity.sh b/plugins/rogue/scripts/git-identity.sh new file mode 100644 index 0000000..8fd8597 --- /dev/null +++ b/plugins/rogue/scripts/git-identity.sh @@ -0,0 +1,56 @@ +#!/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 the last value of [$2] $3 in git config file $1 and its includes. +_rogue_gitcfg_value() { + [ -r "$1" ] || return 0 + awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function value(s) { + s = trim(s) + if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } + sub(/[ \t]*[#;].*$/, "", s); return trim(s) + } + function scan(file, depth, line, sect, l, eq, k, v, inc) { + while ((getline line < file) > 0) { + 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 == section && k == key && v != "") found = v + } + close(file) + } + BEGIN { scan(main, 0); if (found != "") print found } + ' 2>/dev/null +} + +rogue_git_identity() { + ROGUE_GIT_EMAIL="" + ROGUE_GIT_NAME="" + for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) + [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) + [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" + done + unset _rogue_gc _rogue_gv + return 0 +} diff --git a/plugins/rogue/scripts/heartbeat.ps1 b/plugins/rogue/scripts/heartbeat.ps1 index 07f1543..adef7bf 100644 --- a/plugins/rogue/scripts/heartbeat.ps1 +++ b/plugins/rogue/scripts/heartbeat.ps1 @@ -167,40 +167,42 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -# -- actor resolution (mirrors actor.sh / hook.ps1: first non-synthetic wins) - +# -- actor resolution (mirrors actor.sh / hook.ps1 Resolve-RogueActor) -------- # Screen the WHOLE address before splitting it. Taking the local-part first # smuggles the sandbox identity past the screen: noreply@anthropic.com is # rejected as an email, but its local-part "noreply" is not on the list. $hostMail = Select-ActorValue @($env:CLAUDE_CODE_USER_EMAIL) -$actorName = Select-ActorValue @( - $creds['ROGUE_ACTOR_NAME'], - (($hostMail -split '@')[0]) -) -if (-not $actorName) { - $gitName = '' - try { $gitName = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} - # POSIX ends this cascade at `whoami`. Windows deliberately does NOT shell out - # to whoami.exe: its output is DOMAIN\user, a different identity string that - # would re-fingerprint every existing roster row, and it costs a process per - # hook. [Environment]::UserName is the true twin — it reads the process token, - # so it still answers in the service contexts where USERNAME is unset. - $actorName = Select-ActorValue @($gitName, $env:USERNAME, [Environment]::UserName) -} -if (-not $actorName) { $actorName = 'unknown' } - +$actorName = Select-ActorValue @($creds['ROGUE_ACTOR_NAME'], (($hostMail -split '@')[0])) $actorEmail = Select-ActorValue @($creds['ROGUE_ACTOR_EMAIL'], $env:CLAUDE_CODE_USER_EMAIL) -if (-not $actorEmail) { - $gitEmail = '' - try { $gitEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} - $actorEmail = Select-ActorValue @($gitEmail) +if (-not $actorName -or -not $actorEmail) { + # Git identity from the config files (scripts/git-identity.ps1), never git.exe. + $gitId = $null + try { + $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + } catch {} + if ($gitId) { + $actorName = Select-ActorValue @($actorName, [string]$gitId.Name) + $actorEmail = Select-ActorValue @($actorEmail, [string]$gitId.Email) + } } +# POSIX ends this cascade at `whoami`. Windows deliberately does NOT shell out +# to whoami.exe: its output is DOMAIN\user, a different identity string that +# would re-fingerprint every existing roster row, and it costs a process per +# hook. [Environment]::UserName is the true twin - it reads the process token, +# so it still answers in the service contexts where USERNAME is unset. +$login = Select-ActorValue @($env:USERNAME, [Environment]::UserName) +if (-not $actorName) { $actorName = $login } +if (-not $actorName) { $actorName = 'unknown' } if (-not $actorEmail) { # Same fallback the roster host below already uses: COMPUTERNAME can be unset # in service contexts, where the sh twin's `hostname` still answers. $dnsHost = '' try { $dnsHost = [System.Net.Dns]::GetHostName() } catch {} $hostForActor = Select-ActorValue @($env:COMPUTERNAME, $dnsHost) - if ($hostForActor) { $actorEmail = "unknown@$hostForActor" } else { $actorEmail = 'unknown' } + $who = $login + if (-not $who) { $who = 'unknown' } + if ($hostForActor) { $actorEmail = "$who@$hostForActor" } else { $actorEmail = $who } } # -- plugin version (regex from manifest, no python) ------------------------ diff --git a/plugins/rogue/scripts/heartbeat.sh b/plugins/rogue/scripts/heartbeat.sh index 78bdd36..f060052 100755 --- a/plugins/rogue/scripts/heartbeat.sh +++ b/plugins/rogue/scripts/heartbeat.sh @@ -129,12 +129,13 @@ fi # means the common case is that it makes no request at all. # # The actor is PASSED IN, never re-resolved. The shipper deliberately carries no -# cascade of its own: the plugins' cascades differ (actor.sh ends at `hostname`, -# Cursor's at "$USER@$(hostname)"), so a re-resolve would key the log's source -# row differently from the roster row this script just posted, and the logs would -# attach to nothing. actor.sh exports both vars, so the child would inherit them -# anyway - the explicit prefix states the contract at the call site and also -# covers an install whose actor.sh predates that export. +# cascade of its own: the plugins' cascades differ (this actor.sh screens sandbox +# identities and reads CLAUDE_CODE_USER_EMAIL, Cursor's does neither), so a +# re-resolve would key the log's source row differently from the roster row this +# script just posted, and the logs would attach to nothing. actor.sh exports both +# vars, so the child would inherit them anyway - the explicit prefix states the +# contract at the call site and also covers an install whose actor.sh predates +# that export. # # `-r` guarded so a partial or older install is a no-op rather than an error, and # `|| true` because this script runs under `set -u` and must exit 0 regardless. diff --git a/plugins/rogue/scripts/hook.ps1 b/plugins/rogue/scripts/hook.ps1 index 4dbf7f4..a79ddb3 100644 --- a/plugins/rogue/scripts/hook.ps1 +++ b/plugins/rogue/scripts/hook.ps1 @@ -242,14 +242,71 @@ function Test-SyntheticActor { function Select-ActorValue { # First non-synthetic candidate, or '' when every one is rejected. Callers - # invoke it in stages so an expensive candidate (git config) is only computed - # when the cheap ones have already been rejected. + # invoke it in stages so the git config files are only read when the cheap + # candidates have already been rejected. param([string[]]$Candidates) if ($null -eq $Candidates) { return '' } foreach ($c in $Candidates) { if (-not (Test-SyntheticActor $c)) { return $c } } return '' } +function Get-RogueGitIdentity { + # user.email / user.name from the git config FILES through scripts/git-identity.ps1, + # never git.exe (one rule with actor.sh). Empty fields when the library or the + # files are missing. + param([string]$PluginRoot) + $id = $null + try { + $lib = Join-Path $PluginRoot 'scripts\git-identity.ps1' + if (Test-Path -LiteralPath $lib) { $id = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $lib))) } + } catch {} + if (-not $id) { $id = @{ Email = ''; Name = '' } } + return $id +} + +function Resolve-RogueActor { + # Mirrors actor.sh: first NON-SYNTHETIC candidate wins. + # EMAIL: ROGUE_ACTOR_EMAIL -> CLAUDE_CODE_USER_EMAIL -> git config file user.email + # -> @ (marker "unknown" for a missing part) + # NAME: ROGUE_ACTOR_NAME -> local-part of CLAUDE_CODE_USER_EMAIL + # -> git config file user.name -> USERNAME / [Environment]::UserName + # -> marker unknown + # The explicit ROGUE_ACTOR_* values are screened too - compiled bundles already + # in the field bake a git-config pre-seed into ${CLAUDE_PLUGIN_ROOT}\env, so a + # plugin update can only fix them if we distrust a poisoned value. See actor.sh. + param([hashtable]$Creds, [string]$PluginRoot) + # Screen the WHOLE address before splitting it. Taking the local-part first + # smuggles the sandbox identity past the screen: noreply@anthropic.com is + # rejected as an email, but its local-part "noreply" is not on the list. + $hostMail = Select-ActorValue @($env:CLAUDE_CODE_USER_EMAIL) + $name = Select-ActorValue @($Creds['ROGUE_ACTOR_NAME'], (($hostMail -split '@')[0])) + $email = Select-ActorValue @($Creds['ROGUE_ACTOR_EMAIL'], $env:CLAUDE_CODE_USER_EMAIL) + if (-not $name -or -not $email) { + $git = Get-RogueGitIdentity $PluginRoot + $name = Select-ActorValue @($name, [string]$git.Name) + $email = Select-ActorValue @($email, [string]$git.Email) + } + # POSIX ends this cascade at `whoami`. Windows deliberately does NOT shell out + # to whoami.exe: its output is DOMAIN\user, a different identity string that + # would re-fingerprint every existing roster row, and it costs a process per + # hook. [Environment]::UserName is the true twin - it reads the process token, + # so it still answers in the service contexts where USERNAME is unset. + $login = Select-ActorValue @($env:USERNAME, [Environment]::UserName) + if (-not $name) { $name = $login } + if (-not $name) { $name = 'unknown' } + if (-not $email) { + # Same fallback the roster host uses: COMPUTERNAME can be unset in service + # contexts, where the sh twin's `hostname` still answers. + $dnsHost = '' + try { $dnsHost = [System.Net.Dns]::GetHostName() } catch {} + $hostForActor = Select-ActorValue @($env:COMPUTERNAME, $dnsHost) + $who = $login + if (-not $who) { $who = 'unknown' } + if ($hostForActor) { $email = "$who@$hostForActor" } else { $email = $who } + } + return @{ Email = $email; Name = $name } +} + function Test-WantAlert { # True when a native block modal should be fired for this event. Twin of # hook.sh's _rogue_want_alert — keep the two in lockstep; there is no shared @@ -388,49 +445,10 @@ $baseUrl = $creds['ROGUE_BASE_URL'] if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -# -- actor resolution (mirrors actor.sh, first NON-SYNTHETIC candidate wins) -- -# EMAIL: ROGUE_ACTOR_EMAIL -> CLAUDE_CODE_USER_EMAIL -> git config user.email -# -> marker unknown@ -# NAME: ROGUE_ACTOR_NAME -> local-part of CLAUDE_CODE_USER_EMAIL -# -> git config user.name -> USERNAME / [Environment]::UserName -# -> marker unknown -# The explicit ROGUE_ACTOR_* values are screened too - compiled bundles already -# in the field bake a git-config pre-seed into ${CLAUDE_PLUGIN_ROOT}\env, so a -# plugin update can only fix them if we distrust a poisoned value. See actor.sh. -# Screen the WHOLE address before splitting it. Taking the local-part first -# smuggles the sandbox identity past the screen: noreply@anthropic.com is -# rejected as an email, but its local-part "noreply" is not on the list. -$hostMail = Select-ActorValue @($env:CLAUDE_CODE_USER_EMAIL) -$actorName = Select-ActorValue @( - $creds['ROGUE_ACTOR_NAME'], - (($hostMail -split '@')[0]) -) -if (-not $actorName) { - $gitName = '' - try { $gitName = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} - # POSIX ends this cascade at `whoami`. Windows deliberately does NOT shell out - # to whoami.exe: its output is DOMAIN\user, a different identity string that - # would re-fingerprint every existing roster row, and it costs a process per - # hook. [Environment]::UserName is the true twin — it reads the process token, - # so it still answers in the service contexts where USERNAME is unset. - $actorName = Select-ActorValue @($gitName, $env:USERNAME, [Environment]::UserName) -} -if (-not $actorName) { $actorName = 'unknown' } - -$actorEmail = Select-ActorValue @($creds['ROGUE_ACTOR_EMAIL'], $env:CLAUDE_CODE_USER_EMAIL) -if (-not $actorEmail) { - $gitEmail = '' - try { $gitEmail = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} - $actorEmail = Select-ActorValue @($gitEmail) -} -if (-not $actorEmail) { - # Same fallback the roster host below already uses: COMPUTERNAME can be unset - # in service contexts, where the sh twin's `hostname` still answers. - $dnsHost = '' - try { $dnsHost = [System.Net.Dns]::GetHostName() } catch {} - $hostForActor = Select-ActorValue @($env:COMPUTERNAME, $dnsHost) - if ($hostForActor) { $actorEmail = "unknown@$hostForActor" } else { $actorEmail = 'unknown' } -} +# -- actor resolution (Resolve-RogueActor, above the seam so tests can drive it) -- +$actor = Resolve-RogueActor $creds $pluginRoot +$actorName = [string]$actor.Name +$actorEmail = [string]$actor.Email # -- install identity: host + version + surface label ------------------------ # The fleet roster keys an install on host + actor + family + agent, and until diff --git a/plugins/rogue/skills/status/SKILL.md b/plugins/rogue/skills/status/SKILL.md index 94880f2..1b754e4 100644 --- a/plugins/rogue/skills/status/SKILL.md +++ b/plugins/rogue/skills/status/SKILL.md @@ -203,12 +203,13 @@ missing file with a healthy connection just means no events have fired yet. If either is unset: - **A real address and name** — nothing to do. -- **`unknown@` / `unknown`** — no usable identity was found anywhere: the - cascade tried `ROGUE_ACTOR_*`, `CLAUDE_CODE_USER_EMAIL`, `git config --global` - and `whoami`, and either found them empty or rejected them as the sandbox's - synthetic `Claude `. Events still POST and are still - enforced; they are just attributed to a marker instead of a person. Fix by - setting a real git identity, or by provisioning `ROGUE_ACTOR_*` explicitly: +- **`@` / ``** — no identity was found in the env file, + `CLAUDE_CODE_USER_EMAIL` or the git config files (`~/.gitconfig`, read as a + file), so the login name stands in. **`unknown@` / `unknown`** means even + the login was rejected as the sandbox's synthetic `Claude `. + Events still POST and are still enforced; they are just attributed to a marker + instead of a person. Fix by setting a real git identity, or by provisioning + `ROGUE_ACTOR_*` explicitly: - **Managed deployment**: the MDM script (`mdm-provision-actor.sh`) hasn't run yet or ran with empty placeholders. Force an enforcement run on your MDM (Kandji "Run library item now", `sudo jamf policy`). @@ -443,23 +444,10 @@ $hookPs1 = Get-ChildItem "$env:USERPROFILE\.claude\plugins" -Recurse -Filter hoo $actorEmail = [string]$creds['ROGUE_ACTOR_EMAIL']; $actorName = [string]$creds['ROGUE_ACTOR_NAME'] if ($hookPs1) { $env:ROGUE_PS_LIB_ONLY = '1'; . $hookPs1.FullName; $env:ROGUE_PS_LIB_ONLY = $null - # Mirrors the cascade in hook.ps1 / heartbeat.ps1 — keep all three in step. - $hostMail = Select-ActorValue @($env:CLAUDE_CODE_USER_EMAIL) - $actorName = Select-ActorValue @($creds['ROGUE_ACTOR_NAME'], (($hostMail -split '@')[0])) - if (-not $actorName) { - $gn = ''; try { $gn = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} - $actorName = Select-ActorValue @($gn, $env:USERNAME, [Environment]::UserName) - } - if (-not $actorName) { $actorName = 'unknown' } - $actorEmail = Select-ActorValue @($creds['ROGUE_ACTOR_EMAIL'], $env:CLAUDE_CODE_USER_EMAIL) - if (-not $actorEmail) { - $ge = ''; try { $ge = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} - $actorEmail = Select-ActorValue @($ge) - } - if (-not $actorEmail) { - $h = Select-ActorValue @($env:COMPUTERNAME, $dnsHost) - if ($h) { $actorEmail = "unknown@$h" } else { $actorEmail = 'unknown' } - } + # The very cascade hook.ps1 runs (env file -> CLAUDE_CODE_USER_EMAIL -> git config + # files -> login@host), so this can never report a different actor than the hooks. + $a = Resolve-RogueActor $creds (Split-Path (Split-Path $hookPs1.FullName -Parent) -Parent) + $actorEmail = [string]$a.Email; $actorName = [string]$a.Name } else { 'WARNING: hook.ps1 not found - reporting raw env values, which may be a sandbox identity' } diff --git a/scripts/shared/actor.sh b/scripts/shared/actor.sh index 7a2f09b..ee69bdd 100755 --- a/scripts/shared/actor.sh +++ b/scripts/shared/actor.sh @@ -1,10 +1,25 @@ #!/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. +# 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)" +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 export ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME diff --git a/scripts/shared/git-identity.ps1 b/scripts/shared/git-identity.ps1 new file mode 100644 index 0000000..0830bd9 --- /dev/null +++ b/scripts/shared/git-identity.ps1 @@ -0,0 +1,59 @@ +# 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 Read-RogueGitConfig { + param([string]$Path, [string]$UserHome, [hashtable]$Id, [int]$Depth) + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $section = '' + foreach ($raw in [System.IO.File]::ReadAllLines($Path)) { + $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 = $line.Substring($eq + 1).Trim() + if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } + else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + 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 diff --git a/scripts/shared/git-identity.sh b/scripts/shared/git-identity.sh new file mode 100644 index 0000000..8fd8597 --- /dev/null +++ b/scripts/shared/git-identity.sh @@ -0,0 +1,56 @@ +#!/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 the last value of [$2] $3 in git config file $1 and its includes. +_rogue_gitcfg_value() { + [ -r "$1" ] || return 0 + awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function value(s) { + s = trim(s) + if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } + sub(/[ \t]*[#;].*$/, "", s); return trim(s) + } + function scan(file, depth, line, sect, l, eq, k, v, inc) { + while ((getline line < file) > 0) { + 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 == section && k == key && v != "") found = v + } + close(file) + } + BEGIN { scan(main, 0); if (found != "") print found } + ' 2>/dev/null +} + +rogue_git_identity() { + ROGUE_GIT_EMAIL="" + ROGUE_GIT_NAME="" + for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) + [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" + _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) + [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" + done + unset _rogue_gc _rogue_gv + return 0 +} diff --git a/scripts/sync-shared-scripts.sh b/scripts/sync-shared-scripts.sh index 793cdcb..082f7a2 100644 --- a/scripts/sync-shared-scripts.sh +++ b/scripts/sync-shared-scripts.sh @@ -42,6 +42,8 @@ ROWS=( "beacon.ps1|rogue codex cursor copilot antigravity kiro" "env-file.sh|rogue codex cursor copilot antigravity kiro" "env-file.ps1|rogue codex cursor copilot antigravity kiro" + "git-identity.sh|rogue codex cursor copilot antigravity kiro" + "git-identity.ps1|rogue codex cursor copilot antigravity kiro" "actor.sh|codex copilot antigravity kiro" ) From de2a2fed66b2deeb91043f0e3828284eb04e53d9 Mon Sep 17 00:00:00 2001 From: Yuval Date: Fri, 11 Sep 2026 03:28:54 +0300 Subject: [PATCH 2/7] test(plugins): cover the three actor fallback levels per language (FIRE-2117) sh: test_actor_sh.sh drives the Claude actor.sh and the shared actor.sh through env file, ~/.gitconfig (plus include and XDG), and login@hostname, with a stub git on PATH as a tripwire. PowerShell: test_git_identity_ps1.ps1 covers the shared reader and hook.ps1's Resolve-RogueActor, and greps every dispatcher for the old git call. Node: test_hook_mjs.mjs runs the three levels through the real Gemini dispatcher with the same tripwire. The sandbox PATH lists in the dispatcher suites trade git for awk. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/validate.yml | 3 + tests/test_actor_sh.sh | 153 +++++++++++++++++++++++------ tests/test_git_identity_ps1.ps1 | 168 ++++++++++++++++++++++++++++++++ tests/test_hook_mjs.mjs | 84 +++++++++++++++- tests/test_hook_ps1.ps1 | 24 +++-- tests/test_hook_sh.sh | 5 +- tests/test_hook_sh_kiro.sh | 2 +- tests/test_status_skill_sh.sh | 24 ++++- 8 files changed, 415 insertions(+), 48 deletions(-) create mode 100644 tests/test_git_identity_ps1.ps1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f9cdfb8..e16fd73 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 @@ -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 diff --git a/tests/test_actor_sh.sh b/tests/test_actor_sh.sh index ca035e8..6a25efe 100755 --- a/tests/test_actor_sh.sh +++ b/tests/test_actor_sh.sh @@ -1,37 +1,31 @@ #!/usr/bin/env bash -# tests/test_actor_sh.sh — the actor identity cascade (plugins/rogue/scripts/actor.sh). +# tests/test_actor_sh.sh — the actor identity cascade: plugins/rogue/scripts/actor.sh +# (Claude: screens the Cowork sandbox identity, ranks CLAUDE_CODE_USER_EMAIL above +# git) and scripts/shared/actor.sh (codex/copilot/antigravity/kiro, tested through +# the synced codex copy). Both read the git identity from the config FILES via +# scripts/git-identity.sh, so a stub `git` ahead of PATH is a tripwire: on a Mac +# without the Command Line Tools `git` is a stub that opens the installer dialog, +# and a hook must never trigger it. # -# Why this file exists: in Claude Cowork the hook runs in a sandbox as unix user -# `claude`, with git configured as Anthropic's synthetic -# "Claude ", so a git-config-first cascade reported EVERY -# Cowork user as "Claude". actor.sh now ranks CLAUDE_CODE_USER_EMAIL (the real -# authenticated user, set by the Claude host) above git config and rejects -# synthetic identities from ANY source — including ROGUE_ACTOR_*, which compiled -# bundles already in the field pre-seed from git config. +# Three fallback levels per bridge: env file → git config files → login@hostname. # # actor.sh is sourced by hook.sh, which hooks.json invokes via `sh`; override with # TEST_SH=dash to exercise strict POSIX (Debian/Ubuntu /bin/sh) and catch bashisms. set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" -ACTOR="$REPO/plugins/rogue/scripts/actor.sh" +ROGUE_ACTOR="$REPO/plugins/rogue/scripts/actor.sh" +SHARED_ACTOR="$REPO/plugins/codex/scripts/actor.sh" SH="${TEST_SH:-sh}" -# Stub git / hostname / whoami so the cascade's environment-dependent candidates -# are deterministic (the machine running the tests has its own git identity). STUB="$(mktemp -d)" -cleanup() { rm -rf "$STUB"; } +FAKE_HOME="$(mktemp -d)" +TRIPWIRE="$STUB/git-invoked" +cleanup() { rm -rf "$STUB" "$FAKE_HOME"; } trap cleanup EXIT -cat > "$STUB/git" <<'EOF' -#!/bin/sh -# Only `git config --global user.{email,name}` is used by actor.sh. -case "$*" in - *user.email*) [ -n "${STUB_GIT_EMAIL:-}" ] || exit 1; printf '%s\n' "$STUB_GIT_EMAIL" ;; - *user.name*) [ -n "${STUB_GIT_NAME:-}" ] || exit 1; printf '%s\n' "$STUB_GIT_NAME" ;; - *) exit 1 ;; -esac -EOF +# hostname / whoami stubs make the login@host level deterministic; `git` records +# every invocation and fails, so a cascade that still shells out is caught twice. cat > "$STUB/hostname" <<'EOF' #!/bin/sh [ -n "${STUB_HOSTNAME:-}" ] || exit 1 @@ -42,21 +36,41 @@ cat > "$STUB/whoami" <<'EOF' [ -n "${STUB_WHOAMI:-}" ] || exit 1 printf '%s\n' "$STUB_WHOAMI" EOF -chmod +x "$STUB/git" "$STUB/hostname" "$STUB/whoami" +cat > "$STUB/git" <> "$TRIPWIRE" +exit 1 +EOF +chmod +x "$STUB/hostname" "$STUB/whoami" "$STUB/git" + +# Writes the [user] section GIT_EMAIL / GIT_NAME describe into ~/.gitconfig (no +# file at all when both are empty), after clearing every git config file. +write_gitconfig() { + rm -rf "$FAKE_HOME/.gitconfig" "$FAKE_HOME/.gitconfig-work" "$FAKE_HOME/.config" + if [ -n "$GIT_EMAIL" ] || [ -n "$GIT_NAME" ]; then + { + echo "[user]" + if [ -n "$GIT_EMAIL" ]; then printf '\temail = %s\n' "$GIT_EMAIL"; fi + if [ -n "$GIT_NAME" ]; then printf '\tname = %s\n' "$GIT_NAME"; fi + } > "$FAKE_HOME/.gitconfig" + fi +} -# Source actor.sh in a fresh shell and print what it resolved. Empty is passed -# instead of unset on purpose: actor.sh must treat both the same way. +# Source the actor script under test in a fresh shell and print what it resolved. +# Empty is passed instead of unset on purpose: the cascade must treat both alike. +# USER/USERNAME are cleared so the login level goes through the whoami stub. resolve() { - PATH="$STUB:$PATH" \ + HOME="$FAKE_HOME" XDG_CONFIG_HOME= PATH="$STUB:$PATH" USER="${LOGIN_ENV:-}" USERNAME= \ + CLAUDE_PLUGIN_ROOT="${ROOT_DIR:-$REPO/plugins/rogue}" PLUGIN_ROOT="${ROOT_DIR:-$REPO/plugins/codex}" \ ROGUE_ACTOR_EMAIL="${SEED_EMAIL:-}" ROGUE_ACTOR_NAME="${SEED_NAME:-}" \ CLAUDE_CODE_USER_EMAIL="${HOST_EMAIL:-}" \ - STUB_GIT_EMAIL="${GIT_EMAIL:-}" STUB_GIT_NAME="${GIT_NAME:-}" \ STUB_HOSTNAME="${HOST_NAME:-}" STUB_WHOAMI="${WHO:-}" \ "$SH" -c '. "$1"; printf "%s|%s" "$ROGUE_ACTOR_EMAIL" "$ROGUE_ACTOR_NAME"' _ "$ACTOR" } assert_actor() { local expected="$1" label="$2" actual + write_gitconfig actual="$(resolve)" if [ "$actual" != "$expected" ]; then echo "FAIL [$label]: expected <$expected> but got <$actual>" >&2; exit 1 @@ -66,14 +80,17 @@ assert_actor() { # Every case sets the whole environment explicitly, so no state leaks between them. scenario() { - SEED_EMAIL=""; SEED_NAME=""; HOST_EMAIL="" + SEED_EMAIL=""; SEED_NAME=""; HOST_EMAIL=""; LOGIN_ENV=""; ROOT_DIR="" GIT_EMAIL=""; GIT_NAME=""; HOST_NAME="devbox"; WHO="jane" } +echo "── plugins/rogue/scripts/actor.sh ──" +ACTOR="$ROGUE_ACTOR" + # ── Case 1: normal dev machine — real git identity, no host email (no regression) scenario GIT_EMAIL="jane@corp.com"; GIT_NAME="Jane Dev" -assert_actor "jane@corp.com|Jane Dev" "git identity still wins when CLAUDE_CODE_USER_EMAIL is absent" +assert_actor "jane@corp.com|Jane Dev" "git identity from ~/.gitconfig wins when CLAUDE_CODE_USER_EMAIL is absent" # ── Case 2: CLAUDE_CODE_USER_EMAIL outranks a real git identity ─────────────── scenario @@ -135,12 +152,12 @@ assert_actor "claude.dubois@corp.com|Claudia Claude-Smith" "names merely contain # ── Case 11: fields resolve independently (synthetic email, real git name) ──── scenario GIT_EMAIL="noreply@anthropic.com"; GIT_NAME="Jane Dev"; HOST_NAME="devbox" -assert_actor "unknown@devbox|Jane Dev" "email and name cascades are independent" +assert_actor "jane@devbox|Jane Dev" "email and name cascades are independent" -# ── Case 12: whoami is the last human candidate for the name ───────────────── +# ── Case 12: no git identity → login@hostname / login, never blank ──────────── scenario GIT_EMAIL=""; GIT_NAME=""; WHO="jane"; HOST_NAME="devbox" -assert_actor "unknown@devbox|jane" "whoami used for name when git config is absent" +assert_actor "jane@devbox|jane" "login@hostname and login when no git identity exists" # ── Case 13: the synthetic host email must not leak in through its local-part ─ # Screening the full address but splitting it first would report the actor as @@ -157,5 +174,77 @@ HOST_EMAIL="claude@corp.com" GIT_EMAIL=""; GIT_NAME=""; WHO="jane"; HOST_NAME="devbox" assert_actor "claude@corp.com|jane" "real address kept as email, unusable local-part falls through" +# ── Case 15: $USER outranks whoami for the login level ─────────────────────── +scenario +LOGIN_ENV="envuser"; WHO="jane" +assert_actor "envuser@devbox|envuser" "USER from the environment is the login when set" + +# ── Case 16: [include] path is followed, one level ──────────────────────────── +scenario +write_gitconfig +printf '[include]\n\tpath = ~/.gitconfig-work\n' > "$FAKE_HOME/.gitconfig" +printf '[user]\n\temail = work@corp.com\n\tname = "Work Me"\n' > "$FAKE_HOME/.gitconfig-work" +actual="$(resolve)" +[ "$actual" = "work@corp.com|Work Me" ] || { echo "FAIL [include]: got <$actual>" >&2; exit 1; } +echo " ok: identity inside an [include] path file is used (quoted value unwrapped)" + +# ── Case 17: [includeIf] is not evaluated ───────────────────────────────────── +scenario +write_gitconfig +printf '[includeIf "gitdir:~/work/"]\n\tpath = ~/.gitconfig-work\n' > "$FAKE_HOME/.gitconfig" +printf '[user]\n\temail = never@corp.com\n' > "$FAKE_HOME/.gitconfig-work" +actual="$(resolve)" +[ "$actual" = "jane@devbox|jane" ] || { echo "FAIL [includeIf]: got <$actual>" >&2; exit 1; } +echo " ok: a conditional include is skipped" + +# ── Case 18: $XDG_CONFIG_HOME/git/config is read; ~/.gitconfig overrides it ── +scenario +write_gitconfig +mkdir -p "$FAKE_HOME/.config/git" +printf '[user]\n\temail = xdg@corp.com\n\tname = Xdg Me\n' > "$FAKE_HOME/.config/git/config" +actual="$(resolve)" +[ "$actual" = "xdg@corp.com|Xdg Me" ] || { echo "FAIL [xdg]: got <$actual>" >&2; exit 1; } +echo " ok: XDG config is used when ~/.gitconfig is absent" +printf '[user]\n\temail = home@corp.com\n' > "$FAKE_HOME/.gitconfig" +actual="$(resolve)" +[ "$actual" = "home@corp.com|Xdg Me" ] || { echo "FAIL [xdg override]: got <$actual>" >&2; exit 1; } +echo " ok: ~/.gitconfig overrides the XDG value, field by field" + +# ── Case 19: a damaged install (no git-identity.sh) still resolves an actor ── +scenario +ROOT_DIR="$FAKE_HOME" +GIT_EMAIL="jane@corp.com"; GIT_NAME="Jane Dev" +assert_actor "jane@devbox|jane" "missing git-identity.sh degrades to login@hostname" + +echo "── scripts/shared/actor.sh (codex copy) ──" +ACTOR="$SHARED_ACTOR" + +scenario +SEED_EMAIL="mdm@corp.com"; SEED_NAME="MDM Provisioned" +GIT_EMAIL="jane@corp.com"; GIT_NAME="Jane Dev" +assert_actor "mdm@corp.com|MDM Provisioned" "env file actor wins over the git identity" + +scenario +GIT_EMAIL="jane@corp.com"; GIT_NAME="Jane Dev" +assert_actor "jane@corp.com|Jane Dev" "git identity from ~/.gitconfig when the env file has none" + +scenario +SEED_EMAIL="mdm@corp.com" +GIT_EMAIL="jane@corp.com"; GIT_NAME="Jane Dev" +assert_actor "mdm@corp.com|Jane Dev" "fields resolve independently" + +scenario +assert_actor "jane@devbox|jane" "login@hostname and login when no git identity exists" + +scenario +HOST_NAME="" +assert_actor "jane|jane" "login alone when the hostname is unavailable" + +# ── The git binary was never run, in any case above ────────────────────────── +if [ -s "$TRIPWIRE" ]; then + echo "FAIL [git tripwire]: the cascade invoked git:" >&2; cat "$TRIPWIRE" >&2; exit 1 +fi +echo " ok: git binary never invoked" + echo -echo "All actor.sh cascade tests passed (SH=$SH)." +echo "All actor cascade tests passed (SH=$SH)." diff --git a/tests/test_git_identity_ps1.ps1 b/tests/test_git_identity_ps1.ps1 new file mode 100644 index 0000000..e262099 --- /dev/null +++ b/tests/test_git_identity_ps1.ps1 @@ -0,0 +1,168 @@ +#!/usr/bin/env pwsh +# tests/test_git_identity_ps1.ps1 — the PowerShell actor fallback. +# +# 1. scripts/shared/git-identity.ps1: user.email / user.name from the git config +# FILES (XDG then ~/.gitconfig, later wins, one level of [include] path). +# 2. plugins/rogue/scripts/hook.ps1 Resolve-RogueActor: env file → git config +# files → @, loaded through the ROGUE_PS_LIB_ONLY seam. +# 3. No dispatcher or heartbeat shells out to git: a stub git ahead of PATH is a +# tripwire, and the sources are grepped for the old `& git config` call. +# +# Runs under pwsh 7 on any platform and under Windows PowerShell 5.1. + +$ErrorActionPreference = 'Stop' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$repo = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($here, '..')) +$lib = [System.IO.Path]::Combine($repo, 'scripts', 'shared', 'git-identity.ps1') +$hook = [System.IO.Path]::Combine($repo, 'plugins', 'rogue', 'scripts', 'hook.ps1') + +$fails = 0 +$count = 0 +function Assert-Eq { + param($Got, $Expected, [string]$Label) + $script:count++ + if ([string]$Got -ceq [string]$Expected) { Write-Host " ok: $Label" } + else { Write-Host "FAIL [$Label]: got <$Got>, expected <$Expected>"; $script:fails++ } +} + +# ── harness: a throwaway home the readers see through HOME, USERPROFILE and XDG ── +$saved = @{} +foreach ($k in 'HOME','USERPROFILE','XDG_CONFIG_HOME','PATH','CLAUDE_CODE_USER_EMAIL') { + $saved[$k] = [Environment]::GetEnvironmentVariable($k) +} +$homes = @() +function New-TestHome { + $d = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'rogue-gitid-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $d -Force | Out-Null + $script:homes += $d + $env:HOME = $d + $env:USERPROFILE = $d + $env:XDG_CONFIG_HOME = [System.IO.Path]::Combine($d, '.config') + return $d +} +function Write-Cfg { + param([string]$Path, [string]$Text) + $dir = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + [System.IO.File]::WriteAllText($Path, $Text) +} +function Read-GitId { return (& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $lib)))) } + +# Tripwire: `git` on PATH records every invocation. Both a POSIX script and a +# .cmd, so it fires on the Linux runner and under Windows PowerShell 5.1 alike. +$trip = New-TestHome +$tripMarker = [System.IO.Path]::Combine($trip, 'git-invoked') +Write-Cfg ([System.IO.Path]::Combine($trip, 'bin', 'git')) "#!/bin/sh`necho `"git `$*`" >> '$tripMarker'`nexit 1`n" +Write-Cfg ([System.IO.Path]::Combine($trip, 'bin', 'git.cmd')) "@echo git %* >> `"$tripMarker`"`r`n@exit /b 1`r`n" +if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { & chmod +x ([System.IO.Path]::Combine($trip, 'bin', 'git')) } +$env:PATH = [System.IO.Path]::Combine($trip, 'bin') + [System.IO.Path]::PathSeparator + $env:PATH + +try { + Write-Host '-- git-identity.ps1: the config files --' + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "# comment`n[User]`n`tName = `"Jane; Dev`" `n`temail = jane@corp.com ; trailing`n" + $id = Read-GitId + Assert-Eq $id.Email 'jane@corp.com' 'email read from ~/.gitconfig, comment stripped' + Assert-Eq $id.Name 'Jane; Dev' 'quoted name unwrapped, section/key case-insensitive' + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`temail = home@corp.com`n[include]`n`tpath = ~/.gitconfig-work`n" + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig-work')) "[user]`n`temail = work@corp.com`n`tname = Work Me`n" + $id = Read-GitId + Assert-Eq $id.Email 'work@corp.com' '[include] path is followed and overrides the includer' + Assert-Eq $id.Name 'Work Me' 'a field only the included file carries is used' + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[include]`n`tpath = sub/extra`n" + Write-Cfg ([System.IO.Path]::Combine($h, 'sub', 'extra')) "[user]`n`tname = Rel Inc`n" + Assert-Eq (Read-GitId).Name 'Rel Inc' 'a relative include path resolves against the including file' + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[includeIf `"gitdir:~/work/`"]`n`tpath = ~/.gitconfig-never`n" + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig-never')) "[user]`n`temail = never@corp.com`n" + Assert-Eq (Read-GitId).Email '' 'a conditional include is not evaluated' + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.config', 'git', 'config')) "[user]`n`temail = xdg@corp.com`n`tname = Xdg Me`n" + $id = Read-GitId + Assert-Eq $id.Email 'xdg@corp.com' 'XDG config is read when ~/.gitconfig is absent' + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`temail = home@corp.com`n" + $id = Read-GitId + Assert-Eq $id.Email 'home@corp.com' '~/.gitconfig overrides the XDG value' + Assert-Eq $id.Name 'Xdg Me' 'a field only XDG carries survives' + + $h = New-TestHome + $id = Read-GitId + Assert-Eq $id.Email '' 'no config file: empty email, no error' + Assert-Eq $id.Name '' 'no config file: empty name, no error' + Assert-Eq $id.GetType().Name 'Hashtable' 'always outputs a hashtable' + + Write-Host '-- hook.ps1 Resolve-RogueActor: the three fallback levels --' + $env:ROGUE_PS_LIB_ONLY = '1' + . $hook + $env:ROGUE_PS_LIB_ONLY = $null + $ErrorActionPreference = 'Stop' + $env:CLAUDE_CODE_USER_EMAIL = $null + $pluginRoot = [System.IO.Path]::Combine($repo, 'plugins', 'rogue') + + $login = Select-ActorValue @($env:USERNAME, [Environment]::UserName) + $dns = ''; try { $dns = [System.Net.Dns]::GetHostName() } catch {} + $hostName = Select-ActorValue @($env:COMPUTERNAME, $dns) + $loginAtHost = if ($hostName) { "$login@$hostName" } else { $login } + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`temail = jane@corp.com`n`tname = Jane Dev`n" + $a = Resolve-RogueActor @{ ROGUE_ACTOR_EMAIL = 'mdm@corp.com'; ROGUE_ACTOR_NAME = 'MDM Provisioned' } $pluginRoot + Assert-Eq $a.Email 'mdm@corp.com' 'level 1: env file email wins over the git identity' + Assert-Eq $a.Name 'MDM Provisioned' 'level 1: env file name wins over the git identity' + + $a = Resolve-RogueActor @{} $pluginRoot + Assert-Eq $a.Email 'jane@corp.com' 'level 2: git config file email when the env file has none' + Assert-Eq $a.Name 'Jane Dev' 'level 2: git config file name when the env file has none' + + $a = Resolve-RogueActor @{ ROGUE_ACTOR_EMAIL = 'mdm@corp.com' } $pluginRoot + Assert-Eq $a.Email 'mdm@corp.com' 'fields resolve independently (email from env)' + Assert-Eq $a.Name 'Jane Dev' 'fields resolve independently (name from git)' + + $h = New-TestHome + $a = Resolve-RogueActor @{} $pluginRoot + Assert-Eq $a.Email $loginAtHost 'level 3: @ when there is no git identity' + Assert-Eq $a.Name $login 'level 3: login as the name' + Assert-Eq ([bool]$a.Email) $true 'level 3: the email is never blank' + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`temail = noreply@anthropic.com`n`tname = Claude`n" + $a = Resolve-RogueActor @{} $pluginRoot + Assert-Eq $a.Email $loginAtHost 'the sandbox git identity is screened, login@host used' + Assert-Eq $a.Name $login 'the sandbox git name is screened, login used' + + $env:CLAUDE_CODE_USER_EMAIL = 'real.user@corp.com' + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`temail = jane@corp.com`n`tname = Jane Dev`n" + $a = Resolve-RogueActor @{} $pluginRoot + Assert-Eq $a.Email 'real.user@corp.com' 'CLAUDE_CODE_USER_EMAIL outranks the git identity' + Assert-Eq $a.Name 'real.user' 'its local-part is the name' + $env:CLAUDE_CODE_USER_EMAIL = $null + + $a = Resolve-RogueActor @{} ([System.IO.Path]::Combine($h, 'no-such-plugin')) + Assert-Eq $a.Email $loginAtHost 'a damaged install (no git-identity.ps1) degrades to login@host' + + Write-Host '-- no bridge shells out to git --' + Assert-Eq (Test-Path -LiteralPath $tripMarker) $false 'git binary never invoked' + foreach ($p in 'rogue','codex','copilot','antigravity','kiro','cursor') { + foreach ($f in 'hook.ps1','heartbeat.ps1') { + $src = [System.IO.Path]::Combine($repo, 'plugins', $p, 'scripts', $f) + if (-not (Test-Path -LiteralPath $src)) { continue } + Assert-Eq ((Get-Content -Raw -LiteralPath $src) -match '&\s*git\s') $false "$p/$f does not call git" + } + } +} finally { + foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } + foreach ($d in $homes) { Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Host '' +if ($fails -gt 0) { Write-Host "$fails of $count git identity test(s) FAILED."; exit 1 } +Write-Host "All $count git identity tests passed." +exit 0 diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index db125af..f9e2703 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -25,9 +25,11 @@ function freshHome() { } // Run hook.mjs with `payload` on stdin and `env` overrides; resolve stdout. -function runHook(event, payload, env) { +// `prepareHome(home)` may seed the throwaway HOME (env file, git config) first. +function runHook(event, payload, env, prepareHome) { return new Promise((resolve) => { const home = freshHome(); + if (prepareHome) prepareHome(home); const child = spawn(process.execPath, [HOOK, event], { env: { PATH: process.env.PATH, @@ -191,6 +193,86 @@ test("relays server body verbatim and sends the right headers", async () => { } }); +// ── Actor fallback: env file → git config files → login@hostname ─────────── +// The git identity must come from the config FILES: on a Mac without the Command +// Line Tools `git` is a stub that opens the installer dialog, so a stub `git` +// ahead of PATH records any invocation and the tests assert it never fired. +function gitTripwire() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rogue-gem-git-")); + const marker = path.join(dir, "invoked"); + fs.writeFileSync( + path.join(dir, "git"), + `#!/bin/sh\necho "git $@" >> "${marker}"\nexit 1\n`, + { mode: 0o755 }, + ); + return { + marker, + PATH: `${dir}${path.delimiter}${process.env.PATH}`, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} +function seedHome(home, { actor, gitconfig }) { + const lines = ["export ROGUE_API_KEY=rsk_test", `export ROGUE_BASE_URL=${seedHome.base}`]; + if (actor) lines.push(`export ROGUE_ACTOR_EMAIL=${actor.email}`, `export ROGUE_ACTOR_NAME='${actor.name}'`); + fs.writeFileSync(path.join(home, ".rogue-env"), lines.join("\n") + "\n"); + if (gitconfig) fs.writeFileSync(path.join(home, ".gitconfig"), gitconfig); +} +const GITCONFIG = "[user]\n\temail = jane@corp.com\n\tname = Jane Dev\n"; + +test("actor from the env file wins over the git identity", async () => { + const { server, seen, port } = await startServer(200, "{}"); + const git = gitTripwire(); + seedHome.base = `http://127.0.0.1:${port}`; + try { + await runHook("BeforeTool", "{}", { PATH: git.PATH }, (home) => + seedHome(home, { actor: { email: "mdm@corp.com", name: "MDM Provisioned" }, gitconfig: GITCONFIG }), + ); + assert.equal(seen.headers["x-rogue-actor-email"], "mdm@corp.com"); + assert.equal(seen.headers["x-rogue-actor-name"], "MDM Provisioned"); + assert.equal(fs.existsSync(git.marker), false, "git must never be invoked"); + } finally { + server.close(); + git.cleanup(); + } +}); + +test("no actor in the env file → git identity from the config files, git never run", async () => { + const { server, seen, port } = await startServer(200, "{}"); + const git = gitTripwire(); + seedHome.base = `http://127.0.0.1:${port}`; + try { + // The name lives in an [include] path file, so the include level is covered too. + await runHook("BeforeTool", "{}", { PATH: git.PATH }, (home) => { + seedHome(home, { + gitconfig: "[user]\n\temail = jane@corp.com\n[include]\n\tpath = ~/.gitconfig-work\n", + }); + fs.writeFileSync(path.join(home, ".gitconfig-work"), '[user]\n\tname = "Jane Dev"\n'); + }); + assert.equal(seen.headers["x-rogue-actor-email"], "jane@corp.com"); + assert.equal(seen.headers["x-rogue-actor-name"], "Jane Dev"); + assert.equal(fs.existsSync(git.marker), false, "git must never be invoked"); + } finally { + server.close(); + git.cleanup(); + } +}); + +test("no git identity → login@hostname, never a blank actor", async () => { + const { server, seen, port } = await startServer(200, "{}"); + const git = gitTripwire(); + seedHome.base = `http://127.0.0.1:${port}`; + try { + await runHook("BeforeTool", "{}", { PATH: git.PATH }, (home) => seedHome(home, {})); + const login = os.userInfo().username; + assert.equal(seen.headers["x-rogue-actor-email"], `${login}@${os.hostname()}`); + assert.equal(seen.headers["x-rogue-actor-name"], login); + assert.equal(fs.existsSync(git.marker), false, "git must never be invoked"); + } finally { + server.close(); + git.cleanup(); + } +}); + test("allow response ({}) relays verbatim", async () => { const { server, port } = await startServer(200, "{}"); try { diff --git a/tests/test_hook_ps1.ps1 b/tests/test_hook_ps1.ps1 index 0b37cb8..4ca5327 100644 --- a/tests/test_hook_ps1.ps1 +++ b/tests/test_hook_ps1.ps1 @@ -135,7 +135,7 @@ Assert-Selected @('mdm@corp.com', 'real.user@corp.com') 'mdm@corp.com' Assert-Selected @('Claude', 'claude code', ' ') '' 'all-synthetic yields empty (caller emits the unknown marker)' # ── last-resort fallbacks: parity with the POSIX cascade ─────────────────── -# actor.sh ends at `whoami` / `hostname`; the PowerShell twin must still resolve +# actor.sh ends at `@`; the PowerShell twin must still resolve # a real identity when USERNAME or COMPUTERNAME is unset (service contexts), # rather than skipping straight to the unknown marker. It does NOT shell out to # whoami.exe: that prints DOMAIN\user, which is a different identity string and @@ -152,16 +152,24 @@ Assert-Selected @('', (($labMail -split '@')[0])) '' 'synthetic host em $labMail = Select-ActorValue @('jane.doe@corp.com') Assert-Selected @('', (($labMail -split '@')[0])) 'jane.doe' 'real host email still yields its local-part' -# The cascade itself lives below the ROGUE_PS_LIB_ONLY seam (its dispatcher body -# only runs on Windows), so its wiring is asserted structurally here: a silent -# drop of either fallback is exactly the regression this covers. +# hook.ps1's cascade (Resolve-RogueActor) is driven end to end in +# tests/test_git_identity_ps1.ps1; heartbeat.ps1 carries an inline copy whose +# dispatcher body only runs on Windows, so both are pinned structurally here: a +# silent drop of either fallback is exactly the regression this covers. foreach ($f in @('hook.ps1', 'heartbeat.ps1')) { $src = Get-Content -Raw -LiteralPath ([System.IO.Path]::Combine($here, '..', 'plugins', 'rogue', 'scripts', $f)) $script:count++ - if ($src -match [regex]::Escape('Select-ActorValue @($gitName, $env:USERNAME, [Environment]::UserName)')) { - Write-Host " ok: $f name cascade falls back to the process token user" + if ($src -match [regex]::Escape('Select-ActorValue @($env:USERNAME, [Environment]::UserName)')) { + Write-Host " ok: $f login falls back to the process token user" } else { - Write-Host "FAIL [$f]: name cascade does not fall back to [Environment]::UserName" + Write-Host "FAIL [$f]: login does not fall back to [Environment]::UserName" + $script:fails++ + } + $script:count++ + if ($src -match [regex]::Escape("scripts\git-identity.ps1") -and $src -notmatch '&\s*git\s') { + Write-Host " ok: $f reads the git identity from the config files, never git.exe" + } else { + Write-Host "FAIL [$f]: git identity is not read through git-identity.ps1" $script:fails++ } $script:count++ @@ -187,7 +195,7 @@ foreach ($f in @('hook.ps1', 'heartbeat.ps1')) { # It cannot run here (it reads $env:USERPROFILE and posts), so assert its shape. $skill = Get-Content -Raw -LiteralPath ([System.IO.Path]::Combine($here, '..', 'plugins', 'rogue', 'skills', 'status', 'SKILL.md')) $script:count++ -if ($skill -match [regex]::Escape('$env:ROGUE_PS_LIB_ONLY') -and $skill -match 'Select-ActorValue') { +if ($skill -match [regex]::Escape('$env:ROGUE_PS_LIB_ONLY') -and $skill -match 'Resolve-RogueActor' -and $skill -notmatch '&\s*git\s') { Write-Host " ok: status skill resolves the actor through hook.ps1's screen" } else { Write-Host "FAIL: status skill does not load hook.ps1's actor screen" diff --git a/tests/test_hook_sh.sh b/tests/test_hook_sh.sh index 9001d9e..76fc0ea 100755 --- a/tests/test_hook_sh.sh +++ b/tests/test_hook_sh.sh @@ -173,11 +173,12 @@ BLOCK_BODY='{"decision":"block","reason":"PII detected"}' # else — critically, no osascript. Cases put their own stub ahead of it, so the # capability probe sees exactly what the case intends. Listing the tools # explicitly (rather than filtering /usr/bin) also documents the dispatcher's -# real dependency surface: hook.sh + actor.sh + install-id.sh + security-alert.sh. +# real dependency surface: hook.sh + actor.sh + git-identity.sh + install-id.sh + +# security-alert.sh. No git: the identity comes from the config files. SANDBOX_BIN="$(mktemp -d)" # "$SH" is in the list because TEST_SH=dash names an interpreter that is not # called `sh` — without it the sandboxed PATH cannot find the shell under test. -for tool in "$SH" sh bash env curl date mkdir dirname tr grep sed head cat hostname whoami git uname sleep; do +for tool in "$SH" sh bash env curl date mkdir dirname tr grep sed head cat hostname whoami awk uname sleep; do src="$(command -v "$tool" 2>/dev/null)" || continue [ -n "$src" ] && ln -sf "$src" "$SANDBOX_BIN/$tool" done diff --git a/tests/test_hook_sh_kiro.sh b/tests/test_hook_sh_kiro.sh index c447078..1e24428 100755 --- a/tests/test_hook_sh_kiro.sh +++ b/tests/test_hook_sh_kiro.sh @@ -86,7 +86,7 @@ run_bridge() { make_nojq_path() { local d b src d="$(mktemp -d)" - for b in "$SH" sh dirname basename date mkdir cat sed grep tr tail head awk wc hostname whoami git curl printf stat id; do + for b in "$SH" sh dirname basename date mkdir cat sed grep tr tail head awk wc hostname whoami curl printf stat id; do src="$(command -v "$b" 2>/dev/null || true)" [ -n "$src" ] || continue ln -s "$src" "$d/$(basename "$src")" 2>/dev/null || true diff --git a/tests/test_status_skill_sh.sh b/tests/test_status_skill_sh.sh index 161ef43..6a1f2eb 100755 --- a/tests/test_status_skill_sh.sh +++ b/tests/test_status_skill_sh.sh @@ -29,6 +29,14 @@ mkdir -p "$PLUGIN/.claude-plugin" "$PLUGIN/scripts" "$STAGE/bin" printf '{\n "name": "rogue",\n "version": "9.9.9"\n}\n' > "$PLUGIN/.claude-plugin/plugin.json" cp "$ACTOR" "$PLUGIN/scripts/actor.sh" cp "$SURFACE" "$PLUGIN/scripts/surface.sh" +cp "$REPO/plugins/rogue/scripts/git-identity.sh" "$PLUGIN/scripts/git-identity.sh" + +# `env -i` clears USER, so the login level of the cascade ends at whoami; pin it. +cat > "$STAGE/bin/whoami" <<'EOF' +#!/bin/sh +echo jane +EOF +chmod +x "$STAGE/bin/whoami" # The credential file a compiled bundle leaves behind, carrying the pre-seed that # poisons ROGUE_ACTOR_* with the sandbox's git identity. @@ -121,11 +129,19 @@ assert_lacks 'Actor email: noreply@anthropic.com' "$out" "Step 4 never shows the assert_lacks 'Actor name: Claude' "$out" "Step 4 never shows the rejected env-file name" assert_has 'note: env file holds' "$out" "Step 4 flags that the env file was superseded" -# With nothing resolvable, the marker is reported — not "(unset)", which used to -# be followed by advice claiming events POST with blank actor headers. +# With no identity anywhere, the login stands in — not "(unset)", which used to be +# followed by advice claiming events POST with blank actor headers. +out=$(run4) +assert_has 'Actor email: jane@' "$out" "Step 4 reports login@host when nothing else resolves" +assert_has 'Actor name: jane' "$out" "Step 4 reports the login as the name" +assert_lacks '(unset)' "$out" "Step 4 never reports an unset actor" + +# The git identity comes from ~/.gitconfig as a file, the same as the hooks read it. +printf '[user]\n\temail = jane@corp.com\n\tname = Jane Dev\n' > "$FAKE_HOME/.gitconfig" out=$(run4) -assert_has 'Actor email: unknown@' "$out" "Step 4 reports the unknown marker when nothing resolves" -assert_lacks '(unset)' "$out" "Step 4 never reports an unset actor" +assert_has 'Actor email: jane@corp.com' "$out" "Step 4 reports the ~/.gitconfig identity" +assert_has 'Actor name: Jane Dev' "$out" "Step 4 reports the ~/.gitconfig name" +rm -f "$FAKE_HOME/.gitconfig" [ "$fails" -eq 0 ] || { echo "$fails failure(s)"; exit 1; } echo "all status skill tests passed" From 0d82abcfc6843273f18a160786cb5a66be651a26 Mon Sep 17 00:00:00 2001 From: Yuval Date: Fri, 11 Sep 2026 03:28:54 +0300 Subject: [PATCH 3/7] docs(plugins): describe the hook-time actor fallback (FIRE-2117) Co-Authored-By: Claude Fable 5.1 --- README.md | 4 ++++ docs/log-shipping.md | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 425427c..b24c70f 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ 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 `~/.gitconfig` (read as a file; +`git` itself is never run), then `@`. + To revoke: `rm ~/.rogue-env` (per-user) or `sudo rm /etc/rogue/env` (MDM). ## False positive escape hatch diff --git a/docs/log-shipping.md b/docs/log-shipping.md index 9245cf5..0014009 100644 --- a/docs/log-shipping.md +++ b/docs/log-shipping.md @@ -50,9 +50,9 @@ and `agent_family`. A shipped log chunk carrying those same fields is attributab 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 +(shell locals / 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. 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 From e3647677bf2c7360478f1faa8520117ba9e5e308 Mon Sep 17 00:00:00 2001 From: Yuval Date: Fri, 11 Sep 2026 08:17:28 +0300 Subject: [PATCH 4/7] fix(plugins): harden the git config reader and share the PowerShell actor cascade (FIRE-2117) Review follow-ups on the hook-time actor fallback: - git-identity.sh: awk reads stdin from /dev/null, so an `[include] path = /dev/stdin` in ~/.gitconfig can no longer drain the hook payload before the sh bridges read it; trailing \r from CRLF files is stripped, so curl never puts a bare CR in the actor header (same bytes as git-identity.ps1 and shared.mjs). - actor.sh ends in the "unknown" marker when login and hostname are both unavailable, never a blank actor. Cursor's hook.sh sources the shared cascade instead of its inline copy. - New scripts/shared/actor.ps1 (Resolve-RogueSharedActor) replaces the nine inline PowerShell cascades in codex/copilot/antigravity/kiro/cursor, with the token-user and DNS-host fallbacks the rogue bridge already had and the "unknown" terminator. Synced by scripts/sync-shared-scripts.sh. - hook.ps1's git-identity wrapper is Read-RogueGitIdentity, so the library's Get-RogueGitIdentity is the only function of that name. - Tests: stdin-include, CRLF and blank-terminal cases in test_actor_sh.sh; CRLF and the three shared-cascade levels in test_git_identity_ps1.ps1. Co-Authored-By: Claude Fable 5.1 --- docs/log-shipping.md | 8 ++-- plugins/antigravity/scripts/actor.ps1 | 48 ++++++++++++++++++++ plugins/antigravity/scripts/actor.sh | 5 ++- plugins/antigravity/scripts/git-identity.sh | 6 ++- plugins/antigravity/scripts/heartbeat.ps1 | 31 +++++-------- plugins/antigravity/scripts/hook.ps1 | 31 +++++-------- plugins/codex/scripts/actor.ps1 | 48 ++++++++++++++++++++ plugins/codex/scripts/actor.sh | 5 ++- plugins/codex/scripts/git-identity.sh | 6 ++- plugins/codex/scripts/heartbeat.ps1 | 31 +++++-------- plugins/codex/scripts/hook.ps1 | 31 +++++-------- plugins/copilot/scripts/actor.ps1 | 48 ++++++++++++++++++++ plugins/copilot/scripts/actor.sh | 5 ++- plugins/copilot/scripts/git-identity.sh | 6 ++- plugins/copilot/scripts/heartbeat.ps1 | 31 +++++-------- plugins/copilot/scripts/hook.ps1 | 31 +++++-------- plugins/cursor/scripts/actor.ps1 | 48 ++++++++++++++++++++ plugins/cursor/scripts/actor.sh | 28 ++++++++++++ plugins/cursor/scripts/git-identity.sh | 6 ++- plugins/cursor/scripts/hook.ps1 | 32 +++++-------- plugins/cursor/scripts/hook.sh | 26 +++-------- plugins/kiro/scripts/actor.ps1 | 48 ++++++++++++++++++++ plugins/kiro/scripts/actor.sh | 5 ++- plugins/kiro/scripts/git-identity.sh | 6 ++- plugins/kiro/scripts/heartbeat.ps1 | 31 +++++-------- plugins/kiro/scripts/hook.ps1 | 31 +++++-------- plugins/rogue/scripts/git-identity.sh | 6 ++- plugins/rogue/scripts/hook.ps1 | 4 +- scripts/shared/actor.ps1 | 48 ++++++++++++++++++++ scripts/shared/actor.sh | 5 ++- scripts/shared/git-identity.sh | 6 ++- scripts/sync-shared-scripts.sh | 10 ++--- tests/test_actor_sh.sh | 34 ++++++++++++++ tests/test_git_identity_ps1.ps1 | 50 +++++++++++++++++++++ 34 files changed, 572 insertions(+), 223 deletions(-) create mode 100644 plugins/antigravity/scripts/actor.ps1 create mode 100644 plugins/codex/scripts/actor.ps1 create mode 100644 plugins/copilot/scripts/actor.ps1 create mode 100644 plugins/cursor/scripts/actor.ps1 create mode 100755 plugins/cursor/scripts/actor.sh create mode 100644 plugins/kiro/scripts/actor.ps1 create mode 100644 scripts/shared/actor.ps1 diff --git a/docs/log-shipping.md b/docs/log-shipping.md index 0014009..024bd9a 100644 --- a/docs/log-shipping.md +++ b/docs/log-shipping.md @@ -49,10 +49,10 @@ 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 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. 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. 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 diff --git a/plugins/antigravity/scripts/actor.ps1 b/plugins/antigravity/scripts/actor.ps1 new file mode 100644 index 0000000..e4d6713 --- /dev/null +++ b/plugins/antigravity/scripts/actor.ps1 @@ -0,0 +1,48 @@ +# 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 -> 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 = @{} } + $name = [string]$Creds['ROGUE_ACTOR_NAME'] + $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + 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 } +} diff --git a/plugins/antigravity/scripts/actor.sh b/plugins/antigravity/scripts/actor.sh index ee69bdd..4600216 100755 --- a/plugins/antigravity/scripts/actor.sh +++ b/plugins/antigravity/scripts/actor.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Sourceable. Resolves ROGUE_ACTOR_{EMAIL,NAME} from a cascade. -# Cascade: env → git config files (scripts/git-identity.sh) → login@hostname / login. +# 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. if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then @@ -21,5 +22,7 @@ if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then [ -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 diff --git a/plugins/antigravity/scripts/git-identity.sh b/plugins/antigravity/scripts/git-identity.sh index 8fd8597..aa9e210 100644 --- a/plugins/antigravity/scripts/git-identity.sh +++ b/plugins/antigravity/scripts/git-identity.sh @@ -8,10 +8,12 @@ # followed by its [include] path entries (one level; includeIf is not evaluated). # Print the last value of [$2] $3 in git config file $1 and its includes. +# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise +# drain the hook payload the bridge has not read yet. _rogue_gitcfg_value() { [ -r "$1" ] || return 0 awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' - function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } function value(s) { s = trim(s) if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } @@ -39,7 +41,7 @@ _rogue_gitcfg_value() { close(file) } BEGIN { scan(main, 0); if (found != "") print found } - ' 2>/dev/null + ' /dev/null } rogue_git_identity() { diff --git a/plugins/antigravity/scripts/heartbeat.ps1 b/plugins/antigravity/scripts/heartbeat.ps1 index 5c5e30f..10ad2e3 100644 --- a/plugins/antigravity/scripts/heartbeat.ps1 +++ b/plugins/antigravity/scripts/heartbeat.ps1 @@ -158,27 +158,20 @@ function Resolve-BaseUrl { $script:baseUrl = $script:baseUrl.TrimEnd('/') } -# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. function Resolve-Actor { - $script:actorName = $creds['ROGUE_ACTOR_NAME'] - $script:actorEmail = $creds['ROGUE_ACTOR_EMAIL'] - if (-not $script:actorName -or -not $script:actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $script:pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $script:actorName) { $script:actorName = [string]$gitId.Name } - if (-not $script:actorEmail) { $script:actorEmail = [string]$gitId.Email } + $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } + try { + $actorLib = Join-Path $script:pluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $script:pluginRoot } - } - if (-not $script:actorName) { $script:actorName = $env:USERNAME } - if (-not $script:actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $script:actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $script:actorEmail = $env:USERNAME } else { $script:actorEmail = $env:COMPUTERNAME } - } + } catch {} + $script:actorName = [string]$actor.Name + $script:actorEmail = [string]$actor.Email } # ── plugin version (from the bundled VERSION file, NOT plugin.json — the diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index 3a8a399..e4729ac 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -290,27 +290,20 @@ function Resolve-Url { } } -# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. function Resolve-Actor { - $script:actorName = $creds['ROGUE_ACTOR_NAME'] - $script:actorEmail = $creds['ROGUE_ACTOR_EMAIL'] - if (-not $script:actorName -or -not $script:actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $script:pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $script:actorName) { $script:actorName = [string]$gitId.Name } - if (-not $script:actorEmail) { $script:actorEmail = [string]$gitId.Email } + $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } + try { + $actorLib = Join-Path $script:pluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $script:pluginRoot } - } - if (-not $script:actorName) { $script:actorName = $env:USERNAME } - if (-not $script:actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $script:actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $script:actorEmail = $env:USERNAME } else { $script:actorEmail = $env:COMPUTERNAME } - } + } catch {} + $script:actorName = [string]$actor.Name + $script:actorEmail = [string]$actor.Email } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── diff --git a/plugins/codex/scripts/actor.ps1 b/plugins/codex/scripts/actor.ps1 new file mode 100644 index 0000000..e4d6713 --- /dev/null +++ b/plugins/codex/scripts/actor.ps1 @@ -0,0 +1,48 @@ +# 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 -> 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 = @{} } + $name = [string]$Creds['ROGUE_ACTOR_NAME'] + $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + 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 } +} diff --git a/plugins/codex/scripts/actor.sh b/plugins/codex/scripts/actor.sh index ee69bdd..4600216 100755 --- a/plugins/codex/scripts/actor.sh +++ b/plugins/codex/scripts/actor.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Sourceable. Resolves ROGUE_ACTOR_{EMAIL,NAME} from a cascade. -# Cascade: env → git config files (scripts/git-identity.sh) → login@hostname / login. +# 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. if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then @@ -21,5 +22,7 @@ if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then [ -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 diff --git a/plugins/codex/scripts/git-identity.sh b/plugins/codex/scripts/git-identity.sh index 8fd8597..aa9e210 100644 --- a/plugins/codex/scripts/git-identity.sh +++ b/plugins/codex/scripts/git-identity.sh @@ -8,10 +8,12 @@ # followed by its [include] path entries (one level; includeIf is not evaluated). # Print the last value of [$2] $3 in git config file $1 and its includes. +# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise +# drain the hook payload the bridge has not read yet. _rogue_gitcfg_value() { [ -r "$1" ] || return 0 awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' - function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } function value(s) { s = trim(s) if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } @@ -39,7 +41,7 @@ _rogue_gitcfg_value() { close(file) } BEGIN { scan(main, 0); if (found != "") print found } - ' 2>/dev/null + ' /dev/null } rogue_git_identity() { diff --git a/plugins/codex/scripts/heartbeat.ps1 b/plugins/codex/scripts/heartbeat.ps1 index b2aa434..2bc05e0 100644 --- a/plugins/codex/scripts/heartbeat.ps1 +++ b/plugins/codex/scripts/heartbeat.ps1 @@ -123,26 +123,19 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME -$actorName = $creds['ROGUE_ACTOR_NAME'] -$actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorName -or -not $actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $actorName) { $actorName = [string]$gitId.Name } - if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. +$actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } +try { + $actorLib = Join-Path $pluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $pluginRoot } -} -if (-not $actorName) { $actorName = $env:USERNAME } -if (-not $actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } else { $actorEmail = $env:COMPUTERNAME } -} +} catch {} +$actorName = [string]$actor.Name +$actorEmail = [string]$actor.Email # ── plugin version (regex from manifest, no python) ──────────────────────── $ver = 'unknown' diff --git a/plugins/codex/scripts/hook.ps1 b/plugins/codex/scripts/hook.ps1 index 42f0da4..a131365 100644 --- a/plugins/codex/scripts/hook.ps1 +++ b/plugins/codex/scripts/hook.ps1 @@ -244,26 +244,19 @@ if (-not $url) { $url = "$($baseUrl.TrimEnd('/'))/api/v1/hooks/openai" } -# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME -$actorName = $creds['ROGUE_ACTOR_NAME'] -$actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorName -or -not $actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $actorName) { $actorName = [string]$gitId.Name } - if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. +$actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } +try { + $actorLib = Join-Path $pluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $pluginRoot } -} -if (-not $actorName) { $actorName = $env:USERNAME } -if (-not $actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } else { $actorEmail = $env:COMPUTERNAME } -} +} catch {} +$actorName = [string]$actor.Name +$actorEmail = [string]$actor.Email # ── per-turn presence heartbeat + log ship (Stop only) ───────────────────── # The PowerShell twin of hook.sh's Stop block. SessionStart's heartbeat is spawned by diff --git a/plugins/copilot/scripts/actor.ps1 b/plugins/copilot/scripts/actor.ps1 new file mode 100644 index 0000000..e4d6713 --- /dev/null +++ b/plugins/copilot/scripts/actor.ps1 @@ -0,0 +1,48 @@ +# 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 -> 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 = @{} } + $name = [string]$Creds['ROGUE_ACTOR_NAME'] + $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + 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 } +} diff --git a/plugins/copilot/scripts/actor.sh b/plugins/copilot/scripts/actor.sh index ee69bdd..4600216 100755 --- a/plugins/copilot/scripts/actor.sh +++ b/plugins/copilot/scripts/actor.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Sourceable. Resolves ROGUE_ACTOR_{EMAIL,NAME} from a cascade. -# Cascade: env → git config files (scripts/git-identity.sh) → login@hostname / login. +# 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. if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then @@ -21,5 +22,7 @@ if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then [ -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 diff --git a/plugins/copilot/scripts/git-identity.sh b/plugins/copilot/scripts/git-identity.sh index 8fd8597..aa9e210 100644 --- a/plugins/copilot/scripts/git-identity.sh +++ b/plugins/copilot/scripts/git-identity.sh @@ -8,10 +8,12 @@ # followed by its [include] path entries (one level; includeIf is not evaluated). # Print the last value of [$2] $3 in git config file $1 and its includes. +# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise +# drain the hook payload the bridge has not read yet. _rogue_gitcfg_value() { [ -r "$1" ] || return 0 awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' - function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } function value(s) { s = trim(s) if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } @@ -39,7 +41,7 @@ _rogue_gitcfg_value() { close(file) } BEGIN { scan(main, 0); if (found != "") print found } - ' 2>/dev/null + ' /dev/null } rogue_git_identity() { diff --git a/plugins/copilot/scripts/heartbeat.ps1 b/plugins/copilot/scripts/heartbeat.ps1 index 0202fff..f0892af 100644 --- a/plugins/copilot/scripts/heartbeat.ps1 +++ b/plugins/copilot/scripts/heartbeat.ps1 @@ -125,26 +125,19 @@ if (-not $apiKey) { Dbg 'not configured -> no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME -$actorName = $creds['ROGUE_ACTOR_NAME'] -$actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorName -or -not $actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $actorName) { $actorName = [string]$gitId.Name } - if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. +$actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } +try { + $actorLib = Join-Path $pluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $pluginRoot } -} -if (-not $actorName) { $actorName = $env:USERNAME } -if (-not $actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } else { $actorEmail = $env:COMPUTERNAME } -} +} catch {} +$actorName = [string]$actor.Name +$actorEmail = [string]$actor.Email # ── plugin version (regex from manifest, no python) ──────────────────────── $ver = 'unknown' diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index a5d0f5a..b1facdb 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -330,26 +330,19 @@ if (-not $url) { $url = "$($baseUrl.TrimEnd('/'))/api/v1/hooks/copilot" } -# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME -$actorName = $creds['ROGUE_ACTOR_NAME'] -$actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorName -or -not $actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $PluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $actorName) { $actorName = [string]$gitId.Name } - if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. +$actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } +try { + $actorLib = Join-Path $PluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $PluginRoot } -} -if (-not $actorName) { $actorName = $env:USERNAME } -if (-not $actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } else { $actorEmail = $env:COMPUTERNAME } -} +} catch {} +$actorName = [string]$actor.Name +$actorEmail = [string]$actor.Email # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── $payload = [Console]::In.ReadToEnd() diff --git a/plugins/cursor/scripts/actor.ps1 b/plugins/cursor/scripts/actor.ps1 new file mode 100644 index 0000000..e4d6713 --- /dev/null +++ b/plugins/cursor/scripts/actor.ps1 @@ -0,0 +1,48 @@ +# 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 -> 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 = @{} } + $name = [string]$Creds['ROGUE_ACTOR_NAME'] + $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + 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 } +} diff --git a/plugins/cursor/scripts/actor.sh b/plugins/cursor/scripts/actor.sh new file mode 100755 index 0000000..4600216 --- /dev/null +++ b/plugins/cursor/scripts/actor.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Sourceable. Resolves ROGUE_ACTOR_{EMAIL,NAME} from a cascade. +# 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. + +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 diff --git a/plugins/cursor/scripts/git-identity.sh b/plugins/cursor/scripts/git-identity.sh index 8fd8597..aa9e210 100644 --- a/plugins/cursor/scripts/git-identity.sh +++ b/plugins/cursor/scripts/git-identity.sh @@ -8,10 +8,12 @@ # followed by its [include] path entries (one level; includeIf is not evaluated). # Print the last value of [$2] $3 in git config file $1 and its includes. +# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise +# drain the hook payload the bridge has not read yet. _rogue_gitcfg_value() { [ -r "$1" ] || return 0 awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' - function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } function value(s) { s = trim(s) if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } @@ -39,7 +41,7 @@ _rogue_gitcfg_value() { close(file) } BEGIN { scan(main, 0); if (found != "") print found } - ' 2>/dev/null + ' /dev/null } rogue_git_identity() { diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index abaeddb..edb8ac0 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -817,27 +817,19 @@ $baseUrl = $creds['ROGUE_BASE_URL'] if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -# ── actor resolution: explicit creds → git config files → username/hostname ─ -$actorName = $creds['ROGUE_ACTOR_NAME'] -$actorEmail = $creds['ROGUE_ACTOR_EMAIL'] -if (-not $actorName -or -not $actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $actorName) { $actorName = [string]$gitId.Name } - if (-not $actorEmail) { $actorEmail = [string]$gitId.Email } +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. +$actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } +try { + $actorLib = Join-Path $pluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $pluginRoot } -} -if (-not $actorName) { $actorName = $env:USERNAME } -if (-not $actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $actorEmail = $env:USERNAME } - else { $actorEmail = $env:COMPUTERNAME } -} +} catch {} +$actorName = [string]$actor.Name +$actorEmail = [string]$actor.Email # ── install identity: host + plugin version ──────────────────────────────── # The fleet roster keys an install on host + actor + family + agent, and until diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index a670887..b58a3e2 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -183,27 +183,11 @@ 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 files → whoami/hostname ── -# The git identity is read from the config files (scripts/git-identity.sh), never -# by running git: on a Mac without the Command Line Tools `git` opens the installer. -ROGUE_GIT_EMAIL=""; ROGUE_GIT_NAME="" -if { [ -z "${ROGUE_ACTOR_NAME:-}" ] || [ -z "${ROGUE_ACTOR_EMAIL:-}" ]; } && [ -r "$PLUGIN_ROOT/scripts/git-identity.sh" ]; then - . "$PLUGIN_ROOT/scripts/git-identity.sh" - rogue_git_identity -fi - -actor_name="${ROGUE_ACTOR_NAME:-}" -[ -n "$actor_name" ] || actor_name="$ROGUE_GIT_NAME" -[ -n "$actor_name" ] || actor_name="${USER:-${USERNAME:-$(whoami 2>/dev/null)}}" - -actor_email="${ROGUE_ACTOR_EMAIL:-}" -[ -n "$actor_email" ] || actor_email="$ROGUE_GIT_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 +# ── actor resolution: env file → git config files → login@hostname → unknown ─ +# scripts/actor.sh is the shared cascade (synced from scripts/shared/actor.sh). +[ -r "$PLUGIN_ROOT/scripts/actor.sh" ] && . "$PLUGIN_ROOT/scripts/actor.sh" +actor_name="${ROGUE_ACTOR_NAME:-unknown}" +actor_email="${ROGUE_ACTOR_EMAIL:-unknown}" # ── install identity: host + plugin version ──────────────────────────────── # The fleet roster keys an install on host + actor + family + agent, and until diff --git a/plugins/kiro/scripts/actor.ps1 b/plugins/kiro/scripts/actor.ps1 new file mode 100644 index 0000000..e4d6713 --- /dev/null +++ b/plugins/kiro/scripts/actor.ps1 @@ -0,0 +1,48 @@ +# 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 -> 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 = @{} } + $name = [string]$Creds['ROGUE_ACTOR_NAME'] + $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + 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 } +} diff --git a/plugins/kiro/scripts/actor.sh b/plugins/kiro/scripts/actor.sh index ee69bdd..4600216 100755 --- a/plugins/kiro/scripts/actor.sh +++ b/plugins/kiro/scripts/actor.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Sourceable. Resolves ROGUE_ACTOR_{EMAIL,NAME} from a cascade. -# Cascade: env → git config files (scripts/git-identity.sh) → login@hostname / login. +# 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. if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then @@ -21,5 +22,7 @@ if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then [ -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 diff --git a/plugins/kiro/scripts/git-identity.sh b/plugins/kiro/scripts/git-identity.sh index 8fd8597..aa9e210 100644 --- a/plugins/kiro/scripts/git-identity.sh +++ b/plugins/kiro/scripts/git-identity.sh @@ -8,10 +8,12 @@ # followed by its [include] path entries (one level; includeIf is not evaluated). # Print the last value of [$2] $3 in git config file $1 and its includes. +# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise +# drain the hook payload the bridge has not read yet. _rogue_gitcfg_value() { [ -r "$1" ] || return 0 awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' - function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } function value(s) { s = trim(s) if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } @@ -39,7 +41,7 @@ _rogue_gitcfg_value() { close(file) } BEGIN { scan(main, 0); if (found != "") print found } - ' 2>/dev/null + ' /dev/null } rogue_git_identity() { diff --git a/plugins/kiro/scripts/heartbeat.ps1 b/plugins/kiro/scripts/heartbeat.ps1 index 6126187..c99e726 100644 --- a/plugins/kiro/scripts/heartbeat.ps1 +++ b/plugins/kiro/scripts/heartbeat.ps1 @@ -155,27 +155,20 @@ function Resolve-BaseUrl { $script:baseUrl = $script:baseUrl.TrimEnd('/') } -# ── actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. function Resolve-Actor { - $script:actorName = $creds['ROGUE_ACTOR_NAME'] - $script:actorEmail = $creds['ROGUE_ACTOR_EMAIL'] - if (-not $script:actorName -or -not $script:actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $script:pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $script:actorName) { $script:actorName = [string]$gitId.Name } - if (-not $script:actorEmail) { $script:actorEmail = [string]$gitId.Email } + $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } + try { + $actorLib = Join-Path $script:pluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $script:pluginRoot } - } - if (-not $script:actorName) { $script:actorName = $env:USERNAME } - if (-not $script:actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $script:actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $script:actorEmail = $env:USERNAME } else { $script:actorEmail = $env:COMPUTERNAME } - } + } catch {} + $script:actorName = [string]$actor.Name + $script:actorEmail = [string]$actor.Email } # ── plugin version (regex from plugin.json, no python; same source as hook.ps1) ── diff --git a/plugins/kiro/scripts/hook.ps1 b/plugins/kiro/scripts/hook.ps1 index 77bc8fe..81bfe7d 100644 --- a/plugins/kiro/scripts/hook.ps1 +++ b/plugins/kiro/scripts/hook.ps1 @@ -315,27 +315,20 @@ function Initialize-KiroContext { if ($t -match '^[0-9]{1,9}$' -and [int]$t -gt 0) { $script:timeoutSec = [int]$t } } +# ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── +# env file → git config files → @ → unknown. A damaged install with +# no library still reports the env file values. function Resolve-KiroActor { - # -- actor resolution (mirrors actor.sh): creds → git config files → USERNAME/COMPUTERNAME - $script:actorName = $creds['ROGUE_ACTOR_NAME'] - $script:actorEmail = $creds['ROGUE_ACTOR_EMAIL'] - if (-not $script:actorName -or -not $script:actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null - try { - $gitLib = Join-Path $script:pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } - } catch {} - if ($gitId) { - if (-not $script:actorName) { $script:actorName = [string]$gitId.Name } - if (-not $script:actorEmail) { $script:actorEmail = [string]$gitId.Email } + $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } + try { + $actorLib = Join-Path $script:pluginRoot 'scripts\actor.ps1' + if (Test-Path -LiteralPath $actorLib) { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $actorLib))) + $actor = Resolve-RogueSharedActor $creds $script:pluginRoot } - } - if (-not $script:actorName) { $script:actorName = $env:USERNAME } - if (-not $script:actorEmail) { - if ($env:USERNAME -and $env:COMPUTERNAME) { $script:actorEmail = "$($env:USERNAME)@$($env:COMPUTERNAME)" } - elseif ($env:USERNAME) { $script:actorEmail = $env:USERNAME } else { $script:actorEmail = $env:COMPUTERNAME } - } + } catch {} + $script:actorName = [string]$actor.Name + $script:actorEmail = [string]$actor.Email } function Read-KiroPayload { diff --git a/plugins/rogue/scripts/git-identity.sh b/plugins/rogue/scripts/git-identity.sh index 8fd8597..aa9e210 100644 --- a/plugins/rogue/scripts/git-identity.sh +++ b/plugins/rogue/scripts/git-identity.sh @@ -8,10 +8,12 @@ # followed by its [include] path entries (one level; includeIf is not evaluated). # Print the last value of [$2] $3 in git config file $1 and its includes. +# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise +# drain the hook payload the bridge has not read yet. _rogue_gitcfg_value() { [ -r "$1" ] || return 0 awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' - function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } function value(s) { s = trim(s) if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } @@ -39,7 +41,7 @@ _rogue_gitcfg_value() { close(file) } BEGIN { scan(main, 0); if (found != "") print found } - ' 2>/dev/null + ' /dev/null } rogue_git_identity() { diff --git a/plugins/rogue/scripts/hook.ps1 b/plugins/rogue/scripts/hook.ps1 index a79ddb3..a149ef7 100644 --- a/plugins/rogue/scripts/hook.ps1 +++ b/plugins/rogue/scripts/hook.ps1 @@ -250,7 +250,7 @@ function Select-ActorValue { return '' } -function Get-RogueGitIdentity { +function Read-RogueGitIdentity { # user.email / user.name from the git config FILES through scripts/git-identity.ps1, # never git.exe (one rule with actor.sh). Empty fields when the library or the # files are missing. @@ -282,7 +282,7 @@ function Resolve-RogueActor { $name = Select-ActorValue @($Creds['ROGUE_ACTOR_NAME'], (($hostMail -split '@')[0])) $email = Select-ActorValue @($Creds['ROGUE_ACTOR_EMAIL'], $env:CLAUDE_CODE_USER_EMAIL) if (-not $name -or -not $email) { - $git = Get-RogueGitIdentity $PluginRoot + $git = Read-RogueGitIdentity $PluginRoot $name = Select-ActorValue @($name, [string]$git.Name) $email = Select-ActorValue @($email, [string]$git.Email) } diff --git a/scripts/shared/actor.ps1 b/scripts/shared/actor.ps1 new file mode 100644 index 0000000..e4d6713 --- /dev/null +++ b/scripts/shared/actor.ps1 @@ -0,0 +1,48 @@ +# 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 -> 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 = @{} } + $name = [string]$Creds['ROGUE_ACTOR_NAME'] + $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + 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 } +} diff --git a/scripts/shared/actor.sh b/scripts/shared/actor.sh index ee69bdd..4600216 100755 --- a/scripts/shared/actor.sh +++ b/scripts/shared/actor.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Sourceable. Resolves ROGUE_ACTOR_{EMAIL,NAME} from a cascade. -# Cascade: env → git config files (scripts/git-identity.sh) → login@hostname / login. +# 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. if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then @@ -21,5 +22,7 @@ if [ -z "${ROGUE_ACTOR_EMAIL:-}" ] || [ -z "${ROGUE_ACTOR_NAME:-}" ]; then [ -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 diff --git a/scripts/shared/git-identity.sh b/scripts/shared/git-identity.sh index 8fd8597..aa9e210 100644 --- a/scripts/shared/git-identity.sh +++ b/scripts/shared/git-identity.sh @@ -8,10 +8,12 @@ # followed by its [include] path entries (one level; includeIf is not evaluated). # Print the last value of [$2] $3 in git config file $1 and its includes. +# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise +# drain the hook payload the bridge has not read yet. _rogue_gitcfg_value() { [ -r "$1" ] || return 0 awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' - function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } function value(s) { s = trim(s) if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } @@ -39,7 +41,7 @@ _rogue_gitcfg_value() { close(file) } BEGIN { scan(main, 0); if (found != "") print found } - ' 2>/dev/null + ' /dev/null } rogue_git_identity() { diff --git a/scripts/sync-shared-scripts.sh b/scripts/sync-shared-scripts.sh index 082f7a2..45e23c9 100644 --- a/scripts/sync-shared-scripts.sh +++ b/scripts/sync-shared-scripts.sh @@ -31,10 +31,9 @@ SRC="$REPO/scripts/shared" # implementations (plugins/gemini/scripts/ship-logs.mjs, and the beacon throttle # inlined in heartbeat.mjs) because Gemini CLI guarantees Node 20+, which is why # there is no sh/PowerShell pair to keep in lockstep there. -# actor.sh is shared by the plugins whose bridge runs under a plain shell with no -# host-supplied identity; plugins/rogue keeps its own (it ranks -# CLAUDE_CODE_USER_EMAIL above git and screens the Cowork sandbox identity), and -# cursor resolves the actor inline. +# actor.sh / actor.ps1 are shared by the plugins whose host supplies no identity; +# plugins/rogue keeps its own (it ranks CLAUDE_CODE_USER_EMAIL above git and +# screens the Cowork sandbox identity). ROWS=( "ship-logs.sh|rogue codex cursor copilot antigravity kiro" "ship-logs.ps1|rogue codex cursor copilot antigravity kiro" @@ -44,7 +43,8 @@ ROWS=( "env-file.ps1|rogue codex cursor copilot antigravity kiro" "git-identity.sh|rogue codex cursor copilot antigravity kiro" "git-identity.ps1|rogue codex cursor copilot antigravity kiro" - "actor.sh|codex copilot antigravity kiro" + "actor.sh|codex cursor copilot antigravity kiro" + "actor.ps1|codex cursor copilot antigravity kiro" ) # An unrecognized argument is an ERROR, not a silent write: the two modes have diff --git a/tests/test_actor_sh.sh b/tests/test_actor_sh.sh index 6a25efe..1649127 100755 --- a/tests/test_actor_sh.sh +++ b/tests/test_actor_sh.sh @@ -216,6 +216,29 @@ ROOT_DIR="$FAKE_HOME" GIT_EMAIL="jane@corp.com"; GIT_NAME="Jane Dev" assert_actor "jane@devbox|jane" "missing git-identity.sh degrades to login@hostname" +# ── Case 20: a CRLF ~/.gitconfig yields no trailing \r ───────────────────── +# curl would put a bare CR in the actor header and a strict server rejects the +# request; git-identity.ps1 and shared.mjs already drop it, so the shells must too. +scenario +write_gitconfig +printf '[user]\r\n\temail = jane@corp.com\r\n\tname = Jane Dev\r\n' > "$FAKE_HOME/.gitconfig" +actual="$(resolve)" +[ "$actual" = "jane@corp.com|Jane Dev" ] || { echo "FAIL [crlf]: got <$(printf '%s' "$actual" | od -c | head -2)>" >&2; exit 1; } +echo " ok: CRLF line endings in the git config do not leak a \\r into the actor" + +# ── Case 21: `[include] path = /dev/stdin` must not drain the hook payload ───── +# Every sh bridge resolves the actor BEFORE it reads the payload, so a parser +# that inherits stdin would leave the server an empty body and the hook fails open. +scenario +write_gitconfig +printf '[include]\n\tpath = /dev/stdin\n' > "$FAKE_HOME/.gitconfig" +actual="$(printf '{"tool":"Bash"}' | HOME="$FAKE_HOME" XDG_CONFIG_HOME= PATH="$STUB:$PATH" USER= USERNAME= \ + CLAUDE_PLUGIN_ROOT="$REPO/plugins/rogue" PLUGIN_ROOT="$REPO/plugins/codex" \ + ROGUE_ACTOR_EMAIL= ROGUE_ACTOR_NAME= CLAUDE_CODE_USER_EMAIL= STUB_HOSTNAME=devbox STUB_WHOAMI=jane \ + "$SH" -c '. "$1"; printf "%s|%s|%s" "$ROGUE_ACTOR_EMAIL" "$ROGUE_ACTOR_NAME" "$(cat)"' _ "$ACTOR")" +[ "$actual" = 'jane@devbox|jane|{"tool":"Bash"}' ] || { echo "FAIL [stdin include]: got <$actual>" >&2; exit 1; } +echo " ok: an include of /dev/stdin reads nothing; the payload survives for the bridge" + echo "── scripts/shared/actor.sh (codex copy) ──" ACTOR="$SHARED_ACTOR" @@ -240,6 +263,17 @@ scenario HOST_NAME="" assert_actor "jane|jane" "login alone when the hostname is unavailable" +scenario +HOST_NAME=""; WHO="" +assert_actor "unknown|unknown" "the unknown marker when login and hostname are both unavailable, never blank" + +scenario +write_gitconfig +printf '[user]\r\n\temail = jane@corp.com\r\n\tname = Jane Dev\r\n' > "$FAKE_HOME/.gitconfig" +actual="$(resolve)" +[ "$actual" = "jane@corp.com|Jane Dev" ] || { echo "FAIL [shared crlf]: got <$actual>" >&2; exit 1; } +echo " ok: CRLF git config read cleanly by the shared cascade" + # ── The git binary was never run, in any case above ────────────────────────── if [ -s "$TRIPWIRE" ]; then echo "FAIL [git tripwire]: the cascade invoked git:" >&2; cat "$TRIPWIRE" >&2; exit 1 diff --git a/tests/test_git_identity_ps1.ps1 b/tests/test_git_identity_ps1.ps1 index e262099..9ccb6b6 100644 --- a/tests/test_git_identity_ps1.ps1 +++ b/tests/test_git_identity_ps1.ps1 @@ -5,6 +5,8 @@ # FILES (XDG then ~/.gitconfig, later wins, one level of [include] path). # 2. plugins/rogue/scripts/hook.ps1 Resolve-RogueActor: env file → git config # files → @, loaded through the ROGUE_PS_LIB_ONLY seam. +# 2b. scripts/shared/actor.ps1 Resolve-RogueSharedActor, the same three levels +# for codex/cursor/copilot/antigravity/kiro. # 3. No dispatcher or heartbeat shells out to git: a stub git ahead of PATH is a # tripwire, and the sources are grepped for the old `& git config` call. # @@ -15,6 +17,7 @@ $here = Split-Path -Parent $MyInvocation.MyCommand.Path $repo = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($here, '..')) $lib = [System.IO.Path]::Combine($repo, 'scripts', 'shared', 'git-identity.ps1') $hook = [System.IO.Path]::Combine($repo, 'plugins', 'rogue', 'scripts', 'hook.ps1') +$sharedActor = [System.IO.Path]::Combine($repo, 'scripts', 'shared', 'actor.ps1') $fails = 0 $count = 0 @@ -92,6 +95,12 @@ try { Assert-Eq $id.Email 'home@corp.com' '~/.gitconfig overrides the XDG value' Assert-Eq $id.Name 'Xdg Me' 'a field only XDG carries survives' + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`r`n`temail = jane@corp.com`r`n`tname = Jane Dev`r`n" + $id = Read-GitId + Assert-Eq $id.Email 'jane@corp.com' 'CRLF file: no trailing CR on the email (same bytes as git-identity.sh)' + Assert-Eq $id.Name 'Jane Dev' 'CRLF file: no trailing CR on the name' + $h = New-TestHome $id = Read-GitId Assert-Eq $id.Email '' 'no config file: empty email, no error' @@ -148,6 +157,47 @@ try { $a = Resolve-RogueActor @{} ([System.IO.Path]::Combine($h, 'no-such-plugin')) Assert-Eq $a.Email $loginAtHost 'a damaged install (no git-identity.ps1) degrades to login@host' + Write-Host '-- scripts/shared/actor.ps1 Resolve-RogueSharedActor: the three fallback levels --' + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $sharedActor))) + $codexRoot = [System.IO.Path]::Combine($repo, 'plugins', 'codex') + $sharedLogin = $env:USERNAME + if (-not $sharedLogin) { $sharedLogin = [Environment]::UserName } + $sharedHost = $env:COMPUTERNAME + if (-not $sharedHost) { $sharedHost = $dns } + $sharedLoginAtHost = if ($sharedHost) { "$sharedLogin@$sharedHost" } else { $sharedLogin } + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`temail = jane@corp.com`n`tname = Jane Dev`n" + $a = Resolve-RogueSharedActor @{ ROGUE_ACTOR_EMAIL = 'mdm@corp.com'; ROGUE_ACTOR_NAME = 'MDM Provisioned' } $codexRoot + Assert-Eq $a.Email 'mdm@corp.com' 'shared level 1: env file email wins over the git identity' + Assert-Eq $a.Name 'MDM Provisioned' 'shared level 1: env file name wins over the git identity' + + $a = Resolve-RogueSharedActor @{} $codexRoot + Assert-Eq $a.Email 'jane@corp.com' 'shared level 2: git config file email' + Assert-Eq $a.Name 'Jane Dev' 'shared level 2: git config file name' + + $a = Resolve-RogueSharedActor @{ ROGUE_ACTOR_EMAIL = 'mdm@corp.com' } $codexRoot + Assert-Eq $a.Email 'mdm@corp.com' 'shared: fields resolve independently (email from env)' + Assert-Eq $a.Name 'Jane Dev' 'shared: fields resolve independently (name from git)' + + $h = New-TestHome + $a = Resolve-RogueSharedActor @{} $codexRoot + Assert-Eq $a.Email $sharedLoginAtHost 'shared level 3: @ when there is no git identity' + Assert-Eq $a.Name $sharedLogin 'shared level 3: login as the name' + Assert-Eq ([bool]$a.Email) $true 'shared level 3: the email is never blank' + + $a = Resolve-RogueSharedActor @{} ([System.IO.Path]::Combine($h, 'no-such-plugin')) + Assert-Eq $a.Email $sharedLoginAtHost 'shared: a damaged install (no git-identity.ps1) degrades to login@host' + + Write-Host '-- every non-Claude bridge loads the shared cascade --' + foreach ($p in 'codex','copilot','antigravity','kiro','cursor') { + foreach ($f in 'hook.ps1','heartbeat.ps1') { + $src = [System.IO.Path]::Combine($repo, 'plugins', $p, 'scripts', $f) + if (-not (Test-Path -LiteralPath $src)) { continue } + Assert-Eq ((Get-Content -Raw -LiteralPath $src) -match 'Resolve-RogueSharedActor') $true "$p/$f resolves the actor through actor.ps1" + } + } + Write-Host '-- no bridge shells out to git --' Assert-Eq (Test-Path -LiteralPath $tripMarker) $false 'git binary never invoked' foreach ($p in 'rogue','codex','copilot','antigravity','kiro','cursor') { From 7da1dea2b0bc097d09f61e0fd4ff7452d40de1c4 Mon Sep 17 00:00:00 2001 From: Yuval Date: Sat, 12 Sep 2026 21:24:44 +0300 Subject: [PATCH 5/7] fix(plugins): read git config as git does, one Claude cascade on PowerShell, no fail-open on non-Latin-1 names (FIRE-2117) Review follow-ups on the hook-time actor fallback. - git-identity.sh: strip a UTF-8 BOM (Windows editors write one; git and the ps1/mjs readers already accepted it, so the same user was two roster rows), parse values as git does (backslash escapes, quoted # and ;), and scan both config files and both keys in ONE awk process instead of four. - git-identity.ps1 / shared.mjs: the same value syntax, so the three readers agree with git and with each other. - gemini hook.mjs: actor headers are sent as their UTF-8 bytes. fetch() rejects any code unit above 0xFF, so a Hebrew/CJK user.name threw inside the fail-open catch and silently disabled every Gemini guardrail for that user. - rogue heartbeat.ps1: load hook.ps1 through its ROGUE_PS_LIB_ONLY seam in a child scope and call Resolve-RogueActor instead of a third inline copy of the Claude cascade. - codex/copilot/cursor/antigravity/kiro ps1 callers: the unknown marker when the shared library is missing, never a blank actor (parity with the sh side). - cursor/hook.sh: the ship-logs note no longer claims the actor lives in locals. - docs: plugin-log-shipper.md drops the pre-change Cursor cascade and the hostname vs $USER@hostname table; README / log-shipping.md say the actor is self-reported and that attribution comes from the API key and the endpoint. - tests: BOM and escaped-quote cases in all three readers, a non-Latin-1 name reaching the stub server, and the seam construct heartbeat.ps1 uses. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- docs/log-shipping.md | 5 +- docs/plugin-log-shipper.md | 36 ++++------ plugins/antigravity/scripts/git-identity.ps1 | 19 ++++- plugins/antigravity/scripts/git-identity.sh | 55 ++++++++------ plugins/antigravity/scripts/heartbeat.ps1 | 4 +- plugins/antigravity/scripts/hook.ps1 | 4 +- plugins/codex/scripts/git-identity.ps1 | 19 ++++- plugins/codex/scripts/git-identity.sh | 55 ++++++++------ plugins/codex/scripts/heartbeat.ps1 | 4 +- plugins/codex/scripts/hook.ps1 | 4 +- plugins/copilot/scripts/git-identity.ps1 | 19 ++++- plugins/copilot/scripts/git-identity.sh | 55 ++++++++------ plugins/copilot/scripts/heartbeat.ps1 | 4 +- plugins/copilot/scripts/hook.ps1 | 4 +- plugins/cursor/scripts/git-identity.ps1 | 19 ++++- plugins/cursor/scripts/git-identity.sh | 55 ++++++++------ plugins/cursor/scripts/hook.ps1 | 4 +- plugins/cursor/scripts/hook.sh | 11 ++- plugins/gemini/scripts/hook.mjs | 5 +- plugins/gemini/scripts/shared.mjs | 23 +++++- plugins/kiro/scripts/git-identity.ps1 | 19 ++++- plugins/kiro/scripts/git-identity.sh | 55 ++++++++------ plugins/kiro/scripts/heartbeat.ps1 | 4 +- plugins/kiro/scripts/hook.ps1 | 4 +- plugins/rogue/scripts/git-identity.ps1 | 19 ++++- plugins/rogue/scripts/git-identity.sh | 55 ++++++++------ plugins/rogue/scripts/heartbeat.ps1 | 76 ++++++-------------- scripts/shared/git-identity.ps1 | 19 ++++- scripts/shared/git-identity.sh | 55 ++++++++------ tests/test_actor_sh.sh | 25 +++++++ tests/test_git_identity_ps1.ps1 | 33 +++++++++ tests/test_hook_mjs.mjs | 35 +++++++++ tests/test_hook_ps1.ps1 | 8 +-- 34 files changed, 544 insertions(+), 271 deletions(-) diff --git a/README.md b/README.md index b24c70f..73978c2 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,9 @@ 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 `~/.gitconfig` (read as a file; -`git` itself is never run), then `@`. +`git` itself is never run), then `@`. 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). diff --git a/docs/log-shipping.md b/docs/log-shipping.md index 024bd9a..151f986 100644 --- a/docs/log-shipping.md +++ b/docs/log-shipping.md @@ -52,7 +52,10 @@ contract and not a coincidence: it **inherits** them from the caller rather than 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. See **The actor is passed IN** in +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 diff --git a/docs/plugin-log-shipper.md b/docs/plugin-log-shipper.md index b14ffd5..6fa3496 100644 --- a/docs/plugin-log-shipper.md +++ b/docs/plugin-log-shipper.md @@ -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 `@`, 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: @@ -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 diff --git a/plugins/antigravity/scripts/git-identity.ps1 b/plugins/antigravity/scripts/git-identity.ps1 index 0830bd9..1213057 100644 --- a/plugins/antigravity/scripts/git-identity.ps1 +++ b/plugins/antigravity/scripts/git-identity.ps1 @@ -16,6 +16,21 @@ function Resolve-RogueGitInclude { 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++; [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 } @@ -30,9 +45,7 @@ function Read-RogueGitConfig { $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $key = $line.Substring(0, $eq).Trim().ToLowerInvariant() - $val = $line.Substring($eq + 1).Trim() - if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } - else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + $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) { diff --git a/plugins/antigravity/scripts/git-identity.sh b/plugins/antigravity/scripts/git-identity.sh index aa9e210..7335d99 100644 --- a/plugins/antigravity/scripts/git-identity.sh +++ b/plugins/antigravity/scripts/git-identity.sh @@ -7,20 +7,32 @@ # 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 the last value of [$2] $3 in git config file $1 and its includes. -# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise -# drain the hook payload the bridge has not read yet. -_rogue_gitcfg_value() { - [ -r "$1" ] || return 0 - awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' +# Print "E" and "N" (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 } - function value(s) { - s = trim(s) - if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } - sub(/[ \t]*[#;].*$/, "", s); return trim(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) { + s = trim(s); out = ""; q = 0; n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\" && i < n) { i++; out = out substr(s, i, 1) } + else if (c == "\"") q = !q + else if (!q && (c == "#" || c == ";")) break + else out = out c + } + return trim(out) } - function scan(file, depth, line, sect, l, eq, k, v, inc) { + 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) == "[") { @@ -36,23 +48,24 @@ _rogue_gitcfg_value() { if (inc ~ /^~\//) inc = home substr(inc, 2) else if (inc !~ /^\//) inc = dir "/" inc scan(inc, 1) - } else if (sect == section && k == key && v != "") found = v + } else if (sect == "user" && v != "") { + if (k == "email") email = v + else if (k == "name") name = v + } } close(file) } - BEGIN { scan(main, 0); if (found != "") print found } - ' /dev/null + BEGIN { for (i = 1; i < ARGC; i++) scan(ARGV[i], 0); print "E" email; print "N" name } + ' "$@" /dev/null } rogue_git_identity() { ROGUE_GIT_EMAIL="" ROGUE_GIT_NAME="" - for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) - [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) - [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" - done - unset _rogue_gc _rogue_gv + { IFS= read -r ROGUE_GIT_EMAIL; IFS= read -r ROGUE_GIT_NAME; } <@ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. function Resolve-Actor { $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { @@ -172,6 +172,8 @@ function Resolve-Actor { } catch {} $script:actorName = [string]$actor.Name $script:actorEmail = [string]$actor.Email + if (-not $script:actorName) { $script:actorName = 'unknown' } + if (-not $script:actorEmail) { $script:actorEmail = 'unknown' } } # ── plugin version (from the bundled VERSION file, NOT plugin.json — the diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index e4729ac..c3b9dea 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -292,7 +292,7 @@ function Resolve-Url { # ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── # env file → git config files → @ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. function Resolve-Actor { $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { @@ -304,6 +304,8 @@ function Resolve-Actor { } catch {} $script:actorName = [string]$actor.Name $script:actorEmail = [string]$actor.Email + if (-not $script:actorName) { $script:actorName = 'unknown' } + if (-not $script:actorEmail) { $script:actorEmail = 'unknown' } } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── diff --git a/plugins/codex/scripts/git-identity.ps1 b/plugins/codex/scripts/git-identity.ps1 index 0830bd9..1213057 100644 --- a/plugins/codex/scripts/git-identity.ps1 +++ b/plugins/codex/scripts/git-identity.ps1 @@ -16,6 +16,21 @@ function Resolve-RogueGitInclude { 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++; [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 } @@ -30,9 +45,7 @@ function Read-RogueGitConfig { $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $key = $line.Substring(0, $eq).Trim().ToLowerInvariant() - $val = $line.Substring($eq + 1).Trim() - if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } - else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + $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) { diff --git a/plugins/codex/scripts/git-identity.sh b/plugins/codex/scripts/git-identity.sh index aa9e210..7335d99 100644 --- a/plugins/codex/scripts/git-identity.sh +++ b/plugins/codex/scripts/git-identity.sh @@ -7,20 +7,32 @@ # 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 the last value of [$2] $3 in git config file $1 and its includes. -# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise -# drain the hook payload the bridge has not read yet. -_rogue_gitcfg_value() { - [ -r "$1" ] || return 0 - awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' +# Print "E" and "N" (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 } - function value(s) { - s = trim(s) - if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } - sub(/[ \t]*[#;].*$/, "", s); return trim(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) { + s = trim(s); out = ""; q = 0; n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\" && i < n) { i++; out = out substr(s, i, 1) } + else if (c == "\"") q = !q + else if (!q && (c == "#" || c == ";")) break + else out = out c + } + return trim(out) } - function scan(file, depth, line, sect, l, eq, k, v, inc) { + 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) == "[") { @@ -36,23 +48,24 @@ _rogue_gitcfg_value() { if (inc ~ /^~\//) inc = home substr(inc, 2) else if (inc !~ /^\//) inc = dir "/" inc scan(inc, 1) - } else if (sect == section && k == key && v != "") found = v + } else if (sect == "user" && v != "") { + if (k == "email") email = v + else if (k == "name") name = v + } } close(file) } - BEGIN { scan(main, 0); if (found != "") print found } - ' /dev/null + BEGIN { for (i = 1; i < ARGC; i++) scan(ARGV[i], 0); print "E" email; print "N" name } + ' "$@" /dev/null } rogue_git_identity() { ROGUE_GIT_EMAIL="" ROGUE_GIT_NAME="" - for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) - [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) - [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" - done - unset _rogue_gc _rogue_gv + { IFS= read -r ROGUE_GIT_EMAIL; IFS= read -r ROGUE_GIT_NAME; } <@ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { $actorLib = Join-Path $pluginRoot 'scripts\actor.ps1' @@ -136,6 +136,8 @@ try { } catch {} $actorName = [string]$actor.Name $actorEmail = [string]$actor.Email +if (-not $actorName) { $actorName = 'unknown' } +if (-not $actorEmail) { $actorEmail = 'unknown' } # ── plugin version (regex from manifest, no python) ──────────────────────── $ver = 'unknown' diff --git a/plugins/codex/scripts/hook.ps1 b/plugins/codex/scripts/hook.ps1 index a131365..5935b52 100644 --- a/plugins/codex/scripts/hook.ps1 +++ b/plugins/codex/scripts/hook.ps1 @@ -246,7 +246,7 @@ if (-not $url) { # ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── # env file → git config files → @ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { $actorLib = Join-Path $pluginRoot 'scripts\actor.ps1' @@ -257,6 +257,8 @@ try { } catch {} $actorName = [string]$actor.Name $actorEmail = [string]$actor.Email +if (-not $actorName) { $actorName = 'unknown' } +if (-not $actorEmail) { $actorEmail = 'unknown' } # ── per-turn presence heartbeat + log ship (Stop only) ───────────────────── # The PowerShell twin of hook.sh's Stop block. SessionStart's heartbeat is spawned by diff --git a/plugins/copilot/scripts/git-identity.ps1 b/plugins/copilot/scripts/git-identity.ps1 index 0830bd9..1213057 100644 --- a/plugins/copilot/scripts/git-identity.ps1 +++ b/plugins/copilot/scripts/git-identity.ps1 @@ -16,6 +16,21 @@ function Resolve-RogueGitInclude { 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++; [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 } @@ -30,9 +45,7 @@ function Read-RogueGitConfig { $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $key = $line.Substring(0, $eq).Trim().ToLowerInvariant() - $val = $line.Substring($eq + 1).Trim() - if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } - else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + $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) { diff --git a/plugins/copilot/scripts/git-identity.sh b/plugins/copilot/scripts/git-identity.sh index aa9e210..7335d99 100644 --- a/plugins/copilot/scripts/git-identity.sh +++ b/plugins/copilot/scripts/git-identity.sh @@ -7,20 +7,32 @@ # 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 the last value of [$2] $3 in git config file $1 and its includes. -# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise -# drain the hook payload the bridge has not read yet. -_rogue_gitcfg_value() { - [ -r "$1" ] || return 0 - awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' +# Print "E" and "N" (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 } - function value(s) { - s = trim(s) - if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } - sub(/[ \t]*[#;].*$/, "", s); return trim(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) { + s = trim(s); out = ""; q = 0; n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\" && i < n) { i++; out = out substr(s, i, 1) } + else if (c == "\"") q = !q + else if (!q && (c == "#" || c == ";")) break + else out = out c + } + return trim(out) } - function scan(file, depth, line, sect, l, eq, k, v, inc) { + 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) == "[") { @@ -36,23 +48,24 @@ _rogue_gitcfg_value() { if (inc ~ /^~\//) inc = home substr(inc, 2) else if (inc !~ /^\//) inc = dir "/" inc scan(inc, 1) - } else if (sect == section && k == key && v != "") found = v + } else if (sect == "user" && v != "") { + if (k == "email") email = v + else if (k == "name") name = v + } } close(file) } - BEGIN { scan(main, 0); if (found != "") print found } - ' /dev/null + BEGIN { for (i = 1; i < ARGC; i++) scan(ARGV[i], 0); print "E" email; print "N" name } + ' "$@" /dev/null } rogue_git_identity() { ROGUE_GIT_EMAIL="" ROGUE_GIT_NAME="" - for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) - [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) - [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" - done - unset _rogue_gc _rogue_gv + { IFS= read -r ROGUE_GIT_EMAIL; IFS= read -r ROGUE_GIT_NAME; } <@ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { $actorLib = Join-Path $pluginRoot 'scripts\actor.ps1' @@ -138,6 +138,8 @@ try { } catch {} $actorName = [string]$actor.Name $actorEmail = [string]$actor.Email +if (-not $actorName) { $actorName = 'unknown' } +if (-not $actorEmail) { $actorEmail = 'unknown' } # ── plugin version (regex from manifest, no python) ──────────────────────── $ver = 'unknown' diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index b1facdb..d293aaf 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -332,7 +332,7 @@ if (-not $url) { # ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── # env file → git config files → @ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { $actorLib = Join-Path $PluginRoot 'scripts\actor.ps1' @@ -343,6 +343,8 @@ try { } catch {} $actorName = [string]$actor.Name $actorEmail = [string]$actor.Email +if (-not $actorName) { $actorName = 'unknown' } +if (-not $actorEmail) { $actorEmail = 'unknown' } # ── payload from stdin (recover UTF-8, strip BOM) ────────────────────────── $payload = [Console]::In.ReadToEnd() diff --git a/plugins/cursor/scripts/git-identity.ps1 b/plugins/cursor/scripts/git-identity.ps1 index 0830bd9..1213057 100644 --- a/plugins/cursor/scripts/git-identity.ps1 +++ b/plugins/cursor/scripts/git-identity.ps1 @@ -16,6 +16,21 @@ function Resolve-RogueGitInclude { 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++; [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 } @@ -30,9 +45,7 @@ function Read-RogueGitConfig { $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $key = $line.Substring(0, $eq).Trim().ToLowerInvariant() - $val = $line.Substring($eq + 1).Trim() - if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } - else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + $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) { diff --git a/plugins/cursor/scripts/git-identity.sh b/plugins/cursor/scripts/git-identity.sh index aa9e210..7335d99 100644 --- a/plugins/cursor/scripts/git-identity.sh +++ b/plugins/cursor/scripts/git-identity.sh @@ -7,20 +7,32 @@ # 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 the last value of [$2] $3 in git config file $1 and its includes. -# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise -# drain the hook payload the bridge has not read yet. -_rogue_gitcfg_value() { - [ -r "$1" ] || return 0 - awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' +# Print "E" and "N" (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 } - function value(s) { - s = trim(s) - if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } - sub(/[ \t]*[#;].*$/, "", s); return trim(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) { + s = trim(s); out = ""; q = 0; n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\" && i < n) { i++; out = out substr(s, i, 1) } + else if (c == "\"") q = !q + else if (!q && (c == "#" || c == ";")) break + else out = out c + } + return trim(out) } - function scan(file, depth, line, sect, l, eq, k, v, inc) { + 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) == "[") { @@ -36,23 +48,24 @@ _rogue_gitcfg_value() { if (inc ~ /^~\//) inc = home substr(inc, 2) else if (inc !~ /^\//) inc = dir "/" inc scan(inc, 1) - } else if (sect == section && k == key && v != "") found = v + } else if (sect == "user" && v != "") { + if (k == "email") email = v + else if (k == "name") name = v + } } close(file) } - BEGIN { scan(main, 0); if (found != "") print found } - ' /dev/null + BEGIN { for (i = 1; i < ARGC; i++) scan(ARGV[i], 0); print "E" email; print "N" name } + ' "$@" /dev/null } rogue_git_identity() { ROGUE_GIT_EMAIL="" ROGUE_GIT_NAME="" - for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) - [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) - [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" - done - unset _rogue_gc _rogue_gv + { IFS= read -r ROGUE_GIT_EMAIL; IFS= read -r ROGUE_GIT_NAME; } <@ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { $actorLib = Join-Path $pluginRoot 'scripts\actor.ps1' @@ -830,6 +830,8 @@ try { } catch {} $actorName = [string]$actor.Name $actorEmail = [string]$actor.Email +if (-not $actorName) { $actorName = 'unknown' } +if (-not $actorEmail) { $actorEmail = 'unknown' } # ── install identity: host + plugin version ──────────────────────────────── # The fleet roster keys an install on host + actor + family + agent, and until diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index b58a3e2..a9627bf 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -769,12 +769,11 @@ if [ -n "$hb_unthrottled" ]; then # either way. This is the whole point of the `stop` trigger - on `sessionStart` # alone, a long session's log never left the disk. # - # The actor MUST be passed explicitly. Unlike the other plugins, which get it - # from actor.sh (which exports), this dispatcher resolves the actor into plain - # shell LOCALS - so without this prefix the child would inherit nothing, find no - # identity, and skip. It also must not re-resolve: a second cascade (rogue's - # actor.sh screens sandbox identities, for one) could key the log's source row - # differently from the roster row just posted. + # actor.sh exports both vars, so the child would inherit them anyway - the + # explicit prefix states the contract at the call site and covers an install + # whose actor.sh predates that export. The shipper must not re-resolve: a second + # cascade (rogue's actor.sh screens sandbox identities, for one) could key the + # log's source row differently from the roster row just posted. if [ -r "$PLUGIN_ROOT/scripts/ship-logs.sh" ]; then ( ROGUE_ACTOR_EMAIL="$actor_email" ROGUE_ACTOR_NAME="$actor_name" \ sh "$PLUGIN_ROOT/scripts/ship-logs.sh" \ diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index 3090142..76b0110 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -31,6 +31,7 @@ import { SURFACE, loadEnvFiles, resolveActor, + headerBytes, installId, } from "./shared.mjs"; @@ -495,8 +496,8 @@ async function main() { "Content-Type": "application/json", "x-rogue-api-key": apiKey, "x-rogue-event": EVENT, - "x-rogue-actor-email": actor.email, - "x-rogue-actor-name": actor.name, + "x-rogue-actor-email": headerBytes(actor.email), + "x-rogue-actor-name": headerBytes(actor.name), "x-rogue-host": install.host, "x-rogue-version": install.version, "x-rogue-agent": install.agent, diff --git a/plugins/gemini/scripts/shared.mjs b/plugins/gemini/scripts/shared.mjs index 88599ef..d01bdba 100644 --- a/plugins/gemini/scripts/shared.mjs +++ b/plugins/gemini/scripts/shared.mjs @@ -147,10 +147,20 @@ export function installId() { // and .ps1: $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). +// 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 gitConfigValue(raw) { - const v = raw.trim(); - if (v.startsWith('"')) return v.slice(1).replace(/".*$/, ""); - return v.replace(/\s*[#;].*$/, "").trim(); + const s = raw.trim(); + let out = ""; + let quoted = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === "\\" && i + 1 < s.length) out += s[++i]; + else if (c === '"') quoted = !quoted; + else if (!quoted && (c === "#" || c === ";")) break; + else out += c; + } + return out.trim(); } function readGitConfig(file, id, depth) { @@ -214,3 +224,10 @@ export function resolveActor(env) { if (!email) email = login && host ? `${login}@${host}` : login || host; return { email: email || "unknown", name: name || login || "unknown" }; } + +// The actor as a request-header value. fetch() rejects any code unit above 0xFF +// (a Hebrew or CJK git user.name failed the hook open), so the UTF-8 bytes are +// spelled as one char each: the same bytes curl puts on the wire for the sh bridges. +export function headerBytes(value) { + return Buffer.from(String(value), "utf8").toString("latin1"); +} diff --git a/plugins/kiro/scripts/git-identity.ps1 b/plugins/kiro/scripts/git-identity.ps1 index 0830bd9..1213057 100644 --- a/plugins/kiro/scripts/git-identity.ps1 +++ b/plugins/kiro/scripts/git-identity.ps1 @@ -16,6 +16,21 @@ function Resolve-RogueGitInclude { 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++; [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 } @@ -30,9 +45,7 @@ function Read-RogueGitConfig { $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $key = $line.Substring(0, $eq).Trim().ToLowerInvariant() - $val = $line.Substring($eq + 1).Trim() - if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } - else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + $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) { diff --git a/plugins/kiro/scripts/git-identity.sh b/plugins/kiro/scripts/git-identity.sh index aa9e210..7335d99 100644 --- a/plugins/kiro/scripts/git-identity.sh +++ b/plugins/kiro/scripts/git-identity.sh @@ -7,20 +7,32 @@ # 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 the last value of [$2] $3 in git config file $1 and its includes. -# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise -# drain the hook payload the bridge has not read yet. -_rogue_gitcfg_value() { - [ -r "$1" ] || return 0 - awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' +# Print "E" and "N" (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 } - function value(s) { - s = trim(s) - if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } - sub(/[ \t]*[#;].*$/, "", s); return trim(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) { + s = trim(s); out = ""; q = 0; n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\" && i < n) { i++; out = out substr(s, i, 1) } + else if (c == "\"") q = !q + else if (!q && (c == "#" || c == ";")) break + else out = out c + } + return trim(out) } - function scan(file, depth, line, sect, l, eq, k, v, inc) { + 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) == "[") { @@ -36,23 +48,24 @@ _rogue_gitcfg_value() { if (inc ~ /^~\//) inc = home substr(inc, 2) else if (inc !~ /^\//) inc = dir "/" inc scan(inc, 1) - } else if (sect == section && k == key && v != "") found = v + } else if (sect == "user" && v != "") { + if (k == "email") email = v + else if (k == "name") name = v + } } close(file) } - BEGIN { scan(main, 0); if (found != "") print found } - ' /dev/null + BEGIN { for (i = 1; i < ARGC; i++) scan(ARGV[i], 0); print "E" email; print "N" name } + ' "$@" /dev/null } rogue_git_identity() { ROGUE_GIT_EMAIL="" ROGUE_GIT_NAME="" - for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) - [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) - [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" - done - unset _rogue_gc _rogue_gv + { IFS= read -r ROGUE_GIT_EMAIL; IFS= read -r ROGUE_GIT_NAME; } <@ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. function Resolve-Actor { $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { @@ -169,6 +169,8 @@ function Resolve-Actor { } catch {} $script:actorName = [string]$actor.Name $script:actorEmail = [string]$actor.Email + if (-not $script:actorName) { $script:actorName = 'unknown' } + if (-not $script:actorEmail) { $script:actorEmail = 'unknown' } } # ── plugin version (regex from plugin.json, no python; same source as hook.ps1) ── diff --git a/plugins/kiro/scripts/hook.ps1 b/plugins/kiro/scripts/hook.ps1 index 81bfe7d..3589c31 100644 --- a/plugins/kiro/scripts/hook.ps1 +++ b/plugins/kiro/scripts/hook.ps1 @@ -317,7 +317,7 @@ function Initialize-KiroContext { # ── actor resolution: scripts/actor.ps1 (synced from scripts/shared/actor.ps1) ── # env file → git config files → @ → unknown. A damaged install with -# no library still reports the env file values. +# no library still reports the env file values, or the marker, never a blank. function Resolve-KiroActor { $actor = @{ Email = [string]$creds['ROGUE_ACTOR_EMAIL']; Name = [string]$creds['ROGUE_ACTOR_NAME'] } try { @@ -329,6 +329,8 @@ function Resolve-KiroActor { } catch {} $script:actorName = [string]$actor.Name $script:actorEmail = [string]$actor.Email + if (-not $script:actorName) { $script:actorName = 'unknown' } + if (-not $script:actorEmail) { $script:actorEmail = 'unknown' } } function Read-KiroPayload { diff --git a/plugins/rogue/scripts/git-identity.ps1 b/plugins/rogue/scripts/git-identity.ps1 index 0830bd9..1213057 100644 --- a/plugins/rogue/scripts/git-identity.ps1 +++ b/plugins/rogue/scripts/git-identity.ps1 @@ -16,6 +16,21 @@ function Resolve-RogueGitInclude { 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++; [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 } @@ -30,9 +45,7 @@ function Read-RogueGitConfig { $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $key = $line.Substring(0, $eq).Trim().ToLowerInvariant() - $val = $line.Substring($eq + 1).Trim() - if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } - else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + $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) { diff --git a/plugins/rogue/scripts/git-identity.sh b/plugins/rogue/scripts/git-identity.sh index aa9e210..7335d99 100644 --- a/plugins/rogue/scripts/git-identity.sh +++ b/plugins/rogue/scripts/git-identity.sh @@ -7,20 +7,32 @@ # 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 the last value of [$2] $3 in git config file $1 and its includes. -# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise -# drain the hook payload the bridge has not read yet. -_rogue_gitcfg_value() { - [ -r "$1" ] || return 0 - awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' +# Print "E" and "N" (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 } - function value(s) { - s = trim(s) - if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } - sub(/[ \t]*[#;].*$/, "", s); return trim(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) { + s = trim(s); out = ""; q = 0; n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\" && i < n) { i++; out = out substr(s, i, 1) } + else if (c == "\"") q = !q + else if (!q && (c == "#" || c == ";")) break + else out = out c + } + return trim(out) } - function scan(file, depth, line, sect, l, eq, k, v, inc) { + 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) == "[") { @@ -36,23 +48,24 @@ _rogue_gitcfg_value() { if (inc ~ /^~\//) inc = home substr(inc, 2) else if (inc !~ /^\//) inc = dir "/" inc scan(inc, 1) - } else if (sect == section && k == key && v != "") found = v + } else if (sect == "user" && v != "") { + if (k == "email") email = v + else if (k == "name") name = v + } } close(file) } - BEGIN { scan(main, 0); if (found != "") print found } - ' /dev/null + BEGIN { for (i = 1; i < ARGC; i++) scan(ARGV[i], 0); print "E" email; print "N" name } + ' "$@" /dev/null } rogue_git_identity() { ROGUE_GIT_EMAIL="" ROGUE_GIT_NAME="" - for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) - [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) - [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" - done - unset _rogue_gc _rogue_gv + { IFS= read -r ROGUE_GIT_EMAIL; IFS= read -r ROGUE_GIT_NAME; } < no-op'; exit 0 } $baseUrl = $creds['ROGUE_BASE_URL']; if (-not $baseUrl) { $baseUrl = 'https://api.rogue.security' } $baseUrl = $baseUrl.TrimEnd('/') -# -- actor resolution (mirrors actor.sh / hook.ps1 Resolve-RogueActor) -------- -# Screen the WHOLE address before splitting it. Taking the local-part first -# smuggles the sandbox identity past the screen: noreply@anthropic.com is -# rejected as an email, but its local-part "noreply" is not on the list. -$hostMail = Select-ActorValue @($env:CLAUDE_CODE_USER_EMAIL) -$actorName = Select-ActorValue @($creds['ROGUE_ACTOR_NAME'], (($hostMail -split '@')[0])) -$actorEmail = Select-ActorValue @($creds['ROGUE_ACTOR_EMAIL'], $env:CLAUDE_CODE_USER_EMAIL) -if (-not $actorName -or -not $actorEmail) { - # Git identity from the config files (scripts/git-identity.ps1), never git.exe. - $gitId = $null +# -- actor resolution: hook.ps1's Resolve-RogueActor ------------------------- +# The ONE Claude cascade (env file -> CLAUDE_CODE_USER_EMAIL -> git config files -> +# login@host, every candidate screened for the Cowork sandbox identity), loaded +# through hook.ps1's ROGUE_PS_LIB_ONLY seam inside a child scope so none of its +# helpers land in this one. The roster row and the event rows are keyed on the +# actor, so a second copy of the cascade here was a drift waiting to happen. +$actor = $null +$hookLib = Join-Path $pluginRoot 'scripts\hook.ps1' +if (Test-Path -LiteralPath $hookLib) { try { - $gitLib = Join-Path $pluginRoot 'scripts\git-identity.ps1' - if (Test-Path -LiteralPath $gitLib) { $gitId = & ([scriptblock]::Create((Get-Content -Raw -LiteralPath $gitLib))) } + $env:ROGUE_PS_LIB_ONLY = '1' + $actor = & { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $hookLib))) + Resolve-RogueActor $creds $pluginRoot + } } catch {} - if ($gitId) { - $actorName = Select-ActorValue @($actorName, [string]$gitId.Name) - $actorEmail = Select-ActorValue @($actorEmail, [string]$gitId.Email) - } -} -# POSIX ends this cascade at `whoami`. Windows deliberately does NOT shell out -# to whoami.exe: its output is DOMAIN\user, a different identity string that -# would re-fingerprint every existing roster row, and it costs a process per -# hook. [Environment]::UserName is the true twin - it reads the process token, -# so it still answers in the service contexts where USERNAME is unset. -$login = Select-ActorValue @($env:USERNAME, [Environment]::UserName) -if (-not $actorName) { $actorName = $login } -if (-not $actorName) { $actorName = 'unknown' } -if (-not $actorEmail) { - # Same fallback the roster host below already uses: COMPUTERNAME can be unset - # in service contexts, where the sh twin's `hostname` still answers. - $dnsHost = '' - try { $dnsHost = [System.Net.Dns]::GetHostName() } catch {} - $hostForActor = Select-ActorValue @($env:COMPUTERNAME, $dnsHost) - $who = $login - if (-not $who) { $who = 'unknown' } - if ($hostForActor) { $actorEmail = "$who@$hostForActor" } else { $actorEmail = $who } + # ship-logs.ps1, spawned below, honours the same seam and would load as a library. + finally { $env:ROGUE_PS_LIB_ONLY = $null } } +$actorEmail = [string]$creds['ROGUE_ACTOR_EMAIL'] +$actorName = [string]$creds['ROGUE_ACTOR_NAME'] +if ($actor) { $actorEmail = [string]$actor.Email; $actorName = [string]$actor.Name } +if (-not $actorEmail) { $actorEmail = 'unknown' } +if (-not $actorName) { $actorName = 'unknown' } # -- plugin version (regex from manifest, no python) ------------------------ $ver = 'unknown' @@ -285,8 +254,7 @@ if ($env:CLAUDE_CODE_ENTRYPOINT -and # same reason - Start-Process's -ArgumentList quoting is unreliable on Windows # PowerShell 5.1 for anything containing spaces. # -# The actor is PASSED IN, never re-resolved: this script resolved it into ordinary -# locals through a cascade of its own, and a second cascade inside the shipper +# The actor is PASSED IN, never re-resolved: a second cascade inside the shipper # would key the log's source row differently from the roster row just posted, so # the logs would attach to nothing. Writing $env: here is safe - this process # exits immediately below. diff --git a/scripts/shared/git-identity.ps1 b/scripts/shared/git-identity.ps1 index 0830bd9..1213057 100644 --- a/scripts/shared/git-identity.ps1 +++ b/scripts/shared/git-identity.ps1 @@ -16,6 +16,21 @@ function Resolve-RogueGitInclude { 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++; [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 } @@ -30,9 +45,7 @@ function Read-RogueGitConfig { $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $key = $line.Substring(0, $eq).Trim().ToLowerInvariant() - $val = $line.Substring($eq + 1).Trim() - if ($val.StartsWith('"')) { $val = $val.Substring(1) -replace '".*$', '' } - else { $val = ($val -replace '\s*[#;].*$', '').Trim() } + $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) { diff --git a/scripts/shared/git-identity.sh b/scripts/shared/git-identity.sh index aa9e210..7335d99 100644 --- a/scripts/shared/git-identity.sh +++ b/scripts/shared/git-identity.sh @@ -7,20 +7,32 @@ # 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 the last value of [$2] $3 in git config file $1 and its includes. -# awk reads stdin from /dev/null: an `[include] path = /dev/stdin` would otherwise -# drain the hook payload the bridge has not read yet. -_rogue_gitcfg_value() { - [ -r "$1" ] || return 0 - awk -v main="$1" -v dir="${1%/*}" -v home="$HOME" -v section="$2" -v key="$3" ' +# Print "E" and "N" (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 } - function value(s) { - s = trim(s) - if (substr(s, 1, 1) == "\"") { s = substr(s, 2); sub(/".*$/, "", s); return s } - sub(/[ \t]*[#;].*$/, "", s); return trim(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) { + s = trim(s); out = ""; q = 0; n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\" && i < n) { i++; out = out substr(s, i, 1) } + else if (c == "\"") q = !q + else if (!q && (c == "#" || c == ";")) break + else out = out c + } + return trim(out) } - function scan(file, depth, line, sect, l, eq, k, v, inc) { + 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) == "[") { @@ -36,23 +48,24 @@ _rogue_gitcfg_value() { if (inc ~ /^~\//) inc = home substr(inc, 2) else if (inc !~ /^\//) inc = dir "/" inc scan(inc, 1) - } else if (sect == section && k == key && v != "") found = v + } else if (sect == "user" && v != "") { + if (k == "email") email = v + else if (k == "name") name = v + } } close(file) } - BEGIN { scan(main, 0); if (found != "") print found } - ' /dev/null + BEGIN { for (i = 1; i < ARGC; i++) scan(ARGV[i], 0); print "E" email; print "N" name } + ' "$@" /dev/null } rogue_git_identity() { ROGUE_GIT_EMAIL="" ROGUE_GIT_NAME="" - for _rogue_gc in "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" "$HOME/.gitconfig"; do - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user email) - [ -n "$_rogue_gv" ] && ROGUE_GIT_EMAIL="$_rogue_gv" - _rogue_gv=$(_rogue_gitcfg_value "$_rogue_gc" user name) - [ -n "$_rogue_gv" ] && ROGUE_GIT_NAME="$_rogue_gv" - done - unset _rogue_gc _rogue_gv + { IFS= read -r ROGUE_GIT_EMAIL; IFS= read -r ROGUE_GIT_NAME; } <" >&2; exit 1; } echo " ok: an include of /dev/stdin reads nothing; the payload survives for the bridge" +# ── Case 22: a BOM-prefixed ~/.gitconfig (Windows editors write one) is read ─── +# git accepts the BOM; git-identity.ps1 and shared.mjs strip it, so the awk reader +# must too, or the same user is two roster rows: sh reports login@host, ps1 the git identity. +scenario +write_gitconfig +printf '\357\273\277[user]\n\temail = bom@corp.com\n\tname = Bom Me\n' > "$FAKE_HOME/.gitconfig" +actual="$(resolve)" +[ "$actual" = "bom@corp.com|Bom Me" ] || { echo "FAIL [bom]: got <$actual>" >&2; exit 1; } +echo " ok: a UTF-8 BOM before [user] does not hide the section" + +# ── Case 23: git's value syntax — escaped quotes, quoted comment characters ──── +scenario +write_gitconfig +printf '[user]\n\temail = jane@corp.com # work\n\tname = "Jane \\"JJ\\" Dev" ; nick\n' > "$FAKE_HOME/.gitconfig" +actual="$(resolve)" +[ "$actual" = 'jane@corp.com|Jane "JJ" Dev' ] || { echo "FAIL [escapes]: got <$actual>" >&2; exit 1; } +echo " ok: backslash-escaped quotes survive and a trailing comment is dropped, as git reads them" + echo "── scripts/shared/actor.sh (codex copy) ──" ACTOR="$SHARED_ACTOR" @@ -274,6 +292,13 @@ actual="$(resolve)" [ "$actual" = "jane@corp.com|Jane Dev" ] || { echo "FAIL [shared crlf]: got <$actual>" >&2; exit 1; } echo " ok: CRLF git config read cleanly by the shared cascade" +scenario +write_gitconfig +printf '\357\273\277[user]\n\temail = bom@corp.com\n\tname = "Bom \\"B\\" Me"\n' > "$FAKE_HOME/.gitconfig" +actual="$(resolve)" +[ "$actual" = 'bom@corp.com|Bom "B" Me' ] || { echo "FAIL [shared bom]: got <$actual>" >&2; exit 1; } +echo " ok: BOM and escaped quotes read by the shared cascade" + # ── The git binary was never run, in any case above ────────────────────────── if [ -s "$TRIPWIRE" ]; then echo "FAIL [git tripwire]: the cascade invoked git:" >&2; cat "$TRIPWIRE" >&2; exit 1 diff --git a/tests/test_git_identity_ps1.ps1 b/tests/test_git_identity_ps1.ps1 index 9ccb6b6..d3cd326 100644 --- a/tests/test_git_identity_ps1.ps1 +++ b/tests/test_git_identity_ps1.ps1 @@ -101,6 +101,18 @@ try { Assert-Eq $id.Email 'jane@corp.com' 'CRLF file: no trailing CR on the email (same bytes as git-identity.sh)' Assert-Eq $id.Name 'Jane Dev' 'CRLF file: no trailing CR on the name' + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) ([string][char]0xFEFF + "[user]`n`temail = bom@corp.com`n`tname = Bom Me`n") + $id = Read-GitId + Assert-Eq $id.Email 'bom@corp.com' 'a UTF-8 BOM before [user] does not hide the section (same as git-identity.sh)' + Assert-Eq $id.Name 'Bom Me' 'BOM file: name read' + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`temail = jane@corp.com # work`n`tname = `"Jane \`"JJ\`" Dev`" ; nick`n" + $id = Read-GitId + Assert-Eq $id.Email 'jane@corp.com' 'an unquoted trailing comment is dropped' + Assert-Eq $id.Name 'Jane "JJ" Dev' 'backslash-escaped quotes survive, as git reads them' + $h = New-TestHome $id = Read-GitId Assert-Eq $id.Email '' 'no config file: empty email, no error' @@ -157,6 +169,27 @@ try { $a = Resolve-RogueActor @{} ([System.IO.Path]::Combine($h, 'no-such-plugin')) Assert-Eq $a.Email $loginAtHost 'a damaged install (no git-identity.ps1) degrades to login@host' + # heartbeat.ps1 takes the same cascade through this seam inside a child scope: + # the actor comes out, none of hook.ps1's helpers land in the caller. + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`temail = hb@corp.com`n`tname = HB Dev`n" + $creds = @{} + $viaSeam = & { + $env:ROGUE_PS_LIB_ONLY = '1' + try { + & { + . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $hook))) + Resolve-RogueActor $creds $pluginRoot + } + } finally { $env:ROGUE_PS_LIB_ONLY = $null } + } + Assert-Eq $viaSeam.Email 'hb@corp.com' 'heartbeat.ps1 construct: the seam-loaded cascade answers the git identity' + Assert-Eq $viaSeam.Name 'HB Dev' 'heartbeat.ps1 construct: name too' + Assert-Eq ([bool][string]$env:ROGUE_PS_LIB_ONLY) $false 'heartbeat.ps1 construct: the seam variable is cleared for the shipper it spawns' + $hbSrc = Get-Content -Raw -LiteralPath ([System.IO.Path]::Combine($repo, 'plugins', 'rogue', 'scripts', 'heartbeat.ps1')) + Assert-Eq ($hbSrc -match 'Resolve-RogueActor \$creds \$pluginRoot') $true 'heartbeat.ps1 calls hook.ps1 Resolve-RogueActor' + Assert-Eq ($hbSrc -match 'function (Select-ActorValue|Test-SyntheticActor)') $false 'heartbeat.ps1 carries no copy of the cascade' + Write-Host '-- scripts/shared/actor.ps1 Resolve-RogueSharedActor: the three fallback levels --' . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $sharedActor))) $codexRoot = [System.IO.Path]::Combine($repo, 'plugins', 'codex') diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index f9e2703..83c2af8 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -257,6 +257,41 @@ test("no actor in the env file → git identity from the config files, git never } }); +test("BOM-prefixed config and git's escaped quotes read as git does (one rule with sh/ps1)", async () => { + const { server, seen, port } = await startServer(200, "{}"); + seedHome.base = `http://127.0.0.1:${port}`; + try { + await runHook("BeforeTool", "{}", {}, (home) => + seedHome(home, { + gitconfig: '\uFEFF[user]\n\temail = jane@corp.com # work\n\tname = "Jane \\"JJ\\" Dev" ; nick\n', + }), + ); + assert.equal(seen.headers["x-rogue-actor-email"], "jane@corp.com"); + assert.equal(seen.headers["x-rogue-actor-name"], 'Jane "JJ" Dev'); + } finally { + server.close(); + } +}); + +test("a non-Latin-1 git user.name still reaches the server, as the UTF-8 bytes curl would send", async () => { + // fetch() throws on any header code unit above 0xFF; before headerBytes that + // TypeError landed in the fail-open catch and every hook of such a user emitted {}. + const denyBody = JSON.stringify({ decision: "deny", reason: "blocked by test" }); + const { server, seen, port } = await startServer(200, denyBody); + seedHome.base = `http://127.0.0.1:${port}`; + try { + const out = await runHook("BeforeTool", "{}", {}, (home) => + seedHome(home, { gitconfig: "[user]\n\temail = yuval@corp.com\n\tname = יובל\n" }), + ); + assert.equal(out, denyBody, "the decision must reach Gemini, not a fail-open {}"); + // Node's server decodes header bytes as Latin-1; the bytes are the UTF-8 encoding. + assert.equal(Buffer.from(seen.headers["x-rogue-actor-name"], "latin1").toString("utf8"), "יובל"); + assert.equal(seen.headers["x-rogue-actor-email"], "yuval@corp.com"); + } finally { + server.close(); + } +}); + test("no git identity → login@hostname, never a blank actor", async () => { const { server, seen, port } = await startServer(200, "{}"); const git = gitTripwire(); diff --git a/tests/test_hook_ps1.ps1 b/tests/test_hook_ps1.ps1 index 4ca5327..49bae3c 100644 --- a/tests/test_hook_ps1.ps1 +++ b/tests/test_hook_ps1.ps1 @@ -153,10 +153,10 @@ $labMail = Select-ActorValue @('jane.doe@corp.com') Assert-Selected @('', (($labMail -split '@')[0])) 'jane.doe' 'real host email still yields its local-part' # hook.ps1's cascade (Resolve-RogueActor) is driven end to end in -# tests/test_git_identity_ps1.ps1; heartbeat.ps1 carries an inline copy whose -# dispatcher body only runs on Windows, so both are pinned structurally here: a -# silent drop of either fallback is exactly the regression this covers. -foreach ($f in @('hook.ps1', 'heartbeat.ps1')) { +# tests/test_git_identity_ps1.ps1, which also drives the seam construct heartbeat.ps1 +# reaches it through. The fallbacks are pinned structurally here too: a silent drop +# of either is exactly the regression this covers. +foreach ($f in @('hook.ps1')) { $src = Get-Content -Raw -LiteralPath ([System.IO.Path]::Combine($here, '..', 'plugins', 'rogue', 'scripts', $f)) $script:count++ if ($src -match [regex]::Escape('Select-ActorValue @($env:USERNAME, [Environment]::UserName)')) { From 3312245958ab9454602329f0c3743c3e9d8e0670 Mon Sep 17 00:00:00 2001 From: Yuval Date: Mon, 14 Sep 2026 15:46:42 +0300 Subject: [PATCH 6/7] fix(plugins): an unreadable git config does not end the identity cascade (FIRE-2117) Read-RogueGitConfig read both files inside Get-RogueGitIdentity's single outer try, so a ReadAllLines failure on the XDG config skipped ~/.gitconfig as well. The awk twin in git-identity.sh already continues per file. Catching per file makes the two agree. README named only ~/.gitconfig; the resolver has always read ${XDG_CONFIG_HOME:-~/.config}/git/config first. Co-Authored-By: Claude Fable 5.1 --- README.md | 9 +++++---- plugins/antigravity/scripts/git-identity.ps1 | 5 ++++- plugins/codex/scripts/git-identity.ps1 | 5 ++++- plugins/copilot/scripts/git-identity.ps1 | 5 ++++- plugins/cursor/scripts/git-identity.ps1 | 5 ++++- plugins/kiro/scripts/git-identity.ps1 | 5 ++++- plugins/rogue/scripts/git-identity.ps1 | 5 ++++- scripts/shared/git-identity.ps1 | 5 ++++- tests/test_git_identity_ps1.ps1 | 14 ++++++++++++++ 9 files changed, 47 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 73978c2..10cadc6 100644 --- a/README.md +++ b/README.md @@ -126,10 +126,11 @@ hooks check that path first, and when it holds `ROGUE_API_KEY` they read no othe 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 `~/.gitconfig` (read as a file; -`git` itself is never run), then `@`. 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. +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 `@`. 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). diff --git a/plugins/antigravity/scripts/git-identity.ps1 b/plugins/antigravity/scripts/git-identity.ps1 index 1213057..27e9f02 100644 --- a/plugins/antigravity/scripts/git-identity.ps1 +++ b/plugins/antigravity/scripts/git-identity.ps1 @@ -34,8 +34,11 @@ function ConvertFrom-RogueGitValue { 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 [System.IO.File]::ReadAllLines($Path)) { + foreach ($raw in $lines) { $line = $raw.Trim() if ($line -eq '' -or $line[0] -eq '#' -or $line[0] -eq ';') { continue } if ($line[0] -eq '[') { diff --git a/plugins/codex/scripts/git-identity.ps1 b/plugins/codex/scripts/git-identity.ps1 index 1213057..27e9f02 100644 --- a/plugins/codex/scripts/git-identity.ps1 +++ b/plugins/codex/scripts/git-identity.ps1 @@ -34,8 +34,11 @@ function ConvertFrom-RogueGitValue { 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 [System.IO.File]::ReadAllLines($Path)) { + foreach ($raw in $lines) { $line = $raw.Trim() if ($line -eq '' -or $line[0] -eq '#' -or $line[0] -eq ';') { continue } if ($line[0] -eq '[') { diff --git a/plugins/copilot/scripts/git-identity.ps1 b/plugins/copilot/scripts/git-identity.ps1 index 1213057..27e9f02 100644 --- a/plugins/copilot/scripts/git-identity.ps1 +++ b/plugins/copilot/scripts/git-identity.ps1 @@ -34,8 +34,11 @@ function ConvertFrom-RogueGitValue { 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 [System.IO.File]::ReadAllLines($Path)) { + foreach ($raw in $lines) { $line = $raw.Trim() if ($line -eq '' -or $line[0] -eq '#' -or $line[0] -eq ';') { continue } if ($line[0] -eq '[') { diff --git a/plugins/cursor/scripts/git-identity.ps1 b/plugins/cursor/scripts/git-identity.ps1 index 1213057..27e9f02 100644 --- a/plugins/cursor/scripts/git-identity.ps1 +++ b/plugins/cursor/scripts/git-identity.ps1 @@ -34,8 +34,11 @@ function ConvertFrom-RogueGitValue { 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 [System.IO.File]::ReadAllLines($Path)) { + foreach ($raw in $lines) { $line = $raw.Trim() if ($line -eq '' -or $line[0] -eq '#' -or $line[0] -eq ';') { continue } if ($line[0] -eq '[') { diff --git a/plugins/kiro/scripts/git-identity.ps1 b/plugins/kiro/scripts/git-identity.ps1 index 1213057..27e9f02 100644 --- a/plugins/kiro/scripts/git-identity.ps1 +++ b/plugins/kiro/scripts/git-identity.ps1 @@ -34,8 +34,11 @@ function ConvertFrom-RogueGitValue { 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 [System.IO.File]::ReadAllLines($Path)) { + foreach ($raw in $lines) { $line = $raw.Trim() if ($line -eq '' -or $line[0] -eq '#' -or $line[0] -eq ';') { continue } if ($line[0] -eq '[') { diff --git a/plugins/rogue/scripts/git-identity.ps1 b/plugins/rogue/scripts/git-identity.ps1 index 1213057..27e9f02 100644 --- a/plugins/rogue/scripts/git-identity.ps1 +++ b/plugins/rogue/scripts/git-identity.ps1 @@ -34,8 +34,11 @@ function ConvertFrom-RogueGitValue { 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 [System.IO.File]::ReadAllLines($Path)) { + foreach ($raw in $lines) { $line = $raw.Trim() if ($line -eq '' -or $line[0] -eq '#' -or $line[0] -eq ';') { continue } if ($line[0] -eq '[') { diff --git a/scripts/shared/git-identity.ps1 b/scripts/shared/git-identity.ps1 index 1213057..27e9f02 100644 --- a/scripts/shared/git-identity.ps1 +++ b/scripts/shared/git-identity.ps1 @@ -34,8 +34,11 @@ function ConvertFrom-RogueGitValue { 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 [System.IO.File]::ReadAllLines($Path)) { + foreach ($raw in $lines) { $line = $raw.Trim() if ($line -eq '' -or $line[0] -eq '#' -or $line[0] -eq ';') { continue } if ($line[0] -eq '[') { diff --git a/tests/test_git_identity_ps1.ps1 b/tests/test_git_identity_ps1.ps1 index d3cd326..3517942 100644 --- a/tests/test_git_identity_ps1.ps1 +++ b/tests/test_git_identity_ps1.ps1 @@ -95,6 +95,20 @@ try { Assert-Eq $id.Email 'home@corp.com' '~/.gitconfig overrides the XDG value' Assert-Eq $id.Name 'Xdg Me' 'a field only XDG carries survives' + # An unreadable file is skipped, not fatal: the cascade must still reach + # ~/.gitconfig. POSIX only - a deny ACL is not the same experiment on Windows. + if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { + $h = New-TestHome + $xdgCfg = [System.IO.Path]::Combine($h, '.config', 'git', 'config') + Write-Cfg $xdgCfg "[user]`n`temail = xdg@corp.com`n" + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`tname = Home Me`n" + & chmod 000 $xdgCfg + $id = Read-GitId + & chmod 600 $xdgCfg + Assert-Eq $id.Name 'Home Me' 'an unreadable XDG config does not stop ~/.gitconfig being read' + Assert-Eq $id.Email '' 'and contributes nothing itself' + } + $h = New-TestHome Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`r`n`temail = jane@corp.com`r`n`tname = Jane Dev`r`n" $id = Read-GitId From 234fb216da6532536d043a937c278ed46f0eec80 Mon Sep 17 00:00:00 2001 From: Yuval Date: Mon, 14 Sep 2026 16:33:18 +0300 Subject: [PATCH 7/7] FIRE-2117 | fix(plugins): decode git's control escapes, trim actor candidates, load hook.ps1 without ExecutionPolicy CodeRabbit round on #53: - git-identity.{ps1,sh} and gemini shared.mjs: `\n`, `\t` and `\b` in a quoted git config value produced the letters n/t/b. They now decode to a space - a real control character cannot travel in an HTTP header value and would split git-identity.sh's two-line scan output, so the faithful decode would be a regression rather than a fix. - actor.{ps1,sh} and resolveActor: a whitespace-only ROGUE_ACTOR_* counted as present, so the bridges shipped a blank identity and skipped the git/login cascade while ship-logs (which trims) sent a different identity for the same install. Each candidate is trimmed before the presence test and before storage. - rogue status skill: hook.ps1 is loaded through `[scriptblock]::Create` with ROGUE_PS_LIB_ONLY restored in `finally`, matching how the dispatchers load env-file.ps1. Dot-sourcing by path is blocked by an enforced ExecutionPolicy. - test_git_identity_ps1: the "no bridge shells out to git" guard matched the literal `& git ` spelling only, so a plain `git config` passed. It now parses each file and rejects git, git.exe and git.cmd in any command position (bareword, call operator, quoted, full path), with planted-call cases proving it fails and clean cases proving a mention in a string or comment does not. It also covers git-identity.ps1 and actor.ps1, not just the dispatchers. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/antigravity/scripts/actor.ps1 | 7 ++- plugins/antigravity/scripts/actor.sh | 13 +++++ plugins/antigravity/scripts/git-identity.ps1 | 10 +++- plugins/antigravity/scripts/git-identity.sh | 11 +++- plugins/codex/scripts/actor.ps1 | 7 ++- plugins/codex/scripts/actor.sh | 13 +++++ plugins/codex/scripts/git-identity.ps1 | 10 +++- plugins/codex/scripts/git-identity.sh | 11 +++- plugins/copilot/scripts/actor.ps1 | 7 ++- plugins/copilot/scripts/actor.sh | 13 +++++ plugins/copilot/scripts/git-identity.ps1 | 10 +++- plugins/copilot/scripts/git-identity.sh | 11 +++- plugins/cursor/scripts/actor.ps1 | 7 ++- plugins/cursor/scripts/actor.sh | 13 +++++ plugins/cursor/scripts/git-identity.ps1 | 10 +++- plugins/cursor/scripts/git-identity.sh | 11 +++- plugins/gemini/scripts/shared.mjs | 16 ++++- plugins/kiro/scripts/actor.ps1 | 7 ++- plugins/kiro/scripts/actor.sh | 13 +++++ plugins/kiro/scripts/git-identity.ps1 | 10 +++- plugins/kiro/scripts/git-identity.sh | 11 +++- plugins/rogue/scripts/git-identity.ps1 | 10 +++- plugins/rogue/scripts/git-identity.sh | 11 +++- plugins/rogue/skills/status/SKILL.md | 6 +- scripts/shared/actor.ps1 | 7 ++- scripts/shared/actor.sh | 13 +++++ scripts/shared/git-identity.ps1 | 10 +++- scripts/shared/git-identity.sh | 11 +++- tests/test_actor_sh.sh | 34 +++++++++++ tests/test_git_identity_ps1.ps1 | 61 +++++++++++++++++++- tests/test_hook_mjs.mjs | 36 ++++++++++++ 31 files changed, 381 insertions(+), 39 deletions(-) diff --git a/plugins/antigravity/scripts/actor.ps1 b/plugins/antigravity/scripts/actor.ps1 index e4d6713..7e8aeee 100644 --- a/plugins/antigravity/scripts/actor.ps1 +++ b/plugins/antigravity/scripts/actor.ps1 @@ -24,8 +24,11 @@ function Read-RogueGitIdentityFile { function Resolve-RogueSharedActor { param([hashtable]$Creds, [string]$PluginRoot) if ($null -eq $Creds) { $Creds = @{} } - $name = [string]$Creds['ROGUE_ACTOR_NAME'] - $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + # 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 } diff --git a/plugins/antigravity/scripts/actor.sh b/plugins/antigravity/scripts/actor.sh index 4600216..4882719 100755 --- a/plugins/antigravity/scripts/actor.sh +++ b/plugins/antigravity/scripts/actor.sh @@ -4,6 +4,19 @@ # → marker "unknown", never blank. # Sourced by hook.sh and heartbeat.sh after PLUGIN_ROOT is set. +# 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 diff --git a/plugins/antigravity/scripts/git-identity.ps1 b/plugins/antigravity/scripts/git-identity.ps1 index 27e9f02..f961abc 100644 --- a/plugins/antigravity/scripts/git-identity.ps1 +++ b/plugins/antigravity/scripts/git-identity.ps1 @@ -23,7 +23,15 @@ function ConvertFrom-RogueGitValue { $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++; [void]$sb.Append($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) } diff --git a/plugins/antigravity/scripts/git-identity.sh b/plugins/antigravity/scripts/git-identity.sh index 7335d99..c2e2259 100644 --- a/plugins/antigravity/scripts/git-identity.sh +++ b/plugins/antigravity/scripts/git-identity.sh @@ -18,11 +18,18 @@ _rogue_gitcfg_scan() { 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) { + 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) - if (c == "\\" && i < n) { i++; out = out 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 diff --git a/plugins/codex/scripts/actor.ps1 b/plugins/codex/scripts/actor.ps1 index e4d6713..7e8aeee 100644 --- a/plugins/codex/scripts/actor.ps1 +++ b/plugins/codex/scripts/actor.ps1 @@ -24,8 +24,11 @@ function Read-RogueGitIdentityFile { function Resolve-RogueSharedActor { param([hashtable]$Creds, [string]$PluginRoot) if ($null -eq $Creds) { $Creds = @{} } - $name = [string]$Creds['ROGUE_ACTOR_NAME'] - $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + # 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 } diff --git a/plugins/codex/scripts/actor.sh b/plugins/codex/scripts/actor.sh index 4600216..4882719 100755 --- a/plugins/codex/scripts/actor.sh +++ b/plugins/codex/scripts/actor.sh @@ -4,6 +4,19 @@ # → marker "unknown", never blank. # Sourced by hook.sh and heartbeat.sh after PLUGIN_ROOT is set. +# 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 diff --git a/plugins/codex/scripts/git-identity.ps1 b/plugins/codex/scripts/git-identity.ps1 index 27e9f02..f961abc 100644 --- a/plugins/codex/scripts/git-identity.ps1 +++ b/plugins/codex/scripts/git-identity.ps1 @@ -23,7 +23,15 @@ function ConvertFrom-RogueGitValue { $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++; [void]$sb.Append($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) } diff --git a/plugins/codex/scripts/git-identity.sh b/plugins/codex/scripts/git-identity.sh index 7335d99..c2e2259 100644 --- a/plugins/codex/scripts/git-identity.sh +++ b/plugins/codex/scripts/git-identity.sh @@ -18,11 +18,18 @@ _rogue_gitcfg_scan() { 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) { + 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) - if (c == "\\" && i < n) { i++; out = out 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 diff --git a/plugins/copilot/scripts/actor.ps1 b/plugins/copilot/scripts/actor.ps1 index e4d6713..7e8aeee 100644 --- a/plugins/copilot/scripts/actor.ps1 +++ b/plugins/copilot/scripts/actor.ps1 @@ -24,8 +24,11 @@ function Read-RogueGitIdentityFile { function Resolve-RogueSharedActor { param([hashtable]$Creds, [string]$PluginRoot) if ($null -eq $Creds) { $Creds = @{} } - $name = [string]$Creds['ROGUE_ACTOR_NAME'] - $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + # 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 } diff --git a/plugins/copilot/scripts/actor.sh b/plugins/copilot/scripts/actor.sh index 4600216..4882719 100755 --- a/plugins/copilot/scripts/actor.sh +++ b/plugins/copilot/scripts/actor.sh @@ -4,6 +4,19 @@ # → marker "unknown", never blank. # Sourced by hook.sh and heartbeat.sh after PLUGIN_ROOT is set. +# 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 diff --git a/plugins/copilot/scripts/git-identity.ps1 b/plugins/copilot/scripts/git-identity.ps1 index 27e9f02..f961abc 100644 --- a/plugins/copilot/scripts/git-identity.ps1 +++ b/plugins/copilot/scripts/git-identity.ps1 @@ -23,7 +23,15 @@ function ConvertFrom-RogueGitValue { $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++; [void]$sb.Append($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) } diff --git a/plugins/copilot/scripts/git-identity.sh b/plugins/copilot/scripts/git-identity.sh index 7335d99..c2e2259 100644 --- a/plugins/copilot/scripts/git-identity.sh +++ b/plugins/copilot/scripts/git-identity.sh @@ -18,11 +18,18 @@ _rogue_gitcfg_scan() { 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) { + 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) - if (c == "\\" && i < n) { i++; out = out 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 diff --git a/plugins/cursor/scripts/actor.ps1 b/plugins/cursor/scripts/actor.ps1 index e4d6713..7e8aeee 100644 --- a/plugins/cursor/scripts/actor.ps1 +++ b/plugins/cursor/scripts/actor.ps1 @@ -24,8 +24,11 @@ function Read-RogueGitIdentityFile { function Resolve-RogueSharedActor { param([hashtable]$Creds, [string]$PluginRoot) if ($null -eq $Creds) { $Creds = @{} } - $name = [string]$Creds['ROGUE_ACTOR_NAME'] - $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + # 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 } diff --git a/plugins/cursor/scripts/actor.sh b/plugins/cursor/scripts/actor.sh index 4600216..4882719 100755 --- a/plugins/cursor/scripts/actor.sh +++ b/plugins/cursor/scripts/actor.sh @@ -4,6 +4,19 @@ # → marker "unknown", never blank. # Sourced by hook.sh and heartbeat.sh after PLUGIN_ROOT is set. +# 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 diff --git a/plugins/cursor/scripts/git-identity.ps1 b/plugins/cursor/scripts/git-identity.ps1 index 27e9f02..f961abc 100644 --- a/plugins/cursor/scripts/git-identity.ps1 +++ b/plugins/cursor/scripts/git-identity.ps1 @@ -23,7 +23,15 @@ function ConvertFrom-RogueGitValue { $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++; [void]$sb.Append($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) } diff --git a/plugins/cursor/scripts/git-identity.sh b/plugins/cursor/scripts/git-identity.sh index 7335d99..c2e2259 100644 --- a/plugins/cursor/scripts/git-identity.sh +++ b/plugins/cursor/scripts/git-identity.sh @@ -18,11 +18,18 @@ _rogue_gitcfg_scan() { 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) { + 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) - if (c == "\\" && i < n) { i++; out = out 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 diff --git a/plugins/gemini/scripts/shared.mjs b/plugins/gemini/scripts/shared.mjs index d01bdba..bdc4966 100644 --- a/plugins/gemini/scripts/shared.mjs +++ b/plugins/gemini/scripts/shared.mjs @@ -155,7 +155,14 @@ function gitConfigValue(raw) { let quoted = false; for (let i = 0; i < s.length; i++) { const c = s[i]; - if (c === "\\" && i + 1 < s.length) out += s[++i]; + // git decodes \n, \t and \b as control characters. A control character cannot + // travel in an HTTP header value and would split git-identity.sh's two-line scan + // output, so all three land as a space; every other escape is the literal + // character, as git reads it. + if (c === "\\" && i + 1 < s.length) { + const e = s[++i]; + out += e === "n" || e === "t" || e === "b" ? " " : e; + } else if (c === '"') quoted = !quoted; else if (!quoted && (c === "#" || c === ";")) break; else out += c; @@ -207,8 +214,11 @@ export function gitIdentity() { // row and the roster row can never carry different identities: // env file → git config files → @ / . export function resolveActor(env) { - let email = env.ROGUE_ACTOR_EMAIL || ""; - let name = env.ROGUE_ACTOR_NAME || ""; + // 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 the shippers (which trim) send for the same install. + let email = (env.ROGUE_ACTOR_EMAIL || "").trim(); + let name = (env.ROGUE_ACTOR_NAME || "").trim(); if (!email || !name) { const git = gitIdentity(); email = email || git.email; diff --git a/plugins/kiro/scripts/actor.ps1 b/plugins/kiro/scripts/actor.ps1 index e4d6713..7e8aeee 100644 --- a/plugins/kiro/scripts/actor.ps1 +++ b/plugins/kiro/scripts/actor.ps1 @@ -24,8 +24,11 @@ function Read-RogueGitIdentityFile { function Resolve-RogueSharedActor { param([hashtable]$Creds, [string]$PluginRoot) if ($null -eq $Creds) { $Creds = @{} } - $name = [string]$Creds['ROGUE_ACTOR_NAME'] - $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + # 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 } diff --git a/plugins/kiro/scripts/actor.sh b/plugins/kiro/scripts/actor.sh index 4600216..4882719 100755 --- a/plugins/kiro/scripts/actor.sh +++ b/plugins/kiro/scripts/actor.sh @@ -4,6 +4,19 @@ # → marker "unknown", never blank. # Sourced by hook.sh and heartbeat.sh after PLUGIN_ROOT is set. +# 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 diff --git a/plugins/kiro/scripts/git-identity.ps1 b/plugins/kiro/scripts/git-identity.ps1 index 27e9f02..f961abc 100644 --- a/plugins/kiro/scripts/git-identity.ps1 +++ b/plugins/kiro/scripts/git-identity.ps1 @@ -23,7 +23,15 @@ function ConvertFrom-RogueGitValue { $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++; [void]$sb.Append($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) } diff --git a/plugins/kiro/scripts/git-identity.sh b/plugins/kiro/scripts/git-identity.sh index 7335d99..c2e2259 100644 --- a/plugins/kiro/scripts/git-identity.sh +++ b/plugins/kiro/scripts/git-identity.sh @@ -18,11 +18,18 @@ _rogue_gitcfg_scan() { 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) { + 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) - if (c == "\\" && i < n) { i++; out = out 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 diff --git a/plugins/rogue/scripts/git-identity.ps1 b/plugins/rogue/scripts/git-identity.ps1 index 27e9f02..f961abc 100644 --- a/plugins/rogue/scripts/git-identity.ps1 +++ b/plugins/rogue/scripts/git-identity.ps1 @@ -23,7 +23,15 @@ function ConvertFrom-RogueGitValue { $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++; [void]$sb.Append($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) } diff --git a/plugins/rogue/scripts/git-identity.sh b/plugins/rogue/scripts/git-identity.sh index 7335d99..c2e2259 100644 --- a/plugins/rogue/scripts/git-identity.sh +++ b/plugins/rogue/scripts/git-identity.sh @@ -18,11 +18,18 @@ _rogue_gitcfg_scan() { 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) { + 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) - if (c == "\\" && i < n) { i++; out = out 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 diff --git a/plugins/rogue/skills/status/SKILL.md b/plugins/rogue/skills/status/SKILL.md index 1b754e4..0f5f27e 100644 --- a/plugins/rogue/skills/status/SKILL.md +++ b/plugins/rogue/skills/status/SKILL.md @@ -443,7 +443,11 @@ $hookPs1 = Get-ChildItem "$env:USERPROFILE\.claude\plugins" -Recurse -Filter hoo Where-Object { $_.FullName -like '*rogue*' } | Select-Object -First 1 $actorEmail = [string]$creds['ROGUE_ACTOR_EMAIL']; $actorName = [string]$creds['ROGUE_ACTOR_NAME'] if ($hookPs1) { - $env:ROGUE_PS_LIB_ONLY = '1'; . $hookPs1.FullName; $env:ROGUE_PS_LIB_ONLY = $null + # Loaded as a scriptblock, not dot-sourced by path: running a .ps1 by path is + # subject to ExecutionPolicy, which is enforced on a managed machine. + $env:ROGUE_PS_LIB_ONLY = '1' + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $hookPs1.FullName))) } + finally { $env:ROGUE_PS_LIB_ONLY = $null } # The very cascade hook.ps1 runs (env file -> CLAUDE_CODE_USER_EMAIL -> git config # files -> login@host), so this can never report a different actor than the hooks. $a = Resolve-RogueActor $creds (Split-Path (Split-Path $hookPs1.FullName -Parent) -Parent) diff --git a/scripts/shared/actor.ps1 b/scripts/shared/actor.ps1 index e4d6713..7e8aeee 100644 --- a/scripts/shared/actor.ps1 +++ b/scripts/shared/actor.ps1 @@ -24,8 +24,11 @@ function Read-RogueGitIdentityFile { function Resolve-RogueSharedActor { param([hashtable]$Creds, [string]$PluginRoot) if ($null -eq $Creds) { $Creds = @{} } - $name = [string]$Creds['ROGUE_ACTOR_NAME'] - $email = [string]$Creds['ROGUE_ACTOR_EMAIL'] + # 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 } diff --git a/scripts/shared/actor.sh b/scripts/shared/actor.sh index 4600216..4882719 100755 --- a/scripts/shared/actor.sh +++ b/scripts/shared/actor.sh @@ -4,6 +4,19 @@ # → marker "unknown", never blank. # Sourced by hook.sh and heartbeat.sh after PLUGIN_ROOT is set. +# 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 diff --git a/scripts/shared/git-identity.ps1 b/scripts/shared/git-identity.ps1 index 27e9f02..f961abc 100644 --- a/scripts/shared/git-identity.ps1 +++ b/scripts/shared/git-identity.ps1 @@ -23,7 +23,15 @@ function ConvertFrom-RogueGitValue { $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++; [void]$sb.Append($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) } diff --git a/scripts/shared/git-identity.sh b/scripts/shared/git-identity.sh index 7335d99..c2e2259 100644 --- a/scripts/shared/git-identity.sh +++ b/scripts/shared/git-identity.sh @@ -18,11 +18,18 @@ _rogue_gitcfg_scan() { 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) { + 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) - if (c == "\\" && i < n) { i++; out = out 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 diff --git a/tests/test_actor_sh.sh b/tests/test_actor_sh.sh index 84321e0..87c64df 100755 --- a/tests/test_actor_sh.sh +++ b/tests/test_actor_sh.sh @@ -299,6 +299,40 @@ actual="$(resolve)" [ "$actual" = 'bom@corp.com|Bom "B" Me' ] || { echo "FAIL [shared bom]: got <$actual>" >&2; exit 1; } echo " ok: BOM and escaped quotes read by the shared cascade" +# ── git's control escapes: \n, \t and \b decode to a space, never to n/t/b ──── +# A real control character cannot travel in a header value and would split the +# two-line output of git-identity.sh's scan, so all three collapse to a space. +scenario +write_gitconfig +printf '[user]\n\temail = "a\\nb@corp.com"\n\tname = "Jane\\nQ\\tDev\\bX"\n' > "$FAKE_HOME/.gitconfig" +actual="$(resolve)" +[ "$actual" = 'a b@corp.com|Jane Q Dev X' ] || { echo "FAIL [shared escapes]: got <$actual>" >&2; exit 1; } +echo " ok: quoted \\n, \\t and \\b decode to a space, not to the letters n/t/b" + +scenario +write_gitconfig +printf '[user]\n\tname = "C:\\\\dev\\\\me"\n' > "$FAKE_HOME/.gitconfig" +actual="$(resolve)" +[ "$actual" = 'jane@devbox|C:\dev\me' ] || { echo "FAIL [shared backslash]: got <$actual>" >&2; exit 1; } +echo " ok: an escaped backslash stays one backslash" + +# ── whitespace-only ROGUE_ACTOR_* is absent, not present ────────────────────── +# Untrimmed it would ship as a blank identity AND skip the git/login cascade, +# while ship-logs.sh (which trims) sends a different identity for the same install. +scenario +SEED_EMAIL=" "; SEED_NAME="$(printf '\t \n')" +GIT_EMAIL="jane@corp.com"; GIT_NAME="Jane Dev" +assert_actor "jane@corp.com|Jane Dev" "a whitespace-only ROGUE_ACTOR_* falls through to the git identity" + +scenario +SEED_EMAIL=" "; SEED_NAME=" " +assert_actor "jane@devbox|jane" "and on to login@hostname when there is no git identity either" + +scenario +SEED_EMAIL=" mdm@corp.com "; SEED_NAME=" MDM Provisioned " +GIT_EMAIL="jane@corp.com"; GIT_NAME="Jane Dev" +assert_actor "mdm@corp.com|MDM Provisioned" "a padded ROGUE_ACTOR_* is stored trimmed, as the shipper sends it" + # ── The git binary was never run, in any case above ────────────────────────── if [ -s "$TRIPWIRE" ]; then echo "FAIL [git tripwire]: the cascade invoked git:" >&2; cat "$TRIPWIRE" >&2; exit 1 diff --git a/tests/test_git_identity_ps1.ps1 b/tests/test_git_identity_ps1.ps1 index 3517942..91dc7d0 100644 --- a/tests/test_git_identity_ps1.ps1 +++ b/tests/test_git_identity_ps1.ps1 @@ -51,6 +51,22 @@ function Write-Cfg { } function Read-GitId { return (& ([scriptblock]::Create((Get-Content -Raw -LiteralPath $lib)))) } +# True when the script invokes git in ANY command position: bare `git`, `& git`, +# `git.exe`, `git.cmd`, a quoted name or a full path. The old check matched the +# literal `& git ` spelling only, so `git config` slipped straight past it. Parsed +# rather than grepped, so the word inside a string or a comment is not a hit. +function Test-RogueCallsGit { + param([string]$Path) + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$null) + foreach ($cmd in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)) { + $name = $cmd.GetCommandName() + if (-not $name) { continue } + $leaf = ($name -split '[\\/]')[-1] + if (@('git', 'git.exe', 'git.cmd') -contains $leaf.ToLowerInvariant()) { return $true } + } + return $false +} + # Tripwire: `git` on PATH records every invocation. Both a POSIX script and a # .cmd, so it fires on the Linux runner and under Windows PowerShell 5.1 alike. $trip = New-TestHome @@ -127,6 +143,18 @@ try { Assert-Eq $id.Email 'jane@corp.com' 'an unquoted trailing comment is dropped' Assert-Eq $id.Name 'Jane "JJ" Dev' 'backslash-escaped quotes survive, as git reads them' + # git decodes \n, \t and \b as control characters; the readers cannot carry one + # in a header value, so all three arrive as a space (see git-identity.ps1). + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`tname = `"Jane\nQ\tDev\bX`"`n`temail = `"a\nb@corp.com`"`n" + $id = Read-GitId + Assert-Eq $id.Name 'Jane Q Dev X' 'quoted \n, \t and \b decode to a space, not to the letters n/t/b' + Assert-Eq $id.Email 'a b@corp.com' 'the same decoding applies to the email' + + $h = New-TestHome + Write-Cfg ([System.IO.Path]::Combine($h, '.gitconfig')) "[user]`n`tname = `"C:\\dev\\me`"`n" + Assert-Eq (Read-GitId).Name 'C:\dev\me' 'an escaped backslash stays one backslash' + $h = New-TestHome $id = Read-GitId Assert-Eq $id.Email '' 'no config file: empty email, no error' @@ -227,6 +255,17 @@ try { Assert-Eq $a.Email 'mdm@corp.com' 'shared: fields resolve independently (email from env)' Assert-Eq $a.Name 'Jane Dev' 'shared: fields resolve independently (name from git)' + # Untrimmed, a whitespace-only value ships as a blank identity AND skips the + # cascade, while ship-logs.ps1 (which trims) sends a different one for the + # same install. + $a = Resolve-RogueSharedActor @{ ROGUE_ACTOR_EMAIL = ' '; ROGUE_ACTOR_NAME = "`t `n" } $codexRoot + Assert-Eq $a.Email 'jane@corp.com' 'shared: a whitespace-only env email falls through to the git identity' + Assert-Eq $a.Name 'Jane Dev' 'shared: a whitespace-only env name falls through to the git identity' + + $a = Resolve-RogueSharedActor @{ ROGUE_ACTOR_EMAIL = ' mdm@corp.com '; ROGUE_ACTOR_NAME = ' MDM Provisioned ' } $codexRoot + Assert-Eq $a.Email 'mdm@corp.com' 'shared: a padded env email is stored trimmed, as the shipper sends it' + Assert-Eq $a.Name 'MDM Provisioned' 'shared: a padded env name is stored trimmed' + $h = New-TestHome $a = Resolve-RogueSharedActor @{} $codexRoot Assert-Eq $a.Email $sharedLoginAtHost 'shared level 3: @ when there is no git identity' @@ -245,13 +284,31 @@ try { } } + Write-Host '-- the guard itself catches every git spelling --' + $probe = [System.IO.Path]::Combine($trip, 'probe.ps1') + foreach ($planted in 'git config user.name', + '& git config user.name', + 'git.exe config user.name', + 'git.cmd config user.name', + '& "git" config user.name', + '& "C:\Program Files\Git\cmd\git.exe" config user.name') { + Write-Cfg $probe $planted + Assert-Eq (Test-RogueCallsGit $probe) $true "a planted <$planted> fails the guard" + } + foreach ($clean in '$note = "run git config user.name by hand"', + '# git config user.name', + '$gitIdentity = Read-RogueGitIdentity $root') { + Write-Cfg $probe $clean + Assert-Eq (Test-RogueCallsGit $probe) $false "the guard ignores <$clean>" + } + Write-Host '-- no bridge shells out to git --' Assert-Eq (Test-Path -LiteralPath $tripMarker) $false 'git binary never invoked' foreach ($p in 'rogue','codex','copilot','antigravity','kiro','cursor') { - foreach ($f in 'hook.ps1','heartbeat.ps1') { + foreach ($f in 'hook.ps1','heartbeat.ps1','git-identity.ps1','actor.ps1') { $src = [System.IO.Path]::Combine($repo, 'plugins', $p, 'scripts', $f) if (-not (Test-Path -LiteralPath $src)) { continue } - Assert-Eq ((Get-Content -Raw -LiteralPath $src) -match '&\s*git\s') $false "$p/$f does not call git" + Assert-Eq (Test-RogueCallsGit $src) $false "$p/$f does not call git" } } } finally { diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index 83c2af8..484ba84 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -273,6 +273,42 @@ test("BOM-prefixed config and git's escaped quotes read as git does (one rule wi } }); +test("git's control escapes decode to a space, not to the letters n/t/b", async () => { + // A real newline or backspace cannot travel in a header value, so all three of + // git's control escapes collapse to a space - one rule with sh/ps1. + const { server, seen, port } = await startServer(200, "{}"); + seedHome.base = `http://127.0.0.1:${port}`; + try { + await runHook("BeforeTool", "{}", {}, (home) => + seedHome(home, { gitconfig: '[user]\n\temail = "a\\nb@corp.com"\n\tname = "Jane\\nQ\\tDev\\bX"\n' }), + ); + assert.equal(seen.headers["x-rogue-actor-email"], "a b@corp.com"); + assert.equal(seen.headers["x-rogue-actor-name"], "Jane Q Dev X"); + } finally { + server.close(); + } +}); + +test("a whitespace-only ROGUE_ACTOR_* is absent, so the git identity is used", async () => { + // Untrimmed it would ship a blank identity AND skip the cascade, while the log + // shipper (which trims) sends a different identity for the same install. + const { server, seen, port } = await startServer(200, "{}"); + seedHome.base = `http://127.0.0.1:${port}`; + try { + await runHook("BeforeTool", "{}", {}, (home) => { + seedHome(home, { gitconfig: GITCONFIG }); + fs.appendFileSync( + path.join(home, ".rogue-env"), + "export ROGUE_ACTOR_EMAIL=' '\nexport ROGUE_ACTOR_NAME=' '\n", + ); + }); + assert.equal(seen.headers["x-rogue-actor-email"], "jane@corp.com"); + assert.equal(seen.headers["x-rogue-actor-name"], "Jane Dev"); + } finally { + server.close(); + } +}); + test("a non-Latin-1 git user.name still reaches the server, as the UTF-8 bytes curl would send", async () => { // fetch() throws on any header code unit above 0xFF; before headerBytes that // TypeError landed in the fail-open catch and every hook of such a user emitted {}.