diff --git a/README.md b/README.md index 13fd0f56..f77221d1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -460,7 +460,7 @@ gog keep delete --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) @@ -527,6 +527,29 @@ gog --enable-commands calendar,tasks calendar events --today export GOG_ENABLE_COMMANDS=calendar,tasks gog tasks list ``` + +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 @@ -1549,7 +1572,7 @@ gog --verbose gmail search 'newer_than:7d' All commands support these flags: - `--account ` - Account to use (overrides GOG_ACCOUNT) -- `--enable-commands ` - Allowlist top-level commands (e.g., `calendar,tasks`) +- `--enable-commands ` - 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 ` - Color mode: `auto`, `always`, or `never` (default: auto) diff --git a/internal/cmd/enabled_commands.go b/internal/cmd/enabled_commands.go index 20b10cc8..abde3d46 100644 --- a/internal/cmd/enabled_commands.go +++ b/internal/cmd/enabled_commands.go @@ -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 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 { diff --git a/internal/cmd/root_more_test.go b/internal/cmd/root_more_test.go index 026248ea..ee4d60d6 100644 --- a/internal/cmd/root_more_test.go +++ b/internal/cmd/root_more_test.go @@ -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) + } +}