Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5e35196
fix(l7): relay unframed SSE responses
krishicks Jun 16, 2026
ebe3c4b
test(e2e): add JSON-RPC L7 proxy coverage
krishicks Jun 10, 2026
9fd091a
feat(policy): recognize JSON-RPC L7 endpoints
krishicks Jun 10, 2026
6cc312e
refactor(l7): share HTTP body inspection helper
krishicks Jun 10, 2026
6b2b124
feat(l7): enforce JSON-RPC method rules
krishicks Jun 10, 2026
be8d893
fix(l7): honor JSON-RPC body size config
krishicks Jun 11, 2026
1ebf7e2
feat(l7): match JSON-RPC params in rules
krishicks Jun 10, 2026
c9a5498
feat(l7): support JSON-RPC batch calls
krishicks Jun 10, 2026
d8fbf1f
fix(l7): redact JSON-RPC params in logs
krishicks Jun 10, 2026
783a491
docs(policy): document JSON-RPC L7 rules
krishicks Jun 10, 2026
7152e91
fix(sandbox): fail closed on ambiguous JSON-RPC requests
krishicks Jun 15, 2026
c812f3b
ci(e2e): add MCP conformance coverage
krishicks Jun 15, 2026
74c4ec1
fix(l7): port JSON-RPC L7 to supervisor network
krishicks Jun 15, 2026
bd770ce
fix(l7): allow JSON-RPC response messages
krishicks Jun 16, 2026
7593302
fix(policy): require explicit json-rpc rules
krishicks Jun 22, 2026
9e15fb0
fix(l7): reject json-rpc response frames
krishicks Jun 22, 2026
cdf473e
ci: harden mcp conformance credentials
krishicks Jun 22, 2026
e10014d
docs(policy): clarify json-rpc access rules
krishicks Jun 22, 2026
c68172e
test(policy): pin json-rpc rule validation
krishicks Jun 22, 2026
b92cd00
fix(l7): harden json-rpc frame handling
krishicks Jun 22, 2026
fb9c396
refactor(e2e): run MCP conformance runner in a container
krishicks Jun 22, 2026
2aa3214
refactor(policy): rename JSON-RPC rule method field
krishicks Jun 22, 2026
e8a3b30
test(e2e): allow CI Docker internal address ranges
krishicks Jun 22, 2026
3b90724
fix(e2e): constrain MCP conformance bridge requests
krishicks Jun 22, 2026
f242f9c
fix(network): hard-deny JSON-RPC response frames in forward proxy
krishicks Jun 22, 2026
f4b1ea2
fix(e2e): bind MCP bridge calls to runner capability
krishicks Jun 23, 2026
407a4ec
fix(network): require SSE shape for JSON-RPC receive streams
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
31 changes: 24 additions & 7 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 @@ -54,17 +57,25 @@ jobs:
- /var/run/docker.sock:/var/run/docker.sock
- /home/runner/_work:/home/runner/_work
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IMAGE_TAG: ${{ inputs.image-tag }}
OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell
OPENSHELL_REGISTRY_HOST: ghcr.io
OPENSHELL_REGISTRY_NAMESPACE: nvidia/openshell
OPENSHELL_REGISTRY_USERNAME: ${{ github.actor }}
OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
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 All @@ -73,11 +84,17 @@ jobs:
run: apt-get update && apt-get install -y ${APT_PACKAGES} && rm -rf /var/lib/apt/lists/*

- name: Log in to GHCR with Docker
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: echo "${GHCR_PASSWORD}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin

- name: Log in to GHCR with Podman
if: startsWith(matrix.suite, 'rust-podman')
run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: echo "${GHCR_PASSWORD}" | podman login ghcr.io -u "${GHCR_USERNAME}" --password-stdin

- name: Set up rootless Podman user
if: matrix.rootless
Expand All @@ -104,6 +121,8 @@ 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) }}
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_CMD: ${{ matrix.cmd }}
run: |
if [ "${{ matrix.rootless }}" = "true" ]; then
Expand All @@ -120,8 +139,6 @@ jobs:
OPENSHELL_SUPERVISOR_IMAGE="${OPENSHELL_SUPERVISOR_IMAGE}" \
OPENSHELL_REGISTRY="${OPENSHELL_REGISTRY}" \
OPENSHELL_REGISTRY_HOST="${OPENSHELL_REGISTRY_HOST}" \
OPENSHELL_REGISTRY_USERNAME="${OPENSHELL_REGISTRY_USERNAME}" \
OPENSHELL_REGISTRY_PASSWORD="${OPENSHELL_REGISTRY_PASSWORD}" \
IMAGE_TAG="${IMAGE_TAG}" \
MISE_GITHUB_TOKEN="${MISE_GITHUB_TOKEN}" \
bash -c "${E2E_CMD}"
Expand Down
8 changes: 8 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ 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 rejected before policy evaluation so
they cannot be confused with flattened nested selector paths.
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
Loading
Loading