Synced from monorepo

Changes:
- Classify clipboard delivery confidence
- Add durable session update append
- Scope the xAI session bearer to first-party memory embedding endpoints
- Persist subagent outputs to disk and bound long-lived agent state
- Add MiniSweAgent:bash for mini-swe-agent parity
- Revert taking local sessions off the persistent shell
- Contextual tip recommending grok wrap on SSH sessions
- Voice STT bearer from model BYOK env_key/api_key
- Define exact website policies for sandbox
- Gate unsafe shell environments
- Shared pin hoist; single require_sha gate for marketplace plugins
- Server-signed is-managed claim (closes sidecar-removal downgrade)
- Optional require_sha pin for remote plugin installs
- Show session title and last exchange in the exit resume hint
- Gate shell output redirects
- Warn when fail_closed is present but not a boolean
- Add canonical text editing core (ratatui-textarea)
- Keep execution state out of goal scratch
- Add acknowledged persistence primitives
- Inherit child network restrictions in sandbox
- Fail closed when hook matchers fail to recompile
- Add MCP setup preferences for plugin MCPs
- Gate sourced shell scripts
- Gate file-typed project hooks
- grok wrap: restore terminal modes on child death
- Harden owner-only permissions on auth and MCP credentials
- Create crash dump files with owner-only permissions
- Write the agent_id cache owner-only (0600)
- SessionMetrics mode skips Mixpanel profile sync
- Dashboard: slim live-tail peek
- Yank full queued prompt text, not (+N lines)
- Defeat clock-rollback on the signed managed-config cache
- Stop early session/cancel from overtaking the prompt and wedging the turn slot
- Self-heal a diverged agent entrypoint on startup
- Add matched inference expectations in test-support
- Add AuthSingleFlight cancel/successor gap tests
- Remove consumer from external OTEL allowlist and pin scrub coverage
- Enable /copy in minimal mode
- Surface capacity and API-key detail on 429 errors
- Single-flight interactive auth
- Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
grokkybara[bot] 2026-07-17 14:19:50 +01:00
commit 98c3b2438a
225 changed files with 18836 additions and 7156 deletions

View file

@ -12,8 +12,11 @@ dirs = "5.0"
dunce = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
xai-grok-config = { workspace = true }
[target.'cfg(unix)'.dependencies]

View file

@ -29,10 +29,15 @@
pub mod child_net;
mod deny;
mod logging;
mod network_policy;
mod paths;
mod profiles;
mod types;
pub use logging::SandboxLogger;
pub use network_policy::{
ChildNetworkPolicy, NETWORK_POLICY_SNAPSHOT_VERSION, NetworkPolicySnapshot,
NetworkPolicySnapshotError, WebsiteAction, WebsiteOrigin, WebsiteOriginError, WebsitePolicy,
};
#[cfg(all(feature = "enforce", unix))]
use nono::Sandbox;
pub use profiles::{
@ -46,7 +51,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics};
static SANDBOX: OnceLock<GlobalSandboxState> = OnceLock::new();
static CONFIGURED_PROFILE: OnceLock<String> = OnceLock::new();
static RESTRICT_CHILD_NETWORK: AtomicBool = AtomicBool::new(false);
static AUTO_ALLOW_BASH: AtomicBool = AtomicBool::new(false);
const BWRAP_ENV_VAR: &str = "__GROK_INSIDE_BWRAP";
pub fn is_inside_bwrap() -> bool {
@ -59,10 +63,16 @@ struct GlobalSandboxState {
profile: String,
logger: SandboxLogger,
applied: bool,
restrict_network_at_known_linux_launches: bool,
}
/// Whether child subprocesses should have network blocked via seccomp.
fn restrict_network_at_known_linux_launches(applied: bool, configured: bool) -> bool {
applied && configured && cfg!(target_os = "linux")
}
/// Whether known Linux child launch paths should install the seccomp network filter.
pub fn should_restrict_child_network() -> bool {
RESTRICT_CHILD_NETWORK.load(Ordering::Relaxed)
SANDBOX
.get()
.is_some_and(|state| state.restrict_network_at_known_linux_launches)
}
/// Whether bash commands should be auto-approved when the sandbox is active.
pub fn should_auto_allow_bash() -> bool {
@ -140,6 +150,9 @@ impl SandboxManager {
tracing::info!("Sandbox disabled (profile: off)");
return Ok(());
}
let config = profiles::load_sandbox_config(workspace);
let mut resolved = self.profile.resolve_profile(workspace, &config)?;
self.net_restricted = resolved.restrict_network;
let support = Sandbox::support_info();
if !support.is_supported {
tracing::warn!(
@ -153,19 +166,11 @@ impl SandboxManager {
));
return Ok(());
}
let config = profiles::load_sandbox_config(workspace);
let caps = self
.profile
.to_capability_set_with_config(workspace, &config)?;
let mut resolved = self.profile.resolve_profile(workspace, &config)?;
let caps = ProfileName::capability_set_from_profile(workspace, &resolved)?;
resolved.deny = deny::effective_deny_paths(workspace, &resolved.deny);
self.net_restricted = self.profile.restricts_network_resolved(&config);
match Sandbox::apply(&caps) {
Ok(_) => {
self.applied = true;
if self.net_restricted {
RESTRICT_CHILD_NETWORK.store(true, Ordering::Relaxed);
}
self.logger.log(SandboxEvent::profile_applied(
&self.profile.to_string(),
workspace,
@ -173,7 +178,7 @@ impl SandboxManager {
));
tracing::info!(
profile = % self.profile, workspace = % workspace.display(),
restrict_network = self.net_restricted,
restrict_network_configured = self.net_restricted,
"Sandbox applied (kernel-enforced, irreversible)"
);
Ok(())
@ -208,6 +213,10 @@ impl SandboxManager {
profile: self.profile.to_string(),
logger: self.logger,
applied: self.applied,
restrict_network_at_known_linux_launches: restrict_network_at_known_linux_launches(
self.applied,
self.net_restricted,
),
});
}
/// Check whether the current platform supports sandboxing.
@ -219,9 +228,9 @@ impl SandboxManager {
pub fn is_applied(&self) -> bool {
self.applied
}
/// Whether child subprocesses should have network blocked.
/// Whether known Linux child launch paths should install the seccomp network filter.
pub fn restrict_child_network(&self) -> bool {
self.applied && self.net_restricted
restrict_network_at_known_linux_launches(self.applied, self.net_restricted)
}
/// The active profile name.
pub fn profile(&self) -> &ProfileName {
@ -612,6 +621,15 @@ mod tests {
set_configured_profile("read-only");
assert_eq!(configured_profile_name(), Some("read-only"));
}
#[test]
fn known_launch_guard_is_linux_only() {
assert_eq!(
restrict_network_at_known_linux_launches(true, true),
cfg!(target_os = "linux")
);
assert!(!restrict_network_at_known_linux_launches(false, true));
assert!(!restrict_network_at_known_linux_launches(true, false));
}
/// Create a temp workspace whose `.grok/sandbox.toml` contains `toml_body`.
/// Returns the workspace path (caller removes it).
#[cfg(all(feature = "enforce", unix))]

View file

@ -0,0 +1,501 @@
//! Pure policy modeling for future child website egress.
//!
//! These types are not selected by sandbox profiles or enforced by the current
//! runtime. Constructing a policy does not grant or restrict network access.
use std::collections::BTreeSet;
use std::fmt;
use std::net::IpAddr;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use url::{Host, Url};
/// Version of the compact JSON produced by [`NetworkPolicySnapshot`].
pub const NETWORK_POLICY_SNAPSHOT_VERSION: u32 = 1;
/// Requested child-network behavior for future enforcement backends.
///
/// This is not currently selected or enforced by the sandbox runtime.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", content = "policy", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ChildNetworkPolicy {
Unrestricted,
Blocked,
Websites(WebsitePolicy),
}
impl ChildNetworkPolicy {
pub fn from_restrict_network(restrict_network: bool) -> Self {
if restrict_network {
Self::Blocked
} else {
Self::Unrestricted
}
}
}
/// Result of exact-origin website policy evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WebsiteAction {
Allow,
Deny,
}
/// Exact HTTP(S) origin with an IDNA ASCII hostname and effective nonzero port.
///
/// Equality never includes subdomains, redirects, paths, or another scheme or
/// port.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WebsiteOrigin {
scheme: String,
hostname: String,
port: u16,
}
impl WebsiteOrigin {
/// Parses only `http://authority`, `https://authority`, or either with `/`.
pub fn parse(value: &str) -> Result<Self, WebsiteOriginError> {
validate_raw_origin(value)?;
let url = Url::parse(value).map_err(|error| WebsiteOriginError::InvalidOrigin {
value: value.to_owned(),
reason: error.to_string(),
})?;
let scheme = url.scheme();
let hostname = match url.host() {
Some(Host::Domain(hostname)) => hostname.strip_suffix('.').unwrap_or(hostname),
Some(Host::Ipv4(_) | Host::Ipv6(_)) => return Err(WebsiteOriginError::IpLiteral),
None => return Err(WebsiteOriginError::InvalidSyntax),
};
let hostname = hostname.to_ascii_lowercase();
if !valid_dns_hostname(&hostname) || hostname.parse::<IpAddr>().is_ok() {
return Err(WebsiteOriginError::InvalidHost(hostname));
}
let port = url
.port_or_known_default()
.ok_or(WebsiteOriginError::InvalidSyntax)?;
if port == 0 {
return Err(WebsiteOriginError::PortZero);
}
Ok(Self {
scheme: scheme.to_owned(),
hostname,
port,
})
}
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn hostname(&self) -> &str {
&self.hostname
}
pub fn port(&self) -> u16 {
self.port
}
}
fn validate_raw_origin(value: &str) -> Result<(), WebsiteOriginError> {
if value.bytes().any(|byte| byte <= b' ' || byte == 0x7f) {
return Err(WebsiteOriginError::InvalidSyntax);
}
if value.contains('\\') {
return Err(WebsiteOriginError::InvalidSyntax);
}
let authority = value
.strip_prefix("http://")
.or_else(|| value.strip_prefix("https://"))
.ok_or(WebsiteOriginError::InvalidSyntax)?;
let authority = authority.strip_suffix('/').unwrap_or(authority);
if authority.is_empty()
|| authority.contains('/')
|| authority.contains('?')
|| authority.contains('#')
{
return Err(WebsiteOriginError::InvalidSyntax);
}
if authority.contains('@') {
return Err(WebsiteOriginError::Userinfo);
}
if authority.contains('*') {
return Err(WebsiteOriginError::Wildcard);
}
Ok(())
}
fn valid_dns_hostname(hostname: &str) -> bool {
!hostname.is_empty()
&& hostname.len() <= 253
&& hostname.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
&& label
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& label
.as_bytes()
.last()
.is_some_and(u8::is_ascii_alphanumeric)
})
}
impl fmt::Display for WebsiteOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}://{}:{}", self.scheme, self.hostname, self.port)
}
}
impl FromStr for WebsiteOrigin {
type Err = WebsiteOriginError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
impl Serialize for WebsiteOrigin {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for WebsiteOrigin {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).map_err(serde::de::Error::custom)
}
}
/// Immutable exact-origin rules with deny precedence over allow and default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebsitePolicy {
default: WebsiteAction,
allow: BTreeSet<WebsiteOrigin>,
deny: BTreeSet<WebsiteOrigin>,
}
impl WebsitePolicy {
pub fn new(
default: WebsiteAction,
allow: impl IntoIterator<Item = WebsiteOrigin>,
deny: impl IntoIterator<Item = WebsiteOrigin>,
) -> Self {
Self {
default,
allow: allow.into_iter().collect(),
deny: deny.into_iter().collect(),
}
}
pub fn default_action(&self) -> WebsiteAction {
self.default
}
pub fn allow(&self) -> &BTreeSet<WebsiteOrigin> {
&self.allow
}
pub fn deny(&self) -> &BTreeSet<WebsiteOrigin> {
&self.deny
}
/// Evaluates deny exact match, then allow exact match, then the default.
pub fn evaluate(&self, origin: &WebsiteOrigin) -> WebsiteAction {
if self.deny.contains(origin) {
WebsiteAction::Deny
} else if self.allow.contains(origin) {
WebsiteAction::Allow
} else {
self.default
}
}
}
/// Versioned deterministic JSON and SHA-256 identity for later persistence.
///
/// The snapshot is not currently written to sessions or used for enforcement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NetworkPolicySnapshot {
version: u32,
policy: ChildNetworkPolicy,
}
impl NetworkPolicySnapshot {
pub fn new(policy: ChildNetworkPolicy) -> Self {
Self {
version: NETWORK_POLICY_SNAPSHOT_VERSION,
policy,
}
}
pub fn version(&self) -> u32 {
self.version
}
pub fn policy(&self) -> &ChildNetworkPolicy {
&self.policy
}
pub fn into_policy(self) -> ChildNetworkPolicy {
self.policy
}
/// Serializes the stable compact JSON representation for this version.
pub fn canonical_json(&self) -> Result<String, NetworkPolicySnapshotError> {
Ok(serde_json::to_string(self)?)
}
/// Returns SHA-256 hex over [`Self::canonical_json`].
pub fn sha256(&self) -> Result<String, NetworkPolicySnapshotError> {
Ok(format!(
"{:x}",
Sha256::digest(self.canonical_json()?.as_bytes())
))
}
pub fn validate_sha256(&self, expected: &str) -> Result<bool, NetworkPolicySnapshotError> {
Ok(self.sha256()?.eq_ignore_ascii_case(expected))
}
/// Decodes the version envelope before interpreting its policy payload.
pub fn from_canonical_json(value: &str) -> Result<Self, NetworkPolicySnapshotError> {
#[derive(Deserialize)]
struct RawSnapshot {
version: u32,
policy: serde_json::Value,
}
let raw: RawSnapshot = serde_json::from_str(value)?;
if raw.version != NETWORK_POLICY_SNAPSHOT_VERSION {
return Err(NetworkPolicySnapshotError::UnsupportedVersion(raw.version));
}
Ok(Self {
version: raw.version,
policy: serde_json::from_value(raw.policy)?,
})
}
}
/// Validation failures for strict raw exact-origin syntax.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum WebsiteOriginError {
#[error("website origin must use exact http(s)://authority syntax with optional '/'")]
InvalidSyntax,
#[error("invalid website origin '{value}': {reason}")]
InvalidOrigin { value: String, reason: String },
#[error("website origin must not contain userinfo")]
Userinfo,
#[error("website origin must not contain wildcards")]
Wildcard,
#[error("website origin must not use an IP literal")]
IpLiteral,
#[error("invalid website origin hostname '{0}'")]
InvalidHost(String),
#[error("website origin port must be nonzero")]
PortZero,
}
/// Snapshot encoding, decoding, and version failures.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum NetworkPolicySnapshotError {
#[error("invalid network policy snapshot: {0}")]
InvalidJson(#[from] serde_json::Error),
#[error("unsupported network policy snapshot version {0}")]
UnsupportedVersion(u32),
}
#[cfg(test)]
mod tests {
use super::*;
fn origin(value: &str) -> WebsiteOrigin {
WebsiteOrigin::parse(value).unwrap()
}
#[test]
fn normalizes_default_ports_case_trailing_dot_and_idna() {
let http = origin("http://Example.COM");
assert_eq!(http, origin("http://example.com:80/"));
assert_eq!(http.scheme(), "http");
assert_eq!(http.port(), 80);
let https = origin("https://example.com.");
assert_eq!(https, origin("https://EXAMPLE.com:443"));
assert_eq!(https.port(), 443);
assert_eq!(
origin("https://bücher.example").hostname(),
"xn--bcher-kva.example"
);
assert_eq!(origin("https://example.com:8443").port(), 8443);
}
#[test]
fn rejects_non_origin_inputs() {
let cases = [
("ftp://example.com", "exact http(s)"),
("https://127.0.0.1", "IP literal"),
("https://[::1]", "IP literal"),
("https://user@example.com", "userinfo"),
("https://@example.com", "userinfo"),
("https://:@example.com", "userinfo"),
("https://example.com/path", "exact http(s)"),
("https://example.com/?query=1", "exact http(s)"),
("https://example.com/#fragment", "exact http(s)"),
("https://*.example.com", "wildcards"),
("https://example.*", "wildcards"),
("https://example.com:0", "nonzero"),
(
"https://bad_host.example",
"invalid website origin hostname",
),
("https://", "exact http(s)"),
];
for (value, expected) in cases {
let error = WebsiteOrigin::parse(value).unwrap_err().to_string();
assert!(error.contains(expected), "{value}: {error}");
}
}
#[test]
fn rejects_url_parser_repairs_and_ignored_characters() {
for value in [
"https:example.com",
"https:/example.com",
"https:///example.com",
"https:\\example.com",
"https://example.com/..",
" https://example.com",
"https://example.com ",
"https://exam\tple.com",
"https://example.com\n",
] {
assert_eq!(
WebsiteOrigin::parse(value),
Err(WebsiteOriginError::InvalidSyntax),
"{value:?}"
);
}
}
#[test]
fn evaluates_exact_origin_with_deny_precedence() {
let exact = origin("https://example.com");
let allowed = origin("https://allowed.example");
let policy = WebsitePolicy::new(
WebsiteAction::Deny,
[exact.clone(), allowed.clone()],
[exact.clone()],
);
assert_eq!(policy.evaluate(&exact), WebsiteAction::Deny);
assert_eq!(policy.evaluate(&allowed), WebsiteAction::Allow);
for different in [
"http://example.com",
"https://sub.example.com",
"https://example.com:8443",
] {
assert_eq!(policy.evaluate(&origin(different)), WebsiteAction::Deny);
}
}
#[test]
fn default_action_applies_after_exact_rules() {
let allowed = origin("https://allowed.example");
let denied = origin("https://denied.example");
let policy = WebsitePolicy::new(WebsiteAction::Deny, [allowed.clone()], [denied.clone()]);
assert_eq!(policy.evaluate(&allowed), WebsiteAction::Allow);
assert_eq!(policy.evaluate(&denied), WebsiteAction::Deny);
assert_eq!(
policy.evaluate(&origin("https://other.example")),
WebsiteAction::Deny
);
}
#[test]
fn snapshot_deduplicates_sorts_and_hashes_independent_of_input_order() {
let a = origin("https://a.example");
let b = origin("https://b.example");
let first = WebsitePolicy::new(
WebsiteAction::Deny,
[b.clone(), a.clone(), b.clone()],
[b.clone(), a.clone()],
);
let second = WebsitePolicy::new(
WebsiteAction::Deny,
[a.clone(), b.clone()],
[a.clone(), b.clone(), a.clone()],
);
let first = NetworkPolicySnapshot::new(ChildNetworkPolicy::Websites(first));
let second = NetworkPolicySnapshot::new(ChildNetworkPolicy::Websites(second));
assert_eq!(first, second);
let ChildNetworkPolicy::Websites(policy) = first.policy() else {
panic!("expected website policy")
};
assert_eq!(policy.allow().iter().collect::<Vec<_>>(), vec![&a, &b]);
assert_eq!(first.sha256().unwrap(), second.sha256().unwrap());
assert!(first.validate_sha256(&first.sha256().unwrap()).unwrap());
assert!(!first.validate_sha256(&"0".repeat(64)).unwrap());
}
#[test]
fn snapshot_roundtrip_preserves_policy_and_hash() {
let policy = ChildNetworkPolicy::Websites(WebsitePolicy::new(
WebsiteAction::Deny,
[origin("https://allowed.example")],
[origin("http://denied.example:8080")],
));
let snapshot = NetworkPolicySnapshot::new(policy.clone());
let json = snapshot.canonical_json().unwrap();
let expected = r#"{"version":1,"policy":{"mode":"websites","policy":{"default":"deny","allow":["https://allowed.example:443"],"deny":["http://denied.example:8080"]}}}"#;
assert_eq!(json, expected);
assert_eq!(
snapshot.sha256().unwrap(),
"1b076f4854a41891304774143110ef54eb9936160d3d0ea3db91ca08f1e06f84"
);
let decoded = NetworkPolicySnapshot::from_canonical_json(expected).unwrap();
assert_eq!(decoded.version(), NETWORK_POLICY_SNAPSHOT_VERSION);
assert_eq!(decoded.policy(), &policy);
assert_eq!(decoded.clone().into_policy(), policy);
assert_eq!(decoded.sha256().unwrap(), snapshot.sha256().unwrap());
let wrong_version = r#"{"version":2,"policy":{"mode":"future_mode"}}"#;
assert!(matches!(
NetworkPolicySnapshot::from_canonical_json(wrong_version),
Err(NetworkPolicySnapshotError::UnsupportedVersion(2))
));
}
#[test]
fn legacy_restriction_maps_without_selecting_websites() {
assert_eq!(
ChildNetworkPolicy::from_restrict_network(false),
ChildNetworkPolicy::Unrestricted
);
assert_eq!(
ChildNetworkPolicy::from_restrict_network(true),
ChildNetworkPolicy::Blocked
);
}
}

View file

@ -4,9 +4,7 @@
//! ecosystem (package-manager / toolchain) writable paths into helpers
//! consumed by [`super::profiles`].
#[cfg(all(feature = "enforce", unix))]
use std::path::Path;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
// ── Grok state directory ────────────────────────────────────────────────────
@ -51,7 +49,6 @@ pub(crate) const DEVICE_DIRS: &[&str] = &[
/// `/private/var/folders/` (the real `TMPDIR` / `NSTemporaryDirectory()`).
/// git, compilers, and other tools write temp files to `$TMPDIR` which
/// resolves to `/private/var/folders/xx/.../T/` on macOS.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn temp_writable_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("/tmp"), PathBuf::from("/var/tmp")];
@ -81,7 +78,6 @@ pub(crate) fn temp_writable_paths() -> Vec<PathBuf> {
/// Writable directory paths for profiles that allow workspace writes (workspace, devbox, strict).
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec<PathBuf> {
let mut paths = vec![workspace.to_path_buf(), grok_home()];
paths.extend(temp_writable_paths());
@ -90,7 +86,6 @@ pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec<PathBuf> {
/// Writable directory paths for the read-only profile (minimal: just ~/.grok + temp).
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn essential_writable_paths_minimal() -> Vec<PathBuf> {
let mut paths = vec![grok_home()];
paths.extend(temp_writable_paths());

View file

@ -16,9 +16,8 @@ use crate::deny::{
};
use crate::paths::grok_home;
#[cfg(all(feature = "enforce", unix))]
use crate::paths::{
DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
};
use crate::paths::{DEVICE_DIRS, DEVICE_FILES};
use crate::paths::{essential_writable_paths, essential_writable_paths_minimal};
/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
#[derive(Debug, Clone)]
@ -69,22 +68,9 @@ pub enum ProfileName {
}
impl ProfileName {
pub fn restricts_network(&self) -> bool {
pub(crate) fn restricts_network(&self) -> bool {
matches!(self, Self::ReadOnly | Self::Strict)
}
/// Resolve network restriction from config (handles Custom profiles).
pub fn restricts_network_resolved(&self, config: &SandboxConfig) -> bool {
match self {
Self::ReadOnly | Self::Strict => true,
Self::Workspace | Self::Devbox | Self::Off => false,
Self::Custom(name) => config
.profiles
.get(name)
.and_then(|p| p.restrict_network)
.unwrap_or(false),
}
}
}
impl std::fmt::Display for ProfileName {
@ -183,9 +169,9 @@ fn load_config_file(path: &Path) -> Option<SandboxConfig> {
}
}
#[cfg(all(feature = "enforce", unix))]
impl ProfileName {
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
#[cfg(all(feature = "enforce", unix))]
pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
let config = load_sandbox_config(workspace);
self.to_capability_set_with_config(workspace, &config)
@ -195,6 +181,7 @@ impl ProfileName {
///
/// A custom profile's own `deny` list is kernel-enforced (read + write/rename)
/// on top of the base profile.
#[cfg(all(feature = "enforce", unix))]
pub fn to_capability_set_with_config(
&self,
workspace: &Path,
@ -204,10 +191,15 @@ impl ProfileName {
return Ok(CapabilitySet::new());
}
// Resolve to a SandboxProfile
let profile = self.resolve(workspace, config)?;
let profile = self.resolve_profile(workspace, config)?;
Self::capability_set_from_profile(workspace, &profile)
}
// Build CapabilitySet from the resolved profile
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn capability_set_from_profile(
workspace: &Path,
profile: &SandboxProfile,
) -> anyhow::Result<CapabilitySet> {
let mut caps = CapabilitySet::new();
// Default read access
@ -510,12 +502,83 @@ mod tests {
}
#[test]
fn network_restriction() {
assert!(!ProfileName::Workspace.restricts_network());
assert!(!ProfileName::Devbox.restricts_network());
assert!(ProfileName::ReadOnly.restricts_network());
assert!(ProfileName::Strict.restricts_network());
assert!(!ProfileName::Off.restricts_network());
fn built_in_network_restriction_values() {
let workspace = std::env::current_dir().unwrap();
let config = SandboxConfig::default();
for (name, expected) in [
(ProfileName::Workspace, false),
(ProfileName::Devbox, false),
(ProfileName::ReadOnly, true),
(ProfileName::Strict, true),
] {
let resolved = name.resolve_profile(&workspace, &config).unwrap();
assert_eq!(resolved.restrict_network, expected, "{name}");
}
}
fn network_inheritance_config() -> SandboxConfig {
SandboxConfig {
profiles: HashMap::from([
(
"strict-inherited".to_string(),
ProfileConfig {
extends: Some("strict".to_string()),
restrict_network: None,
read_only: vec![],
read_write: vec![],
deny: vec![],
},
),
(
"read-only-inherited".to_string(),
ProfileConfig {
extends: Some("read-only".to_string()),
restrict_network: None,
read_only: vec![],
read_write: vec![],
deny: vec![],
},
),
(
"strict-unrestricted".to_string(),
ProfileConfig {
extends: Some("strict".to_string()),
restrict_network: Some(false),
read_only: vec![],
read_write: vec![],
deny: vec![],
},
),
(
"workspace-restricted".to_string(),
ProfileConfig {
extends: Some("workspace".to_string()),
restrict_network: Some(true),
read_only: vec![],
read_write: vec![],
deny: vec![],
},
),
]),
}
}
#[test]
fn custom_network_restriction_inherits_and_overrides_base() {
let workspace = std::env::current_dir().unwrap();
let config = network_inheritance_config();
for (name, expected) in [
("strict-inherited", true),
("read-only-inherited", true),
("strict-unrestricted", false),
("workspace-restricted", true),
] {
let profile_name = ProfileName::Custom(name.to_string());
let resolved = profile_name.resolve_profile(&workspace, &config).unwrap();
assert_eq!(resolved.restrict_network, expected, "{name}");
}
}
#[test]