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
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue