From 19693fd7946be6fd6c754898c40b746901dea9f9 Mon Sep 17 00:00:00 2001 From: spookyuser <16196262+spookyuser@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:20:35 +0200 Subject: [PATCH] feat(cmd): add --disable-commands flag for command denylist Add support for blocking specific commands using dot-notation (e.g., gmail.send, calendar.delete). This complements the existing --enable-commands allowlist by providing fine-grained control over which subcommands are accessible. - Add GOG_DISABLE_COMMANDS env var - Block commands by exact match or parent prefix - Case-insensitive matching Co-Authored-By: Claude Opus 4.5 --- README.md | 16 +++- internal/cmd/enabled_commands.go | 32 ++++++++ internal/cmd/enabled_commands_test.go | 113 +++++++++++++++++++++++++- internal/cmd/root.go | 41 ++++++---- 4 files changed, 183 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f526db59..823a1728 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,7 @@ gog keep get --account you@yourdomain.com - `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_DISABLE_COMMANDS` - Comma-separated denylist using dot-notation (e.g., `gmail.send`) ### Config File (JSON5) @@ -435,7 +436,7 @@ gog auth alias unset work Aliases work anywhere you pass `--account` or `GOG_ACCOUNT` (reserved: `auto`, `default`). -### Command Allowlist (Sandboxing) +### Command Allowlist/Denylist (Sandboxing) ```bash # Only allow calendar + tasks commands for an agent @@ -444,6 +445,18 @@ gog --enable-commands calendar,tasks calendar events --today # Same via env export GOG_ENABLE_COMMANDS=calendar,tasks gog tasks list + +# Block specific subcommands using dot-notation +gog --disable-commands gmail.send gmail search 'from:boss' # allowed +gog --disable-commands gmail.send gmail send --to a@b.com # blocked + +# Block all subcommands of a command +gog --disable-commands gmail gmail search 'from:boss' # blocked + +# Same via env +export GOG_DISABLE_COMMANDS=gmail.send,calendar.delete +gog gmail search 'from:boss' # allowed +gog gmail send --to a@b.com # blocked ``` ## Security @@ -1235,6 +1248,7 @@ All commands support these flags: - `--account ` - Account to use (overrides GOG_ACCOUNT) - `--enable-commands ` - Allowlist top-level commands (e.g., `calendar,tasks`) +- `--disable-commands ` - Denylist commands using dot-notation (e.g., `gmail.send`) - `--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..04575f60 100644 --- a/internal/cmd/enabled_commands.go +++ b/internal/cmd/enabled_commands.go @@ -40,3 +40,35 @@ func parseEnabledCommands(value string) map[string]bool { } return out } + +func enforceDisabledCommands(kctx *kong.Context, disabled string) error { + return enforceDisabledCommandsForCommand(kctx.Command(), disabled) +} + +func enforceDisabledCommandsForCommand(command, disabled string) error { + disabled = strings.TrimSpace(disabled) + if disabled == "" { + return nil + } + + denyList := parseEnabledCommands(disabled) + if len(denyList) == 0 { + return nil + } + + cmdParts := strings.Fields(command) + if len(cmdParts) == 0 { + return nil + } + + // Check if any prefix of the command path is disabled + // e.g., ["gmail", "send"] checks "gmail.send", then "gmail" + for i := len(cmdParts); i >= 1; i-- { + prefix := strings.ToLower(strings.Join(cmdParts[:i], ".")) + if denyList[prefix] { + return usagef("command %q is disabled (blocked by --disable-commands)", strings.Join(cmdParts[:i], " ")) + } + } + + return nil +} diff --git a/internal/cmd/enabled_commands_test.go b/internal/cmd/enabled_commands_test.go index 243229bc..686ab609 100644 --- a/internal/cmd/enabled_commands_test.go +++ b/internal/cmd/enabled_commands_test.go @@ -1,6 +1,8 @@ package cmd -import "testing" +import ( + "testing" +) func TestParseEnabledCommands(t *testing.T) { allow := parseEnabledCommands("calendar, tasks ,Gmail") @@ -8,3 +10,112 @@ func TestParseEnabledCommands(t *testing.T) { t.Fatalf("unexpected allow map: %#v", allow) } } + +func TestEnforceDisabledCommands(t *testing.T) { + tests := []struct { + name string + disabled string + command string + wantErr bool + }{ + { + name: "empty disabled allows all", + disabled: "", + command: "gmail send", + wantErr: false, + }, + { + name: "whitespace-only disabled allows all", + disabled: " ", + command: "gmail send", + wantErr: false, + }, + { + name: "exact match blocks command", + disabled: "gmail.send", + command: "gmail send", + wantErr: true, + }, + { + name: "parent blocks all children", + disabled: "gmail", + command: "gmail send", + wantErr: true, + }, + { + name: "parent blocks nested children", + disabled: "gmail", + command: "gmail messages list", + wantErr: true, + }, + { + name: "specific subcommand does not block sibling", + disabled: "gmail.send", + command: "gmail search", + wantErr: false, + }, + { + name: "specific subcommand does not block different parent", + disabled: "gmail.messages", + command: "gmail send", + wantErr: false, + }, + { + name: "case insensitive matching", + disabled: "Gmail.SEND", + command: "gmail send", + wantErr: true, + }, + { + name: "multiple disabled commands", + disabled: "gmail.send,calendar.delete", + command: "gmail send", + wantErr: true, + }, + { + name: "multiple disabled commands - second match", + disabled: "gmail.send,calendar.delete", + command: "calendar delete", + wantErr: true, + }, + { + name: "multiple disabled commands - no match", + disabled: "gmail.send,calendar.delete", + command: "gmail search", + wantErr: false, + }, + { + name: "empty command allowed", + disabled: "gmail.send", + command: "", + wantErr: false, + }, + { + name: "three-level command blocked by middle", + disabled: "gmail.messages", + command: "gmail messages list", + wantErr: true, + }, + { + name: "three-level command blocked by exact match", + disabled: "gmail.messages.list", + command: "gmail messages list", + wantErr: true, + }, + { + name: "three-level command - sibling allowed", + disabled: "gmail.messages.list", + command: "gmail messages get", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := enforceDisabledCommandsForCommand(tt.command, tt.disabled) + if (err != nil) != tt.wantErr { + t.Errorf("enforceDisabledCommandsForCommand() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 28012deb..d2ef8dc2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -24,15 +24,16 @@ const ( ) type RootFlags struct { - Color string `help:"Color output: auto|always|never" default:"${color}"` - Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets)"` - Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"` - EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"` - JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}"` - Plain bool `help:"Output stable, parseable text to stdout (TSV; no colors)" default:"${plain}"` - Force bool `help:"Skip confirmations for destructive commands"` - NoInput bool `help:"Never prompt; fail instead (useful for CI)"` - Verbose bool `help:"Enable verbose logging"` + Color string `help:"Color output: auto|always|never" default:"${color}"` + Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets)"` + Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"` + EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"` + DisableCommands string `help:"Comma-separated list of disabled commands using dot-notation (e.g., gmail.send)" default:"${disabled_commands}"` + JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}"` + Plain bool `help:"Output stable, parseable text to stdout (TSV; no colors)" default:"${plain}"` + Force bool `help:"Skip confirmations for destructive commands"` + NoInput bool `help:"Never prompt; fail instead (useful for CI)"` + Verbose bool `help:"Enable verbose logging"` } type CLI struct { @@ -95,6 +96,11 @@ func Execute(args []string) (err error) { return err } + if err = enforceDisabledCommands(kctx, cli.DisableCommands); err != nil { + _, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err)) + return err + } + logLevel := slog.LevelWarn if cli.Verbose { logLevel = slog.LevelDebug @@ -171,14 +177,15 @@ func boolString(v bool) string { func newParser(description string) (*kong.Kong, *CLI, error) { envMode := outfmt.FromEnv() vars := kong.Vars{ - "auth_services": googleauth.UserServiceCSV(), - "color": envOr("GOG_COLOR", "auto"), - "calendar_weekday": envOr("GOG_CALENDAR_WEEKDAY", "false"), - "client": envOr("GOG_CLIENT", ""), - "enabled_commands": envOr("GOG_ENABLE_COMMANDS", ""), - "json": boolString(envMode.JSON), - "plain": boolString(envMode.Plain), - "version": VersionString(), + "auth_services": googleauth.UserServiceCSV(), + "color": envOr("GOG_COLOR", "auto"), + "calendar_weekday": envOr("GOG_CALENDAR_WEEKDAY", "false"), + "client": envOr("GOG_CLIENT", ""), + "enabled_commands": envOr("GOG_ENABLE_COMMANDS", ""), + "disabled_commands": envOr("GOG_DISABLE_COMMANDS", ""), + "json": boolString(envMode.JSON), + "plain": boolString(envMode.Plain), + "version": VersionString(), } cli := &CLI{}