# Rmagent Windows

> Pull-based remote-witness habit for a Windows estate — the "RMAgent" knock. Ask two workgroup Windows servers (WS1/WS2) eight allowlisted named questions (attest, sketch, edges, explain, netedges, pslogs, kernring, attackmap) over pywinrm/WinRM :5985, tracking the Administrator and SYSTEM accounts. Use for identity-led compromise, lateral movement, living-off-the-land, silent hosts, and honest root-cause on Windows boxes you administer — without building a log lake and without switching off the EDR. Phase 0 is watch only; there is no actuate. Does NOT replace CrowdStrike/Defender. Prefer this skill for the two-box estate and Administrator/SYSTEM tracking; use the parent `security-observatory` skill for multi-plane (identity, cloud, network) hunts.

- Skill: `drolu/rmagent-windows` (Agent Skill, multi-file: 73 files)
- Install (CLI): `npx skillmds@latest add drolu/rmagent-windows`
- Raw SKILL.md: https://api.skillmd.com/api/skills/drolu/rmagent-windows/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: DrOlu (https://skillmd.com/u/drolu)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/drolu/rmagent-windows

---


# RMAgent for Windows

You operate a **pull-based witness habit** on a two-box Windows estate (Windows Server 1 `44.197.31.152`, Windows Server 2 `52.3.242.251`), tracking the **Administrator** and **SYSTEM** accounts. You do not build a log lake. You do not retire Defender or CrowdStrike. You ask each box a small named question over WinRM, write a one-page case, and write a **hole** when a box is silent or a hop is stripped.

RMAgent is the **knock from the jump host**. On the Windows side it is just WinRM with an allowlist; on the jump host it is `scripts/lib.py` (`ask()`) plus four tiny `.ps1` payloads. There is no agent installed on the targets. There is no `actuate`.

Canonical architecture: `Hyperspace_Security_Observatory.pdf` (HT-ARCH-SEC-2026-01). This skill is the estate-specific, Administrator/SYSTEM-scoped child of the `security-observatory` skill.

## Non-negotiables

- **Keep the EDR.** Defender / CrowdStrike stays on. Commodity malware is out of this watch. Say so if anyone asks to switch it off.
- **Named questions only.** `attest`, `sketch`, `edges`, `explain`. `ask()` refuses anything else, and refuses `actuate` outright. Watch is not actuate. Isolate / disable / revoke is later, dual-controlled, after Phase 0 is proven. Never arbitrary remote script.
- **Track Administrator and SYSTEM.** Every payload filters to the `track:` principals in the inventory (default `[Administrator, SYSTEM]`). You follow the person/account, not the IP. NAT lies; a hashed LogonId does not.
- **Credentials never live in the skill, the inventory, or the case.** Read from env (`RMAgent_<ID>_USER` / `RMAgent_<ID>_PASS`) or `~/.rmagent/creds.json` (mode 600). Never print them. Never paste them onto a case.
- **Do not become a lake.** Every answer is capped at 32 KB. Oversized pulls become holes. Do not copy `Security.evtx`, `Get-WinEvent` dumps, or tenant exports home. The case is a one-page blackboard.
- **A hole is an answer.** Silent box, stripped ticket, missing field, liar — `{asked, empty, why}`, same shape as a hop. Do not tight-retry a silent host (lockout). Two missed attests = Critical.
- **Cap the walk.** depth ≤ 8, fan-out ≤ 3, 2 concurrent hunts, 15 min explain, 50 edges, 5 min cooldown on the same identity. Census may knock 3 boxes at once (all-windows budget); Hunter is serial.
- **Authorised estate only.** Only WS1 and WS2 (or boxes the user is authorised to administer). A partner box, SaaS you do not tenant, an unmanaged phone, NIBSS — those are holes, not witnesses.

## Setup (one time)

### 1. Jump host (macOS, Linux, or Windows)

The jump host is the trusted desk that knocks. It runs on macOS, Linux, or Windows. pywinrm connects over WinRM from any of them; PowerShell 7 (`pwsh`) is only needed if you use the optional `winrm_pool.ps1` RunspacePool path.

```bash
python3 --version          # 3.11+
pip3 install pywinrm pyyaml
export SKILL_DIR=~/.agents/skills/rmagent-windows
ls "$SKILL_DIR/scripts"/{census,hunt,case,lib}.py
ls "$SKILL_DIR/scripts/questions/windows/"   # attest sketch edges explain netedges
```

> The `secrets` scrt store unlocks with a master password resolved cross-platform: **`SCRT_PASS` env var** (everywhere) → macOS Keychain (macOS only) → **`~/.scrt_pass`** file (first line, restricted — the Linux/Windows fallback). On a Linux/Windows jump host, either `export SCRT_PASS=...` or create `~/.scrt_pass`.

### 2. Open the door on each Windows witness

**NTLM (default, zero-config on AWS):** WinRM is already listening on 5985 on
EC2 Windows instances. Just confirm the firewall allows the jump host:

```powershell
Enable-PSRemoting -Force
Set-Service WinRM -StartupType Automatic; Start-Service WinRM
New-NetFirewallRule -Name "WinRM-5985-JumpHost" -DisplayName "WinRM HTTP from jump host" `
  -Enabled True -Direction Inbound -Protocol TCP -LocalPort 5985 -Action Allow
```

**Basic (alternative — only if you set `transport: basic` in the inventory):**
also enable Basic auth and allow unencrypted (workgroup, HTTP 5985):

```powershell
Set-Item WSMan:\localhost\Service\Auth\Basic -Value $true
Set-Item WSMan:\localhost\Service\AllowUnencrypted -Value $true
```

> The live validation on WS1/WS2 used NTLM and worked with no server-side
> changes. For production, prefer HTTPS 5986 or Kerberos in a real domain.
> The habit — allowlisted named questions — is unchanged either way.

### 3. Credentials (env, recommended)

```bash
export RMAgent_WS1_USER=Administrator
export RMAgent_WS1_PASS='...'      # from your vault / the secrets skill — never commit
export RMAgent_WS2_USER=Administrator
export RMAgent_WS2_PASS='...'
```

Or store in `~/.rmagent/creds.json` (mode 600):

```json
{ "ws1": { "user": "Administrator", "password": "..." },
  "ws2": { "user": "Administrator", "password": "..." } }
```

### 4. Inventory

```bash
cp "$SKILL_DIR/assets/inventory.example.yaml" ./estate.yaml
# edit only if you add a box you administer. Never add passwords here.
```

## The four questions (what each returns, Administrator/SYSTEM-scoped)

| Question | Payload | What you get | What you must NOT get |
|---|---|---|---|
| **Alive?** (attest) | `attest.ps1` | host, utc, last boot, admin failed logons 60s, admin ok logons 5min, local admin count, SYSTEM remote conns | a full Security log dump |
| **Anything odd?** (sketch) | `sketch.ps1` | admin failed in window, new local admins 24h, running privileged services, new services/tasks | raw event lists |
| **Who did they touch?** (edges) | `edges.ps1` | recent Administrator/SYSTEM logons (time, type, src IP, LogonId, auth package) + **explicit-credential uses (4648: who→became→dest)** + **special-privilege grants (4672: privilege set)** + outbound conns owned by them, capped | the whole connection table |
| **What changed?** (explain) | `explain.ps1` | group/service/task/account changes + **4648/4672** (explicit creds, special privs) + **WMI event subscriptions (5861 — fileless persistence, ATT&CK T1546.003)** + **audit-log-cleared (1102 — anti-forensics)** + **LOLBin spawns with command line (4688)** + process spawns by Administrator/SYSTEM in the window, capped | the whole tenant/ring export |
| **What connected?** (netedges) | `netedges.ps1` | SYSTEM/Administrator-owned outbound connections from the **Sysmon EID3 ring** (a persisted log, not a point-in-time snapshot) — catches transient connections after they close. Requires Sysmon with `<NetworkConnect onmatch="exclude">` | the full netflow / packet capture |
| **What code ran?** (pslogs) | `pslogs.ps1` | **PowerShell script blocks (4104) — the actual code being executed**, decompiled. An `-enc` payload appears here as readable text. Requires script-block logging (was already ON on both boxes). NOTE: 4104's UserId field is often empty; blocks are returned unfiltered (capped) because every block is worth reading | the whole PowerShell operational log |
| **What if Sysmon is gone?** (kernring) | `kernring.ps1` | Process + network events from the **built-in kernel analytic channels** (`Kernel-Process/Analytic`, `Kernel-Network/Analytic`) — the no-Sysmon fallback. Also reports `sysmon_status` (the tripwire). Requires setup step D3. **Degraded mode**: no process names on net events, no command lines, short ring (minutes not days) | the whole ETW stream |
| **What persistence already exists?** (attackmap) | `attackmap.ps1` | **ATT&CK-mapped persistence STATE check** — reads the registry locations BLUESPAWN enumerated (Run keys, IFEO debuggers, AppCert/AppInit DLLs, SSPs, Winlogon hijack, Netsh helpers, port monitors, logon scripts). Every finding carries its technique ID (T1547.001, T1546.010, etc.). Catches persistence that predates our monitoring window | the whole registry |

> `edges` reads *currently-Established* connections — a point-in-time snapshot that misses sub-second connections. `netedges` reads the **Sysmon ring**, which persists them. Use `netedges` when you need to catch transient SYSTEM/Administrator outbound connections (e.g. a short C2 beacon). Both stay pull-only, both capped — no lake.

Engine injects `$Track`, `$SinceHours`, `$Limit` as a preamble; payloads read them. No payload is a god-shell.

## Operating loop

```
every 1 min ± jitter →  census (attest only; alive + Admin/SYSTEM smoke digest)
every 5–15 min       →  scout  (sketch: new admins, priv services, new tasks/services)
on smell/silence     →  hunter (edges → explain on hosts with smoke), serial, capped
on High case         →  human (Judge) pins, closes, or escalates. No isolate from Phase 0.
never                →  copy Security.evtx, dump rings, tight-retry silent hosts, actuate
```

Agents are not a SOC. After hours, a human is still on call for Hunter and any explain that touches a person.

## Scripts

Resolve `$SKILL_DIR` as the folder containing this `SKILL.md`.

| Job | Script | Notes |
|---|---|---|
| Minute watch | `scripts/census.py` | pywinrm, max-3 knock budget, 2 misses = Critical |
| Administrator/SYSTEM walk | `scripts/hunt.py` | serial, depth-capped; writes path.json + holes.jsonl |
| Write / list / close a case | `scripts/case.py` | one-page blackboard |
| Allowlisted payloads | `scripts/questions/windows/*.ps1` | attest / sketch / edges / explain |
| Optional all-pwsh path | `scripts/winrm_pool.ps1` | Invoke-Command + RunspacePool (max 3), creds from env |

```bash
# Census — are WS1/WS2 alive?
python3 "$SKILL_DIR/scripts/census.py" --inventory ./estate.yaml

# Open a case, then walk Administrator/SYSTEM across both boxes
CASE=$(python3 "$SKILL_DIR/scripts/case.py" open --title "admin walk" --principal Administrator)
python3 "$SKILL_DIR/scripts/hunt.py" --inventory ./estate.yaml --since 2h --case-dir "$CASE"

# All-pwsh alternate (only if every door is winrm)
pwsh -NoProfile -File "$SKILL_DIR/scripts/winrm_pool.ps1" -Skill attest `
  -ComputerName '44.197.31.152','52.3.242.251' -Ids ws1,ws2 -MaxRunspaces 3
```

## Identifier, pin, hole

| Object | Contract |
|---|---|
| Hop id | LogonId (hex) or a work/request id for app hops. Same id across hosts = same session. |
| Ticket / session | Never store Kerberos/NTLM. The LogonId from a 4624 event is the join, not a secret. |
| If stripped | Idempotency key, then producer child, then **hole**. Never join on public IP. |
| Pin | Auto on 2 missed attests, Hunter walk, human incident. Days (default 14). |
| Hole | `{asked, empty, why}` — same shape as a hop. |

## What success looks like (Phase 0)

You can read the case aloud in two minutes: hops for `ws1` and `ws2`, any holes written, `Security.evtx` still on the boxes, EDR still drawing. **Fail:** you exported `Security.evtx`, isolated a host, invented a hop, or called a timeout "nothing happened."

## Examples

- `examples/lab-a-live.md` — Lab A: attest both, silence one, write the hole.
- `examples/walk-administrator.md` — a full Administrator/SYSTEM hunt across WS1+WS2.

## What this skill will NOT do

- No `actuate`. `ask()` returns a hole for it. Isolate/disable/revoke stays with the EDR/IAM and a human.
- No log lake. No `Security.evtx` copy. No tenant/ring dump.
- No inventing a witness for a box you do not administer (NIBSS, a partner, a phone).
- No tight retry on a silent host.
- No replacement for the EDR. Commodity malware stays with Defender/CrowdStrike.

## Hardening changelog (2026-08-19)

Live-verified bug fixes from a purple-team drill on WS1/WS2:

- **`creds_for()` now falls back to the scrt store** (env → `~/.rmagent/creds.json` → scrt). Previously `census.py`/`hunt.py` failed with "no credential" unless env vars were manually exported.
- **Census miss-state is now stable** at `~/.rmagent/.census_miss.json` (was CWD/case-dir — "2 misses = Critical" could never trigger across runs).
- **`attest`/`sketch` match only `TargetUserName`** on 4624/4625 — matching any event field counted SYSTEM-subject events as admin failures (false positives).
- **`sketch.new_local_admins` only reports members still in the group** — deleted users' 4732 events linger 24h as stale SIDs.
- **`netedges` is advertised in the example inventory** — fresh installs previously scored max 5/6 on drills.
- Payloads compacted under the WinRM UTF-16LE base64 command-line budget (~8191 chars).

## Lateral-movement & persistence additions (2026-08-19, rev 2)

New event coverage in `edges` and `explain`, live-verified on WS1:

- **`edges.explicit_creds` (4648)** — *the* lateral-movement signal: `runas`, `Invoke-Command -Credential`, any explicit-credential logon. Each record shows `who → became → dest` in one line. Live test found 2 uses on WS1 in a 2h window (`EC2AMAZ-8NK9FUP$ → Administrator @ localhost`).
- **`edges.special_privs` (4672)** — the privilege set granted at each admin logon. `SeDebugPrivilege` = process injection; `SeTcbPrivilege` = act-as-OS. Anomalous grants are now visible.
- **`edges` logons now carry the auth package** (`NTLM` vs `Kerberos`) — NTLM on a Kerberos-capable box is itself a signal.
- **`explain.wmi_subscriptions` (5861)** — WMI event subscriptions, the classic fileless persistence (ATT&CK T1546.003), read from `Microsoft-Windows-WMI-Activity/Operational`. Zero is the healthy steady state; any non-zero is an immediate finding.
- **`explain.identity_changes` now include 4648/4672**, and `hunt.py` fires a Telegram smoke alert naming them.
- `hunt.py` output and the case `path.json` record `explicit_creds`, `special_privs`, `wmi_subscriptions` as first-class hop fields.

**Audit prerequisite:** 4648/4672 need "Audit Logon" (success) — same subcategory as the 4625 failure auditing. 5861 needs no audit policy; the WMI-Activity operational log writes it when a subscription is created.

## Rev 3 — 4688 upgrade, anti-forensics, DNS, and PowerShell code (2026-08-19)

Driven by a live probe of what sources actually exist on WS1/WS2:

- **4688 split into two collections.** `proc_spawns` (all, capped) stays for volume; new **`lolbin_spawns`** filters to LOLBins — `powershell`, `cmd`, `wscript`, `cscript`, `mshta`, `rundll32`, `regsvr32`, `certutil`, `bitsadmin`, `msiexec`, `schtasks`, `wmic`, `psexec`, `curl`, `tar` — **and carries `CommandLine`** when command-line auditing is on. "PowerShell ran" becomes "PowerShell ran `-enc SQBFAFgA...`". Probe showed 3,464 raw 4688s/day on WS1 — the LOLBin cut is what makes it signal instead of noise.
- **`audit_cleared` (1102)** — the audit log was cleared. The classic anti-forensics move; on a healthy box this is *always* zero, so any hit is an immediate Critical-grade finding. Telegram alert names it explicitly.
- **`task_events` now includes 4699** (task *deleted*) alongside 4698/4702 — an attacker deleting the task they used is as interesting as creating it.
- **`netedges.dns_queries` (Sysmon 22)** — DNS queries by tracked principals: the domain a beacon resolves *before* the connection. Pair a DNS query with the matching netedges connection and you have the full C2 story. Requires `<DnsQuery onmatch="exclude">` in the Sysmon config (WS2 had 10 in 24h at probe time; WS1 had none — the config differs per box).
- **New question `pslogs` (4104)** — PowerShell script-block logging: **the actual code being executed**, decompiled from the script block. This is the highest-fidelity signal in the skill. Probe found it already enabled on both boxes (348/342 events in 24h). Live-tested: returns the real script text. Caveat discovered live: 4104's `UserId`/`Path` Data fields are often *empty* (the user lives in the event's Security descriptor), so blocks are returned unfiltered and capped — every block is worth reading, unlike a process count. Each block's text is truncated to 500 chars to respect the 32 KB answer cap.
- `hunt.py` prints and records all of it; Telegram smoke alerts name the new signals (audit-cleared gets an explicit "anti-forensics!" flag).

**Prerequisites for the new signals:** 4688 command lines need `auditpol /set /subcategory:"Process Creation" /success:enable` **plus** the registry key `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit\ProcessCreationIncludeCmdLine_Enabled = 1`. 4104 needs script-block logging (Group Policy: Administrative Templates → Windows Components → Windows PowerShell → PowerShell Script Block Logging; or the registry equivalent under `HKLM\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging`). Sysmon 22 needs the DnsQuery rule in the config.

## Rev 4 — kernring: the no-Sysmon fallback (2026-08-20)

**Why:** sophisticated attackers target Sysmon specifically — `Stop-Service Sysmon64; sc.exe delete Sysmon64` — and if Sysmon is your only process/network ring, that one command blinds you. The kernel analytic channels are built into Windows, have no obvious service to stop, and give you a degraded but functional ring when Sysmon is gone.

**What was added:**

- **New seventh question `kernring`** — reads `Microsoft-Windows-Kernel-Process/Analytic` (process start/stop with PID, image, parent PID, and command line where available) and `Microsoft-Windows-Kernel-Network/Analytic` (TCP/UDP connect with PID, dest IP, dest port). Also reports `sysmon_status` in every response.
- **`sysmon_status` tripwire in `attest`** — every census now reports whether Sysmon is Running, Stopped, or not-installed. A change from Running to anything else is a finding: either the sensor died or someone stripped it. This is the signal that tells you when to fall back to kernring.
- **`hunt.py` fires a Telegram smoke alert when Sysmon is not running** — "the primary ring is down; kernring is the fallback."
- **Setup step D3** — `wevtutil sl Microsoft-Windows-Kernel-Process/Analytic /e:true` and the Network equivalent, wired into the setup docs alongside D1 (auditpol) and D2 (Sysmon ring).

**The honest fidelity gap** (documented, not hidden):

| | netedges (Sysmon) | kernring (kernel channels) |
|---|---|---|
| Process name on network events | ✅ | ❌ PID only — must join |
| Command lines | ✅ | ⚠️ version-dependent |
| Process hashes | ✅ | ❌ |
| Stable GUIDs / parent chains | ✅ | ❌ PID-based, PIDs recycle |
| Ring depth | Days (configurable) | **Minutes** (small fixed buffer) |
| Survives Sysmon deletion | ❌ | ✅ |

**Design decision:** kernring is a *separate question*, not a silent fallback inside `netedges`. If `netedges` silently degraded, the operator would think they're getting Sysmon-quality data when they're not — a lie by omission that violates the "a hole is an answer" principle. The operator asks the question that matches the fidelity they need.

**Status: LIVE-VALIDATED 2026-08-25.** Ran against both WS1 and WS2 (estate reachable): `kernring` returns process events from the kernel analytic channels with the expected fields, and reports `sysmon_status` on every call (Sysmon64=Running on ws1, Sysmon=Running on ws2). **Reality check discovered in live testing:** on this estate the kernel channels are NOT enabled as a persistent ring — the payload works as a **10-second burst capture** (`burst_seconds: 10`, note: "burst capture - 10s window, not a ring. Sysmon is the ring."), not the minutes-deep ring the design assumed. To get a persistent ring, setup step D3 (`wevtutil sl Microsoft-Windows-Kernel-Process/Analytic /e:true`) must be run on each box first; until then kernring is a spot-check, not a fallback ring.

## Rev 5 — attackmap + ATT&CK tagging + expanded LOLBins (2026-08-21)

**Source:** BLUESPAWN's ATT&CK-mapped hunt registry (github.com/ION28/BLUESPAWN, MIT license). We took the *knowledge* — the registry locations, the technique mappings, the LOLBin list — and reimplemented it pull-only. We did not take the agent, the YARA scanning, the process-memory scanning, or the mitigation system. Those violate the architecture.

**What was added:**

- **New eighth question `attackmap`** — an ATT&CK-mapped persistence STATE check. Reads 13 registry locations BLUESPAWN enumerated: Run keys (T1547.001), IFEO debuggers (T1546.010), AppCert DLLs (T1546.009), AppInit DLLs (T1546.010), Security Support Providers (T1547.005), Notification packages (T1547.002), Winlogon hijack (T1547.004), Netsh helpers (T1546.007), port monitors (T1547.010), logon scripts (T1037.001), recent accounts (T1136.001), disabled firewall profiles (T1562.004). Every finding carries its technique ID.
- **Why state, not events:** `explain` catches when persistence is CREATED (4698, 7045, 4720). `attackmap` catches persistence that ALREADY EXISTS — including anything that predates our monitoring window or was staged before rmagent was installed. This is the "what was already on the box when we started" check.
- **ATT&CK tags on existing findings.** `sketch` now returns `admin_failed_attack='T1110'`, `new_local_admins_attack='T1136.001'`, `new_services_attack='T1543.003'`, `new_tasks_attack='T1053.005'`. The operator sees not just "a task was created" but "T1053.005: Scheduled Task persistence."
- **LOLBin list expanded 18 → 80** (from BLUESPAWN's curated list). Adds `msxsl`, `installutil`, `msbuild`, `forfiles`, `diskshadow`, `dnscmd`, `cmstp`, `msdt`, `odbcconf`, `pcalua`, `rasautou`, `regasm`, `regsvcs`, `runscripthelper`, `scriptrunner`, `syncappvpublishing`, `tttracer`, `verclsid`, `wab`, `xwizard`, `appvlp`, `bginfo`, `cdb`, `csi`, `devtoolslauncher`, `dnx`, `dotnet`, `dxcap`, `mftrace`, `msdeploy`, `rcsi`, `sqlps`, `sqltoolsps`, `squirrel`, `te`, `tracker`, `update`, `vsjitdebugger`, `wsl`, and more.
- **`hunt.py` integration:** attackmap prints `N/13 ATT&CK techniques with findings`, lists each technique found, records `checked`/`found` as hop fields, and fires a Telegram smoke alert for high-severity techniques (IFEO hijack, SSP, Winlogon hijack, AppCert, firewall disabled).

**What we deliberately did NOT take from BLUESPAWN:**

| Left out | Why |
|---|---|
| The agent | Installs a service on every box. We are agentless by design. |
| YARA file scanning | Requires reading file contents off the box. Violates the no-lake rule. |
| Process memory scanning | Needs kernel access or an agent. Out of scope. |
| Their mitigation system | 20+ hardcoded registry changes with no dry-run, no journal, no undo. The Actuator does this properly. |
| Real-time ETW monitoring | Requires a persistent consumer process = an agent. kernring is our answer. |
| The C++ implementation | ~16 hunt files, thousands of lines. We took the knowledge, not the code. |

**Status: LIVE-VALIDATED 2026-08-25.** Ran against both WS1 and WS2: `attackmap` returns findings with ATT&CK tags on both boxes (ws1: T1547.001 run_keys ×1, T1546.007 netsh ×17; ws2: T1547.001 ×2, T1546.007 ×17). The full 8-signal redteam drill confirmed attackmap catches the staged drill artifacts: `run_key drill: True, ifeo drill: True` on both boxes (the two new Rev 5 signals). Drill artifacts cleaned up cleanly afterwards (0 remaining).

## Rev 6 — RTerm observability integration notes (2026-08-25)

**No skill changes required.** Documented so operators know what's already available.

RTerm (v3.2.14+) now ships OpenLLMetry-style LLM tracing: every model call the
agent makes while running an rmagent hunt (the `explain`/`sketch` analysis
passes, the completion audits) is recorded as an APM span in RTerm's trace
store, grouped per agent run. That means:

- **Hunt latency is already observable.** `rterm call observability:apmSummary`
  shows `llm.chat` / `llm.thinking` / `llm.audit.*` spans with p50/p95 latency —
  how long the LLM analysis of each hunt took, per run, with zero config.
- **No new question needed.** The skill's job is asking the *boxes* questions;
  RTerm's tracing covers the *agent's own* work automatically. Adding a
  `trace` question would duplicate what the platform already does.
- **The skill stays dependency-free.** It deliberately has no RTerm/OTel
  imports — it runs standalone with just pywinrm. Tracing happens around it,
  not inside it.
- **If you want hunt results in an external APM** (Jaeger/Tempo/Datadog), set
  `OTEL_EXPORTER_OTLP_ENDPOINT` on the RTerm backend — LLM spans forward with
  standard `gen_ai.*` attributes. Host metrics push on the same endpoint.
  This is RTerm configuration, not a skill change.

**What is NOT traced:** the WinRM knocks themselves (they're pywinrm calls
made by the skill's scripts, outside RTerm's agent loop). Their timing lives
in the case hops (`asked`/`answered` timestamps) — by design, the case file is
the record.

## Rev 7 — external half: netexec-bridge closes the purple-team loop (2026-08-25)

RTerm v3.2.15 ships **netexec-bridge**, the outside view that rmagent lacked.
The drill previously staged artifacts *on* the boxes; now you can also
simulate the *external* attack (credential spray from outside) and verify
`attest` catches the resulting 4625s.

**How the two halves fit:**

| | rmagent (inside) | netexec-bridge (outside) |
|---|---|---|
| Direction | pull from the boxes | push at the boxes |
| Sees | 4625s, 4720s, new services/tasks, registry state | auth success/failure per host |
| Governance | allowlisted questions, authorized estate | target allowlist required per call (CIDRs ok) |
| Credential handling | env / creds.json / scrt | `env:<vaultRef>` — never the secret |

**Recommended drill extension:** use `netexec_spray_plan` (rate-limited,
jittered, SLOW — does not execute) to review the schedule first, then
`netexec_check` against the SAME authorized estate. Immediately after, run
`attest` on each box and confirm `admin_fail_60s` rose. That is the full
outside→inside detection loop, scored end to end.

**Same rule as always:** authorized estate only. netexec denies by default —
empty or missing allowlists are refused, and every comma-separated target is
validated individually (no batch bypass).

## Rev 12 — correlation, drift, FP allowlist, score history, Linux sibling (2026-08-29)

Eight improvements, live-validated against WS1/WS2:

1. **Cross-witness correlation (`scripts/correlate.py`)** — the biggest gap.
   Every question ran per-box, but the drill's thesis is *lateral movement*.
   `correlate.py` joins edges/netedges answers across witnesses and surfaces:
   - **cross-host-account** — same account logged on to both boxes in the window
   - **lateral-hop** (critical) — WS1 connecting to WS2's address or vice versa
   - **explicit-cred-to-peer** — 4648 on one box naming the other
   - **shared-logonid** (critical) — same LogonId on two boxes (stolen session/PTH)
   `hunt.py` now runs correlation automatically at the end of a walk and writes
   `correlation.json` into the case. `record_ask` persists full answers to
   `case/answers/*.json` so correlation is pure post-processing (no re-pull).

2. **Baseline + drift (`scripts/drift.py`)** — `attest` is point-in-time; the
   sharper question is *who became admin since last week*. First run records a
   baseline (`~/.rmagent/baselines/<id>.json`, mode 600); later runs diff:
   new/removed admins, Sysmon status change, attackmap techniques that grew or
   shrank. `--reset` re-baselines.

3. **Time-windowing** — `--since` now accepts `30m` / `2h` / `24h` / `7d` on
   hunt/correlate/drift (was h-only). Drill comparisons and drift checks are
   meaningful instead of "recent".

4. **attackmap FP allowlist** — live validation showed `T1546.007 netsh ×17`
   on both boxes: OS-default netsh helper DLLs, not persistence. The payload
   now suppresses known-good values for T1546.007 (netsh helpers), T1547.005
   (default SSPs), T1547.002 (default auth notification packages). A real
   `netsh` helper with a non-default name still fires.

5. **Score history + regression alert (`redteam.py`)** — every drill appends
   to `~/.rmagent/drill_history.jsonl`. If the score drops vs the previous
   run, it prints AND Telegram-alerts the lost signals — catching the failure
   mode where a Windows update or GPO silently disables 4688.

6. **Auto-scheduled attest** — RTerm scheduled task (cron `*/30 * * * *`):
   `python3 ~/.agents/skills/rmagent-windows/scripts/drift.py --inventory <estate>`
   → findings into the trigger pipeline → Telegram. Turns the witness from
   "ask me" into "watch and page". Create via `manage_scheduled_task`.

7. **ETW AutoLogger for kernring** — documented as an *estate change* (MOP,
   not Phase 0): `wevtutil sl Microsoft-Windows-Kernel-Process/Analytic /e:true`
   as a standing session catches between-poll events. Not enabled by default.

8. **Linux sibling (`~/.agents/skills/rmagent-linux/`)** — same engine, SSH
   door, five questions (attest/sketch/edges/explain/attackmap) reading
   journald/auth.log/ss/systemd/cron. Makes the observatory multi-plane.
   macOS paths documented as experimental.

**Status: LIVE-VALIDATED 2026-08-29** — correlate + drift + attackmap allowlist
run against WS1/WS2 (see Rev 8 validation notes in the case dir).

## Rev 13 — the audit-blindness lesson: verify the witness can see (2026-08-29)

Two live findings from re-validating Rev 8, both silent false negatives:

1. **correlate.py joined on the wrong field names.** The unit tests used a
   synthetic `logon_id` shape, but the LIVE `edges.ps1` payload emits
   `lid` / `user` / `src` / `who` / `became` / `dest`. Every join key was
   wrong, so **shared-logonid and lateral-hop could never fire** — and the
   suite still printed "ALL UNIT TESTS PASSED". Fixed to read the live shapes
   (verbose `TargetUserName`/`LogonId` kept as fallbacks). The tests now use
   the live payload shapes. **Lesson (again): a unit test with a synthetic
   shape proves nothing about the wire. Always join against a real answer
   file from `cases/*/answers/`.**

2. **WS2 was audit-blind on 12 subcategories** — `Logon` was *Failure-only*,
   `Logoff`/`Special Logon`/`Other Logon/Logoff`/`Group Membership`/
   `Account Lockout` were *No Auditing*, while WS1 had Success+Failure on all.
   Result: `edges` on WS2 returned **zero logons while an Administrator
   WinRM session was actively connected** — and correlate reported a "clean"
   estate. That is the worst failure mode: not an error, an *empty answer
   that looks like good news*.
   **Fixed:** aligned WS2 to WS1 for the 6 subcategories the questions depend
   on (`auditpol /set /subcategory:"Logon" /success:enable /failure:enable`
   …), verified all six show Success and Failure. Baseline saved before the
   change (`/tmp/audit-baseline.json` pattern — record before you touch).
   **Proof:** immediately after, correlate fired
   `cross-host-account: 'Administrator' logged on to 2 witnesses (ws1, ws2)`
   — a finding that was structurally impossible 10 minutes earlier.

**New standing rule:** before trusting any "no findings" result, check the
witness can actually see. A quick blind-check (read-only, one ask per box):

```powershell
# raw 4624 count in 24h with NO track filter — if this is 0 while you are
# connected, the box is not auditing success logons.
(Get-WinEvent -FilterHashtable @{LogName='Security';Id=4624;
  StartTime=(Get-Date).AddHours(-24)} | Measure-Object).Count
auditpol /get /subcategory:"Logon"   # must say "Success and Failure"
```

**`attest` now does this automatically (Rev 13.1).** Every attest payload
carries:

- `raw_4624_24h` — unfiltered 4624 count (the number that was 0 on WS2 while
  an Administrator session was connected)
- `blind_check` — per-subcategory `ok` / `BLIND` / `unknown` for the six
  subcategories the questions depend on (Logon, Logoff, Special Logon,
  Other Logon/Logoff, Group Membership, Account Lockout)
- `blind_count` — how many are blind; **0 is the only healthy value**

`drift.py` treats a growing `blind_count` as a **critical** finding
(`witness_blind`) and `raw_4624_24h` dropping to 0 as a warning
(`logon_visibility_lost`). A witness going blind is the worst regression
because every other question silently returns empty.

**Two implementation notes, both learned the hard way:**

1. **WinRM command budget.** attest.ps1 grew past the ~8191-char *encoded*
   budget (preamble+payload × ~2.7 for UTF-16LE base64) and every ask
   returned "The command line is too long." — on both boxes, instantly.
   The blind_check block is deliberately terse. If you extend attest,
   re-check: `len(preamble) + len(_strip_payload(payload))` × 2.67 < 8191.
2. **auditpol /r CSV columns** are MachineName, PolicyTarget, Subcategory,
   Subcategory GUID, Inclusion Setting — the policy is field **[4]**, not
   [3]. Reading [3] reports every box as blind with GUID strings (a
   false positive that looks authoritative).

**Status: LIVE-VALIDATED 2026-08-29** — both WS1 and WS2 return
`blind_count: 0`, all six `ok`; WS2 `raw_4624_24h` = 19 (was 0 before the
audit-policy fix).

