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:
parent
6fd874075e
commit
46900109e0
@ -120,6 +120,11 @@ linters:
|
||||
- dupl
|
||||
- wsl_v5
|
||||
- tagliatelle
|
||||
- path: internal/safetyprofile/.*\.go
|
||||
linters:
|
||||
- err113
|
||||
- wrapcheck
|
||||
- wsl_v5
|
||||
- path: internal/googleauth/.*\.go
|
||||
linters:
|
||||
- tagliatelle
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
138
cmd/bake-safety-profile/main_test.go
Normal file
138
cmd/bake-safety-profile/main_test.go
Normal 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 ""
|
||||
}
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
internal/safetyprofile/hash.go
Normal file
13
internal/safetyprofile/hash.go
Normal 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()
|
||||
}
|
||||
156
internal/safetyprofile/parse.go
Normal file
156
internal/safetyprofile/parse.go
Normal 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
|
||||
}
|
||||
74
internal/safetyprofile/parse_test.go
Normal file
74
internal/safetyprofile/parse_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
17
internal/safetyprofile/profile.go
Normal file
17
internal/safetyprofile/profile.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user