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. The values it does carry verbatim, because they are the security signal rather than its content, are: file paths; tool names; executable names, taken after any leading NAME=VALUE assignments are stripped; hostnames; URL scheme and host; and bounded enumerations such as an approval outcome, a turn end reason, a sandbox mode, a provider error code, and a hook decision drawn from the hook protocol’s own approve/allow/block/deny/ask. 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, hook findings — reaches the SOC lane only as HMAC-SHA256(key, value) plus a character count.

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>.
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.

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.