Skip to content

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

Closed
drewburchfield wants to merge 10 commits intoopenclaw:mainfrom
drewburchfield:feat/safety-profiles
Closed

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

Conversation

@drewburchfield
Copy link
Copy Markdown
Contributor

@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 (~225 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
  • 34 tests: AST discovery engine (12) + fail-closed security contract (6) + generated output verification (3) + end-to-end build (3) + types file maintenance (3) + profile update (7)
  • 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
  5. feat(safety): add --verify and --sync modes for types file maintenance -- --verify detects struct drift (for CI), --sync generates types files for new services, merge workflow documented
  6. chore(safety): sync with upstream main and harden generator -- Rebase onto current upstream/main, add admin service types, sync ~30 new command keys across all profiles, harden generator (pre-validate before file writes, warning dedup, EXIT trap, generated output tests)
  7. chore(safety): enable recoverable commands in agent-safe profile -- Enable low-risk commands in agent-safe profile (gmail labels rename, chat react, forms move-question, sheets tab ops, keep create)
  8. feat(safety): add --update-profiles mode for YAML profile maintenance -- Auto-adds missing YAML keys to all profiles with sensible defaults, preserves formatting and comments, includes --dry-run preview mode

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/... (34 tests pass)
  • End-to-end test generates + compiles with full.yaml, readonly.yaml, and agent-safe.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 and aborts before writing files
  • Disabled commands verified absent from generated Go source
  • CLI alias filtering excludes disabled aliases from generated CLI struct
  • Warning deduplication prevents duplicate messages
  • 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
  • go run ./cmd/gen-safety --verify reports clean (no duplicates or missing types)
  • go run ./cmd/gen-safety --sync reports nothing to migrate
  • go run ./cmd/gen-safety --update-profiles --dry-run reports all profiles up to date

@salmonumbrella
Copy link
Copy Markdown
Contributor

salmonumbrella commented Feb 25, 2026

Brilliant! @steipete @visionik

@BrennerSpear
Copy link
Copy Markdown

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.

@drewburchfield
Copy link
Copy Markdown
Contributor 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 openclaw#366.
@drewburchfield
Copy link
Copy Markdown
Contributor Author

Added --verify and --sync modes for ongoing maintenance. --verify detects struct drift (duplicates and missing types files) and is designed for CI. If a contributor adds a new service and forgets the types file, CI catches it. --sync generates the missing types file automatically. Also documented the post-merge workflow in docs/safety-profiles.md.

drewburchfield added a commit to drewburchfield/gogcli-safe that referenced this pull request Mar 11, 2026
Rebrand header as gogcli-safe, add "Why this fork?" section explaining
compile-time safety profiles for AI agent use. Links to upstream PR openclaw#366.
drewburchfield added a commit to drewburchfield/gogcli-safe that referenced this pull request Mar 29, 2026
Rebrand header as gogcli-safe, add "Why this fork?" section explaining
compile-time safety profiles for AI agent use. Links to upstream PR openclaw#366.
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
--verify detects two classes of drift: DUPLICATE structs (in both
types and source files) and MISSING services (new CLI-level types
without a corresponding *_types.go). Intended for CI pipelines.

--sync generates *_types.go files for new services found in source,
skipping files that already exist.

Together they give maintainers a clear workflow after merging upstream:
verify to see what needs attention, sync for new services, then
manually remove duplicates guided by the verify output.
Rebase safety profiles onto latest upstream/main (168 new commits).
Update all types files and YAML profiles to match current upstream
command set.

Changes:
- Add admin_types.go for new Admin Directory service
- Add ~30 new command keys to all YAML profiles (gmail autoreply,
  calendar subscribe/alias, chat reactions, forms questions/watch,
  keep create/delete, sheets merge/freeze/tabs/named-ranges,
  slides create-from-template)
- Pre-validate warnings before writing files (--strict aborts early,
  no stale generated files left on disk)
- Deduplicate warnings and add service path context to messages
- Fatal on unexpected YAML types in isServiceDisabled (was warn)
- Add EXIT trap to build-safe.sh (covers ERR + SIGINT/SIGTERM)
- Add tests: disabled command exclusion from output, CLI alias
  filtering, warning deduplication, agent-safe.yaml in E2E build
- E2E test cleanup with t.Cleanup to avoid source tree side effects
- Fix orphaned doc comments, remove redundant Makefile target
- Fix docs: utility command description, merge workflow advice
- Fix gofmt violations
Enable low-risk commands that are recoverable via version history
or inherently non-destructive:
- gmail labels rename (helps with triage organization)
- chat messages react (adding emoji reactions is harmless)
- forms move-question (reordering, non-destructive)
- sheets add-tab, rename-tab, delete-tab (version history)
- keep create (creating notes is low risk)
Scans all YAML profiles for missing command keys and adds them with
profile-appropriate defaults (true for full.yaml, false for others).
Uses yaml.v3 node API to preserve existing comments and formatting.

Usage:
  go run ./cmd/gen-safety --update-profiles            # update all profiles
  go run ./cmd/gen-safety --update-profiles --dry-run  # preview changes

This automates the most tedious part of syncing with upstream: after
new commands are added, running --update-profiles adds the missing
keys to all profiles in one command instead of manual YAML editing.
Rebase safety profiles onto upstream/main (53 new commits up to v0.13.0)
and wire the new commands into the safety profiles.

New upstream commands extracted into _types.go:
- auth credentials remove
- calendar create-calendar
- gmail forward
- gmail labels style
- sheets chart (subtree, single bool gate)
- slides thumbnail

Profile classification per recoverability philosophy (history covers it
= enable, sends to a third party = disable):
- agent-safe: enable style, create-calendar, chart, thumbnail; keep
  forward and credentials.remove disabled (forward is not recoverable;
  agent-safe blocks all auth mutations for parity with other auth flags)
- readonly: enable thumbnail (read-only); disable everything else
- full: enable all
- example: documented with risk levels and disabled by default

Other:
- Restore newGmailService var to gmail.go (matches upstream); remove
  duplicated definition from gmail_types.go
- Drop now-obsolete TestParseTypesFiles assertions for Imports/ExtraCode
  on GmailCmd, since the var lives in gmail.go (always built)
Extract SheetsChartCmd and SheetsNamedRangesCmd into *_types.go so
the generator recognizes their subcommands as YAML keys. Profiles
can now permit read-only chart/named-ranges access without granting
the destructive subcommands.

readonly.yaml now exposes sheets.chart.list/get and
sheets.named-ranges.list/get (previously the entire subtree was
gated on a single boolean and excluded from readonly entirely).

agent-safe.yaml enables all chart subcommands (recoverable via
revision history) and keeps named-ranges writes off (still
conservative about formula reference changes).

Verified by smoke testing each profile:
- readonly: gog sheets chart shows only list/get
- agent-safe: gog sheets chart shows list/get/create/update/delete
- agent-safe and readonly: gog gmail forward absent (unchanged)
drewburchfield added a commit to drewburchfield/gogcli-safe that referenced this pull request Apr 23, 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.
@steipete
Copy link
Copy Markdown
Collaborator

steipete commented May 4, 2026

Thanks again for the excellent safety-profile work. I dug through this against current main and I’m closing this PR as superseded by the landed maintainer version rather than trying to merge the now-dirty command-struct rewrite.\n\nWhat landed instead:\n- #536 / f26af3a: baked safety profiles with build-safe.sh, make build-safe, agent-safe, readonly, and full presets.\n- #540 / 4690010: baked policy rules compile to hash switches instead of raw patchable YAML/rule strings.\n- docs/changelog cover the feature and credit #366/#239.\n- #239 is closed; agent-safe allows draft creation/update while blocking gmail drafts send and gmail send.\n\nImportant nuance: current main does not merge the exact generated-Kong-struct/physical-command-removal approach from this PR. It intentionally keeps the Kong structs unchanged and enforces the baked profile immediately after parse and before any command handler/API call, then filters help/schema output. That avoided the large recurring conflict surface from splitting every command type while still preventing flags/env/config from re-enabling blocked commands.\n\nProof checked now:\n- go test ./internal/cmd ./cmd/bake-safety-profile ./internal/safetyprofile -run 'TestBakedSafetyProfile|TestReadonlySafetyProfile|TestAgentSafeProfile|TestSafetyProfile|TestGenerate|TestParse'\n- ./build-safe.sh safety-profiles/readonly.yaml -o bin/gog-readonly-review\n- ./build-safe.sh safety-profiles/agent-safe.yaml -o bin/gog-agent-safe-review\n- bin/gog-agent-safe-review gmail drafts send draft-1 exits 2 with command "gmail drafts send" is blocked by baked safety profile "agent-safe"\n- bin/gog-agent-safe-review --enable-commands gmail.drafts.send gmail drafts send draft-1 is still blocked\n- bin/gog-readonly-review gmail messages modify msg-1 --add Label_1 is blocked\n- bin/gog-readonly-review --enable-commands gmail.send gmail send ... is still blocked\n- profiled help/schema show allowed commands and hide blocked ones\n\nClosing as superseded by landed main. The core user problem is solved; if we later decide we specifically need physical command removal or separate config-dir semantics, that should be a fresh, narrower design discussion on top of the baked-profile implementation.

@steipete steipete closed this May 4, 2026
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.

4 participants