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
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
- **Groups** - list groups you belong to, view group members (Google Workspace)
- **Local time** - quick local/UTC time display for scripts and agents
- **Multiple accounts** - manage multiple Google accounts simultaneously, with account aliases and per-client OAuth buckets
- **Command allowlist** - restrict top-level commands for sandboxed/agent runs
- **Command allowlist** - restrict commands (top-level or specific subcommands) for sandboxed/agent runs
- **Secure credential storage** using OS keyring or encrypted on-disk keyring (configurable)
- **Auto-refreshing tokens** - authenticate once, use indefinitely
- **Flexible auth** - OAuth refresh tokens, ADC, direct access tokens, service accounts, manual/remote flows, `--extra-scopes`, and proxy-safe callbacks
Expand Down Expand Up @@ -460,7 +460,7 @@ gog keep delete <noteId> --account you@yourdomain.com --force
- `GOG_PLAIN` - Default plain output
- `GOG_COLOR` - Color mode: `auto` (default), `always`, or `never`
- `GOG_TIMEZONE` - Default output timezone for Calendar/Gmail (IANA name, `UTC`, or `local`)
- `GOG_ENABLE_COMMANDS` - Comma-separated allowlist of top-level commands (e.g., `calendar,tasks`)
- `GOG_ENABLE_COMMANDS` - Comma-separated allowlist of commands or subcommand paths (e.g., `calendar,gmail.search,gmail.drafts.create`)

### Config File (JSON5)

Expand Down Expand Up @@ -527,6 +527,29 @@ gog --enable-commands calendar,tasks calendar events --today
export GOG_ENABLE_COMMANDS=calendar,tasks
gog tasks list <tasklistId>
```

Subcommand-level restriction with dotted paths:

```bash
# Allow only gmail search and drafts create, plus all calendar commands
gog --enable-commands='gmail.search,gmail.drafts.create,calendar' gmail search "invoice"
# ✓ gmail search → allowed
# ✓ gmail drafts create → allowed
# ✗ gmail send → blocked
# ✗ gmail drafts send → blocked
# ✓ calendar events → allowed (top-level "calendar" allows all subcommands)

# Allow all drafts subcommands (prefix match)
gog --enable-commands='gmail.drafts' gmail drafts list
# ✓ gmail drafts list → allowed
# ✓ gmail drafts create → allowed
# ✗ gmail send → blocked

# Via env var
export GOG_ENABLE_COMMANDS='gmail.search,gmail.get,gmail.labels,calendar'
```

Top-level entries (e.g., `gmail`) remain backward-compatible and allow all subcommands. Dotted entries (e.g., `gmail.search`, `gmail.drafts.create`) restrict to that specific subcommand path. Intermediate dotted entries (e.g., `gmail.drafts`) allow all children.

## Security

Expand Down Expand Up @@ -1549,7 +1572,7 @@ gog --verbose gmail search 'newer_than:7d'
All commands support these flags:

- `--account <email|alias|auto>` - Account to use (overrides GOG_ACCOUNT)
- `--enable-commands <csv>` - Allowlist top-level commands (e.g., `calendar,tasks`)
- `--enable-commands <csv>` - Allowlist commands or subcommand paths (e.g., `calendar,gmail.search,gmail.drafts.create`)
- `--json` - Output JSON to stdout (best for scripting)
- `--plain` - Output stable, parseable text to stdout (TSV; no colors)
- `--color <mode>` - Color mode: `auto`, `always`, or `never` (default: auto)
Expand Down
44 changes: 40 additions & 4 deletions internal/cmd/enabled_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,47 @@ func enforceEnabledCommands(kctx *kong.Context, enabled string) error {
if len(cmd) == 0 {
return nil
}
top := strings.ToLower(cmd[0])
if !allow[top] {
return usagef("command %q is not enabled (set --enable-commands to allow it)", top)
// Build the command path: only real command words (skip <arg> placeholders).
var cmdPath []string
for _, part := range cmd {
if strings.HasPrefix(part, "<") {
break
}
cmdPath = append(cmdPath, strings.ToLower(part))
}
if len(cmdPath) == 0 {
return nil
}
if matchEnabledCommand(allow, cmdPath) {
return nil
}
label := strings.Join(cmdPath, " ")
return usagef("command %q is not enabled (set --enable-commands to allow it)", label)
}

// matchEnabledCommand checks whether cmdPath (e.g. ["gmail","drafts","create"])
// is permitted by any entry in the allow map.
//
// An allowed entry matches if the command path starts with (or equals) that entry.
// - "gmail" → matches any gmail subcommand
// - "gmail.drafts" → matches gmail drafts and any deeper subcommand
// - "gmail.drafts.create" → matches only gmail drafts create
func matchEnabledCommand(allow map[string]bool, cmdPath []string) bool {
// Check every prefix of cmdPath: if any prefix is allowed, the command is ok.
// e.g. for cmdPath ["gmail","drafts","create"], check:
// "gmail", "gmail.drafts", "gmail.drafts.create"
built := ""
for i, seg := range cmdPath {
if i == 0 {
built = seg
} else {
built += "." + seg
}
if allow[built] {
return true
}
}
return nil
return false
}

func parseEnabledCommands(value string) map[string]bool {
Expand Down
130 changes: 130 additions & 0 deletions internal/cmd/root_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,133 @@ func TestEnableCommandsBlocks(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}

// ---------- Subcommand allowlist tests ----------

func TestEnableCommandsUnit(t *testing.T) {
tests := []struct {
name string
allow string
cmdPath []string
want bool
}{
// Backward compat: top-level entry allows all subcommands
{"top-level gmail allows search", "gmail", []string{"gmail", "search"}, true},
{"top-level gmail allows send", "gmail", []string{"gmail", "send"}, true},
{"top-level gmail allows drafts create", "gmail", []string{"gmail", "drafts", "create"}, true},
{"top-level calendar allows events", "calendar", []string{"calendar", "events"}, true},

// Top-level blocks other top-level
{"calendar does not allow tasks", "calendar", []string{"tasks", "list"}, false},
{"gmail does not allow calendar", "gmail", []string{"calendar", "events"}, false},

// Dotted path: exact subcommand
{"gmail.search allows gmail search", "gmail.search", []string{"gmail", "search"}, true},
{"gmail.search blocks gmail send", "gmail.search", []string{"gmail", "send"}, false},
{"gmail.search blocks gmail drafts create", "gmail.search", []string{"gmail", "drafts", "create"}, false},

// Dotted path: nested subcommand
{"gmail.drafts.create allows gmail drafts create", "gmail.drafts.create", []string{"gmail", "drafts", "create"}, true},
{"gmail.drafts.create blocks gmail drafts send", "gmail.drafts.create", []string{"gmail", "drafts", "send"}, false},
{"gmail.drafts.create blocks gmail drafts list", "gmail.drafts.create", []string{"gmail", "drafts", "list"}, false},
{"gmail.drafts.create blocks gmail search", "gmail.drafts.create", []string{"gmail", "search"}, false},

// Dotted path: intermediate allows all children
{"gmail.drafts allows gmail drafts create", "gmail.drafts", []string{"gmail", "drafts", "create"}, true},
{"gmail.drafts allows gmail drafts send", "gmail.drafts", []string{"gmail", "drafts", "send"}, true},
{"gmail.drafts allows gmail drafts list", "gmail.drafts", []string{"gmail", "drafts", "list"}, true},
{"gmail.drafts blocks gmail search", "gmail.drafts", []string{"gmail", "search"}, false},
{"gmail.drafts blocks gmail send", "gmail.drafts", []string{"gmail", "send"}, false},

// Note: "*" and "all" are short-circuited in enforceEnabledCommands
// before matchEnabledCommand is called, so they are not tested here.
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allow := parseEnabledCommands(tt.allow)
got := matchEnabledCommand(allow, tt.cmdPath)
if got != tt.want {
t.Errorf("matchEnabledCommand(%v, %v) = %v, want %v", tt.allow, tt.cmdPath, got, tt.want)
}
})
}
}

func TestEnableCommandsMixed(t *testing.T) {
// Mixed: "gmail.search,gmail.drafts.create,calendar"
allow := parseEnabledCommands("gmail.search,gmail.drafts.create,calendar")

tests := []struct {
name string
cmdPath []string
want bool
}{
{"gmail search allowed", []string{"gmail", "search"}, true},
{"gmail drafts create allowed", []string{"gmail", "drafts", "create"}, true},
{"calendar events allowed", []string{"calendar", "events"}, true},
{"calendar list allowed", []string{"calendar", "list"}, true},
{"gmail send blocked", []string{"gmail", "send"}, false},
{"gmail drafts send blocked", []string{"gmail", "drafts", "send"}, false},
{"gmail drafts list blocked", []string{"gmail", "drafts", "list"}, false},
{"tasks list blocked", []string{"tasks", "list"}, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchEnabledCommand(allow, tt.cmdPath)
if got != tt.want {
t.Errorf("matchEnabledCommand(mixed, %v) = %v, want %v", tt.cmdPath, got, tt.want)
}
})
}
}

func TestEnableCommandsSubcommandE2E(t *testing.T) {
// End-to-end: gmail.search should allow "gmail search" but block "gmail send"
// "gmail search" requires a query arg; pass one so kong can parse it.
// The command will fail on auth, but should NOT fail on allowlist.
err := Execute([]string{"--enable-commands", "gmail.search", "gmail", "search", "test"})
if err != nil && strings.Contains(err.Error(), "not enabled") {
t.Fatalf("gmail search should be allowed by gmail.search, got: %v", err)
}

// "gmail send" should be blocked by the allowlist.
err = Execute([]string{"--enable-commands", "gmail.search", "gmail", "send", "--to", "x@x.com", "--subject", "s", "--body", "b"})
if err == nil {
t.Fatalf("expected error for blocked gmail send")
}
if !strings.Contains(err.Error(), "not enabled") {
t.Fatalf("expected 'not enabled' error, got: %v", err)
}
}

func TestEnableCommandsDottedDraftsE2E(t *testing.T) {
// gmail.drafts.create should allow "gmail drafts create" but block "gmail drafts send"
err := Execute([]string{"--enable-commands", "gmail.drafts.create", "gmail", "drafts", "create", "--to", "x@x.com", "--subject", "s", "--body", "b"})
if err != nil && strings.Contains(err.Error(), "not enabled") {
t.Fatalf("gmail drafts create should be allowed, got: %v", err)
}

// gmail drafts send should be blocked
err = Execute([]string{"--enable-commands", "gmail.drafts.create", "gmail", "drafts", "send", "draftid"})
if err == nil {
t.Fatalf("expected error for blocked gmail drafts send")
}
if !strings.Contains(err.Error(), "not enabled") {
t.Fatalf("expected 'not enabled' error, got: %v", err)
}
}

func TestEnableCommandsTopLevelBackwardCompat(t *testing.T) {
// Top-level "gmail" should still allow all gmail subcommands (backward compat)
err := Execute([]string{"--enable-commands", "gmail", "gmail", "search", "test"})
if err != nil && strings.Contains(err.Error(), "not enabled") {
t.Fatalf("top-level gmail should allow gmail search, got: %v", err)
}

err = Execute([]string{"--enable-commands", "gmail", "gmail", "send", "--to", "x@x.com", "--subject", "s", "--body", "b"})
if err != nil && strings.Contains(err.Error(), "not enabled") {
t.Fatalf("top-level gmail should allow gmail send, got: %v", err)
}
}