Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ paths, such as proxy support files or GPU device paths when a GPU is present.
All ordinary agent egress is routed through the sandbox proxy. The proxy
identifies the calling binary, checks trust-on-first-use binary identity, rejects
unsafe internal destinations, and evaluates the active policy.
For inspected HTTP traffic, the proxy can enforce REST method/path rules,
WebSocket upgrade and text-message rules, GraphQL operation rules, and
JSON-RPC method and params rules on sandbox-to-server request bodies. JSON-RPC
request inspection buffers up to the endpoint `json_rpc.max_body_bytes` limit.
JSON-RPC responses and server-to-client MCP messages on response or SSE streams
are relayed but are not currently parsed for policy enforcement.

`https://inference.local` is special. It bypasses OPA network policy and is
handled by the inference interception path:
Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-cli/src/policy_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ fn group_allow_rules(specs: &[String]) -> Result<BTreeMap<(String, u32), Vec<L7R
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
rpc_method: String::new(),
params: HashMap::default(),
}),
});
}
Expand All @@ -226,6 +228,8 @@ fn group_deny_rules(specs: &[String]) -> Result<BTreeMap<(String, u32), Vec<L7De
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
rpc_method: String::new(),
params: HashMap::default(),
});
}
Ok(grouped)
Expand Down
154 changes: 116 additions & 38 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ struct NetworkEndpointDef {
graphql_persisted_queries: BTreeMap<String, GraphqlOperationDef>,
#[serde(default, skip_serializing_if = "is_zero_u32")]
graphql_max_body_bytes: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
json_rpc: Option<JsonRpcConfigDef>,
}

// Signature dictated by serde's `skip_serializing_if`, which requires `&T`.
Expand All @@ -149,6 +151,25 @@ fn is_zero_u32(v: &u32) -> bool {
*v == 0
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonRpcConfigDef {
#[serde(default, skip_serializing_if = "is_zero_u32")]
max_body_bytes: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
on_parse_error: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
batch_policy: String,
}

fn json_rpc_config_from_proto(max_body_bytes: u32) -> Option<JsonRpcConfigDef> {
(max_body_bytes > 0).then_some(JsonRpcConfigDef {
max_body_bytes,
on_parse_error: String::new(),
batch_policy: String::new(),
})
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct GraphqlOperationDef {
Expand Down Expand Up @@ -183,6 +204,10 @@ struct L7AllowDef {
operation_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
fields: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
rpc_method: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
params: BTreeMap<String, QueryMatcherDef>,
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -216,6 +241,10 @@ struct L7DenyRuleDef {
operation_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
fields: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
rpc_method: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
params: BTreeMap<String, QueryMatcherDef>,
}

#[derive(Debug, Serialize, Deserialize)]
Expand All @@ -232,6 +261,24 @@ struct NetworkBinaryDef {
// YAML → proto conversion
// ---------------------------------------------------------------------------

fn matcher_def_to_proto(matcher: QueryMatcherDef) -> L7QueryMatcher {
match matcher {
QueryMatcherDef::Glob(glob) => L7QueryMatcher { glob, any: vec![] },
QueryMatcherDef::Any(any) => L7QueryMatcher {
glob: String::new(),
any: any.any,
},
}
}

fn matcher_proto_to_def(matcher: L7QueryMatcher) -> QueryMatcherDef {
if matcher.any.is_empty() {
QueryMatcherDef::Glob(matcher.glob)
} else {
QueryMatcherDef::Any(QueryAnyDef { any: matcher.any })
}
}

fn to_proto(raw: PolicyFile) -> SandboxPolicy {
let network_policies = raw
.network_policies
Expand Down Expand Up @@ -276,21 +323,21 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
operation_type: r.allow.operation_type,
operation_name: r.allow.operation_name,
fields: r.allow.fields,
rpc_method: r.allow.rpc_method,
query: r
.allow
.query
.into_iter()
.map(|(key, matcher)| {
let proto = match matcher {
QueryMatcherDef::Glob(glob) => {
L7QueryMatcher { glob, any: vec![] }
}
QueryMatcherDef::Any(any) => L7QueryMatcher {
glob: String::new(),
any: any.any,
},
};
(key, proto)
(key, matcher_def_to_proto(matcher))
})
.collect(),
params: r
.allow
.params
.into_iter()
.map(|(key, matcher)| {
(key, matcher_def_to_proto(matcher))
})
.collect(),
}),
Expand All @@ -307,21 +354,16 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
operation_type: d.operation_type,
operation_name: d.operation_name,
fields: d.fields,
rpc_method: d.rpc_method,
query: d
.query
.into_iter()
.map(|(key, matcher)| {
let proto = match matcher {
QueryMatcherDef::Glob(glob) => {
L7QueryMatcher { glob, any: vec![] }
}
QueryMatcherDef::Any(any) => L7QueryMatcher {
glob: String::new(),
any: any.any,
},
};
(key, proto)
})
.map(|(key, matcher)| (key, matcher_def_to_proto(matcher)))
.collect(),
params: d
.params
.into_iter()
.map(|(key, matcher)| (key, matcher_def_to_proto(matcher)))
.collect(),
})
.collect(),
Expand All @@ -347,6 +389,10 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
})
.collect(),
graphql_max_body_bytes: e.graphql_max_body_bytes,
json_rpc_max_body_bytes: e
.json_rpc
.as_ref()
.map_or(0, |config| config.max_body_bytes),
}
})
.collect(),
Expand Down Expand Up @@ -448,18 +494,19 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
operation_type: a.operation_type,
operation_name: a.operation_name,
fields: a.fields,
rpc_method: a.rpc_method,
query: a
.query
.into_iter()
.map(|(key, matcher)| {
let yaml_matcher = if matcher.any.is_empty() {
QueryMatcherDef::Glob(matcher.glob)
} else {
QueryMatcherDef::Any(QueryAnyDef {
any: matcher.any,
})
};
(key, yaml_matcher)
(key, matcher_proto_to_def(matcher))
})
.collect(),
params: a
.params
.into_iter()
.map(|(key, matcher)| {
(key, matcher_proto_to_def(matcher))
})
.collect(),
},
Expand All @@ -477,18 +524,19 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
operation_type: d.operation_type.clone(),
operation_name: d.operation_name.clone(),
fields: d.fields.clone(),
rpc_method: d.rpc_method.clone(),
query: d
.query
.iter()
.map(|(key, matcher)| {
let yaml_matcher = if matcher.any.is_empty() {
QueryMatcherDef::Glob(matcher.glob.clone())
} else {
QueryMatcherDef::Any(QueryAnyDef {
any: matcher.any.clone(),
})
};
(key.clone(), yaml_matcher)
(key.clone(), matcher_proto_to_def(matcher.clone()))
})
.collect(),
params: d
.params
.iter()
.map(|(key, matcher)| {
(key.clone(), matcher_proto_to_def(matcher.clone()))
})
.collect(),
})
Expand All @@ -512,6 +560,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
})
.collect(),
graphql_max_body_bytes: e.graphql_max_body_bytes,
json_rpc: json_rpc_config_from_proto(e.json_rpc_max_body_bytes),
}
})
.collect(),
Expand Down Expand Up @@ -1715,6 +1764,35 @@ network_policies:
assert_eq!(ep.deny_rules[0].fields, vec!["deleteRepository"]);
}

#[test]
fn round_trip_preserves_json_rpc_max_body_bytes() {
let yaml = r"
version: 1
network_policies:
mcp:
name: mcp
endpoints:
- host: mcp.example.com
port: 443
protocol: json-rpc
enforcement: enforce
json_rpc:
max_body_bytes: 131072
rules:
- allow:
rpc_method: initialize
binaries:
- path: /usr/bin/curl
";
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");

let ep = &proto2.network_policies["mcp"].endpoints[0];
assert_eq!(ep.protocol, "json-rpc");
assert_eq!(ep.json_rpc_max_body_bytes, 131_072);
}

#[test]
fn round_trip_preserves_websocket_credential_rewrite() {
let yaml = r"
Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-policy/src/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,8 @@ fn expand_access_preset(protocol: &str, access: &str) -> Option<Vec<L7Rule>> {
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
rpc_method: String::new(),
params: HashMap::default(),
}),
})
.collect(),
Expand Down Expand Up @@ -961,6 +963,8 @@ mod tests {
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
rpc_method: String::new(),
params: HashMap::default(),
}),
}
}
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ pub struct EndpointProfile {
pub graphql_persisted_queries: HashMap<String, GraphqlOperationProfile>,
#[serde(default, skip_serializing_if = "is_zero")]
pub graphql_max_body_bytes: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub json_rpc_max_body_bytes: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub path: String,
}
Expand Down Expand Up @@ -743,6 +745,7 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint {
.map(|(name, operation)| (name.clone(), graphql_operation_to_proto(operation)))
.collect(),
graphql_max_body_bytes: endpoint.graphql_max_body_bytes,
json_rpc_max_body_bytes: endpoint.json_rpc_max_body_bytes,
path: endpoint.path.clone(),
}
}
Expand Down Expand Up @@ -773,6 +776,7 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile {
.map(|(name, operation)| (name.clone(), graphql_operation_from_proto(operation)))
.collect(),
graphql_max_body_bytes: endpoint.graphql_max_body_bytes,
json_rpc_max_body_bytes: endpoint.json_rpc_max_body_bytes,
path: endpoint.path.clone(),
}
}
Expand Down Expand Up @@ -816,6 +820,8 @@ fn allow_to_proto(allow: &L7AllowProfile) -> L7Allow {
operation_type: allow.operation_type.clone(),
operation_name: allow.operation_name.clone(),
fields: allow.fields.clone(),
rpc_method: String::new(),
params: HashMap::new(),
}
}

Expand Down Expand Up @@ -848,6 +854,8 @@ fn deny_rule_to_proto(rule: &L7DenyRuleProfile) -> L7DenyRule {
operation_type: rule.operation_type.clone(),
operation_name: rule.operation_name.clone(),
fields: rule.fields.clone(),
rpc_method: String::new(),
params: HashMap::new(),
}
}

Expand Down
Loading
Loading