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:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
501
crates/codegen/xai-grok-sandbox/src/network_policy.rs
Normal file
501
crates/codegen/xai-grok-sandbox/src/network_policy.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue