Skip to content

Add read-only mode backed by read-scoped OAuth logins - #11

Merged
edumansky merged 6 commits into
mainfrom
read-only-mode
Oct 10, 2026
Merged

edumansky merged 6 commits into
mainfrom
read-only-mode

Conversation

@edumansky

@edumansky edumansky commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a read-only mode intended for AI agents and scripts that should read Webex data but not change it. The guarantee comes from the token, not a config flag: the agent runs as the user, so it can edit ~/.webex-cli/config.json or read the keyring directly. The mode is only safe if no write-capable token is reachable.

  • webex login --read-only requests a curated list of read scopes (internal/readonly.DefaultScopes, overridable with webex config set read-only-scopes, which accepts only read scopes). The granted scopes and a ReadOnly marker are stored with the token and carried across refreshes.
  • Entering the mode purges every stored token that is not read-only from the keyring and config. On macOS, go-keyring items are readable by any process via security find-generic-password, so leaving them would bypass every check.
  • While read-only:
    • PUT/PATCH/DELETE and multipart uploads are refused in client.Do and audio.Upload before sending.
    • POST is allowed only for an explicit list of query endpoints (readonly.IsQueryPOST), such as cc search and recording queries, which take their filters in a POST body. A command's verb isn't trusted: 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. Webex also rejects any write the CLI lets through, since the token has no write scopes.
    • --token and $WEBEX_TOKEN are refused, because Webex has no public introspection to check their scopes.
    • ResolveToken rejects users without a read-only token, which covers --user, $WEBEX_USER, and folder defaults. auth switch checks the same thing up front.
    • webex mcp serve does not register webex_write.
  • Leaving the mode requires a plain webex login. It refuses to run without an interactive terminal on stdin and stdout, then asks for confirmation. Agents' shell tools have no terminal, so an agent cannot start the OAuth flow on its own. The MCP server already blocks login.
  • WEBEX_READ_ONLY=1 turns the checks on for one process (for example, an MCP server launch) and never turns them off.
  • auth status shows Mode: read-only and the granted scopes. auth list marks (read-only) users.
  • The read-verb list moved from the MCP dispatcher to internal/readonly so both use the same classification.

Known limitations / needs live verification

  • Release secret. Release builds inject the read-only scopes from a new WEBEX_READ_ONLY_SCOPES repository secret into appconfig.DefaultReadOnlyScopes, the same way WEBEX_SCOPES sets appconfig.DefaultScopes. The secret is set on the repo with the list below. If it is ever unset, builds fall back to readonly.DefaultScopes in source, described below. login --read-only rejects an injected value that contains a write scope.
  • Integration scopes. Every scope in DefaultScopes must be registered on the compiled-in OAuth integration, or login fails with invalid_scope. Scope order does not matter: the same 46 scopes passed in both the portal's order and grouped by type. The invalid_scope failures during testing came from unregistered scopes and from integration edits that hadn't reached the login server yet.
    • The default list is 46 read scopes: every read scope the integration registers, minus meeting:controls_read and meeting:summaries_read.
    • It includes user-level read scopes added to the integration for this mode. spark:all covers those scopes, so the portal only allows ticking them while spark:all is off.
    • spark:{devices,recordings,telephony_config,workspaces}_read are not registered and were dropped. Their spark-admin versions cover the commands tested.
    • Left out entirely: spark:all, the *_write scopes, cjp:config, cjp:user, and spark:xapi_commands.
    • A self-built binary using a different integration can override the list with config set read-only-scopes.
  • Silent browser login. If the browser has a live Webex session and consent was given earlier, the OAuth redirect may finish without the person doing anything. The terminal requirement is the guard against an agent starting that flow, but it can be circumvented with a pseudo-terminal wrapper. It is a speed bump, not a hard barrier.
  • Coverage. Some GET endpoints may need a scope that is missing from the default list, which surfaces as a 403. A sweep of each area's list/get commands with a read-only login would find these.
  • Keyring scope. Purging covers users recorded in config. go-keyring cannot list keyring entries, so orphaned entries for users removed from config by hand are not found.

Testing

  • make check (build + vet) passes.
  • go test ./... passes. New tests cover:
    • scope and verb classification
    • ReadOnlyAllows
    • client.Do blocking writes before they reach an httptest server
    • PurgeWriteTokens and ResolveToken read-only rules, using go-keyring's mock
    • MCP tool registration
  • npm run build in site/ passes.
  • Smoke-tested the CLI against an isolated HOME: rejects --token, $WEBEX_TOKEN, a non-read-only user, auth switch to a non-read-only user, and full login without a terminal or with WEBEX_READ_ONLY=1; WEBEX_READ_ONLY=0 cannot disable a config-enabled mode; config set read-only-scopes rejects spark:all.
  • Live scope test. The released 0.15.0 binary, pointed at this list with config set scopes, logged in successfully against the production integration. With that read-only token, these commands all succeeded:
    • admin people list, admin licenses list
    • calling locations list
    • device devices list, device workspaces list
    • cc site list
    • meetings meetings list
    • messaging rooms list, messaging teams list, messaging memberships list, messaging people get-my-own
  • Live end-to-end test of this branch. A branch build with the production client credentials ran webex login --read-only against the real integration:
    • Login succeeded. Webex's token response does include scope, and the 46 granted scopes were recorded and validated.
    • Read-only mode turned on, and the 5 other stored logins with write access were removed. security find-generic-password confirms those keyring entries are gone.
    • auth status shows Mode: read-only and the scopes. auth list marks the user (read-only).
    • Reads succeeded in admin, calling, device, cc, meetings and messaging.
    • rooms delete, rooms update and rooms create were refused before sending.
    • --token and $WEBEX_TOKEN were refused.
    • webex login without a terminal was refused.
    • A DELETE sent straight to Webex with the read-only token returned 403, "access token is missing required scopes".
    • webex mcp serve listed only webex_read, webex_help and webex_usage.
  • Older binaries. CLI versions before this change don't know the read_only config key and drop it the next time they save the config. The protection then rests on what's left: no token with write access remains, and getting one requires a browser login.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

edumansky and others added 6 commits October 9, 2026 14:27
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3

@edumansky edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review (gpt-6-astra, reasoning effort: high)

The read-only safeguards silently overlook keyring read failures and misclassify some state-changing POST operations as reads. The existing Go test suite passes but does not cover these cases.

Comment thread internal/auth/readonly.go
Comment on lines +23 to +25
tok, err := LoadToken(email)
if err != nil || tok.ReadOnly {
continue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Report keyring read failures during token purging

If reading an existing keyring entry fails, this silently skips it and reports a successful purge. Unlike a missing entry, a keyring access error or token decoding error does not establish that the write-capable credentials are absent. Consequently, login --read-only can announce success while leaving those credentials available. Ignore only confirmed not-found errors; otherwise delete the entry or report the failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 5eb3a81. PurgeWriteTokens now skips an entry only when LoadToken returns keyring.ErrNotFound, or when the token is read-only. An entry that can't be read or decoded is deleted like any write-capable token. If that deletion fails, the purge returns an error naming the user and keeps the user in config. Regression tests: TestPurgeWriteTokensDeletesUnreadableEntries (corrupt JSON entry is deleted) and TestPurgeWriteTokensReportsKeyringErrors (keyring.MockInitWithError makes the purge fail instead of reporting success).

Comment thread cmd/root.go Outdated
Comment on lines +67 to +68
readOnly := readOnlyMode(cfg)
config.SetReadOnly(readOnly, readonly.IsReadAction(cmd.Name()))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Classify permitted POST requests by endpoint semantics

A get command is not necessarily read-only: calling call-controls get and calling external-voicemail get POST to /telephony/calls/retrieve, initiating a new call to retrieve a parked call. This classification therefore permits those write requests through the new read-only HTTP guard. Although restricted OAuth scopes should still reject them, the promised client-side write protection is missing. Use an explicit classification of read-only POST endpoints rather than trusting the command verb.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, confirmed: calling call-controls get and calling external-voicemail get both POST to /telephony/calls/retrieve. Fixed in 5eb3a81. The verb rule is gone. Read-only mode now allows POST only for an explicit list of query endpoints, readonly.IsQueryPOST, matched against the request's path template in client.Do. Every other POST is blocked. The list came from the 35 generated POST commands with read-style verbs. It leaves out /telephony/calls/retrieve, /event (cc get-suggestions sends an event) and getDomainVerificationToken (it generates a token). Regression tests: TestReadOnlyBlocksWritesBeforeSending (POST /telephony/calls/retrieve never reaches the server, while a listed query POST does), TestIsQueryPOST, and TestQueryPOSTPathsMatchRequests. That last test fails if a listed path stops matching a real request template; it already caught {orgId} vs the generated {orgid}. Live check with a read-only login: calling call-controls get is now refused before sending, and cc users list-by-ids is still sent.

@edumansky

Copy link
Copy Markdown
Contributor Author

Codex review: outcome

Codex (gpt-6-astra, reasoning effort: high) reviewed a6ae83f and reported 2 findings. Both were valid and are fixed in 5eb3a81.

Finding Outcome
[P1] Report keyring read failures during token purging (internal/auth/readonly.go) Fixed in 5eb3a81. Only keyring.ErrNotFound is skipped. Unreadable entries are deleted, and keyring failures are reported.
[P2] Classify permitted POST requests by endpoint semantics (cmd/root.go) Fixed in 5eb3a81. POST is allowed only for an explicit list of query endpoints. /telephony/calls/retrieve is now blocked.

Tests and checks

  • go test ./... passes. New regression tests:
    • TestPurgeWriteTokensDeletesUnreadableEntries
    • TestPurgeWriteTokensReportsKeyringErrors
    • TestIsQueryPOST
    • TestQueryPOSTPathsMatchRequests
    • TestReadOnlyBlocksWritesBeforeSending, extended to cover /telephony/calls/retrieve
  • make check (build + go vet) passes.
  • npm run build in site/ passes.
  • Live check with a read-only login and WEBEX_READ_ONLY=1:
    • calling call-controls get and messaging rooms create were refused before sending.
    • cc users list-by-ids, an allowlisted query POST, was sent.

README and site docs now describe the POST allowlist instead of verb matching.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3

@edumansky
edumansky merged commit 6e0b10f into main Oct 10, 2026
1 check passed
@edumansky
edumansky deleted the read-only-mode branch October 10, 2026 04:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant