Skip to content
Merged
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
192 changes: 129 additions & 63 deletions cmd/workflow_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"

"github.com/stackrox/harness-openshell/internal/config"
Expand Down Expand Up @@ -340,11 +339,116 @@ func renderWorkflow(workflow *resolvedWorkflow, output string) error {
// sandbox environment values may originate in the host environment and must
// never be serialized by -o yaml/json.
func redactedWorkflow(resolved, input *config.Harness) *config.Harness {
out := cloneRedactingInterpolated(reflect.ValueOf(resolved), reflect.ValueOf(input)).Interface().(*config.Harness)
for i := range out.Spec.Providers {
out.Spec.Providers[i].Config = redactedStringMap(out.Spec.Providers[i].Config)
out := &config.Harness{
APIVersion: redactInterpolated(resolved.APIVersion, input.APIVersion),
Kind: redactInterpolated(resolved.Kind, input.Kind),
Metadata: config.Metadata{
Name: redactInterpolated(resolved.Metadata.Name, input.Metadata.Name),
},
Spec: config.Spec{
Target: redactedTarget(resolved.Spec.Target, input.Spec.Target),
Inference: config.Inference{
Route: redactInterpolated(resolved.Spec.Inference.Route, input.Spec.Inference.Route),
Provider: redactInterpolated(resolved.Spec.Inference.Provider, input.Spec.Inference.Provider),
Model: redactInterpolated(resolved.Spec.Inference.Model, input.Spec.Inference.Model),
Timeout: redactInterpolated(resolved.Spec.Inference.Timeout, input.Spec.Inference.Timeout),
Verify: copyBool(resolved.Spec.Inference.Verify),
},
Sandbox: redactedSandbox(resolved.Spec.Sandbox, input.Spec.Sandbox),
Agent: config.Agent{
Type: redactInterpolated(resolved.Spec.Agent.Type, input.Spec.Agent.Type),
Args: redactStrings(resolved.Spec.Agent.Args, input.Spec.Agent.Args),
},
Source: config.Source{
Repo: redactInterpolated(resolved.Spec.Source.Repo, input.Spec.Source.Repo),
Ref: redactInterpolated(resolved.Spec.Source.Ref, input.Spec.Source.Ref),
Destination: redactInterpolated(resolved.Spec.Source.Destination, input.Spec.Source.Destination),
Submodules: redactInterpolated(resolved.Spec.Source.Submodules, input.Spec.Source.Submodules),
},
},
}
out.Spec.Providers = redactedProviders(resolved.Spec.Providers, input.Spec.Providers)
out.Spec.Payloads = redactedPayloads(resolved.Spec.Payloads, input.Spec.Payloads)
return out
}

func redactedTarget(resolved, input config.Target) config.Target {
out := config.Target{
Gateway: redactInterpolated(resolved.Gateway, input.Gateway),
Workspace: redactInterpolated(resolved.Workspace, input.Workspace),
}
if resolved.Registration != nil {
var raw config.Registration
if input.Registration != nil {
raw = *input.Registration
}
out.Registration = &config.Registration{
Endpoint: redactInterpolated(resolved.Registration.Endpoint, raw.Endpoint),
}
if resolved.Registration.OIDC != nil {
var rawOIDC config.OIDC
if raw.OIDC != nil {
rawOIDC = *raw.OIDC
}
out.Registration.OIDC = &config.OIDC{
Issuer: redactInterpolated(resolved.Registration.OIDC.Issuer, rawOIDC.Issuer),
ClientID: redactInterpolated(resolved.Registration.OIDC.ClientID, rawOIDC.ClientID),
Audience: redactInterpolated(resolved.Registration.OIDC.Audience, rawOIDC.Audience),
}
}
}
return out
}

func redactedProviders(resolved, input []config.Provider) []config.Provider {
out := make([]config.Provider, len(resolved))
for i, provider := range resolved {
var raw config.Provider
if i < len(input) {
raw = input[i]
}
out[i] = config.Provider{
Name: redactInterpolated(provider.Name, raw.Name),
Type: redactInterpolated(provider.Type, raw.Type),
Management: redactInterpolated(provider.Management, raw.Management),
Adopt: provider.Adopt,
Config: redactedStringMap(provider.Config),
}
}
return out
}

func redactedSandbox(resolved, input config.Sandbox) config.Sandbox {
out := config.Sandbox{
Image: redactInterpolated(resolved.Image, input.Image),
Providers: redactStrings(resolved.Providers, input.Providers),
Env: redactedStringMap(resolved.Env),
Keep: resolved.Keep,
TTY: resolved.TTY,
}
if resolved.Policy != nil {
var raw config.PolicyRef
if input.Policy != nil {
raw = *input.Policy
}
out.Policy = &config.PolicyRef{File: redactInterpolated(resolved.Policy.File, raw.File)}
}
return out
}

func redactedPayloads(resolved, input []config.Payload) []config.Payload {
out := make([]config.Payload, len(resolved))
for i, payload := range resolved {
var raw config.Payload
if i < len(input) {
raw = input[i]
}
out[i] = config.Payload{
Source: redactInterpolated(payload.Source, raw.Source),
Content: redactInterpolated(payload.Content, raw.Content),
Destination: redactInterpolated(payload.Destination, raw.Destination),
}
}
out.Spec.Sandbox.Env = redactedStringMap(out.Spec.Sandbox.Env)
return out
}

Expand All @@ -359,67 +463,29 @@ func redactedStringMap(in map[string]string) map[string]string {
return out
}

// cloneRedactingInterpolated deep-copies a resolved config while replacing any
// string derived from host interpolation. The raw input supplies only shape and
// sensitivity information; its values are never copied into output.
func cloneRedactingInterpolated(resolved, input reflect.Value) reflect.Value {
if !resolved.IsValid() {
return resolved
}
if resolved.Kind() == reflect.Pointer {
if resolved.IsNil() {
return reflect.Zero(resolved.Type())
}
var raw reflect.Value
if input.IsValid() && input.Kind() == reflect.Pointer && !input.IsNil() {
raw = input.Elem()
}
out := reflect.New(resolved.Type().Elem())
out.Elem().Set(cloneRedactingInterpolated(resolved.Elem(), raw))
return out
}
if resolved.Kind() == reflect.String {
if input.IsValid() && input.Kind() == reflect.String && strings.Contains(input.String(), "$") {
return reflect.ValueOf("<redacted>").Convert(resolved.Type())
}
return resolved
func redactInterpolated(resolved, input string) string {
if strings.Contains(input, "$") {
return "<redacted>"
}
return resolved
}

out := reflect.New(resolved.Type()).Elem()
switch resolved.Kind() {
case reflect.Struct:
for i := 0; i < resolved.NumField(); i++ {
var raw reflect.Value
if input.IsValid() && input.Kind() == reflect.Struct {
raw = input.Field(i)
}
out.Field(i).Set(cloneRedactingInterpolated(resolved.Field(i), raw))
func redactStrings(resolved, input []string) []string {
out := make([]string, len(resolved))
for i, value := range resolved {
var raw string
if i < len(input) {
raw = input[i]
}
case reflect.Slice:
out = reflect.MakeSlice(resolved.Type(), resolved.Len(), resolved.Len())
for i := 0; i < resolved.Len(); i++ {
var raw reflect.Value
if input.IsValid() && input.Kind() == reflect.Slice && i < input.Len() {
raw = input.Index(i)
}
out.Index(i).Set(cloneRedactingInterpolated(resolved.Index(i), raw))
}
case reflect.Map:
if resolved.IsNil() {
return reflect.Zero(resolved.Type())
}
out = reflect.MakeMapWithSize(resolved.Type(), resolved.Len())
iter := resolved.MapRange()
for iter.Next() {
key, value := iter.Key(), iter.Value()
var raw reflect.Value
if input.IsValid() && input.Kind() == reflect.Map {
raw = input.MapIndex(key)
}
out.SetMapIndex(key, cloneRedactingInterpolated(value, raw))
}
default:
out.Set(resolved)
out[i] = redactInterpolated(value, raw)
}
return out
}

func copyBool(value *bool) *bool {
if value == nil {
return nil
}
copy := *value
return &copy
}
70 changes: 70 additions & 0 deletions cmd/workflow_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,76 @@ spec:
}
}

func TestRedactedWorkflowRedactsInterpolatedScalars(t *testing.T) {
resolved := &config.Harness{
APIVersion: "harness.openshell.dev/v1alpha1",
Kind: "Harness",
Metadata: config.Metadata{Name: "resolved-name"},
Spec: config.Spec{
Target: config.Target{
Gateway: "resolved-gateway",
Workspace: "workspace",
Registration: &config.Registration{
Endpoint: "https://gateway.example",
OIDC: &config.OIDC{Issuer: "https://issuer.example", ClientID: "client", Audience: "audience"},
},
},
Inference: config.Inference{Route: "inference.local", Provider: "vertex", Model: "model", Timeout: "60s"},
Sandbox: config.Sandbox{
Image: "image",
Providers: []string{"provider"},
Policy: &config.PolicyRef{File: "policy.yaml"},
},
Agent: config.Agent{Type: "agent", Args: []string{"literal", "resolved-argument"}},
Source: config.Source{Repo: "repo", Ref: "main", Destination: "/sandbox", Submodules: "shallow"},
Payloads: []config.Payload{{Source: "payload", Content: "content", Destination: "/sandbox/payload"}},
Providers: []config.Provider{{Name: "provider", Type: "vertex", Management: "referenced"}},
},
}
input := &config.Harness{
APIVersion: resolved.APIVersion,
Kind: resolved.Kind,
Metadata: config.Metadata{Name: "${NAME}"},
Spec: config.Spec{
Target: config.Target{
Gateway: "${GATEWAY}",
Workspace: "workspace",
Registration: &config.Registration{
Endpoint: "${ENDPOINT}",
OIDC: &config.OIDC{Issuer: "${ISSUER}", ClientID: "${CLIENT_ID}", Audience: "${AUDIENCE}"},
},
},
Inference: config.Inference{Route: "${ROUTE}", Provider: "vertex", Model: "${MODEL}", Timeout: "60s"},
Sandbox: config.Sandbox{
Image: "${IMAGE}",
Providers: []string{"${PROVIDER}"},
Policy: &config.PolicyRef{File: "${POLICY}"},
},
Agent: config.Agent{Type: "${AGENT}", Args: []string{"literal", "${ARGUMENT}"}},
Source: config.Source{Repo: "${REPO}", Ref: "main", Destination: "${DESTINATION}", Submodules: "shallow"},
Payloads: []config.Payload{{Source: "${PAYLOAD_SOURCE}", Content: "${PAYLOAD_CONTENT}", Destination: "${PAYLOAD_DESTINATION}"}},
Providers: []config.Provider{{Name: "${PROVIDER_NAME}", Type: "vertex", Management: "${MANAGEMENT}"}},
},
}

got := redactedWorkflow(resolved, input)
if got.Metadata.Name != "<redacted>" || got.Spec.Target.Gateway != "<redacted>" || got.Spec.Target.Registration.Endpoint != "<redacted>" || got.Spec.Target.Registration.OIDC.ClientID != "<redacted>" {
t.Errorf("target fields = %+v, want interpolated values redacted", got.Spec.Target)
}
if got.Spec.Inference.Route != "<redacted>" || got.Spec.Inference.Model != "<redacted>" || got.Spec.Sandbox.Image != "<redacted>" || got.Spec.Sandbox.Providers[0] != "<redacted>" || got.Spec.Sandbox.Policy.File != "<redacted>" {
t.Errorf("inference/sandbox fields were not redacted: %+v %+v", got.Spec.Inference, got.Spec.Sandbox)
}
if got.Spec.Agent.Type != "<redacted>" || got.Spec.Agent.Args[0] != "literal" || got.Spec.Agent.Args[1] != "<redacted>" {
t.Errorf("agent fields = %+v", got.Spec.Agent)
}
if got.Spec.Source.Repo != "<redacted>" || got.Spec.Source.Ref != "main" || got.Spec.Source.Destination != "<redacted>" {
t.Errorf("source fields = %+v", got.Spec.Source)
}
if got.Spec.Payloads[0].Source != "<redacted>" || got.Spec.Payloads[0].Content != "<redacted>" || got.Spec.Payloads[0].Destination != "<redacted>" || got.Spec.Providers[0].Name != "<redacted>" || got.Spec.Providers[0].Management != "<redacted>" {
t.Errorf("payload/provider fields were not redacted: %+v %+v", got.Spec.Payloads[0], got.Spec.Providers[0])
}
}

func TestApplyRejectsUnversionedConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "workflow.yaml")
writeTestFile(t, path, "name: old-config\nentrypoint: claude\n")
Expand Down
Loading