One OCSF Network Activity (4001) record per decision, one JSON object per line in
spoolPath, with the security_control profile declared.
| Outcome | activity_id |
action_id |
disposition_id |
severity_id |
|---|---|---|---|---|
| allowed | 1 Open | 1 Allowed | 1 Allowed | 1 Informational |
denied, mode: enforce |
5 Refuse | 2 Denied | 2 Blocked | 3 Medium |
denied, mode: audit |
1 Open | 1 Allowed | 17 Logged | 3 Medium |
The audit row is worth saying out loud: in audit mode the connection was made, so the record
says Open and Allowed. disposition_id: 17 (Logged) and unmapped.dsh.enforced: false are what
tell a SOC that the policy would have refused it. A record claiming Refuse for a request that
completed would be a false negative in the only direction that matters.
A first-seen host for this installation, any denial audit mode let through, and a session that
has issued alerts.distinctUrlsPerHost or more distinct URLs against one host all set
is_alert: true. The last of those is a signal a host allowlist cannot produce on its own; the
count rides on every fetch record as distinct_urls, and what it does and does not catch is in
the configuration reference.
Nothing is ever appended to the session log. Session.append() offers no way to set the
envelope’s ignorable flag, so an out-of-repo event type is written without it and the user’s
next resume throws SessionFormatUnsupportedError and refuses the whole session. This package
is read-side with respect to the log, and an E2E assertion checks that no row in it carries one
of our types.
Each record therefore carries its own identity. metadata.correlation_uid = <session>:<callId>
is dsh-ocsf-forwarder’s key unchanged, and it is the reason to run the two packages together:
the forwarder already emits a record for every tool call, so the same key on a 4001 record answers
which tool call opened this connection — at tool-call granularity, which no other harness can do.
Join against the right class. The forwarder classes each tool by what it does, and web_fetch
and web_search are HTTP Activity (4002), while bash, run_code and the other process
tools are Process Activity (1007). So a web_fetch record from here joins a 4002, and a
command-arm record joins a 1007 — one join per class, not one join for both.
metadata.uid is deliberately not the forwarder’s key. It is <session>:netguard:<seq>, where
seq counts this package’s decisions in this process; a decision with no session behind it uses
dsh-netguard in the first slot. The forwarder’s is <session>:<seq> over the session log’s own
event sequence. Both start near 1 in the same session, so an un-namespaced key would make
session-88:4 the identity of two unrelated records, and a SIEM following both READMEs —
“deduplicate on metadata.uid”, “records from both packages can sit in one index” — would drop the
netguard one as a duplicate. The namespace sits in this package rather than in the forwarder’s key
because the forwarder is the established emitter with records already in indexes; see ADR.md §18.
When the join hits. A command-arm record is minted inside the guard, which already holds the
call id, so it always carries one. The fetch provider is handed { url } and nothing else, so its
call id comes from the tool guard, which notes url → identity under the URL the model wrote for
the provider to look up moments later, and turn/step come from the tool/call session event. A redirect
target is a URL no tool call ever named, so every hop resolves the URL the chain started from
rather than its own and one chain’s records all carry one correlation_uid. Both maps are
bounded and lossy on purpose — an unbounded map costs the agent its memory. A record the join
missed carries
dst_endpoint, the verdict and the digests, but no correlation_uid and no turn; it is a
connection without a named cause rather than a wrong one.
Verbatim: a validated hostname, port, resolved IP address, verdict, matched rule id, tool
name. Digested: the full URL, any search query, and any string that was supposed to be a
hostname and is not — as HMAC-SHA256(key, value) truncated to 128 bits, with the length beside
it. The digest is stable, so a SIEM can still join on it; nobody reading the spool learns the
value. This mirrors dsh-ocsf-forwarder’s lane rule exactly, so records from both packages can
sit in one index without one of them being the leak — and metadata.uid is namespaced so that
sharing an index does not make one package’s records look like duplicates of the other’s.
“Validated” is the whole point of the word. dst_endpoint.hostname, observables[].value and
message only ever carry a plain host spelling ([a-z0-9.:_-]) or one of these markers, and
the value a marker stands in for rides as a digest in the extension attributes:
| Marker | Stands in for |
|---|---|
(query) |
a decision about a search query rather than about one target |
(command) |
a command past shell.maxCommandLength, refused before its hosts were read |
(non-string-argument) |
a url, query or command argument that was not a string |
(unparsed-url) |
text that is not a URL with a host this package can decide |
(unparsed-source) |
a vendor search result whose source URL does not parse |
(unrecordable-host) |
a hostname carrying characters a verbatim field may not hold |
That last one exists because WHATWG URL keeps ', ", a backtick, $, ;, , and { in a
hostname. report --suggest applies the same rule again on the way out: it writes a host into
YAML only when it matches [a-z0-9._-]+, so nothing a vendor or a model chose can add a line to
the allow list you paste into cordis.yml.
{
"class_uid": 4001, "category_uid": 4, "type_uid": 400105,
"activity_id": 5, "action_id": 2, "disposition_id": 2, "severity_id": 3, "status_id": 2,
"is_alert": false, "time": 1755300000000,
"message": "netguard refused paste.example: blocked-by-denylist",
"metadata": {
"product": { "name": "dsh-netguard", "vendor_name": "dsh-security-plugins", "version": "0.3.0" },
"version": "1.9.0", "profiles": ["security_control", "host"],
"log_provider": "deepseek-harness", "log_name": "netguard",
"uid": "session-88:netguard:4", "correlation_uid": "session-88:call-2", "sequence": 4,
"logged_time": 1755300000000
},
"dst_endpoint": { "hostname": "paste.example", "port": 443, "svc_name": "https" },
"connection_info": { "direction_id": 2, "protocol_name": "tcp" },
"firewall_rule": { "uid": "deny:paste.example", "name": "blocked-by-denylist" },
"observables": [{ "name": "dst_endpoint.hostname", "type_id": 1, "value": "paste.example" }],
"unmapped": { "dsh": {
"v": 1, "kind": "fetch", "mode": "enforce", "verdict": "denied", "enforced": true,
"reason": "blocked-by-denylist", "rule": "deny:paste.example", "tool": "web_fetch",
"session_id": "session-88", "call_id": "call-2", "root_call_id": "call-2",
"turn": 1, "step": 0, "decision_id": "netguard-…", "first_seen_host": false,
"url_digest": "hmac-sha256:9f2a…", "url_length": 63, "has_query": true, "hop": 0,
"distinct_urls": 4
} }
}
device and src_endpoint are left out of that listing only for length; every record carries
both. firewall_rule appears only when a pattern decided the request, and its name is the
same reason the attributes carry — an allowlist denial names no rule, so it has no
firewall_rule at all.
A record whose allow entry was a wide wildcard — one over a namespace anyone can register a name under — carries the namespace beside the rule:
"rule": "allow:*.s3.amazonaws.com", "wide_wildcard": "s3.amazonaws.com"
It says that this request was permitted by a grant that also covers every name a stranger can
create in that namespace. It appears whether or not the deployment named the namespace in
allowWideWildcards, because declaring a namespace does not narrow it, and it changes no other
field — the verdict a wide entry produced is the verdict it produced.
dsh-netguard report counts them; see configuration.
A command-arm record replaces the URL attributes with the command’s own, and names which argument it read:
"unmapped": { "dsh": {
"v": 1, "kind": "guard", "mode": "enforce", "verdict": "denied", "enforced": true,
"reason": "blocked-by-allowlist", "tool": "bash", "command_argument": "command",
"command_digest": "hmac-sha256:41c7…", "command_length": 46, "host_mention": "url",
"session_id": "session-88", "call_id": "call-3", "decision_id": "netguard-…",
"first_seen_host": true
} }
host_mention says how the command named the host — url for a URL written with a scheme,
bare or operator for the spellings shell.readTextHosts turns on. Only a url mention is
remembered as a sighting, so report --suggest never proposes an allow entry for a filename that
happened to parse as a hostname. command_digest and command_length stand in for the command
text, which is never carried verbatim: the destination and the payload are the same string in
curl 'https://host/?token=…', exactly as they are in a search query.
A spool write failure is reported on process.stderr and ctx.logger, then swallowed: the
spool is evidence, not enforcement, and letting a full disk turn every request into a refusal
would trade an egress control for an availability outage. Both channels are used because
ctx.logger’s default exporter is an in-memory ring buffer that no shipped bundle drains, so a
message sent only there is invisible on a stock install.
dsh-netguard report # everything in $DSH_HOME/netguard/decisions.ocsf.jsonl
dsh-netguard report --since 24h # or an ISO timestamp
dsh-netguard report --session <id>
dsh-netguard report --suggest # a ready allow: block from the observed hosts
dsh-netguard report --spool /var/log/dsh/netguard.ocsf.jsonl
It imports nothing from the harness, so it runs wherever the package is installed, with no
profile and no dsh on the path. A plugin installed into a profile puts its bin in that
profile’s node_modules/.bin, which is not on PATH:
"$DSH_HOME/profiles/<name>/node_modules/.bin/dsh-netguard" report
The spool is append-only and a run can be interrupted mid-append, so a line that does not parse as a record is counted and reported rather than trusted.