dsh-ocsf-forwarder

Delegation, identity, lanes

← dsh-ocsf-forwarder docs

Delegation and the coverage boundary

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.

Fleet identity

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.

Two lanes

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.

Correlation

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.

Delivery and failure modes

The spool is written synchronously before anything is queued for shipping, so:

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:

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.

What a Desktop install does that this plugin cannot see

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.

Update installs — not observable, and this is the consequential one

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:

The embedded browser — not observable

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.

Account sign-in and sign-out — observable, and not mapped here

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:

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.