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 (196 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
  • 20 tests: AST discovery engine (12) + fail-closed security contract (6) + end-to-end build (2)
  • Contributor docs at docs/safety-profiles.md

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

Commits

  1. feat: compile-time safety profiles with AST-based generator -- Core feature: types file split, generator, build script, profiles, docs
  2. feat(safety): add sedmat and contacts.other to safety profiles -- Sync with upstream (sedmat, contacts.other commands)
  3. feat(safety): add gmail organize commands and sheets links to safety profiles -- Sync with upstream (gmail archive/mark-read/unread/trash, sheets links)
  4. chore(safety): remove migration tool, add docs and tests -- Remove one-time migration artifact, add contributor docs, add end-to-end build tests, harden test isolation

Test plan

  • Stock go build ./cmd/gog/ produces identical binary (no safety_profile tag)
  • go vet ./... clean
  • go test ./... (all existing tests pass)
  • go test ./cmd/gen-safety/... (20 tests pass)
  • End-to-end test generates + compiles with both full.yaml and readonly.yaml
  • Safe binary's --help omits disabled commands
  • Safe binary returns error when disabled command is attempted
  • Missing YAML keys are excluded (fail-closed), with stderr warnings
  • --strict mode makes warnings fatal
  • Safe binary uses separate config directory (gogcli-safe/ not gogcli/)
  • All preset profiles build with --strict:
    • ./build-safe.sh safety-profiles/full.yaml
    • ./build-safe.sh safety-profiles/readonly.yaml
    • ./build-safe.sh safety-profiles/agent-safe.yaml

@salmonumbrella
Copy link
Contributor

salmonumbrella commented Feb 25, 2026

Brilliant! @steipete @visionik

@BrennerSpear
Copy link

BrennerSpear commented Mar 6, 2026

Hey! We've been using this branch in production — built a safety profile skill around it that makes it easy to pick a level (L1 draft-only, L2 collaborate, L3 full write) and build/deploy a profiled binary.

Here's the skill: https://clawhub.com/skills/gog-safety

It's working great as-is, but it would be awesome to get this merged into main so we're not building off a branch. Happy to help with any cleanup needed to get it across the line.

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.

refactor(safety): replace manual registry with AST auto-discovery

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.

fix(safety): address review findings from quality gate

- 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

feat(safety): isolate config directory for safety profile builds

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.
fix(safety): enforce --strict in build-safe.sh

Ensures YAML typos in safety profiles cause build failures instead of
silent warnings. Without --strict, a mistyped key like 'gmal' instead
of 'gmail' would silently exclude the service (fail-closed is safe but
violates user intent). All three preset profiles already pass --strict
with zero warnings.
…profiles

New upstream commands:
- gmail: archive, mark-read, unread, trash (convenience organize commands)
- sheets: links/hyperlinks (read cell hyperlinks)

Profile decisions:
- full: all enabled
- readonly: mark-read/unread enabled, archive/trash disabled
- agent-safe: archive/mark-read/unread enabled, trash disabled
- example: archive/mark-read/unread enabled, trash disabled

fix(safety): readonly profile - mark-read and unread are write ops

Both commands modify message state via Gmail API (add/remove UNREAD label).
Setting them true violated the readonly profile's stated contract of
no writes. All four gmail organize commands now correctly disabled:
archive, mark-read, unread, trash all false in readonly.

fix(safety): harden gen-safety and build-safe.sh robustness

- build-safe.sh: anchor to repo root via cd $(dirname $0) so relative
  paths work correctly when script is invoked from outside repo root

- discover.go: buildSpecsForStruct now fatal() instead of warn() when
  a struct is not found; warn() was semantically wrong since missing
  struct guarantees a compile failure anyway - better to fail loud early

- main.go: fatal on empty/null YAML profile instead of silently
  disabling all services with no warnings (empty profile now gives
  clear actionable error message)

- main.go: generateCLIFile now skips services where all leaf commands
  are false, matching the stated comment and preventing ghost empty
  top-level commands in the CLI struct
- Remove cmd/extract-types/ (one-time migration artifact, not ongoing tooling)
- Add docs/safety-profiles.md explaining the *_types.go convention,
  YAML format, build process, and contributor workflow
- Unify utility-command exception lists: buildKnownKeys now derives
  tolerated YAML keys from utilityTypes via buildCLIFields, removing
  the manually maintained config/time hardcoded entries
- Add TestEndToEndSafeBuild: generates from full.yaml with --strict
  then compiles with -tags safety_profile
- Add TestValidateYAMLKeys, TestBuildEmptyStruct,
  TestBuildEmptyStructWithNonCmdPrefix
@drewburchfield
Copy link
Author

drewburchfield commented Mar 6, 2026

Thanks @BrennerSpear! Great to hear you're running this in production. Custom profiles for different/custom use cases is exactly what this was designed for.

I just force-pushed a cleaned up version (4 squashed commits, rebased on current main). @salmonumbrella @steipete @visionik happy to address any feedback whenever you get a chance to look.

drewburchfield added a commit to drewburchfield/gogcli-safe that referenced this pull request Mar 6, 2026
Rebrand header as gogcli-safe, add "Why this fork?" section explaining
compile-time safety profiles for AI agent use. Links to upstream PR steipete#366.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants