Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 "<scopes>"`.

### Organization Override

Partner admins managing customer orgs can set a persistent default org so they don't need `--organization` on every command:
Expand Down Expand Up @@ -199,6 +211,7 @@ webex cc users list --output csv > users.csv
webex config set client-id <id> # Use custom OAuth client ID
webex config set client-secret <secret> # Use custom OAuth client secret
webex config set scopes <scopes> # Override OAuth scopes
webex config set read-only-scopes <scopes> # Override scopes for login --read-only
webex config get client-id # View current value
```

Expand Down Expand Up @@ -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:

Expand Down
22 changes: 20 additions & 2 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/Cloverhound/webex-cli/internal/appconfig"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 == "" {
Expand Down
23 changes: 19 additions & 4 deletions cmd/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -27,7 +30,7 @@ To use your own Webex Integration instead of the built-in default:
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
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]
Expand All @@ -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 {
Expand All @@ -66,7 +74,7 @@ var configSetCmd = &cobra.Command{
var configGetCmd = &cobra.Command{
Use: "get <key>",
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]
Expand Down Expand Up @@ -108,14 +116,21 @@ 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)
} else {
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
Expand Down
46 changes: 44 additions & 2 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@ 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"
)

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)
Expand All @@ -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.
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
67 changes: 23 additions & 44 deletions cmd/mcp/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", ""))

Expand Down
21 changes: 21 additions & 0 deletions cmd/mcp/dispatcher_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading