NVIDIA OpenShell
Record each decision NVIDIA OpenShell logs, allowed or blocked, as an entry, with the SDK's two lines. The events stay in your log, and each entry carries its event's fingerprint.
NVIDIA OpenShell runs an agent in a sandbox and enforces a policy on what it can do. With its OCSF export on, it writes each decision as one line of JSON in OCSF, the open security log format: what the agent tried to reach, the policy that applied, and whether it was allowed or blocked. Your code reads those lines and writes each decision as an entry, so the blocked decisions are committed beside the allowed ones, where no one can change them, PacSpace included.
OpenShell decides what the agent may do. PacSpace commits the record of what it decided, and decides nothing.
Turn on the OCSF export
NVIDIA's OCSF JSON Export page gives the setting. For every sandbox:
openshell settings set --global --key ocsf_json_enabled --value trueFor one sandbox, name it in place of --global. OpenShell then writes one event per line to /var/log/openshell-ocsf.YYYY-MM-DD.log inside its supervisor, and to the supervisor's standard error. NVIDIA's page says where each one goes for Docker, Podman and Kubernetes.
One event, one entry
A blocked connection, on one line in the log:
{"class_uid":4001,"class_name":"Network Activity","activity_name":"Open","time":1791900435212,"message":"CONNECT denied fleet-admin.internal:443","action":"Denied","disposition":"Blocked","status_detail":"no matching policy","dst_endpoint":{"domain":"fleet-admin.internal","port":443},"actor":{"process":{"name":"/usr/bin/python3","pid":4418}},"firewall_rule":{"name":"-","type":"opa"}}| Entry field | From the event |
|---|---|
record | Your id for the sandbox's run, one record per run, such as agent-7-runtime-4418. |
title | The decision and OpenShell's message: Blocked: CONNECT denied fleet-admin.internal:443. |
kind | runtime-decision, or your own word. |
occurredAt | time, which OCSF gives in milliseconds since 1970, in UTC. |
actorId | The runtime that decided, openshell. |
payloads | The fingerprint of the event's line, exactly as OpenShell wrote it. |
idempotencyKey | The log's name and the line's number, so a line sent twice is one write. |
Everything else in the event, the process, the addresses and the policy, stays in your log. The entry carries the line's fingerprint, so a reader you later hand the line and its blinding can check it is the one that was recorded. The title is sealed as written, and a reader you share it with sees it, so leave out of it anything you wouldn't show them.
The code
The code reads the log's lines from standard input, one OCSF event per line, and writes the network, HTTP and SSH decisions. It keeps each sent line's blinding in a file beside the log, and starts after the last line it sent, so a restart picks up where it stopped. A line that was sent just before a restart is kept with no blinding: its entry is in the record, and its seal still checks, but that line can't be matched to it later.
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { PacSpace, PacSpaceError, blindingFileText, fingerprint } from '@pacspace-io/sdk';
const pac = new PacSpace({ apiKey: process.env.PACSPACE_API_KEY! });
const [log, record] = process.argv.slice(2); // the log's name, and one record per sandbox run
const kept = `${log}.pacspace.jsonl`; // each sent line's blinding: keep it as private as the log
const DECISIONS = new Set([4001, 4002, 4007]); // network, HTTP and SSH activity, allowed or blocked
// The last line already sent, so a restart picks up where it stopped.
const sent = existsSync(kept)
? readFileSync(kept, 'utf8').split('\n').filter(Boolean).reduce((last, row) => Math.max(last, JSON.parse(row).line), 0)
: 0;
let n = 0;
for await (const line of createInterface({ input: process.stdin })) {
n += 1;
if (n <= sent) continue;
const event = JSON.parse(line);
if (!DECISIONS.has(event.class_uid) || !event.disposition) continue;
const { ref, blinding } = await fingerprint(new TextEncoder().encode(line));
let text: string | null = blindingFileText(blinding!);
try {
await pac.records.emit({
record,
title: `${event.disposition}: ${event.message}`.slice(0, 120),
kind: 'runtime-decision',
occurredAt: new Date(event.time).toISOString(),
actorId: 'openshell',
payloads: [ref],
idempotencyKey: `${log}:${n}`,
});
} catch (err) {
// Sent just before a restart: the entry is in the record, and this line's blinding wasn't kept.
if (!(err instanceof PacSpaceError && err.code === 'IDEMPOTENCY_CONFLICT')) throw err;
text = null;
}
appendFileSync(kept, JSON.stringify({ line: n, blinding: text }) + '\n', { mode: 0o600 });
}Start it with the log's name and your record's id, and send it the log from its first line, so each line keeps its number. Where the log sits in OpenShell's supervisor, run the same tail there, for example with docker exec.
tail -n +1 -F /var/log/openshell-ocsf.2026-10-13.log | python3 record_decisions.py openshell-ocsf.2026-10-13.log agent-7-runtime-4418How many to send
A workspace writes up to 120 entries a minute, and the SDKs wait and send again when a write is over that limit. The code above leaves process, lifecycle and other events in your log. For a sandbox that decides more than that, keep the blocked decisions as entries of their own and fold the allowed ones into fewer entries, each carrying several fingerprints, as Integration patterns describes.
What the record can and can't show
The record holds every decision your code sends, committed where no one can change it, PacSpace included. NVIDIA's page calls OpenShell's log collection best effort, so an event can be dropped before it reaches your code. An event that never reaches your code isn't in the record, and the record can't show that it's missing.