Skip to content

Commit e4239f8

Browse files
authored
fix(gmail): enforce per-account no-send guard before dry-run exit (#916)
Enforce per-account Gmail no-send guards before dry-run exits for first-class and Discovery send paths. Preserve alias and account resolution, avoid keyring access when no active per-account guard exists, and retain post-auth defense in depth. Fixes #915. Co-authored-by: Henry Sowell <[email protected]>
1 parent 5800e0e commit e4239f8

6 files changed

Lines changed: 207 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 0.34.1 - Unreleased
44

5+
- Gmail: enforce per-account no-send guards before dry-run exits for first-class and Discovery send paths while preserving no-guard keyring avoidance. (#915, #916) — thanks @veteranbv.
56
- MCP: add optional global and per-account capability ceilings in `config.json`, with narrow persistent write authorization, runtime-only restriction, and fail-closed selector validation. (#913) — thanks @mcaldas.
67

78
## 0.34.0 - 2026-07-11

internal/cmd/api.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ func (c *APICallCmd) Run(ctx context.Context, flags *RootFlags) error {
118118
if !read && !c.AllowWrite {
119119
return usagef("method %s uses %s; pass --allow-write to opt in", method.ID, method.Spec.HttpMethod)
120120
}
121+
// Gmail send guards run before the dry-run exit so --dry-run reports
122+
// the block, matching the first-class Gmail send commands.
123+
if guardErr := checkDiscoveryGmailNoSend(ctx, flags, method.ID); guardErr != nil {
124+
return guardErr
125+
}
121126
plan := map[string]any{"api": c.API, "version": c.Version, "method": method.ID, "http_method": method.Spec.HttpMethod, "url": requestURL, "has_body": len(body) > 0}
122127
if dryRunErr := dryRunExit(ctx, flags, "api.call", plan); dryRunErr != nil {
123128
return dryRunErr
@@ -138,8 +143,12 @@ func (c *APICallCmd) Run(ctx context.Context, flags *RootFlags) error {
138143
if err != nil {
139144
return err
140145
}
141-
if guardErr := checkDiscoveryGmailNoSend(ctx, flags, account, method.ID); guardErr != nil {
142-
return guardErr
146+
// Defense-in-depth for the auth-resolved account, mirroring the
147+
// first-class send commands.
148+
if isDiscoveryGmailSendMethod(method.ID) {
149+
if guardErr := checkAccountNoSend(ctx, account); guardErr != nil {
150+
return guardErr
151+
}
143152
}
144153
scopes, err := discoveryScopes(method.Spec.Scopes, c.Scope)
145154
if err != nil {
@@ -249,8 +258,12 @@ func discoveryScopeScore(scope string) int {
249258
}
250259
}
251260

252-
func checkDiscoveryGmailNoSend(ctx context.Context, flags *RootFlags, account, methodID string) error {
253-
if methodID != "gmail.users.messages.send" && methodID != "gmail.users.drafts.send" {
261+
func isDiscoveryGmailSendMethod(methodID string) bool {
262+
return methodID == "gmail.users.messages.send" || methodID == "gmail.users.drafts.send"
263+
}
264+
265+
func checkDiscoveryGmailNoSend(ctx context.Context, flags *RootFlags, methodID string) error {
266+
if !isDiscoveryGmailSendMethod(methodID) {
254267
return nil
255268
}
256269
if flags != nil && flags.GmailNoSend {
@@ -269,7 +282,16 @@ func checkDiscoveryGmailNoSend(ctx context.Context, flags *RootFlags, account, m
269282
return usage("Gmail sending is blocked by config gmail_no_send")
270283
}
271284

272-
return checkAccountNoSend(ctx, account)
285+
// Same rules as enforceGmailNoSend: only resolve an account when a
286+
// per-account guard could match (default-account inference reads the
287+
// keyring), and leave resolution failures to the command itself.
288+
if !hasActiveNoSendAccount(cfg.NoSendAccounts) {
289+
return nil
290+
}
291+
if account, accountErr := requireAccount(flags); accountErr == nil {
292+
return checkAccountNoSend(ctx, account)
293+
}
294+
return nil
273295
}
274296

275297
func readDiscoveryBody(value string) (json.RawMessage, error) {

internal/cmd/api_test.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import (
1010
"strings"
1111
"testing"
1212

13+
"github.com/steipete/gogcli/internal/app"
14+
"github.com/steipete/gogcli/internal/config"
1315
"github.com/steipete/gogcli/internal/googleapi"
1416
"github.com/steipete/gogcli/internal/outfmt"
17+
"github.com/steipete/gogcli/internal/secrets"
1518
)
1619

1720
func TestAPICallRequiresWriteOptIn(t *testing.T) {
@@ -116,12 +119,58 @@ func TestValidateDiscoveryRedirect(t *testing.T) {
116119
}
117120

118121
func TestDiscoveryGmailSendHonorsNoSendFlag(t *testing.T) {
119-
err := checkDiscoveryGmailNoSend(context.Background(), &RootFlags{GmailNoSend: true}, "[email protected]", "gmail.users.messages.send")
122+
err := checkDiscoveryGmailNoSend(context.Background(), &RootFlags{GmailNoSend: true}, "gmail.users.messages.send")
120123
if err == nil || !strings.Contains(err.Error(), "--gmail-no-send") {
121124
t.Fatalf("error = %v, want no-send policy", err)
122125
}
123126
}
124127

128+
func TestDiscoveryGmailSendHonorsPerAccountNoSend(t *testing.T) {
129+
t.Parallel()
130+
131+
store := config.NewConfigStore(config.Layout{ConfigDir: t.TempDir()})
132+
if err := store.Write(config.File{NoSendAccounts: map[string]bool{"[email protected]": true}}); err != nil {
133+
t.Fatalf("WriteConfig: %v", err)
134+
}
135+
ctx := app.WithRuntime(context.Background(), &app.Runtime{Config: store})
136+
flags := &RootFlags{Account: "[email protected]"}
137+
138+
err := checkDiscoveryGmailNoSend(ctx, flags, "gmail.users.messages.send")
139+
if err == nil || !strings.Contains(err.Error(), "no-send") {
140+
t.Fatalf("error = %v, want per-account no-send", err)
141+
}
142+
if err := checkDiscoveryGmailNoSend(ctx, flags, "gmail.users.drafts.send"); err == nil {
143+
t.Fatalf("drafts.send: expected per-account no-send error")
144+
}
145+
// Non-send methods and non-guarded accounts are unaffected.
146+
if err := checkDiscoveryGmailNoSend(ctx, flags, "gmail.users.labels.list"); err != nil {
147+
t.Fatalf("non-send method: %v", err)
148+
}
149+
if err := checkDiscoveryGmailNoSend(ctx, &RootFlags{Account: "[email protected]"}, "gmail.users.messages.send"); err != nil {
150+
t.Fatalf("non-guarded account: %v", err)
151+
}
152+
}
153+
154+
func TestDiscoveryGmailSendWithInactiveGuardsSkipsAccountResolution(t *testing.T) {
155+
t.Parallel()
156+
157+
store := config.NewConfigStore(config.Layout{ConfigDir: t.TempDir()})
158+
if err := store.Write(config.File{NoSendAccounts: map[string]bool{"[email protected]": false}}); err != nil {
159+
t.Fatalf("WriteConfig: %v", err)
160+
}
161+
ctx := app.WithRuntime(context.Background(), &app.Runtime{Config: store})
162+
flags := &RootFlags{authOperations: app.AuthOperations{
163+
OpenSecretsStore: func() (secrets.Store, error) {
164+
t.Fatal("inactive no-send entries must not trigger account resolution")
165+
return nil, errors.New("unexpected account resolution")
166+
},
167+
}}
168+
169+
if err := checkDiscoveryGmailNoSend(ctx, flags, "gmail.users.messages.send"); err != nil {
170+
t.Fatalf("inactive no-send entry: %v", err)
171+
}
172+
}
173+
125174
func TestWriteDiscoveryResponsePreservesMedia(t *testing.T) {
126175
var stdout bytes.Buffer
127176
ctx := newCmdRuntimeOutputContext(t, &stdout, &bytes.Buffer{})

internal/cmd/gmail_no_send.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,38 @@ func enforceGmailNoSend(kctx *kong.Context, flags *RootFlags, runtime *app.Runti
4040
if cfg.GmailNoSend {
4141
return usage("Gmail sending is blocked by config gmail_no_send")
4242
}
43+
// Per-account guard, enforced at the same layer as the flag and the
44+
// global config key so it also holds under --dry-run, which exits the
45+
// command before the post-auth checkAccountNoSend call is reached.
46+
// Skip entirely when no per-account guards exist so a plain dry-run
47+
// never resolves an account (default-account inference reads the
48+
// keyring). Account resolution failures are not errors here: commands
49+
// own that failure mode, and checkAccountNoSend still covers real
50+
// sends after auth resolves the account.
51+
if !hasActiveNoSendAccount(cfg.NoSendAccounts) {
52+
return nil
53+
}
54+
if account, accountErr := requireAccount(flags); accountErr == nil {
55+
blocked, blockedErr := runtime.Config.IsNoSendAccount(account)
56+
if blockedErr != nil {
57+
return blockedErr
58+
}
59+
if blocked {
60+
return usagef("Gmail sending is blocked for %s (config no-send)", strings.TrimSpace(account))
61+
}
62+
}
4363
return nil
4464
}
4565

66+
func hasActiveNoSendAccount(accounts map[string]bool) bool {
67+
for _, blocked := range accounts {
68+
if blocked {
69+
return true
70+
}
71+
}
72+
return false
73+
}
74+
4675
func checkAccountNoSend(ctx context.Context, account string) error {
4776
store, err := commandConfigStore(ctx)
4877
if err != nil {

internal/cmd/root.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) {
167167
cli.authMode = googleapi.ParseAuthMode(os.Getenv("GOG_AUTH_MODE"))
168168
applyExplicitOutputModePrecedence(kctx, &cli.RootFlags)
169169

170+
// Make config-backed account and alias resolution available to the
171+
// pre-Run enforcement hooks below (enforceGmailNoSend resolves the
172+
// target account). The context-backed resolver installed later
173+
// replaces this with an equivalent one; both reduce to
174+
// configureRuntimeConfig + runtime.Config.
175+
cli.configStoreResolver = func() (*config.ConfigStore, error) {
176+
if cfgErr := configureRuntimeConfig(runtime); cfgErr != nil {
177+
return nil, cfgErr
178+
}
179+
return runtime.Config, nil
180+
}
181+
170182
if err = enforceBakedSafetyProfile(kctx); err != nil {
171183
return reportEarlyError(runtimeIO.Err, err)
172184
}

internal/cmd/root_more_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/steipete/gogcli/internal/app"
1616
"github.com/steipete/gogcli/internal/config"
17+
"github.com/steipete/gogcli/internal/secrets"
1718
)
1819

1920
func setTestConfigHome(t *testing.T) {
@@ -396,6 +397,93 @@ func TestConfigGmailNoSendBlocksBeforeAuth(t *testing.T) {
396397
}
397398
}
398399

400+
func TestConfigNoSendAccountBlocksBeforeDryRun(t *testing.T) {
401+
t.Parallel()
402+
403+
store := config.NewConfigStore(config.Layout{ConfigDir: t.TempDir()})
404+
if err := store.Write(config.File{
405+
AccountAliases: map[string]string{"work": "[email protected]"},
406+
NoSendAccounts: map[string]bool{"[email protected]": true},
407+
}); err != nil {
408+
t.Fatalf("WriteConfig: %v", err)
409+
}
410+
runtime := &app.Runtime{Config: store}
411+
tests := [][]string{
412+
{"gmail", "send", "--account", "[email protected]", "--to", "[email protected]", "--subject", "S", "--body", "B", "--dry-run"},
413+
{"gmail", "autoreply", "from:[email protected]", "--account", "[email protected]", "--subject", "S", "--body", "B", "--dry-run"},
414+
{"gmail", "forward", "msg-1", "--account", "[email protected]", "--to", "[email protected]", "--dry-run"},
415+
{"gmail", "drafts", "send", "draft-1", "--account", "[email protected]", "--dry-run"},
416+
// Alias resolving to a guarded account is blocked too.
417+
{"gmail", "send", "--account", "work", "--to", "[email protected]", "--subject", "S", "--body", "B", "--dry-run"},
418+
}
419+
for _, args := range tests {
420+
result := executeWithTestRuntime(t, args, runtime)
421+
err := result.err
422+
if err == nil {
423+
t.Fatalf("expected error for %v", args)
424+
}
425+
if !strings.Contains(err.Error(), "no-send") {
426+
t.Fatalf("unexpected error for %v: %v", args, err)
427+
}
428+
}
429+
}
430+
431+
func TestConfigNoSendAccountDoesNotOverblockDryRun(t *testing.T) {
432+
t.Parallel()
433+
434+
store := config.NewConfigStore(config.Layout{ConfigDir: t.TempDir()})
435+
if err := store.Write(config.File{NoSendAccounts: map[string]bool{"[email protected]": true}}); err != nil {
436+
t.Fatalf("WriteConfig: %v", err)
437+
}
438+
runtime := &app.Runtime{Config: store}
439+
tests := [][]string{
440+
// Send from a different account is not blocked.
441+
{"gmail", "send", "--account", "[email protected]", "--to", "[email protected]", "--subject", "S", "--body", "B", "--dry-run"},
442+
// Non-send commands for the guarded account are not blocked.
443+
{"gmail", "drafts", "create", "--account", "[email protected]", "--to", "[email protected]", "--subject", "S", "--body", "B", "--dry-run"},
444+
}
445+
for _, args := range tests {
446+
result := executeWithTestRuntime(t, args, runtime)
447+
if result.err != nil {
448+
t.Fatalf("expected success for %v, got %v\nstderr=%q", args, result.err, result.stderr)
449+
}
450+
}
451+
}
452+
453+
func TestGmailSendDryRunWithoutGuardsSkipsAccountResolution(t *testing.T) {
454+
t.Parallel()
455+
456+
// No per-account guards configured: dry-run must succeed without any
457+
// account being resolvable (resolution would otherwise read the
458+
// keyring before the dry-run exit).
459+
store := config.NewConfigStore(config.Layout{ConfigDir: t.TempDir()})
460+
runtime := &app.Runtime{Config: store}
461+
args := []string{"gmail", "send", "--to", "[email protected]", "--subject", "S", "--body", "B", "--dry-run"}
462+
result := executeWithTestRuntime(t, args, runtime)
463+
if result.err != nil {
464+
t.Fatalf("expected success for %v, got %v\nstderr=%q", args, result.err, result.stderr)
465+
}
466+
}
467+
468+
func TestGmailSendDryRunWithInactiveGuardsSkipsAccountResolution(t *testing.T) {
469+
t.Parallel()
470+
471+
store := config.NewConfigStore(config.Layout{ConfigDir: t.TempDir()})
472+
if err := store.Write(config.File{NoSendAccounts: map[string]bool{"[email protected]": false}}); err != nil {
473+
t.Fatalf("WriteConfig: %v", err)
474+
}
475+
runtime := &app.Runtime{Config: store}
476+
runtime.Auth.OpenSecretsStore = func() (secrets.Store, error) {
477+
t.Fatal("inactive no-send entries must not trigger account resolution")
478+
return nil, errors.New("unexpected account resolution")
479+
}
480+
args := []string{"gmail", "send", "--to", "[email protected]", "--subject", "S", "--body", "B", "--dry-run"}
481+
result := executeWithTestRuntime(t, args, runtime)
482+
if result.err != nil {
483+
t.Fatalf("expected success for %v, got %v\nstderr=%q", args, result.err, result.stderr)
484+
}
485+
}
486+
399487
func TestConfigIndependentCommandsDoNotRequireHome(t *testing.T) {
400488
t.Setenv("HOME", "")
401489
t.Setenv("USERPROFILE", "")

0 commit comments

Comments
 (0)