diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 1fcb4bd9d..13b3bc7f9 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -245,7 +245,7 @@ func publicNetworkEndpointFromInternalProto(endpoint *sbv1.NetworkEndpoint) *pol SigningRegion: endpoint.GetSigningRegion(), } if ports := endpoint.GetPorts(); len(ports) > 0 { - result.Ports = append([]uint32(nil), ports...) + result.Ports = stableUnique(ports) } else if port := endpoint.GetPort(); port != 0 { // Older internal records may still use the legacy scalar port. The // authored contract has only the canonical list representation. @@ -435,7 +435,7 @@ func publicMatcherMapFromInternalProto(matchers map[string]*sbv1.L7QueryMatcher) func publicMatcherFromInternalProto(matcher *sbv1.L7QueryMatcher) *policyv1.Matcher { if matcher != nil && len(matcher.GetAny()) > 0 { return &policyv1.Matcher{Kind: &policyv1.Matcher_Any{Any: &policyv1.AnyMatcher{ - Values: CopyStringSlice(matcher.GetAny()), + Values: stableUnique(matcher.GetAny()), }}} } glob := "" @@ -445,6 +445,22 @@ func publicMatcherFromInternalProto(matcher *sbv1.L7QueryMatcher) *policyv1.Matc return &policyv1.Matcher{Kind: &policyv1.Matcher_Glob{Glob: glob}} } +// stableUnique canonicalizes legacy runtime lists for public fields whose +// protobuf contract requires unique values. Retaining the first occurrence +// keeps the projection deterministic without changing caller-visible order. +func stableUnique[T comparable](values []T) []T { + result := make([]T, 0, len(values)) + seen := make(map[T]struct{}, len(values)) + for _, value := range values { + if _, found := seen[value]; found { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + func publicTLSMode(mode sbv1.NetworkTlsMode) string { // Numeric cases keep the explicit projection compatible with deprecated // internal enum values without making new SDK code depend on their names. diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index 351e26be0..5cd564276 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -393,6 +393,39 @@ func TestPolicyDocumentFromInternalProtoExplicitProjection(t *testing.T) { assert.Equal(t, []string{"2025-11-25"}, policy.NetworkPolicies["api"].Endpoints[0].Mcp.Versions) } +func TestPolicyDocumentFromInternalProtoDeduplicatesLegacyValues(t *testing.T) { + internal := &sbv1.SandboxPolicy{ + Version: 1, + NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{ + "api": { + Endpoints: []*sbv1.NetworkEndpoint{{ + Host: "api.example.com", + Ports: []uint32{443, 8443, 443}, + Protocol: "http", + Rules: []*sbv1.L7Rule{{Allow: &sbv1.L7Allow{ + Method: "GET", + Query: map[string]*sbv1.L7QueryMatcher{ + "state": {Any: []string{"open", "closed", "open"}}, + }, + }}}, + }}, + }, + }, + } + + policy := PolicyDocumentFromInternalProto(internal) + require.NotNil(t, policy) + endpoint := policy.NetworkPolicies["api"].Endpoints[0] + assert.Equal(t, []uint32{443, 8443}, endpoint.Ports) + require.NotNil(t, endpoint.Rules[0].Allow) + assert.Equal(t, []string{"open", "closed"}, endpoint.Rules[0].Allow.Query["state"].Any) + + // Policy updates use the checked conversion path, so a projected policy + // must satisfy the public protobuf uniqueness constraints before submission. + _, err := PolicyDocumentToProtoChecked(policy) + require.NoError(t, err) +} + func TestSandboxPolicyRoundTrip(t *testing.T) { original := &v1.PolicyDocument{ Version: 5, diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 804e54c79..aad4108cb 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -1317,6 +1317,56 @@ describe('config / policy', () => { expect(Object.hasOwn(Object, 'children')).toBe(false); }); + it('getConfig canonicalizes legacy lists before a checked policy update', async () => { + let submittedPolicy: unknown; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + getSandboxConfig: () => ({ + policy: { + version: 1, + networkPolicies: { + api: { + endpoints: [ + { + host: 'api.example.com', + ports: [443, 8443, 443], + protocol: 'http', + rules: [ + { + allow: { + method: 'GET', + query: { state: { any: ['open', 'closed', 'open'] } }, + }, + }, + ], + }, + ], + }, + }, + }, + settings: {}, + }), + updateConfig: (req) => { + submittedPolicy = req.policy; + return { version: 2, policyHash: 'canonical', settingsRevision: 0n, deleted: false }; + }, + }); + + const config = await sandbox.getConfig('sb'); + const projectedPolicy = config.policy; + expect(projectedPolicy).toBeDefined(); + if (!projectedPolicy) throw new Error('expected projected policy'); + const endpoint = projectedPolicy.networkPolicies.api?.endpoints[0]; + expect(endpoint?.ports).toEqual([443, 8443]); + expect(endpoint?.rules[0]?.allow?.query.state?.kind).toEqual({ + case: 'any', + value: expect.objectContaining({ values: ['open', 'closed'] }), + }); + + await expect(sandbox.setPolicy('sb', projectedPolicy)).resolves.toMatchObject({ policyHash: 'canonical' }); + expect(submittedPolicy).toBeDefined(); + }); + it('setPolicy sends global=false + version pin and (wait) polls until the hash matches', async () => { let updateReq: { sandbox?: string; diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 72083d970..1033f2b21 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -606,7 +606,7 @@ function policyEndpointFromRuntime( const mcpProtocol = endpoint.protocol.toLowerCase() === 'mcp'; return { host: endpoint.host, - ports: endpoint.ports.length > 0 ? [...endpoint.ports] : endpoint.port === 0 ? [] : [endpoint.port], + ports: endpoint.ports.length > 0 ? stableUnique(endpoint.ports) : endpoint.port === 0 ? [] : [endpoint.port], protocol: endpoint.protocol, tls: runtimeTLSMode(endpoint.tls), enforcement: runtimeEnforcementMode(endpoint.enforcement), @@ -703,11 +703,17 @@ function policyMCPMethod( function policyMatcherFromRuntime(matcher: RuntimeMatcher | undefined): MatcherInit { if (matcher && matcher.any.length > 0) { - return { kind: { case: 'any', value: { values: [...matcher.any] } } }; + return { kind: { case: 'any', value: { values: stableUnique(matcher.any) } } }; } return { kind: { case: 'glob', value: matcher?.glob ?? '' } }; } +// The public protobuf contract requires these projected lists to be unique. +// Set iteration retains the first-seen order from legacy runtime records. +function stableUnique(values: readonly T[]): T[] { + return [...new Set(values)]; +} + function policyMatcherMapFromRuntime(matchers: Record): Record { return Object.fromEntries( Object.entries(matchers).map(([name, matcher]) => [name, policyMatcherFromRuntime(matcher)]),