Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ go.work.sum
bin/
/gog

# Safety profile generated files
internal/cmd/*_cmd_gen.go

# Node (optional dev scripts)
node_modules/
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ SHELL := /bin/bash
.DEFAULT_GOAL := build

.PHONY: build gog gogcli gog-help gogcli-help help fmt fmt-check lint test ci tools
.PHONY: worker-ci
.PHONY: worker-ci build-safe build-profile clean-gen

BIN_DIR := $(CURDIR)/bin
BIN := $(BIN_DIR)/gog
Expand Down Expand Up @@ -86,6 +86,20 @@ pnpm-gate:
test:
@go test ./...

# Safety profile builds
SAFE_BIN := $(BIN_DIR)/gog-safe
PROFILE ?= safety-profile.example.yaml

build-safe:
@./build-safe.sh $(PROFILE)

build-profile:
@./build-safe.sh $(PROFILE) -o $(SAFE_BIN)

clean-gen:
@rm -f internal/cmd/*_cmd_gen.go
@echo "Cleaned generated files"

ci: pnpm-gate fmt-check lint test

worker-ci:
Expand Down
94 changes: 93 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
- **Local time** - quick local/UTC time display for scripts and agents
- **Multiple accounts** - manage multiple Google accounts simultaneously (with aliases)
- **Command allowlist** - restrict top-level commands for sandboxed/agent runs
- **Safety profiles** - compile-time command removal via YAML config (non-bypassable, ideal for AI agents)
- **Secure credential storage** using OS keyring or encrypted on-disk keyring (configurable)
- **Auto-refreshing tokens** - authenticate once, use indefinitely
- **Least-privilege auth** - `--readonly`, `--drive-scope`, and `--gmail-scope` to request fewer scopes
Expand Down Expand Up @@ -52,6 +53,13 @@ cd gogcli
make
```

Build a safety-profiled binary (see [Safety Profiles](#safety-profiles-compile-time)):

```bash
make build-safe # uses ./safety-profile.example.yaml
make build-safe PROFILE=safety-profiles/readonly.yaml
```

Run:

```bash
Expand Down Expand Up @@ -494,7 +502,91 @@ gog --enable-commands calendar,tasks calendar events --today
export GOG_ENABLE_COMMANDS=calendar,tasks
gog tasks list <tasklistId>
```


### Safety Profiles (Compile-Time)

`--enable-commands` is a runtime flag, which means an AI agent can bypass it by passing `--enable-commands=""`. Safety profiles solve this by removing commands from the binary at compile time. If `gmail send` is not in the binary, no prompt injection or flag override can invoke it.

A safety profile is a YAML file that maps every command to `true` (include) or `false` (omit):

```yaml
gmail:
search: true
get: true
send: false # removed from the binary
drafts:
list: true
create: true
send: false # can draft but not send
settings: false # all settings subcommands removed

calendar:
events: true
create: true
delete: false

drive:
ls: true
search: true
upload: false
delete: false

classroom: false # entire service removed
```

Build a profiled binary with `make build-safe`:

```bash
# Use the example profile
make build-safe

# Use a preset profile
make build-safe PROFILE=safety-profiles/readonly.yaml

# Custom output path
./build-safe.sh safety-profiles/agent-safe.yaml -o /usr/local/bin/gog-safe
```

The generator parses the `*_types.go` source files via Go's AST package to auto-discover all commands and their hierarchy. It then reads the YAML profile and generates Go source files containing only the enabled command fields, compiled with `-tags safety_profile` so the generated structs replace the full ones. New upstream commands are picked up automatically with no manual registry to maintain. The resulting binary is identical to stock `gog` for the commands it includes; disabled commands simply do not exist.

**Preset profiles** are provided in `safety-profiles/`:

| Profile | Description |
|---------|-------------|
| `full.yaml` | Everything enabled (equivalent to stock `gog`) |
| `readonly.yaml` | Read/list/search/get only, no creates, updates, sends, or deletes |
| `agent-safe.yaml` | Read + draft + archive + label, no send/delete/settings |

To create a custom profile, copy `safety-profiles/full.yaml` and set commands to `false` as needed. Setting a service key to `false` (e.g., `classroom: false`) disables all of its subcommands.

#### Why compile-time instead of runtime?

Runtime restrictions (`--enable-commands`, environment variables, config flags) are useful for preventing accidental misuse, but an AI agent with shell access can bypass them:

```bash
# Agent can clear the runtime allowlist
gog --enable-commands="" gmail send ...

# Agent can unset the env var
unset GOG_ENABLE_COMMANDS && gog gmail send ...
```

Most agent frameworks (Claude Code, OpenClaw, Cursor, etc.) do not inspect the content of individual shell commands at the policy level, and those that support it (like Claude Code's PreToolUse hooks) rely on pattern matching that can be circumvented with creative quoting or indirection. They can allow or deny the shell/exec tool entirely, but reliably blocking specific subcommands within an allowed tool is brittle. This means that if an agent has shell access at all, runtime flags are the only thing standing between it and destructive commands, and those flags are bypassable.

Compile-time removal closes this gap. The binary physically lacks the command. There is no sequence of arguments, environment variables, or config changes that can invoke it.

This is especially relevant for Gmail's draft-without-send use case: Google's OAuth scopes have no `gmail.drafts` scope. `gmail.readonly` blocks all writes including drafts; `gmail.compose` allows drafts AND sending. The only way to allow `gog gmail drafts create` while blocking `gog gmail send` is to remove `send` from the binary.

#### Config isolation

Safety profile builds automatically use a separate config directory (`gogcli-safe/` instead of `gogcli/`), so credentials are not shared between stock `gog` and a profiled binary. This means an agent cannot bypass command removal by calling the stock `gog` binary with the same account's tokens.

- macOS: `~/Library/Application Support/gogcli-safe/`
- Linux: `~/.config/gogcli-safe/` (or `$XDG_CONFIG_HOME/gogcli-safe/`)
- Windows: `%AppData%\\gogcli-safe\\`

Authenticate each binary independently. The two binaries can run side-by-side on the same machine with different accounts and different permission levels.

## Security

### Credential Storage
Expand Down
89 changes: 89 additions & 0 deletions build-safe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# build-safe.sh - Build a safety-profiled gog binary.
#
# Reads a safety-profile.yaml, generates Go source files with only
# the enabled commands, and compiles with -tags safety_profile.
# The resulting binary version is tagged with "-safe" suffix.
#
# Usage:
# ./build-safe.sh safety-profile.example.yaml # Uses the example 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
#
set -euo pipefail

# Anchor to repo root so relative paths (internal/cmd, go.mod) always work
# regardless of where the user invokes the script from.
cd "$(dirname "$0")"

if [[ -z "${1:-}" ]] || [[ "$1" == -* ]]; then
echo "Usage: $0 <profile.yaml> [-o output]" >&2
echo "" >&2
echo "Examples:" >&2
echo " $0 safety-profile.example.yaml" >&2
echo " $0 safety-profiles/readonly.yaml" >&2
echo " $0 safety-profiles/agent-safe.yaml -o /usr/local/bin/gog-safe" >&2
exit 1
fi

PROFILE="$1"
shift

# Parse optional flags
OUTPUT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output)
if [[ -z "${2:-}" ]]; then
echo "Error: -o requires an output path" >&2
exit 1
fi
OUTPUT="$2"
shift 2
;;
*)
echo "Unknown flag: $1" >&2
exit 1
;;
esac
done

if [[ -z "$OUTPUT" ]]; then
OUTPUT="bin/gog-safe"
fi

if [[ ! -f "$PROFILE" ]]; then
echo "Error: profile not found: $PROFILE" >&2
exit 1
fi

echo "Safety profile: $PROFILE"
echo "Output binary: $OUTPUT"
echo ""

# Step 1: Clean previous generated files to avoid stale leftovers
rm -f internal/cmd/*_cmd_gen.go

# Step 2: Generate Go files from the safety profile
echo "Generating command structs from profile..."
go run ./cmd/gen-safety --strict "$PROFILE"

# Step 3: Build with the safety_profile tag
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo "")
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS="-X github.com/steipete/gogcli/internal/cmd.version=${VERSION}-safe -X github.com/steipete/gogcli/internal/cmd.commit=${COMMIT} -X github.com/steipete/gogcli/internal/cmd.date=${DATE}"

mkdir -p "$(dirname "$OUTPUT")"

echo "Building with -tags safety_profile..."
go build -tags safety_profile -ldflags "$LDFLAGS" -o "$OUTPUT" ./cmd/gog/

echo ""
echo "Built: $OUTPUT"
echo "Profile: $PROFILE"
if ! "$OUTPUT" --version; then
echo "WARNING: built binary failed to run --version" >&2
exit 1
fi
Loading