Bundle
dsh-ocsf-forwarder
Read-side SIEM forwarder for DeepSeek Harness: normalises session activity to OCSF and ships it
- Source
- CharlotteN7
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 2 days ago
Readme
# dsh-ocsf-forwarder
A read-side SIEM forwarder for [DeepSeek Harness](https://github.com/deepseek-ai). It observes the
session event firehose, normalises every event to **OCSF 1.9.0** with the native `ai_operation`
profile, and writes newline-delimited OCSF JSON to a local append-only spool โ optionally shipping
it to **Splunk HTTP Event Collector** or an **OTLP/HTTP** collector.
๐ **[Full documentation](https://charlotten7.github.io/dsh-ocsf-forwarder/)** โ including the
complete event โ OCSF mapping table for all 51 session event types.
## What it does
- Subscribes to `session/event`, `session/created` and `session/disposed`, and sweeps
`ctx.sessions.list()` at mount.
- Correlates `tool/call` โ `tool/result` and `approval/asked` โ `approval/decided`, emitting
**approval decision latency** โ the approval-fatigue signal.
- Classifies tool calls by what they do: shell and code execution โ Process Activity (1007),
file tools โ File System Activity (1001), web tools โ HTTP Activity (4002), approvals and
sandbox changes โ Authorize Session (3003), everything else โ API Activity (6003).
- Names the MCP server behind every `mcp__<server>__<tool>` call.
- Emits a **high-severity record when a tool hands the task to an external harness**, stating in
the record that telemetry coverage ends at that boundary.
- Records the **session log itself leaving the host**: with `session-log-deepseek` enabled the
harness attaches the log's own event envelopes to every model request, and each accepted upload
becomes a record naming the destination service and counting the events that went with it.
- Reads the **team** events by name โ who joined, which agent messaged which other agent and
whether the message wakes it, and the write scopes a shared task carries.
- Emits a periodic **heartbeat** carrying counters, live session count and delivery cursor, so a
host that goes quiet is distinguishable from one that is idle. A spool that has stopped writing
reports itself there at `severity_id: 5`, with the count of what it dropped.
- Chains every spooled record with the OCSF **`record_integrity`** profile, and ships
`dsh-ocsf-verify` to check the chain.
- Replays a resumed or forked session's constructor seed, which never reaches the live firehose.
- Keeps raw values out of the SOC lane: keyed digests, value classifications and lengths instead.
## What it does not do
- **It never writes to the session log.** `Session.append()` cannot set the envelope's `ignorable`
flag, so a plugin-owned event type makes the next resume throw `SessionFormatUnsupportedError`
and refuse the entire session. All durable output goes to our own sink, and the plugin registers
no waterfall listener, so it cannot change a tool call, an approval decision or a model request.
- **It is not a containment boundary.** It runs in the agent's process at the agent's uid; an agent
that can run `bash` can delete or rewrite the spool โ and can recompute the hash chain over what
it wrote, because the algorithm is published. What it buys you is that records leave the host
promptly and that a gap is *visible* โ the chain's entry numbering, `metadata.sequence` holes per
session, and a shipper cursor that stopped advancing.
- It ships no detection content, no alerting and no secret detectors.
[The full scope statement โ](https://charlotten7.github.io/dsh-ocsf-forwarder/)
## Install
The profile must already compose a runnable agent โ a profile carrying only
`@deepseek-ai/dsh-base` has no agent loop and this plugin would observe nothing:
```sh
dsh plugin --profile <name> add @deepseek-ai/dsh-headless@0.1.1-rc.2
dsh plugin --profile <name> add dsh-ocsf-forwarder
dsh --profile <name> --dump-config # verify the row is mounted
```
Pin `@deepseek-ai/dsh-headless` explicitly โ the `@deepseek-ai/dsh-*` libraries' npm `latest` tag
still points at `0.0.1-rc.1`. Install from the registry or a packed tarball, **not** from a git
spec: `lib/` is a build output git does not carry.
Runs on dsh `0.1.0-rc.6` through the `0.1.2` line; CI runs the end-to-end suite against every line
in that range.
[Install in full โ](https://charlotten7.github.io/dsh-ocsf-forwarder/install.html)
## Configure
```yaml
- id: dsh-ocsf-forwarder
config:
spoolPath: /var/log/dsh/ocsf.jsonl # absolute; created 0640
splunk:
endpoint: https://splunk.example:8088
token: { source: env, variable: SPLUNK_HEC_TOKEN }
privacy:
hmacKey: { source: env, variable: DSH_OCSF_KEY }
```
Every numeric key that is resolved must be a positive finite number, and those counting records or
files must be whole numbers โ `statsIntervalMs` is the one exception, where `0` means "only at
unload". A value outside those ranges fails at load, because the alternative is worse than a
refused mount: `batchSize: 0` makes the shipper loop without ever advancing its cursor. A shipper
block with no `endpoint` configures no shipper and is not resolved, so nothing in it is checked.
The default privacy posture keeps raw values out of the SOC lane โ argument values and command
lines are digested, URLs reduced to their host. A second **restricted lane** carries verbatim
payloads and must be explicitly acknowledged before it will open.
[Every configuration key โ](https://charlotten7.github.io/dsh-ocsf-forwarder/configuration.html) ยท
[Record format and the mapping table โ](https://charlotten7.github.io/dsh-ocsf-forwarder/mapping.html)
## Shipping to a SIEM
Splunk HEC and OTLP/HTTP are both supported; configure exactly one per spool. Delivery is
cursor-based off the spool, so a collector outage costs nothing but disk, and the spool refuses to
delete an un-drained generation rather than silently discarding unacknowledged evidence.
[Splunk and OTLP setup โ](https://charlotten7.github.io/dsh-ocsf-forwarder/shipping.html) ยท
[Delivery and failure modes โ](https://charlotten7.github.io/dsh-ocsf-forwarder/operations.html)
## Tamper-evidence
Every record carries an OCSF 1.9.0 `record_integrity` attestation: the SHA-256 fingerprint of the
record, plus the uid and fingerprint of the record before it. Editing, reordering, or deleting a
record from the middle of a spool breaks the chain at that record and at the one after it.
```sh
dsh-ocsf-verify /var/log/dsh/ocsf.jsonl # 0 intact, 1 broken, 2 unreadable
dsh-ocsf-verify --anchor shipped.jsonl /var/log/dsh/ocsf.jsonl
```
Deleting from the **end** breaks nothing โ the shorter chain still verifies โ so that check needs a
reference the writer cannot reach. Every shipped record is one: `--anchor` takes records back from
the SIEM and reports a spool that stops short of them. Without anchors the report says `no anchor`
rather than implying it checked.
The fingerprints are **unkeyed**, so anyone can recompute them โ which is the point, and which also
means the chain does not resist the agent it observes. A spool replaced wholesale under a fresh
`chain_uid` contradicts no anchor; it fails to overlap one, and since 0.8.0 an anchor naming a chain
that is absent is a finding (`--no-strict-anchors` makes it a count again, for a host whose shipper
legitimately drained that chain). What remains open is direction: anchors bound a chain from below
and never from above, so records *added* past the last one delivered are not caught.
[The canonicalisation, the threat model, and the cost โ](https://charlotten7.github.io/dsh-ocsf-forwarder/integrity.html)
On Linux the complementary move is `chattr +a` on the spool, which makes truncation and rewriting
fail rather than merely detectable. The plugin tolerates the `chmod` that attribute refuses โ but
it also refuses the rename, so **rotation stops permanently** and the file's size becomes a manual
job. Harden the live spool file only; hardening its directory breaks the mount.
[What `chattr +a` buys, costs, and breaks โ](https://charlotten7.github.io/dsh-ocsf-forwarder/hardening.html)
## Running it with `dsh-netguard`
Both packages emit OCSF into one index and share the `correlation_uid` scheme
`<session>:<callId>`, so a Network Activity record from netguard joins to this package's Process
Activity record for the same tool call โ answering *which tool call opened this connection*.
`metadata.uid` is deliberately **not** shared: this package's key is `<session>:<seq>` over the
session log's event sequence, and netguard namespaces its own as `<session>:netguard:<seq>` so a
SIEM deduplicating on that field cannot mistake one package's records for the other's.
## Development
```sh
nvm use 22 # Node ^22.19.0 || >=24, and pnpm 11
pnpm install
pnpm run typecheck
pnpm run test:coverage
pnpm run test:e2e # boots a real dsh against a mock model; no API key
```
Design decisions and their rationale live in [ADR.md](ADR.md). Security policy is in
[SECURITY.md](SECURITY.md).
## License
MIT
Install
dsh plugin --profile web add github:CharlotteN7/dsh-ocsf-forwarder
Profile: web
With the hub plugin installed, ask your agent to install it by name โ it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-ocsf-forwarder from the hub
- This package builds from source on install. pnpm will ask you to allow its build script โ that is permission to run the packageโs code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.