Generator validation perimeter¶
- Authors
- Matt Cockayne, Claude (claude-fable-5) (AI drafting assistant)
- Date
- 2026-06-12
- Status
- IMPLEMENTED (open questions resolved in review 2026-06-12; implemented 2026-06-13)
Summary¶
The code generator (internal/generator/) renders user-influenced fields into scaffolded files. It has a well-built escaping layer (template_escape.go) and a validator (validate.go), but the audit found the perimeter is incomplete: some inputs reach a filepath.Join/RemoveAll or a non-code render site without passing through validation or escaping. This spec closes those gaps so the documented template-security model (docs/development/template-security.md) holds for every user-influenced field, not just the project-name/description/host/org fields it currently covers.
Findings addressed (from docs/development/reports/codebase-audit-2026-06-12.md):
command-name-no-validation-path-traversalβ High, securitysigning-fields-unvalidated-unescaped-yaml-injectionβ High, securityai-doc-tools-path-traversalβ Medium, securitygenerator-sentinel-with-format-verbβ Medium, bug (folded in: it lives in the same files and is a correctness pre-req for clean error reporting)remove-generate-command-name-path-traversalβ Low (resolved as a side effect ofValidateCommandName)
Motivation¶
ValidateManifest is documented as the gate that forecloses path-traversal and injection from a tampered .gtb/manifest.yaml. In practice it validates only Properties and ReleaseSource and never walks m.Commands or m.Signing. Two concrete exploit primitives result:
-
Path traversal (write + recursive delete). Command names get only
not "options"/not "root"/no-spaceschecks, then flow intofilepath.Join(g.config.Path, "pkg", "cmd", name)andFS.RemoveAll(cmdDir).filepath.Joincleans.., so a crafted name escapes the project tree. The most serious vector isregenerate: a tampered manifest drives writes/deletes outside the tree despite the gate whose documented purpose is to prevent exactly this. -
YAML injection into a CI-executed file.
Backend,KMSRegion,KeyID,PublicKeyare interpolated into double-quoted YAML scalars inskeleton/.goreleaser.yamlwith noescapeYAMLpipe and no validation. A value containing a quote + newline breaks out of the scalar and injects keys into the GoReleasersignsblock, which runs in CI at release time.
A third, lower-severity gap: the AI doc-generation tools (handleReadFileTool/handleListDirTool) join LLM-supplied paths to the project root with no containment check, while the sibling handleGoDocTool validates its argument.
Design decisions¶
D1 β A single ValidateCommandName applied at every entry point¶
Add ValidateCommandName(name string) error to validate.go enforcing an NFC-normalised character class of kebab plus underscore β ^[a-z][a-z0-9_-]{0,63}$ (resolved O1: underscores are permitted for downstream snake_case command names) β and explicitly rejecting /, ., and ... Call it from:
generate/removeCLI option validation (internal/cmd/generate,internal/cmd/remove) β here an invalid name is a hard error to the user (they typed it), and- recursively over
m.CommandsinsideValidateManifest, so theregeneratepath (which trusts the manifest) is covered.
This is the load-bearing fix: validating only at the CLI flag is insufficient because regenerate reads names from the manifest, not flags.
For the manifest path, an invalid command does not abort the whole regeneration (resolved O3): the offending command is skipped and the generator emits an ERROR-level log naming the command and the validation failure, so the user clearly sees there is a problem while the valid commands still regenerate. (The traversal-into-filepath.Join/RemoveAll is still foreclosed because a skipped command is never acted on.) The signing-field path is treated the same way β an invalid ManifestSigning field skips signing-block rendering with an ERROR log rather than aborting.
D2 β Escape and validate every ManifestSigning field¶
Pipe every Signing.* value rendered into skeleton/.goreleaser.yaml through escapeYAML (matching project_name: {{ .Name | escapeYAML }}), and add field-specific validators invoked from ValidateManifest:
Backendβ registered-backend-name character class.KMSRegionβ AWS region character class ([a-z0-9-]).KeyIDβ KMS key-id / alias character class.PublicKeyβ path validation (resolved O2:manifest.godocuments this field as "the path to the armored public-key file", defaulting tointernal/trustkeys/keys/signing-key-v1.asc). Validate it as a clean relative path under the project (no../absolute escape), not as inline armor.
Both halves are required: escaping prevents scalar breakout; validation rejects nonsense before it reaches CI.
D3 β Contain AI doc-tool paths¶
Confirmed during review: handleReadFileTool (docs.go:621) and handleListDirTool (docs.go:639) are not bound to the project root β they filepath.Join(g.config.Path, params.Path) with no post-join containment, so a model-supplied ../../etc/passwd escapes. The go-doc path (docs.go:443-465) does contain via filepath.Abs + filepath.Rel.
Resolution (O4): extend the same filepath.Abs + filepath.Rel containment guard the go-doc path already uses to handleReadFileTool/handleListDirTool, rather than introducing a new BasePathFs β keeps the two code paths consistent. After joining, compute the path relative to the absolute project root and reject any result beginning with ...
D4 β Make the project sentinel placeholder-free¶
ErrNotGoToolBaseProject embeds a literal %s. One call site concatenates it into a Newf without %w (breaking errors.Is); another wraps with %w but supplies no path (rendering literal at '%s'). Make the sentinel placeholder-free and attach the path via errors.Wrapf at call sites. This is in scope because the validator additions need clean, matchable errors.
Open questions¶
All resolved during review (2026-06-12).
- O1 β Character-class for command names. Resolved: kebab plus underscore β
^[a-z][a-z0-9_-]{0,63}$β so downstream snake_case command names are permitted. (D1) - O2 β
PublicKeyfield. Resolved: it is a path to an armored public-key file (manifest.go:215), validated as a clean project-relative path. (D2) - O3 β Invalid command/signing field in a manifest. Resolved: skip the offending entry and emit an ERROR-level log (not abort), so the valid entries still regenerate while the problem is clearly surfaced. CLI-flag inputs remain a hard error. (D1)
- O4 β AI doc-tool containment. Resolved:
handleReadFileTool/handleListDirToolare currently unguarded (confirmed in code); extend the existingfilepath.Abs+filepath.Relcontainment guard the go-doc path already uses to them. (D3)
Verification plan¶
- Unit β
validate_test.go:ValidateCommandNameaccepts the kebab class and rejects..,/,., leading digit, over-length, and the reserved names. New cases provingValidateManifestwalksCommandsandSigning. - Traversal regression β a tampered manifest with
Commands: [{name: "../../evil"}]fed to theregeneratepath must error (not write/delete outside the tree). AManifestSigning.KeyIDcontaining"\n - artifact: pwned"must be rejected and, if it somehow rendered, escaped. - AI doc-tool β
handleReadFileToolwith../../../../etc/passwdreturns a containment error. - Scaffold check β
just build && go run ./cmd/gtb generate project -p tmpthenregenerate, verifytmp/.goreleaser.yamlsigning block is well-formed; deletetmp. - Docs β update
docs/development/template-security.mdto list the newly-validated fields.
Out of scope¶
- Re-architecting the escaping helpers (they are sound; this is a perimeter fix).
- Signing-feature behaviour beyond input validation (covered by
2026-06-10-signing-generator-feature.md).
Related¶
- 2026-06-12 codebase audit Β§3.1
- Plan 1 β security & bugs Phase 1
docs/development/template-security.md- signing generator feature spec