Skip to content

Commit 5ddbcc6

Browse files
committed
feat: add field-level redaction for sensitive resource values
Add a redacted_resources configuration section that allows AI agents to see Kubernetes resource metadata while having sensitive field values replaced with opaque or HMAC-hashed placeholders. Redaction is applied in the kubernetes wrapper layer (Core struct) so that all tool handlers benefit automatically. An exported RedactResource/RedactResourceList helper is available for toolsets that bypass the wrapper. Configuration uses a separate [[redacted_resources]] TOML section with fields and mode parameters, cleanly separated from denied_resources. Signed-off-by: Yury Sokov <me@yurzs.dev>
1 parent 21c16e5 commit 5ddbcc6

21 files changed

Lines changed: 2116 additions & 14 deletions

File tree

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,19 @@ log_level = 2
222222
read_only = true
223223
toolsets = ["core", "config", "helm", "kubevirt"]
224224
225-
# Deny access to sensitive resources
225+
# Deny access to sensitive resources (fully blocked)
226226
[[denied_resources]]
227+
group = "rbac.authorization.k8s.io"
228+
version = "v1"
229+
kind = "ClusterRole"
230+
231+
# Allow Secrets but redact their values (keys and metadata remain visible)
232+
[[redacted_resources]]
227233
group = ""
228234
version = "v1"
229235
kind = "Secret"
236+
fields = ["data.*", "stringData.*"]
237+
mode = "hashed"
230238
231239
[telemetry]
232240
endpoint = "http://localhost:4317"
@@ -238,6 +246,7 @@ For comprehensive TOML configuration documentation, including:
238246
- Drop-in configuration files for modular settings
239247
- Dynamic configuration reload via SIGHUP
240248
- Denied resources for restricting access to sensitive resource types
249+
- Redacted resources for field-level redaction of sensitive values
241250
- Server instructions for MCP Tool Search
242251
- [Custom MCP prompts](docs/prompts.md)
243252
- OAuth/OIDC authentication for HTTP mode ([Keycloak](docs/KEYCLOAK_OIDC_SETUP.md), [Microsoft Entra ID](docs/ENTRA_ID_SETUP.md))

claude-config.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
read_only = true
2+
log_level = 0
3+
4+
# Block Secrets entirely
5+
[[denied_resources]]
6+
group = ""
7+
version = "v1"
8+
kind = "Secret"
9+
10+
# Block RBAC
11+
[[denied_resources]]
12+
group = "rbac.authorization.k8s.io"
13+
version = "v1"
14+
kind = "ClusterRole"
15+
16+
[[denied_resources]]
17+
group = "rbac.authorization.k8s.io"
18+
version = "v1"
19+
kind = "ClusterRoleBinding"
20+
21+
[[denied_resources]]
22+
group = "rbac.authorization.k8s.io"
23+
version = "v1"
24+
kind = "Role"
25+
26+
[[denied_resources]]
27+
group = "rbac.authorization.k8s.io"
28+
version = "v1"
29+
kind = "RoleBinding"

docs/configuration.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,68 @@ version = "v1"
410410
kind = "ClusterRoleBinding"
411411
```
412412

413+
### Redacted Resources
414+
415+
Instead of fully denying a resource, you can allow access but redact sensitive fields. This is useful for resources like Secrets where the agent needs to see metadata (name, namespace, keys, type, ownership) but should not see actual values.
416+
417+
| Field | Type | Default | Description |
418+
|-------|------|---------|-------------|
419+
| `redacted_resources` | array | `[]` | List of resources with field-level redaction. |
420+
421+
Each entry in `redacted_resources` supports the following fields:
422+
423+
| Field | Type | Default | Description |
424+
|-------|------|---------|-------------|
425+
| `group` | string | required | API group (e.g. `""` for core, `"apps"` for apps/v1) |
426+
| `version` | string | required | API version (e.g. `"v1"`) |
427+
| `kind` | string | required | Resource kind (e.g. `"Secret"`) |
428+
| `fields` | array | required | Dot-separated field paths to redact |
429+
| `mode` | string | `"opaque"` | How to redact values: `"opaque"` or `"hashed"` |
430+
431+
**Example — redact Secret values while keeping metadata and key names visible:**
432+
```toml
433+
[[redacted_resources]]
434+
group = ""
435+
version = "v1"
436+
kind = "Secret"
437+
fields = ["data.*", "stringData.*"]
438+
mode = "hashed"
439+
```
440+
441+
**Path syntax:**
442+
443+
Fields are specified as dot-separated paths with `*` wildcard support. The wildcard adapts to the type it encounters — it iterates keys in a map and items in an array.
444+
445+
| Path | Behavior |
446+
|------|----------|
447+
| `data.*` | Redact all values in a map (keys remain visible) |
448+
| `spec.credentials` | Redact a single field |
449+
| `spec.template.spec.containers.*.env.*.value` | Traverse arrays and maps to reach nested fields |
450+
451+
**Redaction modes:**
452+
453+
- `opaque` (default): replaces values with `[REDACTED]`
454+
- `hashed`: replaces values with `[REDACTED:gen_<id>:<hash>]`
455+
456+
Hashed mode uses HMAC-SHA256 with a random salt generated once at server startup. This allows the agent to detect when two different resources reference the same value (e.g. two services using the same connection string) without exposing the actual content. The salt is never persisted and changes on restart. A generation ID is included so consumers can detect when the salt changed and know that hashes from different generations are not comparable.
457+
458+
**More examples:**
459+
```toml
460+
# ConfigMap with sensitive data
461+
[[redacted_resources]]
462+
group = ""
463+
version = "v1"
464+
kind = "ConfigMap"
465+
fields = ["data.*"]
466+
467+
# CRD with embedded credentials
468+
[[redacted_resources]]
469+
group = "db.example.com"
470+
version = "v1"
471+
kind = "DatabaseConnection"
472+
fields = ["spec.credentials.*", "spec.connectionString"]
473+
```
474+
413475
### Server Instructions
414476

415477
Provide hints to MCP clients (like Claude Code) about when to use this server's tools. Useful for clients that support **MCP Tool Search**.

pkg/api/config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,28 @@ type GroupVersionKind struct {
5858
Kind string `json:"kind,omitempty" toml:"kind,omitempty"`
5959
}
6060

61+
// RedactedResource defines a resource type whose specific fields should be redacted
62+
// rather than having the entire resource denied. This allows agents to see resource
63+
// metadata while sensitive field values are replaced with redaction markers.
64+
type RedactedResource struct {
65+
Group string `json:"group" toml:"group"`
66+
Version string `json:"version" toml:"version"`
67+
Kind string `json:"kind" toml:"kind"`
68+
Fields []string `json:"fields" toml:"fields"`
69+
Mode string `json:"mode,omitempty" toml:"mode,omitempty"`
70+
}
71+
6172
type DeniedResourcesProvider interface {
6273
// GetDeniedResources returns a list of GroupVersionKinds that are denied.
6374
GetDeniedResources() []GroupVersionKind
6475
}
6576

77+
// RedactedResourcesProvider provides access to resources that should have specific fields redacted.
78+
type RedactedResourcesProvider interface {
79+
// GetRedactedResources returns the list of resources with field-level redaction configured.
80+
GetRedactedResources() []RedactedResource
81+
}
82+
6683
type StsConfigProvider interface {
6784
GetStsClientId() string
6885
GetStsClientSecret() string
@@ -96,6 +113,7 @@ type BaseConfig interface {
96113
ClusterProvider
97114
ConfirmationRulesProvider
98115
DeniedResourcesProvider
116+
RedactedResourcesProvider
99117
ExtendedConfigProvider
100118
StsConfigProvider
101119
ValidationEnabledProvider

pkg/config/config.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/BurntSushi/toml"
1616
"github.com/containers/kubernetes-mcp-server/pkg/api"
1717
"github.com/containers/kubernetes-mcp-server/pkg/output"
18+
"github.com/containers/kubernetes-mcp-server/pkg/redaction"
1819
"github.com/containers/kubernetes-mcp-server/pkg/tokenexchange"
1920
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
2021
"k8s.io/klog/v2"
@@ -32,7 +33,8 @@ type ToolOverride struct {
3233
// StaticConfig is the configuration for the server.
3334
// It allows to configure server specific settings and tools to be enabled or disabled.
3435
type StaticConfig struct {
35-
DeniedResources []api.GroupVersionKind `toml:"denied_resources"`
36+
DeniedResources []api.GroupVersionKind `toml:"denied_resources"`
37+
RedactedResources []api.RedactedResource `toml:"redacted_resources"`
3638

3739
LogLevel int `toml:"log_level,omitzero"`
3840
LogFile string `toml:"log_file,omitempty"`
@@ -382,6 +384,10 @@ func (c *StaticConfig) GetDeniedResources() []api.GroupVersionKind {
382384
return c.DeniedResources
383385
}
384386

387+
func (c *StaticConfig) GetRedactedResources() []api.RedactedResource {
388+
return c.RedactedResources
389+
}
390+
385391
func (c *StaticConfig) GetKubeConfigPath() string {
386392
return c.KubeConfig
387393
}
@@ -550,6 +556,44 @@ func (c *StaticConfig) Validate() error {
550556
if err := c.HTTP.Validate(); err != nil {
551557
return err
552558
}
559+
if err := c.validateRedactedResources(); err != nil {
560+
return err
561+
}
562+
return nil
563+
}
564+
565+
// validateRedactedResources validates the redacted_resources configuration:
566+
// - version must be set
567+
// - kind must be set (cannot redact fields on an entire group/version)
568+
// - fields must not be empty and must contain valid dot-separated paths
569+
// - mode must be "opaque", "hashed", or empty (defaults to opaque)
570+
func (c *StaticConfig) validateRedactedResources() error {
571+
for i, rr := range c.RedactedResources {
572+
if rr.Version == "" {
573+
return fmt.Errorf("redacted_resources[%d]: version must be set", i)
574+
}
575+
if rr.Kind == "" {
576+
return fmt.Errorf("redacted_resources[%d]: kind must be set", i)
577+
}
578+
if len(rr.Fields) == 0 {
579+
return fmt.Errorf("redacted_resources[%d]: fields must not be empty", i)
580+
}
581+
for j, field := range rr.Fields {
582+
if field == "" {
583+
return fmt.Errorf("redacted_resources[%d]: fields[%d] must not be empty", i, j)
584+
}
585+
segments := strings.Split(field, ".")
586+
for _, seg := range segments {
587+
if seg == "" {
588+
return fmt.Errorf("redacted_resources[%d]: fields[%d] %q contains empty path segment", i, j, field)
589+
}
590+
}
591+
}
592+
mode := rr.Mode
593+
if mode != "" && mode != redaction.RedactionModeOpaque && mode != redaction.RedactionModeHashed {
594+
return fmt.Errorf("redacted_resources[%d]: invalid mode %q, must be %q or %q", i, mode, redaction.RedactionModeOpaque, redaction.RedactionModeHashed)
595+
}
596+
}
553597
return nil
554598
}
555599

pkg/config/validate_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,151 @@ func (s *ValidateSuite) TestClusterAuthMode() {
639639
})
640640
}
641641

642+
func (s *ValidateSuite) TestRedactedResources() {
643+
s.Run("missing version is rejected", func() {
644+
cfg := s.validConfig()
645+
cfg.RedactedResources = []api.RedactedResource{
646+
{
647+
Group: "",
648+
Kind: "Secret",
649+
Fields: []string{"data.*"},
650+
},
651+
}
652+
err := cfg.Validate()
653+
s.Require().Error(err)
654+
s.Contains(err.Error(), "version must be set")
655+
})
656+
657+
s.Run("missing kind is rejected", func() {
658+
cfg := s.validConfig()
659+
cfg.RedactedResources = []api.RedactedResource{
660+
{
661+
Group: "",
662+
Version: "v1",
663+
Fields: []string{"data.*"},
664+
},
665+
}
666+
err := cfg.Validate()
667+
s.Require().Error(err)
668+
s.Contains(err.Error(), "kind must be set")
669+
})
670+
671+
s.Run("empty fields is rejected", func() {
672+
cfg := s.validConfig()
673+
cfg.RedactedResources = []api.RedactedResource{
674+
{
675+
Group: "",
676+
Version: "v1",
677+
Kind: "Secret",
678+
},
679+
}
680+
err := cfg.Validate()
681+
s.Require().Error(err)
682+
s.Contains(err.Error(), "fields must not be empty")
683+
})
684+
685+
s.Run("empty field path is rejected", func() {
686+
cfg := s.validConfig()
687+
cfg.RedactedResources = []api.RedactedResource{
688+
{
689+
Group: "",
690+
Version: "v1",
691+
Kind: "Secret",
692+
Fields: []string{""},
693+
},
694+
}
695+
err := cfg.Validate()
696+
s.Require().Error(err)
697+
s.Contains(err.Error(), "fields[0] must not be empty")
698+
})
699+
700+
s.Run("field path with empty segment is rejected", func() {
701+
cfg := s.validConfig()
702+
cfg.RedactedResources = []api.RedactedResource{
703+
{
704+
Group: "",
705+
Version: "v1",
706+
Kind: "Secret",
707+
Fields: []string{"data..key"},
708+
},
709+
}
710+
err := cfg.Validate()
711+
s.Require().Error(err)
712+
s.Contains(err.Error(), "contains empty path segment")
713+
})
714+
715+
s.Run("invalid mode is rejected", func() {
716+
cfg := s.validConfig()
717+
cfg.RedactedResources = []api.RedactedResource{
718+
{
719+
Group: "",
720+
Version: "v1",
721+
Kind: "Secret",
722+
Fields: []string{"data.*"},
723+
Mode: "invalid-mode",
724+
},
725+
}
726+
err := cfg.Validate()
727+
s.Require().Error(err)
728+
s.Contains(err.Error(), "invalid mode")
729+
s.Contains(err.Error(), "invalid-mode")
730+
})
731+
732+
s.Run("opaque mode is accepted", func() {
733+
cfg := s.validConfig()
734+
cfg.RedactedResources = []api.RedactedResource{
735+
{
736+
Group: "",
737+
Version: "v1",
738+
Kind: "Secret",
739+
Fields: []string{"data.*"},
740+
Mode: "opaque",
741+
},
742+
}
743+
s.NoError(cfg.Validate())
744+
})
745+
746+
s.Run("hashed mode is accepted", func() {
747+
cfg := s.validConfig()
748+
cfg.RedactedResources = []api.RedactedResource{
749+
{
750+
Group: "",
751+
Version: "v1",
752+
Kind: "Secret",
753+
Fields: []string{"data.*"},
754+
Mode: "hashed",
755+
},
756+
}
757+
s.NoError(cfg.Validate())
758+
})
759+
760+
s.Run("empty mode defaults to opaque and is accepted", func() {
761+
cfg := s.validConfig()
762+
cfg.RedactedResources = []api.RedactedResource{
763+
{
764+
Group: "",
765+
Version: "v1",
766+
Kind: "Secret",
767+
Fields: []string{"data.*"},
768+
},
769+
}
770+
s.NoError(cfg.Validate())
771+
})
772+
773+
s.Run("valid multi-segment field path is accepted", func() {
774+
cfg := s.validConfig()
775+
cfg.RedactedResources = []api.RedactedResource{
776+
{
777+
Group: "apps",
778+
Version: "v1",
779+
Kind: "Deployment",
780+
Fields: []string{"spec.template.spec.containers.*.env.*.value"},
781+
},
782+
}
783+
s.NoError(cfg.Validate())
784+
})
785+
}
786+
642787
func TestValidate(t *testing.T) {
643788
suite.Run(t, new(ValidateSuite))
644789
}

0 commit comments

Comments
 (0)