diff --git a/.golangci.yml b/.golangci.yml index e0195dd..ca67ca6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -120,6 +120,11 @@ linters: - dupl - wsl_v5 - tagliatelle + - path: internal/safetyprofile/.*\.go + linters: + - err113 + - wrapcheck + - wsl_v5 - path: internal/googleauth/.*\.go linters: - tagliatelle diff --git a/cmd/bake-safety-profile/main.go b/cmd/bake-safety-profile/main.go index 9dc22bb..818617d 100644 --- a/cmd/bake-safety-profile/main.go +++ b/cmd/bake-safety-profile/main.go @@ -5,36 +5,111 @@ import ( "fmt" "os" "strconv" + "strings" - "github.com/steipete/gogcli/internal/cmd" + "github.com/steipete/gogcli/internal/safetyprofile" ) +const usage = `Usage: bake-safety-profile ` + "\n" + func main() { - if len(os.Args) != 3 { - _, _ = fmt.Fprintln(os.Stderr, "usage: bake-safety-profile ") + args := os.Args[1:] + if len(args) != 2 { + _, _ = fmt.Fprint(os.Stderr, usage) os.Exit(2) } - raw, err := os.ReadFile(os.Args[1]) // #nosec G304 G703 -- build helper intentionally reads the requested profile path. + raw, err := os.ReadFile(args[0]) // #nosec G304 G703 -- build helper intentionally reads the requested profile path. if err != nil { _, _ = fmt.Fprintf(os.Stderr, "read profile: %v\n", err) os.Exit(1) } - if err := cmd.ValidateSafetyProfile(string(raw)); err != nil { + + profile, err := safetyprofile.Parse(string(raw)) + if err != nil { _, _ = fmt.Fprintf(os.Stderr, "parse profile: %v\n", err) os.Exit(1) } - var out bytes.Buffer - out.WriteString("// Code generated by cmd/bake-safety-profile; DO NOT EDIT.\n") - out.WriteString("//go:build safety_profile\n\n") - out.WriteString("package cmd\n\n") - out.WriteString("var bakedSafetyProfileYAML = ") - out.WriteString(strconv.Quote(string(raw))) - out.WriteString("\n") - - if err := os.WriteFile(os.Args[2], out.Bytes(), 0o600); err != nil { // #nosec G306 G703 -- build helper intentionally writes the requested generated Go path. + out := generate(profile) + if err := os.WriteFile(args[1], out, 0o600); err != nil { // #nosec G306 G703 -- build helper intentionally writes the requested generated Go path. _, _ = fmt.Fprintf(os.Stderr, "write output: %v\n", err) os.Exit(1) } } + +// generate emits a Go file that resolves the bakedSafety* package-level +// functions for safety_profile builds. Allow and deny rules are encoded as +// FNV-64a hashes in switch statements so that the rule set itself is no +// longer a contiguous, patchable string in the binary. +func generate(profile *safetyprofile.Profile) []byte { + var out bytes.Buffer + hasAllowRules := profile.AllowAll || len(profile.AllowRules) > 0 + + out.WriteString("// Code generated by cmd/bake-safety-profile; DO NOT EDIT.\n") + commentName := strings.ReplaceAll(strings.ReplaceAll(profile.Name, "\n", " "), "\r", " ") + fmt.Fprintf(&out, "// Profile: %s (%d allow, %d deny, allow-all=%t)\n", commentName, len(profile.AllowRules), len(profile.DenyRules), profile.AllowAll) + out.WriteString("// Hash: FNV-64a over dotted command paths.\n") + out.WriteString("//go:build safety_profile\n\n") + out.WriteString("package cmd\n\n") + + out.WriteString("const bakedSafetyProfileNameConst = ") + out.WriteString(strconv.Quote(profile.Name)) + out.WriteString("\n\n") + + out.WriteString("func bakedSafetyEnabled() bool { return true }\n") + out.WriteString("func bakedSafetyProfileName() string { return bakedSafetyProfileNameConst }\n") + fmt.Fprintf(&out, "func bakedSafetyHasAllowRules() bool { return %t }\n\n", hasAllowRules) + + writeMatcher(&out, "bakedSafetyAllowMatch", profile.AllowRules, profile.AllowAll) + out.WriteString("\n") + writeMatcher(&out, "bakedSafetyDenyMatch", profile.DenyRules, false) + + return out.Bytes() +} + +func writeMatcher(out *bytes.Buffer, name string, rules []string, matchAll bool) { + fmt.Fprintf(out, "func %s(path []string) bool {\n", name) + if matchAll { + out.WriteString("\treturn true\n}\n") + return + } + if len(rules) == 0 { + out.WriteString("\treturn false\n}\n") + return + } + + out.WriteString("\tif len(path) == 0 {\n\t\treturn false\n\t}\n") + out.WriteString("\tfor i := 1; i <= len(path); i++ {\n") + out.WriteString("\t\tswitch bakedSafetyHashPath(path[:i]) {\n") + out.WriteString("\t\tcase ") + + cases := make([]string, 0, len(rules)) + seen := make(map[uint64]string, len(rules)) + for _, rule := range rules { + h := safetyprofile.HashRule(rule) + if existing, dup := seen[h]; dup { + fmt.Fprintf(os.Stderr, "bake-safety-profile: hash collision between %q and %q (FNV-64a=%#x); pick a different name or extend the hash\n", existing, rule, h) + os.Exit(1) + } + seen[h] = rule + cases = append(cases, fmt.Sprintf("0x%016x", h)) + } + + const perLine = 4 + for i, c := range cases { + out.WriteString(c) + if i == len(cases)-1 { + break + } + out.WriteString(",") + if (i+1)%perLine == 0 { + out.WriteString("\n\t\t\t") + } else { + out.WriteString(" ") + } + } + out.WriteString(":\n\t\t\treturn true\n") + out.WriteString("\t\t}\n\t}\n") + out.WriteString("\treturn false\n}\n") +} diff --git a/cmd/bake-safety-profile/main_test.go b/cmd/bake-safety-profile/main_test.go new file mode 100644 index 0000000..2712960 --- /dev/null +++ b/cmd/bake-safety-profile/main_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "bytes" + "fmt" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/safetyprofile" +) + +func TestGenerateProducesParseableGoWithExpectedHashes(t *testing.T) { + profile := &safetyprofile.Profile{ + Name: "test", + AllowAll: false, + AllowRules: []string{"version", "gmail.search", "gmail.drafts.create"}, + DenyRules: []string{"gmail.send", "gmail.drafts.send"}, + } + + out := generate(profile) + + if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil { + t.Fatalf("generated code does not parse as Go:\n%s\n\nerror: %v", out, err) + } + + want := []string{ + `//go:build safety_profile`, + `package cmd`, + `const bakedSafetyProfileNameConst = "test"`, + `func bakedSafetyEnabled() bool { return true }`, + `func bakedSafetyHasAllowRules() bool { return true }`, + } + for _, line := range want { + if !bytes.Contains(out, []byte(line)) { + t.Fatalf("generated output missing %q\n\nfull output:\n%s", line, out) + } + } + + for _, rule := range profile.AllowRules { + hex := fmt.Sprintf("0x%016x", safetyprofile.HashRule(rule)) + if !bytes.Contains(out, []byte(hex)) { + t.Fatalf("expected allow hash %s for rule %q in output", hex, rule) + } + } + for _, rule := range profile.DenyRules { + hex := fmt.Sprintf("0x%016x", safetyprofile.HashRule(rule)) + if !bytes.Contains(out, []byte(hex)) { + t.Fatalf("expected deny hash %s for rule %q in output", hex, rule) + } + } + + for _, rule := range profile.AllowRules { + if bytes.Contains(out, []byte(fmt.Sprintf("%q", rule))) { + t.Fatalf("rule string %q must not appear in generated output", rule) + } + } + for _, rule := range profile.DenyRules { + if bytes.Contains(out, []byte(fmt.Sprintf("%q", rule))) { + t.Fatalf("rule string %q must not appear in generated output", rule) + } + } +} + +func TestGenerateSanitizesProfileNameInComment(t *testing.T) { + profile := &safetyprofile.Profile{ + Name: "bad\nname\rwith-controls", + AllowAll: true, + DenyRules: []string{}, + } + out := generate(profile) + + if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil { + t.Fatalf("generated code with control chars in name does not parse:\n%s\n\nerror: %v", out, err) + } + if bytes.Contains(out, []byte("\nname\r")) || bytes.Contains(out, []byte("\nname\n")) { + t.Fatalf("comment header leaked control chars from profile name:\n%s", out) + } +} + +func TestGenerateAllowAllEmitsConstantTrue(t *testing.T) { + profile := &safetyprofile.Profile{ + Name: "full", + AllowAll: true, + DenyRules: []string{}, + } + out := string(generate(profile)) + + allowFn := extractFunc(t, out, "bakedSafetyAllowMatch") + if !strings.Contains(allowFn, "return true") || strings.Contains(allowFn, "switch ") { + t.Fatalf("AllowAll allow matcher should be `return true` only, got:\n%s", allowFn) + } + + denyFn := extractFunc(t, out, "bakedSafetyDenyMatch") + if !strings.Contains(denyFn, "return false") { + t.Fatalf("empty deny matcher should `return false`, got:\n%s", denyFn) + } +} + +func TestGenerateEmptyAllowEmitsConstantFalse(t *testing.T) { + profile := &safetyprofile.Profile{ + Name: "deny-only", + AllowAll: false, + DenyRules: []string{"gmail.send"}, + } + out := string(generate(profile)) + + if !strings.Contains(out, "func bakedSafetyHasAllowRules() bool { return false }") { + t.Fatalf("expected hasAllowRules=false for deny-only profile, got:\n%s", out) + } + allowFn := extractFunc(t, out, "bakedSafetyAllowMatch") + if !strings.Contains(allowFn, "return false") || strings.Contains(allowFn, "switch ") { + t.Fatalf("empty allow matcher should be `return false` only, got:\n%s", allowFn) + } +} + +func extractFunc(t *testing.T, src, name string) string { + t.Helper() + start := strings.Index(src, "func "+name+"(") + if start < 0 { + t.Fatalf("function %s not found in:\n%s", name, src) + } + depth := 0 + for i := start; i < len(src); i++ { + switch src[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return src[start : i+1] + } + } + } + t.Fatalf("function %s has unbalanced braces", name) + return "" +} diff --git a/docs/safety-profiles.md b/docs/safety-profiles.md index 2034e11..71d8711 100644 --- a/docs/safety-profiles.md +++ b/docs/safety-profiles.md @@ -67,6 +67,21 @@ bin/gog-readonly --enable-commands gmail.send gmail send \ The command still fails because the baked policy is checked before runtime allowlists. +## Tamper Resistance + +The generator emits the allow and deny rule sets as `switch` statements on the +FNV-64a hash of each dotted command path, not as raw YAML. The compiled rule +table never contains the rule strings themselves, so to re-enable a blocked +command an attacker has to patch compiled machine code rather than flip ASCII +bytes in a YAML blob; the cost goes from a one-line `sed` invocation to +disassembly-level work. + +Note that command names may still appear in the binary from unrelated metadata +(API URLs, error message format strings, Kong help text). What this hardening +guarantees is that the rule set itself is no longer a contiguous, patchable +string. The profile name (e.g. `agent-safe`) is also embedded as a constant so +error messages can reference it. + ## Preset Profiles `safety-profiles/agent-safe.yaml` diff --git a/internal/cmd/safety_profile.go b/internal/cmd/safety_profile.go index 55ebcfc..64132bd 100644 --- a/internal/cmd/safety_profile.go +++ b/internal/cmd/safety_profile.go @@ -1,18 +1,27 @@ package cmd import ( - "fmt" + "hash/fnv" "strings" "github.com/alecthomas/kong" - "gopkg.in/yaml.v3" ) type bakedSafetyProfile struct { enabled bool name string - allow map[string]bool - deny map[string]bool +} + +// bakedSafetyHashPath returns the FNV-64a hash of the dotted command path. +// The generated allow/deny matchers switch on these hashes so that rule +// strings never appear in the binary's data section. The build-time +// generator hashes via internal/safetyprofile.HashRule; both call hash/fnv +// over the same input, and TestSafetyProfileHashAgreement asserts they +// produce identical values. +func bakedSafetyHashPath(parts []string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(strings.Join(parts, "."))) + return h.Sum64() } func enforceBakedSafetyProfile(kctx *kong.Context) error { @@ -42,38 +51,36 @@ func bakedSafetyProfileError(path []string, profileName string, included bool) e return usagef("command %q is not included in baked safety profile %q", command, profileName) } +// loadBakedSafetyProfile constructs a profile handle from the package-level +// hooks supplied by either the generated safety_profile_baked_gen.go (for +// safety_profile builds) or safety_profile_default.go (for stock and test +// builds). The error result is retained for compatibility with the upstream +// caller signatures; the profile is validated by cmd/bake-safety-profile at +// build time, so the runtime path cannot fail. +// +//nolint:unparam // error preserved to keep upstream caller signatures unchanged. func loadBakedSafetyProfile() (bakedSafetyProfile, error) { - raw := strings.TrimSpace(bakedSafetyProfileYAML) - if raw == "" { - return bakedSafetyProfile{}, nil - } - profile, err := parseSafetyProfile(raw) - if err != nil { - return bakedSafetyProfile{}, err - } - return *profile, nil -} - -func ValidateSafetyProfile(raw string) error { - _, err := parseSafetyProfile(raw) - return err + return bakedSafetyProfile{ + enabled: bakedSafetyEnabled(), + name: bakedSafetyProfileName(), + }, nil } func (p bakedSafetyProfile) allowsCommandPath(path []string) bool { if !p.enabled || len(path) == 0 { return true } - if commandPathMatches(p.deny, path) { + if bakedSafetyDenyMatch(path) { return false } - if len(p.allow) == 0 { + if !bakedSafetyHasAllowRules() { return true } - return commandPathMatches(p.allow, path) + return bakedSafetyAllowMatch(path) } func (p bakedSafetyProfile) commandPathError(path []string) error { - if commandPathMatches(p.deny, path) { + if bakedSafetyDenyMatch(path) { return bakedSafetyProfileError(path, p.name, true) } return bakedSafetyProfileError(path, p.name, false) @@ -162,100 +169,3 @@ func applySafetyProfileVisibility(root *kong.Node, profile bakedSafetyProfile) f } } } - -func parseSafetyProfile(raw string) (*bakedSafetyProfile, error) { - var root map[string]any - if err := yaml.Unmarshal([]byte(raw), &root); err != nil { - return nil, err - } - - profile := &bakedSafetyProfile{ - enabled: true, - name: "unnamed", - allow: map[string]bool{}, - deny: map[string]bool{}, - } - - if name, ok := root["name"].(string); ok && strings.TrimSpace(name) != "" { - profile.name = strings.TrimSpace(name) - } - if err := addSafetyProfileList(profile.allow, root["allow"]); err != nil { - return nil, fmt.Errorf("allow: %w", err) - } - if err := addSafetyProfileList(profile.deny, root["deny"]); err != nil { - return nil, fmt.Errorf("deny: %w", err) - } - - for key, value := range root { - switch key { - case "name", "description", "allow", "deny": - continue - } - prefix := []string{key} - if key == "aliases" { - prefix = nil - } - if err := flattenSafetyProfileNode(profile, prefix, value); err != nil { - return nil, err - } - } - - if len(profile.allow) == 0 && len(profile.deny) == 0 { - return nil, fmt.Errorf("profile has no allow or deny entries") - } - return profile, nil -} - -func addSafetyProfileList(out map[string]bool, value any) error { - if value == nil { - return nil - } - items, ok := value.([]any) - if !ok { - return fmt.Errorf("expected list") - } - for _, item := range items { - s, ok := item.(string) - if !ok { - return fmt.Errorf("expected string item") - } - rule := normalizeSafetyProfileRule(s) - if rule != "" { - out[rule] = true - } - } - return nil -} - -func flattenSafetyProfileNode(profile *bakedSafetyProfile, prefix []string, value any) error { - switch typed := value.(type) { - case bool: - rule := normalizeSafetyProfileRule(strings.Join(prefix, ".")) - if rule == "" { - return fmt.Errorf("empty safety profile command path") - } - if typed { - profile.allow[rule] = true - } else { - profile.deny[rule] = true - } - return nil - case map[string]any: - for key, child := range typed { - next := append(append([]string{}, prefix...), key) - if err := flattenSafetyProfileNode(profile, next, child); err != nil { - return err - } - } - return nil - default: - return fmt.Errorf("unsupported safety profile value at %q", strings.Join(prefix, ".")) - } -} - -func normalizeSafetyProfileRule(rule string) string { - rule = strings.TrimSpace(strings.ToLower(rule)) - rule = strings.ReplaceAll(rule, " ", ".") - rule = strings.Trim(rule, ".") - return rule -} diff --git a/internal/cmd/safety_profile_default.go b/internal/cmd/safety_profile_default.go index efec126..5cf14ef 100644 --- a/internal/cmd/safety_profile_default.go +++ b/internal/cmd/safety_profile_default.go @@ -2,4 +2,33 @@ package cmd -var bakedSafetyProfileYAML = "" +// bakedSafetyTestProfile is the test-only override that backs the +// bakedSafety* package-level functions in non-safety builds. Production +// safety_profile builds compile safety_profile_baked_gen.go instead, which +// resolves these functions to a generated hash switch and never reads this +// variable. Tests in this package mutate the struct via withBakedSafetyProfile +// to set up scenarios; stock binaries leave it zeroed and the profile reports +// disabled. +var bakedSafetyTestProfile struct { + enabled bool + name string + hasAllowRules bool + allowAll bool + allow map[string]bool + deny map[string]bool +} + +func bakedSafetyEnabled() bool { return bakedSafetyTestProfile.enabled } +func bakedSafetyProfileName() string { return bakedSafetyTestProfile.name } +func bakedSafetyHasAllowRules() bool { return bakedSafetyTestProfile.hasAllowRules } + +func bakedSafetyAllowMatch(path []string) bool { + if bakedSafetyTestProfile.allowAll { + return true + } + return commandPathMatches(bakedSafetyTestProfile.allow, path) +} + +func bakedSafetyDenyMatch(path []string) bool { + return commandPathMatches(bakedSafetyTestProfile.deny, path) +} diff --git a/internal/cmd/safety_profile_test.go b/internal/cmd/safety_profile_test.go index 39d4937..0f49bfc 100644 --- a/internal/cmd/safety_profile_test.go +++ b/internal/cmd/safety_profile_test.go @@ -1,3 +1,5 @@ +//go:build !safety_profile + package cmd import ( @@ -5,39 +7,51 @@ import ( "path/filepath" "strings" "testing" + + "github.com/steipete/gogcli/internal/safetyprofile" ) func withBakedSafetyProfile(t *testing.T, raw string) { t.Helper() - prev := bakedSafetyProfileYAML - bakedSafetyProfileYAML = raw - t.Cleanup(func() { bakedSafetyProfileYAML = prev }) + profile, err := safetyprofile.Parse(raw) + if err != nil { + t.Fatalf("safetyprofile.Parse: %v", err) + } + allow := make(map[string]bool, len(profile.AllowRules)) + for _, r := range profile.AllowRules { + allow[r] = true + } + deny := make(map[string]bool, len(profile.DenyRules)) + for _, r := range profile.DenyRules { + deny[r] = true + } + prev := bakedSafetyTestProfile + bakedSafetyTestProfile.enabled = true + bakedSafetyTestProfile.name = profile.Name + bakedSafetyTestProfile.allowAll = profile.AllowAll + bakedSafetyTestProfile.hasAllowRules = profile.AllowAll || len(profile.AllowRules) > 0 + bakedSafetyTestProfile.allow = allow + bakedSafetyTestProfile.deny = deny + t.Cleanup(func() { bakedSafetyTestProfile = prev }) } -func TestParseSafetyProfileNestedAndAliases(t *testing.T) { - profile, err := parseSafetyProfile(` -name: test -gmail: - search: true - send: false -aliases: - send: false -allow: - - version -deny: - - auth.remove -`) - if err != nil { - t.Fatalf("parseSafetyProfile: %v", err) +func TestSafetyProfileHashAgreement(t *testing.T) { + cases := []struct { + path []string + rule string + }{ + {[]string{"version"}, "version"}, + {[]string{"gmail", "send"}, "gmail.send"}, + {[]string{"gmail", "drafts", "send"}, "gmail.drafts.send"}, + {[]string{"gmail", "drafts", "create"}, "gmail.drafts.create"}, + {[]string{"calendar", "alias", "set"}, "calendar.alias.set"}, + {[]string{"a"}, "a"}, } - for _, rule := range []string{"gmail.search", "version"} { - if !profile.allow[rule] { - t.Fatalf("expected allow rule %q in %#v", rule, profile.allow) - } - } - for _, rule := range []string{"gmail.send", "send", "auth.remove"} { - if !profile.deny[rule] { - t.Fatalf("expected deny rule %q in %#v", rule, profile.deny) + for _, c := range cases { + runtime := bakedSafetyHashPath(c.path) + codegen := safetyprofile.HashRule(c.rule) + if runtime != codegen { + t.Fatalf("hash mismatch for %v / %q: runtime=%#x codegen=%#x", c.path, c.rule, runtime, codegen) } } } diff --git a/internal/safetyprofile/hash.go b/internal/safetyprofile/hash.go new file mode 100644 index 0000000..bc7bc71 --- /dev/null +++ b/internal/safetyprofile/hash.go @@ -0,0 +1,13 @@ +package safetyprofile + +import "hash/fnv" + +// HashRule returns the FNV-64a hash of a dotted-path safety rule +// (e.g. "gmail.send"). The build-time generator and the runtime matcher both +// hash with this algorithm so that rule strings never appear in the binary's +// data section. +func HashRule(rule string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(rule)) + return h.Sum64() +} diff --git a/internal/safetyprofile/parse.go b/internal/safetyprofile/parse.go new file mode 100644 index 0000000..101c760 --- /dev/null +++ b/internal/safetyprofile/parse.go @@ -0,0 +1,156 @@ +package safetyprofile + +import ( + "fmt" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +const literalAll = "all" + +// Parse validates the YAML form of a safety profile and returns the Profile +// the generator emits as code. Allow and deny lists are sorted and +// deduplicated; "all" / "*" entries on the allow side collapse into the +// AllowAll flag. Wildcards on the deny side or as a parent of a nested rule +// are rejected because they would be silent no-ops in the hashed runtime +// switch. +func Parse(raw string) (*Profile, error) { + parsed, err := parseRaw(raw) + if err != nil { + return nil, err + } + out := &Profile{ + Name: parsed.name, + AllowAll: parsed.allow[literalAll] || parsed.allow["*"], + } + for k := range parsed.allow { + if k == literalAll || k == "*" { + continue + } + out.AllowRules = append(out.AllowRules, k) + } + for k := range parsed.deny { + out.DenyRules = append(out.DenyRules, k) + } + sort.Strings(out.AllowRules) + sort.Strings(out.DenyRules) + return out, nil +} + +type rawProfile struct { + name string + allow map[string]bool + deny map[string]bool +} + +func parseRaw(raw string) (*rawProfile, error) { + var root map[string]any + if err := yaml.Unmarshal([]byte(raw), &root); err != nil { + return nil, err + } + + profile := &rawProfile{ + name: "unnamed", + allow: map[string]bool{}, + deny: map[string]bool{}, + } + + if rawName, present := root["name"]; present { + name, ok := rawName.(string) + if !ok || strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("name: expected non-empty string, got %T", rawName) + } + profile.name = strings.TrimSpace(name) + } + if err := addList(profile.allow, root["allow"]); err != nil { + return nil, fmt.Errorf("allow: %w", err) + } + if err := addList(profile.deny, root["deny"]); err != nil { + return nil, fmt.Errorf("deny: %w", err) + } + + for key, value := range root { + switch key { + case "name", "description", "allow", "deny": + continue + } + prefix := []string{key} + if key == "aliases" { + prefix = nil + } + if err := flatten(profile, prefix, value); err != nil { + return nil, err + } + } + + if profile.deny[literalAll] || profile.deny["*"] { + return nil, fmt.Errorf("deny: wildcards %q and %q are not allowed; list specific commands or remove the entry", literalAll, "*") + } + + if len(profile.allow) == 0 && len(profile.deny) == 0 { + return nil, fmt.Errorf("profile has no allow or deny entries") + } + return profile, nil +} + +func addList(out map[string]bool, value any) error { + if value == nil { + return nil + } + items, ok := value.([]any) + if !ok { + return fmt.Errorf("expected list") + } + for _, item := range items { + s, ok := item.(string) + if !ok { + return fmt.Errorf("expected string item") + } + rule := normalize(s) + if rule != "" { + out[rule] = true + } + } + return nil +} + +func flatten(profile *rawProfile, prefix []string, value any) error { + switch typed := value.(type) { + case bool: + rule := normalize(strings.Join(prefix, ".")) + if rule == "" { + return fmt.Errorf("empty safety profile command path") + } + if typed { + profile.allow[rule] = true + } else { + profile.deny[rule] = true + } + return nil + case map[string]any: + if len(prefix) > 0 { + root := normalize(prefix[0]) + if root == literalAll || root == "*" { + return fmt.Errorf("safety profile rules cannot be nested under wildcard %q at %q; list specific commands", prefix[0], strings.Join(prefix, ".")) + } + } + for key, child := range typed { + next := append(append([]string{}, prefix...), key) + if err := flatten(profile, next, child); err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("unsupported safety profile value at %q", strings.Join(prefix, ".")) + } +} + +func normalize(rule string) string { + rule = strings.TrimSpace(strings.ToLower(rule)) + rule = strings.ReplaceAll(rule, " ", ".") + rule = strings.Trim(rule, ".") + return rule +} diff --git a/internal/safetyprofile/parse_test.go b/internal/safetyprofile/parse_test.go new file mode 100644 index 0000000..6bada81 --- /dev/null +++ b/internal/safetyprofile/parse_test.go @@ -0,0 +1,74 @@ +package safetyprofile + +import ( + "strings" + "testing" +) + +func TestParseRejectsDenyWildcards(t *testing.T) { + for _, raw := range []string{ + "name: x\nallow:\n - version\ndeny:\n - all\n", + "name: x\nallow:\n - version\ndeny:\n - \"*\"\n", + "name: x\nallow:\n - version\nall: false\n", + "name: x\nallow:\n - version\n\"*\": false\n", + "name: x\nallow:\n - version\naliases:\n all: false\n", + } { + _, err := Parse(raw) + if err == nil { + t.Fatalf("expected error parsing deny wildcard, got nil for: %s", raw) + } + if !strings.Contains(err.Error(), "wildcards") { + t.Fatalf("unexpected error: %v", err) + } + } +} + +func TestParseRejectsWildcardPrefix(t *testing.T) { + for _, raw := range []string{ + "name: x\nallow:\n - version\nall:\n gmail: false\n", + "name: x\nallow:\n - version\n\"*\":\n gmail: false\n", + } { + _, err := Parse(raw) + if err == nil { + t.Fatalf("expected error parsing wildcard prefix, got nil for: %s", raw) + } + if !strings.Contains(err.Error(), "wildcard") { + t.Fatalf("unexpected error: %v", err) + } + } +} + +func TestParseRejectsNonStringName(t *testing.T) { + for _, raw := range []string{ + "name: 42\nallow:\n - version\n", + "name: true\nallow:\n - version\n", + "name: \"\"\nallow:\n - version\n", + } { + _, err := Parse(raw) + if err == nil { + t.Fatalf("expected error for invalid name, got nil for: %s", raw) + } + if !strings.Contains(err.Error(), "name:") { + t.Fatalf("unexpected error: %v", err) + } + } +} + +func TestHashRuleKnownValues(t *testing.T) { + cases := []struct { + rule string + want uint64 + }{ + {"version", HashRule("version")}, + {"gmail.send", HashRule("gmail.send")}, + } + for _, c := range cases { + got := HashRule(c.rule) + if got != c.want { + t.Fatalf("HashRule(%q) = %#x, want %#x", c.rule, got, c.want) + } + } + if HashRule("a") == HashRule("b") { + t.Fatalf("expected distinct hashes for distinct rules") + } +} diff --git a/internal/safetyprofile/profile.go b/internal/safetyprofile/profile.go new file mode 100644 index 0000000..f8b7702 --- /dev/null +++ b/internal/safetyprofile/profile.go @@ -0,0 +1,17 @@ +// Package safetyprofile parses and hashes baked safety profile YAML for the +// build-time generator (cmd/bake-safety-profile). It is intentionally outside +// internal/cmd so the runtime CLI binary does not pull in the parser or its +// dependencies. +package safetyprofile + +// Profile is the build-time intermediate representation that the generator +// consumes to emit the hash-based allow and deny switches. Rules are sorted +// and deduplicated; AllowAll captures the "all" / "*" wildcard so the +// generator can emit a constant-true matcher instead of enumerating every +// command. +type Profile struct { + Name string + AllowAll bool + AllowRules []string + DenyRules []string +}