subagent-claude-code and subagent-codex resolve a real external CLI and spawn it in the parent
session’s workspace. There is no DSH session for the child, so no session event describes anything
it does: this plugin’s coverage ends at the tool call. That is the most important gap in what a SOC
sees, so the call is graded severity_id: 4, classed as Process Activity, and says so in
message:
{
"class_uid": 1007, "severity_id": 4,
"message": "tool call subagent_codex delegates to codex; session telemetry coverage ends at this boundary",
"process": { "name": "codex" },
"unmapped": { "dsh": {
"tool": "subagent_codex", "tool_class": "delegation-external",
"delegation_provider": "codex", "delegation_boundary": true, "delegation_coverage": "none"
} }
}
The record carries the tool name and the provider name. It does not carry the prompt handed to the other harness — that follows the same redaction policy as any other tool argument.
The mapping is best-effort, and here is why. The provider name is fixed per plugin row and is
not in the tool-call payload, so the plugin cannot name the destination harness from the event
alone. At mount it reads the composed tool-subagent rows out of ctx.registry and pairs each
row’s toolName with its provider. Since toolName is a deployment choice, a row may be composed
after this plugin mounts, and a deployment may reach an external harness through a plugin this build
has never heard of, delegationTools lets you name one directly:
delegationTools:
handoff_to_codex: codex
A configured entry may add a name. It cannot un-name one discovery found: repo-local configuration is attacker-controlled, and re-pointing a discovered delegation tool at a benign provider would silence the loudest record this plugin emits.
spawn and fork subagents are not delegation boundaries. They run in process and are fully
observed.
Every record carries the identity a multi-team SOC filters on. None of it is inferred — an invented tenant is worse than an absent one, so an unconfigured field is omitted.
| Field | Source |
|---|---|
metadata.tenant_uid |
fleet.tenantUid. |
metadata.labels |
fleet.labels, a string list. |
metadata.tags |
fleet.tags, a map. OCSF types this as an array of key_value_object, so {owner: soc} is emitted as [{"name":"owner","value":"soc"}] — labels is the slot for bare strings. |
device.uid |
fleet.installUid, or a uid minted once and persisted at fleet.installUidPath, which defaults to $DSH_HOME/install-uid. A hostname is not an identity: it changes when a laptop is renamed and collides across a fleet imaged from one template. The path is under the harness home rather than beside the spool so that dsh-netguard, whose spool is elsewhere, reports the same device.uid for this machine. A uid an earlier release left at <spoolPath>.install-uid is carried over on first run, so upgrading does not re-identify the host. Persisting is best effort: a home this process cannot write costs the uid its stability across restarts, and is reported, but never fails the mount. |
metadata.original_time |
The session log’s own rendering of the append time, passed through as a string. OCSF wants “a pass-through string in its native format… not normalized” — the normalised value is time — and says to omit it for generated events, so the heartbeat carries none. |
The SOC lane is metadata, classifications, keyed digests, and lengths. Anything a model, a user, a
provider, or a hook composed as free text — prompts, completions, commands, grep patterns,
approval prompt text, provider failure messages, compaction failures, hook findings — reaches it
only as HMAC-SHA256(key, value) plus a character count.
This is the complete list of what it does carry verbatim, because each is the security signal rather than its content:
| Value | Where | Why it is not digested |
|---|---|---|
| File paths | file.path, the file.path observable |
A path is what a detection matches on. |
| Tool names | unmapped.dsh.tool, api.operation, privileges[] |
The identity of the capability used. |
| Executable names | process.name, after any leading NAME=VALUE assignments are stripped |
The subject of a process launch. |
| Hostnames, URL scheme and host | device.hostname, src_endpoint, http_request.url |
The destination, without the query string that carries tokens. |
| Schedule ids, goal ids, command ids, call ids, handler ids | job.uid, unmapped.dsh.*_id |
Durable identifiers a SOC pivots and joins on. |
| Model-chosen argument names | unmapped.dsh.arguments[].key |
The model names its own arguments. A digest of file_path groups nothing and tells nobody which argument the value belonged to. The argument’s value follows privacy.argumentValues. |
A tool error’s name and code |
status_detail, from tool/result.error |
The failure class, chosen by the tool implementation rather than composed per call. |
A team member’s name |
unmapped.dsh.member_name, .sender_name |
The label a team/message/queued repeats as its sender. A digest of it joins nothing. The member’s description is digested. |
A team task’s writeScopes |
unmapped.dsh.write_scopes |
A path pattern, on the same reasoning as file.path: it is what a detection matches on. The task’s subject and description are digested. |
A hook’s matcher |
unmapped.dsh.matcher |
A deployment-authored pattern from the hook configuration, not model or user input — the one pattern on this page that is not digested, and it is deployment-trusted where a grep pattern is not. |
| Enumerated outcomes | status_detail, unmapped.dsh.end_reason, .decision, .mode |
See below. |
“Bounded enumeration” is a weaker claim than it reads. Only hook/result.decision is actually
reduced to a fixed set — approve/allow/block/deny/ask, with anything else recorded as
other plus a digest. The rest are passed through as the payload wrote them: TurnEndReasonMap,
ApprovalOutcome and the sandbox modes are merge-extensible, so a plugin outside this repository
can put an arbitrary string in turn/end’s reason.kind and it reaches status_detail verbatim.
The values are short discriminants by construction and by convention, not by enforcement. If that
matters for your deployment, the restricted lane is not the answer — pin the plugin set.
The restricted lane (restricted.path plus restricted.acknowledged: true, mode 0600) is the same
records with the verbatim event payload in raw_data, joined to the SOC lane on metadata.uid.
| Field | Joins |
|---|---|
metadata.uid = <session>:<seq> |
The idempotency key. Deduplicate on it. <seq> is the session log’s own event sequence. dsh-netguard writes <session>:netguard:<seq> over a counter of its own, so deduplicating an index holding both packages does not drop its records as duplicates of these. |
metadata.correlation_uid |
<session>:<callId> for a tool call and its result, <session>:approval:<id> for an approval pair, <session>:turn:<n>, <session>:<turn>:<step>, <session>:team-message:<id> for a queued message and its delivery, <session>:team-task:<id>, <session>:team:<teamId>, <session>:session-log-delivery for every upload of the log. |
metadata.sequence |
The session-log seq — a per-session gap detector. |
ai_agent.instance_uid / unmapped.dsh.session_id |
The session. parent_session_id and seed_length stitch forks. |
unmapped.dsh.turn, .step, .call_id, .approval_id |
Agent-loop position and pairing ids. |
Approval latency is on the decision record as duration and unmapped.dsh.approval_latency_ms.
The spool is written synchronously before anything is queued for shipping, so:
A killed process leaves records on disk and a cursor that stopped advancing — a visible gap, not silent loss. A sink that refuses a write leaves the session cursor on the unwritten event, so an outage delays records rather than consuming them.
Read the right half of the counter line. forwarded counts records handed to the sink, not
records on disk: a spool that lost its descriptor accepts every record and drops it, and those
records are counted as forwarded. The two counters that report that are sink_dropped and
sink_failed, which the periodic line carries alongside the forwarder’s own:
ocsf-forwarder: forwarded=3 dropped=0 unreadable=0 failed=0 sink_dropped=3 sink_failed=true
dropped is the drop policy — dropEventTypes and the built-in list — and failed is a
contained exception inside the listener. Neither says anything about the spool. Off-host, the
heartbeat’s sink_failed and sink_dropped_records are the same two signals, at
severity_id: 5.
metadata.uid.session/event fires post-commit but pre-durable, so a record can
describe an event a later crash loses from the session log; conversely an event appended while the
plugin was unmounted is not on the live path. ctx.sessionQuery.readSession(id) reads a full
historical log for an offline backfill (it needs the session-query-sqlite row mounted).seedReplay: full a resumed session re-emits its prior log. That is deliberate: coverage
beats duplication for an audit lane, and metadata.uid makes the duplicates exact.Rotation. At spoolMaxBytes the live file is renamed to a fixed-width timestamped generation —
<spoolPath>.2026-08-16T11-42-22.123Z-000 — and a fresh live file is opened. A generation name is
never reused, so rotation never overwrites one. The shipper drains generations oldest-first, ahead
of the live file, and unlinks each only once the collector has acknowledged every byte in it. The
delivery cursor follows the rename onto the generation it now indexes, so rotation does not resend
what was already delivered out of that file. Rotation stops at either of two bounds: spoolMaxGenerations un-drained generations, or
spoolMaxTotalBytes on disk across the live file and every generation. The live file then grows
past spoolMaxBytes and the plugin logs why. That is deliberate — an audit lane that deletes
unacknowledged evidence to stay under a size limit is worse than one that gets loud and large. The
second bound exists because a file count bounds nothing about the disk once the live file is the
one growing. Once a bound has stopped rotation, the two conditions are re-checked at most once a
minute rather than once per record, so an outage costs the agent one directory listing a minute;
rotation resumes within that window of the shipper draining a generation.
Neither bound is a retention policy and neither ever deletes a record. The alarm is
spoolHighWaterBytes, which sits below the stop condition and raises the heartbeat to
severity_id: 4 while there is still room; a high-water mark above the stop condition would never
fire in time, so that combination fails at load.
A write failure never ends the spool. Renaming the live file is the one moment the spool holds
no descriptor, so a failure there — a directory permission changed under it, a filesystem that went
read-only — is reported through the plugin logger and a descriptor is taken again immediately: on
the new generation when the rename went through, on the original file when it did not. Rotation is
then held off for a minute and rotation_stopped reads true, exactly as a refused rotation reads.
If the descriptor cannot be taken back at all, the spool says so rather than accepting records into
nothing. Every record it drops is counted, the first one logs a warning, and the heartbeat carries
sink_failed: true with sink_dropped_records at severity_id: 5. Each later write retries the
open — immediately on the first attempt, then backing off from 250 ms to 30 s — and the first
success logs how many records were lost in between. None of this is configurable: nothing about a
deployment makes a different retry rate correct, and a slower first retry only destroys evidence
whose cause has already cleared.
One writer per path. A spool path is held by an exclusive <spoolPath>.lock. Two processes
sharing one path would each rename the inode the other is writing into, so the second one fails at
load with the pid that holds it. Give each process its own path — the bundle patch’s
dshHomePath(...) default already does, per $DSH_HOME. A lock left by a process that no longer
exists is taken over.
The modes 0640 and 0600 cover the spool files and nothing else. They are forced onto the spool,
the restricted lane and the quarantine file after opening, so a permissive umask cannot widen
them — best effort on the spool itself, because a file made append-only with chattr +a refuses
the chmod and keeping the appends is worth more than re-stating the mode. See
Hardening the spool, which also covers what +a does to rotation. Everything around them is umask-governed:
mkdir -p semantics: under umask 000 they are 0777;<spoolPath>.lock, created 0666 under the same umask — and its content is the holding pid,
which is what the one-writer guarantee reads to decide whether a lock is live.A world-writable lock file lets any local account replace the pid in it and take the path from the
process that holds it. Set a umask for the agent process, or place the spool under a directory
whose mode you control; this plugin does not set one for you, because a plugin changing the process
umask changes it for the agent and everything else in it.
Retry and quarantine. A batch the destination cannot take right now is retried with
exponential backoff from flushIntervalMs up to maxBackoffMs, and the cursor does not move. A
batch it refuses on content is appended to quarantinePath and stepped over, because retrying it
forever would hold every later record behind one the destination will never accept. That file holds
whole OCSF records, so its mode is forced to 0640 — the SOC lane’s own — rather than left to the
process umask. Each
quarantined batch is reported through the plugin logger, naming the destination that refused it.
Which statuses fall where is the transport’s decision: OTLP retries 5xx, timeouts, connection
failures and 408/425/429 and refuses any other 4xx; Splunk’s reading is in
Shipping to a SIEM and differs on 401 and 403.
DeepSeek Harness Desktop is an Electron shell around the Web application. An Electron
RunAsNode child runs the Host, and that is where plugins — including this one — load. The
Electron main process is a different process, and nothing here runs in it. Everything below
was read from the harness source at dsh-v0.1.7-rc.2; apps/desktop and apps/desktop-host are
private: true and never published, so a checkout at that tag is the only way to see them.
None of it was executed: there is no packaged Electron build here.
The one measurement that covers all of it: session.append does not appear anywhere in
apps/desktop/src or apps/desktop-host/src. No Desktop-specific behaviour produces a session
event, so the firehose this plugin subscribes to carries none of it.
Desktop auto-updates: it polls a fixed feed, downloads, verifies SHA-512, and hands the process exit to an installer. A security product that cannot tell a SOC “this host’s agent binary changed version” is missing the most consequential event on the box, and this one cannot.
DesktopUpdateCoordinator (apps/desktop/src/update-coordinator.ts) owns the whole state
machine — check, available, download progress, verify, ready, quitAndInstall — on top of
electron-updater’s autoUpdater, which is an Electron-main-only API. Its state reaches the
renderer over dsh-desktop:updates-presentation and never the Host.
The Host is involved in an install, and what it receives is the limit:
| Host IPC message | Payload |
|---|---|
shutdown |
nothing |
quit-inspection |
requestId |
update-tasks |
requestId, action ∈ inspect | lock | unlock |
That is the complete inbound vocabulary of apps/desktop-host/src/index.ts. There is no version,
no artifact identity, and no way to tell an update lock from any other lock. The handler,
installDesktopUpdateTaskControl (apps/desktop-host/src/update-tasks.ts, 35 lines from its export to the end of the file), registers a
connection/request middleware that answers 503 while locked and returns a boolean; it calls no
ctx.emit, provides no service, and appends no event.
So there is no mapping to invent here, and none is emitted. What a deployment can do instead:
getDshRuntimeVersion() from
@deepseek-ai/dsh-app-boot, and the signed desktop-runtime.json whose directory the Host
receives as process.argv[2]. That answers “what am I running”, not “it just changed”, and this
plugin does not read either: a version that only ever reports itself is already in
metadata.product for this package and in ai_agent.version for the agent.update-coordinator.ts
says so in its own comment.%LOCALAPPDATA%\…-updater\installer-logs\ reports, the macOS packaging records — are files a
host agent already collects.Desktop’s embedded Platform view keys a persistent WebContentsView partition by a SHA-256 of
the Platform origin and the account id, clears cookies, IndexedDB, Cache Storage, service workers
and HTTP auth before each document loads, and destroys the document on close
(apps/desktop/src/platform-view.ts). Sidebar Browser <webview> guests get a lease and a
process-lifetime partition (apps/desktop/src/browser-guests.ts). All of it is Electron main; the
Host companion plugin for the sidebar browser has an empty apply(). The IPC channels
(dsh-desktop:browser-acquire, dsh-desktop:browser-open-requested,
dsh-platform:bootstrap, …) run main ↔ renderer and are not on the Host’s protocol at all.
Do not confuse this with packages/browser-use: the Stagehand and MCP browser tools a model
calls (stagehand_navigate, mcp__playwright-mcp__*, …) go through ctx.tools, so they produce
ordinary tool/call / tool/result events and are mapped like any other tool. That is a
different feature driving a different browser.
This is the one Desktop-adjacent event that a Host plugin can see, and OCSF has Authentication (3002) for exactly it. It is not implemented in this release, and the reason is scope rather than feasibility — see ADR §51. The evidence a later change starts from:
PlatformAccount in
packages/credentials/deepseek-account-platform/src/index.ts mints the PKCE verifier and state,
registers a temporary /oauth/callback route on the Host’s own web server, exchanges the code,
and commits the grant under the credential key deepseek-account-platform:default.deepseek-account/signed-in event. The module declares three:
deepseek-account/signed-out, deepseek-account/session-expired,
deepseek-account/model-sign-in-required. A successful sign-in is observable only indirectly,
through authorization/settled and credentials/record-updated on that key.ctx.emit('deepseek-account/signed-out') fires from the user-initiated
signOut() and from the credential-expiry path.deepseekAccount gets getState() and a watch()
async iterable.The asymmetry is worth stating plainly, because it is what a mapping would have to live with: the sign-out is a first-class event and the sign-in is an inference from a generic credential write.