How embedding JSON schemas, YAML controls, Go templates, and a pack registry index into the binary eliminates external file dependencies — critical for air-gapped deployment and deterministic evaluation.
A security CLI that depends on external schema files to validate input is a CLI that breaks when deployed to a Docker container, a CI runner, or an air-gapped server where those files don't exist.
go:embed solves this by compiling files into the binary at build time. The binary carries its own schemas, controls, templates, and registry index. No installation step. No file path configuration. No "schema file not found" errors in production.
Here are four ways to use, each solving a different embed problem.
1. JSON Schemas — Validation Without a Registry
//go:embed embedded/*/*/*.json
var embeddedFS embed.FS
The directory structure:
internal/contracts/schema/embedded/
├── control/v1/control.schema.json
├── diagnose/v1/diagnose.schema.json
└── output/v1/output.schema.json
The validator loads schemas from the embedded filesystem:
func (v *Validator) getSchema(kind Kind, version string) (*jsonschema.Schema, error) {
v.mu.RLock()
if cached, ok := v.compiled[cacheKey]; ok {
v.mu.RUnlock()
return cached, nil
}
v.mu.RUnlock()
// Load from embedded FS — no disk access
data, err := embeddedFS.ReadFile(schemaPath(kind, version))
if err != nil {
return nil, fmt.Errorf("load schema %s/%s: %w", kind, version, err)
}
v.mu.Lock()
defer v.mu.Unlock()
// ... compile and cache
}
Why embed, not bundle separately: The schema version must match the binary version. If schemas are external files, a user could upgrade the binary but keep old schemas — leading to validation that passes when it shouldn't or fails when it should pass. Embedding guarantees schema-binary consistency.
2. YAML Controls — Built-In Security Policies
//go:embed embedded/s3/**/*.yaml
var controlFS embed.FS
43 S3 security controls embedded in the binary:
internal/controldata/embedded/s3/
├── access/
│ ├── CTL.S3.PUBLIC.001.yaml
│ ├── CTL.S3.ACL.001.yaml
│ └── ...
├── encryption/
│ ├── CTL.S3.ENC.001.yaml
│ └── ...
└── governance/
└── ...
The loader accepts any fs.FS — embedded for production, fstest.MapFS for tests:
type BuiltinLoader struct {
fsys fs.FS
mu sync.RWMutex
once sync.Once
controls []policy.ControlDefinition
}
// Production: uses embedded FS
func EmbeddedFS() fs.FS { return controlFS }
// Tests: use any fs.FS
func NewBuiltinLoader(fsys fs.FS) *BuiltinLoader {
return &BuiltinLoader{fsys: fsys}
}
Why embed controls: Users can run stave apply --controls controls/s3 to use the built-in controls without copying YAML files to their project. The controls ship with the binary. For custom controls, users point --controls at their own directory — the built-in ones are a starting point, not a lock-in.
3. Go Templates — Customizable Output
//go:embed templates/prompt_default.tmpl
var DefaultTemplate string
The LLM prompt template is embedded as a string (not embed.FS — it's a single file):
func RenderPrompt(data Data, tmpl string) (string, error) {
return ui.ExecuteTemplate(tmpl, data)
}
For the default template, tmpl is DefaultTemplate (the embedded string). For custom templates, the user provides a path:
# Default embedded template
stave prompt from-finding --evaluation-file eval.json
# Custom template from disk
stave prompt from-finding --evaluation-file eval.json --prompt-template ./my-template.tmpl
Why string, not embed.FS: A single template doesn't need a filesystem abstraction. string is simpler — it can be passed directly to template.Parse(). The embed.FS pattern is for collections of files.
4. Pack Registry — Index of Built-In Control Packs
//go:embed embedded/index.yaml
var embeddedRegistryFS embed.FS
The registry index maps pack names to control IDs:
# embedded/index.yaml
packs:
s3:
description: "AWS S3 bucket security controls"
controls:
- CTL.S3.PUBLIC.001
- CTL.S3.ACL.001
- CTL.S3.ENC.001
# ... 43 controls
The registry validates its own integrity at startup:
func (r *Index) ValidateStrict(fsys embed.FS) error {
// Verify every control referenced in index.yaml exists as a YAML file
// Verify every YAML file in the embedded FS is referenced in index.yaml
// No orphans. No phantom references.
}
This catches build-time mistakes: adding a control YAML file but forgetting to register it in the index, or removing a file but leaving the index entry.
The Build Pipeline
sync-schemas:
@mkdir -p $(SCHEMA_DST)
rm -rf $(SCHEMA_DST)/*
cp -R $(SCHEMA_SRC)/* $(SCHEMA_DST)/
build: sync-schemas
go build -o stave ./cmd/stave
Schemas are synced from a canonical source directory into the embed directory before building. The canonical schemas are human-editable; the embedded copies are build artifacts. This means go build alone won't work after a fresh clone — make build is required (documented in CLAUDE.md).
When NOT to Embed
- User-authored files (observations, custom controls): these change per project — don't embed
- Large binary assets (images, videos): bloats the binary unnecessarily
- Files that change between deployments (config, secrets): must be external
- Files > 10MB: Go's embed has no compression — large files inflate the binary
Embed when the file is part of the tool's identity (schemas, built-in policies, default templates) and must be available in every deployment environment including air-gapped.
These 4 embed patterns are used in Stave, a Go CLI for offline security evaluation. The embedded schemas, controls, templates, and pack registry ensure the binary is self-contained — deployable to air-gapped environments with no external file dependencies.
Top comments (0)