diff --git a/README.md b/README.md index 5eceee3..e186df4 100644 --- a/README.md +++ b/README.md @@ -297,16 +297,25 @@ See [`sandbox_metrics.py`](examples/sandbox_metrics.py) · [reference](docs/api- ### Audit trail -Read what was run inside a sandbox, newest first: commands, terminal and SSH sessions, -and file and process operations. Each event says who made the call and how it ended. -Programs are recorded by name only, never with their arguments. +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. ```python -trail = sandbox.audit(operation="exec", status="failure") +trail = sandbox.audit(status="failure") for e in trail.data: - print(e.time, e.api.operation, e.actor.user.uid, e.status) + 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) ``` +Program recording is on by default; turn it 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. diff --git a/docs/api-inventory.md b/docs/api-inventory.md index 8f0613d..d515ef1 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -435,10 +435,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: terminal commands, SSH, -process and file operations, each with who made the call and how it ended. 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 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. `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 @@ -459,7 +460,8 @@ Filters, all optional: trail = sandbox.audit(limit=50) while True: for e in trail.data: - print(e.time, e.api.operation, e.actor.user.name or e.actor.user.uid, e.status) + 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) if not trail.next_cursor: break trail = sandbox.audit(limit=50, cursor=trail.next_cursor) @@ -1911,15 +1913,28 @@ 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 call, in the OCSF API Activity format. The fields most callers read: +`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: | Field | Type | Required | | ----- | ---- | -------- | | `time` | `int` | yes (milliseconds since the Unix epoch) | -| `api.operation` | `str` | yes (e.g. `pty_command`, `ssh`, `exec`, `process.start`, `fs.read`) | +| `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 | +| `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) | diff --git a/examples/agent_ports_audit.py b/examples/agent_ports_audit.py index dc9c471..5b66e77 100644 --- a/examples/agent_ports_audit.py +++ b/examples/agent_ports_audit.py @@ -62,8 +62,12 @@ def main() -> None: trail = agent.audit(limit=20) for e in trail.data: at = datetime.fromtimestamp(e.time / 1000, tz=timezone.utc) - who = e.actor.user.name or e.actor.user.uid - print(f"{at:%H:%M:%S} {e.api.operation:<14} {who} {e.status}") + 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}") agent.revoke_port(PORT) except NeevAIError as e: diff --git a/examples/audit_trail.py b/examples/audit_trail.py index f28b129..8dbf8ac 100644 --- a/examples/audit_trail.py +++ b/examples/audit_trail.py @@ -1,10 +1,11 @@ """ Run a few commands in a sandbox, then read back its audit trail. -Each event is one call made inside the sandbox — terminal commands, SSH, process -and file operations — newest first, with who made it and how it ended. Only the -program name is recorded, never its arguments. Filter by ``operation``, -``status``, ``actor_type``, ``actor_uid`` or ``credential_uid``. +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``. Pages are read with ``cursor``: pass the previous page's ``next_cursor`` until it is empty. @@ -28,10 +29,23 @@ TEMPLATE = os.environ.get("NEEV_SANDBOX_TEMPLATE_ID", "sb-ubuntu-26-04-dev") +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: + 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) - who = e.actor.user.name or e.actor.user.uid - print(f"{at:%H:%M:%S} {e.api.operation:<14} {who:<20} {e.status}") + print(f"{at:%H:%M:%S} {describe(e):<40} {e.status}") def main() -> None: @@ -63,8 +77,8 @@ def main() -> None: break trail = sandbox.audit(limit=20, cursor=trail.next_cursor) - # Only the commands that were run. - print("exec calls:") + # Exec calls and program launches. + print("exec:") for e in sandbox.audit(operation="exec").data: show(e) except NeevAIError as e: diff --git a/specs/aiagent.yaml b/specs/aiagent.yaml index 760a40a..4edc42e 100644 --- a/specs/aiagent.yaml +++ b/specs/aiagent.yaml @@ -1904,7 +1904,7 @@ components: in: query required: false description: >- - Only these calls (api.operation), one or several separated by commas, such as `exec,pty_command`. + 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. schema: {type: string} AuditStatus: name: status @@ -2075,6 +2075,12 @@ components: type: object required: [id, org_id, project_id, name, region, image, phase, created_at, updated_at] properties: + execution_trace: + type: boolean + description: | + Whether this sandbox records every program that runs inside it, which + is on unless it was turned off. False means it records only the + operations you request. id: {type: string, format: uuid} org_id: {type: string} project_id: {type: string} @@ -2285,9 +2291,9 @@ components: type: object additionalProperties: false description: | - Request to update a running sandbox's CPU/memory and/or egress rules. - At least one of `resources`, `egress`, `egress_add` or `egress_remove` - must be provided. `egress` replaces the policy in full; `egress_add` and + Request to update a running sandbox's CPU/memory, egress rules, or whether + it records what runs inside it. At least one of `resources`, `egress`, + `egress_add`, `egress_remove` or `execution_trace` must be provided. `egress` replaces the policy in full; `egress_add` and `egress_remove` edit the allow-list in place and cannot be combined with it. properties: resources: @@ -2304,6 +2310,14 @@ components: New egress policy for the sandbox. Replaces the existing policy in full and takes effect immediately for new connections — no sandbox restart is required. + execution_trace: + type: boolean + description: | + Record every program that runs inside this sandbox, not only the + operations you request. On by default; false turns it off, and true + turns it back on. Takes effect on the running sandbox without a + restart. Recording costs a little processor time on each program + started. egress_add: allOf: - $ref: '#/components/schemas/SandboxEgressRules' @@ -2587,33 +2601,34 @@ components: AuditEvent: type: object description: >- - One call to the sandbox, as an OCSF 1.9.0 API Activity event (class_uid 6003). Program names are recorded without their arguments, and input typed at a hidden password prompt is never recorded. - required: [class_uid, class_name, category_uid, category_name, activity_id, activity_name, type_uid, type_name, time, severity_id, severity, status_id, status, actor, api, cloud, src_endpoint, osint, metadata] + One thing that happened in the sandbox, as an OCSF 1.9.0 event: an API Activity event (class_uid 6003) for a call to the sandbox. Unless its `execution_trace` is turned off, a sandbox also records every program that runs inside it, whether or not anything requested it: a Process Activity event (1007) for a program starting or ending, and a Network Activity event (4001) for a connection a program opened. Program names are recorded without their arguments, and input typed at a hidden password prompt is never recorded. + required: [class_uid, class_name, category_uid, category_name, activity_id, activity_name, type_uid, type_name, time, severity_id, severity, status_id, status, actor, cloud, osint, metadata] properties: class_uid: type: integer - enum: [6003] - x-enum-varnames: [AuditClassAPIActivity] - description: OCSF class, API Activity. - class_name: {type: string, description: Always API Activity.} + enum: [6003, 1007, 4001] + x-enum-varnames: [AuditClassAPIActivity, AuditClassProcessActivity, AuditClassNetworkActivity] + description: OCSF class, API Activity, Process Activity or Network Activity. + class_name: {type: string, description: "The name of class_uid, such as API Activity."} category_uid: type: integer - enum: [6] - x-enum-varnames: [AuditCategoryApplicationActivity] - description: OCSF category, Application Activity. - category_name: {type: string, description: Always Application Activity.} + enum: [6, 1, 4] + x-enum-varnames: [AuditCategoryApplicationActivity, AuditCategorySystemActivity, AuditCategoryNetworkActivity] + description: >- + OCSF category: Application Activity for an API call, System Activity for a program, Network Activity for a connection. + category_name: {type: string, description: "The name of category_uid, such as Application Activity."} activity_id: type: integer enum: [1, 2, 3, 4, 99] x-enum-varnames: [AuditActivityCreate, AuditActivityRead, AuditActivityUpdate, AuditActivityDelete, AuditActivityOther] description: >- - 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. - activity_name: {type: string, description: "The name of activity_id, such as Create."} + 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. + activity_name: {type: string, description: "The name of activity_id within its class, such as Create or Launch."} type_uid: type: integer format: int64 - description: class_uid * 100 + activity_id, such as 600301. - type_name: {type: string, description: "Such as API Activity: Create."} + description: class_uid * 100 + activity_id, such as 600301 or 100701. + type_name: {type: string, description: "Such as API Activity: Create or Process Activity: Launch."} time: type: integer format: int64 @@ -2641,9 +2656,15 @@ components: status_detail: type: string description: >- - How a code-interpreter cell ended: `ok`, `error`, `deadline_exceeded`, `kernel_restarted`, `memory_exceeded`, `unavailable`, `invalid_response`, or `client_gone` when the caller disconnected first. + How a code-interpreter cell ended: `ok`, `error`, `deadline_exceeded`, `kernel_restarted`, `memory_exceeded`, `unavailable`, `invalid_response`, or `client_gone` when the caller disconnected first. On a program that was stopped rather than ending on its own, such as one out of memory or told to stop, `signal ` names the signal that stopped it. actor: {$ref: '#/components/schemas/AuditActor'} api: {$ref: '#/components/schemas/AuditAPI'} + process: {$ref: '#/components/schemas/AuditProcess'} + exit_code: + type: integer + 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'} resources: type: array description: >- @@ -2653,12 +2674,48 @@ components: src_endpoint: {$ref: '#/components/schemas/AuditEndpoint'} osint: type: array - description: Threat intelligence about the call. Always empty. + description: Threat intelligence about the event. Always empty. items: {type: object} metadata: {$ref: '#/components/schemas/AuditMetadata'} + 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. + 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.} + file: {$ref: '#/components/schemas/AuditFile'} + parent_process: {$ref: '#/components/schemas/AuditParentProcess'} + AuditParentProcess: + type: object + description: >- + The process that started this one, so a run can be traced back to what caused it. Its own Launch event names it. + properties: + pid: {type: integer, description: Identifies the parent within the sandbox.} + AuditFile: + type: object + required: [path] + properties: + path: + type: string + description: The file that actually executed, after symlinks and interpreters were resolved. + AuditDstEndpoint: + type: object + description: >- + Where a program tried to connect, as OCSF dst_endpoint. It records the attempt and where it was aimed, not what was sent. + properties: + ip: {type: string, description: The address reached for.} + port: {type: integer, description: The port reached for.} + name: + type: string + description: A local socket, as `unix:`, in place of ip and port. AuditAPI: type: object - description: The call made to the sandbox, as OCSF api. + description: The call made to the sandbox, as OCSF api. Present on API Activity only. required: [operation] properties: operation: @@ -2700,7 +2757,7 @@ components: AuditEndpoint: type: object description: >- - Where the call came from. The caller's address is not recorded yet, so name is Platform for the platform's own calls and unknown otherwise. + Where the call came from. The caller's address is not recorded yet, so name is Platform for the platform's own calls and unknown otherwise. Present on API Activity only. required: [name] properties: name: {type: string} @@ -2724,11 +2781,12 @@ components: AuditActor: type: object description: >- - Who made the call, as an OCSF actor. A console record from before actors were recorded names only its session, so its user is unknown. + Who made the call, as an OCSF actor. A console record from before actors were recorded names only its session, so its user is unknown. A program's event records no user, so its user is unknown too. required: [user] properties: user: {$ref: '#/components/schemas/AuditActorUser'} session: {$ref: '#/components/schemas/AuditActorSession'} + process: {$ref: '#/components/schemas/AuditProcess'} AuditActorUser: type: object description: The principal that acted, as OCSF actor.user. diff --git a/src/neevai/generated/aiagent.py b/src/neevai/generated/aiagent.py index b446f1a..3e302c9 100644 --- a/src/neevai/generated/aiagent.py +++ b/src/neevai/generated/aiagent.py @@ -353,10 +353,14 @@ class ConnectTokenResponse(BaseModel): class ClassUid(IntEnum): AuditClassAPIActivity = 6003 + AuditClassProcessActivity = 1007 + AuditClassNetworkActivity = 4001 class CategoryUid(IntEnum): AuditCategoryApplicationActivity = 6 + AuditCategorySystemActivity = 1 + AuditCategoryNetworkActivity = 4 class ActivityId(IntEnum): @@ -376,6 +380,25 @@ class StatusId(IntEnum): AuditStatusFailure = 2 +class AuditParentProcess(BaseModel): + pid: int | None = Field(None, description="Identifies the parent within the sandbox.") + + +class AuditFile(BaseModel): + path: str = Field( + ..., + description="The file that actually executed, after symlinks and interpreters were resolved.", + ) + + +class AuditDstEndpoint(BaseModel): + ip: str | None = Field(None, description="The address reached for.") + port: int | None = Field(None, description="The port reached for.") + name: str | None = Field( + None, description="A local socket, as `unix:`, in place of ip and port." + ) + + class AuditAPIRequest(BaseModel): uid: str = Field(..., description="The request that caused the call, for correlation.") @@ -532,6 +555,17 @@ class SandboxEgressConfig(BaseModel): ) +class AuditProcess(BaseModel): + pid: int | None = Field(None, description="Identifies the program within the sandbox.") + name: str | None = Field( + 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.") + file: AuditFile | None = None + parent_process: AuditParentProcess | None = None + + class AuditAPI(BaseModel): operation: str = Field( ..., @@ -560,6 +594,7 @@ class AuditMetadata(BaseModel): class AuditActor(BaseModel): user: AuditActorUser session: AuditActorSession | None = None + process: AuditProcess | None = None class Agent(BaseModel): @@ -708,6 +743,10 @@ class AgentTemplateListResponse(BaseModel): class Sandbox(BaseModel): + execution_trace: bool | None = Field( + None, + description="Whether this sandbox records every program that runs inside it, which\nis on unless it was turned off. False means it records only the\noperations you request.\n", + ) id: UUID org_id: str project_id: str @@ -1038,6 +1077,10 @@ class UpdateSandboxRequest(BaseModel): None, description="New egress policy for the sandbox. Replaces the existing policy\nin full and takes effect immediately for new connections — no sandbox\nrestart is required.\n", ) + execution_trace: bool | None = Field( + None, + description="Record every program that runs inside this sandbox, not only the\noperations you request. On by default; false turns it off, and true\nturns it back on. Takes effect on the running sandbox without a\nrestart. Recording costs a little processor time on each program\nstarted.\n", + ) egress_add: SandboxEgressRules | None = Field( None, description="Destinations to add to the existing allow-list, leaving every other\nrule in place. Adding a host already allowed replaces its ports and\nprotocol. Rejected with `egress`, and rejected when the current mode\nis deny_all — switch the mode with `egress` first.\n", @@ -1056,17 +1099,32 @@ class SandboxListResponse(BaseModel): class AuditEvent(BaseModel): - class_uid: ClassUid = Field(..., description="OCSF class, API Activity.") - class_name: str = Field(..., description="Always API Activity.") - category_uid: CategoryUid = Field(..., description="OCSF category, Application Activity.") - category_name: str = Field(..., description="Always Application Activity.") + class_uid: ClassUid = Field( + ..., + description="OCSF class, API Activity, Process Activity or Network Activity.", + ) + class_name: str = Field(..., description="The name of class_uid, such as API Activity.") + category_uid: CategoryUid = Field( + ..., + description="OCSF category: Application Activity for an API call, System Activity for a program, Network Activity for a connection.", + ) + category_name: str = Field( + ..., description="The name of category_uid, such as Application Activity." + ) activity_id: ActivityId = Field( ..., - description="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.", + 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.", + ) + activity_name: str = Field( + ..., + description="The name of activity_id within its class, such as Create or Launch.", + ) + type_uid: int = Field( + ..., description="class_uid * 100 + activity_id, such as 600301 or 100701." + ) + type_name: str = Field( + ..., description="Such as API Activity: Create or Process Activity: Launch." ) - activity_name: str = Field(..., description="The name of activity_id, such as Create.") - type_uid: int = Field(..., description="class_uid * 100 + activity_id, such as 600301.") - type_name: str = Field(..., description="Such as API Activity: Create.") time: int = Field( ..., description="When the call was recorded, in milliseconds since the Unix epoch.", @@ -1085,18 +1143,24 @@ class AuditEvent(BaseModel): ) status_detail: str | None = Field( None, - description="How a code-interpreter cell ended: `ok`, `error`, `deadline_exceeded`, `kernel_restarted`, `memory_exceeded`, `unavailable`, `invalid_response`, or `client_gone` when the caller disconnected first.", + description="How a code-interpreter cell ended: `ok`, `error`, `deadline_exceeded`, `kernel_restarted`, `memory_exceeded`, `unavailable`, `invalid_response`, or `client_gone` when the caller disconnected first. On a program that was stopped rather than ending on its own, such as one out of memory or told to stop, `signal ` names the signal that stopped it.", ) actor: AuditActor - api: AuditAPI + api: AuditAPI | None = None + process: AuditProcess | None = None + exit_code: int | None = Field( + None, + 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 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.", ) cloud: AuditCloud - src_endpoint: AuditEndpoint + src_endpoint: AuditEndpoint | None = None osint: list[dict[str, Any]] = Field( - ..., description="Threat intelligence about the call. Always empty." + ..., description="Threat intelligence about the event. Always empty." ) metadata: AuditMetadata diff --git a/src/neevai/resources/sandboxes.py b/src/neevai/resources/sandboxes.py index 8f7d4b0..5b9cf5b 100644 --- a/src/neevai/resources/sandboxes.py +++ b/src/neevai/resources/sandboxes.py @@ -356,8 +356,10 @@ 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. At least one of - ``resources``, ``egress``, ``egress_add`` or ``egress_remove`` must result. + 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``, + ``egress_remove`` or ``execution_trace`` must result. """ from neevai.handles.sandbox import Sandbox @@ -840,8 +842,10 @@ 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. At least one of - ``resources``, ``egress``, ``egress_add`` or ``egress_remove`` must result. + 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``, + ``egress_remove`` or ``execution_trace`` must result. """ from neevai.handles.sandbox import AsyncSandbox diff --git a/tests/test_audit.py b/tests/test_audit.py index b121e3f..ec65c48 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -121,7 +121,7 @@ def _assert_trail(trail: AuditTrail) -> None: assert trail.next_cursor == "c2" assert [e.metadata.uid for e in trail.data] == ["e1", "e2"] first, second = trail.data - assert first.api.operation == "exec" + 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" @@ -305,3 +305,45 @@ async def test_async_handles_pass_filters(): assert dict(captured[0].url.params) == {"status": "success"} assert dict(captured[1].url.params) == {"credential_uid": "key-1"} 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}), + "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}, + }, + } + 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 diff --git a/tests/test_sandboxes.py b/tests/test_sandboxes.py index a106caf..98af9f9 100644 --- a/tests/test_sandboxes.py +++ b/tests/test_sandboxes.py @@ -521,6 +521,19 @@ def test_sandboxes_update_resources_and_egress_single_patch(mock_transport): client.close() +def test_sandboxes_update_execution_trace_alone(mock_transport): + client = _make_client(mock_transport) + sb = client.sandboxes.create({"name": "s1", "sandbox_template_id": "sb-x"}) + captured = _capture_requests(client) + + client.sandboxes.update(sb.id, {"execution_trace": False}) + + method, _path, body = captured[-1] + assert method == "PATCH" + assert body == {"execution_trace": False} + client.close() + + def test_sandboxes_update_empty_raises_without_http(mock_transport): client = _make_client(mock_transport) sb = client.sandboxes.create({"name": "s1", "sandbox_template_id": "sb-x"})