fix(safety): compile baked policy to code to resist binary tampering

Compile baked safety-profile policies into generated hash switches so the raw allow/deny rule strings are no longer embedded as a patchable YAML blob.

Verification before merge:
- `go test ./cmd/bake-safety-profile ./internal/safetyprofile ./internal/cmd`
- `make lint`
- `./build-safe.sh safety-profiles/agent-safe.yaml -o bin/gog-agent-safe-review`
- `./build-safe.sh safety-profiles/readonly.yaml -o bin/gog-readonly-review`
- runtime block checks for agent-safe and readonly baked binaries

Co-authored-by: drewburchfield <drewburchfield@gmail.com>
This commit is contained in:
Drew Burchfield 2026-05-03 23:55:05 -05:00 committed by GitHub
parent 6fd874075e
commit 46900109e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 606 additions and 160 deletions

View File

@ -120,6 +120,11 @@ linters:
- dupl
- wsl_v5
- tagliatelle
- path: internal/safetyprofile/.*\.go
linters:
- err113
- wrapcheck
- wsl_v5
- path: internal/googleauth/.*\.go
linters:
- tagliatelle

View File

@ -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 <profile.yaml> <output.go>` + "\n"
func main() {
if len(os.Args) != 3 {
_, _ = fmt.Fprintln(os.Stderr, "usage: bake-safety-profile <profile.yaml> <output.go>")
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")
}

View File

@ -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 ""
}

View File

@ -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`

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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
}