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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ gog keep get <noteId> --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)

Expand Down Expand Up @@ -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
Expand All @@ -444,6 +445,18 @@ gog --enable-commands calendar,tasks calendar events --today
# Same via env
export GOG_ENABLE_COMMANDS=calendar,tasks
gog tasks list <tasklistId>

# 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
Expand Down Expand Up @@ -1235,6 +1248,7 @@ 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`)
- `--disable-commands <csv>` - 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 <mode>` - Color mode: `auto`, `always`, or `never` (default: auto)
Expand Down
32 changes: 32 additions & 0 deletions internal/cmd/enabled_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
113 changes: 112 additions & 1 deletion internal/cmd/enabled_commands_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,121 @@
package cmd

import "testing"
import (
"testing"
)

func TestParseEnabledCommands(t *testing.T) {
allow := parseEnabledCommands("calendar, tasks ,Gmail")
if !allow["calendar"] || !allow["tasks"] || !allow["gmail"] {
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)
}
})
}
}
41 changes: 24 additions & 17 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand Down