From 9301fab64807c1f4ba019b5566902f0410c91608 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 14:27:35 -0600 Subject: [PATCH 1/6] Add read-only mode backed by read-scoped OAuth logins `webex login --read-only` requests only read scopes and turns on read-only mode. Write-capable tokens are purged from the keyring, write requests are refused client-side, --token/$WEBEX_TOKEN and non-read-only users are rejected, and the MCP server omits webex_write. Leaving the mode requires a full `webex login` from an interactive terminal with a confirmation prompt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3 --- README.md | 15 ++- cmd/auth.go | 22 ++++- cmd/config_cmd.go | 23 ++++- cmd/login.go | 46 ++++++++- cmd/mcp/dispatcher.go | 67 +++++-------- cmd/mcp/dispatcher_test.go | 21 +++++ cmd/mcp/serve.go | 9 +- cmd/readonly.go | 56 +++++++++++ cmd/root.go | 6 +- internal/appconfig/appconfig.go | 12 +++ internal/audio/audio.go | 4 + internal/auth/keyring.go | 2 + internal/auth/oauth.go | 16 +++- internal/auth/readonly.go | 40 ++++++++ internal/auth/readonly_test.go | 95 +++++++++++++++++++ internal/auth/resolve.go | 24 +++-- internal/client/client.go | 12 +++ internal/client/client_test.go | 47 ++++++++++ internal/config/config.go | 28 ++++++ internal/config/config_test.go | 28 ++++++ internal/readonly/readonly.go | 109 ++++++++++++++++++++++ internal/readonly/readonly_test.go | 66 +++++++++++++ site/src/content/docs/getting-started.mdx | 20 ++++ skill/SKILL.md | 4 + 24 files changed, 708 insertions(+), 64 deletions(-) create mode 100644 cmd/mcp/dispatcher_test.go create mode 100644 cmd/readonly.go create mode 100644 internal/auth/readonly.go create mode 100644 internal/auth/readonly_test.go create mode 100644 internal/client/client_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/readonly/readonly.go create mode 100644 internal/readonly/readonly_test.go diff --git a/README.md b/README.md index cc3d144..04f842f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ See the [full refresh inventory](docs/command-inventory.md), [every renamed comm ```bash webex login # Login (opens browser) +webex login --read-only # Login with read scopes only and turn on read-only mode webex logout # Remove stored tokens webex auth status # Show current user and token status webex auth list # List all authenticated users @@ -144,6 +145,17 @@ webex auth clear-org # Clear the org override Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > OS keyring. +### Read-only Mode + +`webex login --read-only` requests only read scopes, so Webex itself rejects writes made with the token. Use it when an AI agent should read but not change your Webex data. While read-only mode is on: + +- Stored logins with write access are deleted from the keyring, because any program running as you can read it. +- `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for read actions such as `list`, `get`, `search`, and `query`. +- `--token` and `$WEBEX_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. +- `webex mcp serve` does not register `webex_write`. + +Leaving read-only mode requires `webex login` from an interactive terminal, with a confirmation prompt. `WEBEX_READ_ONLY=1` turns the same checks on for one process and never turns them off. If your OAuth integration lacks some of the default read scopes, set your own list with `webex config set read-only-scopes ""`. + ### Organization Override Partner admins managing customer orgs can set a persistent default org so they don't need `--organization` on every command: @@ -199,6 +211,7 @@ webex cc users list --output csv > users.csv webex config set client-id # Use custom OAuth client ID webex config set client-secret # Use custom OAuth client secret webex config set scopes # Override OAuth scopes +webex config set read-only-scopes # Override scopes for login --read-only webex config get client-id # View current value ``` @@ -228,7 +241,7 @@ The server exposes 4 tools: | `webex_help` | — | Get help text for any command or command group | | `webex_usage` | — | Query the MCP usage log (recent commands, timing, status) | -Read and write operations are split into separate tools so that `webex_read` can be auto-approved in permissions while `webex_write` always prompts for confirmation. +Read and write operations are split into separate tools so that `webex_read` can be auto-approved in permissions while `webex_write` always prompts for confirmation. In [read-only mode](#read-only-mode) the server does not register `webex_write`. And 2 MCP resources: diff --git a/cmd/auth.go b/cmd/auth.go index ade06fb..e4aa9b5 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/Cloverhound/webex-cli/internal/appconfig" @@ -78,6 +79,16 @@ var authStatusCmd = &cobra.Command{ fmt.Printf("Source: keyring\n") + switch { + case cfg.ReadOnly: + fmt.Println("Mode: read-only") + case readOnlyEnvSet(): + fmt.Printf("Mode: read-only ($%s)\n", readOnlyEnv) + } + if tok.ReadOnly { + fmt.Printf("Scopes: %s\n", tok.Scopes) + } + if tok.IsExpired() { fmt.Printf("Token: expired (at %s)\n", tok.ExpiresAt.Format(time.RFC3339)) if tok.IsRefreshExpired() { @@ -142,10 +153,14 @@ var authListCmd = &cobra.Command{ status = "active" } - marker := "" + var markers []string if email == cfg.DefaultUser { - marker = "(default)" + markers = append(markers, "(default)") } + if err == nil && tok.ReadOnly { + markers = append(markers, "(read-only)") + } + marker := strings.Join(markers, " ") name := info.DisplayName if len(name) > 20 { @@ -178,6 +193,9 @@ var authSwitchCmd = &cobra.Command{ if _, ok := cfg.Users[email]; !ok { return fmt.Errorf("user %s not found — run: webex login", email) } + if readOnlyMode(cfg) && !auth.HasReadOnlyToken(email) { + return fmt.Errorf("read-only mode: %s has no read-only login — run: webex login --read-only", email) + } clearedOrg := cfg.DefaultOrgName if clearedOrg == "" { diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 180b3c4..ea42d7e 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -6,9 +6,12 @@ import ( "github.com/Cloverhound/webex-cli/internal/appconfig" "github.com/Cloverhound/webex-cli/internal/config" + "github.com/Cloverhound/webex-cli/internal/readonly" "github.com/spf13/cobra" ) +const configKeys = "client-id, client-secret, scopes, read-only-scopes, region" + var configCmd = &cobra.Command{ Use: "config", Short: "Manage CLI configuration", @@ -27,7 +30,7 @@ To use your own Webex Integration instead of the built-in default: var configSetCmd = &cobra.Command{ Use: "set ", Short: "Set a configuration value", - Long: "Set a configuration value. Valid keys: client-id, client-secret, scopes, region", + Long: "Set a configuration value. Valid keys: " + configKeys + ". read-only-scopes accepts only read scopes.", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] @@ -45,13 +48,18 @@ var configSetCmd = &cobra.Command{ cfg.ClientSecret = value case "scopes": cfg.Scopes = value + case "read-only-scopes": + if err := readonly.ValidateScopes(value); err != nil { + return err + } + cfg.ReadOnlyScopes = value case "region": if err := setRegion(value); err != nil { return err } cfg.Region = config.Region() default: - return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes, region)", key) + return fmt.Errorf("unknown config key: %s (valid: %s)", key, configKeys) } if err := cfg.Save(); err != nil { @@ -66,7 +74,7 @@ var configSetCmd = &cobra.Command{ var configGetCmd = &cobra.Command{ Use: "get ", Short: "Get a configuration value", - Long: "Get a configuration value. Valid keys: client-id, client-secret, scopes, region", + Long: "Get a configuration value. Valid keys: " + configKeys, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] @@ -108,6 +116,13 @@ var configGetCmd = &cobra.Command{ } else { fmt.Printf("%s (default)\n", value) } + case "read-only-scopes": + value := cfg.EffectiveReadOnlyScopes() + if cfg.ReadOnlyScopes != "" { + fmt.Printf("%s (custom)\n", value) + } else { + fmt.Printf("%s (default)\n", value) + } case "region": if cfg.Region != "" { fmt.Printf("%s (custom)\n", cfg.Region) @@ -115,7 +130,7 @@ var configGetCmd = &cobra.Command{ fmt.Println("us (default)") } default: - return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes, region)", key) + return fmt.Errorf("unknown config key: %s (valid: %s)", key, configKeys) } return nil diff --git a/cmd/login.go b/cmd/login.go index 6418d11..a16ae6a 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -8,6 +8,7 @@ import ( "github.com/Cloverhound/webex-cli/internal/appconfig" "github.com/Cloverhound/webex-cli/internal/auth" "github.com/Cloverhound/webex-cli/internal/localconfig" + "github.com/Cloverhound/webex-cli/internal/readonly" "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -15,8 +16,15 @@ import ( var loginCmd = &cobra.Command{ Use: "login", Short: "Log in to Webex via OAuth", - Long: "Opens a browser for Webex OAuth login. Stores tokens in the OS keyring for the authenticated user.", + Long: `Opens a browser for Webex OAuth login. Stores tokens in the OS keyring for the authenticated user. + +With --read-only, the login requests only read scopes and turns on read-only +mode: stored logins with write access are deleted from the keyring, write +requests are refused, and only read-only logins can be used. Leaving read-only +mode requires a plain 'webex login' from an interactive terminal.`, RunE: func(cmd *cobra.Command, args []string) error { + readOnlyLogin, _ := cmd.Flags().GetBool("read-only") + cfg, err := appconfig.Load() if err != nil { return fmt.Errorf("loading config: %w", err) @@ -25,11 +33,28 @@ var loginCmd = &cobra.Command{ clientID := cfg.EffectiveClientID() clientSecret := cfg.EffectiveClientSecret() scopes := cfg.EffectiveScopes() + if readOnlyLogin { + scopes = cfg.EffectiveReadOnlyScopes() + if err := readonly.ValidateScopes(scopes); err != nil { + return fmt.Errorf("read-only-scopes config: %w", err) + } + fmt.Println("Read-only login: stored logins with write access will be removed.") + } else if readOnlyMode(cfg) { + if err := confirmLeaveReadOnly(); err != nil { + return err + } + } result, err := auth.Login(clientID, clientSecret, scopes) if err != nil { return err } + if readOnlyLogin { + if err := readonly.ValidateScopes(result.Token.Scopes); err != nil { + return fmt.Errorf("granted scopes include write access, token not saved: %w", err) + } + result.Token.ReadOnly = true + } // Offer to store OAuth credentials with this user account before saving the token. // This allows each user to have independent client credentials for token refresh. @@ -45,6 +70,12 @@ var loginCmd = &cobra.Command{ // Update config cfg.AddUser(result.Email, result.DisplayName, result.OrgID, result.OrgName) cfg.SetDefaultUser(result.Email) + cfg.ReadOnly = readOnlyLogin + var purged []string + var purgeErr error + if readOnlyLogin { + purged, purgeErr = auth.PurgeWriteTokens(cfg) + } if err := cfg.Save(); err != nil { return fmt.Errorf("saving config: %w", err) } @@ -53,7 +84,17 @@ var loginCmd = &cobra.Command{ if result.OrgName != "" { orgInfo = fmt.Sprintf(" — %s", result.OrgName) } - fmt.Printf("Logged in as %s (%s)%s\n\n", result.DisplayName, result.Email, orgInfo) + fmt.Printf("Logged in as %s (%s)%s\n", result.DisplayName, result.Email, orgInfo) + if readOnlyLogin { + fmt.Println("Read-only mode is on.") + for _, email := range purged { + fmt.Printf("Removed write-capable login: %s\n", email) + } + if purgeErr != nil { + return purgeErr + } + } + fmt.Println() // Offer to associate this user with the current folder if cwd, err := os.Getwd(); err == nil { @@ -130,5 +171,6 @@ func promptFolderAssociation(email, dir string) { } func init() { + loginCmd.Flags().Bool("read-only", false, "Log in with read-only scopes and turn on read-only mode") rootCmd.AddCommand(loginCmd) } diff --git a/cmd/mcp/dispatcher.go b/cmd/mcp/dispatcher.go index 7a0717a..d150877 100644 --- a/cmd/mcp/dispatcher.go +++ b/cmd/mcp/dispatcher.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Cloverhound/webex-cli/internal/readonly" mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -30,21 +31,7 @@ var blockedPrefixes = []string{ // exec.Command does not shell-eval, but these characters indicate misuse. var dangerousChars = []string{"$", "`", "|", ";", "&&"} -// readPrefixes are action verbs (and their hyphenated variants) that map to GET API calls. -var readPrefixes = []string{ - "list", - "get", - "download", - "export", - "search", - "status", - "describe", - "query", - "show", - "fetch", -} - -func registerTools(s *server.MCPServer, lg *usageLogger) { +func registerTools(s *server.MCPServer, lg *usageLogger, readOnly bool) { s.AddTool( mcplib.NewTool("webex_read", mcplib.WithDescription( @@ -66,25 +53,27 @@ func registerTools(s *server.MCPServer, lg *usageLogger) { makeDispatchHandler(lg, true), ) - s.AddTool( - mcplib.NewTool("webex_write", - mcplib.WithDescription( - "Execute a Webex CLI command that creates, updates, or deletes a resource. "+ - "Maps to POST, PUT, PATCH, or DELETE API calls — requires explicit approval. "+ - "Provide the command without the 'webex' prefix "+ - "(e.g. 'messaging messages create'). "+ - "Use webex_read for list/get/download/export operations.", + if !readOnly { + s.AddTool( + mcplib.NewTool("webex_write", + mcplib.WithDescription( + "Execute a Webex CLI command that creates, updates, or deletes a resource. "+ + "Maps to POST, PUT, PATCH, or DELETE API calls — requires explicit approval. "+ + "Provide the command without the 'webex' prefix "+ + "(e.g. 'messaging messages create'). "+ + "Use webex_read for list/get/download/export operations.", + ), + mcplib.WithString("command", + mcplib.Required(), + mcplib.Description("Write CLI command without 'webex' prefix (e.g. 'messaging messages create', 'cc team update', 'admin people delete')"), + ), + mcplib.WithString("flags", + mcplib.Description(`Optional flags as a JSON object string (e.g. {"room-id": "xxx", "text": "Hello"}). Keys are flag names without '--'.`), + ), ), - mcplib.WithString("command", - mcplib.Required(), - mcplib.Description("Write CLI command without 'webex' prefix (e.g. 'messaging messages create', 'cc team update', 'admin people delete')"), - ), - mcplib.WithString("flags", - mcplib.Description(`Optional flags as a JSON object string (e.g. {"room-id": "xxx", "text": "Hello"}). Keys are flag names without '--'.`), - ), - ), - makeDispatchHandler(lg, false), - ) + makeDispatchHandler(lg, false), + ) + } s.AddTool( mcplib.NewTool("webex_help", @@ -156,7 +145,7 @@ func makeDispatchHandler(lg *usageLogger, readOnly bool) func(context.Context, m // Read-only enforcement for webex_read. if readOnly { action := actionToken(args) - if action != "" && !isReadAction(action) { + if action != "" && !readonly.IsReadAction(action) { return mcplib.NewToolResultText(fmt.Sprintf( "Error: '%s' is a write operation (POST/PUT/PATCH/DELETE). Use the webex_write tool instead.", action, )), nil @@ -252,16 +241,6 @@ func actionToken(args []string) string { } } -// isReadAction returns true if the verb maps to a GET API call. -func isReadAction(action string) bool { - for _, p := range readPrefixes { - if action == p || strings.HasPrefix(action, p+"-") { - return true - } - } - return false -} - func handleHelp(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { command := strings.TrimSpace(req.GetString("command", "")) diff --git a/cmd/mcp/dispatcher_test.go b/cmd/mcp/dispatcher_test.go new file mode 100644 index 0000000..b882dd8 --- /dev/null +++ b/cmd/mcp/dispatcher_test.go @@ -0,0 +1,21 @@ +package mcp + +import ( + "testing" + + "github.com/mark3labs/mcp-go/server" +) + +func TestReadOnlyServerOmitsWriteTool(t *testing.T) { + for _, readOnly := range []bool{false, true} { + s := server.NewMCPServer("test", "0", server.WithToolCapabilities(false)) + registerTools(s, nil, readOnly) + + if s.GetTool("webex_read") == nil { + t.Errorf("readOnly=%v: webex_read missing", readOnly) + } + if hasWrite := s.GetTool("webex_write") != nil; hasWrite == readOnly { + t.Errorf("readOnly=%v: webex_write registered = %v", readOnly, hasWrite) + } + } +} diff --git a/cmd/mcp/serve.go b/cmd/mcp/serve.go index 4a2dac2..dbed693 100644 --- a/cmd/mcp/serve.go +++ b/cmd/mcp/serve.go @@ -11,6 +11,7 @@ import ( "time" cmd "github.com/Cloverhound/webex-cli/cmd" + "github.com/Cloverhound/webex-cli/internal/config" "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" ) @@ -46,6 +47,9 @@ Tools: webex_help Get help text for any command or command group webex_usage Query the MCP usage log (recent commands, timing) +In read-only mode (webex login --read-only, or WEBEX_READ_ONLY=1) the server +does not register webex_write. + Resources: webex://commands JSON array of all available CLI commands webex://usage Last 50 lines of the raw usage log @@ -80,7 +84,10 @@ Usage log flags: server.WithToolCapabilities(false), ) registerResources(s, logPath) - registerTools(s, lg) + registerTools(s, lg, config.ReadOnly()) + if config.ReadOnly() { + fmt.Fprintln(os.Stderr, "Read-only mode: webex_write is not available") + } if flagHTTP { host, _, err := net.SplitHostPort(flagHTTPAddr) diff --git a/cmd/readonly.go b/cmd/readonly.go new file mode 100644 index 0000000..9ea9896 --- /dev/null +++ b/cmd/readonly.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + + "github.com/Cloverhound/webex-cli/internal/appconfig" + "github.com/charmbracelet/huh" + "golang.org/x/term" +) + +const readOnlyEnv = "WEBEX_READ_ONLY" + +// readOnlyEnvSet reports whether $WEBEX_READ_ONLY asks for read-only mode. +func readOnlyEnvSet() bool { + on, _ := strconv.ParseBool(os.Getenv(readOnlyEnv)) + return on +} + +// readOnlyMode reports whether read-only mode is on. The environment can turn +// it on for one process but never off. +func readOnlyMode(cfg *appconfig.Config) bool { + return cfg.ReadOnly || readOnlyEnvSet() +} + +// confirmLeaveReadOnly asks the person at the terminal to approve a login with +// write access. Agents usually run commands without a terminal, so requiring +// one keeps them from leaving read-only mode on their own. +func confirmLeaveReadOnly() error { + if readOnlyEnvSet() { + return fmt.Errorf("$%s is set; unset it to log in with write access", readOnlyEnv) + } + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + return fmt.Errorf("read-only mode is on; leaving it requires 'webex login' from an interactive terminal") + } + + var leave bool + err := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Leave read-only mode?"). + Description("This logs in with write access. Only continue if you started this login yourself."). + Affirmative("Leave read-only mode"). + Negative("Cancel"). + Value(&leave), + ), + ).Run() + if err != nil { + return err + } + if !leave { + return fmt.Errorf("cancelled; still in read-only mode") + } + return nil +} diff --git a/cmd/root.go b/cmd/root.go index dad88ee..9e8d892 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "github.com/Cloverhound/webex-cli/internal/config" "github.com/Cloverhound/webex-cli/internal/localconfig" "github.com/Cloverhound/webex-cli/internal/output" + "github.com/Cloverhound/webex-cli/internal/readonly" "github.com/spf13/cobra" ) @@ -63,6 +64,9 @@ var rootCmd = &cobra.Command{ return err } + readOnly := readOnlyMode(cfg) + config.SetReadOnly(readOnly, readonly.IsReadAction(cmd.Name())) + // Skip auth for certain commands if skipAuth(cmd) { return nil @@ -83,7 +87,7 @@ var rootCmd = &cobra.Command{ } } - result, err := auth.ResolveToken(flagToken, envToken, userFlag, envUser, cfg) + result, err := auth.ResolveToken(flagToken, envToken, userFlag, envUser, cfg, readOnly) if err != nil { return err } diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index 75b3342..787c665 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/Cloverhound/webex-cli/internal/readonly" ) // Compiled-in defaults (set via ldflags or hardcoded). @@ -29,6 +31,8 @@ type Config struct { DefaultOrgID string `json:"default_org_id,omitempty"` DefaultOrgName string `json:"default_org_name,omitempty"` Region string `json:"region,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + ReadOnlyScopes string `json:"read_only_scopes,omitempty"` path string // file path, not serialized } @@ -107,6 +111,14 @@ func (c *Config) EffectiveScopes() string { return DefaultScopes } +// EffectiveReadOnlyScopes returns the scopes requested by `webex login --read-only`. +func (c *Config) EffectiveReadOnlyScopes() string { + if c.ReadOnlyScopes != "" { + return c.ReadOnlyScopes + } + return readonly.DefaultScopes +} + func (c *Config) SetDefaultUser(email string) { c.DefaultUser = email } diff --git a/internal/audio/audio.go b/internal/audio/audio.go index 29848ca..4d4dcc4 100644 --- a/internal/audio/audio.go +++ b/internal/audio/audio.go @@ -104,6 +104,10 @@ func UploadMultipart(method, url string, parts []MultipartPart) ([]byte, int, er } } + if config.ReadOnly() { + return nil, 0, client.ReadOnlyError(method, url) + } + if config.DryRun() { fmt.Fprintf(os.Stderr, "[DRY RUN] %s %s\n", method, url) fmt.Fprintf(os.Stderr, "[DRY RUN] Content-Type: %s\n", contentType) diff --git a/internal/auth/keyring.go b/internal/auth/keyring.go index 3e6a320..b4b7b2f 100644 --- a/internal/auth/keyring.go +++ b/internal/auth/keyring.go @@ -19,6 +19,8 @@ type StoredToken struct { IssuedAt time.Time `json:"issued_at"` ClientID string `json:"client_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` + Scopes string `json:"scopes,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` } // IsExpired returns true if the access token is expired or within 60s of expiry. diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index ffd95dd..232a87c 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -110,7 +110,7 @@ func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { } // Exchange code for tokens - tok, err := exchangeCode(clientID, clientSecret, code, verifier) + tok, err := exchangeCode(clientID, clientSecret, code, verifier, scopes) if err != nil { return nil, err } @@ -165,6 +165,8 @@ func RefreshAccessToken(clientID, clientSecret string, tok *StoredToken) (*Store return nil, fmt.Errorf("parsing refresh response: %w", err) } + // A refresh keeps the original grant, so the scopes and the per-account + // credentials carry over unchanged. now := time.Now() return &StoredToken{ AccessToken: tokenResp.AccessToken, @@ -172,6 +174,10 @@ func RefreshAccessToken(clientID, clientSecret string, tok *StoredToken) (*Store ExpiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second), TokenType: tokenResp.TokenType, IssuedAt: now, + ClientID: tok.ClientID, + ClientSecret: tok.ClientSecret, + Scopes: tok.Scopes, + ReadOnly: tok.ReadOnly, }, nil } @@ -180,9 +186,10 @@ type tokenResponse struct { RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` + Scope string `json:"scope"` } -func exchangeCode(clientID, clientSecret, code, verifier string) (*StoredToken, error) { +func exchangeCode(clientID, clientSecret, code, verifier, scopes string) (*StoredToken, error) { form := url.Values{ "grant_type": {"authorization_code"}, "client_id": {clientID}, @@ -207,6 +214,10 @@ func exchangeCode(clientID, clientSecret, code, verifier string) (*StoredToken, if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("parsing token response: %w", err) } + // Prefer the granted scopes when Webex reports them; otherwise record the request. + if tokenResp.Scope != "" { + scopes = tokenResp.Scope + } now := time.Now() return &StoredToken{ @@ -215,6 +226,7 @@ func exchangeCode(clientID, clientSecret, code, verifier string) (*StoredToken, ExpiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second), TokenType: tokenResp.TokenType, IssuedAt: now, + Scopes: scopes, }, nil } diff --git a/internal/auth/readonly.go b/internal/auth/readonly.go new file mode 100644 index 0000000..a99fa11 --- /dev/null +++ b/internal/auth/readonly.go @@ -0,0 +1,40 @@ +package auth + +import ( + "fmt" + "sort" + + "github.com/Cloverhound/webex-cli/internal/appconfig" +) + +// HasReadOnlyToken reports whether the keyring holds a read-only token for email. +func HasReadOnlyToken(email string) bool { + tok, err := LoadToken(email) + return err == nil && tok.ReadOnly +} + +// PurgeWriteTokens deletes every stored token that is not read-only and removes +// its user from cfg. It returns the removed emails, sorted. Read-only mode +// depends on this: the keyring is readable by any process running as the user, +// so a write-capable token left there would bypass the CLI's checks. +func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { + var removed, failed []string + for _, email := range cfg.UserEmails() { + tok, err := LoadToken(email) + if err != nil || tok.ReadOnly { + continue + } + if err := DeleteToken(email); err != nil { + failed = append(failed, email) + continue + } + cfg.RemoveUser(email) + removed = append(removed, email) + } + sort.Strings(removed) + if len(failed) > 0 { + sort.Strings(failed) + return removed, fmt.Errorf("could not remove write-capable tokens for %v from the keyring; remove them manually", failed) + } + return removed, nil +} diff --git a/internal/auth/readonly_test.go b/internal/auth/readonly_test.go new file mode 100644 index 0000000..f121c02 --- /dev/null +++ b/internal/auth/readonly_test.go @@ -0,0 +1,95 @@ +package auth + +import ( + "strings" + "testing" + "time" + + "github.com/Cloverhound/webex-cli/internal/appconfig" + "github.com/zalando/go-keyring" +) + +func testConfig(emails ...string) *appconfig.Config { + cfg := &appconfig.Config{Users: map[string]appconfig.UserInfo{}} + for _, e := range emails { + cfg.AddUser(e, e, "org", "Org") + } + return cfg +} + +func saveTestToken(t *testing.T, email string, readOnly bool) { + t.Helper() + tok := &StoredToken{ + AccessToken: "tok-" + email, + ExpiresAt: time.Now().Add(time.Hour), + IssuedAt: time.Now(), + ReadOnly: readOnly, + } + if err := SaveToken(email, tok); err != nil { + t.Fatal(err) + } +} + +func TestPurgeWriteTokens(t *testing.T) { + keyring.MockInit() + cfg := testConfig("ro@example.com", "rw@example.com", "rw2@example.com", "gone@example.com") + cfg.SetDefaultUser("rw@example.com") + saveTestToken(t, "ro@example.com", true) + saveTestToken(t, "rw@example.com", false) + saveTestToken(t, "rw2@example.com", false) + + removed, err := PurgeWriteTokens(cfg) + if err != nil { + t.Fatal(err) + } + if strings.Join(removed, ",") != "rw2@example.com,rw@example.com" { + t.Errorf("removed = %v", removed) + } + for _, e := range removed { + if _, err := LoadToken(e); err == nil { + t.Errorf("token for %s still in keyring", e) + } + if _, ok := cfg.Users[e]; ok { + t.Errorf("%s still in config", e) + } + } + if cfg.DefaultUser != "" { + t.Errorf("default user = %q, want cleared", cfg.DefaultUser) + } + if !HasReadOnlyToken("ro@example.com") { + t.Error("read-only token was removed") + } + if _, ok := cfg.Users["gone@example.com"]; !ok { + t.Error("user without a token should be left alone") + } +} + +func TestResolveTokenReadOnly(t *testing.T) { + keyring.MockInit() + cfg := testConfig("ro@example.com", "rw@example.com") + cfg.SetDefaultUser("ro@example.com") + saveTestToken(t, "ro@example.com", true) + saveTestToken(t, "rw@example.com", false) + + if _, err := ResolveToken("flag-token", "", "", "", cfg, true); err == nil { + t.Error("--token accepted in read-only mode") + } + if _, err := ResolveToken("", "env-token", "", "", cfg, true); err == nil { + t.Error("$WEBEX_TOKEN accepted in read-only mode") + } + if _, err := ResolveToken("", "", "rw@example.com", "", cfg, true); err == nil { + t.Error("write-capable user accepted in read-only mode") + } + + res, err := ResolveToken("", "", "", "", cfg, true) + if err != nil { + t.Fatal(err) + } + if res.Token != "tok-ro@example.com" { + t.Errorf("token = %q", res.Token) + } + + if _, err := ResolveToken("", "", "rw@example.com", "", cfg, false); err != nil { + t.Errorf("write-capable user rejected outside read-only mode: %v", err) + } +} diff --git a/internal/auth/resolve.go b/internal/auth/resolve.go index 204b84c..5b066ce 100644 --- a/internal/auth/resolve.go +++ b/internal/auth/resolve.go @@ -41,7 +41,15 @@ type ResolveResult struct { // 1. --token flag // 2. $WEBEX_TOKEN env // 3. Keyring (resolve user from --user flag → $WEBEX_USER → config default) -func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig.Config) (*ResolveResult, error) { +// +// In read-only mode only keyring tokens from `webex login --read-only` are accepted. +func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig.Config, readOnly bool) (*ResolveResult, error) { + // Webex has no public token introspection, so the scopes of a token the CLI + // did not obtain itself cannot be checked. + if readOnly && (flagToken != "" || envToken != "") { + return nil, fmt.Errorf("read-only mode does not accept --token or $WEBEX_TOKEN; use a stored read-only login") + } + // 1. Explicit --token flag if flagToken != "" { return &ResolveResult{Token: flagToken, Source: SourceFlag}, nil @@ -64,9 +72,16 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. return nil, fmt.Errorf("no authenticated user — run: webex login") } + loginCmd := "webex login" + if readOnly { + loginCmd = "webex login --read-only" + } tok, err := LoadToken(email) if err != nil { - return nil, fmt.Errorf("no token for %s — run: webex login", email) + return nil, fmt.Errorf("no token for %s — run: %s", email, loginCmd) + } + if readOnly && !tok.ReadOnly { + return nil, fmt.Errorf("read-only mode: %s has no read-only login — run: %s", email, loginCmd) } // Auto-refresh if expired @@ -76,9 +91,6 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. if err != nil { return nil, fmt.Errorf("token expired for %s and refresh failed: %w\nRun: webex login", email, err) } - // Carry per-token credentials forward into the refreshed token - refreshed.ClientID = tok.ClientID - refreshed.ClientSecret = tok.ClientSecret tok = refreshed // Persist the refreshed token if saveErr := SaveToken(email, tok); saveErr != nil { @@ -112,8 +124,6 @@ func MakeRefresher(email string, cfg *appconfig.Config) func() (string, error) { if err != nil { return "", err } - refreshed.ClientID = tok.ClientID - refreshed.ClientSecret = tok.ClientSecret if saveErr := SaveToken(email, refreshed); saveErr != nil { fmt.Printf("Warning: could not save refreshed token: %v\n", saveErr) } diff --git a/internal/client/client.go b/internal/client/client.go index b2b3e06..c5a0b7a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -18,6 +18,14 @@ import ( // ErrDryRun is returned when a write operation is intercepted by --dry-run mode. var ErrDryRun = errors.New("dry run: no changes made") +// ErrReadOnly is returned when read-only mode blocks a request. +var ErrReadOnly = errors.New("read-only mode") + +// ReadOnlyError reports a request blocked by read-only mode. +func ReadOnlyError(method, url string) error { + return fmt.Errorf("%w: %s %s not sent — run 'webex login' to leave read-only mode", ErrReadOnly, method, url) +} + // Do executes an HTTP request. // On a 401 it refreshes the token and retries once. // On a 429 it reads Retry-After and retries up to 3 times. @@ -159,6 +167,10 @@ func doOnce(req *Request) ([]byte, int, http.Header, error) { } } + if !config.ReadOnlyAllows(req.method) { + return nil, 0, nil, ReadOnlyError(req.method, url) + } + // Dry-run: intercept write operations before making the HTTP call if config.DryRun() && isWriteMethod(req.method) { fmt.Fprintf(os.Stderr, "[DRY RUN] %s %s\n", req.method, url) diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..9ce5e91 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,47 @@ +package client + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Cloverhound/webex-cli/internal/config" +) + +func TestReadOnlyBlocksWritesBeforeSending(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Write([]byte(`{}`)) + })) + defer srv.Close() + defer config.SetReadOnly(false, false) + + config.SetReadOnly(true, true) + + for _, method := range []string{"PUT", "PATCH", "DELETE"} { + _, _, err := Do(NewRequest(srv.URL, method, "/things/1")) + if !errors.Is(err, ErrReadOnly) { + t.Errorf("%s: got err %v, want ErrReadOnly", method, err) + } + } + if hits != 0 { + t.Fatalf("blocked requests reached the server %d times", hits) + } + + if _, _, err := Do(NewRequest(srv.URL, "GET", "/things")); err != nil { + t.Fatalf("GET: %v", err) + } + if _, _, err := Do(NewRequest(srv.URL, "POST", "/search")); err != nil { + t.Fatalf("query POST: %v", err) + } + + config.SetReadOnly(true, false) + if _, _, err := Do(NewRequest(srv.URL, "POST", "/things")); !errors.Is(err, ErrReadOnly) { + t.Fatalf("write POST: got err %v, want ErrReadOnly", err) + } + if hits != 2 { + t.Fatalf("server hits = %d, want 2", hits) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b93cda4..960661c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,34 @@ func Paginate() bool { return paginate } func SetDryRun(d bool) { dryRun = d } func DryRun() bool { return dryRun } +var ( + readOnly bool + readOnlyPOSTQuery bool +) + +// SetReadOnly turns on read-only request checks for this process. postIsQuery +// allows POST for commands whose verb marks them as reads: several Webex +// search and list endpoints take their filters in a POST body. +func SetReadOnly(on, postIsQuery bool) { + readOnly = on + readOnlyPOSTQuery = on && postIsQuery +} +func ReadOnly() bool { return readOnly } + +// ReadOnlyAllows reports whether an HTTP method may be sent in the current mode. +func ReadOnlyAllows(method string) bool { + if !readOnly { + return true + } + switch strings.ToUpper(method) { + case "GET", "HEAD", "OPTIONS": + return true + case "POST": + return readOnlyPOSTQuery + } + return false +} + // SetOrgID stores the org ID in both UUID and base64 formats. // Accepts either format as input and derives the other. func SetOrgID(id string) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a8522a7 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,28 @@ +package config + +import "testing" + +func TestReadOnlyAllows(t *testing.T) { + defer SetReadOnly(false, false) + + cases := []struct { + on, postIsQuery bool + method string + want bool + }{ + {false, false, "DELETE", true}, + {true, false, "GET", true}, + {true, false, "POST", false}, + {true, true, "POST", true}, + {true, true, "PUT", false}, + {true, true, "PATCH", false}, + {true, true, "DELETE", false}, + {false, true, "POST", true}, + } + for _, c := range cases { + SetReadOnly(c.on, c.postIsQuery) + if got := ReadOnlyAllows(c.method); got != c.want { + t.Errorf("readOnly=%v postIsQuery=%v %s: got %v, want %v", c.on, c.postIsQuery, c.method, got, c.want) + } + } +} diff --git a/internal/readonly/readonly.go b/internal/readonly/readonly.go new file mode 100644 index 0000000..161c88b --- /dev/null +++ b/internal/readonly/readonly.go @@ -0,0 +1,109 @@ +// Package readonly classifies OAuth scopes and CLI actions for read-only mode. +package readonly + +import ( + "fmt" + "strings" +) + +// DefaultScopes are requested by `webex login --read-only`. Every entry must be +// registered on the OAuth integration, or Webex rejects the login with invalid_scope. +var DefaultScopes = strings.Join([]string{ + "spark:kms", + "spark:people_read", + "spark:rooms_read", + "spark:memberships_read", + "spark:messages_read", + "spark:teams_read", + "spark:team_memberships_read", + "spark:devices_read", + "spark:recordings_read", + "spark:telephony_config_read", + "spark:workspaces_read", + "spark-admin:people_read", + "spark-admin:organizations_read", + "spark-admin:licenses_read", + "spark-admin:roles_read", + "spark-admin:locations_read", + "spark-admin:devices_read", + "spark-admin:workspaces_read", + "spark-admin:workspace_locations_read", + "spark-admin:telephony_config_read", + "spark-admin:telephony_pstn_read", + "spark-admin:calling_cdr_read", + "spark-admin:recordings_read", + "spark-admin:reports_read", + "spark-admin:hybrid_clusters_read", + "spark-admin:hybrid_connectors_read", + "meeting:schedules_read", + "meeting:recordings_read", + "meeting:transcripts_read", + "meeting:participants_read", + "meeting:preferences_read", + "meeting:admin_schedule_read", + "meeting:admin_recordings_read", + "meeting:admin_participants_read", + "meeting:admin_transcripts_read", + "cjp:config_read", + "analytics:read_all", + "audit:events_read", +}, " ") + +// spark:kms grants access to message encryption keys, which reading message +// content requires; it allows no changes on its own. +var readScopeExceptions = map[string]bool{ + "spark:kms": true, +} + +// IsReadScope reports whether an OAuth scope grants read access only. +func IsReadScope(scope string) bool { + if readScopeExceptions[scope] { + return true + } + return strings.HasSuffix(scope, "_read") || + strings.HasSuffix(scope, ":read") || + strings.HasSuffix(scope, ":read_all") +} + +// ValidateScopes returns an error naming every scope in a space-separated list +// that is not read-only. +func ValidateScopes(scopes string) error { + fields := strings.Fields(scopes) + if len(fields) == 0 { + return fmt.Errorf("no read-only scopes configured") + } + var bad []string + for _, s := range fields { + if !IsReadScope(s) { + bad = append(bad, s) + } + } + if len(bad) > 0 { + return fmt.Errorf("not read-only scopes: %s", strings.Join(bad, ", ")) + } + return nil +} + +// readActions are command verbs (and their hyphenated variants) that only read data. +var readActions = []string{ + "list", + "get", + "download", + "export", + "search", + "status", + "describe", + "query", + "show", + "fetch", +} + +// IsReadAction reports whether a command verb such as "list" or "get-details" only reads data. +func IsReadAction(action string) bool { + for _, p := range readActions { + if action == p || strings.HasPrefix(action, p+"-") { + return true + } + } + return false +} diff --git a/internal/readonly/readonly_test.go b/internal/readonly/readonly_test.go new file mode 100644 index 0000000..537abaa --- /dev/null +++ b/internal/readonly/readonly_test.go @@ -0,0 +1,66 @@ +package readonly + +import "testing" + +func TestIsReadScope(t *testing.T) { + cases := map[string]bool{ + "spark-admin:people_read": true, + "spark:kms": true, + "analytics:read_all": true, + "cjp-hybrid-conn:read": true, + "spark:all": false, + "spark-admin:all": false, + "spark-admin:people_write": false, + "cjp:config": false, + "identity:people_rw": false, + "spark-admin:telephony_read_only": false, + } + for scope, want := range cases { + if got := IsReadScope(scope); got != want { + t.Errorf("IsReadScope(%q) = %v, want %v", scope, got, want) + } + } +} + +func TestDefaultScopesAreReadOnly(t *testing.T) { + if err := ValidateScopes(DefaultScopes); err != nil { + t.Fatal(err) + } +} + +func TestValidateScopesRejectsWriteScopes(t *testing.T) { + err := ValidateScopes("spark:people_read spark:all cjp:config_write") + if err == nil { + t.Fatal("expected error") + } + want := "not read-only scopes: spark:all, cjp:config_write" + if err.Error() != want { + t.Errorf("got %q, want %q", err, want) + } +} + +func TestValidateScopesRejectsEmpty(t *testing.T) { + if err := ValidateScopes(" "); err == nil { + t.Fatal("expected error") + } +} + +func TestIsReadAction(t *testing.T) { + cases := map[string]bool{ + "list": true, + "list-by-ids": true, + "get-dynamic-settings": true, + "search": true, + "create": false, + "update": false, + "delete": false, + "listen": false, + "getaway": false, + "assign-phone-number": false, + } + for action, want := range cases { + if got := IsReadAction(action); got != want { + t.Errorf("IsReadAction(%q) = %v, want %v", action, got, want) + } + } +} diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 6012e25..5f71718 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -27,6 +27,26 @@ You can also use a token directly: webex --token "$WEBEX_TOKEN" calling people list ``` +### Read-only mode + +Use read-only mode when an AI agent or script should be able to read your Webex data but not change it. + +```bash +webex login --read-only +``` + +This login requests only read scopes (`webex config get read-only-scopes` lists them) and turns on read-only mode: + +- Stored logins with write access are deleted from the OS keyring. Other programs running as you can read the keyring, so leaving those logins there would let them bypass read-only mode. +- `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for commands whose action reads data, such as `list`, `get`, `search`, and `query`. Webex also rejects writes because the token has no write scopes. +- `--token` and `$WEBEX_TOKEN` are refused, because the CLI cannot check the scopes of a token it did not create. +- `auth switch`, `--user`, and folder defaults work only for users who logged in with `--read-only`. +- `webex mcp serve` does not register the `webex_write` tool. + +To leave read-only mode, run `webex login` from an interactive terminal and confirm the prompt. The command refuses to run without a terminal, so an agent running shell commands cannot leave read-only mode on its own. Setting `WEBEX_READ_ONLY=1` turns on the same checks for one process. It never turns them off. + +The OAuth integration must have every read-only scope registered. If login fails with `invalid_scope`, set a list that matches your integration with `webex config set read-only-scopes ""`. Only read scopes are accepted. + ## Command Structure ``` diff --git a/skill/SKILL.md b/skill/SKILL.md index b1aa0a4..efde0e1 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -41,6 +41,8 @@ Token resolution order: `--token` flag > `WEBEX_TOKEN` env var > `--user` flag > Org resolution order: `--organization` flag > `auth set-org` override > login user's home org. +**Read-only mode:** When `webex auth status` shows `Mode: read-only`, the stored login has read scopes only. Write requests fail with `read-only mode`, `--token`/`$WEBEX_TOKEN` are refused, and only users marked `(read-only)` in `webex auth list` can be used. Do not try to leave read-only mode or work around it; tell the user which change you need and let them decide. Leaving it requires the user to run `webex login` in an interactive terminal. + ## Command Structure ``` @@ -137,6 +139,8 @@ The CLI includes a built-in MCP server exposing four tools: | `webex_help` | — | Discover commands and flags | | `webex_usage` | — | View recent MCP command history | +In read-only mode the server does not register `webex_write`. + **Claude Code / stdio clients** — no server process needed: ```bash claude mcp add webex -- webex mcp serve From c550c1c7d11f84883d10b09c545ccb94a07ecca7 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 15:38:46 -0600 Subject: [PATCH 2/6] Align read-only scopes with the registered integration scopes Use every read scope the release build requests, plus the user-level read scopes registered for read-only mode, and accept spark:xapi_statuses as a read scope. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3 --- internal/readonly/readonly.go | 20 +++++++++++++++++--- internal/readonly/readonly_test.go | 3 +++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/internal/readonly/readonly.go b/internal/readonly/readonly.go index 161c88b..dd4b2d8 100644 --- a/internal/readonly/readonly.go +++ b/internal/readonly/readonly.go @@ -10,6 +10,7 @@ import ( // registered on the OAuth integration, or Webex rejects the login with invalid_scope. var DefaultScopes = strings.Join([]string{ "spark:kms", + "spark:xapi_statuses", "spark:people_read", "spark:rooms_read", "spark:memberships_read", @@ -25,16 +26,22 @@ var DefaultScopes = strings.Join([]string{ "spark-admin:licenses_read", "spark-admin:roles_read", "spark-admin:locations_read", + "spark-admin:places_read", "spark-admin:devices_read", "spark-admin:workspaces_read", "spark-admin:workspace_locations_read", + "spark-admin:workspace_metrics_read", "spark-admin:telephony_config_read", "spark-admin:telephony_pstn_read", "spark-admin:calling_cdr_read", + "spark-admin:call_qualities_read", "spark-admin:recordings_read", "spark-admin:reports_read", + "spark-admin:messages_read", "spark-admin:hybrid_clusters_read", "spark-admin:hybrid_connectors_read", + "spark-admin:resource_groups_read", + "spark-admin:resource_group_memberships_read", "meeting:schedules_read", "meeting:recordings_read", "meeting:transcripts_read", @@ -44,15 +51,22 @@ var DefaultScopes = strings.Join([]string{ "meeting:admin_recordings_read", "meeting:admin_participants_read", "meeting:admin_transcripts_read", + "meeting:admin_preferences_read", + "meeting:admin_config_read", + "identity:people_read", + "identity:organizations_read", + "identity:groups_read", "cjp:config_read", "analytics:read_all", "audit:events_read", }, " ") -// spark:kms grants access to message encryption keys, which reading message -// content requires; it allows no changes on its own. +// Read-only scopes whose names lack a _read suffix. spark:kms grants the +// encryption keys that reading message content requires; spark:xapi_statuses +// reads device status. Neither allows changes on its own. var readScopeExceptions = map[string]bool{ - "spark:kms": true, + "spark:kms": true, + "spark:xapi_statuses": true, } // IsReadScope reports whether an OAuth scope grants read access only. diff --git a/internal/readonly/readonly_test.go b/internal/readonly/readonly_test.go index 537abaa..b33f0ba 100644 --- a/internal/readonly/readonly_test.go +++ b/internal/readonly/readonly_test.go @@ -6,6 +6,9 @@ func TestIsReadScope(t *testing.T) { cases := map[string]bool{ "spark-admin:people_read": true, "spark:kms": true, + "spark:xapi_statuses": true, + "spark:xapi_commands": false, + "cjp:user": false, "analytics:read_all": true, "cjp-hybrid-conn:read": true, "spark:all": false, From 6934b4a6fbb7bbea1f9119c3d06a55ecd62a7f27 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 15:40:42 -0600 Subject: [PATCH 3/6] Inject default read-only scopes from a release secret Release builds set appconfig.DefaultReadOnlyScopes from WEBEX_READ_ONLY_SCOPES, matching how WEBEX_SCOPES sets DefaultScopes. Builds without the secret fall back to readonly.DefaultScopes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3 --- .github/workflows/release.yml | 1 + .goreleaser.yaml | 1 + internal/appconfig/appconfig.go | 7 +++++++ internal/readonly/readonly.go | 5 +++-- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16026ac..b14db5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,3 +29,4 @@ jobs: WEBEX_CLIENT_ID: ${{ secrets.WEBEX_CLIENT_ID }} WEBEX_CLIENT_SECRET: ${{ secrets.WEBEX_CLIENT_SECRET }} WEBEX_SCOPES: ${{ secrets.WEBEX_SCOPES }} + WEBEX_READ_ONLY_SCOPES: ${{ secrets.WEBEX_READ_ONLY_SCOPES }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 56f08ff..1414923 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -10,6 +10,7 @@ builds: - -X github.com/Cloverhound/webex-cli/internal/appconfig.DefaultClientID={{.Env.WEBEX_CLIENT_ID}} - -X github.com/Cloverhound/webex-cli/internal/appconfig.DefaultClientSecret={{.Env.WEBEX_CLIENT_SECRET}} - "-X 'github.com/Cloverhound/webex-cli/internal/appconfig.DefaultScopes={{.Env.WEBEX_SCOPES}}'" + - "-X 'github.com/Cloverhound/webex-cli/internal/appconfig.DefaultReadOnlyScopes={{.Env.WEBEX_READ_ONLY_SCOPES}}'" goos: - darwin - linux diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index 787c665..f990c79 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -14,6 +14,10 @@ var ( DefaultClientID = "" DefaultClientSecret = "" DefaultScopes = "spark:all spark-admin:all" + + // DefaultReadOnlyScopes is injected by release builds to match the OAuth + // integration's registered scopes. Empty means readonly.DefaultScopes. + DefaultReadOnlyScopes = "" ) type UserInfo struct { @@ -116,6 +120,9 @@ func (c *Config) EffectiveReadOnlyScopes() string { if c.ReadOnlyScopes != "" { return c.ReadOnlyScopes } + if DefaultReadOnlyScopes != "" { + return DefaultReadOnlyScopes + } return readonly.DefaultScopes } diff --git a/internal/readonly/readonly.go b/internal/readonly/readonly.go index dd4b2d8..4f78885 100644 --- a/internal/readonly/readonly.go +++ b/internal/readonly/readonly.go @@ -6,8 +6,9 @@ import ( "strings" ) -// DefaultScopes are requested by `webex login --read-only`. Every entry must be -// registered on the OAuth integration, or Webex rejects the login with invalid_scope. +// DefaultScopes are requested by `webex login --read-only` when the build does +// not inject appconfig.DefaultReadOnlyScopes. Every entry must be registered on +// the OAuth integration, or Webex rejects the login with invalid_scope. var DefaultScopes = strings.Join([]string{ "spark:kms", "spark:xapi_statuses", From f56160a808001d97918e4d171f5dcf681cdc16cf Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 17:50:05 -0600 Subject: [PATCH 4/6] Order read-only scopes to match the integration's registration Webex rejected valid read scope sets with invalid_scope unless they followed the order of the integration's scope list. Use the tested list, in portal order, and drop the four spark: read scopes the integration does not register. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3 --- README.md | 2 +- internal/readonly/readonly.go | 74 +++++++++++------------ site/src/content/docs/getting-started.mdx | 2 +- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 04f842f..c136ec9 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > OS keyring. - `--token` and `$WEBEX_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. - `webex mcp serve` does not register `webex_write`. -Leaving read-only mode requires `webex login` from an interactive terminal, with a confirmation prompt. `WEBEX_READ_ONLY=1` turns the same checks on for one process and never turns them off. If your OAuth integration lacks some of the default read scopes, set your own list with `webex config set read-only-scopes ""`. +Leaving read-only mode requires `webex login` from an interactive terminal, with a confirmation prompt. `WEBEX_READ_ONLY=1` turns the same checks on for one process and never turns them off. If your OAuth integration lacks some of the default read scopes, set your own list with `webex config set read-only-scopes ""`, in the same order as your integration's authorize URL on the developer portal. Webex can reject the same scopes in a different order. ### Organization Override diff --git a/internal/readonly/readonly.go b/internal/readonly/readonly.go index 4f78885..6e84690 100644 --- a/internal/readonly/readonly.go +++ b/internal/readonly/readonly.go @@ -9,57 +9,57 @@ import ( // DefaultScopes are requested by `webex login --read-only` when the build does // not inject appconfig.DefaultReadOnlyScopes. Every entry must be registered on // the OAuth integration, or Webex rejects the login with invalid_scope. +// +// Webex also rejects some valid scope sets unless they follow the order of the +// integration's own scope list (the authorize URL shown in the developer +// portal), so keep this list in that order rather than grouped or sorted. var DefaultScopes = strings.Join([]string{ - "spark:kms", - "spark:xapi_statuses", + "meeting:admin_preferences_read", "spark:people_read", - "spark:rooms_read", - "spark:memberships_read", + "analytics:read_all", + "meeting:admin_participants_read", "spark:messages_read", - "spark:teams_read", - "spark:team_memberships_read", - "spark:devices_read", - "spark:recordings_read", - "spark:telephony_config_read", - "spark:workspaces_read", - "spark-admin:people_read", - "spark-admin:organizations_read", - "spark-admin:licenses_read", - "spark-admin:roles_read", - "spark-admin:locations_read", "spark-admin:places_read", - "spark-admin:devices_read", - "spark-admin:workspaces_read", - "spark-admin:workspace_locations_read", "spark-admin:workspace_metrics_read", - "spark-admin:telephony_config_read", + "spark:rooms_read", + "identity:groups_read", + "spark-admin:recordings_read", "spark-admin:telephony_pstn_read", - "spark-admin:calling_cdr_read", + "spark-admin:workspace_locations_read", + "cjp:config_read", "spark-admin:call_qualities_read", - "spark-admin:recordings_read", - "spark-admin:reports_read", + "spark:kms", "spark-admin:messages_read", - "spark-admin:hybrid_clusters_read", - "spark-admin:hybrid_connectors_read", + "spark-admin:reports_read", + "meeting:admin_config_read", + "spark-admin:people_read", + "meeting:admin_transcripts_read", "spark-admin:resource_groups_read", - "spark-admin:resource_group_memberships_read", - "meeting:schedules_read", "meeting:recordings_read", - "meeting:transcripts_read", + "spark-admin:locations_read", "meeting:participants_read", - "meeting:preferences_read", - "meeting:admin_schedule_read", + "spark-admin:organizations_read", "meeting:admin_recordings_read", - "meeting:admin_participants_read", - "meeting:admin_transcripts_read", - "meeting:admin_preferences_read", - "meeting:admin_config_read", - "identity:people_read", + "meeting:transcripts_read", + "spark:xapi_statuses", + "spark:memberships_read", + "spark-admin:calling_cdr_read", "identity:organizations_read", - "identity:groups_read", - "cjp:config_read", - "analytics:read_all", + "spark-admin:devices_read", + "spark-admin:hybrid_clusters_read", + "spark-admin:telephony_config_read", + "meeting:admin_schedule_read", + "spark:team_memberships_read", + "meeting:schedules_read", + "spark-admin:roles_read", + "meeting:preferences_read", + "identity:people_read", + "spark-admin:workspaces_read", + "spark-admin:resource_group_memberships_read", "audit:events_read", + "spark-admin:hybrid_connectors_read", + "spark:teams_read", + "spark-admin:licenses_read", }, " ") // Read-only scopes whose names lack a _read suffix. spark:kms grants the diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 5f71718..97911fb 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -45,7 +45,7 @@ This login requests only read scopes (`webex config get read-only-scopes` lists To leave read-only mode, run `webex login` from an interactive terminal and confirm the prompt. The command refuses to run without a terminal, so an agent running shell commands cannot leave read-only mode on its own. Setting `WEBEX_READ_ONLY=1` turns on the same checks for one process. It never turns them off. -The OAuth integration must have every read-only scope registered. If login fails with `invalid_scope`, set a list that matches your integration with `webex config set read-only-scopes ""`. Only read scopes are accepted. +The OAuth integration must have every read-only scope registered. If login fails with `invalid_scope`, set a list that matches your integration with `webex config set read-only-scopes ""`. Only read scopes are accepted. List them in the same order as the scopes in your integration's authorize URL on the developer portal; Webex can reject the same scopes in a different order. ## Command Structure From a6ae83fd8a6f4c4533946a44f07896a4a55801bc Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 18:04:48 -0600 Subject: [PATCH 5/6] Restore grouped read-only scope order A live login with the same scopes grouped by type succeeded, so Webex does not require the portal's order. The earlier invalid_scope failures came from the four unregistered spark: read scopes and integration edits that had not yet propagated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3 --- README.md | 2 +- internal/readonly/readonly.go | 72 +++++++++++------------ site/src/content/docs/getting-started.mdx | 2 +- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index c136ec9..04f842f 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > OS keyring. - `--token` and `$WEBEX_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. - `webex mcp serve` does not register `webex_write`. -Leaving read-only mode requires `webex login` from an interactive terminal, with a confirmation prompt. `WEBEX_READ_ONLY=1` turns the same checks on for one process and never turns them off. If your OAuth integration lacks some of the default read scopes, set your own list with `webex config set read-only-scopes ""`, in the same order as your integration's authorize URL on the developer portal. Webex can reject the same scopes in a different order. +Leaving read-only mode requires `webex login` from an interactive terminal, with a confirmation prompt. `WEBEX_READ_ONLY=1` turns the same checks on for one process and never turns them off. If your OAuth integration lacks some of the default read scopes, set your own list with `webex config set read-only-scopes ""`. ### Organization Override diff --git a/internal/readonly/readonly.go b/internal/readonly/readonly.go index 6e84690..54dba1c 100644 --- a/internal/readonly/readonly.go +++ b/internal/readonly/readonly.go @@ -9,57 +9,55 @@ import ( // DefaultScopes are requested by `webex login --read-only` when the build does // not inject appconfig.DefaultReadOnlyScopes. Every entry must be registered on // the OAuth integration, or Webex rejects the login with invalid_scope. -// -// Webex also rejects some valid scope sets unless they follow the order of the -// integration's own scope list (the authorize URL shown in the developer -// portal), so keep this list in that order rather than grouped or sorted. +// spark:{devices,recordings,telephony_config,workspaces}_read are not registered +// on the release integration; their spark-admin counterparts cover those reads. var DefaultScopes = strings.Join([]string{ - "meeting:admin_preferences_read", + "spark:kms", + "spark:xapi_statuses", "spark:people_read", - "analytics:read_all", - "meeting:admin_participants_read", + "spark:rooms_read", + "spark:memberships_read", "spark:messages_read", + "spark:teams_read", + "spark:team_memberships_read", + "spark-admin:people_read", + "spark-admin:organizations_read", + "spark-admin:licenses_read", + "spark-admin:roles_read", + "spark-admin:locations_read", "spark-admin:places_read", + "spark-admin:devices_read", + "spark-admin:workspaces_read", + "spark-admin:workspace_locations_read", "spark-admin:workspace_metrics_read", - "spark:rooms_read", - "identity:groups_read", - "spark-admin:recordings_read", + "spark-admin:telephony_config_read", "spark-admin:telephony_pstn_read", - "spark-admin:workspace_locations_read", - "cjp:config_read", + "spark-admin:calling_cdr_read", "spark-admin:call_qualities_read", - "spark:kms", - "spark-admin:messages_read", + "spark-admin:recordings_read", "spark-admin:reports_read", - "meeting:admin_config_read", - "spark-admin:people_read", - "meeting:admin_transcripts_read", + "spark-admin:messages_read", + "spark-admin:hybrid_clusters_read", + "spark-admin:hybrid_connectors_read", "spark-admin:resource_groups_read", + "spark-admin:resource_group_memberships_read", + "meeting:schedules_read", "meeting:recordings_read", - "spark-admin:locations_read", - "meeting:participants_read", - "spark-admin:organizations_read", - "meeting:admin_recordings_read", "meeting:transcripts_read", - "spark:xapi_statuses", - "spark:memberships_read", - "spark-admin:calling_cdr_read", - "identity:organizations_read", - "spark-admin:devices_read", - "spark-admin:hybrid_clusters_read", - "spark-admin:telephony_config_read", - "meeting:admin_schedule_read", - "spark:team_memberships_read", - "meeting:schedules_read", - "spark-admin:roles_read", + "meeting:participants_read", "meeting:preferences_read", + "meeting:admin_schedule_read", + "meeting:admin_recordings_read", + "meeting:admin_participants_read", + "meeting:admin_transcripts_read", + "meeting:admin_preferences_read", + "meeting:admin_config_read", "identity:people_read", - "spark-admin:workspaces_read", - "spark-admin:resource_group_memberships_read", + "identity:organizations_read", + "identity:groups_read", + "cjp:config_read", + "analytics:read_all", "audit:events_read", - "spark-admin:hybrid_connectors_read", - "spark:teams_read", - "spark-admin:licenses_read", }, " ") // Read-only scopes whose names lack a _read suffix. spark:kms grants the diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 97911fb..5f71718 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -45,7 +45,7 @@ This login requests only read scopes (`webex config get read-only-scopes` lists To leave read-only mode, run `webex login` from an interactive terminal and confirm the prompt. The command refuses to run without a terminal, so an agent running shell commands cannot leave read-only mode on its own. Setting `WEBEX_READ_ONLY=1` turns on the same checks for one process. It never turns them off. -The OAuth integration must have every read-only scope registered. If login fails with `invalid_scope`, set a list that matches your integration with `webex config set read-only-scopes ""`. Only read scopes are accepted. List them in the same order as the scopes in your integration's authorize URL on the developer portal; Webex can reject the same scopes in a different order. +The OAuth integration must have every read-only scope registered. If login fails with `invalid_scope`, set a list that matches your integration with `webex config set read-only-scopes ""`. Only read scopes are accepted. ## Command Structure From 5eb3a819ffef51b4370696762252e3ba8f436924 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 18:34:49 -0600 Subject: [PATCH 6/6] Allow read-only POSTs by endpoint and purge unreadable tokens Read-only mode now permits POST only for an explicit list of query endpoints instead of trusting the command verb: `calling call-controls get` POSTs to /telephony/calls/retrieve, which picks up a parked call. A test checks each listed path still matches a request template. PurgeWriteTokens now skips only entries that are confirmed missing. Entries that cannot be read or decoded are deleted, and keyring failures are reported instead of counted as a successful purge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3 --- README.md | 2 +- cmd/root.go | 3 +- internal/auth/readonly.go | 7 ++- internal/auth/readonly_test.go | 40 ++++++++++++++++ internal/client/client.go | 12 ++++- internal/client/client_test.go | 31 +++++++----- internal/config/config.go | 30 ++---------- internal/config/config_test.go | 28 ----------- internal/readonly/readonly.go | 33 +++++++++++++ internal/readonly/readonly_test.go | 58 ++++++++++++++++++++++- site/src/content/docs/getting-started.mdx | 2 +- 11 files changed, 173 insertions(+), 73 deletions(-) delete mode 100644 internal/config/config_test.go diff --git a/README.md b/README.md index 04f842f..7961ede 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > OS keyring. `webex login --read-only` requests only read scopes, so Webex itself rejects writes made with the token. Use it when an AI agent should read but not change your Webex data. While read-only mode is on: - Stored logins with write access are deleted from the keyring, because any program running as you can read it. -- `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for read actions such as `list`, `get`, `search`, and `query`. +- `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries. - `--token` and `$WEBEX_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. - `webex mcp serve` does not register `webex_write`. diff --git a/cmd/root.go b/cmd/root.go index 9e8d892..cb1b568 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,7 +13,6 @@ import ( "github.com/Cloverhound/webex-cli/internal/config" "github.com/Cloverhound/webex-cli/internal/localconfig" "github.com/Cloverhound/webex-cli/internal/output" - "github.com/Cloverhound/webex-cli/internal/readonly" "github.com/spf13/cobra" ) @@ -65,7 +64,7 @@ var rootCmd = &cobra.Command{ } readOnly := readOnlyMode(cfg) - config.SetReadOnly(readOnly, readonly.IsReadAction(cmd.Name())) + config.SetReadOnly(readOnly) // Skip auth for certain commands if skipAuth(cmd) { diff --git a/internal/auth/readonly.go b/internal/auth/readonly.go index a99fa11..583bee7 100644 --- a/internal/auth/readonly.go +++ b/internal/auth/readonly.go @@ -1,10 +1,12 @@ package auth import ( + "errors" "fmt" "sort" "github.com/Cloverhound/webex-cli/internal/appconfig" + "github.com/zalando/go-keyring" ) // HasReadOnlyToken reports whether the keyring holds a read-only token for email. @@ -16,12 +18,13 @@ func HasReadOnlyToken(email string) bool { // PurgeWriteTokens deletes every stored token that is not read-only and removes // its user from cfg. It returns the removed emails, sorted. Read-only mode // depends on this: the keyring is readable by any process running as the user, -// so a write-capable token left there would bypass the CLI's checks. +// so a write-capable token left there would bypass the CLI's checks. An entry +// that cannot be read or decoded is deleted too, since it may hold write access. func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { var removed, failed []string for _, email := range cfg.UserEmails() { tok, err := LoadToken(email) - if err != nil || tok.ReadOnly { + if errors.Is(err, keyring.ErrNotFound) || (err == nil && tok.ReadOnly) { continue } if err := DeleteToken(email); err != nil { diff --git a/internal/auth/readonly_test.go b/internal/auth/readonly_test.go index f121c02..102d0e7 100644 --- a/internal/auth/readonly_test.go +++ b/internal/auth/readonly_test.go @@ -1,6 +1,7 @@ package auth import ( + "errors" "strings" "testing" "time" @@ -64,6 +65,45 @@ func TestPurgeWriteTokens(t *testing.T) { } } +func TestPurgeWriteTokensDeletesUnreadableEntries(t *testing.T) { + keyring.MockInit() + cfg := testConfig("corrupt@example.com") + if err := keyring.Set(serviceName, "corrupt@example.com", "not json"); err != nil { + t.Fatal(err) + } + + removed, err := PurgeWriteTokens(cfg) + if err != nil { + t.Fatal(err) + } + if strings.Join(removed, ",") != "corrupt@example.com" { + t.Errorf("removed = %v", removed) + } + if _, err := keyring.Get(serviceName, "corrupt@example.com"); !errors.Is(err, keyring.ErrNotFound) { + t.Errorf("unreadable entry still in keyring: %v", err) + } +} + +func TestPurgeWriteTokensReportsKeyringErrors(t *testing.T) { + keyring.MockInitWithError(errors.New("keychain locked")) + defer keyring.MockInit() + cfg := testConfig("locked@example.com") + + removed, err := PurgeWriteTokens(cfg) + if err == nil { + t.Fatal("expected error when the keyring cannot be read or cleared") + } + if !strings.Contains(err.Error(), "locked@example.com") { + t.Errorf("error does not name the user: %v", err) + } + if len(removed) != 0 { + t.Errorf("removed = %v, want none", removed) + } + if _, ok := cfg.Users["locked@example.com"]; !ok { + t.Error("user removed from config although its token could not be deleted") + } +} + func TestResolveTokenReadOnly(t *testing.T) { keyring.MockInit() cfg := testConfig("ro@example.com", "rw@example.com") diff --git a/internal/client/client.go b/internal/client/client.go index c5a0b7a..a7ff5c1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -13,6 +13,7 @@ import ( "time" "github.com/Cloverhound/webex-cli/internal/config" + "github.com/Cloverhound/webex-cli/internal/readonly" ) // ErrDryRun is returned when a write operation is intercepted by --dry-run mode. @@ -167,7 +168,7 @@ func doOnce(req *Request) ([]byte, int, http.Header, error) { } } - if !config.ReadOnlyAllows(req.method) { + if config.ReadOnly() && !readOnlyAllows(req.method, req.path) { return nil, 0, nil, ReadOnlyError(req.method, url) } @@ -212,6 +213,15 @@ func truncate(s string, n int) string { return s[:n] + "..." } +// readOnlyAllows reports whether a request may be sent in read-only mode. +// path is the unexpanded template, which is how query endpoints are listed. +func readOnlyAllows(method, path string) bool { + if method == http.MethodPost { + return readonly.IsQueryPOST(path) + } + return !isWriteMethod(method) +} + // isWriteMethod returns true for HTTP methods that modify data. func isWriteMethod(method string) bool { switch method { diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 9ce5e91..1016d8d 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -16,14 +16,21 @@ func TestReadOnlyBlocksWritesBeforeSending(t *testing.T) { w.Write([]byte(`{}`)) })) defer srv.Close() - defer config.SetReadOnly(false, false) + defer config.SetReadOnly(false) - config.SetReadOnly(true, true) + config.SetReadOnly(true) - for _, method := range []string{"PUT", "PATCH", "DELETE"} { - _, _, err := Do(NewRequest(srv.URL, method, "/things/1")) + blocked := []struct{ method, path string }{ + {"PUT", "/things/1"}, + {"PATCH", "/things/1"}, + {"DELETE", "/things/1"}, + {"POST", "/things"}, + {"POST", "/telephony/calls/retrieve"}, + } + for _, b := range blocked { + _, _, err := Do(NewRequest(srv.URL, b.method, b.path)) if !errors.Is(err, ErrReadOnly) { - t.Errorf("%s: got err %v, want ErrReadOnly", method, err) + t.Errorf("%s %s: got err %v, want ErrReadOnly", b.method, b.path, err) } } if hits != 0 { @@ -33,15 +40,17 @@ func TestReadOnlyBlocksWritesBeforeSending(t *testing.T) { if _, _, err := Do(NewRequest(srv.URL, "GET", "/things")); err != nil { t.Fatalf("GET: %v", err) } - if _, _, err := Do(NewRequest(srv.URL, "POST", "/search")); err != nil { + req := NewRequest(srv.URL, "POST", "/meetings/{meetingId}/registrants/query") + req.PathParam("meetingId", "m1") + if _, _, err := Do(req); err != nil { t.Fatalf("query POST: %v", err) } - - config.SetReadOnly(true, false) - if _, _, err := Do(NewRequest(srv.URL, "POST", "/things")); !errors.Is(err, ErrReadOnly) { - t.Fatalf("write POST: got err %v, want ErrReadOnly", err) - } if hits != 2 { t.Fatalf("server hits = %d, want 2", hits) } + + config.SetReadOnly(false) + if _, _, err := Do(NewRequest(srv.URL, "DELETE", "/things/1")); err != nil { + t.Fatalf("DELETE outside read-only mode: %v", err) + } } diff --git a/internal/config/config.go b/internal/config/config.go index 960661c..702b5c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,33 +36,11 @@ func Paginate() bool { return paginate } func SetDryRun(d bool) { dryRun = d } func DryRun() bool { return dryRun } -var ( - readOnly bool - readOnlyPOSTQuery bool -) - -// SetReadOnly turns on read-only request checks for this process. postIsQuery -// allows POST for commands whose verb marks them as reads: several Webex -// search and list endpoints take their filters in a POST body. -func SetReadOnly(on, postIsQuery bool) { - readOnly = on - readOnlyPOSTQuery = on && postIsQuery -} -func ReadOnly() bool { return readOnly } +var readOnly bool -// ReadOnlyAllows reports whether an HTTP method may be sent in the current mode. -func ReadOnlyAllows(method string) bool { - if !readOnly { - return true - } - switch strings.ToUpper(method) { - case "GET", "HEAD", "OPTIONS": - return true - case "POST": - return readOnlyPOSTQuery - } - return false -} +// SetReadOnly turns on read-only request checks for this process. +func SetReadOnly(on bool) { readOnly = on } +func ReadOnly() bool { return readOnly } // SetOrgID stores the org ID in both UUID and base64 formats. // Accepts either format as input and derives the other. diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index a8522a7..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package config - -import "testing" - -func TestReadOnlyAllows(t *testing.T) { - defer SetReadOnly(false, false) - - cases := []struct { - on, postIsQuery bool - method string - want bool - }{ - {false, false, "DELETE", true}, - {true, false, "GET", true}, - {true, false, "POST", false}, - {true, true, "POST", true}, - {true, true, "PUT", false}, - {true, true, "PATCH", false}, - {true, true, "DELETE", false}, - {false, true, "POST", true}, - } - for _, c := range cases { - SetReadOnly(c.on, c.postIsQuery) - if got := ReadOnlyAllows(c.method); got != c.want { - t.Errorf("readOnly=%v postIsQuery=%v %s: got %v, want %v", c.on, c.postIsQuery, c.method, got, c.want) - } - } -} diff --git a/internal/readonly/readonly.go b/internal/readonly/readonly.go index 54dba1c..06f9fd4 100644 --- a/internal/readonly/readonly.go +++ b/internal/readonly/readonly.go @@ -3,6 +3,7 @@ package readonly import ( "fmt" + "slices" "strings" ) @@ -97,6 +98,38 @@ func ValidateScopes(scopes string) error { return nil } +// queryPOSTPaths are POST endpoints that only read: Webex takes their filters +// in the request body. Read-only mode blocks every other POST, because a +// command's verb does not prove an endpoint is safe (`calling call-controls +// get` POSTs to /telephony/calls/retrieve, which picks up a parked call). +var queryPOSTPaths = []string{ + "/admin/recordings/query", + "/recordings/query", + "/generated-summaries/search", + "/livemonitoring/liveMeetingsByCountry", + "/meetingParticipants/query", + "/meetings/{meetingId}/registrants/query", + "/meetings/{meetingId}/surveyLinks", + "/organization/{orgid}/contact-service-queue/fetch-by-dynamic-skills-and-skillProfile", + "/organization/{orgid}/contact-service-queue/fetch-by-userId-skillProfileId", + "/organization/{orgid}/contact-service-queue/fetch-manually-assignable-queues", + "/organization/{orgid}/user/fetch-by-skill-requirements", + "/organization/{orgid}/user/fetch-user-details-by-ids", + "/organization/{orgid}/v2/contact-service-queue/fetch-by-grouped-assistant-skill", + "/search", + "/summary/list", + "/telephony/config/lists/devices/dynamicSettings/actions/getSettings/invoke", + "/telephony/config/lists/devices/{deviceId}/dynamicSettings/actions/getSettings/invoke", + "/telephony/config/lists/locations/{locationId}/devices/dynamicSettings/actions/getSettings/invoke", + "/v1/captures/query", + "/v1/{orgid}/functions/{id}:export", +} + +// IsQueryPOST reports whether a POST to the path template only reads data. +func IsQueryPOST(path string) bool { + return slices.Contains(queryPOSTPaths, path) +} + // readActions are command verbs (and their hyphenated variants) that only read data. var readActions = []string{ "list", diff --git a/internal/readonly/readonly_test.go b/internal/readonly/readonly_test.go index b33f0ba..188c96d 100644 --- a/internal/readonly/readonly_test.go +++ b/internal/readonly/readonly_test.go @@ -1,6 +1,12 @@ package readonly -import "testing" +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) func TestIsReadScope(t *testing.T) { cases := map[string]bool{ @@ -48,6 +54,56 @@ func TestValidateScopesRejectsEmpty(t *testing.T) { } } +func TestIsQueryPOST(t *testing.T) { + queries := []string{ + "/search", + "/recordings/query", + "/meetings/{meetingId}/registrants/query", + "/v1/{orgid}/functions/{id}:export", + } + writes := []string{ + "/telephony/calls/retrieve", + "/event", + "/identity/organizations/{orgId}/actions/getDomainVerificationToken", + "/rooms", + "/meetings/{meetingId}/registrants/query/", + } + for _, path := range queries { + if !IsQueryPOST(path) { + t.Errorf("IsQueryPOST(%q) = false, want true", path) + } + } + for _, path := range writes { + if IsQueryPOST(path) { + t.Errorf("IsQueryPOST(%q) = true, want false", path) + } + } +} + +// Query paths must match a POST request template exactly, or read-only mode +// blocks the command. Regenerated commands can rename path parameters. +func TestQueryPOSTPathsMatchRequests(t *testing.T) { + var src strings.Builder + for _, dir := range []string{"../../cmd", "../../internal"} { + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return err + } + b, err := os.ReadFile(path) + src.Write(b) + return err + }) + if err != nil { + t.Fatal(err) + } + } + for _, p := range queryPOSTPaths { + if !strings.Contains(src.String(), `"POST", "`+p+`")`) { + t.Errorf("no POST request uses %q", p) + } + } +} + func TestIsReadAction(t *testing.T) { cases := map[string]bool{ "list": true, diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 5f71718..2d0af61 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -38,7 +38,7 @@ webex login --read-only This login requests only read scopes (`webex config get read-only-scopes` lists them) and turns on read-only mode: - Stored logins with write access are deleted from the OS keyring. Other programs running as you can read the keyring, so leaving those logins there would let them bypass read-only mode. -- `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for commands whose action reads data, such as `list`, `get`, `search`, and `query`. Webex also rejects writes because the token has no write scopes. +- `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries. Webex also rejects writes because the token has no write scopes. - `--token` and `$WEBEX_TOKEN` are refused, because the CLI cannot check the scopes of a token it did not create. - `auth switch`, `--user`, and folder defaults work only for users who logged in with `--read-only`. - `webex mcp serve` does not register the `webex_write` tool.