feat(policy): add pkg/policy shared validator and capiscio policy CLI#46
feat(policy): add pkg/policy shared validator and capiscio policy CLI#46
Conversation
PR 6 of POLICY-CONFIG-001: Shared validator + CLI commands
New package: pkg/policy/
- Config types (Config, RateLimitRule, OperationRule, MCPToolRule)
- Parse() - YAML parsing with validation
- Validate() - semantic validation (version, trust levels, DIDs, etc)
- ToMap() - convert to OPA data document format
New CLI commands:
- capiscio policy validate [-f file] [--json]
Local-only YAML config validation, non-zero exit on failure
- capiscio policy context [--registry url] [--api-key key] [-o file]
Fetch GET /v1/sdk/policy-context from registry
Tests: 14 new tests for pkg/policy validator
There was a problem hiding this comment.
Pull request overview
Adds a shared pkg/policy YAML policy configuration validator (intended for reuse by CLI and server) and introduces new capiscio policy CLI commands to validate configs locally and fetch registry policy context.
Changes:
- Introduce
pkg/policywith config types plusParse,Validate, andToMap. - Add unit tests for parsing/validation and
ToMap. - Add
capiscio policy validateandcapiscio policy contextcommands; add YAML dependency.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.
| File | Description |
|---|---|
| pkg/policy/config.go | Implements policy config types, parsing, semantic validation, and OPA data conversion |
| pkg/policy/config_test.go | Adds tests for parse/validate behavior and ToMap output |
| cmd/capiscio/policy.go | Adds CLI commands for local validation and fetching policy context from registry |
| go.mod | Promotes gopkg.in/yaml.v3 to a direct dependency |
| func Parse(data []byte) (*Config, error) { | ||
| var cfg Config | ||
| if err := yaml.Unmarshal(data, &cfg); err != nil { | ||
| return nil, fmt.Errorf("parse policy config: %w", err) | ||
| } |
There was a problem hiding this comment.
Parse uses yaml.Unmarshal, which ignores unknown fields by default. For a security-sensitive policy file this can lead to silent misconfiguration (typos are dropped). Consider switching to a yaml.Decoder with KnownFields(true) so unknown keys fail validation.
| // Validate checks a parsed Config for semantic correctness. | ||
| func Validate(cfg *Config) error { | ||
| var errs []string | ||
|
|
||
| if cfg.Version != "1" { | ||
| errs = append(errs, fmt.Sprintf("version must be \"1\", got %q", cfg.Version)) | ||
| } |
There was a problem hiding this comment.
Validate assumes cfg is non-nil; calling Validate(nil) will panic when accessing cfg.Version. Since Validate is exported, it should return a descriptive error for nil input instead of panicking.
pkg/policy/config.go
Outdated
| for _, did := range cfg.AllowedDIDs { | ||
| if !strings.HasPrefix(did, "did:") { | ||
| errs = append(errs, fmt.Sprintf("allowed_dids: invalid DID format %q", did)) | ||
| } |
There was a problem hiding this comment.
DID validation here only checks strings.HasPrefix(did, "did:"). This will accept many invalid/unsupported DIDs. Prefer using the repo’s canonical DID parser (pkg/did.Parse) so policy configs reject malformed/unsupported identifiers consistently.
pkg/policy/config.go
Outdated
| deniedSeen := make(map[string]bool) | ||
| for _, did := range cfg.DeniedDIDs { | ||
| if !strings.HasPrefix(did, "did:") { | ||
| errs = append(errs, fmt.Sprintf("denied_dids: invalid DID format %q", did)) | ||
| } |
There was a problem hiding this comment.
Denied DID validation only checks the "did:" prefix; this can accept malformed/unsupported DIDs. Use pkg/did.Parse for consistent validation, and keep the existing overlap check with allowed_dids.
| // Validate rate limits | ||
| for i, rl := range cfg.RateLimits { | ||
| if !strings.HasPrefix(rl.DID, "did:") { | ||
| errs = append(errs, fmt.Sprintf("rate_limits[%d]: invalid DID format %q", i, rl.DID)) | ||
| } | ||
| if rl.RPM <= 0 { | ||
| errs = append(errs, fmt.Sprintf("rate_limits[%d]: rpm must be positive, got %d", i, rl.RPM)) | ||
| } | ||
| } |
There was a problem hiding this comment.
rate_limits validation does not check for duplicate entries for the same DID, so later consumers may get ambiguous behavior. Consider tracking seen DIDs (similar to allowed_dids/denied_dids) and erroring on duplicates.
| for i, rl := range cfg.RateLimits { | ||
| if !strings.HasPrefix(rl.DID, "did:") { | ||
| errs = append(errs, fmt.Sprintf("rate_limits[%d]: invalid DID format %q", i, rl.DID)) | ||
| } |
There was a problem hiding this comment.
rate_limits[%d] DID validation only checks the "did:" prefix. To avoid accepting malformed/unsupported identifiers, validate rl.DID using pkg/did.Parse (or equivalent shared validator).
| } | ||
| if !validTrustLevels[op.MinTrustLevel] { | ||
| errs = append(errs, fmt.Sprintf("operations[%d]: invalid min_trust_level %q", i, op.MinTrustLevel)) | ||
| } |
There was a problem hiding this comment.
operations validation checks pattern and min_trust_level but does not validate op.AllowedDIDs/op.DeniedDIDs (format, duplicates, or overlap between allowed/denied). That leaves operation-scoped rules semantically unchecked even though they’re part of the config schema.
| } | |
| } | |
| // Validate operation-scoped allowed DIDs | |
| opAllowedSeen := make(map[string]bool) | |
| for j, did := range op.AllowedDIDs { | |
| if !strings.HasPrefix(did, "did:") { | |
| errs = append(errs, fmt.Sprintf("operations[%d]: allowed_dids[%d]: invalid DID format %q", i, j, did)) | |
| } | |
| if opAllowedSeen[did] { | |
| errs = append(errs, fmt.Sprintf("operations[%d]: allowed_dids[%d]: duplicate DID %q", i, j, did)) | |
| } | |
| opAllowedSeen[did] = true | |
| } | |
| // Validate operation-scoped denied DIDs | |
| opDeniedSeen := make(map[string]bool) | |
| for j, did := range op.DeniedDIDs { | |
| if !strings.HasPrefix(did, "did:") { | |
| errs = append(errs, fmt.Sprintf("operations[%d]: denied_dids[%d]: invalid DID format %q", i, j, did)) | |
| } | |
| if opDeniedSeen[did] { | |
| errs = append(errs, fmt.Sprintf("operations[%d]: denied_dids[%d]: duplicate DID %q", i, j, did)) | |
| } | |
| opDeniedSeen[did] = true | |
| if opAllowedSeen[did] { | |
| errs = append(errs, fmt.Sprintf("operations[%d]: DID %q appears in both allowed_dids and denied_dids", i, did)) | |
| } | |
| } |
| } | ||
| if !validTrustLevels[tool.MinTrustLevel] { | ||
| errs = append(errs, fmt.Sprintf("mcp_tools[%d]: invalid min_trust_level %q", i, tool.MinTrustLevel)) | ||
| } |
There was a problem hiding this comment.
mcp_tools validation checks tool name and min_trust_level but does not validate AllowedDIDs/DeniedDIDs (format, duplicates, overlap). This can allow invalid tool-scoped rules to pass validation.
| } | |
| } | |
| // Validate MCP tool allowed DIDs | |
| toolAllowedSeen := make(map[string]bool) | |
| for _, did := range tool.AllowedDIDs { | |
| if !strings.HasPrefix(did, "did:") { | |
| errs = append(errs, fmt.Sprintf("mcp_tools[%d].allowed_dids: invalid DID format %q", i, did)) | |
| } | |
| if toolAllowedSeen[did] { | |
| errs = append(errs, fmt.Sprintf("mcp_tools[%d].allowed_dids: duplicate DID %q", i, did)) | |
| } | |
| toolAllowedSeen[did] = true | |
| } | |
| // Validate MCP tool denied DIDs | |
| toolDeniedSeen := make(map[string]bool) | |
| for _, did := range tool.DeniedDIDs { | |
| if !strings.HasPrefix(did, "did:") { | |
| errs = append(errs, fmt.Sprintf("mcp_tools[%d].denied_dids: invalid DID format %q", i, did)) | |
| } | |
| if toolDeniedSeen[did] { | |
| errs = append(errs, fmt.Sprintf("mcp_tools[%d].denied_dids: duplicate DID %q", i, did)) | |
| } | |
| toolDeniedSeen[did] = true | |
| if toolAllowedSeen[did] { | |
| errs = append(errs, fmt.Sprintf("mcp_tools[%d]: DID %q appears in both allowed_dids and denied_dids", i, did)) | |
| } | |
| } |
cmd/capiscio/policy.go
Outdated
| cfg, err := policy.Parse(data) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "❌ Validation failed for %s:\n%s\n", policyFile, err.Error()) | ||
| os.Exit(1) |
There was a problem hiding this comment.
runPolicyValidate calls os.Exit(1) inside a Cobra RunE handler. This bypasses Cobra’s normal error handling, makes the command hard to test, and can skip deferred cleanup. Prefer returning an error (and optionally set cmd.SilenceUsage/SilenceErrors as needed) and let main() exit non-zero.
| os.Exit(1) | |
| return fmt.Errorf("validation failed for %s: %w", policyFile, err) |
cmd/capiscio/policy.go
Outdated
| return nil | ||
| } | ||
|
|
||
| fmt.Printf("✅ %s is valid (version %s, trust_level: %s)\n", policyFile, cfg.Version, cfg.MinTrustLevel) |
There was a problem hiding this comment.
Success output says "trust_level" but the config field is "min_trust_level". Consider updating the message to match the actual YAML key to avoid confusing users when troubleshooting configs.
| fmt.Printf("✅ %s is valid (version %s, trust_level: %s)\n", policyFile, cfg.Version, cfg.MinTrustLevel) | |
| fmt.Printf("✅ %s is valid (version %s, min_trust_level: %s)\n", policyFile, cfg.Version, cfg.MinTrustLevel) |
| func Validate(cfg *Config) error { | ||
| var errs []string | ||
|
|
||
| if cfg.Version != "1" { | ||
| errs = append(errs, fmt.Sprintf("version must be \"1\", got %q", cfg.Version)) | ||
| } |
There was a problem hiding this comment.
Validate will panic if called with a nil cfg (cfg.Version dereference). Since Validate is exported and can be called independently of Parse, it should return a clear error when cfg is nil instead of crashing.
| func Parse(data []byte) (*Config, error) { | ||
| var cfg Config | ||
| if err := yaml.Unmarshal(data, &cfg); err != nil { | ||
| return nil, fmt.Errorf("parse policy config: %w", err) | ||
| } |
There was a problem hiding this comment.
Parse uses yaml.Unmarshal, which silently ignores unknown YAML keys. For a config validator (and given the “silent-allow is worst failure mode” requirement), typos like allowed_did: would be dropped without error and could change policy behavior. Consider decoding with yaml.Decoder and enabling KnownFields(true) so unknown fields fail validation.
| for _, did := range dids { | ||
| if !strings.HasPrefix(did, "did:") { | ||
| errs = append(errs, fmt.Sprintf("%s: invalid DID format %q", name, did)) | ||
| } |
There was a problem hiding this comment.
DID validation currently only checks strings.HasPrefix("did:") (including for rate_limits). This accepts many invalid values (e.g., "did:" or unsupported methods) even though the repo has a canonical DID parser in pkg/did.Parse that enforces did:web/did:key rules. Using the shared DID parser here would make “DID formats” validation accurate and consistent.
| // Validate operations | ||
| for i, op := range cfg.Operations { | ||
| if op.Pattern == "" { | ||
| errs = append(errs, fmt.Sprintf("operations[%d]: pattern must not be empty", i)) | ||
| } | ||
| if !validTrustLevels[op.MinTrustLevel] { | ||
| errs = append(errs, fmt.Sprintf("operations[%d]: invalid min_trust_level %q", i, op.MinTrustLevel)) | ||
| } | ||
| } | ||
|
|
||
| // Validate MCP tools | ||
| for i, tool := range cfg.MCPTools { | ||
| if tool.Tool == "" { | ||
| errs = append(errs, fmt.Sprintf("mcp_tools[%d]: tool must not be empty", i)) | ||
| } | ||
| if !validTrustLevels[tool.MinTrustLevel] { | ||
| errs = append(errs, fmt.Sprintf("mcp_tools[%d]: invalid min_trust_level %q", i, tool.MinTrustLevel)) | ||
| } | ||
| } |
There was a problem hiding this comment.
OperationRule and MCPToolRule both include allowed_dids/denied_dids fields, but Validate does not validate their DID lists at all (format, duplicates, or allowed-vs-denied conflicts). This means invalid or conflicting DIDs inside operations/mcp_tools can pass validation despite the policy file containing those lists. Extend validation to run the same DID list checks per operation/tool entry.
| func TestParse_FullConfig(t *testing.T) { | ||
| yaml := `version: "1" | ||
| min_trust_level: DV | ||
| allowed_dids: | ||
| - did:web:agent1.example.com | ||
| - did:web:agent2.example.com | ||
| denied_dids: | ||
| - did:web:evil.example.com | ||
| rate_limits: | ||
| - did: did:web:agent1.example.com | ||
| rpm: 100 | ||
| operations: | ||
| - pattern: "POST /tasks" | ||
| min_trust_level: OV | ||
| allowed_dids: | ||
| - did:web:agent1.example.com | ||
| mcp_tools: | ||
| - tool: file_read | ||
| min_trust_level: EV | ||
| denied_dids: | ||
| - did:web:untrusted.example.com | ||
| ` |
There was a problem hiding this comment.
There are no tests covering validation of allowed_dids/denied_dids inside operations[] or mcp_tools[] entries (e.g., invalid DID format, duplicates, or appearing in both lists). Once per-entry validation is added, add table cases to ensure these nested DID lists are enforced (and that errors are attributed to the right index).
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- Add nil guard in Validate() - Add duplicate rate limit DID check - Validate DIDs in operation/MCP tool rules (format, duplicates) - Replace os.Exit(1) with return err in RunE handler - Fix output label: trust_level → min_trust_level - Add 4 new tests for nil, rate limit dedup, op/tool DID validation
| func Parse(data []byte) (*Config, error) { | ||
| var cfg Config | ||
| if err := yaml.Unmarshal(data, &cfg); err != nil { | ||
| return nil, fmt.Errorf("parse policy config: %w", err) | ||
| } | ||
| if err := Validate(&cfg); err != nil { | ||
| return nil, err | ||
| } |
There was a problem hiding this comment.
Parse uses yaml.Unmarshal, which will silently ignore unknown YAML keys. For a canonical config validator this is risky (typos like min_trsut_level would be dropped without error). Consider switching to yaml.NewDecoder with KnownFields(true) so unknown fields fail validation.
| for i, op := range cfg.Operations { | ||
| if op.Pattern == "" { | ||
| errs = append(errs, fmt.Sprintf("operations[%d]: pattern must not be empty", i)) | ||
| } | ||
| if !validTrustLevels[op.MinTrustLevel] { | ||
| errs = append(errs, fmt.Sprintf("operations[%d]: invalid min_trust_level %q", i, op.MinTrustLevel)) | ||
| } | ||
| _, opDIDErrs := validateDIDList(fmt.Sprintf("operations[%d].allowed_dids", i), op.AllowedDIDs, nil) | ||
| errs = append(errs, opDIDErrs...) | ||
| _, opDeniedErrs := validateDIDList(fmt.Sprintf("operations[%d].denied_dids", i), op.DeniedDIDs, nil) | ||
| errs = append(errs, opDeniedErrs...) | ||
| } |
There was a problem hiding this comment.
Top-level allowed_dids vs denied_dids are cross-checked for conflicts, but operation-scoped lists are not. As a result, the same DID can appear in both operations[i].allowed_dids and operations[i].denied_dids without error, making evaluation order ambiguous and contradicting the PR description’s "conflicting lists" validation.
pkg/policy/config.go
Outdated
| _, toolDIDErrs := validateDIDList(fmt.Sprintf("mcp_tools[%d].allowed_dids", i), tool.AllowedDIDs, nil) | ||
| errs = append(errs, toolDIDErrs...) | ||
| _, toolDeniedErrs := validateDIDList(fmt.Sprintf("mcp_tools[%d].denied_dids", i), tool.DeniedDIDs, nil) |
There was a problem hiding this comment.
Similarly, MCP tool rules validate allowed/denied lists independently but do not detect a DID that appears in both mcp_tools[i].allowed_dids and mcp_tools[i].denied_dids. If conflicts are meant to be rejected (as with top-level lists), add a cross-check here as well to avoid ambiguous policy behavior.
| _, toolDIDErrs := validateDIDList(fmt.Sprintf("mcp_tools[%d].allowed_dids", i), tool.AllowedDIDs, nil) | |
| errs = append(errs, toolDIDErrs...) | |
| _, toolDeniedErrs := validateDIDList(fmt.Sprintf("mcp_tools[%d].denied_dids", i), tool.DeniedDIDs, nil) | |
| toolAllowedSeen, toolDIDErrs := validateDIDList(fmt.Sprintf("mcp_tools[%d].allowed_dids", i), tool.AllowedDIDs, nil) | |
| errs = append(errs, toolDIDErrs...) | |
| _, toolDeniedErrs := validateDIDList(fmt.Sprintf("mcp_tools[%d].denied_dids", i), tool.DeniedDIDs, toolAllowedSeen) |
…l rules - Cross-check allowed vs denied DID lists within each operation rule - Cross-check allowed vs denied DID lists within each MCP tool rule - Add tests for operation and MCP tool DID conflicts - Fix DID conflict error messages to include scoped name
| } | ||
|
|
||
| // ToMap converts a Config to the map format used in OPA data documents. | ||
| func ToMap(cfg *Config) map[string]interface{} { |
There was a problem hiding this comment.
ToMap dereferences cfg without a nil check; calling ToMap(nil) will panic. Since this is an exported helper, consider returning an empty map (or nil) and/or mirroring Validate by returning an error when cfg is nil.
| func ToMap(cfg *Config) map[string]interface{} { | |
| func ToMap(cfg *Config) map[string]interface{} { | |
| if cfg == nil { | |
| return map[string]interface{}{} | |
| } |
| var policyValidateCmd = &cobra.Command{ | ||
| Use: "validate", | ||
| Short: "Validate a YAML policy config file", | ||
| Long: `Validate a CapiscIO YAML policy configuration file locally. | ||
| No network access is required. Checks schema version, trust levels, |
There was a problem hiding this comment.
This command does not declare an Args validator, so extra positional args will be silently accepted/ignored by cobra. Consider adding Args: cobra.NoArgs (and similarly for policy context) to fail fast on unexpected input.
| var policyContextCmd = &cobra.Command{ | ||
| Use: "context", | ||
| Short: "Fetch policy context from registry", | ||
| Long: `Fetch the aggregate policy context from the CapiscIO registry. | ||
| This calls GET /v1/sdk/policy-context and writes the result as JSON. |
There was a problem hiding this comment.
This command does not declare an Args validator, so extra positional args will be silently accepted/ignored by cobra. Consider adding Args: cobra.NoArgs to ensure misuse is caught early.
- ToMap returns nil for nil input instead of panicking - Add Args: cobra.NoArgs to validate and context commands
| } | ||
| seen[did] = true | ||
| if crossCheck != nil && crossCheck[did] { | ||
| errs = append(errs, fmt.Sprintf("%s: DID %q conflicts with allowed_dids", name, did)) |
There was a problem hiding this comment.
The conflict error message is hard-coded as "conflicts with allowed_dids" even when validating nested scopes like operations[i].denied_dids or mcp_tools[i].denied_dids. Consider including the fully-qualified allowed list name in the message (e.g., operations[i].allowed_dids) so users can quickly identify what it conflicts with.
| errs = append(errs, fmt.Sprintf("%s: DID %q conflicts with allowed_dids", name, did)) | |
| allowedName := strings.Replace(name, "denied_dids", "allowed_dids", 1) | |
| errs = append(errs, fmt.Sprintf("%s: DID %q conflicts with %s", name, did, allowedName)) |
PR 6 of POLICY-CONFIG-001: Shared Policy Validator + CLI Commands
Overview
Adds the canonical policy configuration validator as a shared package (
pkg/policy/) and two new CLI commands for policy management.New Package:
pkg/policy/Config,RateLimitRule,OperationRule,MCPToolRuleParse(data []byte)— YAML parsing with full semantic validationValidate(cfg *Config)— version, trust levels, DID formats, rate limits, operations, MCP toolsToMap(cfg *Config)— converts to OPA data document format for bundle embeddingThis is the shared validator used by both the CLI and the server. The server's
internal/pdp/policy_config.gocan be updated to import from this package in a follow-up.New CLI Commands
capiscio policy validate [-f file] [--json]--jsonflag for machine-readable outputcapiscio-policy.yamlcapiscio policy context [--registry url] [--api-key key] [-o file]GET /v1/sdk/policy-context(requires PR 5 on server)CAPISCIO_API_KEYenv var or--api-keyflagTests
ToMapround-trip, multi-error reporting)Verification
Part of
POLICY-CONFIG-001 implementation sequence (Step 6 of 8)