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.
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:
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):
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)
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):
{ "ws1": { "user": "Administrator", "password": "..." },
"ws2": { "user": "Administrator", "password": "..." } }
4. Inventory
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> |
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 |
# 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> 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:
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).
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.
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".
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.
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.
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.
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.
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:
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/.
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):
# 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:
- 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.
- 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).
1---2name: rmagent-windows3description: 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.4---56# RMAgent for Windows78You 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.910RMAgent 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`.1112Canonical 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.1314## Non-negotiables1516- **Keep the EDR.** Defender / CrowdStrike stays on. Commodity malware is out of this watch. Say so if anyone asks to switch it off.17- **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.18- **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.19- **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.20- **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.21- **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.22- **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.23- **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.2425## Setup (one time)2627### 1. Jump host (macOS, Linux, or Windows)2829The 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.3031```bash32python3 --version # 3.11+33pip3 install pywinrm pyyaml34export SKILL_DIR=~/.agents/skills/rmagent-windows35ls "$SKILL_DIR/scripts"/{census,hunt,case,lib}.py36ls "$SKILL_DIR/scripts/questions/windows/" # attest sketch edges explain netedges37```3839> 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`.4041### 2. Open the door on each Windows witness4243**NTLM (default, zero-config on AWS):** WinRM is already listening on 5985 on44EC2 Windows instances. Just confirm the firewall allows the jump host:4546```powershell47Enable-PSRemoting -Force48Set-Service WinRM -StartupType Automatic; Start-Service WinRM49New-NetFirewallRule -Name "WinRM-5985-JumpHost" -DisplayName "WinRM HTTP from jump host" `50 -Enabled True -Direction Inbound -Protocol TCP -LocalPort 5985 -Action Allow51```5253**Basic (alternative — only if you set `transport: basic` in the inventory):**54also enable Basic auth and allow unencrypted (workgroup, HTTP 5985):5556```powershell57Set-Item WSMan:\localhost\Service\Auth\Basic -Value $true58Set-Item WSMan:\localhost\Service\AllowUnencrypted -Value $true59```6061> The live validation on WS1/WS2 used NTLM and worked with no server-side62> changes. For production, prefer HTTPS 5986 or Kerberos in a real domain.63> The habit — allowlisted named questions — is unchanged either way.6465### 3. Credentials (env, recommended)6667```bash68export RMAgent_WS1_USER=Administrator69export RMAgent_WS1_PASS='...' # from your vault / the secrets skill — never commit70export RMAgent_WS2_USER=Administrator71export RMAgent_WS2_PASS='...'72```7374Or store in `~/.rmagent/creds.json` (mode 600):7576```json77{ "ws1": { "user": "Administrator", "password": "..." },78 "ws2": { "user": "Administrator", "password": "..." } }79```8081### 4. Inventory8283```bash84cp "$SKILL_DIR/assets/inventory.example.yaml" ./estate.yaml85# edit only if you add a box you administer. Never add passwords here.86```8788## The four questions (what each returns, Administrator/SYSTEM-scoped)8990| Question | Payload | What you get | What you must NOT get |91|---|---|---|---|92| **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 |93| **Anything odd?** (sketch) | `sketch.ps1` | admin failed in window, new local admins 24h, running privileged services, new services/tasks | raw event lists |94| **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 |95| **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 |96| **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 |97| **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 |98| **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 |99| **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 |100101> `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.102103Engine injects `$Track`, `$SinceHours`, `$Limit` as a preamble; payloads read them. No payload is a god-shell.104105## Operating loop106107```108every 1 min ± jitter → census (attest only; alive + Admin/SYSTEM smoke digest)109every 5–15 min → scout (sketch: new admins, priv services, new tasks/services)110on smell/silence → hunter (edges → explain on hosts with smoke), serial, capped111on High case → human (Judge) pins, closes, or escalates. No isolate from Phase 0.112never → copy Security.evtx, dump rings, tight-retry silent hosts, actuate113```114115Agents are not a SOC. After hours, a human is still on call for Hunter and any explain that touches a person.116117## Scripts118119Resolve `$SKILL_DIR` as the folder containing this `SKILL.md`.120121| Job | Script | Notes |122|---|---|---|123| Minute watch | `scripts/census.py` | pywinrm, max-3 knock budget, 2 misses = Critical |124| Administrator/SYSTEM walk | `scripts/hunt.py` | serial, depth-capped; writes path.json + holes.jsonl |125| Write / list / close a case | `scripts/case.py` | one-page blackboard |126| Allowlisted payloads | `scripts/questions/windows/*.ps1` | attest / sketch / edges / explain |127| Optional all-pwsh path | `scripts/winrm_pool.ps1` | Invoke-Command + RunspacePool (max 3), creds from env |128129```bash130# Census — are WS1/WS2 alive?131python3 "$SKILL_DIR/scripts/census.py" --inventory ./estate.yaml132133# Open a case, then walk Administrator/SYSTEM across both boxes134CASE=$(python3 "$SKILL_DIR/scripts/case.py" open --title "admin walk" --principal Administrator)135python3 "$SKILL_DIR/scripts/hunt.py" --inventory ./estate.yaml --since 2h --case-dir "$CASE"136137# All-pwsh alternate (only if every door is winrm)138pwsh -NoProfile -File "$SKILL_DIR/scripts/winrm_pool.ps1" -Skill attest `139 -ComputerName '44.197.31.152','52.3.242.251' -Ids ws1,ws2 -MaxRunspaces 3140```141142## Identifier, pin, hole143144| Object | Contract |145|---|---|146| Hop id | LogonId (hex) or a work/request id for app hops. Same id across hosts = same session. |147| Ticket / session | Never store Kerberos/NTLM. The LogonId from a 4624 event is the join, not a secret. |148| If stripped | Idempotency key, then producer child, then **hole**. Never join on public IP. |149| Pin | Auto on 2 missed attests, Hunter walk, human incident. Days (default 14). |150| Hole | `{asked, empty, why}` — same shape as a hop. |151152## What success looks like (Phase 0)153154You 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."155156## Examples157158- `examples/lab-a-live.md` — Lab A: attest both, silence one, write the hole.159- `examples/walk-administrator.md` — a full Administrator/SYSTEM hunt across WS1+WS2.160161## What this skill will NOT do162163- No `actuate`. `ask()` returns a hole for it. Isolate/disable/revoke stays with the EDR/IAM and a human.164- No log lake. No `Security.evtx` copy. No tenant/ring dump.165- No inventing a witness for a box you do not administer (NIBSS, a partner, a phone).166- No tight retry on a silent host.167- No replacement for the EDR. Commodity malware stays with Defender/CrowdStrike.168169## Hardening changelog (2026-08-19)170171Live-verified bug fixes from a purple-team drill on WS1/WS2:172173- **`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.174- **Census miss-state is now stable** at `~/.rmagent/.census_miss.json` (was CWD/case-dir — "2 misses = Critical" could never trigger across runs).175- **`attest`/`sketch` match only `TargetUserName`** on 4624/4625 — matching any event field counted SYSTEM-subject events as admin failures (false positives).176- **`sketch.new_local_admins` only reports members still in the group** — deleted users' 4732 events linger 24h as stale SIDs.177- **`netedges` is advertised in the example inventory** — fresh installs previously scored max 5/6 on drills.178- Payloads compacted under the WinRM UTF-16LE base64 command-line budget (~8191 chars).179180## Lateral-movement & persistence additions (2026-08-19, rev 2)181182New event coverage in `edges` and `explain`, live-verified on WS1:183184- **`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`).185- **`edges.special_privs` (4672)** — the privilege set granted at each admin logon. `SeDebugPrivilege` = process injection; `SeTcbPrivilege` = act-as-OS. Anomalous grants are now visible.186- **`edges` logons now carry the auth package** (`NTLM` vs `Kerberos`) — NTLM on a Kerberos-capable box is itself a signal.187- **`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.188- **`explain.identity_changes` now include 4648/4672**, and `hunt.py` fires a Telegram smoke alert naming them.189- `hunt.py` output and the case `path.json` record `explicit_creds`, `special_privs`, `wmi_subscriptions` as first-class hop fields.190191**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.192193## Rev 3 — 4688 upgrade, anti-forensics, DNS, and PowerShell code (2026-08-19)194195Driven by a live probe of what sources actually exist on WS1/WS2:196197- **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.198- **`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.199- **`task_events` now includes 4699** (task *deleted*) alongside 4698/4702 — an attacker deleting the task they used is as interesting as creating it.200- **`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).201- **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.202- `hunt.py` prints and records all of it; Telegram smoke alerts name the new signals (audit-cleared gets an explicit "anti-forensics!" flag).203204**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.205206## Rev 4 — kernring: the no-Sysmon fallback (2026-08-20)207208**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.209210**What was added:**211212- **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.213- **`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.214- **`hunt.py` fires a Telegram smoke alert when Sysmon is not running** — "the primary ring is down; kernring is the fallback."215- **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).216217**The honest fidelity gap** (documented, not hidden):218219| | netedges (Sysmon) | kernring (kernel channels) |220|---|---|---|221| Process name on network events | ✅ | ❌ PID only — must join |222| Command lines | ✅ | ⚠️ version-dependent |223| Process hashes | ✅ | ❌ |224| Stable GUIDs / parent chains | ✅ | ❌ PID-based, PIDs recycle |225| Ring depth | Days (configurable) | **Minutes** (small fixed buffer) |226| Survives Sysmon deletion | ❌ | ✅ |227228**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.229230**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.231232## Rev 5 — attackmap + ATT&CK tagging + expanded LOLBins (2026-08-21)233234**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.235236**What was added:**237238- **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.239- **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.240- **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."241- **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.242- **`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).243244**What we deliberately did NOT take from BLUESPAWN:**245246| Left out | Why |247|---|---|248| The agent | Installs a service on every box. We are agentless by design. |249| YARA file scanning | Requires reading file contents off the box. Violates the no-lake rule. |250| Process memory scanning | Needs kernel access or an agent. Out of scope. |251| Their mitigation system | 20+ hardcoded registry changes with no dry-run, no journal, no undo. The Actuator does this properly. |252| Real-time ETW monitoring | Requires a persistent consumer process = an agent. kernring is our answer. |253| The C++ implementation | ~16 hunt files, thousands of lines. We took the knowledge, not the code. |254255**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).256257## Rev 6 — RTerm observability integration notes (2026-08-25)258259**No skill changes required.** Documented so operators know what's already available.260261RTerm (v3.2.14+) now ships OpenLLMetry-style LLM tracing: every model call the262agent makes while running an rmagent hunt (the `explain`/`sketch` analysis263passes, the completion audits) is recorded as an APM span in RTerm's trace264store, grouped per agent run. That means:265266- **Hunt latency is already observable.** `rterm call observability:apmSummary`267 shows `llm.chat` / `llm.thinking` / `llm.audit.*` spans with p50/p95 latency —268 how long the LLM analysis of each hunt took, per run, with zero config.269- **No new question needed.** The skill's job is asking the *boxes* questions;270 RTerm's tracing covers the *agent's own* work automatically. Adding a271 `trace` question would duplicate what the platform already does.272- **The skill stays dependency-free.** It deliberately has no RTerm/OTel273 imports — it runs standalone with just pywinrm. Tracing happens around it,274 not inside it.275- **If you want hunt results in an external APM** (Jaeger/Tempo/Datadog), set276 `OTEL_EXPORTER_OTLP_ENDPOINT` on the RTerm backend — LLM spans forward with277 standard `gen_ai.*` attributes. Host metrics push on the same endpoint.278 This is RTerm configuration, not a skill change.279280**What is NOT traced:** the WinRM knocks themselves (they're pywinrm calls281made by the skill's scripts, outside RTerm's agent loop). Their timing lives282in the case hops (`asked`/`answered` timestamps) — by design, the case file is283the record.284285## Rev 7 — external half: netexec-bridge closes the purple-team loop (2026-08-25)286287RTerm v3.2.15 ships **netexec-bridge**, the outside view that rmagent lacked.288The drill previously staged artifacts *on* the boxes; now you can also289simulate the *external* attack (credential spray from outside) and verify290`attest` catches the resulting 4625s.291292**How the two halves fit:**293294| | rmagent (inside) | netexec-bridge (outside) |295|---|---|---|296| Direction | pull from the boxes | push at the boxes |297| Sees | 4625s, 4720s, new services/tasks, registry state | auth success/failure per host |298| Governance | allowlisted questions, authorized estate | target allowlist required per call (CIDRs ok) |299| Credential handling | env / creds.json / scrt | `env:<vaultRef>` — never the secret |300301**Recommended drill extension:** use `netexec_spray_plan` (rate-limited,302jittered, SLOW — does not execute) to review the schedule first, then303`netexec_check` against the SAME authorized estate. Immediately after, run304`attest` on each box and confirm `admin_fail_60s` rose. That is the full305outside→inside detection loop, scored end to end.306307**Same rule as always:** authorized estate only. netexec denies by default —308empty or missing allowlists are refused, and every comma-separated target is309validated individually (no batch bypass).310311## Rev 12 — correlation, drift, FP allowlist, score history, Linux sibling (2026-08-29)312313Eight improvements, live-validated against WS1/WS2:3143151. **Cross-witness correlation (`scripts/correlate.py`)** — the biggest gap.316 Every question ran per-box, but the drill's thesis is *lateral movement*.317 `correlate.py` joins edges/netedges answers across witnesses and surfaces:318 - **cross-host-account** — same account logged on to both boxes in the window319 - **lateral-hop** (critical) — WS1 connecting to WS2's address or vice versa320 - **explicit-cred-to-peer** — 4648 on one box naming the other321 - **shared-logonid** (critical) — same LogonId on two boxes (stolen session/PTH)322 `hunt.py` now runs correlation automatically at the end of a walk and writes323 `correlation.json` into the case. `record_ask` persists full answers to324 `case/answers/*.json` so correlation is pure post-processing (no re-pull).3253262. **Baseline + drift (`scripts/drift.py`)** — `attest` is point-in-time; the327 sharper question is *who became admin since last week*. First run records a328 baseline (`~/.rmagent/baselines/<id>.json`, mode 600); later runs diff:329 new/removed admins, Sysmon status change, attackmap techniques that grew or330 shrank. `--reset` re-baselines.3313323. **Time-windowing** — `--since` now accepts `30m` / `2h` / `24h` / `7d` on333 hunt/correlate/drift (was h-only). Drill comparisons and drift checks are334 meaningful instead of "recent".3353364. **attackmap FP allowlist** — live validation showed `T1546.007 netsh ×17`337 on both boxes: OS-default netsh helper DLLs, not persistence. The payload338 now suppresses known-good values for T1546.007 (netsh helpers), T1547.005339 (default SSPs), T1547.002 (default auth notification packages). A real340 `netsh` helper with a non-default name still fires.3413425. **Score history + regression alert (`redteam.py`)** — every drill appends343 to `~/.rmagent/drill_history.jsonl`. If the score drops vs the previous344 run, it prints AND Telegram-alerts the lost signals — catching the failure345 mode where a Windows update or GPO silently disables 4688.3463476. **Auto-scheduled attest** — RTerm scheduled task (cron `*/30 * * * *`):348 `python3 ~/.agents/skills/rmagent-windows/scripts/drift.py --inventory <estate>`349 → findings into the trigger pipeline → Telegram. Turns the witness from350 "ask me" into "watch and page". Create via `manage_scheduled_task`.3513527. **ETW AutoLogger for kernring** — documented as an *estate change* (MOP,353 not Phase 0): `wevtutil sl Microsoft-Windows-Kernel-Process/Analytic /e:true`354 as a standing session catches between-poll events. Not enabled by default.3553568. **Linux sibling (`~/.agents/skills/rmagent-linux/`)** — same engine, SSH357 door, five questions (attest/sketch/edges/explain/attackmap) reading358 journald/auth.log/ss/systemd/cron. Makes the observatory multi-plane.359 macOS paths documented as experimental.360361**Status: LIVE-VALIDATED 2026-08-29** — correlate + drift + attackmap allowlist362run against WS1/WS2 (see Rev 8 validation notes in the case dir).363364## Rev 13 — the audit-blindness lesson: verify the witness can see (2026-08-29)365366Two live findings from re-validating Rev 8, both silent false negatives:3673681. **correlate.py joined on the wrong field names.** The unit tests used a369 synthetic `logon_id` shape, but the LIVE `edges.ps1` payload emits370 `lid` / `user` / `src` / `who` / `became` / `dest`. Every join key was371 wrong, so **shared-logonid and lateral-hop could never fire** — and the372 suite still printed "ALL UNIT TESTS PASSED". Fixed to read the live shapes373 (verbose `TargetUserName`/`LogonId` kept as fallbacks). The tests now use374 the live payload shapes. **Lesson (again): a unit test with a synthetic375 shape proves nothing about the wire. Always join against a real answer376 file from `cases/*/answers/`.**3773782. **WS2 was audit-blind on 12 subcategories** — `Logon` was *Failure-only*,379 `Logoff`/`Special Logon`/`Other Logon/Logoff`/`Group Membership`/380 `Account Lockout` were *No Auditing*, while WS1 had Success+Failure on all.381 Result: `edges` on WS2 returned **zero logons while an Administrator382 WinRM session was actively connected** — and correlate reported a "clean"383 estate. That is the worst failure mode: not an error, an *empty answer384 that looks like good news*.385 **Fixed:** aligned WS2 to WS1 for the 6 subcategories the questions depend386 on (`auditpol /set /subcategory:"Logon" /success:enable /failure:enable`387 …), verified all six show Success and Failure. Baseline saved before the388 change (`/tmp/audit-baseline.json` pattern — record before you touch).389 **Proof:** immediately after, correlate fired390 `cross-host-account: 'Administrator' logged on to 2 witnesses (ws1, ws2)`391 — a finding that was structurally impossible 10 minutes earlier.392393**New standing rule:** before trusting any "no findings" result, check the394witness can actually see. A quick blind-check (read-only, one ask per box):395396```powershell397# raw 4624 count in 24h with NO track filter — if this is 0 while you are398# connected, the box is not auditing success logons.399(Get-WinEvent -FilterHashtable @{LogName='Security';Id=4624;400 StartTime=(Get-Date).AddHours(-24)} | Measure-Object).Count401auditpol /get /subcategory:"Logon" # must say "Success and Failure"402```403404**`attest` now does this automatically (Rev 13.1).** Every attest payload405carries:406407- `raw_4624_24h` — unfiltered 4624 count (the number that was 0 on WS2 while408 an Administrator session was connected)409- `blind_check` — per-subcategory `ok` / `BLIND` / `unknown` for the six410 subcategories the questions depend on (Logon, Logoff, Special Logon,411 Other Logon/Logoff, Group Membership, Account Lockout)412- `blind_count` — how many are blind; **0 is the only healthy value**413414`drift.py` treats a growing `blind_count` as a **critical** finding415(`witness_blind`) and `raw_4624_24h` dropping to 0 as a warning416(`logon_visibility_lost`). A witness going blind is the worst regression417because every other question silently returns empty.418419**Two implementation notes, both learned the hard way:**4204211. **WinRM command budget.** attest.ps1 grew past the ~8191-char *encoded*422 budget (preamble+payload × ~2.7 for UTF-16LE base64) and every ask423 returned "The command line is too long." — on both boxes, instantly.424 The blind_check block is deliberately terse. If you extend attest,425 re-check: `len(preamble) + len(_strip_payload(payload))` × 2.67 < 8191.4262. **auditpol /r CSV columns** are MachineName, PolicyTarget, Subcategory,427 Subcategory GUID, Inclusion Setting — the policy is field **[4]**, not428 [3]. Reading [3] reports every box as blind with GUID strings (a429 false positive that looks authoritative).430431**Status: LIVE-VALIDATED 2026-08-29** — both WS1 and WS2 return432`blind_count: 0`, all six `ok`; WS2 `raw_4624_24h` = 19 (was 0 before the433audit-policy fix).