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/README.md b/README.md index cc3d144..7961ede 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 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`. + +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..cb1b568 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,6 +63,9 @@ var rootCmd = &cobra.Command{ return err } + readOnly := readOnlyMode(cfg) + config.SetReadOnly(readOnly) + // Skip auth for certain commands if skipAuth(cmd) { return nil @@ -83,7 +86,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..f990c79 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). @@ -12,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 { @@ -29,6 +35,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 +115,17 @@ 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 + } + if DefaultReadOnlyScopes != "" { + return DefaultReadOnlyScopes + } + 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..583bee7 --- /dev/null +++ b/internal/auth/readonly.go @@ -0,0 +1,43 @@ +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. +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. 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 errors.Is(err, keyring.ErrNotFound) || (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..102d0e7 --- /dev/null +++ b/internal/auth/readonly_test.go @@ -0,0 +1,135 @@ +package auth + +import ( + "errors" + "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 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") + 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..a7ff5c1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -13,11 +13,20 @@ 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. 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 +168,10 @@ func doOnce(req *Request) ([]byte, int, http.Header, error) { } } + if config.ReadOnly() && !readOnlyAllows(req.method, req.path) { + 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) @@ -200,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 new file mode 100644 index 0000000..1016d8d --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,56 @@ +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) + + config.SetReadOnly(true) + + 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 %s: got err %v, want ErrReadOnly", b.method, b.path, 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) + } + 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) + } + 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 b93cda4..702b5c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,12 @@ func Paginate() bool { return paginate } func SetDryRun(d bool) { dryRun = d } func DryRun() bool { return dryRun } +var readOnly bool + +// 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. func SetOrgID(id string) { diff --git a/internal/readonly/readonly.go b/internal/readonly/readonly.go new file mode 100644 index 0000000..06f9fd4 --- /dev/null +++ b/internal/readonly/readonly.go @@ -0,0 +1,155 @@ +// Package readonly classifies OAuth scopes and CLI actions for read-only mode. +package readonly + +import ( + "fmt" + "slices" + "strings" +) + +// 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. +// 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{ + "spark:kms", + "spark:xapi_statuses", + "spark:people_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-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", + "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", + "identity:organizations_read", + "identity:groups_read", + "cjp:config_read", + "analytics:read_all", + "audit:events_read", +}, " ") + +// 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:xapi_statuses": 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 +} + +// 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", + "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..188c96d --- /dev/null +++ b/internal/readonly/readonly_test.go @@ -0,0 +1,125 @@ +package readonly + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +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, + "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 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, + "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..2d0af61 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 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. + +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