cf-token-scope · git:20261029.c5fd25c · 2026-10-29 · sha256 73431eaab34c9c61

cf-token-scope git:20261029.c5fd25cA

Immutable. This exact content never changes and is served at /api/v1/blob/73431eaab34c9c61.

---
name: cf-token-scope
description: This skill should be used to widen an existing Cloudflare API token's scope via Playwright dashboard automation, then run the ADR-130 retained-scope probe set verifying the target scope was added and none was dropped.
disable-model-invocation: true
---

<!-- soleur-cloud-mode:start -->
**Cloud Mode (Devin):** before pipeline work run `bash "${CLAUDE_PLUGIN_ROOT}/scripts/cloud-detect.sh"`. If `CLAUDE_PLUGIN_ROOT` is unset (cloud exec shells do not export it), resolve the root by IDENTITY, never by script basename: for `d` in `"$HOME/.local/share/devin/cli/plugins/cache"` and `/opt/.devin/plugins`, skip unless `[ -d "$d" ]`, then `MANIFEST="$(find "$d" -path '*/.claude-plugin/plugin.json' -exec grep -l '"name"[[:space:]]*:[[:space:]]*"soleur"' {} + 2>/dev/null | head -1)"`, `ROOT="${MANIFEST%/.claude-plugin/plugin.json}"` — one resolution, two consumers: `$ROOT/scripts/cloud-detect.sh` and `$ROOT/scripts/precommit-guard.sh`. (Shape check, not authentication: a planted `{"name":"soleur"}` dir passes — ADR-179 A11.) `local` or `not-local:no-devin-env` proceeds normally; any other `not-local:<reason>` applies `<plugin-root>/devin/INSTRUCTIONS.md` §Cloud Mode: emit the `--banner`, fan out sequentially with `Reviewed-Coverage: sequential-fallback` disclosure (never claim an independent review ran), `message_user` ack before any secrets read or production mutation, and run `precommit-guard.sh` before any `git commit` — hooks do not fire in cloud.
<!-- soleur-cloud-mode:end -->

<!-- grok-harness-invoke:start -->
**Grok Build (`plugins/soleur/lib/harness.ts` `invokeSkill()`):** Read this SKILL.md in this process and run it to completion. A one-segment `soleur:<name>` in this document names a SKILL — on Grok Build, Read `plugins/soleur/skills/<name>/SKILL.md` in this process; it is not a nested tool_use. A multi-segment id such as `soleur:<domain>:<name>` names an AGENT: spawn it, never Read it, and on Grok Build spawn_subagent takes the id with its colons replaced by hyphens (`agentIdToGrokSubagentType`). **Claude Code:** Skill tool for a skill (`soleur:<name>`), Task tool with `subagent_type` for an agent. Forbidden is executing a subset, not the Read.
<!-- grok-harness-invoke:end -->

# cf-token-scope

Close the capability gap ADR-130 leaves open: there is no first-party path for
Cloudflare API-token scope changes, so each one is an ad-hoc dashboard trip
(third on record — #6657 DNS, #6649 R2, #6755 Config Rules). This skill widens a
token via **Playwright MCP** dashboard automation and makes the ADR-130
retained-scope check a deterministic, fail-closed command.

The widen mutates a live production credential that four concerns depend on. A
dashboard "save" that **replaces** rather than **appends** scopes silently breaks
cache rules, WAF, single redirects, transform rules, and account bulk redirects
at once — so the retained-scope probe is the load-bearing half, not the widen.

## Usage

The deterministic core is a read-only probe script,
[cf-token-scope.sh](./scripts/cf-token-scope.sh). It only ever probes — run it as
often as needed; it mutates nothing.

### Preflight: verify the plugin install before any snapshot

The redactor is reached through `${CLAUDE_PLUGIN_ROOT}`. An ambient value pointing at a
directory that is not a Soleur install would resolve to a path that does not exist — or, worse,
to one an attacker chose. Verify plugin IDENTITY and halt if it does not hold (ADR-179 decision 2);
a `test -f` on the script alone is a shape check and was measured bypassable.

```bash
[ -f "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json" ] \
  && grep -q '"name"[[:space:]]*:[[:space:]]*"soleur"' "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json" \
  || { echo "SOLEUR_SNAPSHOT_HALT reason=plugin-root-unverified root=[${CLAUDE_PLUGIN_ROOT}]" >&2
       echo "  Cannot locate the snapshot redactor, so no accessibility snapshot may be taken here." >&2
       echo "  Root EMPTY: no Soleur plugin is loaded in this session. Install it and start a NEW session." >&2
       echo "  Root set but wrong: a repo checkout is not an install. Run 'claude plugin update soleur@soleur-marketplace' (or the id 'claude plugin list' prints, if you added the repository directly), then RESTART Claude Code." >&2
       echo "  Nothing has been captured yet, so nothing has leaked." >&2
       exit 2; }
```

```bash
# Baseline / re-check — probe the four ADR-130 retained scopes:
bash "${CLAUDE_PLUGIN_ROOT}/skills/cf-token-scope/scripts/cf-token-scope.sh"

# After a widen — also assert the newly-added scope is live:
bash "${CLAUDE_PLUGIN_ROOT}/skills/cf-token-scope/scripts/cf-token-scope.sh" \
  --target-entrypoint http_config_settings

# default-token profile — the #11181 CF_API_TOKEN widen precondition + post-check:
bash "${CLAUDE_PLUGIN_ROOT}/skills/cf-token-scope/scripts/cf-token-scope.sh" --profile default-token
bash "${CLAUDE_PLUGIN_ROOT}/skills/cf-token-scope/scripts/cf-token-scope.sh" \
  --profile default-token --expect-lb-scopes

# Print the probe commands without running them (token stays unexpanded):
bash "${CLAUDE_PLUGIN_ROOT}/skills/cf-token-scope/scripts/cf-token-scope.sh" --dry-run
```

The token (`CF_API_TOKEN_RULESETS` or `CF_API_TOKEN`, resolved by `--profile`),
zone / account (`CF_ZONE_ID`, `CF_ACCOUNT_ID`) and Doppler config
(`soleur` / `prd_terraform`) are **profiles only, no raw token-var knob**: each
probe set is meaningful only for the token it binds to, so a `--token-var` knob
pointed at an arbitrary secret would run the wrong URLs against the wrong token.
The named-profile table is the one sanctioned widening of that posture —
`default-token` pairs the production default `CF_API_TOKEN` (the `cloudflare`
provider alias) with its own ledger probe set.

## Execution — the 3-step widen flow

1. **Pre-widen baseline.** Run the probe. Before the widen, the target
   entrypoint reads `403` (scope absent) while the known-granted controls read
   the authorized signal. Record this output — the added-scope check in step 3
   compares against it.

2. **Widen (Playwright MCP).** Follow
   [widen-playbook.md](./references/widen-playbook.md): decide widen-vs-mint per
   ADR-130, navigate to `https://dash.cloudflare.com/profile/api-tokens`, hand
   off only the login/MFA gate to the operator, then edit the token —
   three-dot menu → Edit → Add more → select the permission (same API family) →
   Continue to summary → Update token. Editing permissions does **not** rotate
   the token value, so no Doppler write follows.

   The widen transits a **full-power dashboard session** (the cookie is an
   account-wide bearer). Do **not** dump `browser_network_requests` /
   `browser_console_messages` to files, scope screenshots to the edit control,
   take any accessibility snapshot in the file form and filter the file through
   the redactor (`"${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"`)
   — an MCP tool result cannot be piped through a script; the playbook states
   when a bare call is safe — and
   always call `browser_evaluate` **with** a `filename` — without one the value
   is returned into the transcript. This corrects an inverted instruction that
   stood here previously; see the playbook's leak constraints.

3. **Post-widen verification.** Re-run the probe with
   `--target-entrypoint <phase>`. Success = the target is currently authorized AND
   every retained control stayed authorized AND the account-scheme control is an
   authorized `200` — exit 0, `PASS: target scope present` + `PASS: no scope
   dropped`. A single run cannot observe the `403 → authorized` *transition*, so
   the tool prints "present", not "added" — confirm the transition against your
   step-1 baseline.

### The same 3 steps for `CF_API_TOKEN` (the #11181 LB widen)

1. **Pre-widen baseline:** `--profile default-token`. The four retained ledger
   rows (Tunnel, Access, DNS, Notifications) must read `authorized (200)` — a
   retained failure here means the widen precondition is unmet, halt. The two LB
   target rows read `not-yet-widened (403)` and the run exits 0 with a `NOTE:`
   line — that is the expected absent-scope state BEFORE the widen.
2. **Widen** via the playbook to Zone `soleur.ai` → `Load Balancers: Edit` AND
   Account → `Load Balancing: Monitors and Pools: Edit` (append, never rebuild).
3. **Assert:** `--profile default-token --expect-lb-scopes` must exit 0 with
   `PASS: LB scopes present`. `not-yet-widened` is neutral ONLY in the step-1
   baseline — the widen is not verified until an assert run exits 0.

## Probe-set contract (three-layer fail-closed classifier)

The probe set is the ADR-130 four-probe retained set plus Zone WAF — four **zone**
phases (`http_config_settings`, `http_request_dynamic_redirect`,
`http_request_cache_settings`, `http_request_firewall_custom`) and one **account**
list (`accounts/<acct>/rulesets`). The classifier captures each response **body**
(not `-o /dev/null`) and decides in three layers:

1. **Status** — `403` / `000` / `5xx` / empty / no-newline / non-numeric = FAIL.
2. **Body-shape** — a `200` passes only when the body has `success == true` AND
   `.result` is an array. A degraded `200` (`{"success":false,...}` OR
   `{"success":true,"result":null}`) is a FAIL. Never key on `.result | length` —
   jq reads `null` as `0` and would pass.
3. **Per-scheme control** — the account list is the account-scheme control and
   must be an authorized `200` (an account `404` = FAIL). A zone `404` (ADR-130's
   "phase exists, empty") is trusted **only** for `http_config_settings` — the one
   phase whose 403-on-missing-scope semantics ADR-130 empirically verified — and
   only under an authorized zone control (`http_request_dynamic_redirect`). Every
   other phase's `404` fails closed: 403-on-missing is unverified there, so a
   dropped scope that returned `404` must not read as green.

Profile `default-token` — the `CF_API_TOKEN` set (the two LB rows are the
readiness probes of exactly the granted permission surfaces of the #11181
ruling; GETs attest the READ half):

| class | label | GET | attests | trust404 |
|---|---|---|---|---|
| retained | accounts/<acct>/cfd_tunnel | `.../cfd_tunnel?per_page=1` | Tunnel | 0 |
| retained | accounts/<acct>/access/apps | `.../access/apps?per_page=1` | Access | 0 |
| retained | zones/<zone>/dns_records | `.../dns_records?per_page=1` | DNS, zone soleur.ai | 0 |
| retained | accounts/<acct>/alerting/v3/policies | `.../alerting/v3/policies` | Notifications | 0 |
| target | accounts/<acct>/lb/pools | `.../load_balancers/pools?per_page=1` | Account Monitors-and-Pools (read half) | 0 |
| target | zones/<zone>/load_balancers | `.../load_balancers?per_page=1` | Zone Load Balancers (read half) | 0 |

Every default-token row is `trust404=0` — all rows fail closed on 404 (the
verified-404 exception stays exclusive to `http_config_settings`). `target`-class
neutrality is minted at exactly ONE classifier site and ONLY in baseline mode: a
clean `403` prints `not-yet-widened (403)` (with the parsed `errors[0].code`
appended when present and numeric) and the run stays exit-neutral with a `NOTE:` summary;
any other anomaly (404, degraded 200, wrong-shape 200, 5xx, 000, empty) is a
hard fail in BOTH modes. In assert mode (`--expect-lb-scopes`) every target row
must be authorized or the run exits 3 naming the still-absent 403s. The `target`-class
vocabulary is default-token-local: the rulesets `--target-entrypoint` path keeps
its own `TARGET_PRESENT` handling and never consults this classifier. The baseline
summary's verbatim literal (a stable grep target for the data channel):
`NOTE: LB scope not-yet-widened — expected BEFORE the #11181 widen; re-run with --expect-lb-scopes after it`.

## Exit codes

- `0` — every retained scope authorized (and, per profile: the rulesets target,
  if given; in `default-token` baseline, LB rows authorized OR reported
  `not-yet-widened`; in `--expect-lb-scopes` assert mode, all LB rows authorized).
- `2` — usage error (including an unknown `--profile`, a flag used with the
  wrong profile, and a malformed `--target-entrypoint` phase name — all raised
  before any secret read), missing prerequisite (`curl` / `doppler` / `jq`), an
  absent Doppler secret, or a refused internal table-integrity tripwire (source
  drift only, unreachable by argv; stderr-only and names the corruption).
- `3` — probe failed: a scope was dropped, degraded, or denied (assert mode: an
  LB row still `403` or anomalous).
- `78` — the script REFUSED to run under shell tracing (`-x`) — a refusal, not a
  probe verdict; see the STREAM CONTRACT in the script header (#7797).

## Sharp Edges

- **The probe set is a CANARY for the whole-list REPLACE failure mode, not
  exhaustive per-permission coverage.** A dashboard save that replaces (not
  appends) drops *all* scopes at once → every probe catches it. Zone WAF
  (`http_request_firewall_custom`) is now probed, but Transform Rules
  (`http_response_headers_transform`) and account Filter Lists are not — a
  *surgical* single-permission drop of one of those can still pass. Extend the
  entrypoint set if a future threat model needs per-permission coverage.
- **The probe attests read reachability, not `:Edit` retention.** It issues `GET`,
  so a dashboard REPLACE that re-adds a phase as **Read-only** (a one-click
  operator error — the CF dropdown lists Read/Edit side-by-side) returns an
  authorized `200` and reads as green while the write/hijack capability was
  dropped. Visually confirm the widened permission is `Edit`, not `Read`.
- **Probe, never trust the Cloudflare UI permission label** — it is named
  inconsistently across surfaces. The target entrypoint returning non-403 is the
  ground truth (ADR-130).
- **Update the scope ledger.** After a widen, add the new permission to the
  `apps/web-platform/infra/variables.tf` description of the widened token (ADR-130's
  scope ledger), in the feature PR that consumes the widen. For the `CF_API_TOKEN`
  LB widen that is the post-widen follow-on: the two permissions join the
  `cf_api_token` description — and ADR-130 gains its one-paragraph amendment note
  pinning that the `CF_API_TOKEN` retained-scope re-check is the `default-token`
  profile probe set — in the PR that consummates the widen, never before the
  dashboard act has actually run.
- **The LB readiness probes attest read reachability, not `:Edit`** — the same
  one-click Read-vs-Edit trap as the rulesets probe: a dashboard REPLACE that
  re-adds `Load Balancers` as Read-only returns an authorized `200` and reads as
  green while the write capability was dropped. Visually confirm `Edit` on both
  granted rows.
- **`not-yet-widened` is neutral ONLY in baseline mode.** The widen is not
  verified until an `--expect-lb-scopes` run exits 0. Reading a green baseline
  (post-widen) as proof of the transition is the same present-not-added trap the
  rulesets flow documents.
- **Never print the Bearer token.** The script passes it from a private fd (not
  argv), `--dry-run` leaves it unexpanded, and it runs no `set -x`. When driving
  the widen, keep the full-power-session leak constraints above.
- **New phase → enumerate before apply.** If the widen enables a *new* ruleset
  phase, confirm its entrypoint is `404`/empty before any infra apply, or a
  whole-list ruleset create clobbers dashboard rules (ADR-130, ADR-136).

## Related

- [ADR-130](../../../../knowledge-base/engineering/architecture/decisions/ADR-130-cloudflare-token-widen-vs-narrow-alias.md) — widen-vs-narrow-alias rule, the four-probe retained-scope set, and the capability gap this skill closes.
- [ADR-136](../../../../knowledge-base/engineering/architecture/decisions/ADR-136-preapply-entrypoint-enumeration-gate.md) — the standing pre-apply entrypoint-enumeration gate.
- [provision-cloudflare](../provision-cloudflare/SKILL.md) — mints *tenant* tokens (a distinct function; requires `User API Tokens:Edit`, which no Soleur token holds).
- [widen-playbook.md](./references/widen-playbook.md) — the Playwright MCP click-path and leak constraints.
- Learnings: `2026-03-21-cloudflare-api-token-permission-editing.md` (the Playwright widen path), `2026-07-23-live-api-fail-closed-guard-counts-degraded-200-as-empty-and-control-probe-must-cover-every-scheme.md` (the three-layer classifier).