Skip to content

Commit f31c293

Browse files
committed
feat: add field-level redaction for denied_resources
Add redacted_fields and redaction_mode to denied_resources config entries. When redacted_fields is set, the resource is allowed through the access control layer but specified fields have their values replaced with a redaction marker before being returned to the agent. Two modes are supported: - opaque: replaces values with [REDACTED] - hashed: replaces values with [REDACTED:gen_<id>:<hash>] using HMAC-SHA256 with a per-startup random salt for cross-resource value correlation without exposing actual content. Fields use dot-separated paths with * wildcard support that adapts to the runtime type (map keys or array items). Config validation ensures redacted_fields requires kind to be set, preventing accidental security bypasses on entire API groups. Signed-off-by: Yury Sokov <me@yurzs.dev>
1 parent 21c16e5 commit f31c293

14 files changed

Lines changed: 1177 additions & 7 deletions

README.md

Lines changed: 9 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)
226+
[[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)
226232
[[denied_resources]]
227233
group = ""
228234
version = "v1"
229235
kind = "Secret"
236+
redacted_fields = ["data.*", "stringData.*"]
237+
redaction_mode = "hashed"
230238
231239
[telemetry]
232240
endpoint = "http://localhost:4317"

docs/configuration.md

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,23 @@ description = "Get a Kubernetes resource by name. Always specify the namespace e
369369

370370
### Denied Resources
371371

372-
Prevent access to specific Kubernetes resource types.
372+
Prevent access to specific Kubernetes resource types, or allow access with field-level redaction.
373373

374374
| Field | Type | Default | Description |
375375
|-------|------|---------|-------------|
376-
| `denied_resources` | array | `[]` | List of GroupVersionKind objects that should not be accessible. |
376+
| `denied_resources` | array | `[]` | List of GroupVersionKind objects that should not be accessible (or should be accessible with redacted fields). |
377377

378-
**Example:**
378+
Each entry in `denied_resources` supports the following fields:
379+
380+
| Field | Type | Default | Description |
381+
|-------|------|---------|-------------|
382+
| `group` | string | required | API group (e.g. `""` for core, `"apps"` for apps/v1) |
383+
| `version` | string | required | API version (e.g. `"v1"`) |
384+
| `kind` | string | optional | Resource kind (e.g. `"Secret"`). If omitted, denies the entire group/version. |
385+
| `redacted_fields` | array | `[]` | Dot-separated field paths to redact instead of denying the resource entirely. Requires `kind` to be set. |
386+
| `redaction_mode` | string | `"opaque"` | How to redact values: `"opaque"` or `"hashed"`. |
387+
388+
**Example — fully deny resources:**
379389
```toml
380390
# Deny access to Secrets and ConfigMaps
381391
[[denied_resources]]
@@ -410,6 +420,56 @@ version = "v1"
410420
kind = "ClusterRoleBinding"
411421
```
412422

423+
#### Field-Level Redaction
424+
425+
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.
426+
427+
When `redacted_fields` is set, the resource is allowed through the access control layer and the specified fields have their values replaced with a redaction marker before being returned.
428+
429+
**Example — redact Secret values while keeping metadata and key names visible:**
430+
```toml
431+
[[denied_resources]]
432+
group = ""
433+
version = "v1"
434+
kind = "Secret"
435+
redacted_fields = ["data.*", "stringData.*"]
436+
redaction_mode = "hashed"
437+
```
438+
439+
**Path syntax:**
440+
441+
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.
442+
443+
| Path | Behavior |
444+
|------|----------|
445+
| `data.*` | Redact all values in a map (keys remain visible) |
446+
| `spec.credentials` | Redact a single field |
447+
| `spec.template.spec.containers.*.env.*.value` | Traverse arrays and maps to reach nested fields |
448+
449+
**Redaction modes:**
450+
451+
- `opaque` (default): replaces values with `[REDACTED]`
452+
- `hashed`: replaces values with `[REDACTED:gen_<id>:<hash>]`
453+
454+
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.
455+
456+
**More examples:**
457+
```toml
458+
# ConfigMap with sensitive data
459+
[[denied_resources]]
460+
group = ""
461+
version = "v1"
462+
kind = "ConfigMap"
463+
redacted_fields = ["data.*"]
464+
465+
# CRD with embedded credentials
466+
[[denied_resources]]
467+
group = "db.example.com"
468+
version = "v1"
469+
kind = "DatabaseConnection"
470+
redacted_fields = ["spec.credentials.*", "spec.connectionString"]
471+
```
472+
413473
### Server Instructions
414474

415475
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: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,24 @@ type ExtendedConfigProvider interface {
5353
}
5454

5555
type GroupVersionKind struct {
56-
Group string `json:"group" toml:"group"`
57-
Version string `json:"version" toml:"version"`
58-
Kind string `json:"kind,omitempty" toml:"kind,omitempty"`
56+
Group string `json:"group" toml:"group"`
57+
Version string `json:"version" toml:"version"`
58+
Kind string `json:"kind,omitempty" toml:"kind,omitempty"`
59+
RedactedFields []string `json:"redacted_fields,omitempty" toml:"redacted_fields,omitempty"`
60+
RedactionMode string `json:"redaction_mode,omitempty" toml:"redaction_mode,omitempty"`
5961
}
6062

6163
type DeniedResourcesProvider interface {
6264
// GetDeniedResources returns a list of GroupVersionKinds that are denied.
6365
GetDeniedResources() []GroupVersionKind
6466
}
6567

68+
// RedactedResourcesProvider provides access to resources that should have specific fields redacted.
69+
type RedactedResourcesProvider interface {
70+
// GetRedactedResources returns a list of GroupVersionKinds with redacted field configurations.
71+
GetRedactedResources() []GroupVersionKind
72+
}
73+
6674
type StsConfigProvider interface {
6775
GetStsClientId() string
6876
GetStsClientSecret() string
@@ -96,6 +104,7 @@ type BaseConfig interface {
96104
ClusterProvider
97105
ConfirmationRulesProvider
98106
DeniedResourcesProvider
107+
RedactedResourcesProvider
99108
ExtendedConfigProvider
100109
StsConfigProvider
101110
ValidationEnabledProvider

pkg/config/config.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,16 @@ func (c *StaticConfig) GetDeniedResources() []api.GroupVersionKind {
382382
return c.DeniedResources
383383
}
384384

385+
func (c *StaticConfig) GetRedactedResources() []api.GroupVersionKind {
386+
var redacted []api.GroupVersionKind
387+
for _, dr := range c.DeniedResources {
388+
if len(dr.RedactedFields) > 0 {
389+
redacted = append(redacted, dr)
390+
}
391+
}
392+
return redacted
393+
}
394+
385395
func (c *StaticConfig) GetKubeConfigPath() string {
386396
return c.KubeConfig
387397
}
@@ -550,6 +560,28 @@ func (c *StaticConfig) Validate() error {
550560
if err := c.HTTP.Validate(); err != nil {
551561
return err
552562
}
563+
if err := c.validateRedactedResources(); err != nil {
564+
return err
565+
}
566+
return nil
567+
}
568+
569+
// validateRedactedResources validates redaction-related fields in denied_resources:
570+
// - redacted_fields requires kind to be set (cannot redact fields on an entire group/version)
571+
// - redaction_mode must be "opaque", "hashed", or empty (defaults to opaque)
572+
func (c *StaticConfig) validateRedactedResources() error {
573+
for i, dr := range c.DeniedResources {
574+
if len(dr.RedactedFields) == 0 {
575+
continue
576+
}
577+
if dr.Kind == "" {
578+
return fmt.Errorf("denied_resources[%d]: redacted_fields requires kind to be set", i)
579+
}
580+
mode := dr.RedactionMode
581+
if mode != "" && mode != "opaque" && mode != "hashed" {
582+
return fmt.Errorf("denied_resources[%d]: invalid redaction_mode %q, must be %q or %q", i, mode, "opaque", "hashed")
583+
}
584+
}
553585
return nil
554586
}
555587

pkg/config/validate_test.go

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

642+
func (s *ValidateSuite) TestRedactedResources() {
643+
s.Run("redacted_fields without kind is rejected", func() {
644+
cfg := s.validConfig()
645+
cfg.DeniedResources = []api.GroupVersionKind{
646+
{
647+
Group: "",
648+
Version: "v1",
649+
RedactedFields: []string{"data.*"},
650+
},
651+
}
652+
err := cfg.Validate()
653+
s.Require().Error(err)
654+
s.Contains(err.Error(), "redacted_fields requires kind to be set")
655+
})
656+
657+
s.Run("invalid redaction_mode is rejected", func() {
658+
cfg := s.validConfig()
659+
cfg.DeniedResources = []api.GroupVersionKind{
660+
{
661+
Group: "",
662+
Version: "v1",
663+
Kind: "Secret",
664+
RedactedFields: []string{"data.*"},
665+
RedactionMode: "invalid-mode",
666+
},
667+
}
668+
err := cfg.Validate()
669+
s.Require().Error(err)
670+
s.Contains(err.Error(), "invalid redaction_mode")
671+
s.Contains(err.Error(), "invalid-mode")
672+
})
673+
674+
s.Run("opaque redaction_mode is accepted", func() {
675+
cfg := s.validConfig()
676+
cfg.DeniedResources = []api.GroupVersionKind{
677+
{
678+
Group: "",
679+
Version: "v1",
680+
Kind: "Secret",
681+
RedactedFields: []string{"data.*"},
682+
RedactionMode: "opaque",
683+
},
684+
}
685+
s.NoError(cfg.Validate())
686+
})
687+
688+
s.Run("hashed redaction_mode is accepted", func() {
689+
cfg := s.validConfig()
690+
cfg.DeniedResources = []api.GroupVersionKind{
691+
{
692+
Group: "",
693+
Version: "v1",
694+
Kind: "Secret",
695+
RedactedFields: []string{"data.*"},
696+
RedactionMode: "hashed",
697+
},
698+
}
699+
s.NoError(cfg.Validate())
700+
})
701+
702+
s.Run("empty redaction_mode defaults to opaque and is accepted", func() {
703+
cfg := s.validConfig()
704+
cfg.DeniedResources = []api.GroupVersionKind{
705+
{
706+
Group: "",
707+
Version: "v1",
708+
Kind: "Secret",
709+
RedactedFields: []string{"data.*"},
710+
},
711+
}
712+
s.NoError(cfg.Validate())
713+
})
714+
715+
s.Run("denied_resources without redacted_fields is accepted", func() {
716+
cfg := s.validConfig()
717+
cfg.DeniedResources = []api.GroupVersionKind{
718+
{
719+
Group: "",
720+
Version: "v1",
721+
Kind: "Secret",
722+
},
723+
}
724+
s.NoError(cfg.Validate())
725+
})
726+
727+
s.Run("denied_resources without redacted_fields and without kind is accepted", func() {
728+
cfg := s.validConfig()
729+
cfg.DeniedResources = []api.GroupVersionKind{
730+
{
731+
Group: "",
732+
Version: "v1",
733+
},
734+
}
735+
s.NoError(cfg.Validate())
736+
})
737+
}
738+
642739
func TestValidate(t *testing.T) {
643740
suite.Run(t, new(ValidateSuite))
644741
}

pkg/kubernetes/accesscontrol_round_tripper.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ func (rt *AccessControlRoundTripper) isAllowed(
155155
}
156156

157157
for _, val := range rt.deniedResourcesProvider.GetDeniedResources() {
158+
// Entries with redacted_fields are not full denials — they allow access
159+
// but specific fields will be redacted at the tool handler level.
160+
if len(val.RedactedFields) > 0 {
161+
continue
162+
}
158163
// If kind is empty, that means Group/Version pair is denied entirely
159164
if val.Kind == "" {
160165
if gvk.Group == val.Group && gvk.Version == val.Version {

pkg/kubernetes/accesscontrol_round_tripper_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,31 @@ func (s *AccessControlRoundTripperTestSuite) TestRoundTripForDeniedAPIResources(
403403

404404
})
405405

406+
407+
s.Run("Redacted fields entries allow access", func() {
408+
s.Require().NoError(toml.Unmarshal([]byte(`
409+
denied_resources = [ { version = "v1", kind = "Pod", redacted_fields = ["spec.containers.*.env.*.value"] } ]
410+
`), rt.deniedResourcesProvider), "Expected to parse redacted resources config")
411+
412+
s.Run("Get pod is allowed when redacted_fields is set", func() {
413+
delegateCalled = false
414+
req := httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/my-pod", nil)
415+
resp, err := rt.RoundTrip(req)
416+
s.NoError(err)
417+
s.NotNil(resp)
418+
s.True(delegateCalled, "Expected delegate to be called for resource with redacted_fields")
419+
})
420+
421+
s.Run("List pods is allowed when redacted_fields is set", func() {
422+
delegateCalled = false
423+
req := httptest.NewRequest("GET", "/api/v1/pods", nil)
424+
resp, err := rt.RoundTrip(req)
425+
s.NoError(err)
426+
s.NotNil(resp)
427+
s.True(delegateCalled, "Expected delegate to be called for resource list with redacted_fields")
428+
})
429+
})
430+
406431
s.Run("RESTMapper error for unknown resource", func() {
407432
rt.deniedResourcesProvider = nil
408433
delegateCalled = false

0 commit comments

Comments
 (0)