From a29f8f06b1ef9804f6636605bd994f986b694edd Mon Sep 17 00:00:00 2001 From: Mayur Das Date: Mon, 2 Nov 2026 18:41:51 +0530 Subject: [PATCH] feat(audit): programs and connections in the audit trail, and execution_trace The audit trail returns three OCSF event classes: API Activity for a call, Process Activity for a program that ran in the sandbox, and Network Activity for a connection it opened. AuditProcess, AuditFile, AuditDstEndpoint and AuditDevice are exported; api and resources are None outside API Activity. sandboxes.update and sandbox.update accept execution_trace to turn program recording off or back on, and sandbox.execution_trace reports it. The API reference gains an Audit trail section covering the classes, their fields, which filters apply to which class, and how to tie a program to the call that started it. The audit_trail example prints all three classes and does that walk. The step-by-step examples README shows the output both audit examples now print. Signed-off-by: Mayur Das --- .changeset/audit-event-classes.md | 9 ++ README.md | 32 +++---- docs/api-inventory.md | 61 ++++++------- docs/api-reference.md | 46 ++++++++-- docs/example-coverage.md | 5 +- examples/README.md | 8 +- examples/agent_ports_audit.py | 10 +-- examples/audit_trail.py | 138 +++++++++++++++++----------- specs/aiagent.yaml | 99 +++++++++++++++----- src/neevai/__init__.py | 8 ++ src/neevai/generated/aiagent.py | 119 ++++++++++++++++-------- src/neevai/handles/sandbox.py | 14 ++- src/neevai/resources/sandboxes.py | 14 +-- src/neevai/types.py | 16 ++++ tests/test_audit.py | 145 ++++++++++++++++++++++-------- tests/test_generated_enums.py | 6 +- tests/test_sandboxes.py | 8 +- 17 files changed, 512 insertions(+), 226 deletions(-) create mode 100644 .changeset/audit-event-classes.md diff --git a/.changeset/audit-event-classes.md b/.changeset/audit-event-classes.md new file mode 100644 index 0000000..6933b05 --- /dev/null +++ b/.changeset/audit-event-classes.md @@ -0,0 +1,9 @@ +--- +"neevai": minor +--- + +The audit trail now records every program a sandbox runs and every connection those programs open, not only the calls made to it. `audit()` returns three OCSF event classes in `data`: API Activity (`class_uid` 6003) for a call, Process Activity (1007) for a program starting or ending, and Network Activity (4001) for a connection. A program event carries `process` (`pid`, `name`, `file.path`, `working_directory`, `parent_process.pid`) and, when it ends, `exit_code` or a `status_detail` such as `signal 9`; a connection carries `actor.process` and `dst_endpoint`. New models: `AuditProcess`, `AuditFile`, `AuditDstEndpoint`, `AuditDevice`. + +`api` and `resources` are present on API Activity only and are `None` otherwise. `actor.user.type` is now the OCSF caption (`"User"`, `"API Key"`, `"System"`, `"Unknown"`), and an API key is in `actor.user.programmatic_credentials[0].uid` instead of `credential_uid`. The `actor_type` filter still takes `"user"`, `"api_key"` and `"system"`. + +`client.sandboxes.update(id, {"execution_trace": False})` and `sandbox.update({"execution_trace": False})` stop recording programs and connections without a restart; `True` turns it back on. `sandbox.execution_trace` reports the current setting. diff --git a/README.md b/README.md index e186df4..7e75ca0 100644 --- a/README.md +++ b/README.md @@ -297,30 +297,26 @@ See [`sandbox_metrics.py`](examples/sandbox_metrics.py) · [reference](docs/api- ### Audit trail -Read what was run inside a sandbox, newest first, as OCSF events: API calls (commands, -terminal and SSH sessions, file and process operations) with who made them, plus every -program the sandbox ran and every connection it opened, whether or not anything asked for -them. Programs are recorded by name only, never with their arguments. +Read what happened inside a sandbox, newest first, as OCSF 1.9.0 events in three classes: +calls made to it (API Activity, `class_uid` 6003), every program that ran in it, including +ones no call asked for (Process Activity, 1007), and every connection those programs +opened (Network Activity, 4001). Programs are recorded by name only, never with their arguments. ```python -trail = sandbox.audit(status="failure") +trail = sandbox.audit(limit=50) for e in trail.data: - if e.api is not None: - what = e.api.operation - else: - proc = e.process or e.actor.process - what = f"{e.class_name}: {proc.name if proc else '?'}" - print(e.time, what, e.status) + # A recorded program has no api; its process names it instead. + proc = e.process or e.actor.process + what = e.api.operation if e.api else (proc.name if proc else None) + print(e.time, e.type_name, what, e.status) ``` -Program recording is on by default; turn it off with -`sandbox.update({"execution_trace": False})`. +Filter with `status` (every class), or `operation`, `actor_type`, `actor_uid`, +`credential_uid` (calls only), and page back with `cursor=trail.next_cursor`. Calls take a +few seconds to appear, programs and connections about a minute. Turn program recording off +with `sandbox.update({"execution_trace": False})`. -Filter with `operation`, `status`, `actor_type`, `actor_uid` or `credential_uid`, and -page back with `cursor=trail.next_cursor`. Events take a few seconds to appear after a -call. - -See [`audit_trail.py`](examples/audit_trail.py) · [reference](docs/api-reference.md#clientsandboxes) +See [`audit_trail.py`](examples/audit_trail.py) · [reference](docs/api-reference.md#audit-trail) ### Code interpreter diff --git a/docs/api-inventory.md b/docs/api-inventory.md index d515ef1..e963d05 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -91,7 +91,7 @@ Everything in `neevai.__all__`: | `CreateSnapshotParams` | model | `types.py` | | `SnapshotListResponse` | model | generated | | `AuditTrail` | model alias | generated → `AuditTrailResponse` | -| `AuditEvent` / `AuditActor` / `AuditActorUser` / `AuditAPI` / `AuditResource` | model | generated | +| `AuditEvent` / `AuditActor` / `AuditActorUser` / `AuditAPI` / `AuditResource` / `AuditProcess` / `AuditFile` / `AuditDstEndpoint` / `AuditDevice` | model | generated | | `SandboxEgressRule` | model | generated | | `SandboxEgressRules` | model | generated | | `NeevAIError`, `APIConnectionError`, `APITimeoutError`, `APIError`, `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `PreconditionFailedError`, `RateLimitError`, `DeadlineExceededError`, `InternalServerError`, `ServiceUnavailableError` | exceptions | `errors.py` | @@ -143,7 +143,7 @@ Types exported from `neevai.types.__all__`: | `OnIdleAction` | enum | generated | | `SandboxPort` | model | generated (`port`, `slug`, `preview_url`) | | `AuditTrail` | model alias | generated → `AuditTrailResponse` | -| `AuditEvent` / `AuditActor` / `AuditActorUser` / `AuditAPI` / `AuditResource` | model | generated | +| `AuditEvent` / `AuditActor` / `AuditActorUser` / `AuditAPI` / `AuditResource` / `AuditProcess` / `AuditFile` / `AuditDstEndpoint` / `AuditDevice` | model | generated | | `Snapshot` / `SnapshotListResponse` / `SnapshotStatus` | model / model / enum | generated | | `CreateSnapshotParams` | model | `types.py` | | `CreateSnapshotRequest` / `ForkSandboxRequest` / `RestoreSandboxRequest` | model | generated | @@ -320,6 +320,9 @@ single `PATCH`: existing allow-list in place, leaving other rules untouched. Removals apply first, so one call can swap a host. Adding a host already allowed replaces its ports and protocol. Rejected while the policy is `deny_all`. +- `execution_trace` — `False` stops recording every program the sandbox runs (and its + connections) in the audit trail; `True` turns it back on. On by default, takes effect + with no restart, and the change is written to your organization's audit log. **Returns:** Updated `Sandbox` handle. @@ -331,6 +334,7 @@ convenience) is combined with `egress_add` / `egress_remove`. # Resize, then re-scope egress (two calls, or combine both in one): client.sandboxes.update(sandbox.id, {"resources": {"cpu": 2, "memory_gb": 4}}) client.sandboxes.update(sandbox.id, {}, allow_egress=["api.github.com"]) +client.sandboxes.update(sandbox.id, {"execution_trace": False}) ``` **Async:** `updated = await client.sandboxes.update(...)` @@ -435,11 +439,11 @@ for series in metrics.series: ### `client.sandboxes.audit(id, *, from_=None, to=None, cursor=None, limit=None, operation=None, status=None, actor_type=None, actor_uid=None, credential_uid=None, org_id=None, project_id=None)` -Reads one page of the sandbox's audit trail, newest first, as OCSF events: terminal -commands, SSH, process and file operations, each with who made the call and how it -ended, plus every program the sandbox ran and every connection it opened, unless its -`execution_trace` is off. Only the program name is recorded, never its arguments, and -input typed at a hidden password prompt is not captured. +Reads one page of the sandbox's audit trail, newest first, as OCSF 1.9.0 events: calls +made to the sandbox (API Activity, 6003), programs that ran in it (Process Activity, +1007) and connections they opened (Network Activity, 4001). Only the program name is +recorded, never its arguments; the environment and input typed at a hidden password +prompt are never recorded. `from_` / `to` (RFC3339) default to the last 24 hours. `limit` is clamped server-side. The response's `window_truncated` is `True` when `from_` reaches past the @@ -448,10 +452,11 @@ The response's `window_truncated` is `True` when `from_` reaches past the Filters, all optional: - `operation` — one call name or a list, such as `"exec"` or `["exec", "pty_command"]`. -- `status` — `"success"` or `"failure"`. -- `actor_type` — `"user"`, `"api_key"`, `"system"`, or a list of them. -- `actor_uid` — a user id or an API key id. -- `credential_uid` — only calls made with this API key. + Leaves out programs and connections. +- `status` — `"success"` or `"failure"`; applies to every class. +- `actor_type` — `"user"`, `"api_key"`, `"system"`, or a list of them. Calls only. +- `actor_uid` — a user id or an API key id. Calls only. +- `credential_uid` — only calls made with this API key. Calls only. **Returns:** `AuditTrail` (`from_`, `to`, `retention_days`, `window_truncated`, `next_cursor`, `data: list[AuditEvent]`). See [`AuditTrail` / `AuditEvent`](#audittrail--auditevent). @@ -460,8 +465,9 @@ Filters, all optional: trail = sandbox.audit(limit=50) while True: for e in trail.data: - what = e.api.operation if e.api is not None else e.class_name - print(e.time, what, e.actor.user.name or e.actor.user.uid, e.status) + proc = e.process or e.actor.process + what = e.api.operation if e.api else (proc.name if proc else None) + print(e.time, e.type_name, what, e.status) if not trail.next_cursor: break trail = sandbox.audit(limit=50, cursor=trail.next_cursor) @@ -802,6 +808,7 @@ mirroring the last API response. Call `refresh()` to sync from the server. | `phase` | `str` | OpenAPI steady states: `"Pending"`, `"Ready"`, `"NotReady"`, `"Unknown"`, `"Paused"`. API may also return transitional values (e.g. `"Pausing"`, `"Resuming"`) not in the spec enum; SDK accepts any phase string. | | `connect_url` | `str \| None` | Regional runtime URL (available when ready) | | `addressable` | `bool` | Whether the sandbox can be reached yet; briefly `False` after create (`True` when the API omits it) | +| `execution_trace` | `bool` | Whether every program the sandbox runs is recorded in its audit trail; `True` unless turned off | | `labels` | `dict[str, str]` | Key-value labels the sandbox was created with; `{}` when it has none | | `last_crash` | `SandboxLastCrash \| None` | Most recent unexpected stop; `storage_reset=True` means it restarted with an empty filesystem. `None` if it never crashed. Restoring from a snapshot taken before that stop brings the files back and clears it. | | `data` | `dict[str, Any]` | Full record snapshot | @@ -1913,33 +1920,27 @@ for `egress_add` / `egress_remove`. | `next_cursor` | `str \| None` | no (pass as `cursor` for the next, older page) | | `data` | `list[AuditEvent]` | yes (newest first) | -`AuditEvent` is one OCSF 1.9.0 event. `class_uid` says which kind, and which fields -are present: - -| `class_uid` | Kind | Carries | -| ----------- | ---- | ------- | -| 6003 | API Activity: a call to the sandbox | `api`, `src_endpoint`, `actor.user` | -| 1007 | Process Activity: a program started (`activity_id` 1, Launch), ended (2, Terminate) or the sandbox started (99) | `process`, `exit_code` on Terminate (or `status_detail` `signal N` if killed) | -| 4001 | Network Activity: a program opened a connection (`activity_id` 1, Open) | `dst_endpoint`, `actor.process` | - -The fields most callers read: +`AuditEvent` is one OCSF 1.9.0 event: API Activity (`class_uid` 6003), Process Activity (1007) or Network Activity (4001). The fields most callers read: | Field | Type | Required | | ----- | ---- | -------- | | `time` | `int` | yes (milliseconds since the Unix epoch) | +| `class_uid` / `type_name` | `int` / `str` | yes (e.g. `1007` / `Process Activity: Launch`) | | `api.operation` | `str` | API Activity only (e.g. `pty_command`, `ssh`, `exec`, `process.start`, `fs.read`) | -| `process` | `AuditProcess \| None` | Process Activity only (`pid`, `name` without arguments, `cwd`, `file.path`, `parent_process.pid`) | -| `exit_code` | `int \| None` | no (what a program returned; on Terminate unless a signal stopped it) | -| `dst_endpoint` | `AuditDstEndpoint \| None` | Network Activity only (`ip` and `port`, or `name` as `unix:`) | -| `actor.process` | `AuditProcess \| None` | Network Activity only (the program that connected) | | `status` | `str` | yes (`Success` or `Failure`) | | `status_code` | `str \| None` | no (`ok`, `permission_denied`, `internal`) | -| `actor.user.type` | `user` \| `api_key` \| `system` \| `unknown` | yes (`unknown` on program and connection events) | +| `actor.user.type` | `User` \| `API Key` \| `System` \| `Unknown` | yes (`Unknown` on program and connection events) | | `actor.user.uid` | `str` | yes (the user's or API key's id) | | `actor.user.name` | `str \| None` | no | -| `actor.user.credential_uid` | `str \| None` | no (the API key used; absent for a user) | +| `actor.user.programmatic_credentials` | `list \| None` | no (the API key used, as `[0].uid`; absent for a user) | +| `actor.process` | `AuditProcess \| None` | no (on a Launch, the parent; on a connection, the program) | +| `process` | `AuditProcess \| None` | Process Activity only (`pid`, `name`, `working_directory`, `file.path`, `parent_process.pid`) | +| `exit_code` | `int \| None` | no (on a Terminate, unless a signal stopped the program) | +| `status_detail` | `str \| None` | no (`signal ` for a program a signal stopped; how a code-interpreter run ended) | +| `dst_endpoint` | `AuditDstEndpoint \| None` | Network Activity only (`ip` and `port`, or `name` as `unix:`) | +| `device.uid` | `str \| None` | no (the sandbox id) | | `actor.session.uid` | `str \| None` | no (the console session) | -| `resources` | `list[AuditResource] \| None` | no (the sandbox, then any file, process, program or terminal the call touched) | +| `resources` | `list[AuditResource] \| None` | API Activity only (the sandbox, then any file, process, program or terminal the call touched; `data.pid` on the process or terminal it started, which a program's pid or an ancestor's equals) | | `duration` | `int \| None` | no (milliseconds) | | `metadata.uid` | `str` | yes (stable id of the event) | | `metadata.sequence` | `int \| None` | no (a terminal command's ordinal within its terminal) | diff --git a/docs/api-reference.md b/docs/api-reference.md index 625fbb7..04e46f6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -5,7 +5,7 @@ Every product and method in `neevai`, grouped by product, with short snippets. F ## Contents - [Client and configuration](#client-and-configuration) -- [Sandboxes](#sandboxes): [`client.sandboxes`](#clientsandboxes), [Templates](#templates), [Snapshots, fork and rollback](#snapshots-fork-and-rollback), [Sandbox handle](#sandbox-handle), [Commands and processes](#commands-and-processes), [Files](#files), [Terminals and SSH](#terminals-and-ssh), [Code interpreter](#code-interpreter) +- [Sandboxes](#sandboxes): [`client.sandboxes`](#clientsandboxes), [Audit trail](#audit-trail), [Templates](#templates), [Snapshots, fork and rollback](#snapshots-fork-and-rollback), [Sandbox handle](#sandbox-handle), [Commands and processes](#commands-and-processes), [Files](#files), [Terminals and SSH](#terminals-and-ssh), [Code interpreter](#code-interpreter) - [Agents](#agents): [`client.agents`](#clientagents), [Agent templates](#agent-templates), [`Agent` handle](#agent-handle) - [Agent tools](#agent-tools): [The tool contract](#the-tool-contract), [Framework adapters](#framework-adapters) - [Errors](#errors) @@ -45,14 +45,14 @@ A sandbox is an isolated machine for your code. `client.sandboxes` and `client.t | `create(params, org_id=None, project_id=None, *, allow_internet=None, allow_egress=None, labels=None)` | `Sandbox` | Creates a new sandbox. `allow_internet=True` / `allow_egress=[...]` open egress (deny-all by default; explicit `egress` wins). `labels={...}` tags it with key-value labels. Optional `restore` in params provisions from a snapshot. | | `list(page=None, limit=None, name=None, status=None, sandbox_id=None, org_id=None, project_id=None, *, labels=None)` | `SandboxPage` | Lists sandboxes with pagination in the resolved org/project scope. `labels={...}` keeps only sandboxes carrying every given label. | | `get(id, org_id=None, project_id=None)` | `Sandbox` | Fetches the current record for a sandbox by ID or name. | -| `update(id, params, org_id=None, project_id=None, *, allow_internet=None, allow_egress=None)` | `Sandbox` | In-place update of `resources` (cpu/memory) and/or `egress`. Resize keeps the ID/name/preview URLs and does not restart; `disk_gb` is not resizable and the server rejects a change. `allow_internet` / `allow_egress` are the same egress convenience as `create`. `egress_add` / `egress_remove` edit the allow-list in place (not combinable with `egress`). Rejects `{}` locally, naming every field. | +| `update(id, params, org_id=None, project_id=None, *, allow_internet=None, allow_egress=None)` | `Sandbox` | In-place update of `resources` (cpu/memory) and/or `egress`. Resize keeps the ID/name/preview URLs and does not restart; `disk_gb` is not resizable and the server rejects a change. `allow_internet` / `allow_egress` are the same egress convenience as `create`. `egress_add` / `egress_remove` edit the allow-list in place (not combinable with `egress`). `{"execution_trace": False}` stops recording programs, `True` turns it back on. Rejects `{}` locally, naming every field. | | `pause(id, org_id=None, project_id=None)` | `Sandbox` | Stops a sandbox and keeps its state (Paused). Sends an empty body; a pause always captures full state, so a resume picks up where it left off. | | `resume(id, org_id=None, project_id=None)` | `Sandbox` | Starts a paused sandbox again, toward Ready. | | `keepalive(id, org_id=None, project_id=None)` | `Sandbox` | Resets the sandbox's idle timer (POST `.../keepalive`). | | `update_timeout(id, params, org_id=None, project_id=None)` | `Sandbox` | Changes idle/lifetime windows (PUT `.../timeout`); only the windows passed change, `0` turns one off. | | `delete(id, org_id=None, project_id=None)` | `None` | Permanently deletes a sandbox. | | `metrics(id, from_=None, to=None, step=None, ...)` | `SandboxMetricsResponse` | Queries live health metrics over an optional time range. | -| `audit(id, *, from_=None, to=None, cursor=None, limit=None, ...)` | `AuditTrail` | One page of the audit trail, newest first. Filter with `operation`, `status`, `actor_type`, `actor_uid` or `credential_uid`; page with `next_cursor`. | +| `audit(id, *, from_=None, to=None, cursor=None, limit=None, ...)` | `AuditTrail` | One page of the audit trail, newest first: calls, programs and connections. See [Audit trail](#audit-trail). | | `expose_port(id, port, ..., *, slug=None)` / `list_ports(id)` / `revoke_port(id, port)` | `SandboxPort` / `list[SandboxPort]` / `None` | Preview ports. A new `slug` on an exposed port rotates its URL. | | `get_port_url(id, port, wait_until_ready=True, ..., *, slug=None)` | `str` | Exposes a port and returns its preview URL once routable. | | `create_snapshot(id, params=None, ...)` | `Snapshot` | Creates a filesystem snapshot (returns immediately with status Pending). | @@ -62,6 +62,40 @@ A sandbox is an isolated machine for your code. `client.sandboxes` and `client.t | `rollback(id, snapshot_id, ...)` | `Sandbox` | Rolls a sandbox back in place to a previous snapshot. Prefer `create({..., "restore": ...})` to recover into a new sandbox — see [Snapshots](#snapshots-fork-and-rollback). | | `fork(id, name, ...)` | `Sandbox` | Forks a sandbox into a new sandbox from its current state. | +### Audit trail + +`audit()` returns an `AuditTrail` (`from_`, `to`, `retention_days`, `window_truncated`, `next_cursor`, `data: list[AuditEvent]`), newest first. Each event is an [OCSF 1.9.0](https://schema.ocsf.io/1.9.0/) event, so it can go straight into a SIEM. + +| `class_uid` | `class_name` | What it records | `activity_name` | +| --- | --- | --- | --- | +| `6003` | API Activity | A call made to the sandbox: `exec`, a terminal command, a file operation, a process start, SSH, a code-interpreter cell | Create, Read, Update, Delete, Other | +| `1007` | Process Activity | A program that ran inside the sandbox, whether or not a call asked for it: scripts, generated code, child processes | Launch, Terminate, Sandbox Start | +| `4001` | Network Activity | A connection a program opened | Open | + +Every event has `time` (milliseconds since the Unix epoch), `type_name` (such as `Process Activity: Launch`), `status` (`Success` or `Failure`), `device.uid` (the sandbox id) and `metadata.uid` (a stable event id). + +| Class | Key fields | +| --- | --- | +| API Activity | `api.operation`, `api.request.uid`, `actor.user` (`type` is `User`, `API Key`, `System` or `Unknown`; an API key also has `programmatic_credentials[].uid`), `resources`, `duration`, `status_code` | +| Process Activity | `process.pid`, `process.name` (no arguments), `process.file.path` (what actually executed), `process.working_directory`, `process.parent_process.pid`. On Launch, `actor.process` is the parent. On Terminate, `exit_code`, or `status_detail` such as `signal 9` when a signal stopped it | +| Network Activity | `actor.process` (the program), `dst_endpoint.ip` and `dst_endpoint.port`, or `dst_endpoint.name` (`unix:`) for a local socket | + +`api` and `resources` are `None` on program and connection events, which don't name who acted. To find the call that started a program, take the API Activity event whose `resources[].data.pid` equals the program's pid, or the pid of an ancestor reached through `parent_process.pid` (each ancestor's own Launch event names its parent). A terminal command's programs lead back to the terminal's shell pid. + +| Filter | Applies to | +| --- | --- | +| `from_`, `to` (RFC 3339), `limit` (1–200, default 50), `cursor` | Every event | +| `status` (`"success"` or `"failure"`) | Every event | +| `operation` (one name or a list) | API Activity only; programs and connections are left out | +| `actor_type`, `actor_uid`, `credential_uid` | API Activity only | + +- Program names are recorded without their arguments, the environment is never recorded, and input typed at a hidden password prompt is never recorded. +- Programs and connections can take about a minute to appear. The first programs a newly started sandbox runs may be missing. +- `update(id, {"execution_trace": False})` stops recording programs and connections; calls are still recorded. `sandbox.execution_trace` reports the current setting. +- The trail covers the last `retention_days` days; `window_truncated` is `True` when `from_` reaches further back. + +[examples/audit_trail.py](../examples/audit_trail.py) prints all three classes and ties each program to the call that started it. + ### Templates | Method | Returns | Summary | @@ -107,9 +141,9 @@ Returned by `create()`, `get()`, `list().items`, etc. | API | Kind | | --- | ---- | -| `id`, `name`, `phase`, `connect_url`, `addressable`, `labels`, `last_crash`, `data` | properties | +| `id`, `name`, `phase`, `connect_url`, `addressable`, `execution_trace`, `labels`, `last_crash`, `data` | properties | | `refresh()` | method | -| `update(params, *, allow_internet=None, allow_egress=None)` | method — in-place resize / egress re-scope (`egress_add` / `egress_remove` edit the allow-list); updates state in place | +| `update(params, *, allow_internet=None, allow_egress=None)` | method — in-place resize / egress re-scope (`egress_add` / `egress_remove` edit the allow-list) / `execution_trace`; updates state in place | | `wait_until_ready(timeout_ms=120000, ...)` | method — polls the API until `Ready` and addressable | | `pause()` / `resume()` | methods | | `keepalive()` / `update_timeout(params)` | methods — reset idle timer / change lifecycle windows | @@ -267,7 +301,7 @@ An agent is a catalogue template (such as `"claude-code"`) running on its own sa | `update(id, params, org_id=None, project_id=None, *, allow_internet=None, allow_egress=None)` | `Agent` | In-place update of egress, cpu/memory (`resources`; defaults & bounds in [Agent resources](./api-inventory.md#agent-resources)) and `idle_timeout_seconds`. `allow_internet` / `allow_egress` are the same egress convenience as `create`; `egress_add` / `egress_remove` edit the allow-list in place. Rejects `{}` locally, naming every field. | | `keepalive(id, ...)` / `rollback(id, snapshot_id, ...)` | `Agent` | Resets the idle timer / rolls the backing sandbox back to a snapshot. | | `expose_port` / `list_ports` / `revoke_port` / `get_port_url` | as on `client.sandboxes` | Agent preview ports, same signatures. | -| `audit(id, *, from_=None, to=None, cursor=None, limit=None, ...)` | `AuditTrail` | The agent's audit trail, with the same filters. | +| `audit(id, *, from_=None, to=None, cursor=None, limit=None, ...)` | `AuditTrail` | The agent's audit trail: the same events and filters as the [sandbox one](#audit-trail). An agent has no `execution_trace` setting. | | `pause(id, org_id=None, project_id=None)` | `Agent` | Pauses the agent and its backing sandbox. | | `resume(id, org_id=None, project_id=None)` | `Agent` | Resumes a paused agent. | | `delete(id, org_id=None, project_id=None)` | `None` | Permanently deletes an agent (HTTP 204, no body). | diff --git a/docs/example-coverage.md b/docs/example-coverage.md index 3a67c21..2ec4899 100644 --- a/docs/example-coverage.md +++ b/docs/example-coverage.md @@ -29,7 +29,8 @@ examples themselves, see [`examples/README.md`](../examples/README.md). | `client.sandboxes.update_timeout` / `sandbox.update_timeout` | `sandbox_lifecycle_windows.py` | `sandbox.update_timeout({"idle_timeout_seconds": 300, "max_lifetime_seconds": 0})` | | `client.sandboxes.delete` | `sandbox_lifecycle_controller.py` | `client.sandboxes.delete(sandbox_id)` | | `client.sandboxes.metrics` | `sandbox_lifecycle_controller.py` | `metrics = client.sandboxes.metrics(sandbox_id)` | -| `client.sandboxes.audit` / `sandbox.audit` | `audit_trail.py` | `trail = sandbox.audit(limit=20)` — then `cursor=trail.next_cursor` | +| `client.sandboxes.audit` / `sandbox.audit` | `audit_trail.py` | `trail = sandbox.audit(limit=200)` — calls, programs and connections | +| `sandbox.execution_trace` | — | `sandbox.update({"execution_trace": False})` | | `client.sandboxes.update` (`egress_add` / `egress_remove`) | `update_resize_egress.py` | `sandbox.update({"egress_add": {"allow": [{"host": "pypi.org"}]}})` | | `sandbox.expose_port` / `.list_ports` / `.revoke_port` / `.get_url` | `preview_ports.py` | `url = sandbox.get_url(3000)`; `sandbox.expose_port(3000, slug=new_slug)` rotates it | | `sandbox.labels` | `sandbox_labels.py` | `sandbox.labels` — `{}` when it has none | @@ -115,7 +116,7 @@ examples themselves, see [`examples/README.md`](../examples/README.md). | Upload & download | `examples/upload_download.py` | Chunked `upload_file` with progress → `download_file` → compare sizes | | Preview ports | `examples/preview_ports.py` | Serve a port → `get_url` → `list_ports` → rotate the slug → `revoke_port` | | Resize & egress edits | `examples/update_resize_egress.py` | Resize + re-scope egress in one update → `egress_add` / `egress_remove` | -| Audit trail | `examples/audit_trail.py` | Run commands → page through `sandbox.audit()` | +| Audit trail | `examples/audit_trail.py` | Run a script → read calls, programs and connections, each program tied to its call | | Agent ports & audit | `examples/agent_ports_audit.py` | Agent preview port with slug → rotate → `keepalive` → `audit` → revoke | | Streaming exec | `examples/streaming_exec.py` | `exec_stream` with progress output | | Processes lifecycle | `examples/processes.py` | Create → connect_url wait → Ready → runtime probe → start → follow → logs → list → kill → wait | diff --git a/examples/README.md b/examples/README.md index 7c0c843..73c1e66 100644 --- a/examples/README.md +++ b/examples/README.md @@ -61,8 +61,8 @@ These need only your credentials. Start with `templates_list.py`. | [`files_api.py`](files_api.py) | Write, read and list files | | [`upload_download.py`](upload_download.py) | Upload and download a large file with progress | | [`preview_ports.py`](preview_ports.py) | Serve a port, get its preview URL, rotate it, revoke it | -| [`audit_trail.py`](audit_trail.py) | Run commands, then page through the audit trail | -| [`pty.py`](pty.py) | A scripted terminal session (no typing needed) | +| [`audit_trail.py`](audit_trail.py) | Calls, programs and connections, each program tied to the call that started it | +| [`pty.py`](pty.py) | An interactive terminal | | [`ssh_tunnel.py`](ssh_tunnel.py) | SSH, `rsync` and port forwarding through one tunnel | | [`streaming_exec.py`](streaming_exec.py) | Stream command output line by line | | [`processes.py`](processes.py) | Start, follow, read logs of and stop a process | @@ -223,7 +223,7 @@ uv run python examples/preview_ports.py ```bash uv run python examples/audit_trail.py ``` -→ `ready: …` → `retention: … days, truncated: …` → `HH:MM:SS ` (one line per event, newest first) → `exec calls:` → the same kind of line for each `exec` event. +→ `ready: …` → `HH:MM:SS ` (one line per call, program and connection, newest first; a program or connection ends with `<- exec by ` for the call that started it) → `N events ended in failure`. **17. Terminal (PTY)** ```bash @@ -303,7 +303,7 @@ uv run python examples/create_agent.py ```bash uv run python examples/agent_ports_audit.py ``` -→ `ready: … (idle window: …)` → `preview URL: …` → `rotated to: … (the old URL no longer works)` → `exposed ports: [3000]` → `HH:MM:SS ` (one line per audit event). +→ `ready: … (idle window: …)` → `preview URL: …` → `rotated to: … (the old URL no longer works)` → `exposed ports: [3000]` → `HH:MM:SS ` (one line per audit event). **30. Agent auto-resume** ```bash diff --git a/examples/agent_ports_audit.py b/examples/agent_ports_audit.py index 5b66e77..24b8f97 100644 --- a/examples/agent_ports_audit.py +++ b/examples/agent_ports_audit.py @@ -62,12 +62,10 @@ def main() -> None: trail = agent.audit(limit=20) for e in trail.data: at = datetime.fromtimestamp(e.time / 1000, tz=timezone.utc) - if e.api is not None: - what = f"{e.api.operation} by {e.actor.user.name or e.actor.user.uid}" - else: - proc = e.process or e.actor.process - what = f"{e.class_name}: {proc.name if proc else '?'}" - print(f"{at:%H:%M:%S} {what} {e.status}") + # A recorded program has no api; its process names it instead. + proc = e.process or e.actor.process + what = e.api.operation if e.api else (proc.name if proc else None) + print(f"{at:%H:%M:%S} {e.type_name:<28} {what} {e.status}") agent.revoke_port(PORT) except NeevAIError as e: diff --git a/examples/audit_trail.py b/examples/audit_trail.py index 8dbf8ac..1fb5283 100644 --- a/examples/audit_trail.py +++ b/examples/audit_trail.py @@ -1,14 +1,15 @@ """ -Run a few commands in a sandbox, then read back its audit trail. +Read a sandbox's audit trail: the calls made to it, every program that ran +inside it, and every connection those programs opened, newest first. -Events are OCSF, newest first: each call made to the sandbox — terminal commands, -SSH, process and file operations — with who made it, plus every program the -sandbox ran and every connection it opened, whether or not anything asked for -them. Only the program name is recorded, never its arguments. Filter by -``operation``, ``status``, ``actor_type``, ``actor_uid`` or ``credential_uid``. +Runs a script through exec, then reads ``sandbox.audit()`` and ties each program +back to the call that started it through its parent chain. Only the program name +is recorded, never its arguments. -Pages are read with ``cursor``: pass the previous page's ``next_cursor`` until it -is empty. +Events come in three OCSF classes: API Activity (``class_uid`` 6003) for a call, +Process Activity (1007) for a program starting or ending, and Network Activity +(4001) for a connection. ``status`` filters every class; ``operation``, +``actor_type``, ``actor_uid`` and ``credential_uid`` return calls only. Run:: @@ -21,66 +22,103 @@ import os import sys import time +from collections.abc import Callable, Sequence from datetime import datetime, timezone -from neevai import AuditEvent, NeevAI +from neevai import AuditEvent, AuditTrail, NeevAI from neevai.errors import NeevAIError TEMPLATE = os.environ.get("NEEV_SANDBOX_TEMPLATE_ID", "sb-ubuntu-26-04-dev") +API_ACTIVITY = 6003 +PROCESS_ACTIVITY = 1007 +NETWORK_ACTIVITY = 4001 + def describe(e: AuditEvent) -> str: - """One line per event: the API call, or the program and what it did.""" - if e.api is not None: - return f"{e.api.operation} by {e.actor.user.name or e.actor.user.uid}" - if e.dst_endpoint is not None: + if e.class_uid == API_ACTIVITY: + who = e.actor.user.name or e.actor.user.uid + return f"{e.api.operation if e.api else '?'} by {who}" + if e.class_uid == PROCESS_ACTIVITY and e.process is not None: + p = e.process + parent = p.parent_process.pid if p.parent_process else "-" + if e.exit_code is not None: + ended = f" exit {e.exit_code}" + elif e.status_detail: + ended = f" {e.status_detail}" + else: + ended = "" + return f"{p.name} pid {p.pid} (parent {parent}){ended}" + if e.class_uid == NETWORK_ACTIVITY and e.actor.process is not None: d = e.dst_endpoint - dst = d.name or f"{d.ip}:{d.port}" - proc = e.actor.process.name if e.actor.process else "?" - return f"{proc} connect {dst}" - name = e.process.name if e.process else "?" - exit_code = f" exit {e.exit_code}" if e.exit_code is not None else "" - return f"{name} {e.activity_name.lower()}{exit_code}" - - -def show(e: AuditEvent) -> None: - at = datetime.fromtimestamp(e.time / 1000, tz=timezone.utc) - print(f"{at:%H:%M:%S} {describe(e):<40} {e.status}") + where = (d.name or f"{d.ip}:{d.port}") if d else "?" + return f"{e.actor.process.name} pid {e.actor.process.pid} -> {where}" + return e.type_name + + +def started_by(pid: int | None, events: Sequence[AuditEvent]) -> AuditEvent | None: + """The call that started a program: its own pid, or an ancestor's, is a call's pid.""" + calls: dict[int, AuditEvent] = {} + parent: dict[int, int] = {} + for e in events: + for r in e.resources or []: + if r.data is not None and r.data.pid is not None: + calls[r.data.pid] = e + launch = e.class_uid == PROCESS_ACTIVITY and e.activity_id == 1 + if launch and e.process is not None and e.process.pid is not None: + pp = e.process.parent_process + if pp is not None and pp.pid is not None: + parent[e.process.pid] = pp.pid + seen: set[int] = set() + while pid is not None and pid not in seen: + seen.add(pid) + if pid in calls: + return calls[pid] + pid = parent.get(pid) + return None + + +def page_with_programs(read: Callable[[], AuditTrail]) -> AuditTrail: + """Programs and connections can take about a minute to appear, so poll until they do.""" + deadline = time.monotonic() + 120 + while True: + trail = read() + has_programs = any(e.class_uid == PROCESS_ACTIVITY for e in trail.data) + if has_programs or time.monotonic() >= deadline: + return trail + time.sleep(5) def main() -> None: with NeevAI() as client: sandbox = None try: - sandbox = client.sandboxes.create({"sandbox_template_id": TEMPLATE}) + sandbox = client.sandboxes.create( + {"sandbox_template_id": TEMPLATE}, allow_internet=True + ) sandbox.wait_until_ready() print(f"ready: {sandbox.id}", file=sys.stderr) - # Generate some activity: a process and a couple of file operations. - sandbox.exec(["sh", "-c", "echo hello > greeting.txt"]) - sandbox.files.read_text("greeting.txt") - sandbox.files.list(".") - - # Events take a few seconds to reach the trail. - trail = sandbox.audit(limit=20) - deadline = time.monotonic() + 30 - while not trail.data and time.monotonic() < deadline: - time.sleep(2) - trail = sandbox.audit(limit=20) - - # Read the trail one page at a time, newest first. - print(f"retention: {trail.retention_days} days, truncated: {trail.window_truncated}") - while True: - for e in trail.data: - show(e) - if not trail.next_cursor: - break - trail = sandbox.audit(limit=20, cursor=trail.next_cursor) - - # Exec calls and program launches. - print("exec:") - for e in sandbox.audit(operation="exec").data: - show(e) + # A script that starts programs of its own: none of them is a call you made. + sandbox.files.write( + "work.sh", "#!/bin/sh\nls /workspace\ncurl -s -o /dev/null https://example.com\n" + ) + sandbox.exec(["sh", "work.sh"]) + + trail = page_with_programs(lambda: sandbox.audit(limit=200)) + for e in trail.data: + at = datetime.fromtimestamp(e.time / 1000, tz=timezone.utc) + pid = (e.process.pid if e.process else None) or ( + e.actor.process.pid if e.actor.process else None + ) + call = None if e.class_uid == API_ACTIVITY else started_by(pid, trail.data) + via = "" + if call is not None and call.api is not None: + via = f" <- {call.api.operation} by {call.actor.user.name or call.actor.user.uid}" + print(f"{at:%H:%M:%S} {e.type_name:<28} {describe(e)}{via}") + + failed = sandbox.audit(status="failure") + print(f"{len(failed.data)} events ended in failure") except NeevAIError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) diff --git a/specs/aiagent.yaml b/specs/aiagent.yaml index 4edc42e..0cb5ed4 100644 --- a/specs/aiagent.yaml +++ b/specs/aiagent.yaml @@ -952,7 +952,9 @@ paths: const sandbox = await neev.sandboxes.get(sandboxId); const trail = await sandbox.audit({ limit: 50 }); for (const e of trail.data) { - console.log(new Date(e.time).toISOString(), e.api.operation, e.actor.user.name ?? e.actor.user.uid, e.status); + // A recorded program has no api; its process names it instead. + const what = e.api?.operation ?? e.process?.name ?? e.actor.process?.name; + console.log(new Date(e.time).toISOString(), e.type_name, what, e.status); } - lang: Python source: | @@ -963,7 +965,10 @@ paths: sandbox = neev.sandboxes.get(sandbox_id) trail = sandbox.audit(limit=50) for e in trail.data: - print(e.time, e.api.operation, e.actor.user.name or e.actor.user.uid, e.status) + # A recorded program has no api; its process names it instead. + proc = e.process or e.actor.process + what = e.api.operation if e.api else (proc.name if proc else None) + print(e.time, e.type_name, what, e.status) parameters: - {$ref: '#/components/parameters/OrgID'} - {$ref: '#/components/parameters/ProjectID'} @@ -1685,7 +1690,9 @@ paths: const agent = await neev.agents.get(agentId); const trail = await agent.audit({ limit: 50 }); for (const e of trail.data) { - console.log(new Date(e.time).toISOString(), e.api.operation, e.actor.user.name ?? e.actor.user.uid, e.status); + // A recorded program has no api; its process names it instead. + const what = e.api?.operation ?? e.process?.name ?? e.actor.process?.name; + console.log(new Date(e.time).toISOString(), e.type_name, what, e.status); } - lang: Python source: | @@ -1696,7 +1703,10 @@ paths: agent = neev.agents.get(agent_id) trail = agent.audit(limit=50) for e in trail.data: - print(e.time, e.api.operation, e.actor.user.name or e.actor.user.uid, e.status) + # A recorded program has no api; its process names it instead. + proc = e.process or e.actor.process + what = e.api.operation if e.api else (proc.name if proc else None) + print(e.time, e.type_name, what, e.status) parameters: - {$ref: '#/components/parameters/OrgID'} - {$ref: '#/components/parameters/ProjectID'} @@ -1904,7 +1914,7 @@ components: in: query required: false description: >- - Only these calls (api.operation), one or several separated by commas, such as `exec,pty_command`. A recorded program matches `exec` when it starts, `exit` when it ends and `connect` when it opens a connection. + Only these calls (api.operation), one or several separated by commas, such as `exec,pty_command`. Recorded programs and connections are not calls and have no operation, so this filter leaves them out. schema: {type: string} AuditStatus: name: status @@ -1917,7 +1927,7 @@ components: in: query required: false description: >- - Only calls by this kind of principal (actor.user.type), one or several separated by commas. Records from before actors were recorded are not matched; use credential_uid for those. + Only calls by this kind of principal, one or several separated by commas: `user`, `api_key` or `system`, matching actor.user.type User, API Key or System. Records from before actors were recorded are not matched; use credential_uid for those. style: form explode: false schema: @@ -1934,7 +1944,7 @@ components: name: credential_uid in: query required: false - description: Only calls made with this API key (actor.user.credential_uid). + description: Only calls made with this API key (actor.user.programmatic_credentials[].uid). schema: {type: string} AgentTemplateID: name: template_id @@ -2622,7 +2632,7 @@ components: enum: [1, 2, 3, 4, 99] x-enum-varnames: [AuditActivityCreate, AuditActivityRead, AuditActivityUpdate, AuditActivityDelete, AuditActivityOther] description: >- - Read with class_uid. For an API call: 1 Create (a write, a started process, a terminal), 2 Read, 3 Update (a move), 4 Delete (a removed file, a killed process), 99 Other. For a program: 1 Launch, 2 Terminate, 99 Other (the sandbox starting). For a connection: 1 Open. + Read with class_uid. For an API call: 1 Create (a write, a started process, a terminal), 2 Read, 3 Update (a move), 4 Delete (a removed file, a killed process), 99 Other. For a program: 1 Launch, 2 Terminate, 99 Sandbox Start. For a connection: 1 Open. activity_name: {type: string, description: "The name of activity_id within its class, such as Create or Launch."} type_uid: type: integer @@ -2665,10 +2675,11 @@ components: description: >- What a program returned when it ended. Zero means it succeeded. Present on a Process Activity Terminate event unless a signal stopped the program. dst_endpoint: {$ref: '#/components/schemas/AuditDstEndpoint'} + device: {$ref: '#/components/schemas/AuditDevice'} resources: type: array description: >- - What the call touched: the sandbox, then any file or process it named, the program it ran, and the terminal it ran in. + What the call touched: the sandbox, then any file or process it named, the program it ran, and the terminal it ran in. Present on API Activity only; Process and Network events name the sandbox in device. items: {$ref: '#/components/schemas/AuditResource'} cloud: {$ref: '#/components/schemas/AuditCloud'} src_endpoint: {$ref: '#/components/schemas/AuditEndpoint'} @@ -2677,17 +2688,30 @@ components: description: Threat intelligence about the event. Always empty. items: {type: object} metadata: {$ref: '#/components/schemas/AuditMetadata'} + AuditDevice: + type: object + description: >- + The sandbox the event happened in, as OCSF device. Present on every event. + required: [type_id] + properties: + type_id: + type: integer + enum: [6] + x-enum-varnames: [AuditDeviceTypeVirtual] + description: Always 6, Virtual. + type: {type: string, description: Always Virtual.} + uid: {type: string, description: The sandbox id.} AuditProcess: type: object description: >- - A program that ran inside the sandbox, as an OCSF process. On a Process Activity event it is the program that started or ended; on a Network Activity event it is actor.process, the program that opened the connection. + A program that ran inside the sandbox, as an OCSF process. On a Process Activity event it is the program that started or ended. As actor.process it is the program that opened a connection (Network Activity) or the parent that launched this program (Process Activity Launch, pid only). properties: pid: {type: integer, description: Identifies the program within the sandbox.} name: type: string description: >- The program, without its arguments. A program can name itself freely, so file.path is the stronger field. - cwd: {type: string, description: The working directory the program ran in.} + working_directory: {type: string, description: The working directory the program ran in.} file: {$ref: '#/components/schemas/AuditFile'} parent_process: {$ref: '#/components/schemas/AuditParentProcess'} AuditParentProcess: @@ -2698,11 +2722,18 @@ components: pid: {type: integer, description: Identifies the parent within the sandbox.} AuditFile: type: object - required: [path] + required: [name, path, type_id] properties: + name: {type: string, description: "The file name, the last element of path."} path: type: string description: The file that actually executed, after symlinks and interpreters were resolved. + type_id: + type: integer + enum: [8] + x-enum-varnames: [AuditFileTypeExecutable] + description: Always 8, Executable File. + type: {type: string, description: Always Executable File.} AuditDstEndpoint: type: object description: >- @@ -2737,20 +2768,37 @@ components: type: string enum: [sandbox, file, process, channel, terminal, interpreter_context] x-enum-varnames: [AuditResourceSandbox, AuditResourceFile, AuditResourceProcess, AuditResourceChannel, AuditResourceTerminal, AuditResourceInterpreterContext] - uid: {type: string, description: "The sandbox, terminal or interpreter context id."} + uid: {type: string, description: "The sandbox, terminal or interpreter context id, or the pid of a started process that has no name."} + data: {$ref: '#/components/schemas/AuditResourceData'} name: type: string description: >- A file path (a move reads `source -> destination`), a program name without its arguments, or an SSH channel such as `direct-tcpip db.internal:5432`, `tcpip-forward 0.0.0.0:8080` or `auth-agent `. A program reads `[redacted]` where its name could not be established, and a terminal command interrupted with Ctrl-C is prefixed `[Ctrl-C]`. Truncated with a trailing `...` if unusually long. + AuditResourceData: + type: object + description: >- + Set on the resource a call started a process for: the program it ran, or the terminal whose shell runs typed commands. + properties: + pid: + type: integer + description: >- + The process the call started. A Process Activity event whose pid, or any ancestor's pid through parent_process, equals it was started by this call, so its actor.user is who ran that program. AuditCloud: type: object required: [provider] properties: provider: {type: string, description: Always NeevCloud.} account: {$ref: '#/components/schemas/AuditAccount'} - project_uid: {type: string, description: The project id.} + org: {$ref: '#/components/schemas/AuditOrg'} AuditAccount: type: object + description: The project, as OCSF cloud.account. + required: [uid] + properties: + uid: {type: string, description: The project id.} + AuditOrg: + type: object + description: The organization, as OCSF cloud.org. required: [uid] properties: uid: {type: string, description: The organization id.} @@ -2769,6 +2817,10 @@ components: product: {$ref: '#/components/schemas/AuditProduct'} uid: {type: string, description: "Stable id of this event, unique within the trail."} tenant_uid: {type: string, description: The organization id.} + profiles: + type: array + items: {type: string} + description: The OCSF profiles the event uses, always cloud, osint and host. sequence: type: integer description: Ordinal of a terminal command within its terminal. Absent otherwise. @@ -2794,15 +2846,15 @@ components: properties: type: type: string - enum: [user, api_key, system, unknown] + enum: [User, API Key, System, Unknown] x-enum-varnames: [AuditActorTypeUser, AuditActorTypeAPIKey, AuditActorTypeSystem, AuditActorTypeUnknown] description: >- - A user in the console, an API key, the platform acting on its own, such as pairing a device, or unknown. + The OCSF caption of type_id: User for a user in the console, System for the platform acting on its own, such as pairing a device, Unknown when no principal can be named, and API Key (type_id 99, Other) for an API key. type_id: type: integer enum: [0, 1, 3, 99] x-enum-varnames: [AuditActorTypeIDUnknown, AuditActorTypeIDUser, AuditActorTypeIDSystem, AuditActorTypeIDOther] - description: "OCSF user.type_id: 0 (Unknown), 1 (User), 3 (System), 99 (Other) for api_key." + description: "OCSF user.type_id: 0 (Unknown), 1 (User), 3 (System), 99 (Other) for an API key." uid: type: string description: >- @@ -2810,9 +2862,16 @@ components: name: type: string description: The user's or the API key's name when the call ran. - credential_uid: - type: string - description: The API key the call was made with. Absent for a user. + programmatic_credentials: + type: array + description: The API key the call was made with, as OCSF programmatic_credentials. Absent for a user. + items: {$ref: '#/components/schemas/AuditProgrammaticCredential'} + AuditProgrammaticCredential: + type: object + required: [uid] + properties: + uid: {type: string, description: The API key's id.} + type: {type: string, description: Always API Key.} AuditActorSession: type: object description: The console session a user's call came from, as OCSF actor.session. diff --git a/src/neevai/__init__.py b/src/neevai/__init__.py index 69db99e..083fe00 100644 --- a/src/neevai/__init__.py +++ b/src/neevai/__init__.py @@ -48,7 +48,11 @@ AuditActor, AuditActorUser, AuditAPI, + AuditDevice, + AuditDstEndpoint, AuditEvent, + AuditFile, + AuditProcess, AuditResource, AuditTrail, CodeContext, @@ -104,7 +108,11 @@ "AuditAPI", "AuditActor", "AuditActorUser", + "AuditDevice", + "AuditDstEndpoint", "AuditEvent", + "AuditFile", + "AuditProcess", "AuditResource", "AuditTrail", "SandboxConnection", diff --git a/src/neevai/generated/aiagent.py b/src/neevai/generated/aiagent.py index 3e302c9..2cfe03f 100644 --- a/src/neevai/generated/aiagent.py +++ b/src/neevai/generated/aiagent.py @@ -380,15 +380,32 @@ class StatusId(IntEnum): AuditStatusFailure = 2 +class TypeId(IntEnum): + AuditDeviceTypeVirtual = 6 + + +class AuditDevice(BaseModel): + type_id: TypeId = Field(..., description="Always 6, Virtual.") + type: str | None = Field(None, description="Always Virtual.") + uid: str | None = Field(None, description="The sandbox id.") + + class AuditParentProcess(BaseModel): pid: int | None = Field(None, description="Identifies the parent within the sandbox.") +class TypeId1(IntEnum): + AuditFileTypeExecutable = 8 + + class AuditFile(BaseModel): + name: str = Field(..., description="The file name, the last element of path.") path: str = Field( ..., description="The file that actually executed, after symlinks and interpreters were resolved.", ) + type_id: TypeId1 = Field(..., description="Always 8, Executable File.") + type: str | None = Field(None, description="Always Executable File.") class AuditDstEndpoint(BaseModel): @@ -412,16 +429,18 @@ class Type(str, Enum): AuditResourceInterpreterContext = "interpreter_context" -class AuditResource(BaseModel): - type: Type - uid: str | None = Field(None, description="The sandbox, terminal or interpreter context id.") - name: str | None = Field( +class AuditResourceData(BaseModel): + pid: int | None = Field( None, - description="A file path (a move reads `source -> destination`), a program name without its arguments, or an SSH channel such as `direct-tcpip db.internal:5432`, `tcpip-forward 0.0.0.0:8080` or `auth-agent `. A program reads `[redacted]` where its name could not be established, and a terminal command interrupted with Ctrl-C is prefixed `[Ctrl-C]`. Truncated with a trailing `...` if unusually long.", + description="The process the call started. A Process Activity event whose pid, or any ancestor's pid through parent_process, equals it was started by this call, so its actor.user is who ran that program.", ) class AuditAccount(BaseModel): + uid: str = Field(..., description="The project id.") + + +class AuditOrg(BaseModel): uid: str = Field(..., description="The organization id.") @@ -435,38 +454,22 @@ class AuditProduct(BaseModel): class Type1(str, Enum): - AuditActorTypeUser = "user" - AuditActorTypeAPIKey = "api_key" - AuditActorTypeSystem = "system" - AuditActorTypeUnknown = "unknown" + AuditActorTypeUser = "User" + AuditActorTypeAPIKey = "API Key" + AuditActorTypeSystem = "System" + AuditActorTypeUnknown = "Unknown" -class TypeId(IntEnum): +class TypeId2(IntEnum): AuditActorTypeIDUnknown = 0 AuditActorTypeIDUser = 1 AuditActorTypeIDSystem = 3 AuditActorTypeIDOther = 99 -class AuditActorUser(BaseModel): - type: Type1 = Field( - ..., - description="A user in the console, an API key, the platform acting on its own, such as pairing a device, or unknown.", - ) - type_id: TypeId = Field( - ..., - description="OCSF user.type_id: 0 (Unknown), 1 (User), 3 (System), 99 (Other) for api_key.", - ) - uid: str = Field( - ..., - description="The user's id, the API key's id, a stable name for what the platform ran, such as device-pairing, or unknown.", - ) - name: str | None = Field( - None, description="The user's or the API key's name when the call ran." - ) - credential_uid: str | None = Field( - None, description="The API key the call was made with. Absent for a user." - ) +class AuditProgrammaticCredential(BaseModel): + uid: str = Field(..., description="The API key's id.") + type: str | None = Field(None, description="Always API Key.") class AuditActorSession(BaseModel): @@ -561,7 +564,9 @@ class AuditProcess(BaseModel): None, description="The program, without its arguments. A program can name itself freely, so file.path is the stronger field.", ) - cwd: str | None = Field(None, description="The working directory the program ran in.") + working_directory: str | None = Field( + None, description="The working directory the program ran in." + ) file: AuditFile | None = None parent_process: AuditParentProcess | None = None @@ -574,10 +579,23 @@ class AuditAPI(BaseModel): request: AuditAPIRequest | None = None +class AuditResource(BaseModel): + type: Type + uid: str | None = Field( + None, + description="The sandbox, terminal or interpreter context id, or the pid of a started process that has no name.", + ) + data: AuditResourceData | None = None + name: str | None = Field( + None, + description="A file path (a move reads `source -> destination`), a program name without its arguments, or an SSH channel such as `direct-tcpip db.internal:5432`, `tcpip-forward 0.0.0.0:8080` or `auth-agent `. A program reads `[redacted]` where its name could not be established, and a terminal command interrupted with Ctrl-C is prefixed `[Ctrl-C]`. Truncated with a trailing `...` if unusually long.", + ) + + class AuditCloud(BaseModel): provider: str = Field(..., description="Always NeevCloud.") account: AuditAccount | None = None - project_uid: str | None = Field(None, description="The project id.") + org: AuditOrg | None = None class AuditMetadata(BaseModel): @@ -585,16 +603,36 @@ class AuditMetadata(BaseModel): product: AuditProduct uid: str = Field(..., description="Stable id of this event, unique within the trail.") tenant_uid: str | None = Field(None, description="The organization id.") + profiles: list[str] | None = Field( + None, + description="The OCSF profiles the event uses, always cloud, osint and host.", + ) sequence: int | None = Field( None, description="Ordinal of a terminal command within its terminal. Absent otherwise.", ) -class AuditActor(BaseModel): - user: AuditActorUser - session: AuditActorSession | None = None - process: AuditProcess | None = None +class AuditActorUser(BaseModel): + type: Type1 = Field( + ..., + description="The OCSF caption of type_id: User for a user in the console, System for the platform acting on its own, such as pairing a device, Unknown when no principal can be named, and API Key (type_id 99, Other) for an API key.", + ) + type_id: TypeId2 = Field( + ..., + description="OCSF user.type_id: 0 (Unknown), 1 (User), 3 (System), 99 (Other) for an API key.", + ) + uid: str = Field( + ..., + description="The user's id, the API key's id, a stable name for what the platform ran, such as device-pairing, or unknown.", + ) + name: str | None = Field( + None, description="The user's or the API key's name when the call ran." + ) + programmatic_credentials: list[AuditProgrammaticCredential] | None = Field( + None, + description="The API key the call was made with, as OCSF programmatic_credentials. Absent for a user.", + ) class Agent(BaseModel): @@ -1098,6 +1136,12 @@ class SandboxListResponse(BaseModel): limit: int +class AuditActor(BaseModel): + user: AuditActorUser + session: AuditActorSession | None = None + process: AuditProcess | None = None + + class AuditEvent(BaseModel): class_uid: ClassUid = Field( ..., @@ -1113,7 +1157,7 @@ class AuditEvent(BaseModel): ) activity_id: ActivityId = Field( ..., - description="Read with class_uid. For an API call: 1 Create (a write, a started process, a terminal), 2 Read, 3 Update (a move), 4 Delete (a removed file, a killed process), 99 Other. For a program: 1 Launch, 2 Terminate, 99 Other (the sandbox starting). For a connection: 1 Open.", + description="Read with class_uid. For an API call: 1 Create (a write, a started process, a terminal), 2 Read, 3 Update (a move), 4 Delete (a removed file, a killed process), 99 Other. For a program: 1 Launch, 2 Terminate, 99 Sandbox Start. For a connection: 1 Open.", ) activity_name: str = Field( ..., @@ -1153,9 +1197,10 @@ class AuditEvent(BaseModel): description="What a program returned when it ended. Zero means it succeeded. Present on a Process Activity Terminate event unless a signal stopped the program.", ) dst_endpoint: AuditDstEndpoint | None = None + device: AuditDevice | None = None resources: list[AuditResource] | None = Field( None, - description="What the call touched: the sandbox, then any file or process it named, the program it ran, and the terminal it ran in.", + description="What the call touched: the sandbox, then any file or process it named, the program it ran, and the terminal it ran in. Present on API Activity only; Process and Network events name the sandbox in device.", ) cloud: AuditCloud src_endpoint: AuditEndpoint | None = None diff --git a/src/neevai/handles/sandbox.py b/src/neevai/handles/sandbox.py index 381bba9..4df1485 100644 --- a/src/neevai/handles/sandbox.py +++ b/src/neevai/handles/sandbox.py @@ -120,6 +120,11 @@ def addressable(self) -> bool: """Whether the sandbox can be reached yet; briefly False right after create.""" return self._state.addressable is not False + @property + def execution_trace(self) -> bool: + """Whether every program the sandbox runs is recorded; True unless turned off.""" + return self._state.execution_trace is not False + @property def labels(self) -> dict[str, str]: """Key-value labels the sandbox was created with; empty when it has none.""" @@ -171,7 +176,7 @@ def update( allow_internet: bool | None = None, allow_egress: list[str] | None = None, ) -> Sandbox: - """Resizes this sandbox and/or re-scopes its egress in place. + """Resizes, re-scopes egress or sets ``execution_trace`` in place. Updates handle state from the response. The sandbox keeps its ID, name, and preview URLs and is not restarted, so the cached runtime connection stays valid. @@ -651,6 +656,11 @@ def addressable(self) -> bool: """Whether the sandbox can be reached yet; briefly False right after create.""" return self._state.addressable is not False + @property + def execution_trace(self) -> bool: + """Whether every program the sandbox runs is recorded; True unless turned off.""" + return self._state.execution_trace is not False + @property def labels(self) -> dict[str, str]: """Key-value labels the sandbox was created with; empty when it has none.""" @@ -692,7 +702,7 @@ async def update( allow_internet: bool | None = None, allow_egress: list[str] | None = None, ) -> AsyncSandbox: - """Resizes this sandbox and/or re-scopes its egress in place (async). + """Resizes, re-scopes egress or sets ``execution_trace`` in place (async). Updates handle state from the response. The sandbox keeps its ID, name, and preview URLs and is not restarted, so the cached runtime connection stays valid. diff --git a/src/neevai/resources/sandboxes.py b/src/neevai/resources/sandboxes.py index 5b9cf5b..32fa82f 100644 --- a/src/neevai/resources/sandboxes.py +++ b/src/neevai/resources/sandboxes.py @@ -346,7 +346,7 @@ def update( allow_internet: bool | None = None, allow_egress: builtins.list[str] | None = None, ) -> Sandbox: - """Updates a running sandbox in place (``resources`` and/or egress). + """Updates a running sandbox in place (``resources``, egress or ``execution_trace``). ``resources`` (cpu/memory) are resized on the running sandbox without a restart; ``disk_gb`` is not resizable in place and is rejected by the server if @@ -356,9 +356,9 @@ def update( explicit ``egress`` in ``params`` takes precedence. ``egress_add`` / ``egress_remove`` edit the existing allow-list in place instead, leaving other rules untouched (removals apply first, so one call can swap a host); they cannot - be combined with ``egress`` or the convenience flags. ``execution_trace=False`` - stops recording every program the sandbox runs (on by default); ``True`` turns - it back on. At least one of ``resources``, ``egress``, ``egress_add``, + be combined with ``egress`` or the convenience flags. ``execution_trace`` + turns recording of every program the sandbox runs off (False) or back on (True), + with no restart. At least one of ``resources``, ``egress``, ``egress_add``, ``egress_remove`` or ``execution_trace`` must result. """ from neevai.handles.sandbox import Sandbox @@ -842,9 +842,9 @@ async def update( explicit ``egress`` in ``params`` takes precedence. ``egress_add`` / ``egress_remove`` edit the existing allow-list in place instead, leaving other rules untouched (removals apply first, so one call can swap a host); they cannot - be combined with ``egress`` or the convenience flags. ``execution_trace=False`` - stops recording every program the sandbox runs (on by default); ``True`` turns - it back on. At least one of ``resources``, ``egress``, ``egress_add``, + be combined with ``egress`` or the convenience flags. ``execution_trace`` + turns recording of every program the sandbox runs off (False) or back on (True), + with no restart. At least one of ``resources``, ``egress``, ``egress_add``, ``egress_remove`` or ``execution_trace`` must result. """ from neevai.handles.sandbox import AsyncSandbox diff --git a/src/neevai/types.py b/src/neevai/types.py index d8cf543..b4602c1 100644 --- a/src/neevai/types.py +++ b/src/neevai/types.py @@ -13,7 +13,11 @@ "AuditAPI", "AuditActor", "AuditActorUser", + "AuditDevice", + "AuditDstEndpoint", "AuditEvent", + "AuditFile", + "AuditProcess", "AuditResource", "AuditTrail", "CreateAgentParams", @@ -91,9 +95,21 @@ from neevai.generated.aiagent import ( # noqa: F401 AuditAPI as AuditAPI, ) +from neevai.generated.aiagent import ( # noqa: F401 + AuditDevice as AuditDevice, +) +from neevai.generated.aiagent import ( # noqa: F401 + AuditDstEndpoint as AuditDstEndpoint, +) from neevai.generated.aiagent import ( # noqa: F401 AuditEvent as AuditEvent, ) +from neevai.generated.aiagent import ( # noqa: F401 + AuditFile as AuditFile, +) +from neevai.generated.aiagent import ( # noqa: F401 + AuditProcess as AuditProcess, +) from neevai.generated.aiagent import ( # noqa: F401 AuditResource as AuditResource, ) diff --git a/tests/test_audit.py b/tests/test_audit.py index ec65c48..ddc0b71 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -82,11 +82,11 @@ def _event(uid: str, operation: str, status_id: int, actor: dict, **extra) -> di 1, { "user": { - "type": "api_key", + "type": "API Key", "type_id": 99, "uid": "key-1", "name": "ci", - "credential_uid": "key-1", + "programmatic_credentials": [{"uid": "key-1", "type": "API Key"}], } }, duration=120, @@ -97,7 +97,7 @@ def _event(uid: str, operation: str, status_id: int, actor: dict, **extra) -> di "e2", "fs.read", 2, - {"user": {"type": "user", "type_id": 1, "uid": "user-1"}, "session": {"uid": "s1"}}, + {"user": {"type": "User", "type_id": 1, "uid": "user-1"}, "session": {"uid": "s1"}}, status_code="permission_denied", resources=[{"type": "file", "name": "notes.txt"}], ), @@ -124,9 +124,10 @@ def _assert_trail(trail: AuditTrail) -> None: assert first.api is not None and first.api.operation == "exec" assert first.status == "Success" assert first.time == 1767268800000 - assert first.actor.user.type == "api_key" + assert first.actor.user.type == "API Key" assert first.actor.user.name == "ci" - assert first.actor.user.credential_uid == "key-1" + creds = first.actor.user.programmatic_credentials + assert creds is not None and creds[0].uid == "key-1" assert second.status_code == "permission_denied" assert second.actor.user.name is None assert second.actor.session is not None and second.actor.session.uid == "s1" @@ -307,43 +308,111 @@ async def test_async_handles_pass_filters(): await client.aclose() -def test_program_and_connection_events_validate() -> None: - unknown = {"type": "unknown", "type_id": 0, "uid": "unknown"} - launch = { - **_event("p1", "exec", 1, {"user": unknown}), +def _program(activity_id: int, name: str, **extra) -> dict: + return { "class_uid": 1007, "class_name": "Process Activity", "category_uid": 1, "category_name": "System Activity", - "activity_name": "Launch", - "type_uid": 100701, - "type_name": "Process Activity: Launch", - "process": { - "pid": 31, - "name": "curl", - "cwd": "/workspace", - "file": {"path": "/usr/bin/curl"}, - "parent_process": {"pid": 12}, + "activity_id": activity_id, + "activity_name": {1: "Launch", 2: "Terminate"}[activity_id], + "type_uid": 100700 + activity_id, + "type_name": "Process Activity: " + {1: "Launch", 2: "Terminate"}[activity_id], + "time": 1767268800000, + "severity_id": 1, + "severity": "Informational", + "status_id": 1, + "status": "Success", + "actor": {"user": {"type": "Unknown", "type_id": 0, "uid": "unknown"}}, + "device": {"type_id": 6, "type": "Virtual", "uid": SANDBOX_ID}, + "cloud": {"provider": "NeevCloud"}, + "osint": [], + "metadata": { + "version": "1.9.0", + "product": {"name": "Sandbox Audit", "vendor_name": "NeevCloud"}, + "uid": f"p-{name}-{activity_id}", }, + **extra, } - connect = { - **_event("p2", "connect", 1, {"user": unknown, "process": {"pid": 31, "name": "curl"}}), - "class_uid": 4001, - "class_name": "Network Activity", - "category_uid": 4, - "category_name": "Network Activity", - "activity_name": "Open", - "type_uid": 400101, - "type_name": "Network Activity: Open", - "dst_endpoint": {"ip": "169.254.25.10", "port": 53}, - } - for e in (launch, connect): - del e["api"], e["src_endpoint"] - trail = AuditTrail.model_validate({**TRAIL, "data": [launch, connect]}) - first, second = trail.data - assert first.api is None - assert first.process is not None and first.process.parent_process is not None - assert first.process.parent_process.pid == 12 - assert second.actor.process is not None and second.actor.process.name == "curl" - assert second.dst_endpoint is not None and second.dst_endpoint.port == 53 + +CLASSES_TRAIL = { + "from": "2026-01-01T00:00:00Z", + "to": "2026-01-02T00:00:00Z", + "retention_days": 30, + "window_truncated": False, + "data": [ + { + **_program(1, "curl"), + "class_uid": 4001, + "class_name": "Network Activity", + "category_uid": 4, + "category_name": "Network Activity", + "activity_name": "Open", + "type_uid": 400101, + "type_name": "Network Activity: Open", + "actor": { + "user": {"type": "Unknown", "type_id": 0, "uid": "unknown"}, + "process": {"pid": 40, "name": "curl", "parent_process": {"pid": 39}}, + }, + "dst_endpoint": {"ip": "10.0.0.5", "port": 443}, + }, + _program( + 2, + "curl", + status_id=2, + status="Failure", + status_detail="signal 9", + process={"pid": 40, "name": "curl", "parent_process": {"pid": 39}}, + ), + _program( + 1, + "curl", + actor={ + "user": {"type": "Unknown", "type_id": 0, "uid": "unknown"}, + "process": {"pid": 39}, + }, + process={ + "pid": 40, + "name": "curl", + "working_directory": "/workspace", + "file": { + "name": "curl", + "path": "/usr/bin/curl", + "type_id": 8, + "type": "Executable File", + }, + "parent_process": {"pid": 39}, + }, + ), + TRAIL["data"][0], + ], +} + + +def test_audit_parses_every_event_class(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=CLASSES_TRAIL) + + client = NeevAI( + api_key="k", + base_url="https://api.example.com", + org_id="org1", + project_id="proj1", + client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + connect, killed, launch, call = client.sandboxes.audit(SANDBOX_ID).data + assert [e.class_uid for e in (connect, killed, launch, call)] == [4001, 1007, 1007, 6003] + assert connect.api is None and connect.dst_endpoint is not None + assert connect.dst_endpoint.ip == "10.0.0.5" and connect.dst_endpoint.port == 443 + assert connect.actor.process is not None and connect.actor.process.name == "curl" + assert killed.exit_code is None and killed.status_detail == "signal 9" + assert killed.status == "Failure" + assert launch.process is not None and launch.process.file is not None + assert launch.process.file.path == "/usr/bin/curl" + assert launch.process.parent_process is not None + assert launch.process.parent_process.pid == 39 + assert launch.device is not None and launch.device.uid == SANDBOX_ID + assert launch.actor.user.type == "Unknown" + assert call.api is not None and call.api.operation == "exec" + client.close() diff --git a/tests/test_generated_enums.py b/tests/test_generated_enums.py index bf4261d..3e690a6 100644 --- a/tests/test_generated_enums.py +++ b/tests/test_generated_enums.py @@ -10,6 +10,6 @@ def test_snapshot_status_equals_its_string() -> None: def test_audit_actor_type_equals_its_string() -> None: - """Callers filter audit events with `event.actor.user.type == "api_key"`.""" - user = AuditActorUser.model_validate({"type": "api_key", "type_id": 99, "uid": "key-1"}) - assert user.type == "api_key" + """Callers filter audit events with `event.actor.user.type == "API Key"`.""" + user = AuditActorUser.model_validate({"type": "API Key", "type_id": 99, "uid": "key-1"}) + assert user.type == "API Key" diff --git a/tests/test_sandboxes.py b/tests/test_sandboxes.py index 98af9f9..07daa5d 100644 --- a/tests/test_sandboxes.py +++ b/tests/test_sandboxes.py @@ -521,16 +521,18 @@ def test_sandboxes_update_resources_and_egress_single_patch(mock_transport): client.close() -def test_sandboxes_update_execution_trace_alone(mock_transport): +def test_sandboxes_update_execution_trace(mock_transport): client = _make_client(mock_transport) sb = client.sandboxes.create({"name": "s1", "sandbox_template_id": "sb-x"}) + assert sb.execution_trace is True captured = _capture_requests(client) - client.sandboxes.update(sb.id, {"execution_trace": False}) + updated = client.sandboxes.update(sb.id, {"execution_trace": False}) - method, _path, body = captured[-1] + method, _, body = captured[-1] assert method == "PATCH" assert body == {"execution_trace": False} + assert updated.execution_trace is False client.close()