From 2cace3313a034ba0b86f321e090746293a402f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 8 Aug 2026 15:20:28 +0200 Subject: [PATCH 1/7] docs: add design spec for Drop D proto gap fixes (#35, #36, #37) --- .../2026-08-08-proto-gap-drop-d-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-proto-gap-drop-d-design.md diff --git a/docs/superpowers/specs/2026-08-08-proto-gap-drop-d-design.md b/docs/superpowers/specs/2026-08-08-proto-gap-drop-d-design.md new file mode 100644 index 0000000..2219999 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-proto-gap-drop-d-design.md @@ -0,0 +1,118 @@ +# Proto Gap Fixes: Drop D Network Policy Types + +**Date**: 2026-08-08 +**Issues**: #35, #36, #37 +**JIRA**: RHAIENG-6595 +**Branch**: `fix/proto-gap-drop-d` + +## Problem + +Three sets of upstream proto fields have no corresponding SDK domain types or converter code. Some are incorrectly listed as "handled" in the coverage test despite having no implementation. This blocks Drop D upstream contribution. + +## Scope + +Single branch and PR covering all three issues. All changes follow the established 3-layer pattern: domain types, converters, coverage test, unit tests. + +## Issue #37: SigV4 + JSON-RPC Fields on NetworkEndpoint + +Four scalar fields added to `PolicyNetworkEndpoint`: + +| Field | Go Type | Proto Source | +|-------|---------|-------------| +| `CredentialSigning` | `string` | `credential_signing` (line 166) | +| `SigningService` | `string` | `signing_service` (line 169) | +| `SigningRegion` | `string` | `signing_region` (line 172) | +| `JsonRpcMaxBodyBytes` | `uint32` | `json_rpc_max_body_bytes` (line 175) | + +Converter: direct field assignment, no nil checks (scalars). + +## Issue #35: MCP Policy Types + +### New type: `McpOptions` + +```go +type McpOptions struct { + StrictToolNames *bool + AllowAllKnownMcpMethods *bool +} +``` + +Uses `*bool` because proto fields are `optional bool`. Converter uses `CopyBoolPtr()`. + +### Modified types + +- `PolicyNetworkEndpoint`: add `Mcp *McpOptions` field +- `L7Allow`: add `Params map[string]L7QueryMatcher` field +- `L7DenyRule`: add `Params map[string]L7QueryMatcher` field + +Note: `L7QueryMatcher` needs to be defined if not already present. Check proto for the exact message shape during implementation. + +## Issue #36: Network Middleware Types + +### New type: `NetworkMiddlewareConfig` + +```go +type NetworkMiddlewareConfig struct { + Name string + Middleware string + Config map[string]any + OnError string + Endpoints *MiddlewareEndpointSelector + Order int32 +} +``` + +`Config` maps from `google.protobuf.Struct` (same pattern used elsewhere in the SDK). + +### New type: `MiddlewareEndpointSelector` + +```go +type MiddlewareEndpointSelector struct { + Include []string + Exclude []string +} +``` + +### New type: `SupervisorMiddlewareService` + +```go +type SupervisorMiddlewareService struct { + Name string + GrpcEndpoint string + MaxBodyBytes uint64 + Timeout string +} +``` + +### Modified type: `SandboxPolicy` + +Add `NetworkMiddlewares map[string]NetworkMiddlewareConfig`. Move `network_middlewares` from `skipped` to `handled` in coverage test. + +## Files Changed + +| File | Changes | +|------|---------| +| `openshell/v1/types/network_policy.go` | `PolicyNetworkEndpoint` fields, `McpOptions` type | +| `openshell/v1/types/policy.go` | Middleware types, `SandboxPolicy.NetworkMiddlewares`, `Params` on L7 types | +| `openshell/v1/internal/converter/network_policy.go` | Converter for SigV4, MCP, Params fields | +| `openshell/v1/internal/converter/policy.go` | Converter for middleware types | +| `openshell/v1/internal/converter/coverage_test.go` | Move fields to handled, remove from skipped | +| `openshell/v1/internal/converter/network_policy_test.go` | Tests for endpoint/MCP conversions | +| `openshell/v1/internal/converter/policy_test.go` | Tests for middleware conversions | + +## Testing Strategy + +For each new type/field: +- `TestXxxFromProto`: build proto message, convert, assert each field +- `TestXxxRoundTrip`: SDK -> proto -> SDK, assert equality +- `TestXxxDeepCopy`: convert, mutate source, assert target isolation + +Coverage test must pass with all new fields in `handled` set. + +`mise run ci` must pass (lint, build, test, proto:check). + +## Out of Scope + +- Fake client changes (fakes don't need to know about these types) +- New sub-client methods (these are type/converter additions only) +- Proto file changes (we consume upstream proto as-is) From df14200b3cde3b2ee32514631d0ccd2b029ba918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 8 Aug 2026 15:27:36 +0200 Subject: [PATCH 2/7] docs: add implementation plan for Drop D proto gap fixes --- .../plans/2026-08-08-proto-gap-drop-d.md | 661 ++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-proto-gap-drop-d.md diff --git a/docs/superpowers/plans/2026-08-08-proto-gap-drop-d.md b/docs/superpowers/plans/2026-08-08-proto-gap-drop-d.md new file mode 100644 index 0000000..2678bd1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-proto-gap-drop-d.md @@ -0,0 +1,661 @@ +# Proto Gap Fixes: Drop D Network Policy Types + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add domain types, converters, and tests for three sets of upstream proto fields: SigV4/JSON-RPC on NetworkEndpoint (#37), MCP policy types (#35), and network middleware types (#36). + +**Architecture:** Follow the established 3-layer pattern: domain types in `types/`, converter functions in `internal/converter/`, coverage test entries in `coverage_test.go`. Each proto field gets a corresponding Go struct field, a FromProto conversion, a ToProto conversion, and round-trip/deep-copy tests. + +**Tech Stack:** Go, protobuf, testify (assert/require) + +## Global Constraints + +- Every `.go` file must start with the SPDX license header +- All slice/map fields must be deep-copied at the proto/SDK boundary +- `*bool` for proto `optional bool` fields, using `CopyBoolPtr()` helper +- Coverage test must list every proto field in either `handled` or `skipped` +- `mise run ci` must pass after each task + +--- + +### Task 1: SigV4 and JSON-RPC Fields on NetworkEndpoint (#37) + +**Files:** +- Modify: `openshell/v1/types/network_policy.go:19-38` (PolicyNetworkEndpoint struct) +- Modify: `openshell/v1/internal/converter/network_policy.go:65-113` (policyNetworkEndpointFromProto) +- Modify: `openshell/v1/internal/converter/network_policy.go:115-157` (policyNetworkEndpointToProto) +- Modify: `openshell/v1/internal/converter/network_policy_test.go` + +**Interfaces:** +- Consumes: `sbv1.NetworkEndpoint` getters: `GetCredentialSigning()`, `GetSigningService()`, `GetSigningRegion()`, `GetJsonRpcMaxBodyBytes()` +- Produces: Four new fields on `types.PolicyNetworkEndpoint`: `CredentialSigning string`, `SigningService string`, `SigningRegion string`, `JsonRpcMaxBodyBytes uint32` + +- [ ] **Step 1: Add fields to PolicyNetworkEndpoint** + +In `openshell/v1/types/network_policy.go`, add four fields after `AdvisorProposed`: + +```go +AdvisorProposed bool +CredentialSigning string +SigningService string +SigningRegion string +JsonRpcMaxBodyBytes uint32 +``` + +- [ ] **Step 2: Add FromProto conversion** + +In `openshell/v1/internal/converter/network_policy.go`, in `policyNetworkEndpointFromProto`, add after the `AdvisorProposed` line: + +```go +CredentialSigning: ep.GetCredentialSigning(), +SigningService: ep.GetSigningService(), +SigningRegion: ep.GetSigningRegion(), +JsonRpcMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(), +``` + +- [ ] **Step 3: Add ToProto conversion** + +In `policyNetworkEndpointToProto`, add after `AdvisorProposed`: + +```go +CredentialSigning: ep.CredentialSigning, +SigningService: ep.SigningService, +SigningRegion: ep.SigningRegion, +JsonRpcMaxBodyBytes: ep.JsonRpcMaxBodyBytes, +``` + +- [ ] **Step 4: Update TestNetworkPolicyRuleFromProto** + +In `network_policy_test.go`, add to the proto endpoint literal: + +```go +CredentialSigning: "sigv4", +SigningService: "bedrock", +SigningRegion: "us-west-2", +JsonRpcMaxBodyBytes: 65536, +``` + +Add assertions after the `AdvisorProposed` assertion: + +```go +assert.Equal(t, "sigv4", ep.CredentialSigning) +assert.Equal(t, "bedrock", ep.SigningService) +assert.Equal(t, "us-west-2", ep.SigningRegion) +assert.Equal(t, uint32(65536), ep.JsonRpcMaxBodyBytes) +``` + +- [ ] **Step 5: Update TestNetworkPolicyRuleRoundTrip** + +Add the four fields to the SDK endpoint literal in the round-trip test: + +```go +CredentialSigning: "sigv4", +SigningService: "bedrock", +SigningRegion: "us-east-1", +JsonRpcMaxBodyBytes: 32768, +``` + +Add assertions: + +```go +assert.Equal(t, original.Endpoints[0].CredentialSigning, roundTrip.Endpoints[0].CredentialSigning) +assert.Equal(t, original.Endpoints[0].SigningService, roundTrip.Endpoints[0].SigningService) +assert.Equal(t, original.Endpoints[0].SigningRegion, roundTrip.Endpoints[0].SigningRegion) +assert.Equal(t, original.Endpoints[0].JsonRpcMaxBodyBytes, roundTrip.Endpoints[0].JsonRpcMaxBodyBytes) +``` + +- [ ] **Step 6: Run tests** + +Run: `mise run test` +Expected: All tests pass including coverage test (fields already in `handled` set). + +- [ ] **Step 7: Commit** + +```bash +git add openshell/v1/types/network_policy.go openshell/v1/internal/converter/network_policy.go openshell/v1/internal/converter/network_policy_test.go +git commit -m "feat: add SigV4 and JSON-RPC fields to PolicyNetworkEndpoint (#37)" +``` + +--- + +### Task 2: MCP Policy Types (#35) + +**Files:** +- Modify: `openshell/v1/types/network_policy.go` (add McpOptions type, Mcp field on endpoint, Params on L7Allow/L7DenyRule) +- Modify: `openshell/v1/internal/converter/network_policy.go` (McpOptions converter, Params converter, wire into endpoint/L7 converters) +- Modify: `openshell/v1/internal/converter/network_policy_test.go` + +**Interfaces:** +- Consumes: `sbv1.McpOptions` with `GetStrictToolNames() *bool`, `GetAllowAllKnownMcpMethods() *bool`; `sbv1.NetworkEndpoint.GetMcp() *sbv1.McpOptions`; `sbv1.L7Allow.GetParams()`, `sbv1.L7DenyRule.GetParams()` returning `map[string]*sbv1.L7QueryMatcher` +- Produces: `types.McpOptions` struct; `types.PolicyNetworkEndpoint.Mcp *McpOptions`; `types.L7Allow.Params map[string]L7QueryMatcher`; `types.L7DenyRule.Params map[string]L7QueryMatcher` + +- [ ] **Step 1: Add McpOptions domain type** + +In `openshell/v1/types/network_policy.go`, add after the `GraphqlOperation` struct: + +```go +// McpOptions configures MCP-specific policy controls on a network endpoint. +type McpOptions struct { + StrictToolNames *bool + AllowAllKnownMcpMethods *bool +} +``` + +- [ ] **Step 2: Add Mcp field to PolicyNetworkEndpoint** + +In the `PolicyNetworkEndpoint` struct, add after `JsonRpcMaxBodyBytes`: + +```go +Mcp *McpOptions +``` + +- [ ] **Step 3: Add Params field to L7Allow and L7DenyRule** + +In the `L7Allow` struct, add after `Fields`: + +```go +Params map[string]L7QueryMatcher +``` + +In the `L7DenyRule` struct, add after `Fields`: + +```go +Params map[string]L7QueryMatcher +``` + +- [ ] **Step 4: Add McpOptions converter functions** + +In `openshell/v1/internal/converter/network_policy.go`, add: + +```go +func mcpOptionsFromProto(m *sbv1.McpOptions) *types.McpOptions { + if m == nil { + return nil + } + return &types.McpOptions{ + StrictToolNames: CopyBoolPtr(m.StrictToolNames), + AllowAllKnownMcpMethods: CopyBoolPtr(m.AllowAllKnownMcpMethods), + } +} + +func mcpOptionsToProto(m *types.McpOptions) *sbv1.McpOptions { + if m == nil { + return nil + } + return &sbv1.McpOptions{ + StrictToolNames: CopyBoolPtr(m.StrictToolNames), + AllowAllKnownMcpMethods: CopyBoolPtr(m.AllowAllKnownMcpMethods), + } +} +``` + +- [ ] **Step 5: Wire Mcp into endpoint converters** + +In `policyNetworkEndpointFromProto`, add after the struct literal (near the GraphQL block): + +```go +result.Mcp = mcpOptionsFromProto(ep.GetMcp()) +``` + +In `policyNetworkEndpointToProto`, add after the GraphQL block: + +```go +result.Mcp = mcpOptionsToProto(ep.Mcp) +``` + +- [ ] **Step 6: Wire Params into L7Allow converter** + +In `l7RuleFromProto`, inside the `if a := r.GetAllow(); a != nil` block, add after the Query handling: + +```go +if p := a.GetParams(); len(p) > 0 { + result.Allow.Params = l7QueryMapFromProto(p) +} +``` + +In `l7RuleToProto`, inside the `if r.Allow != nil` block, add after the Query handling: + +```go +if len(r.Allow.Params) > 0 { + result.Allow.Params = l7QueryMapToProto(r.Allow.Params) +} +``` + +- [ ] **Step 7: Wire Params into L7DenyRule converter** + +In `l7DenyRuleFromProto`, add `Params` to the return struct: + +```go +Params: l7QueryMapFromProtoDeny(r.GetParams()), +``` + +In `l7DenyRuleToProto`, add after the Query handling: + +```go +if len(r.Params) > 0 { + result.Params = l7QueryMapToProtoDeny(r.Params) +} +``` + +- [ ] **Step 8: Add MCP tests** + +In `network_policy_test.go`, update `TestNetworkPolicyRuleFromProto` proto literal to include MCP: + +```go +Mcp: &sbv1.McpOptions{ + StrictToolNames: boolPtr(true), + AllowAllKnownMcpMethods: boolPtr(false), +}, +``` + +Add a `boolPtr` helper at the bottom of the test file: + +```go +func boolPtr(v bool) *bool { return &v } +``` + +Add assertions: + +```go +require.NotNil(t, ep.Mcp) +require.NotNil(t, ep.Mcp.StrictToolNames) +assert.True(t, *ep.Mcp.StrictToolNames) +require.NotNil(t, ep.Mcp.AllowAllKnownMcpMethods) +assert.False(t, *ep.Mcp.AllowAllKnownMcpMethods) +``` + +Also add `Params` to the L7Allow proto in the test: + +```go +Params: map[string]*sbv1.L7QueryMatcher{ + "name": {Glob: "my-tool-*"}, +}, +``` + +And assert: + +```go +require.Contains(t, allow.Params, "name") +assert.Equal(t, "my-tool-*", allow.Params["name"].Glob) +``` + +Add `Params` to the L7DenyRule proto and assert similarly. + +- [ ] **Step 9: Add MCP round-trip test data** + +Update `TestNetworkPolicyRuleRoundTrip` SDK literal to include: + +```go +Mcp: &v1.McpOptions{ + StrictToolNames: boolPtr(true), + AllowAllKnownMcpMethods: boolPtr(false), +}, +``` + +And add Params to the L7Allow and L7DenyRule in the round-trip test. + +Add assertions for MCP round-trip: + +```go +require.NotNil(t, roundTrip.Endpoints[0].Mcp) +assert.Equal(t, original.Endpoints[0].Mcp.StrictToolNames, roundTrip.Endpoints[0].Mcp.StrictToolNames) +``` + +- [ ] **Step 10: Add MCP deep-copy test** + +Add to `TestNetworkPolicyRuleDeepCopy`: + +```go +// Also test MCP deep copy +mcpProto := &sbv1.NetworkPolicyRule{ + Name: "mcp-test", + Endpoints: []*sbv1.NetworkEndpoint{ + { + Mcp: &sbv1.McpOptions{ + StrictToolNames: boolPtr(true), + }, + }, + }, +} +mcpRule := NetworkPolicyRuleFromProto(mcpProto) +*mcpProto.Endpoints[0].Mcp.StrictToolNames = false +require.NotNil(t, mcpRule.Endpoints[0].Mcp.StrictToolNames) +assert.True(t, *mcpRule.Endpoints[0].Mcp.StrictToolNames) +``` + +- [ ] **Step 11: Run tests** + +Run: `mise run test` +Expected: All tests pass. + +- [ ] **Step 12: Commit** + +```bash +git add openshell/v1/types/network_policy.go openshell/v1/internal/converter/network_policy.go openshell/v1/internal/converter/network_policy_test.go +git commit -m "feat: add MCP policy types and Params field (#35)" +``` + +--- + +### Task 3: Network Middleware Types (#36) + +**Files:** +- Modify: `openshell/v1/types/policy.go` (add 3 new types, NetworkMiddlewares field on SandboxPolicy) +- Modify: `openshell/v1/internal/converter/policy.go` (middleware converter functions, wire into SandboxPolicy converter) +- Modify: `openshell/v1/internal/converter/coverage_test.go` (move network_middlewares from skipped to handled) +- Modify: `openshell/v1/internal/converter/policy_test.go` (tests for middleware conversion) + +**Interfaces:** +- Consumes: `sbv1.NetworkMiddlewareConfig` with getters: `GetName()`, `GetMiddleware()`, `GetConfig() *structpb.Struct`, `GetOnError()`, `GetEndpoints() *sbv1.MiddlewareEndpointSelector`, `GetOrder() int32`; `sbv1.MiddlewareEndpointSelector` with `GetInclude()`, `GetExclude()`; `sbv1.SandboxPolicy.GetNetworkMiddlewares() map[string]*sbv1.NetworkMiddlewareConfig` +- Produces: `types.NetworkMiddlewareConfig`, `types.MiddlewareEndpointSelector`, `types.SandboxPolicy.NetworkMiddlewares map[string]NetworkMiddlewareConfig` + +Note: `SupervisorMiddlewareService` is on `GetSandboxConfigResponse`, not `SandboxPolicy`. It belongs to a config client converter, not the policy converter. Leave it out of this task (it will be handled when the config client is implemented in Drop D). + +- [ ] **Step 1: Add middleware domain types** + +In `openshell/v1/types/policy.go`, add after the `SandboxPolicy` struct (before `FilesystemPolicy`): + +```go +// NetworkMiddlewareConfig configures a supervisor middleware pipeline for +// network egress. Middleware configs are referenced by name in the policy. +type NetworkMiddlewareConfig struct { + Name string + Middleware string + Config map[string]any + OnError string + Endpoints *MiddlewareEndpointSelector + Order int32 +} + +// MiddlewareEndpointSelector controls which admitted destinations use a +// middleware config, using host glob patterns. +type MiddlewareEndpointSelector struct { + Include []string + Exclude []string +} +``` + +- [ ] **Step 2: Add NetworkMiddlewares field to SandboxPolicy** + +In the `SandboxPolicy` struct, add after `NetworkPolicies`: + +```go +NetworkMiddlewares map[string]NetworkMiddlewareConfig +``` + +- [ ] **Step 3: Add middleware converter functions** + +In `openshell/v1/internal/converter/policy.go`, add the import for `structpb`: + +```go +import ( + "github.com/rhuss/openshell-sdk-go/openshell/v1/types" + pb "github.com/rhuss/openshell-sdk-go/proto/openshellv1" + sbv1 "github.com/rhuss/openshell-sdk-go/proto/sandboxv1" + "google.golang.org/protobuf/types/known/structpb" +) +``` + +Add converter functions: + +```go +func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMiddlewareConfig { + result := types.NetworkMiddlewareConfig{ + Name: m.GetName(), + Middleware: m.GetMiddleware(), + OnError: m.GetOnError(), + Order: m.GetOrder(), + } + if c := m.GetConfig(); c != nil { + result.Config = c.AsMap() + } + if ep := m.GetEndpoints(); ep != nil { + result.Endpoints = &types.MiddlewareEndpointSelector{ + Include: CopyStringSlice(ep.GetInclude()), + Exclude: CopyStringSlice(ep.GetExclude()), + } + } + return result +} + +func middlewareConfigToProto(m *types.NetworkMiddlewareConfig) *sbv1.NetworkMiddlewareConfig { + result := &sbv1.NetworkMiddlewareConfig{ + Name: m.Name, + Middleware: m.Middleware, + OnError: m.OnError, + Order: m.Order, + } + if m.Config != nil { + s, err := structpb.NewStruct(m.Config) + if err == nil { + result.Config = s + } + } + if m.Endpoints != nil { + result.Endpoints = &sbv1.MiddlewareEndpointSelector{ + Include: CopyStringSlice(m.Endpoints.Include), + Exclude: CopyStringSlice(m.Endpoints.Exclude), + } + } + return result +} +``` + +- [ ] **Step 4: Wire middleware into SandboxPolicy converters** + +In `SandboxPolicyFromProto`, add after the `NetworkPolicies` block: + +```go +if mw := p.GetNetworkMiddlewares(); mw != nil { + result.NetworkMiddlewares = make(map[string]types.NetworkMiddlewareConfig, len(mw)) + for k, v := range mw { + if v != nil { + result.NetworkMiddlewares[k] = middlewareConfigFromProto(v) + } + } +} +``` + +In `SandboxPolicyToProto`, add after the `NetworkPolicies` block: + +```go +if p.NetworkMiddlewares != nil { + result.NetworkMiddlewares = make(map[string]*sbv1.NetworkMiddlewareConfig, len(p.NetworkMiddlewares)) + for k, v := range p.NetworkMiddlewares { + result.NetworkMiddlewares[k] = middlewareConfigToProto(&v) + } +} +``` + +- [ ] **Step 5: Update coverage test** + +In `coverage_test.go`, in `TestConverterCoversAllProtoFields_SandboxPolicy`: + +Move `"network_middlewares"` from `skipped` to `handled`: + +```go +handled := fieldSet{ + "version": true, + "filesystem": true, + "network_policies": true, + "process": true, + "landlock": true, + "network_middlewares": true, +} +``` + +Remove the `skipped` variable and pass `nil` for skipped: + +```go +assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, nil) +``` + +Add a new coverage test for `NetworkMiddlewareConfig`: + +```go +func TestConverterCoversAllProtoFields_NetworkMiddlewareConfig(t *testing.T) { + handled := fieldSet{ + "name": true, + "middleware": true, + "config": true, + "on_error": true, + "endpoints": true, + "order": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.NetworkMiddlewareConfig{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_MiddlewareEndpointSelector(t *testing.T) { + handled := fieldSet{ + "include": true, + "exclude": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.MiddlewareEndpointSelector{}).ProtoReflect().Descriptor(), handled, nil) +} +``` + +- [ ] **Step 6: Add middleware converter tests** + +In `openshell/v1/internal/converter/policy_test.go`, add: + +```go +func TestSandboxPolicyFromProto_WithMiddleware(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + Version: 3, + NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + "sigv4-rewriter": { + Name: "sigv4-rewriter", + Middleware: "aws-sigv4", + OnError: "fail_closed", + Order: 10, + Config: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{ + "region": "us-east-1", + "service": "bedrock", + }) + return s + }(), + Endpoints: &sbv1.MiddlewareEndpointSelector{ + Include: []string{"*.bedrock.amazonaws.com"}, + Exclude: []string{"sts.amazonaws.com"}, + }, + }, + }, + } + + policy := SandboxPolicyFromProto(proto) + + require.NotNil(t, policy) + require.Contains(t, policy.NetworkMiddlewares, "sigv4-rewriter") + mw := policy.NetworkMiddlewares["sigv4-rewriter"] + assert.Equal(t, "sigv4-rewriter", mw.Name) + assert.Equal(t, "aws-sigv4", mw.Middleware) + assert.Equal(t, "fail_closed", mw.OnError) + assert.Equal(t, int32(10), mw.Order) + require.NotNil(t, mw.Config) + assert.Equal(t, "us-east-1", mw.Config["region"]) + assert.Equal(t, "bedrock", mw.Config["service"]) + require.NotNil(t, mw.Endpoints) + assert.Equal(t, []string{"*.bedrock.amazonaws.com"}, mw.Endpoints.Include) + assert.Equal(t, []string{"sts.amazonaws.com"}, mw.Endpoints.Exclude) +} + +func TestSandboxPolicyMiddlewareRoundTrip(t *testing.T) { + original := &types.SandboxPolicy{ + Version: 5, + NetworkMiddlewares: map[string]types.NetworkMiddlewareConfig{ + "rate-limiter": { + Name: "rate-limiter", + Middleware: "envoy-ratelimit", + OnError: "fail_open", + Order: 20, + Config: map[string]any{ + "requests_per_second": float64(100), + }, + Endpoints: &types.MiddlewareEndpointSelector{ + Include: []string{"api.*"}, + }, + }, + }, + } + + proto := SandboxPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := SandboxPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + require.Contains(t, roundTrip.NetworkMiddlewares, "rate-limiter") + mw := roundTrip.NetworkMiddlewares["rate-limiter"] + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Name, mw.Name) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Middleware, mw.Middleware) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].OnError, mw.OnError) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Order, mw.Order) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Config["requests_per_second"], mw.Config["requests_per_second"]) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Endpoints.Include, mw.Endpoints.Include) +} + +func TestSandboxPolicyMiddlewareDeepCopy(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + "test": { + Endpoints: &sbv1.MiddlewareEndpointSelector{ + Include: []string{"original.com"}, + }, + }, + }, + } + + policy := SandboxPolicyFromProto(proto) + proto.NetworkMiddlewares["test"].Endpoints.Include[0] = "mutated.com" + + assert.Equal(t, "original.com", policy.NetworkMiddlewares["test"].Endpoints.Include[0]) +} +``` + +Add the `structpb` import to the test file: + +```go +import ( + "google.golang.org/protobuf/types/known/structpb" +) +``` + +- [ ] **Step 7: Run tests** + +Run: `mise run test` +Expected: All tests pass, coverage test has no skipped fields for SandboxPolicy. + +- [ ] **Step 8: Commit** + +```bash +git add openshell/v1/types/policy.go openshell/v1/internal/converter/policy.go openshell/v1/internal/converter/coverage_test.go openshell/v1/internal/converter/policy_test.go +git commit -m "feat: add network middleware types and SandboxPolicy.NetworkMiddlewares (#36)" +``` + +--- + +### Task 4: Final Verification and CI + +**Files:** None (verification only) + +**Interfaces:** +- Consumes: All changes from Tasks 1-3 +- Produces: Green CI + +- [ ] **Step 1: Run full CI** + +Run: `mise run ci` +Expected: lint, build, test, proto:check all pass. + +- [ ] **Step 2: Verify no coverage test gaps** + +Run: `go test -run TestConverterCoversAllProtoFields ./openshell/v1/internal/converter/ -v` +Expected: All coverage tests pass, no "not handled" or "not explicitly skipped" errors. + +- [ ] **Step 3: Verify proto:check** + +Run: `mise run proto:check` +Expected: Generated files are up to date (proto was regenerated at the start). From aca3c899f4990c14aa8cc9b536b459fc7e3aa8c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 8 Aug 2026 15:36:40 +0200 Subject: [PATCH 3/7] feat: add SigV4 and JSON-RPC fields to PolicyNetworkEndpoint (#37) --- .../v1/internal/converter/network_policy.go | 8 ++++++++ .../v1/internal/converter/network_policy_test.go | 16 ++++++++++++++++ openshell/v1/types/network_policy.go | 4 ++++ 3 files changed, 28 insertions(+) diff --git a/openshell/v1/internal/converter/network_policy.go b/openshell/v1/internal/converter/network_policy.go index de84e49..e8b124e 100644 --- a/openshell/v1/internal/converter/network_policy.go +++ b/openshell/v1/internal/converter/network_policy.go @@ -77,6 +77,10 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor WebsocketCredentialRewrite: ep.GetWebsocketCredentialRewrite(), RequestBodyCredentialRewrite: ep.GetRequestBodyCredentialRewrite(), AdvisorProposed: ep.GetAdvisorProposed(), + CredentialSigning: ep.GetCredentialSigning(), + SigningService: ep.GetSigningService(), + SigningRegion: ep.GetSigningRegion(), + JsonRpcMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(), } if ports := ep.GetPorts(); len(ports) > 0 { result.Ports = make([]uint32, len(ports)) @@ -127,6 +131,10 @@ func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.Network WebsocketCredentialRewrite: ep.WebsocketCredentialRewrite, RequestBodyCredentialRewrite: ep.RequestBodyCredentialRewrite, AdvisorProposed: ep.AdvisorProposed, + CredentialSigning: ep.CredentialSigning, + SigningService: ep.SigningService, + SigningRegion: ep.SigningRegion, + JsonRpcMaxBodyBytes: ep.JsonRpcMaxBodyBytes, } if len(ep.Ports) > 0 { result.Ports = make([]uint32, len(ep.Ports)) diff --git a/openshell/v1/internal/converter/network_policy_test.go b/openshell/v1/internal/converter/network_policy_test.go index cab3c41..46f0d13 100644 --- a/openshell/v1/internal/converter/network_policy_test.go +++ b/openshell/v1/internal/converter/network_policy_test.go @@ -34,6 +34,10 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { WebsocketCredentialRewrite: true, RequestBodyCredentialRewrite: false, AdvisorProposed: true, + CredentialSigning: "sigv4", + SigningService: "bedrock", + SigningRegion: "us-west-2", + JsonRpcMaxBodyBytes: 65536, Rules: []*sbv1.L7Rule{ { Allow: &sbv1.L7Allow{ @@ -97,6 +101,10 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { assert.True(t, ep.WebsocketCredentialRewrite) assert.False(t, ep.RequestBodyCredentialRewrite) assert.True(t, ep.AdvisorProposed) + assert.Equal(t, "sigv4", ep.CredentialSigning) + assert.Equal(t, "bedrock", ep.SigningService) + assert.Equal(t, "us-west-2", ep.SigningRegion) + assert.Equal(t, uint32(65536), ep.JsonRpcMaxBodyBytes) // L7 rules require.Len(t, ep.Rules, 1) @@ -160,6 +168,10 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { WebsocketCredentialRewrite: false, RequestBodyCredentialRewrite: true, AdvisorProposed: false, + CredentialSigning: "sigv4", + SigningService: "bedrock", + SigningRegion: "us-east-1", + JsonRpcMaxBodyBytes: 32768, Rules: []v1.L7Rule{ { Allow: &v1.L7Allow{ @@ -215,6 +227,10 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { assert.Equal(t, original.Endpoints[0].AllowEncodedSlash, roundTrip.Endpoints[0].AllowEncodedSlash) assert.Equal(t, original.Endpoints[0].GraphqlMaxBodyBytes, roundTrip.Endpoints[0].GraphqlMaxBodyBytes) assert.Equal(t, original.Endpoints[0].AdvisorProposed, roundTrip.Endpoints[0].AdvisorProposed) + assert.Equal(t, original.Endpoints[0].CredentialSigning, roundTrip.Endpoints[0].CredentialSigning) + assert.Equal(t, original.Endpoints[0].SigningService, roundTrip.Endpoints[0].SigningService) + assert.Equal(t, original.Endpoints[0].SigningRegion, roundTrip.Endpoints[0].SigningRegion) + assert.Equal(t, original.Endpoints[0].JsonRpcMaxBodyBytes, roundTrip.Endpoints[0].JsonRpcMaxBodyBytes) // L7 rules round-trip require.Len(t, roundTrip.Endpoints[0].Rules, 1) diff --git a/openshell/v1/types/network_policy.go b/openshell/v1/types/network_policy.go index 99084a8..861f133 100644 --- a/openshell/v1/types/network_policy.go +++ b/openshell/v1/types/network_policy.go @@ -35,6 +35,10 @@ type PolicyNetworkEndpoint struct { WebsocketCredentialRewrite bool RequestBodyCredentialRewrite bool AdvisorProposed bool + CredentialSigning string + SigningService string + SigningRegion string + JsonRpcMaxBodyBytes uint32 } // PolicyNetworkBinary identifies a binary subject to network policy enforcement. From 980302e64f0a2e21294bfea78d08fb76d925bf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 8 Aug 2026 15:41:14 +0200 Subject: [PATCH 4/7] feat: add MCP policy types and Params field (#35) --- .../v1/internal/converter/network_policy.go | 34 +++++++++++ .../internal/converter/network_policy_test.go | 56 +++++++++++++++++++ openshell/v1/types/network_policy.go | 9 +++ 3 files changed, 99 insertions(+) diff --git a/openshell/v1/internal/converter/network_policy.go b/openshell/v1/internal/converter/network_policy.go index e8b124e..a9359b0 100644 --- a/openshell/v1/internal/converter/network_policy.go +++ b/openshell/v1/internal/converter/network_policy.go @@ -105,6 +105,7 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor } } } + result.Mcp = mcpOptionsFromProto(ep.GetMcp()) if gql := ep.GetGraphqlPersistedQueries(); len(gql) > 0 { result.GraphqlPersistedQueries = make(map[string]types.GraphqlOperation, len(gql)) for k, v := range gql { @@ -161,9 +162,32 @@ func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.Network result.GraphqlPersistedQueries[k] = graphqlOperationToProto(&v) } } + result.Mcp = mcpOptionsToProto(ep.Mcp) return result } +// --- McpOptions --- + +func mcpOptionsFromProto(m *sbv1.McpOptions) *types.McpOptions { + if m == nil { + return nil + } + return &types.McpOptions{ + StrictToolNames: CopyBoolPtr(m.StrictToolNames), + AllowAllKnownMcpMethods: CopyBoolPtr(m.AllowAllKnownMcpMethods), + } +} + +func mcpOptionsToProto(m *types.McpOptions) *sbv1.McpOptions { + if m == nil { + return nil + } + return &sbv1.McpOptions{ + StrictToolNames: CopyBoolPtr(m.StrictToolNames), + AllowAllKnownMcpMethods: CopyBoolPtr(m.AllowAllKnownMcpMethods), + } +} + // --- L7Rule --- func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule { @@ -180,6 +204,9 @@ func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule { if q := a.GetQuery(); len(q) > 0 { result.Allow.Query = l7QueryMapFromProto(q) } + if p := a.GetParams(); len(p) > 0 { + result.Allow.Params = l7QueryMapFromProto(p) + } } return result } @@ -198,6 +225,9 @@ func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule { if len(r.Allow.Query) > 0 { result.Allow.Query = l7QueryMapToProto(r.Allow.Query) } + if len(r.Allow.Params) > 0 { + result.Allow.Params = l7QueryMapToProto(r.Allow.Params) + } } return result } @@ -213,6 +243,7 @@ func l7DenyRuleFromProto(r *sbv1.L7DenyRule) types.L7DenyRule { OperationName: r.GetOperationName(), Fields: CopyStringSlice(r.GetFields()), Query: l7QueryMapFromProtoDeny(r.GetQuery()), + Params: l7QueryMapFromProtoDeny(r.GetParams()), } } @@ -228,6 +259,9 @@ func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule { if len(r.Query) > 0 { result.Query = l7QueryMapToProtoDeny(r.Query) } + if len(r.Params) > 0 { + result.Params = l7QueryMapToProtoDeny(r.Params) + } return result } diff --git a/openshell/v1/internal/converter/network_policy_test.go b/openshell/v1/internal/converter/network_policy_test.go index 46f0d13..6958d2b 100644 --- a/openshell/v1/internal/converter/network_policy_test.go +++ b/openshell/v1/internal/converter/network_policy_test.go @@ -38,6 +38,10 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { SigningService: "bedrock", SigningRegion: "us-west-2", JsonRpcMaxBodyBytes: 65536, + Mcp: &sbv1.McpOptions{ + StrictToolNames: boolPtr(true), + AllowAllKnownMcpMethods: boolPtr(false), + }, Rules: []*sbv1.L7Rule{ { Allow: &sbv1.L7Allow{ @@ -50,6 +54,9 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { OperationType: "query", OperationName: "GetUsers", Fields: []string{"id", "name"}, + Params: map[string]*sbv1.L7QueryMatcher{ + "name": {Glob: "my-tool-*"}, + }, }, }, }, @@ -64,6 +71,9 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { Query: map[string]*sbv1.L7QueryMatcher{ "force": {Glob: "true"}, }, + Params: map[string]*sbv1.L7QueryMatcher{ + "tool": {Glob: "deny-*"}, + }, }, }, GraphqlPersistedQueries: map[string]*sbv1.GraphqlOperation{ @@ -106,6 +116,13 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { assert.Equal(t, "us-west-2", ep.SigningRegion) assert.Equal(t, uint32(65536), ep.JsonRpcMaxBodyBytes) + // MCP options + require.NotNil(t, ep.Mcp) + require.NotNil(t, ep.Mcp.StrictToolNames) + assert.True(t, *ep.Mcp.StrictToolNames) + require.NotNil(t, ep.Mcp.AllowAllKnownMcpMethods) + assert.False(t, *ep.Mcp.AllowAllKnownMcpMethods) + // L7 rules require.Len(t, ep.Rules, 1) allow := ep.Rules[0].Allow @@ -119,6 +136,8 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { require.Contains(t, allow.Query, "page") assert.Equal(t, "[0-9]*", allow.Query["page"].Glob) assert.Equal(t, []string{"1", "2"}, allow.Query["page"].Any) + require.Contains(t, allow.Params, "name") + assert.Equal(t, "my-tool-*", allow.Params["name"].Glob) // Deny rules require.Len(t, ep.DenyRules, 1) @@ -131,6 +150,8 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { assert.Equal(t, []string{"*"}, deny.Fields) require.Contains(t, deny.Query, "force") assert.Equal(t, "true", deny.Query["force"].Glob) + require.Contains(t, deny.Params, "tool") + assert.Equal(t, "deny-*", deny.Params["tool"].Glob) // GraphQL persisted queries require.Contains(t, ep.GraphqlPersistedQueries, "abc123") @@ -172,6 +193,10 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { SigningService: "bedrock", SigningRegion: "us-east-1", JsonRpcMaxBodyBytes: 32768, + Mcp: &v1.McpOptions{ + StrictToolNames: boolPtr(true), + AllowAllKnownMcpMethods: boolPtr(false), + }, Rules: []v1.L7Rule{ { Allow: &v1.L7Allow{ @@ -183,6 +208,9 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { Query: map[string]v1.L7QueryMatcher{ "limit": {Glob: "[0-9]+"}, }, + Params: map[string]v1.L7QueryMatcher{ + "tool": {Glob: "allowed-*"}, + }, }, }, }, @@ -192,6 +220,9 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { Path: "/graphql", OperationType: "mutation", OperationName: "DropDB", + Params: map[string]v1.L7QueryMatcher{ + "tool": {Glob: "denied-*"}, + }, }, }, GraphqlPersistedQueries: map[string]v1.GraphqlOperation{ @@ -232,15 +263,22 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { assert.Equal(t, original.Endpoints[0].SigningRegion, roundTrip.Endpoints[0].SigningRegion) assert.Equal(t, original.Endpoints[0].JsonRpcMaxBodyBytes, roundTrip.Endpoints[0].JsonRpcMaxBodyBytes) + // MCP round-trip + require.NotNil(t, roundTrip.Endpoints[0].Mcp) + assert.Equal(t, original.Endpoints[0].Mcp.StrictToolNames, roundTrip.Endpoints[0].Mcp.StrictToolNames) + assert.Equal(t, original.Endpoints[0].Mcp.AllowAllKnownMcpMethods, roundTrip.Endpoints[0].Mcp.AllowAllKnownMcpMethods) + // L7 rules round-trip require.Len(t, roundTrip.Endpoints[0].Rules, 1) assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Method, roundTrip.Endpoints[0].Rules[0].Allow.Method) assert.Equal(t, original.Endpoints[0].Rules[0].Allow.OperationName, roundTrip.Endpoints[0].Rules[0].Allow.OperationName) assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Query["limit"].Glob, roundTrip.Endpoints[0].Rules[0].Allow.Query["limit"].Glob) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Params["tool"].Glob, roundTrip.Endpoints[0].Rules[0].Allow.Params["tool"].Glob) // Deny rules round-trip require.Len(t, roundTrip.Endpoints[0].DenyRules, 1) assert.Equal(t, original.Endpoints[0].DenyRules[0].OperationName, roundTrip.Endpoints[0].DenyRules[0].OperationName) + assert.Equal(t, original.Endpoints[0].DenyRules[0].Params["tool"].Glob, roundTrip.Endpoints[0].DenyRules[0].Params["tool"].Glob) // GraphQL persisted queries round-trip require.Contains(t, roundTrip.Endpoints[0].GraphqlPersistedQueries, "hash1") @@ -279,6 +317,22 @@ func TestNetworkPolicyRuleDeepCopy(t *testing.T) { assert.Equal(t, "1.2.3.4", rule.Endpoints[0].AllowedIPs[0]) assert.Equal(t, uint32(80), rule.Endpoints[0].Ports[0]) assert.Equal(t, "f1", rule.Endpoints[0].Rules[0].Allow.Fields[0]) + + // MCP deep copy + mcpProto := &sbv1.NetworkPolicyRule{ + Name: "mcp-test", + Endpoints: []*sbv1.NetworkEndpoint{ + { + Mcp: &sbv1.McpOptions{ + StrictToolNames: boolPtr(true), + }, + }, + }, + } + mcpRule := NetworkPolicyRuleFromProto(mcpProto) + *mcpProto.Endpoints[0].Mcp.StrictToolNames = false + require.NotNil(t, mcpRule.Endpoints[0].Mcp.StrictToolNames) + assert.True(t, *mcpRule.Endpoints[0].Mcp.StrictToolNames) } func TestL7RuleFromProto_NilAllow(t *testing.T) { @@ -286,3 +340,5 @@ func TestL7RuleFromProto_NilAllow(t *testing.T) { result := l7RuleFromProto(proto) assert.Nil(t, result.Allow) } + +func boolPtr(v bool) *bool { return &v } diff --git a/openshell/v1/types/network_policy.go b/openshell/v1/types/network_policy.go index 861f133..04be7b7 100644 --- a/openshell/v1/types/network_policy.go +++ b/openshell/v1/types/network_policy.go @@ -39,6 +39,7 @@ type PolicyNetworkEndpoint struct { SigningService string SigningRegion string JsonRpcMaxBodyBytes uint32 + Mcp *McpOptions } // PolicyNetworkBinary identifies a binary subject to network policy enforcement. @@ -63,6 +64,7 @@ type L7Allow struct { OperationType string OperationName string Fields []string + Params map[string]L7QueryMatcher } // L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL traffic. @@ -74,6 +76,7 @@ type L7DenyRule struct { OperationType string OperationName string Fields []string + Params map[string]L7QueryMatcher } // L7QueryMatcher matches query parameters by glob pattern or exact values. @@ -82,6 +85,12 @@ type L7QueryMatcher struct { Any []string } +// McpOptions configures MCP-specific policy controls on a network endpoint. +type McpOptions struct { + StrictToolNames *bool + AllowAllKnownMcpMethods *bool +} + // GraphqlOperation describes a GraphQL operation for persisted-query validation. type GraphqlOperation struct { OperationType string From d143e152f3a9880a461f965e0b6b6de84565a3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 8 Aug 2026 15:44:27 +0200 Subject: [PATCH 5/7] feat: add network middleware types and SandboxPolicy.NetworkMiddlewares (#36) --- .../v1/internal/converter/coverage_test.go | 37 +++++-- openshell/v1/internal/converter/policy.go | 56 +++++++++++ .../v1/internal/converter/policy_test.go | 96 +++++++++++++++++++ openshell/v1/types/policy.go | 21 ++++ 4 files changed, 200 insertions(+), 10 deletions(-) diff --git a/openshell/v1/internal/converter/coverage_test.go b/openshell/v1/internal/converter/coverage_test.go index 3a78593..aa380da 100644 --- a/openshell/v1/internal/converter/coverage_test.go +++ b/openshell/v1/internal/converter/coverage_test.go @@ -78,20 +78,37 @@ func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) { func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { handled := fieldSet{ - "version": true, - "filesystem": true, - "network_policies": true, - "process": true, - "landlock": true, + "version": true, + "filesystem": true, + "network_policies": true, + "process": true, + "landlock": true, + "network_middlewares": true, } - skipped := fieldSet{ - // Middleware support is not yet exposed in the SDK domain model. - // Tracked in GitHub issue #36 for Drop D. - "network_middlewares": true, + assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_NetworkMiddlewareConfig(t *testing.T) { + handled := fieldSet{ + "name": true, + "middleware": true, + "config": true, + "on_error": true, + "endpoints": true, + "order": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.NetworkMiddlewareConfig{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_MiddlewareEndpointSelector(t *testing.T) { + handled := fieldSet{ + "include": true, + "exclude": true, } - assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, skipped) + assertAllFieldsCovered(t, (&sandboxpb.MiddlewareEndpointSelector{}).ProtoReflect().Descriptor(), handled, nil) } func TestConverterCoversAllProtoFields_NetworkEndpoint(t *testing.T) { diff --git a/openshell/v1/internal/converter/policy.go b/openshell/v1/internal/converter/policy.go index ff5a053..2f7919f 100644 --- a/openshell/v1/internal/converter/policy.go +++ b/openshell/v1/internal/converter/policy.go @@ -7,6 +7,7 @@ import ( "github.com/rhuss/openshell-sdk-go/openshell/v1/types" pb "github.com/rhuss/openshell-sdk-go/proto/openshellv1" sbv1 "github.com/rhuss/openshell-sdk-go/proto/sandboxv1" + "google.golang.org/protobuf/types/known/structpb" ) // --- PolicyLoadStatus enum mapping --- @@ -117,6 +118,14 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { } } } + if mw := p.GetNetworkMiddlewares(); mw != nil { + result.NetworkMiddlewares = make(map[string]types.NetworkMiddlewareConfig, len(mw)) + for k, v := range mw { + if v != nil { + result.NetworkMiddlewares[k] = middlewareConfigFromProto(v) + } + } + } return result } @@ -138,6 +147,53 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { result.NetworkPolicies[k] = NetworkPolicyRuleToProto(&v) } } + if p.NetworkMiddlewares != nil { + result.NetworkMiddlewares = make(map[string]*sbv1.NetworkMiddlewareConfig, len(p.NetworkMiddlewares)) + for k, v := range p.NetworkMiddlewares { + result.NetworkMiddlewares[k] = middlewareConfigToProto(&v) + } + } + return result +} + +func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMiddlewareConfig { + result := types.NetworkMiddlewareConfig{ + Name: m.GetName(), + Middleware: m.GetMiddleware(), + OnError: m.GetOnError(), + Order: m.GetOrder(), + } + if c := m.GetConfig(); c != nil { + result.Config = c.AsMap() + } + if ep := m.GetEndpoints(); ep != nil { + result.Endpoints = &types.MiddlewareEndpointSelector{ + Include: CopyStringSlice(ep.GetInclude()), + Exclude: CopyStringSlice(ep.GetExclude()), + } + } + return result +} + +func middlewareConfigToProto(m *types.NetworkMiddlewareConfig) *sbv1.NetworkMiddlewareConfig { + result := &sbv1.NetworkMiddlewareConfig{ + Name: m.Name, + Middleware: m.Middleware, + OnError: m.OnError, + Order: m.Order, + } + if m.Config != nil { + s, err := structpb.NewStruct(m.Config) + if err == nil { + result.Config = s + } + } + if m.Endpoints != nil { + result.Endpoints = &sbv1.MiddlewareEndpointSelector{ + Include: CopyStringSlice(m.Endpoints.Include), + Exclude: CopyStringSlice(m.Endpoints.Exclude), + } + } return result } diff --git a/openshell/v1/internal/converter/policy_test.go b/openshell/v1/internal/converter/policy_test.go index 1f8755b..3e89dab 100644 --- a/openshell/v1/internal/converter/policy_test.go +++ b/openshell/v1/internal/converter/policy_test.go @@ -11,6 +11,7 @@ import ( sbv1 "github.com/rhuss/openshell-sdk-go/proto/sandboxv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) // --- PolicyLoadStatus --- @@ -606,3 +607,98 @@ func TestDraftHistoryEntryFromProto(t *testing.T) { func TestDraftHistoryEntryFromProto_Nil(t *testing.T) { assert.Nil(t, DraftHistoryEntryFromProto(nil)) } + +// --- NetworkMiddleware --- + +func TestSandboxPolicyFromProto_WithMiddleware(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + Version: 3, + NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + "sigv4-rewriter": { + Name: "sigv4-rewriter", + Middleware: "aws-sigv4", + OnError: "fail_closed", + Order: 10, + Config: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{ + "region": "us-east-1", + "service": "bedrock", + }) + return s + }(), + Endpoints: &sbv1.MiddlewareEndpointSelector{ + Include: []string{"*.bedrock.amazonaws.com"}, + Exclude: []string{"sts.amazonaws.com"}, + }, + }, + }, + } + + policy := SandboxPolicyFromProto(proto) + + require.NotNil(t, policy) + require.Contains(t, policy.NetworkMiddlewares, "sigv4-rewriter") + mw := policy.NetworkMiddlewares["sigv4-rewriter"] + assert.Equal(t, "sigv4-rewriter", mw.Name) + assert.Equal(t, "aws-sigv4", mw.Middleware) + assert.Equal(t, "fail_closed", mw.OnError) + assert.Equal(t, int32(10), mw.Order) + require.NotNil(t, mw.Config) + assert.Equal(t, "us-east-1", mw.Config["region"]) + assert.Equal(t, "bedrock", mw.Config["service"]) + require.NotNil(t, mw.Endpoints) + assert.Equal(t, []string{"*.bedrock.amazonaws.com"}, mw.Endpoints.Include) + assert.Equal(t, []string{"sts.amazonaws.com"}, mw.Endpoints.Exclude) +} + +func TestSandboxPolicyMiddlewareRoundTrip(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 5, + NetworkMiddlewares: map[string]v1.NetworkMiddlewareConfig{ + "rate-limiter": { + Name: "rate-limiter", + Middleware: "envoy-ratelimit", + OnError: "fail_open", + Order: 20, + Config: map[string]any{ + "requests_per_second": float64(100), + }, + Endpoints: &v1.MiddlewareEndpointSelector{ + Include: []string{"api.*"}, + }, + }, + }, + } + + proto := SandboxPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := SandboxPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + require.Contains(t, roundTrip.NetworkMiddlewares, "rate-limiter") + mw := roundTrip.NetworkMiddlewares["rate-limiter"] + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Name, mw.Name) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Middleware, mw.Middleware) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].OnError, mw.OnError) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Order, mw.Order) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Config["requests_per_second"], mw.Config["requests_per_second"]) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Endpoints.Include, mw.Endpoints.Include) +} + +func TestSandboxPolicyMiddlewareDeepCopy(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + "test": { + Endpoints: &sbv1.MiddlewareEndpointSelector{ + Include: []string{"original.com"}, + }, + }, + }, + } + + policy := SandboxPolicyFromProto(proto) + proto.NetworkMiddlewares["test"].Endpoints.Include[0] = "mutated.com" + + assert.Equal(t, "original.com", policy.NetworkMiddlewares["test"].Endpoints.Include[0]) +} diff --git a/openshell/v1/types/policy.go b/openshell/v1/types/policy.go index cdae6b6..90e6f0e 100644 --- a/openshell/v1/types/policy.go +++ b/openshell/v1/types/policy.go @@ -110,6 +110,27 @@ type SandboxPolicy struct { // NetworkPolicies contains named network access rules. // Nil means no network policies are specified; an empty map is distinct from nil. NetworkPolicies map[string]NetworkPolicyRule + // NetworkMiddlewares contains named middleware pipeline configurations for + // network egress. Nil means no middleware is specified; an empty map is distinct from nil. + NetworkMiddlewares map[string]NetworkMiddlewareConfig +} + +// NetworkMiddlewareConfig configures a supervisor middleware pipeline for +// network egress. Middleware configs are referenced by name in the policy. +type NetworkMiddlewareConfig struct { + Name string + Middleware string + Config map[string]any + OnError string + Endpoints *MiddlewareEndpointSelector + Order int32 +} + +// MiddlewareEndpointSelector controls which admitted destinations use a +// middleware config, using host glob patterns. +type MiddlewareEndpointSelector struct { + Include []string + Exclude []string } // FilesystemPolicy controls which directories the sandbox can access From cff97856e3706a34c19804243f11fc0fbee9cbe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 8 Aug 2026 15:47:05 +0200 Subject: [PATCH 6/7] fix: rename JsonRpcMaxBodyBytes to JSONRPCMaxBodyBytes per Go conventions --- openshell/v1/internal/converter/network_policy.go | 4 ++-- openshell/v1/internal/converter/network_policy_test.go | 6 +++--- openshell/v1/types/network_policy.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openshell/v1/internal/converter/network_policy.go b/openshell/v1/internal/converter/network_policy.go index a9359b0..638aa4e 100644 --- a/openshell/v1/internal/converter/network_policy.go +++ b/openshell/v1/internal/converter/network_policy.go @@ -80,7 +80,7 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor CredentialSigning: ep.GetCredentialSigning(), SigningService: ep.GetSigningService(), SigningRegion: ep.GetSigningRegion(), - JsonRpcMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(), + JSONRPCMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(), } if ports := ep.GetPorts(); len(ports) > 0 { result.Ports = make([]uint32, len(ports)) @@ -135,7 +135,7 @@ func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.Network CredentialSigning: ep.CredentialSigning, SigningService: ep.SigningService, SigningRegion: ep.SigningRegion, - JsonRpcMaxBodyBytes: ep.JsonRpcMaxBodyBytes, + JsonRpcMaxBodyBytes: ep.JSONRPCMaxBodyBytes, } if len(ep.Ports) > 0 { result.Ports = make([]uint32, len(ep.Ports)) diff --git a/openshell/v1/internal/converter/network_policy_test.go b/openshell/v1/internal/converter/network_policy_test.go index 6958d2b..7a72d58 100644 --- a/openshell/v1/internal/converter/network_policy_test.go +++ b/openshell/v1/internal/converter/network_policy_test.go @@ -114,7 +114,7 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { assert.Equal(t, "sigv4", ep.CredentialSigning) assert.Equal(t, "bedrock", ep.SigningService) assert.Equal(t, "us-west-2", ep.SigningRegion) - assert.Equal(t, uint32(65536), ep.JsonRpcMaxBodyBytes) + assert.Equal(t, uint32(65536), ep.JSONRPCMaxBodyBytes) // MCP options require.NotNil(t, ep.Mcp) @@ -192,7 +192,7 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { CredentialSigning: "sigv4", SigningService: "bedrock", SigningRegion: "us-east-1", - JsonRpcMaxBodyBytes: 32768, + JSONRPCMaxBodyBytes: 32768, Mcp: &v1.McpOptions{ StrictToolNames: boolPtr(true), AllowAllKnownMcpMethods: boolPtr(false), @@ -261,7 +261,7 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { assert.Equal(t, original.Endpoints[0].CredentialSigning, roundTrip.Endpoints[0].CredentialSigning) assert.Equal(t, original.Endpoints[0].SigningService, roundTrip.Endpoints[0].SigningService) assert.Equal(t, original.Endpoints[0].SigningRegion, roundTrip.Endpoints[0].SigningRegion) - assert.Equal(t, original.Endpoints[0].JsonRpcMaxBodyBytes, roundTrip.Endpoints[0].JsonRpcMaxBodyBytes) + assert.Equal(t, original.Endpoints[0].JSONRPCMaxBodyBytes, roundTrip.Endpoints[0].JSONRPCMaxBodyBytes) // MCP round-trip require.NotNil(t, roundTrip.Endpoints[0].Mcp) diff --git a/openshell/v1/types/network_policy.go b/openshell/v1/types/network_policy.go index 04be7b7..ff18c3e 100644 --- a/openshell/v1/types/network_policy.go +++ b/openshell/v1/types/network_policy.go @@ -38,7 +38,7 @@ type PolicyNetworkEndpoint struct { CredentialSigning string SigningService string SigningRegion string - JsonRpcMaxBodyBytes uint32 + JSONRPCMaxBodyBytes uint32 Mcp *McpOptions } From d0be11f88ff9b63d44890d5d81c710abee2f7ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 8 Aug 2026 16:00:59 +0200 Subject: [PATCH 7/7] fix: address review findings (Mcp field order, structpb comment) --- openshell/v1/internal/converter/network_policy.go | 2 +- openshell/v1/internal/converter/policy.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openshell/v1/internal/converter/network_policy.go b/openshell/v1/internal/converter/network_policy.go index 638aa4e..cc2ce0f 100644 --- a/openshell/v1/internal/converter/network_policy.go +++ b/openshell/v1/internal/converter/network_policy.go @@ -105,7 +105,6 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor } } } - result.Mcp = mcpOptionsFromProto(ep.GetMcp()) if gql := ep.GetGraphqlPersistedQueries(); len(gql) > 0 { result.GraphqlPersistedQueries = make(map[string]types.GraphqlOperation, len(gql)) for k, v := range gql { @@ -114,6 +113,7 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor } } } + result.Mcp = mcpOptionsFromProto(ep.GetMcp()) return result } diff --git a/openshell/v1/internal/converter/policy.go b/openshell/v1/internal/converter/policy.go index 2f7919f..d3cfae5 100644 --- a/openshell/v1/internal/converter/policy.go +++ b/openshell/v1/internal/converter/policy.go @@ -183,6 +183,8 @@ func middlewareConfigToProto(m *types.NetworkMiddlewareConfig) *sbv1.NetworkMidd Order: m.Order, } if m.Config != nil { + // Non-JSON-compatible values (e.g., chan, func) are silently dropped. + // Round-trip data from structpb.AsMap is always re-serializable. s, err := structpb.NewStruct(m.Config) if err == nil { result.Config = s