go-cli-attestation-dual-format
GAP-004 root cause: ParseCommitMessage only recognized Prompt: sha256:<hex> and ValidateAttestation only knew how to verify that format against a registry, so path-style refs mandated by AGENTS.md (Prompt: prompts/<name>/v<N>.md) failed with ATTESTATION_MISSING. Fix adds a PromptPath field, a path-style regex, workDir-relative resolution via filepath.Join, and treats an existing prompt file as valid without any registry lookup.
attestation.go — dual-format Attestation + parserpackage attestation
import (
"fmt"
"path/filepath"
"regexp"
"strings"
)
type RefKind int
const (
RefHash RefKind = iota // sha256:<hex>
RefPath // prompts/<name>/v<N>.md
)
// Attestation is the parsed "Prompt: ..." line from a commit message.
type Attestation struct {
Kind RefKind
// Hash is the full sha256:<hex> reference when Kind == RefHash.
Hash string
// PromptPath is the prompts/<name>/v<N>.md reference when Kind == RefPath.
PromptPath string
}
var (
// hashRefRe matches Prompt: sha256:<hex> (64 hex digits).
hashRefRe = regexp.MustCompile(`(?i)^sha256:[0-9a-f]{64}$`)
// pathRefRe matches AGENTS.md-mandated prompts/<name>/v<N>.md for flat
// layouts and prompts/<name>/v<N>/<sub>/.../<file>.md for nested layouts.
pathRefRe = regexp.MustCompile(`^prompts/[A-Za-z0-9._-]+/v[0-9]+(?:/[A-Za-z0-9._-]+)*\.md$`)
)
// ParseCommitMessage extracts the attestation from a commit message body.
// The "Prompt:" header may appear on any line; leading "prompt:" is accepted.
// No Prompt line ⇒ error containing "attestation missing" (the
// ATTESTATION_MISSING failure reported by helix prompt verify).
func ParseCommitMessage(msg string) (*Attestation, error) {
for _, line := range strings.Split(msg, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToLower(trimmed), "prompt:") {
continue
}
ref := strings.TrimSpace(trimmed[len("prompt:"):])
if ref == "" {
continue
}
return ParseRef(ref)
}
return nil, fmt.Errorf("attestation missing: no Prompt: line in commit message")
}
// ParseRef accepts both ref formats and rejects path traversal.
func ParseRef(ref string) (*Attestation, error) {
ref = strings.TrimSpace(ref)
switch {
case hashRefRe.MatchString(ref):
return &Attestation{Kind: RefHash, Hash: ref}, nil
case pathRefRe.MatchString(ref) && isCleanPath(ref):
return &Attestation{Kind: RefPath, PromptPath: ref}, nil
default:
return nil, fmt.Errorf("invalid prompt ref %q: want sha256:<hex> or prompts/<name>/v<N>.md", ref)
}
}
// isCleanPath rejects "." / ".." components (defense against traversal).
func isCleanPath(ref string) bool {
return filepath.ToSlash(filepath.Clean(ref)) == ref
}
attestation_validator.go — resolves refs relative to workDir, file-exists semanticspackage attestation
import (
"fmt"
"os"
"path/filepath"
)
// Registry is only consulted for hash-style refs; path-style refs are
// validated against the working tree instead (flat prompts are unregistered
// by design).
type Registry interface {
Contains(hash string) (bool, error)
}
type MapRegistry map[string]struct{}
func (m MapRegistry) Contains(hash string) (bool, error) {
_, ok := m[hash]
return ok, nil
}
type ValidateResult struct {
HashMatch bool // true when the referenced prompt verified successfully
FileExists bool // true when a path ref resolved to an existing file
Err error
}
// ValidateAttestation verifies an attestation against workDir.
// Path refs are resolved with filepath.Join(workDir, ref) so flat (workDir ==
// repo root) and nested (workDir == subdirectory) layouts both work. An
// existing regular file is valid ⇒ HashMatch=true, no registry lookup.
func ValidateAttestation(a *Attestation, workDir string, registry Registry) ValidateResult {
switch a.Kind {
case RefHash:
if registry == nil {
return ValidateResult{Err: fmt.Errorf("hash ref %q cannot be verified: nil registry", a.Hash)}
}
ok, err := registry.Contains(a.Hash)
return ValidateResult{HashMatch: ok, Err: err}
case RefPath:
full := filepath.Join(workDir, a.PromptPath)
info, err := os.Stat(full)
if err != nil {
if os.IsNotExist(err) {
return ValidateResult{Err: fmt.Errorf("attestation missing: prompt file %q does not exist", full)}
}
return ValidateResult{Err: fmt.Errorf("attestation error: prompt file %q: %w", full, err)}
}
if info.IsDir() {
return ValidateResult{Err: fmt.Errorf("attestation error: prompt path %q is a directory, want a .md file", full)}
}
// Existing file ⇒ valid. Flat prompts are unregistered by design.
return ValidateResult{HashMatch: true, FileExists: true}
default:
return ValidateResult{Err: fmt.Errorf("attestation error: unknown ref kind %d", a.Kind)}
}
}
hook.go — hook accepts both ref kindspackage attestation
import "fmt"
// VerifyCommit is the git-hook entry point (helix prompt verify / pre-commit).
// Accepts hash-style and path-style refs; mirrors ValidateAttestation
// semantics. registry is only consulted for hash refs and may be nil when
// verifying path refs exclusively.
func VerifyCommit(msg, workDir string, registry Registry) error {
att, err := ParseCommitMessage(msg)
if err != nil {
return err
}
res := ValidateAttestation(att, workDir, registry)
if res.Err != nil {
return res.Err
}
if !res.HashMatch {
return fmt.Errorf("attestation mismatch: prompt %q is not verified", refString(att))
}
return nil
}
(refString returns PromptPath for path refs, Hash otherwise.)
Verified in `~/attestfix` with Go 1.26: `go vet ./...` clean, `gofmt -l` clean, `go test -race -count=1 ./...` → **24/24 PASS** (0.003s non-race). **Semantics proven by tests:** - **Dual format parse**: `Prompt: sha256:<hex>` (hash) and `Prompt: prompts/my-prompt/v3.md` (path) both parse; header is case-insensitive (`Prompt:`/`prompt:`/`PROMPT:`), Prompt line may appear anywhere in the body, empty headers are skipped. - **Path resolution relative to workDir**: flat layout (`prompts/my-prompt/v3.md` under repo root) and **nested layouts** both verify — nested both in prompt path (`prompts/my-prompt/v3/prompt.md`) and in workDir (`repos/team/proj/prompts/...`), via `filepath.Join`. - **No registry lookup for path refs**: a tracking registry asserted `lookups == 0` for a path ref, and a path ref verifies with `nil` registry — flat prompts are unregistered by design. Result is `HashMatch=true, FileExists=true`. - **ATTESTATION_MISSING preserved**: missing Prompt line and missing prompt file both return errors containing `"attestation missing"`; the hook surfaces them verbatim. - **Hash refs still work**: registered hash → `HashMatch=true` (1 lookup); unregistered hash → `HashMatch=false` → hook rejects with mismatch; `nil` registry + hash → clear error; registry error propagates (`errors.Is`-compatible via `%w`). - **Edge cases**: prompt path that is a directory → error; invalid refs (`prompts/a/v1` without `.md`, `vX` version, wrong extension, bad hash hex, empty name) → error; path traversal refs (`prompts/a/v1/../../secret.md`, `prompts/../etc/passwd.md`, `.` components) rejected at parse time. **Design notes** (faithful to the GAP): `PromptPath` is a new field on `Attestation`; the path regex enforces AGENTS.md's `prompts/<name>/v<N>.md` while permitting nested variants; validation semantics mirror `attestation_validator.go`'s resolve-vs-workDir model; `hook.go` delegates to `ParseCommitMessage` + `ValidateAttestation`, so it accepts path refs by construction. ---
{"model": "deepseek-v4-flash", "problem_class": "go-cli-attestation-dual-format", "result": "passed", "tests": 24}