Skip to content

feat: compile-time safety profiles for command removal#366

Open
drewburchfield wants to merge 4 commits intosteipete:mainfrom
drewburchfield:feat/safety-profiles
Open

feat: compile-time safety profiles for command removal#366
drewburchfield wants to merge 4 commits intosteipete:mainfrom
drewburchfield:feat/safety-profiles

Conversation

@drewburchfield
Copy link

@drewburchfield drewburchfield commented Feb 24, 2026

Summary

Adds compile-time safety profiles, a system that physically removes CLI commands from the gog binary so that AI agents (or other untrusted callers) cannot invoke them regardless of flags, environment variables, or config file changes.

  • YAML config file defines which commands are included per service (170+ commands mapped)
  • Code generator produces trimmed Kong parent structs with //go:build safety_profile
  • AST auto-discovery: the generator parses *_types.go source files via go/ast to find all commands automatically, so no manual registry updates are needed when upstream adds new commands
  • Stock go build is completely unchanged (zero risk to existing users/CI/Homebrew)
  • Three preset profiles: full.yaml, readonly.yaml, agent-safe.yaml
  • Fail-closed defaults: commands not listed in the YAML are excluded from the build
  • Build summary output shows exactly what's enabled/disabled per service
  • Config isolation: safety profile builds use a separate config directory (gogcli-safe/ instead of gogcli/), so credentials are not shared between stock gog and a profiled binary
  • 16 new tests: AST discovery engine (10) + fail-closed security contract (6)
  • Bonus: fixes a nil-pointer bug in contacts_crud.go field updates

Why this matters

AI agents with shell access need Google Workspace CLI tools but shouldn't have full write/delete access. The Summer Yue incident (Meta AI safety researcher's inbox wiped by an agent) is the cautionary tale. As more people give agents CLI access via gog, compile-time command removal becomes essential.

The Gmail draft problem that OAuth scopes can't solve

This directly addresses #239 (restrict Gmail to creating drafts, never send). Google's OAuth scopes have a gap:

  • gmail.readonly blocks ALL writes, including drafts
  • gmail.compose allows drafts AND sending
  • There is no gmail.drafts scope

The only way to allow gog gmail drafts create while blocking gog gmail send and gog gmail drafts send is to remove those commands from the binary. OAuth scope restriction alone cannot do this.

How it works

Two versions of each parent struct exist:

  • *_types.go with //go:build !safety_profile -- full struct (normal build)
  • *_cmd_gen.go with //go:build safety_profile -- trimmed struct (generated from YAML profile)
# safety-profile.yaml
gmail:
  search: true       # [safe] Read-only
  send: false        # [high] Removed from binary
  drafts:
    create: true     # [low] Allowed
    send: false      # [high] Removed from binary
./build-safe.sh safety-profile.example.yaml              # Uses a custom profile
./build-safe.sh safety-profiles/readonly.yaml             # Uses a preset
./build-safe.sh safety-profiles/agent-safe.yaml -o /usr/local/bin/gog-safe

Kong registers commands by walking struct fields with cmd:"" tags. The code generator parses all *_types.go source files via Go's AST package to auto-discover commands and their hierarchy, then produces *_cmd_gen.go files with //go:build safety_profile that contain trimmed parent structs. The original struct definitions live in *_types.go files with //go:build !safety_profile. Implementation code is shared and unchanged.

This means no manual updates are needed when upstream adds new commands. The fail-closed default ensures new commands are excluded until explicitly enabled.

Related issues

Test plan

  • Stock go build ./cmd/gog/ produces identical binary (no safety_profile tag)
  • go test ./... (all existing tests pass, 16 packages)
  • go test ./cmd/gen-safety/ (16 tests: 10 AST discovery + 6 fail-closed contract)
  • go run ./cmd/gen-safety safety-profile.example.yaml generates correct files with build summary
  • go build -tags safety_profile ./cmd/gog/ compiles successfully
  • Safe binary's --help omits disabled commands
  • Safe binary returns error when disabled command is attempted
  • gmail: false disables entire service (empty struct, builds clean)
  • keep: false compiles correctly (non-cmd fields preserved in empty struct)
  • Missing YAML keys are excluded (fail-closed), with stderr warnings
  • String values like send: "false" cause fatal error (not silent enable)
  • Safe binary uses separate config directory (gogcli-safe/ not gogcli/)
  • All preset profiles build with --strict (0 warnings):
    • go run ./cmd/gen-safety --strict safety-profiles/full.yaml && go build -tags safety_profile ./cmd/gog/
    • go run ./cmd/gen-safety --strict safety-profiles/readonly.yaml && go build -tags safety_profile ./cmd/gog/
    • go run ./cmd/gen-safety --strict safety-profiles/agent-safe.yaml && go build -tags safety_profile ./cmd/gog/
@salmonumbrella
Copy link
Contributor

salmonumbrella commented Feb 25, 2026

Brilliant! @steipete @visionik

Add a build-time code generation system that produces restricted CLI
binaries from a YAML configuration. Disabled commands are removed at
compile time via Go build tags, so they cannot be invoked at all.

How it works:
- Parent struct definitions extracted to *_types.go (build tag !safety_profile)
- cmd/gen-safety reads a YAML profile and generates *_cmd_gen.go files
  with build tag safety_profile, containing only the enabled commands
- Building with -tags safety_profile uses the generated structs
- Stock "go build" is completely unchanged

Key design decisions:
- Fail-closed: commands not listed in YAML are excluded by default
- Each service section can be toggled with enabled: true/false
- Individual subcommands can be selectively included or excluded
- Utility commands (version, auth, config, completion) always included

Includes:
- cmd/gen-safety: code generator with YAML validation and --strict mode
- cmd/extract-types: one-time tool for extracting parent structs
- build-safe.sh: convenience script (generate + compile)
- Preset profiles: full.yaml, readonly.yaml, agent-safe.yaml
- Example profile: safety-profile.yaml

Also fixes contacts_crud.go parameter type grouping (given/org were
bool instead of string), which is an existing upstream bug.
Replace the ~655-line hand-maintained command registry in gen-safety
with automatic discovery via Go's AST package. The generator now
parses *_types.go source files directly to find all Cmd structs and
their hierarchy, eliminating manual sync when upstream adds commands.

What changed:
- New discover.go: parses *_types.go files, walks from CLI struct down
  to build serviceSpec list and CLI field categorization automatically
- New discover_test.go: 10 tests covering tag parsing, file parsing,
  multi-struct files, NonCmdPrefix, field categorization, and more
- main.go: wired to call AST discovery instead of manual registry;
  ~655 lines of spec functions deleted
- Rename safety-profile.yaml to safety-profile.example.yaml (users
  should copy and customize, not edit the example directly)
- Updated Makefile, build-safe.sh, README.md for the rename

The generated output is identical (verified by diffing all *_cmd_gen.go
files before and after). Net change: ~400 fewer lines of code and no
more manual updates when upstream adds commands.
- buildEmptyStruct: preserve non-command fields (e.g. KeepCmd's
  ServiceAccount/Impersonate) when service is fully disabled
- mapHasEnabledLeaf: fatal on unexpected YAML types instead of
  silently ignoring (matches isEnabled behavior)
- isServiceDisabled: warn on unexpected types before fail-closed
- Remove misleading `open` key from utility section (it's an alias,
  not a utility, and is controllable via aliases.open)
- Add main_test.go with tests for fail-closed security contract:
  isEnabled, filterFields, isServiceDisabled, resolveEnabledFields,
  mapHasEnabledLeaf (15 total tests now)
- Fix doc comments, remove dead code, fix build-safe.sh version suffix
Safety profile builds now use ~/Library/Application Support/gogcli-safe/
instead of gogcli/, preventing credential sharing between stock gog and
gog-safe binaries. Uses the existing safety_profile build tag.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants