Repository navigation
feat: network allowlist policy, sandbox events, namespace/KVM probes, contract 1.19 #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5589c56
82953b6
efd8212
f8b1b4f
0b42ab7
ec5f10c
fd5950f
9928bf3
5e32bc5
5dac6f5
b9cac1b
ed06962
e87c0e8
177a4eb
932a31e
cb9b5e0
0f22a00
195159e
ec97cd6
1d594a9
91e1bd6
7f0571a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -397,3 +397,10 @@ TinyBus itself is not shipped here; the pinned submodule is the build-time SDK. | |
| ## License | ||
|
|
||
| GPL-3.0-only. See [LICENSE](LICENSE). | ||
|
|
||
| ## Scope | ||
|
|
||
| tinybox is sandbox-only. It enforces the confinement a caller asks for (filesystem, | ||
| network, processes, resources), reports observational events, and refuses what it | ||
| cannot enforce. It owns no policy, approval, audit or command-classification logic; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Clarify which kind of policy tinybox does not own. The Layout section at Line 274 says 🤖 Prompt for AI Agents |
||
| that is `tinysecurity`. See `docs/specs/contract-1-19.md`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| //! Wire vocabulary for network allowlists and sandbox observations. | ||
| //! | ||
| //! These mirror the model types in `tinybox-core` so a module and a host can | ||
| //! exchange them without sharing a dependency. They carry no policy: a sandbox | ||
| //! reports what happened and the caller decides what to do about it. | ||
|
|
||
| use serde::{Deserialize, Serialize}; | ||
|
|
||
| /// Hosts and ports an allowlisted box may reach. | ||
| /// | ||
| /// Hostnames are resolved outside the box and pinned; metadata endpoints are | ||
| /// always refused. See `tinybox_core::EgressAllowlist` for the full rules. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] | ||
| pub struct EgressAllowlist { | ||
| /// Host names or IP literals. | ||
| pub hosts: Vec<String>, | ||
| /// TCP ports. | ||
| pub ports: Vec<u16>, | ||
| } | ||
|
|
||
| /// Which kind of access a sandbox refused. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[serde(rename_all = "snake_case")] | ||
| pub enum DenialKind { | ||
| /// Filesystem access outside the granted paths. | ||
| Filesystem, | ||
| /// A network connection the policy forbids. | ||
| Network, | ||
| /// Process creation the sandbox forbids. | ||
| Subprocess, | ||
| /// A system call a filter blocked. | ||
| Syscall, | ||
| } | ||
|
|
||
| /// Which resource limit a workload reached. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[serde(rename_all = "snake_case")] | ||
| pub enum LimitKind { | ||
| /// CPU time or share. | ||
| Cpu, | ||
| /// Resident memory. | ||
| Memory, | ||
| /// Process or thread count. | ||
| Pids, | ||
| /// Writable disk. | ||
| Disk, | ||
| /// Wall-clock timeout. | ||
| Timeout, | ||
| } | ||
|
|
||
| /// One observation about a running workload. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[serde(tag = "event", rename_all = "snake_case")] | ||
| pub enum SandboxEvent { | ||
| /// The workload started. | ||
| Started, | ||
| /// The sandbox refused an access. | ||
| Denied { | ||
| /// What was refused. | ||
| kind: DenialKind, | ||
| /// The path, address or syscall, when the OS reports it. | ||
| #[serde(default)] | ||
| target: Option<String>, | ||
| }, | ||
| /// The workload reached a resource limit. | ||
| LimitHit { | ||
| /// The limit reached. | ||
| limit: LimitKind, | ||
| }, | ||
| /// The workload ended. | ||
| Exited { | ||
| /// Exit status, or `None` when killed by a signal. | ||
| #[serde(default)] | ||
| status: Option<i32>, | ||
| }, | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| #[path = "event_tests.rs"] | ||
| mod tests; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| use super::*; | ||
| use crate::NetworkPolicy; | ||
|
|
||
| #[test] | ||
| fn events_keep_their_wire_labels() -> Result<(), serde_json::Error> { | ||
| let denied = SandboxEvent::Denied { | ||
| kind: DenialKind::Network, | ||
| target: Some("169.254.169.254:80".into()), | ||
| }; | ||
| assert_eq!( | ||
| serde_json::to_value(&denied)?, | ||
| serde_json::json!({"event":"denied","kind":"network","target":"169.254.169.254:80"}) | ||
| ); | ||
| assert_eq!( | ||
| serde_json::to_value(SandboxEvent::LimitHit { | ||
| limit: LimitKind::Pids | ||
| })?, | ||
| serde_json::json!({"event":"limit_hit","limit":"pids"}) | ||
| ); | ||
| assert_eq!( | ||
| serde_json::to_value(SandboxEvent::Started)?, | ||
| serde_json::json!({"event":"started"}) | ||
| ); | ||
| let exited: SandboxEvent = serde_json::from_value(serde_json::json!({"event":"exited"}))?; | ||
| assert_eq!(exited, SandboxEvent::Exited { status: None }); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_allowlist_policy_round_trips_and_old_policies_still_parse() -> Result<(), serde_json::Error> { | ||
| let policy = NetworkPolicy::Allowlist(EgressAllowlist { | ||
| hosts: vec!["example.com".into()], | ||
| ports: vec![443], | ||
| }); | ||
| let value = serde_json::to_value(&policy)?; | ||
| assert_eq!( | ||
| value, | ||
| serde_json::json!({"Allowlist":{"hosts":["example.com"],"ports":[443]}}) | ||
| ); | ||
| assert_eq!(serde_json::from_value::<NetworkPolicy>(value)?, policy); | ||
| assert_eq!( | ||
| serde_json::from_value::<NetworkPolicy>(serde_json::json!("Egress"))?, | ||
| NetworkPolicy::Egress | ||
| ); | ||
| Ok(()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| //! Purely observational sandbox events. | ||
| //! | ||
| //! A sandbox reports what happened to a workload; it never decides what is | ||
| //! allowed. A caller (the host, and through it an audit log) subscribes with an | ||
| //! [`EventSink`] and records what it is told. Denial attribution is only as | ||
| //! precise as the OS allows, so a [`SandboxEvent::Denied`] carries a | ||
| //! [`DenialKind`] and an optional target, and a backend that cannot attribute | ||
| //! denials emits none and declares that in its capabilities rather than | ||
| //! guessing. | ||
|
|
||
| use std::sync::Mutex; | ||
|
|
||
| use serde::{Deserialize, Serialize}; | ||
|
|
||
| /// Which kind of access the sandbox refused. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[non_exhaustive] | ||
| pub enum DenialKind { | ||
| /// A filesystem read, write or execute outside the granted paths. | ||
| Filesystem, | ||
| /// A network connection the policy does not permit. | ||
| Network, | ||
| /// A process or thread creation the sandbox forbids. | ||
| Subprocess, | ||
| /// A system call a filter blocked. | ||
| Syscall, | ||
| } | ||
|
|
||
| /// Which resource limit a workload reached. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[non_exhaustive] | ||
| pub enum LimitKind { | ||
| /// CPU time or share. | ||
| Cpu, | ||
| /// Resident memory. | ||
| Memory, | ||
| /// Process or thread count. | ||
| Pids, | ||
| /// Writable disk. | ||
| Disk, | ||
| /// Wall-clock timeout. | ||
| Timeout, | ||
| } | ||
|
|
||
| /// One observation about a running workload. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[non_exhaustive] | ||
| pub enum SandboxEvent { | ||
| /// The workload started inside the sandbox. | ||
| Started, | ||
| /// The sandbox refused an access. | ||
| Denied { | ||
| /// What was refused. | ||
| kind: DenialKind, | ||
| /// The path, address or syscall refused, when the OS reports it. | ||
| target: Option<String>, | ||
| }, | ||
| /// The workload reached a resource limit. | ||
| LimitHit { | ||
| /// The limit reached. | ||
| limit: LimitKind, | ||
| }, | ||
| /// The workload ended. | ||
| Exited { | ||
| /// The exit status, or `None` when it was killed by a signal. | ||
| status: Option<i32>, | ||
| }, | ||
| } | ||
|
|
||
| /// Receives events from a sandbox. Implementations must not block. | ||
| pub trait EventSink: Send + Sync { | ||
| /// Observe one event for the box `box_id`. | ||
| fn emit(&self, box_id: &str, event: SandboxEvent); | ||
| } | ||
|
|
||
| /// An [`EventSink`] that keeps every event in memory, for tests and short runs. | ||
| #[derive(Debug, Default)] | ||
| pub struct CollectingSink { | ||
| events: Mutex<Vec<(String, SandboxEvent)>>, | ||
| } | ||
|
|
||
| impl CollectingSink { | ||
| /// An empty sink. | ||
| #[must_use] | ||
| pub fn new() -> Self { | ||
| Self::default() | ||
| } | ||
|
|
||
| /// Everything received so far, oldest first. | ||
| #[must_use] | ||
| pub fn events(&self) -> Vec<(String, SandboxEvent)> { | ||
| self.events | ||
| .lock() | ||
| .map(|events| events.clone()) | ||
| .unwrap_or_default() | ||
| } | ||
| } | ||
|
|
||
| impl EventSink for CollectingSink { | ||
| fn emit(&self, box_id: &str, event: SandboxEvent) { | ||
| if let Ok(mut events) = self.events.lock() { | ||
| events.push((box_id.to_owned(), event)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| #[path = "mod_tests.rs"] | ||
| mod tests; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn collecting_sink_keeps_events_in_order() { | ||
| let sink = CollectingSink::new(); | ||
| sink.emit("b1", SandboxEvent::Started); | ||
| sink.emit( | ||
| "b1", | ||
| SandboxEvent::LimitHit { | ||
| limit: LimitKind::Memory, | ||
| }, | ||
| ); | ||
| sink.emit("b1", SandboxEvent::Exited { status: Some(137) }); | ||
| let events = sink.events(); | ||
| assert_eq!(events.len(), 3); | ||
| assert_eq!(events[0].1, SandboxEvent::Started); | ||
| assert_eq!( | ||
| events[1].1, | ||
| SandboxEvent::LimitHit { | ||
| limit: LimitKind::Memory | ||
| } | ||
| ); | ||
| assert_eq!(events[2].1, SandboxEvent::Exited { status: Some(137) }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Qualify event reporting until backends emit these events.
The PR adds event types and a collecting sink, but its objectives say backends do not emit the new events yet. These present-tense statements promise behavior that is not currently available.
AGENTS.md#L48-L48: describe event reporting as intended behavior or identify the current event-emitting path.README.md#L404-L404: qualify the observational-event reporting claim.📍 Affects 2 files
AGENTS.md#L48-L48(this comment)README.md#L404-L404🤖 Prompt for AI Agents