Centralize YAML handling with safe unmarshal wrappers - #225
Conversation
Creates internal/yamlutil with UnmarshalSafe, UnmarshalNodeSafe, Marshal, and RejectYAMLAliases wrappers. Migrates all 13+ yaml.Unmarshal call sites to use the safe wrappers, eliminating duplicated RejectYAMLAliases + yaml.Unmarshal pairs across 7 packages. Removes internal/lint/yamlsafe.go as RejectYAMLAliases now lives in yamlutil. https://claude.ai/code/session_01WnJMsRhRBvtMHQX1seKaQW
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #225 +/- ##
==========================================
+ Coverage 94.51% 94.58% +0.07%
==========================================
Files 136 136
Lines 15850 15839 -11
==========================================
+ Hits 14981 14982 +1
+ Misses 530 524 -6
+ Partials 339 333 -6 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR centralizes YAML parsing/marshaling behind a new internal/yamlutil package so mdsmith can apply one consistent alias-rejection policy anywhere it reads user-controlled YAML. It fits into the codebase as a security-hardening and cleanup pass across config loading, front matter handling, directive parsing, and YAML-emitting command output.
Changes:
- Added
internal/yamlutilwith shared safe-unmarshal, node-unmarshal, marshal, and alias-rejection helpers plus dedicated tests. - Replaced scattered direct YAML unmarshalling at user-input call sites with
yamlutilwrappers across config, front matter, directives, corpus config, and CLI code. - Removed the old
internal/lintalias-check helper and marked the YAML-handling plan item complete.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| plan/121_yaml-handling-review.md | Marks the YAML-centralization plan complete and updates acceptance criteria. |
| internal/yamlutil/yamlutil_test.go | Adds focused tests for alias rejection and wrapper behavior. |
| internal/yamlutil/yamlutil.go | Introduces the new shared YAML safety/marshal helpers. |
| internal/rules/requiredstructure/rule.go | Switches schema/directive/front-matter parsing to the new helpers. |
| internal/rules/catalog/rule.go | Uses the centralized safe unmarshal for catalog front matter. |
| internal/lint/yamlsafe_test.go | Retargets alias-rejection coverage through front-matter parsing. |
| internal/lint/yamlsafe.go | Removes the old alias-rejection implementation after extraction. |
| internal/lint/frontmatter.go | Routes front-matter kinds: parsing through yamlutil. |
| internal/kindsout/kindsout.go | Uses the shared marshal wrapper for YAML output. |
| internal/corpus/config.go | Uses safe unmarshal for corpus config and local overrides. |
| internal/config/load.go | Replaces config parsing/key inspection with centralized YAML helpers. |
| internal/config/convention.go | Uses node-safe parsing for raw convention: validation. |
| internal/archetype/gensection/parse.go | Uses safe unmarshal for generated-section YAML bodies. |
| cmd/mdsmith/main.go | Uses centralized YAML helpers for front matter reading and init output. |
| PLAN.md | Updates the plan catalog entry for plan 121 to completed. |
- Fix broken doc link: docs/security/adversarial-markdown.md → docs/security/2026-04-05-adversarial-markdown.md - convention.go: revert validateConventionScalar to plain yaml.Unmarshal; node parsing never expands aliases so UnmarshalNodeSafe's alias scan was redundant - requiredstructure: remove double alias check in front-matter path; keep explicit RejectYAMLAliases + yaml.Unmarshal to preserve distinct error messages with a single decode pass - plan/121: correct UnmarshalNodeSafe signature (value not pointer), document validateConventionScalar exception and front-matter error-message split, fix test-migration note https://claude.ai/code/session_01WnJMsRhRBvtMHQX1seKaQW
|
🔍 Merge Queue — bisecting A larger batch failed CI. Bisection is isolating the culprit: this run tests up to 2 of 3 candidate PRs on Next: No action needed — you'll be notified when the culprit is isolated or this PR merges. |
|
🟢 Merge Queue — picked up This PR is in the queue and will be batched with other Next: No action needed — you'll get another comment when CI starts on the batch. View merge queue run. |
|
🔵 Merge Queue — CI running Merged into batch branch Next: No action needed — you'll be notified when CI completes. |
|
✅ Merge Queue — merged This PR landed on Next: Done — nothing more to do here. |
…fix README After PR #225 merged into main, RejectYAMLAliases moved from internal/lint to internal/yamlutil and a combined UnmarshalSafe helper was introduced. Update singleh1/rule.go to import yamlutil and use UnmarshalSafe (which already rejects YAML aliases internally) instead of the now-removed lint.RejectYAMLAliases + yaml.Unmarshal pair. Also add the missing blank import of singleh1 to cmd/mdsmith/main.go so the rule is registered in the binary. Update the README Diagnostics table condition for the front-matter conflict message to accurately describe when it fires: the front matter must contain the configured field with a non-empty string value (YAML parse errors and alias rejection both result in no conflict). https://claude.ai/code/session_019k6vHWPxLjqFjVmtUX6V51
…fix README After PR #225 merged into main, RejectYAMLAliases moved from internal/lint to internal/yamlutil and a combined UnmarshalSafe helper was introduced. Update singleh1/rule.go to import yamlutil and use UnmarshalSafe (which already rejects YAML aliases internally) instead of the now-removed lint.RejectYAMLAliases + yaml.Unmarshal pair. Also add the missing blank import of singleh1 to cmd/mdsmith/main.go so the rule is registered in the binary. Update the README Diagnostics table condition for the front-matter conflict message to accurately describe when it fires: the front matter must contain the configured field with a non-empty string value (YAML parse errors and alias rejection both result in no conflict). https://claude.ai/code/session_019k6vHWPxLjqFjVmtUX6V51
Summary
This PR extracts YAML security and marshaling logic into a new
internal/yamlutilpackage, centralizing all user-content YAML parsing under safe unmarshal wrappers that prevent billion-laughs denial-of-service attacks.Key Changes
New
internal/yamlutilpackage with three main functions:UnmarshalSafe()— unmarshal user content into Go structs/maps with anchor/alias rejectionUnmarshalNodeSafe()— unmarshal into rawyaml.Nodetrees with the same security checksMarshal()— thin wrapper aroundyaml.Marshalfor consistencyRejectYAMLAliases()— core security check that decodes YAML into nodes without expanding aliases, safely detecting billion-laughs payloadsMigrated
RejectYAMLAliasesfrominternal/linttointernal/yamlutiland removed the old implementationUpdated all user-content unmarshal sites to use
yamlutil.UnmarshalSafeinstead of directyaml.Unmarshal:internal/config/load.go— config file parsinginternal/corpus/config.go— corpus config parsinginternal/lint/frontmatter.go— front matter parsinginternal/rules/requiredstructure/rule.go— directive and schema parsinginternal/rules/catalog/rule.go— catalog front matter parsinginternal/archetype/gensection/parse.go— generated section YAML parsingcmd/mdsmith/main.go— front matter readingUpdated marshal sites to use
yamlutil.Marshalfor consistency:internal/kindsout/kindsout.goComprehensive test coverage in
internal/yamlutil/yamlutil_test.gowith 20+ test cases covering:Updated test in
internal/lint/yamlsafe_test.goto verify anchor/alias rejection through the front matter parsing pathMarked plan item 121 complete in
plan/121_yaml-handling-review.mdImplementation Details
The security model relies on decoding YAML into
yaml.Nodewithout expanding aliases (which prevents the exponential expansion attack), then checking for anchor/alias nodes in the tree. This approach safely detects malicious YAML before the standardyaml.Unmarshalcall that would expand aliases. Non-anchor syntax errors are allowed through to be handled by the caller's unmarshal logic.https://claude.ai/code/session_01WnJMsRhRBvtMHQX1seKaQW