dsh-ocsf-forwarder

Record format and mapping

← dsh-ocsf-forwarder docs

A record

{
  "class_uid": 1007, "category_uid": 1, "type_uid": 100701, "activity_id": 1,
  "severity_id": 1, "status_id": 0, "message": "tool call bash",
  "time": 1786881335332,
  "metadata": {
    "product": { "name": "dsh-ocsf-forwarder", "vendor_name": "dsh-security-plugins", "version": "0.5.1" },
    "version": "1.9.0", "profiles": ["ai_operation", "cloud", "osint", "record_integrity"],
    "log_provider": "deepseek-harness", "log_name": "session",
    "uid": "01JB0SESSION:7", "correlation_uid": "01JB0SESSION:call_9f2",
    "sequence": 7, "logged_time": 1786823920155, "original_time": "1786881335332",
    "tenant_uid": "platform-eng", "labels": ["prod"], "tags": [{ "name": "owner", "value": "soc" }]
  },
  "cloud": { "provider": "Other" }, "osint": [],
  "ai_agent": { "name": "deepseek-harness", "type_id": 1, "instance_uid": "01JB0SESSION" },
  "actor": { "process": { "pid": 4242, "name": "dsh" }, "user": { "name": "agent", "type_id": 1 } },
  "device": {
    "type_id": 0, "hostname": "app-01.example.test",
    "uid": "0c6f1f1a-9c1e-4f0a-9a63-6a1a6c5f1b2e",
    "os": { "name": "linux", "type_id": 0 }
  },
  "process": {
    "name": "curl", "uid": "01JB0SESSION:call_9f2",
    "cmd_line": "hmac-sha256:d7df26fddfd3af030679709c66165379"
  },
  "attestation_list": [{
    "uid": "7a1f0c5e-6b2d-4c8a-9f31-2d5b8e0a1c74:41",
    "chain_uid": "7a1f0c5e-6b2d-4c8a-9f31-2d5b8e0a1c74",
    "prev_event": { "uid": "01JB0SESSION:6", "type_uid": 600302, "fingerprint": { "value": "9d1c…", "algorithm_id": 3, "encoding_id": 1 } },
    "fingerprint": { "value": "4b77…", "algorithm_id": 3, "encoding_id": 1 }
  }],
  "observables": [{ "name": "process.cmd_line", "type_id": 8, "value": "hmac-sha256:d7df26…" }],
  "unmapped": {
    "dsh": {
      "v": 1, "session_id": "01JB0SESSION", "event_type": "tool/call", "seq": 7,
      "replayed": false, "cwd": "/srv/app", "tool": "bash", "tool_class": "process-launch",
      "arguments": [{ "key": "command", "class": "command", "length": 53, "digest": "hmac-sha256:d7df26…" }],
      "turn": 1, "step": 0, "call_id": "call_9f2", "phase": "invoke"
    }
  }
}

The model called bash with curl -s https://api.example.test/v1/x?token=sk-live-1. The record says a process was launched, that its executable was curl, how long the command was, and gives a digest that joins it to every other occurrence of the same command — and discloses neither the URL nor the token.

process.uid is the correlation uid, repeated on the record of this same process settling. The OCSF process object constrains at_least_one: [pid, uid, cpid], and no session event names the child’s operating-system pid, so the identifier is the one the schema defines for a producer to assign — “a unique identifier for this process assigned by the producer (tool)”. No pid is invented to fill the slot.

attestation_list is the OCSF record_integrity profile: the record’s own SHA-256 fingerprint and the fingerprint of the record before it in the spool. Tamper-evidence gives the canonicalisation a reader recomputes it from, and what it is and is not evidence of.

Every OCSF class is additionalProperties: false, so the extension attributes live under unmapped, which is the base event’s own slot for exactly this. cloud and osint are stubs — a host agent has no cloud deployment and no open-source intelligence — and metadata.profiles declares both, because an attribute whose profile is undeclared fails validation just as an undefined one does.

The same rule decides where a class-owned attribute may appear. OCSF defines src_endpoint on API Activity, Authorize Session and HTTP Activity, and this plugin emits it only on API Activity (6003), which is the one class that requires it: the caller of an approval or a web_fetch on a host agent is the host the record already names in device. The top-level user object is only on Authorize Session, the one class this plugin emits that defines it; the account behind every other record is actor.user, which every class does define.

The conformance suite checks each class against its own OCSF definition rather than the union of all seven, because the union accepts exactly the stampings additionalProperties: false rejects — and it checks each nested object the same way, against the definition of that object, constraints included. Walking only the top level let process.exit_code ship, an attribute the process object does not define and class 1007 defines at the top level of the record.

Event mapping

All 59 session event types this build knows (packages/core/session/src/known-event-types.ts in the harness, catalogued in its docs/persistence-catalog.md), in the harness’s own order. The count is the vocabulary of the newest @deepseek-ai/dsh-session the peer range admits, which is what tests/unit/mapping-doc.spec.ts reads it from.

An older harness in the range knows fewer, and not always a prefix: 0.1.1-rc.2 knows 48, 0.1.2-rc.1 51, 0.1.5-rc.3 56 and 0.1.7-rc.2 59, and the 0.1.5 line both added five types and renamed three. assistant/chunk became assistant/attempt — a different event, not a rename of the same one — and tool/code-dispatch{,-start} became tool/ptc-dispatch{,-start} with their payloads unchanged, retired by the session format’s v3-to-v4 migration. The dispatcher still routes the two retired dispatch names, because the peer range admits builds that emit them; they have no row here, because this table is the newest admitted vocabulary. type_uid = class_uid * 100 + activity_id.

Tool events are classified by tool name first — see Tool classification — which is why they list several classes.

# Session event OCSF class (class_uid) activity_id Status / notes
1 agent-preset/selected Application Lifecycle (6002) 8 Update Composition change: the preset later turns run under, in unmapped.dsh.agent_preset.
2 agent/inbox/spliced API Activity (6003) 3 Update Messages inserted into the agent’s pending list — a steering surface. unmapped.dsh.inbox_target, .splice_start, .removed_count, .inserted_count, and one digest of the inserted text.
3 approval/asked Authorize Session (3003) 1 Assign Privileges status_id: 0 (pending). privileges: [tool:<name>], unmapped.dsh.approval_id. The prompt reason quotes the command being approved, so it is digested.
4 approval/decided Authorize Session (3003) 1 Assign Privileges status_id: 1 for allowed-once, else 2. duration + unmapped.dsh.approval_latency_ms from the paired ask.
5 approval/policy Authorize Session (3003) 1 Assign Privileges Session policy switch (ask/never).
6 assistant/attempt API Activity (6003) 2 Read One model attempt that committed no surface message — a failure, a retry, a cancellation, or a stream error. status_id: 2, status_detail: no-surface-message, paired to the step by <session>:<turn>:<step>. The embedded stream is the model’s own partial output and is never read: unmapped.dsh.stream_records counts it, which separates an attempt that produced nothing from one cut off part-way. Not the retired assistant/chunk, which was per-token and dropped.
7 assistant/message API Activity (6003) 2 Read Model completion. message_context.ai_role_id: 2, token counts from usage. Text digested in the SOC lane.
8 command/done API Activity (6003) 3 Update status_id and status_detail from kind; duration from the paired command/run, which also supplies the command name. The handler’s outcome text is digested.
9 command/run API Activity (6003) 1 Create Slash command; api.operation = command:<name>, status_id: 0, args digested. Correlates to command/done by commandId, as <session>:command:<id>.
10 compaction/end API Activity (6003) 3 Update status_id: 2 when error present; duration is how long the compaction held the lock, paired to compaction/start on compactionId.
11 compaction/prune API Activity (6003) 4 Delete History removal — kept deliberately: shadowed seq range and token count are a tamper-relevant signal. The payload carries no compactionId, so the record is correlated by the range it replaced.
12 compaction/start API Activity (6003) 3 Update Holds the compaction lock.
13 compaction/summary API Activity (6003) 3 Update Model-written replacement for history. Summary text digested in the SOC lane. ai_model is the summarizer the compaction backend chose, from the event’s own provider/model; ai_agent.ai_model stays the session route.
14 deliverables/presented File System Activity (1001) 2 Read The files a turn declared as its deliverables, with their paths verbatim on file.path’s rule. The first path fills the class-required file; every path is in unmapped.dsh.presented_paths and contributes its own file.path observable. The model-written description is not read. Correlates to the present call as <session>:<callId>. A payload declaring no readable path produces no record: File System Activity has none without a file.
15 developer/message Application Lifecycle (6002) 8 Update Incremental session changes on the model surface — the harness documents these as tool additions and removals, so a mid-session admission widens what one prompt can reach and it is graded severity_id: 2. message_context.ai_role_id: 99 with ai_role: developer, because OCSF 1.9.0 defines no developer member. Text digested; unmapped.dsh.header_seq names the request/header that defines every added tool.
16 feedback/message-delete — — Dropped by default: it names only the message whose rating went away, and says nothing without the put it removes. Re-enabled through includeEventTypes, it takes the generic fallback below.
17 feedback/message-put — — Dropped by default: a human rating carrying the rater’s own free-text note — the trade feedback/record is already dropped on. Re-enabled, it takes the generic fallback below.
18 feedback/record — — Dropped by default: a free-text human remark about the session — no security value, high privacy cost. Re-enabled through includeEventTypes it takes the generic fallback below: metadata only, no field of the payload.
19 goal/change API Activity (6003) 3 Update api.operation = goal:<operation>. Goal id, revision, phase and block-reason code verbatim; the objective is digested. A clear carries its tombstone’s id and revision.
20 hook/invoked Process Activity (1007) 1 Launch A hook is a subprocess. process.name = hook point, process.uid = <session>:hook:<handlerId>, unmapped.dsh.handler_id, unmapped.dsh.dialect.
21 hook/result Process Activity (1007) 2 Terminate status_id from decision, reduced to the protocol’s approve/allow/block/deny/ask with anything else recorded as other plus a digest; exit_code, which class 1007 defines at the top level and the process object does not define at all; duration = durationMs.
22 image/offload API Activity (6003) 4 Delete Input images permanently omitted from every later model request. Model-visible content removed while the log keeps it, which is compaction/prune’s shape, so it takes the same activity. unmapped.dsh.offload_seqs lists the surface nodes edited, with .offload_target_count and .offload_image_count; no image is read.
23 llm/retry API Activity (6003) 2 Read status_id: 2 (the attempt that failed), status_detail = failure code. Provider, retry number, cap, and delay in unmapped.dsh; the provider’s failure message is digested. Correlates to llm/retry-started as <session>:retry:<retryId>.
24 llm/retry-started API Activity (6003) 2 Read status_id: 0; the wait completed and the next attempt starts. Paired to llm/retry on retryId.
25 model/selection Application Lifecycle (6002) 8 Update The validated provider and model committed for the next request, with ai_model on the record. It does not move the session route: request/context records what a request actually used, and folding a selection in would attribute the records between the two to a model that has served nothing.
26 permission/preset Authorize Session (3003) 1 Assign Privileges privileges: [preset:<name>].
27 plan/mode API Activity (6003) 3 Update Plan mode on/off, in unmapped.dsh.plan_mode_active.
28 request/context Application Lifecycle (6002) 8 Update Provider/model route change. Folds into ai_model for every later record in the session.
29 request/header Application Lifecycle (6002) 8 Update Capability-set change. Tool names and count, model config, and a digest of the system prompt. Never the prompt text or tool schemas in the SOC lane.
30 sandbox/mode Authorize Session (3003) 1 Assign Privileges Confinement change; privileges: [sandbox:<mode>]. High value.
31 schedule/change Scheduled Job Activity (1006) 1 Create / 2 Update / 3 Delete / 99 Other From operation (create / dispatch / delete); job: { name, uid } from schedule.id on a create, id otherwise.
32 session-log-deepseek/delivery-accepted API Activity (6003) 1 Create The session log itself left the host. @deepseek-ai/dsh-session-log-deepseek uploads every canonical event since the last watermark with a model request; this event records the endpoint accepting it. api.service.name = 'deepseek-llm-api', plus delivered_through_seq, and delivered_after_seq / delivered_event_count once a preceding watermark has been observed — first_observed_delivery when none has. A marker naming another session was inherited through a fork seed and is graded severity_id: 1, because nothing left on this session’s account.
33 session/end-seed — — Dropped. Internal construction marker; its meaning is carried by the seed-replay boundary record. Re-enabled, it takes the generic fallback below.
34 session/title — — Dropped by default: a model-written summary of the user’s prompt — user content by another name. Re-enabled, it takes the generic fallback below.
35 session/title-llm-request — — Dropped by default: carries prompt text. Re-enabled, it takes the generic fallback below.
36 step/end API Activity (6003) 2 Read status_id: 1, duration from the paired step/start.
37 step/start API Activity (6003) 2 Read Opens one model call plus its tool executions; status_id: 0.
38 subagent/catalog Application Lifecycle (6002) 3 Start The parent’s own record of a direct child, and the third event type that names a child session by id, so it builds the same delegation link team/member and tool-workflow/agent-start do. subagent_mode and catalog_version verbatim; label is the delegating call’s model-written description and is digested. A payload naming no child produces no record.
39 subagent/descriptor Application Lifecycle (6002) 3 Start This session is the child. mode and provider only: the payload names no session id, so no delegation is invented.
40 subagent/model-selection-policy Authorize Session (3003) 1 Assign Privileges The exact routes this session may hand a child agent, as privileges: [subagent-model:<provider>/<model>]. A policy naming no complete route produces no record: OCSF constrains the class at_least_one: [privileges, groups, iam_roles].
41 system/message Application Lifecycle (6002) 8 Update The rendered system prompt as it stands on the model-visible surface. The same category of fact as request/header’s prompt digest, under the same redaction: system_prompt_digest + system_prompt_length, never the text. system_prompt_empty separates a cleared prompt from a changed one, and message_id identifies the surface node a later replacement rewrites.
42 team/member Application Lifecycle (6002) 3 Start An agent joined this session’s team, and the second event type that names a child session by id: delegation = { uid: member.id, parent_uid: <this session> }. Provider, context (fresh/fork) and provisioning phase verbatim; the member’s brief is digested.
43 team/message/delivered API Activity (6003) 3 Update Settles the message on messageId; duration + unmapped.dsh.delivery_latency_ms from the paired queueing.
44 team/message/queued API Activity (6003) 1 Create One agent addressed another. Sender session, target session and delivery (quiet/wakeup, which decides whether the target acts on it now); message_context.ai_role_id: 4. The text itself is digested. Correlates to team/message/delivered as <session>:team-message:<id>.
45 team/task API Activity (6003) 3 Update / 4 Delete Task snapshot: owner session, revision, status, blocked-by count, and write_scopes verbatim — a path pattern is the security signal, on the same reasoning as file.path. Subject and description are digested. No 1 Create: the payload gives this plugin no way to tell a task’s first revision from a later one.
46 todo/write — — Dropped by default: UI state made of user and model task text. Re-enabled, it takes the generic fallback below — metadata only, and not an item count.
47 tool-workflow/agent-end Application Lifecycle (6002) 4 Stop status_id from outcome.
48 tool-workflow/agent-start Application Lifecycle (6002) 3 Start Member agent, with delegation = { uid: childId, parent_uid: <this session> }. One of the two event types that name a child by id; team/member is the other.
49 tool-workflow/run-end Application Lifecycle (6002) 4 Stop status_id from stopReason.
50 tool-workflow/run-start Application Lifecycle (6002) 3 Start  
51 tool/call by tool name: 1007 / 1001 / 4002 / 6003 by tool name status_id: 0 (in flight). metadata.correlation_uid = <session>:<callId>.
52 tool/ptc-dispatch by inner tool name by tool name Sub-call settlement; status_id from isError. Renamed from tool/code-dispatch by the same migration, and routed the same way.
53 tool/ptc-dispatch-start by inner tool name by tool name Sub-call inside run_code; unmapped.dsh.parent_call_id and .root_call_id. Named tool/code-dispatch-start before the session format’s v3-to-v4 migration; the dispatcher routes both names to one mapper, because the peer range admits builds on either side of the rename and the payload fields are unchanged.
54 tool/result by tool name (same class as its call) by tool name status_id from content[0].isError; start_time/end_time/duration from the correlated call.
55 turn/end API Activity (6003) 1 Create TurnEndReason is the outcome discriminant; duration from the paired turn/start. A provider failure contributes its code and a digest of its message.
56 turn/start API Activity (6003) 1 Create The unit of agent work; status_id: 0.
57 user/message API Activity (6003) 1 Create message_context.ai_role_id: 1; unmapped.dsh.message_source distinguishes a human prompt from an injected context. Text digested in the SOC lane.
58 web/deepseek-search-llm-request API Activity (6003) 2 Read Auxiliary search request: api.service.name = 'deepseek-search', api.version = apiVersion, and ai_model = the search provider’s own model, which is not the session route. The query text is digested.
59 workspace/changes API Activity (6003) 99 Other The generic fallback, and that is the whole payload. The event says a completed turn’s changed files were summarized and carries nothing but turn; the summary stays on the Host, served by ctx.workspaceChanges.summary while the session lives, so no file name is in the log. Reading it would mean injecting a live Host service, which this package does not do.

The generic fallback is API Activity 6003 / activity 99 Other, api.operation = the event type, and unmapped.dsh.event plus whatever turn and step the payload happened to carry — no other field of the payload, and nothing read from its content. Unknown (out-of-repo, plugin-merged) event types take it, and so do the seven dropped types, on the deployments that re-enable them — assistant/chunk is an eighth, dropped for the older harnesses in the peer range that still emit it and absent from the table above for the same reason. SessionEventMap is merge-extensible, so the mapper’s switch ends in a documented default, never assertNever.

Tool classification

tool/call, tool/result, and the two tool/ptc-dispatch* events are classified by tool name:

Tools Class activity_id Extra objects
bash, pwsh, run_code, terminal_open, terminal_send Process Activity (1007) 1 Launch process.cmd_line (per commandLine policy), process.name, process.uid = the correlation uid, actor.process = the harness process
terminal_close, terminal_signal, job_kill Process Activity (1007) 2 Terminate process.uid, as above
read, read_image, glob, grep File System Activity (1001) 2 Read file: { name, path, type_id } from file_path/path. A grep pattern is not a path and never fills one.
write File System Activity (1001) 1 Create  
edit, str_replace_editor File System Activity (1001) 3 Update  
web_fetch, web_search HTTP Activity (4002) 3 Get http_request: { http_method, url } under the url policy; satisfies the class’s at_least_one: [http_request, http_response]
load_workspace_dependencies Application Lifecycle (6002) 1 Install application: { name: 'dsh-primary-runtime' }, overriding the harness application every other 6002 record carries. See Installing a runtime.
everything else API Activity (6003) 2 Read api.operation = tool:<name>

The table is a Record<string, ToolClass> constant plus a documented default. It is not configurable: misclassifying bash as an API call on a deployment’s say-so would break every process-based detection downstream. Deployments extend coverage for their own tools through toolClasses (additive only — a config entry may add an unknown tool name, never reclassify a known one). runtime-install and delegation-external are deliberately outside the configurable set: each one changes what the mapper reads, not only how a call is labelled.

Installing a runtime

Desktop ships its own Python, Node and pnpm, and load_workspace_dependencies copies that payload onto the host under $DSH_HOME/dsh-runtimes/dsh-primary-runtime on first use. The copy happens inside the tool (packages/skill/tool-workspace-dependencies/src/index.ts, installPrimaryRuntime) and appends no session event of its own, so the ordinary tool/call / tool/result pair is the only trace an installed interpreter leaves in the log. Classified as an API read it said nothing at all: the tool declares parameters: {}, so the call carries no argument to read.

The install report is therefore taken from the result. The tool declares an output schema and renders its value as one JSON text block, so the tool/result record carries, in unmapped.dsh: runtime_python_path, runtime_node_path and runtime_pnpm_path where the payload ships them, runtime_python_packages_path and runtime_node_packages_path, and runtime_python_distributions — the bundled distribution name → version map, plus its count. The paths are emitted verbatim on file.path’s rule and each executable path also contributes a file.path observable. The distribution map is a software inventory a SOC joins against advisories; it excludes anything a user installed later, which is the tool’s own documented limit.

A failed call’s result text is a diagnostic rather than a report, so nothing is read from it and the record is the application plus status_id: 2. Text that is not a JSON object yields no attributes rather than a guess.

Observables

A tool call contributes at most one observable — a runtime install contributes one per installed executable, and deliverables/presented one per declared file — carrying the same value the record’s own object carries and typed for what that value actually is:

name type_id Value
process.cmd_line 8 Hash, or 13 Command Line under commandLine: full The command under the commandLine policy — a keyed digest by default. The type follows the policy, so a digest is never presented to a SIEM as a command line.
file.path 45 File Path The file_path/path argument itself, emitted verbatim: a path is the security signal, not a secret. Never the argument record it was read from. Also each interpreter path a runtime install reported, and each path deliverables/presented declared.
http_request.url.url_string 6 URL The URL under the url policy — scheme and host by default, so the query string that carries reset and API tokens is gone before the record exists.

A call whose arguments name no subject — a grep with only a pattern, a web_fetch whose URL does not parse, any API-class tool — contributes none.

observables[] is the one place a redacted value and its raw source sit one line apart in the mapper, so it is covered by an invariant rather than by a test per call site: a sentinel secret is placed in every text-bearing session-event payload field, a full forwarder run is driven over them, and the serialized SOC-lane records are searched for all of them at once.