Multi-platform seam¶
ShadowClaw was written for macOS and the assumption reached further than anyone meant it to. The fix is a seam, not three source trees.
Canonical design document
The full reasoning, including the tests that pin each property, lives in docs/platforms/architecture.md in the repository. This page is the operator-facing summary.
Why not three trees¶
Three trees would triple the surface carrying the detection logic — the chain correlator, the scoring, the ledger's hash chain, the KTP envelope. That is the part with value in it. Forking it three ways means every future improvement is written three times and drifts twice.
A complete reformat is not needed either, thanks to a piece of luck in the original design: every platform-specific thing the sensor does already has the same shape — invoke a system tool, parse its output into a neutral record.
The test suite confirms it from the other direction. Of the calls into the platform modules, the pure parsers are exercised forty times over and the acquisition functions are touched once each. The valuable, tested logic was never coupled to macOS; only the acquisition was.
Three layers vary, and no more¶
shadowclaw/platforms/ is where they live. Anything outside it that branches on the operating system is a bug against the design.
1. Acquisition — how a plane is observed¶
| Plane | macOS | Linux |
|---|---|---|
| A — inference heartbeat | ps(1) |
/proc/<pid>/{stat,statm,cmdline} |
| B — shadow egress | lsof(8), tcpdump(1) |
/proc/net/* joined to /proc/<pid>/fd, tcpdump(1) |
| C — agent actions | Endpoint Security via eslogger |
netlink process connector (cn_proc) + fanotify via ctypes — see below |
Linux reads the kernel's tables directly instead of shelling out. ps output varies between procps versions and distributions, and lsof is simply absent from a minimal container image. /proc is a documented stable interface that is always present when the kernel is. Reading it removes two external tool dependencies, removes a subprocess spawn from every poll, and removes a class of parse failure that would only ever appear on someone else's distribution.
It also keeps the sensor inside its dependency rule — os, pwd, and string handling, no psutil. That rule is also why eBPF was never a candidate for planes A and B.
2. Locations — where things live¶
| macOS | Linux | |
|---|---|---|
| System ledger | /Library/Application Support/ShadowClaw |
/var/lib/shadowclaw |
| User ledger | ~/Library/Application Support/ShadowClaw |
~/.local/share/shadowclaw |
| System config | /etc/shadowclaw/shadowclaw.json |
same |
| Home roots | /Users |
/home, /root |
The root-versus-user split those paths encode carries over unchanged: a privileged run sees every user's sockets and writes a machine-wide record; an unprivileged run sees a partial one and must not write it to the same place.
3. Indicators — what counts as suspicious¶
This is the layer a careless port drops, and dropping it is worse than not porting at all.
The first two layers are mechanism — how a thing is observed. This one is content — what is worth observing. Both vary by operating system, which is what makes the distinction easy to miss, and missing it is what lets a port look finished when it is two-thirds done.
The tactics are universal. An agent reads a credential, creates an identity, escalates privilege, persists, exfiltrates — that sequence is a property of what agents do, not of what kernel they do it on. The evidence is entirely local:
| Tactic | macOS | Linux |
|---|---|---|
| Persistence | /Library/LaunchAgents, LaunchDaemons |
systemd units, cron.d, ~/.bashrc, /etc/ld.so.preload |
| Credential store | login.keychain, security(1) |
/etc/shadow, ~/.ssh/id_*, libsecret keyrings, keyctl |
| Identity creation | dscl -create /Users/ |
useradd, /etc/sudoers.d/ |
| MAC subsystem | TCC | SELinux, AppArmor |
An indicator set aimed at the wrong platform fails silently
Acquisition failures are loud — no process table, an obvious error, a support ticket. A wrong indicator set is quiet: the sensor starts, watches /Library/LaunchAgents on a host with no such directory, matches nothing, and reports a clean machine. Every layer above it behaves correctly on evidence that could never arrive. A detector that confidently reports nothing is worse than one that refuses to start.
Ten collections vary, and two of them are scoring inputs rather than evidence lists: high_confidence_credentials is the subset of credential paths that score at full confidence, and privesc_events carries a base confidence per event beside its description. The rest are credential_path_fragments, persistence_path_fragments, credential_command_patterns, identity_command_patterns, identity_events, power_conferring_attributes, privileged_groups, and privileged_rights.
identity_events and power_conferring_attributes are empty on Linux and say so in place, because no Linux Plane C source has been chosen and inventing plausible event names would let a reader assume identity events were covered.
AGENT_CONFIG_FRAGMENTS deliberately stays in tactics.py. ~/.claude and ~/.cursor are the same paths wherever the agent runs, so agent configuration is not platform-varying evidence and moving it would have been wrong.
No sys.platform in the detection core¶
There is deliberately no platform conditional left in tactics.py, and the rule is architectural rather than stylistic: the moment one conditional is allowed there, every future indicator can arrive as another branch instead of as platform content, and the seam stops meaning anything.
tests/test_tactics_seam.py reads the source of tactics.py, scoring.py and agentchain.py with docstrings and comments stripped, and fails if any of them names an operating system or asks which one it is on.
Two further properties are pinned by tests rather than asserted:
- macOS content is unchanged by the move.
tests/test_platforms.pyasserts thedarwinset and thetacticsnames are the same objects, and every collection, its order, and every confidence is compared against a copy frozen fromtactics.pyas it stood before the move — extracted from its syntax tree rather than retyped, since a hand copy would only have agreed with whatever mistake it copied. - The Linux set scores identically for identical acts. A sudo is a sudo. If those diverged, the same action would carry different risk depending on the kernel underneath it, which would surface as a platform artefact in any dashboard aggregating across hosts.
Blindness must be loud¶
Before Plane C was implemented on Linux, the risk was not that it was unfinished; it was that a Linux host would report two planes of clean findings and look indistinguishable from a Mac watching a quiet machine. The same principle now applies one level down: an unprivileged Linux host gets Plane C's process half over cn_proc with the file half (fanotify) honestly absent, rather than the tuple that says what the module can emit being mistaken for what it is emitting.
ShadowClaw already settled the top-level argument once. shadowclaw/ktp/stress.py treats an unobserved environmental term as maximum stress rather than zero, so a sensor that cannot see is never mistaken for a calm host. Platform capability feeds the same terms for the same reason.
So Capability is a dataclass with rules: an available plane must name its mechanism and an unavailable one must give a reason, both refused by __post_init__; and a backend may not claim a plane it returns no source for. A Linux host now reports all three planes, and --self-test prints the plane-by-plane summary that count comes from — including, for Plane C specifically, whether the file half actually came up this run.
The fraction that was removed¶
An observation_coverage() on the platform briefly reduced those planes to a single fraction. It was removed, and the removal is a decision rather than a postponement.
KTP already carries a different quantity named observation_coverage, and it runs the opposite way: there it is a capacity reducer where 1 means fully worsened, against a fraction where 1 meant fully sighted. Two same-named floats of opposite polarity, one of them unused, is a trap set for whoever finally connects them — a fully capable host would have reduced capacity further than a blind one, and every number in that calculation would have looked reasonable.
Degradation reaches KTP by a better route. Each Risk Factor carries per-input {feeds_active, feeds_total} health, emitted as sensor_health with a degraded_inputs list naming what went dark. That preserves which input was lost, which is exactly what an aggregate destroys: two hosts each down one plane are not interchangeable.
Plane C on Linux: implemented, over cn_proc and fanotify¶
macOS has one answer, Endpoint Security, and it is a good one. Linux had four partial answers, and choosing between them committed ShadowClaw to an operational profile. Both halves of that choice have now shipped: the netlink process connector (cn_proc) for the process half, and fanotify via ctypes for the file half, composed into one kernel event stream in shadowclaw/linuxevents.py and shadowclaw/linuxfanotify.py. The other candidates below were considered and are still available as future, additive work — they were not the ones chosen.
The kernel multicasts a proc_event for every fork, exec, exit, uid/gid change, ptrace attach, comm change and coredump, over a plain AF_NETLINK socket the standard library opens — no compiled dependency.
Chosen over the audit subsystem for the reason that makes audit dangerous: cn_proc is multicast and multi-consumer, so subscribing disturbs nothing already listening, where the audit netlink group is single-consumer and taking it breaks auditd. It also works unprivileged, so this half of Plane C comes up without root — unlike Endpoint Security. Its one real limit: it delivers pids only, so linuxevents enriches with a /proc/<pid> read on the event, which races a short-lived process into an empty exe/argv rather than dropping the event.
Directory- and file-level watches with low overhead. Covers exactly what cn_proc structurally cannot: credential reads (FAN_OPEN) and persistence writes (FAN_CLOSE_WRITE).
Marks are applied surgically against tactics.prefilter_fragments() rather than a filesystem-wide watch, for the same load-shedding discipline the macOS grep -F prefilter already applies. Needs root (CAP_SYS_ADMIN) and a ctypes libc binding — unlike cn_proc — so linuxevents.EsfStream starts it independently: an unprivileged host keeps the process half with the file half honestly absent, reported at runtime through credential_stream_active rather than the static, always-non-empty CREDENTIAL_EVENTS tuple.
Compiled into essentially every distribution kernel. Delivers execve with argv, file access against explicit watches, setuid, and socket syscalls — very nearly the ESF event set. Reachable over a netlink socket the standard library can open, so it costs no dependency.
Two problems. Syscall auditing is expensive and the rules must be kept narrow. More seriously, the audit netlink subscription is single-consumer: taking it would silently break auditd on any host that runs one, and on RHEL-family systems that is most of them. cn_proc reaches the same process events without that hazard, which is why audit was not needed for the process half; it remains open as a cooperative complement (an audispd plugin, or reading what auditd already writes) if a second file-plane source is ever wanted.
The best signal by a distance: exec, file, network and identity, at the lowest overhead, with the most context. Also the largest commitment.
The Python bindings are third-party and break the dependency rule outright. bpftrace as a subprocess would match the existing pattern exactly — structurally the same move as eslogger — but it is not installed by default and wants kernel headers or BTF. A libbpf CO-RE component means ShadowClaw stops being pure Python, which changes packaging, signing, and review for the whole product. Still open as an opt-in high-fidelity backend for operators who will accept that trade — explicitly not the default.
Microsoft's, eBPF underneath, emits to syslog. Good events, but it is a third-party agent the operator must install and keep — and depending on it makes ShadowClaw's Plane C someone else's release schedule.
What shipped, and what is left¶
- The process plane — done, over cn_proc. No new dependency, no conflict with a running
auditd, no root. - The file plane — done, over fanotify via
ctypes. Root-gated, marks-based, composed into the same stream as the process half rather than a second source. - eBPF, cooperative audit remain open as additive, opt-in work — neither is needed for the plane the sensor now observes on every Linux host, root or not.
What should not happen is picking eBPF first because it is the best mechanism. It is — and it also converts a Python port into a compiled-component port, with a new build pipeline and a new signing story.
Above the sensor¶
The sensor is the tractable part. Two layers around it are still macOS-shaped.
Service management. launchctl and a LaunchDaemon plist appear about fifty times across scripts/ and packaging/. The Linux equivalent is a systemd unit, and scripts/ShadowclawAI needs a systemd path beside its launchctl one. The uninstaller's hard-won safety property has to survive the port: never match a process by a bare program name, always anchor to our own directory, so a Loki that predates us is never signalled.
Packaging. pkgbuild, productbuild, hdiutil and codesign have no Linux counterparts. Linux wants a .deb and an .rpm, or a tarball with an install script. The bundled Python.framework that makes the macOS installer self-contained becomes a question rather than an answer — most distributions ship a usable Python 3, but "usable" varies, and the floor has to be stated and checked in preflight instead of assumed.
Windows, implemented¶
platforms.get("win32") (and "windows", "cygwin") now returns a real backend, built against the sketch that used to live in this section but bent toward two constraints the codebase's own rules impose:
| Layer | Implementation |
|---|---|
| Plane A | Not WMI, not NtQuerySystemInformation — WMI needs pywin32/comtypes (third-party, forbidden by tests/test_ktp_zero_dependency.py), and the latter is undocumented. Instead: CreateToolhelp32Snapshot/Process32FirstW/Process32NextW (kernel32) for the process table, GetProcessTimes (kernel32) and GetProcessMemoryInfo (psapi) for CPU time and RSS. shadowclaw/winprobe.py. |
| Plane B | GetExtendedTcpTable/GetExtendedUdpTable via ctypes (iphlpapi), which hand back socket-to-pid directly per row — simpler than Linux's inode join. Also winprobe.py. |
| Plane C | Two argv-capable sources: a private ETW SystemTraceProvider process stream (Process_TypeGroup1 decoded by property name through TDH), and Security event 4688 as an independent policy-gated fallback; the same Security subscription carries identity/privilege/persistence events. Runtime health exposes whether the argv stream is active instead of treating any one reader as full coverage. Declared requires_grant because Security events need Advanced Audit Policy subcategories enabled. shadowclaw/windowsevents.py, duck-typed to the same EsfStream contract as Linux. |
| Locations | %PROGRAMDATA% and %LOCALAPPDATA%/%SystemDrive%\Users, resolved from the environment at call time rather than written as literals. shadowclaw/platforms/windows.py. |
| Indicators | Run/RunOnce keys, Startup folder, Scheduled Tasks, service registration, Image File Execution Options, Winlogon; DPAPI, Credential Manager, Vault, browser-saved passwords; net.exe/PowerShell/dsadd identity commands; cmdkey/vaultcmd credential enumeration; privileged groups and Se*Privilege rights. Reuses every shared/portable collection Linux already uses, unmodified. platforms/indicators.py's windows_indicators(). |
| POSIX assumptions | platforms.running_as_admin() (an IsUserAnAdmin token check) sits beside running_as_root(), guarded so it is always safe to call and always False off Windows. |
Two corrections Windows forced, both from DefenseClaw alignment¶
| Correction | Why |
|---|---|
Paths come from protected 64-bit HKLM registration, not %PROGRAMDATA%. shadowclaw/winpaths.py, via the stdlib's winreg; per-user roots from ProfileList\<SID>\ProfileImagePath, which the profile service writes and the described account cannot reach. |
ShadowClaw watches AI agent processes, and agent runtimes legitimately set %USERPROFILE%/%LOCALAPPDATA% for connector isolation — so a sensor resolving its own evidence store from those variables can be redirected by the process it is watching. DefenseClaw's machine_roots_windows.go: canonical roots "never consult the inherited process environment". Which source answered travels with the path, so locations_trust() can report an untrusted fallback instead of silently accepting one. |
File security is a protected DACL, not chmod 0700. shadowclaw/winacl.py (owner + SYSTEM + Administrators, inheritance blocked); ledger.restrict_to_owner() picks the mechanism from the seam. |
On NTFS, mode bits restrict nobody — the hash-chained ledger would have been readable host-wide while the code that "secured" it reported success. DefenseClaw's windows_acl.py: a descriptor "must not [be translated] through POSIX mode bits. That loses explicit and inherited ACEs and can silently widen access." A refusal becomes Ledger.restriction_error, logged and shown in the banner. |
The core carried POSIX assumptions the seam never covered¶
The seam handled acquisition, locations and indicators. These only a Windows run would hit:
sensor.pycalledos.geteuid()— which does not exist on Windows, so the sensor crashed at startup. Nowplatform.wide_coverage(); a merely guardedgeteuidwould still have reported an elevated Windows run as narrow.settings.pyhardcoded/etc/shadowclaw/shadowclaw.json, so an installed Windows policy was unreachable while the banner listed it as searched.preflight._pid_aliveusedos.kill(pid, 0). CPython implementsos.killon Windows asTerminateProcess, so the two-sensors check would have killed the other sensor and then not warned, because the conflict had just been resolved. NowOpenProcess(SYNCHRONIZE)with a zero-timeout wait.dnssniffer.pyreported a missingtcpdumpas "requires root"; Windows now names Npcap — a third-party kernel driver, outside the dependency budget — instead of prompting for elevation that unlocks nothing.Platform.wide_coverage()is now declared on the base class: all three backends implemented it while the contract never mentioned it.
What is closed, and what is not¶
Closed: the Security-log field validation. Every <Data Name> element is Microsoft's published name, and checking found three invented ones already in the code — 4673's routine is Service not ServiceName, a group event names the group in TargetUserName not GroupName, and SubjectProcessId is published by none of the mapped IDs. identity_events is populated; power_conferring_attributes carries three of ten candidate names, because 4738 populates every field with its previous value for a local account, so PrimaryGroupId/ScriptPath/ProfilePath would score every workstation account edit as an escalation.
Also closed: live Win32 execution. Two runs on a real Windows host found and fixed exactly the class of defect fixture tests cannot reach. The first crashed in the process table: winprobe.py had declared the ctypes signature for one of eleven Win32 calls, so a PSID read out of a token buffer — a heap address above 4GB — was silently truncated by ctypes's default 32-bit assumption before LookupAccountSidW ever saw it. All eleven are now declared. The second run surfaced two findings, neither the same kind of bug: the unprivileged self-test told the operator to elevate for "full coverage" on Plane B sockets, when GetExtendedTcpTable returns every row's owning pid without privilege on Windows — understating coverage, which costs the same trust as overstating it, so partial_without_root is now a positive declaration a backend must earn rather than a default guess; and the tester's guide itself sent an operator to $env:ProgramFiles, which resolves to Program Files (x86) under a 32-bit PowerShell even though the 64-bit installer had correctly placed the CLI in 64-bit Program Files — the guide was doing exactly what this port's trust-boundary work exists to refuse, reading a path out of the environment instead of the same HKLM registration the sensor trusts. Both fixed. Windows Planes A/B/C, the installer, and the SCM service registration have since run on real endpoints repeatedly, and the same detection logic has been independently re-verified by DefenseClaw's Go reimplementation (cisco-ai-defense/defenseclaw#864).
Cross-cutting bugs this surfaced and fixed for every platform: configwatch.py's hardcoded /Users, and the os.kill liveness check above.
Status¶
Done, in two steps kept deliberately apart — the seam first with macOS behaviour provably unchanged, then the detection path moved onto it.
- The
shadowclaw/platforms/seam: platform detection and capability model,darwin.py,linux.pyandwindows.pybackends,indicators.pyfor detection content - A macOS backend that changes no behaviour, asserted by object identity against the
tacticsconstants - Linux planes A and B, parsed from
/procwith pure functions, testable from fixture text on any host - The Linux indicator set — persistence, credentials, identity, privilege
-
tactics.pyreading its evidence fromplatforms.current().indicators() -
os.typederived rather than hardcoded - Ledger paths taken from the platform instead of macOS literals
- The sensor loop sampling processes and sockets through the seam —
ps/lsofon macOS,/procon Linux, Toolhelp/GetExtendedTcpTableon Windows, with no platform branch in the loop - Linux Plane C —
cn_procfor the process half,fanotifyviactypesfor the file half, composed into one kernel event stream that degrades gracefully to process-only on an unprivileged host - Windows Planes A/B/C — Toolhelp/psapi/iphlpapi for A/B, a live ETW session plus an
EvtSubscribeSecurity-log subscription for C, and the full Windows indicator set - Windows trust boundary — HKLM-resolved roots (
winpaths.py) and protected DACLs on the evidence store (winacl.py) - Windows deployability —
packaging/windows/(install.ps1, a.psm1module, PowerShell certification scripts) and the SCM service hostwinservice.py, registered demand-start so no reboot brings up a sensor nobody asked for - The core's POSIX assumptions removed, including the
os.kill(pid, 0)check that would have killed a running sensor on Windows - Windows certified on real hosts — earlier runs found and fixed an
undeclared
ctypessignature, a coverage-claim error, and a tester-guide path bug; the 1.5 private SystemTraceProvider command-line stream plus Security 4688 fallback subsequently passedtest-windows-argv-acquisition.pyon an elevated Windows host. The detection logic has also been independently re-verified by DefenseClaw's Go reimplementation.
Next, in order:
scripts/and packaging: systemd units, a Linux runner path,.deb/.rpm.- Giving
tactics.py's new platform-neutral event vocabulary the same explicit contract the indicator collections have — it became the seam between a platform's events and the scoring during this port, and it should be documented as one.
The sensor now runs through the seam, on all three planes, across three platforms
sensor.py builds its ProcessSampler from process_source() and reads sockets from socket_source(), so the production poll loop samples ps/lsof on macOS, /proc on Linux, and Toolhelp32/GetExtendedTcpTable on Windows without knowing which — everything downstream sees the same neutral records. locations() in ledger.py, indicators() in tactics.py, os_type() in otlp.py, the capability summary behind --self-test, and kernel_event_source() for Plane C are all wired for all three.
Linux runs all three planes in the production sensor loop. Plane C comes from linuxevents — cn_proc for the process half unconditionally, and fanotify for the file half wherever linuxfanotify.fanotify_reachable() says root and the libc symbols are both present. An unprivileged host still gets the process half honestly, with the file half absent rather than assumed, which is exactly what the capability model exists to guarantee one level below the plane itself.
Windows runs all three planes, including the live-accepted 1.5 argv
path. Plane C comes from
windowsevents — a private SystemTraceProvider process stream and an
EvtSubscribe subscription on the Security channel. The documented
process template carries command lines in the creation event, so
short-lived children do not race a later process-table query; 4688 supplies
an independent fallback when its audit policy and command-line policy are
enabled. requires_root and requires_grant are both true and mean
different things: elevation is a script's decision, Advanced Audit Policy
is a human at a settings pane. A process/security reader can fail
independently, and argv_stream_active plus degraded_detail prevents
partial startup from reading as full Plane C. The elevated acceptance run
confirmed both SystemTraceProvider and Security 4688 carried argv.