Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e6bddf3
fix(network): skip no-op startup symlink reloads
krishicks Jun 23, 2026
db80648
fix(l7): relay unframed SSE responses
krishicks Jun 16, 2026
4bfe238
test(e2e): add JSON-RPC L7 proxy coverage
krishicks Jun 10, 2026
2a8a261
feat(policy): recognize JSON-RPC L7 endpoints
krishicks Jun 10, 2026
7db5d84
refactor(l7): share HTTP body inspection helper
krishicks Jun 10, 2026
1ce62da
feat(l7): enforce JSON-RPC method rules
krishicks Jun 10, 2026
b9bbc2b
fix(l7): honor JSON-RPC body size config
krishicks Jun 11, 2026
7740d91
feat(l7): match JSON-RPC params in rules
krishicks Jun 10, 2026
4f1fbdc
feat(l7): support JSON-RPC batch calls
krishicks Jun 10, 2026
3f237df
fix(l7): redact JSON-RPC params in logs
krishicks Jun 10, 2026
6e2ca22
docs(policy): document JSON-RPC L7 rules
krishicks Jun 10, 2026
8a94b06
fix(sandbox): fail closed on ambiguous JSON-RPC requests
krishicks Jun 15, 2026
b69215d
ci(e2e): add MCP conformance coverage
krishicks Jun 15, 2026
47540ad
fix(l7): port JSON-RPC L7 to supervisor network
krishicks Jun 15, 2026
fe8b9ec
fix(l7): allow JSON-RPC response messages
krishicks Jun 16, 2026
f6fc7ef
fix(policy): require explicit json-rpc rules
krishicks Jun 22, 2026
3a05b68
fix(l7): reject json-rpc response frames
krishicks Jun 22, 2026
86f9a96
ci: harden mcp conformance credentials
krishicks Jun 22, 2026
539627f
docs(policy): clarify json-rpc access rules
krishicks Jun 22, 2026
6173529
test(policy): pin json-rpc rule validation
krishicks Jun 22, 2026
8b9c4ff
fix(l7): harden json-rpc frame handling
krishicks Jun 22, 2026
7dd55c8
refactor(e2e): run MCP conformance runner in a container
krishicks Jun 22, 2026
0702655
refactor(policy): rename JSON-RPC rule method field
krishicks Jun 22, 2026
4d8ed8d
fix(network): hard-deny JSON-RPC response frames in forward proxy
krishicks Jun 22, 2026
b1bf4e6
fix(e2e): bind MCP bridge calls to runner capability
krishicks Jun 23, 2026
bfe8095
fix(network): require SSE shape for JSON-RPC receive streams
krishicks Jun 23, 2026
14ed51c
fix(lint): Add mcp-conformance Python files
krishicks Jun 23, 2026
f16c3c2
fix(e2e): tolerate NAT for MCP bridge callbacks
krishicks Jun 23, 2026
4edf117
test(e2e): add JSON-RPC L7 failure diagnostics
krishicks Jun 23, 2026
1abfcf9
fix(e2e): pass resolved openshell binary to MCP bridge
krishicks Jun 23, 2026
2814a85
fix(l7): keep sparse SSE responses open
krishicks Jun 23, 2026
bb1d8ef
fix(e2e): authenticate MCP bridge before body read
krishicks Jun 23, 2026
a572b95
docs(e2e): describe MCP bridge NAT tolerance
krishicks Jun 23, 2026
91dac00
ci(e2e): restore host-side registry credentials
krishicks Jun 23, 2026
0c1e237
fix(jsonrpc): allow literal dotted params
krishicks Jun 23, 2026
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
15 changes: 15 additions & 0 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
cmd: "mise run --no-deps --skip-deps e2e:podman:rootless"
apt_packages: "openssh-client podman uidmap"
rootless: true
- suite: mcp
cmd: "mise run --no-deps --skip-deps e2e:mcp"
apt_packages: ""
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
Expand All @@ -65,6 +68,17 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs['checkout-ref'] || github.sha }}
persist-credentials: false

- name: Check out MCP conformance tests
if: matrix.suite == 'mcp'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
repository: modelcontextprotocol/conformance
# Pin after v0.1.16 to include the tools_call client scenario fix.
ref: b9041ea41b0188581803459dbae71bc7e02fd995
path: .cache/mcp-conformance
persist-credentials: false

- name: Install OS test dependencies
if: matrix.apt_packages != ''
Expand Down Expand Up @@ -104,6 +118,7 @@ jobs:
- name: Run tests
env:
OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }}
OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE: ${{ format('openshell-mcp-conformance-client:{0}', inputs.image-tag) }}
E2E_CMD: ${{ matrix.cmd }}
run: |
if [ "${{ matrix.rootless }}" = "true" ]; then
Expand Down
9 changes: 9 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ 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.
Literal dotted keys in JSON-RPC params are accepted. If a literal key and a
flattened nested selector path produce the same matcher key, the literal key
takes precedence.
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
2 changes: 2 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,7 @@ fn group_allow_rules(specs: &[String]) -> Result<BTreeMap<(String, u32), Vec<L7R
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
params: HashMap::default(),
}),
});
}
Expand All @@ -226,6 +227,7 @@ fn group_deny_rules(specs: &[String]) -> Result<BTreeMap<(String, u32), Vec<L7De
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
params: HashMap::default(),
});
}
Ok(grouped)
Expand Down
163 changes: 125 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,17 @@ 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,
}

fn json_rpc_config_from_proto(max_body_bytes: u32) -> Option<JsonRpcConfigDef> {
(max_body_bytes > 0).then_some(JsonRpcConfigDef { max_body_bytes })
}

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

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

#[derive(Debug, Serialize, Deserialize)]
Expand All @@ -232,6 +249,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 @@ -281,16 +316,15 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
.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 @@ -310,18 +344,12 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
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 +375,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 @@ -452,14 +484,14 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
.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 @@ -481,14 +513,14 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
.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 +544,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 @@ -1699,6 +1732,60 @@ 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:
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 parse_rejects_unsupported_json_rpc_config_fields() {
let yaml = r"
version: 1
network_policies:
mcp:
endpoints:
- host: mcp.example.com
port: 443
protocol: json-rpc
json_rpc:
max_body_bytes: 131072
on_parse_error: deny
batch_policy: all
access: full
binaries:
- path: /usr/bin/curl
";

assert!(
parse_sandbox_policy(yaml).is_err(),
"unsupported json_rpc fields must not be silently accepted"
);
}

#[test]
fn round_trip_preserves_websocket_credential_rewrite() {
let yaml = r"
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-policy/src/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ fn expand_access_preset(protocol: &str, access: &str) -> Option<Vec<L7Rule>> {
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
params: HashMap::default(),
}),
})
.collect(),
Expand Down Expand Up @@ -961,6 +962,7 @@ mod tests {
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
params: HashMap::default(),
}),
}
}
Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,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 @@ -744,6 +746,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 @@ -774,6 +777,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 @@ -817,6 +821,7 @@ fn allow_to_proto(allow: &L7AllowProfile) -> L7Allow {
operation_type: allow.operation_type.clone(),
operation_name: allow.operation_name.clone(),
fields: allow.fields.clone(),
params: HashMap::new(),
}
}

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

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-sandbox/src/mechanistic_mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ fn build_l7_rules(samples: &HashMap<(String, String), u32>) -> Vec<L7Rule> {
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
params: HashMap::new(),
}),
});
}
Expand Down
Loading
Loading