Repository navigation
Add read-only mode backed by read-scoped OAuth logins - #11
Conversation
`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
left a comment
There was a problem hiding this comment.
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.
| tok, err := LoadToken(email) | ||
| if err != nil || tok.ReadOnly { | ||
| continue |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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).
| readOnly := readOnlyMode(cfg) | ||
| config.SetReadOnly(readOnly, readonly.IsReadAction(cmd.Name())) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
Codex review: outcomeCodex (
Tests and checks
README and site docs now describe the POST allowlist instead of verb matching. 🤖 Generated with Claude Code |
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.jsonor read the keyring directly. The mode is only safe if no write-capable token is reachable.webex login --read-onlyrequests a curated list of read scopes (internal/readonly.DefaultScopes, overridable withwebex config set read-only-scopes, which accepts only read scopes). The granted scopes and aReadOnlymarker are stored with the token and carried across refreshes.security find-generic-password, so leaving them would bypass every check.PUT/PATCH/DELETEand multipart uploads are refused inclient.Doandaudio.Uploadbefore sending.POSTis allowed only for an explicit list of query endpoints (readonly.IsQueryPOST), such ascc searchand recording queries, which take their filters in a POST body. A command's verb isn't trusted:calling call-controls getPOSTs 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.--tokenand$WEBEX_TOKENare refused, because Webex has no public introspection to check their scopes.ResolveTokenrejects users without a read-only token, which covers--user,$WEBEX_USER, and folder defaults.auth switchchecks the same thing up front.webex mcp servedoes not registerwebex_write.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 blockslogin.WEBEX_READ_ONLY=1turns the checks on for one process (for example, an MCP server launch) and never turns them off.auth statusshowsMode: read-onlyand the granted scopes.auth listmarks(read-only)users.internal/readonlyso both use the same classification.Known limitations / needs live verification
WEBEX_READ_ONLY_SCOPESrepository secret intoappconfig.DefaultReadOnlyScopes, the same wayWEBEX_SCOPESsetsappconfig.DefaultScopes. The secret is set on the repo with the list below. If it is ever unset, builds fall back toreadonly.DefaultScopesin source, described below.login --read-onlyrejects an injected value that contains a write scope.DefaultScopesmust be registered on the compiled-in OAuth integration, or login fails withinvalid_scope. Scope order does not matter: the same 46 scopes passed in both the portal's order and grouped by type. Theinvalid_scopefailures during testing came from unregistered scopes and from integration edits that hadn't reached the login server yet.meeting:controls_readandmeeting:summaries_read.spark:allcovers those scopes, so the portal only allows ticking them whilespark:allis off.spark:{devices,recordings,telephony_config,workspaces}_readare not registered and were dropped. Theirspark-adminversions cover the commands tested.spark:all, the*_writescopes,cjp:config,cjp:user, andspark:xapi_commands.config set read-only-scopes.list/getcommands with a read-only login would find these.Testing
make check(build + vet) passes.go test ./...passes. New tests cover:ReadOnlyAllowsclient.Doblocking writes before they reach anhttptestserverPurgeWriteTokensandResolveTokenread-only rules, using go-keyring's mocknpm run buildinsite/passes.HOME: rejects--token,$WEBEX_TOKEN, a non-read-only user,auth switchto a non-read-only user, and fullloginwithout a terminal or withWEBEX_READ_ONLY=1;WEBEX_READ_ONLY=0cannot disable a config-enabled mode;config set read-only-scopesrejectsspark:all.config set scopes, logged in successfully against the production integration. With that read-only token, these commands all succeeded:admin people list,admin licenses listcalling locations listdevice devices list,device workspaces listcc site listmeetings meetings listmessaging rooms list,messaging teams list,messaging memberships list,messaging people get-my-ownwebex login --read-onlyagainst the real integration:scope, and the 46 granted scopes were recorded and validated.security find-generic-passwordconfirms those keyring entries are gone.auth statusshowsMode: read-onlyand the scopes.auth listmarks the user(read-only).rooms delete,rooms updateandrooms createwere refused before sending.--tokenand$WEBEX_TOKENwere refused.webex loginwithout a terminal was refused.DELETEsent straight to Webex with the read-only token returned 403, "access token is missing required scopes".webex mcp servelisted onlywebex_read,webex_helpandwebex_usage.read_onlyconfig 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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.