IMPL 0005: HCL Policy Configuration and Rule Engine
Status: Completed Author: Donald Gifford Date: 2026-03-15
Objective
Implement the internal/policy package that parses HCL configuration files
into typed Go structs, and refactor the hardcoded FileRule registry into a
config-driven rule engine with three check modes (exists, contains,
exact) and typed content assertions.
Implements: DESIGN-0006
Scope
In Scope
- HCL parser using
hashicorp/hcl/v2forguardian.hcl - Go types:
PolicyConfig,GuardianConfig,FileRuleConfig,AssertionConfig,PRConfig - Config loading: single file and directory of
.hclfiles - Config merge: built-in defaults → HCL → env var overrides
- Validation at load time with clear error messages
- Three check modes:
exists,contains,exact - Content assertions:
pattern,not_pattern,yaml_path+contains/equals - Minimal YAML path evaluator (dot paths + array wildcards)
- Refactor
main.gowiring to usePolicyConfig - Backward compatibility when no HCL config is present
- Helm chart
policyConfigMap support GUARDIAN_CONFIGenv var
Out of Scope
- Reconciler interface and types (IMPL-0006)
- Push event handler (IMPL-0006)
- Ignore lists (IMPL-0007)
rule "setting"andrule "branch_protection"types (IMPL-0007)- Additional reconciler implementations (IMPL-0007)
Implementation Phases
Each phase builds on the previous one. A phase is complete when all its tasks are checked off and its success criteria are met.
Phase 1: Go Types and HCL Schema
Establish the foundational Go types and HCL schema definition. No parsing logic yet -- just the data structures that everything else builds on.
Tasks
- [x] Create
internal/policy/types.gowith all Go types:PolicyConfig,GuardianConfig,FileRuleConfig,CheckModeconstants,PRConfig,AssertionConfig - [x] Add placeholder fields for future types:
IgnoreConfig,ReconcilerConfig(empty structs, filled in IMPL-0006/0007) - [x] Add
hashicorp/hcl/v2andhashicorp/hcl/v2/hclsimpletogo.mod - [x] Add HCL struct tags to all types for schema binding
- [x] Write unit tests validating Go type construction and zero-value defaults
Success Criteria
go build ./...succeedsmake lintpasses- Types compile with correct HCL struct tags
- Tests cover type construction
Phase 2: Built-in Defaults
Create the default policy config that mirrors current hardcoded behavior, ensuring backward compatibility when no HCL config is present.
Tasks
- [x] Create
internal/policy/defaults.gowithBuiltinDefaults() *PolicyConfig - [x] Map current
rules.DefaultRulestoFileRuleConfigstructs (CODEOWNERS, Dependabot, Renovate) - [x] Map current
config.Configdefaults toGuardianConfigfields - [x] Write unit tests asserting built-in defaults match current
DefaultRulesandconfig.Load()defaults exactly - [x] Write comparison test:
BuiltinDefaults().FileRulesproduces the same rule names, paths, targets, and enabled states asrules.DefaultRules
Success Criteria
BuiltinDefaults()returns a config equivalent to current hardcoded behavior- All three default rules (CODEOWNERS, Dependabot, Renovate) are represented
- All
GuardianConfigfields have defaults matching current env var defaults make testpasses
Phase 3: HCL Parser and Config Loader
Implement the core Load function that reads HCL files, merges with
defaults, applies env var overrides, and validates the result.
Tasks
- [x] Create
internal/policy/loader.gowithLoad(path string) (*PolicyConfig, error) - [x] Implement single-file loading (
.hclfile) - [x] Implement directory loading (all
*.hclfiles, lexicographic order) - [x] Implement env var override layer for
GuardianConfigfields (use same env var names as currentconfig.Config) - [x] Implement merge logic: built-in defaults, then HCL overrides
- [x] Handle
GUARDIAN_CONFIGunset or path not found → returnBuiltinDefaults() - [x] Support HCL variables and
locals {}blocks - [x] Write unit tests:
- Valid HCL file parses correctly
- Directory with multiple
.hclfiles merges correctly - Duplicate rule names across files produce validation error
- Env var overrides take precedence over HCL values
- Missing config path returns built-in defaults
- HCL syntax errors produce clear error messages
locals {}blocks resolve correctly
Success Criteria
Load("/path/to/guardian.hcl")returns correctPolicyConfigLoad("/path/to/dir/")loads and merges all.hclfilesLoad("")returns built-in defaults- Env vars override HCL values
make testpasses
Phase 4: Config Validation
Add comprehensive validation at load time to catch configuration errors early with clear, actionable error messages.
Tasks
- [x] Create
internal/policy/validate.gowithValidate(cfg *PolicyConfig) error - [x] Validate
FileRuleConfig: checkmust be one ofexists,contains,exactpathsmust be non-emptytargetmust be non-emptytemplatemust be non-empty- Assertions require
check = "contains" patternandyaml_pathare mutually exclusiveyaml_pathrequires eithercontainsorequalsmessageis required for all assertions- [x] Validate
GuardianConfig: worker_count> 0queue_size> 0rate_limit_thresholdbetween 0.0 and 1.0log_levelis one of debug, info, warn, error- [x] Validate no duplicate rule names (same type + name)
- [x] Write unit tests for each validation rule with both valid and invalid inputs
- [x] Write unit tests for error message clarity (errors should name the field and explain the constraint)
Success Criteria
- All invalid configs produce clear, specific error messages
- Valid configs pass validation without error
- Validation runs as part of
Load()before returning make testandmake lintpass
Phase 5: YAML Path Evaluator
Build the minimal YAML path evaluator for content assertions on structured
files. Uses gopkg.in/yaml.v3 (already in go.mod).
Tasks
- [x] Create
internal/policy/yamlpath.gowithEvaluateYAMLPath(content string, path string) ([]string, error) - [x] Support dot-separated paths:
spec.owner,metadata.name - [x] Support literal slashes in keys:
metadata.annotations.jira/project-key - [x] Support array wildcards:
updates[*].package-ecosystem - [x] Return all matching values as
[]string - [x] Return clear errors for invalid YAML or invalid path expressions
- [x] Write table-driven unit tests covering:
- Simple dot paths
- Nested dot paths
- Keys containing slashes
- Array wildcard paths
- Non-existent paths (empty result, no error)
- Invalid YAML content
- Invalid path syntax
Success Criteria
- All YAML path expressions from DESIGN-0006 table work correctly
- No external YAML path library dependency (just
gopkg.in/yaml.v3) make testandmake lintpass
Phase 6: Content Assertions
Implement the assertion evaluation engine that runs when check = "contains"
and the file exists.
Tasks
- [x] Create
internal/policy/assertion.gowith(a *AssertionConfig) Evaluate(content string, filePath string) error - [x] Implement
patternassertion: compile regex, match against content - [x] Implement
not_patternassertion: compile regex, fail if matched - [x] Implement
yaml_path+containsassertion: evaluate path, check if any value contains the string - [x] Implement
yaml_path+equalsassertion: evaluate path, check if any value equals the string - [x] Return error with
messagefield when assertion fails - [x] Pre-compile regexes at config load time (not per-evaluation)
- [x] Write unit tests:
- Regex match passes and fails
- Regex not_pattern passes and fails
- YAML path contains passes and fails
- YAML path equals passes and fails
- Multiple assertions all pass
- Multiple assertions with one failure
- Clear error messages include the assertion
messagefield
Success Criteria
- All assertion types evaluate correctly
- Assertions return user-friendly failure messages
- Regexes are validated at config load time
make testandmake lintpass
Phase 7: Rule Engine Refactor
Refactor internal/checker/engine.go to use PolicyConfig instead of the
hardcoded rules.Registry. Implement the three check modes.
Tasks
- [x] Modify
checker.Engineto accept*policy.PolicyConfiginstead of*rules.Registry - [x] Refactor
CheckRepoto iterate overPolicyConfig.FileRules - [x] Implement
existsmode: same as current behavior (check file existence, PR if missing) - [x] Implement
containsmode: check existence → if present, run assertions → if assertions fail, create PR to replace with template - [x] Implement
exactmode: check existence → if present, compare against template → YAML semantic comparison for.yml/.yamlfiles, byte comparison otherwise → create PR if mismatch - [x] Add YAML semantic diff helper (parse both, compare parsed structures)
- [x] Update
findMissingFilesto handle the new check modes - [x] Preserve existing metrics (
files_missing_total,prs_created_total, etc.) - [x] Maintain backward compatibility: when using built-in defaults, behavior is identical to current
- [x] Write unit tests:
existsmode: file missing → PR createdexistsmode: file present → no actioncontainsmode: file missing → PR createdcontainsmode: file present, assertions pass → no actioncontainsmode: file present, assertions fail → PR createdexactmode: file missing → PR createdexactmode: file matches template → no actionexactmode: file differs from template → PR createdexactmode: YAML files use semantic comparison- Backward compatibility with
BuiltinDefaults()
Success Criteria
- All three check modes work correctly
- Existing tests continue to pass
- Backward compatibility with default rules
- Metrics are preserved
make testandmake lintpass
Phase 8: Main Wiring and Config Integration
Update cmd/repo-guardian/main.go to load policy config and wire it through
the application. Trim config.Config to only hold credentials.
Tasks
- [x] Add
GUARDIAN_CONFIGtoconfig.Config(path to HCL file/dir) - [x] Call
policy.Load()inmain.goafterconfig.Load() - [x] Pass
PolicyConfig.Guardianfields to components that currently read fromconfig.Config(engine, scheduler, queue) - [x] Remove operational fields from
config.Configthat are now inGuardianConfig(keep credentials-only) - [x] Update
checker.NewEnginesignature to accept*policy.PolicyConfig - [x] Update
scheduler.NewSchedulerto useGuardianConfigfields - [x] Update
checker.NewQueueto useGuardianConfig.QueueSize - [x] Log the config source at startup (HCL file path or "built-in defaults")
- [x] Write integration test:
config.Load()+policy.Load()→ engine creation → no errors
Success Criteria
main.gocompiles and runs with both HCL config and without- Existing env vars continue to work as overrides
- Credential config remains env-var-only
make cipasses (lint + test + build)
Phase 9: Helm Chart Policy ConfigMap
Add Helm chart support for mounting the HCL policy file via ConfigMap.
Tasks
- [x] Add
policy.configandpolicy.existingConfigMaptovalues.yaml - [x] Create
templates/policy-configmap.yaml(rendered whenpolicy.configis set) - [x] Update
templates/deployment.yamlto mount policy ConfigMap at/etc/repo-guardian/guardian.hcl - [x] Set
GUARDIAN_CONFIGenv var in deployment when policy ConfigMap is present - [x] Write helm-unittest tests:
- Policy ConfigMap renders correctly with inline config
- Policy ConfigMap not rendered when
policy.configis empty - External ConfigMap used when
policy.existingConfigMapis set - Deployment volume mount present when policy is configured
GUARDIAN_CONFIGenv var set correctly- [x] Update
ci/ci-values.yamlif needed for chart-testing
Success Criteria
helm templaterenders correctly with policy confighelm templaterenders correctly without policy config (backward compat)- helm-unittest tests pass
make lintpasses (includes chart linting)
File Changes
| File | Action | Description |
|---|---|---|
internal/policy/types.go |
Create | Go types with HCL struct tags |
internal/policy/defaults.go |
Create | Built-in defaults matching current behavior |
internal/policy/loader.go |
Create | HCL parser, file/dir loading, env var merge |
internal/policy/validate.go |
Create | Config validation with clear errors |
internal/policy/yamlpath.go |
Create | Minimal YAML path evaluator |
internal/policy/assertion.go |
Create | Content assertion evaluation |
internal/policy/types_test.go |
Create | Type construction tests |
internal/policy/defaults_test.go |
Create | Default equivalence tests |
internal/policy/loader_test.go |
Create | Parser and loader tests |
internal/policy/validate_test.go |
Create | Validation tests |
internal/policy/yamlpath_test.go |
Create | YAML path evaluator tests |
internal/policy/assertion_test.go |
Create | Assertion evaluation tests |
internal/checker/engine.go |
Modify | Refactor to use PolicyConfig |
internal/checker/engine_test.go |
Modify | Add check mode tests |
internal/config/config.go |
Modify | Add GUARDIAN_CONFIG, trim operational fields |
cmd/repo-guardian/main.go |
Modify | Wire policy.Load() into startup |
go.mod |
Modify | Add hashicorp/hcl/v2 |
charts/repo-guardian/values.yaml |
Modify | Add policy.config section |
charts/repo-guardian/templates/policy-configmap.yaml |
Create | Policy ConfigMap template |
charts/repo-guardian/templates/deployment.yaml |
Modify | Mount policy ConfigMap |
Testing Plan
- [ ] Unit tests for all exported functions in
internal/policy/ - [ ] Table-driven tests for YAML path evaluator
- [ ] Table-driven tests for assertion evaluation
- [ ] Table-driven tests for config validation
- [ ] Integration tests: HCL file → PolicyConfig → engine
- [ ] Backward compatibility tests: no HCL → identical behavior
- [ ] Helm-unittest tests for policy ConfigMap
Dependencies
hashicorp/hcl/v2— HCL parser (new dependency)gopkg.in/yaml.v3— already in go.mod, used for YAML path evaluator- DESIGN-0006 — design document (completed)
Resolved Questions
-
Config trimming scope: Fully trim
config.Configof operational fields in Phase 8. No compatibility shim — clean cut.config.Configkeeps only credentials (GitHubAppID, private key, webhook secret). -
HCL library choice: Use
hclsimpleas the primary parsing API. Bothhclsimpleandgohclare subpackages ofgithub.com/hashicorp/hcl/v2— single dependency. Pull in other parts of the library as needed (e.g.,hclparsefor better diagnostics). -
Regex compilation caching: Use a separate compiled assertion type. Regexes are compiled at config load time into a distinct type — compile errors are caught early, and the type is independently testable and mockable.
References
- DESIGN-0006: HCL Policy Configuration and Rule Engine
- RFC-0002: HCL-driven Policy Engine
- Current rule registry:
internal/rules/registry.go - Current config:
internal/config/config.go - Current checker engine:
internal/checker/engine.go