帰属までの時間を追ってください。許可の識別子を与えられてから、名前のある人間が所有と正当化を確認するまでの時間です。最初は日単位ですが、パート C が埋まれば分単位で終わるはずです。これは実際のインシデントで組織がどう動けるかを予測する数字です。そのとき問われるのは、90 件の連携のどれが侵害されたトークンを持っていたかだからです。
Use Any API Client は API Access Control を迂回する。 サポートケースを起票し、許可リストを有効化し、これで解決したと考えている組織にも、その権限が割り当てられている場所には迂回路が残ります。ガード: パート D はこの権限の割り当て先自体を許可レコードとして収集し、許可リストの内容にかかわらず書き込み可能として格付けします。
vs 代替案
vs SSPM 製品(AppOmni、Obsidian、Valence)。 これらは継続的に監視し、2 つの CRM よりはるかに広い SaaS を対象にし、ベンダーリスクの独自インテリジェンスを維持します。このバンドルが持たず、主張もしない利点です。大規模な SaaS 群にわたって新しい許可へアラートを出すことが要件で、予算があるなら買ってください。この Skill が勝つのは、それらの製品が最も手薄な面です。すなわちエージェントのクライアント側で、MCP サーバーが IdP や SaaS 管理コンソールではなく開発用ノート PC の設定ファイルに存在する領域です。
vs Salesforce のネイティブ機能のみ。 OAuth Usage ページと API Access Control は無償で、面 A については正典です。API Access Control はここで使える単体としては最強の統制で、API の到達範囲を許可リストに固定します。限界は 2 つ。有効化にサポートケースが要るため即日の回答にはならず、承認済みアプリをどのエージェントが使っているかも、MCP サーバーが自身について何を宣言しているかも語りません。両方使ってください。この Skill のパート D は、まさにその許可リストの迂回路を収集するために存在します。
vs 表計算による年次アクセスレビュー。 これが正直な比較です。ほとんどのチームが実際にやっているのはこれだからです。表計算は無償で、セットアップも要りません。同時に、証跡のない一時点のスナップショットであり、レビュー担当者が尋ねようと思いついたものしか捉えられず、誰も列挙しなかった許可に気づく仕組みを持ちません。バンドルの grants.jsonl は、次回のレビューを再調査ではなく差分にするために存在します。
vs 直接スクリプトを書く。 面 A は実際に書きやすく、クエリ 1 本とエクスポート 1 回です。書き直さないほうがよいのは、スコープからレベルへの表、修正子の計算、そして破壊範囲の注記を伴う処置カタログです。判断が宿っているのはそこだからです。面 A だけが必要なら直接書いてください。突き合わせが要るならバンドルを使ってください。
---
name: crm-agent-access-audit
description: Inventories every non-human identity that can write to CRM objects — OAuth connected apps, private-app tokens, MCP servers, and agent integrations — reconciles what the CRM has authorized against what agent clients are actually configured to reach, and ranks over-broad, unattributed, and stale grants by blast radius. Use before or during an access review of Salesforce or HubSpot when agents and MCP servers hold API credentials.
---
# CRM agent access audit
This Skill answers one question: **which non-human identities can change CRM data, and who authorized each one?**
It is read-only. It revokes nothing, blocks nothing, and edits no configuration. Its output is an evidence file and a ranked finding list that a human acts on. That split is deliberate — the remediations here are org-wide and immediate (blocking a connected app in Salesforce kills every user's authorization at once), so the decision to fire one belongs to a named person, not to an agent run.
## The premise
Access reviews for agent integrations get run against the wrong artifact. Two of them, usually:
1. **The MCP tool annotation.** A server declares `readOnlyHint: true` on its tools and the reviewer records it as a read-only integration. That annotation is the server's self-description. The MCP specification is explicit that annotations "are not guaranteed to faithfully describe tool behavior, and clients **must** treat them as untrusted unless they come from a trusted server." What the integration can actually do is set by the OAuth scopes on its token, which live in the CRM, not in the server manifest.
2. **The connected-app list alone.** Salesforce's Connected Apps OAuth Usage page is authoritative for what the org has authorized, and blind to which agent, on whose laptop, is driving that authorization. A single service credential shared across four MCP servers looks like one grant.
The findings live in the gap between those two views. This Skill collects both, plus a third, and reports the deltas.
## When to invoke
- An access review, SOC 2 user-access-review cycle, or security questionnaire covers CRM integrations and agent tooling.
- A vendor with a CRM integration has disclosed an incident and you need the exposure answer today rather than next week.
- Engineers and ops staff add MCP servers themselves, so no single person knows the current set.
- Agent write access to CRM is being expanded and someone asked what is already there.
## When NOT to invoke
- **A single-admin org with under 10 connected apps.** Read the Connected Apps OAuth Usage page directly. Normalization and scoring are overhead at that size.
- **You do not hold read access to both planes.** Without CRM setup read access *and* the ability to read agent client configs, the reconciliation phase produces nothing and the run degrades to a list you already had.
- **You want remediation.** This Skill produces findings. Blocking, revoking, and scope reduction are separate, human-approved actions — see `references/3-finding-dispositions.md` for what each one breaks.
- **Continuous monitoring is the actual requirement.** This is a point-in-time audit. If the org needs alerting on new grants, buy an SSPM product; see the alternatives section of the published page.
- **The owner registry has never been filled in.** Every grant needs a named human owner to be actionable. Running without `references/1-grant-inventory-sources.md` Part C produces findings nobody can route.
## Inputs
Required:
- `crm_platform` (string, `salesforce` | `hubspot` | `both`) — which CRM planes to collect.
- `run_dir` (path) — writable directory for raw collection, normalized records, and the report.
- `agent_config_roots` (list of paths) — where to look for MCP client configuration. Defaults to the current project directory, `~/.claude.json`, and the platform Claude Desktop config path.
Optional:
- `stale_days` (integer, default `90`) — a grant unused for longer is flagged stale. 90 days is the common access-review interval; set it to your own review cadence rather than inheriting the default.
- `owner_registry` (path) — CSV of `app_identifier,owner_email,business_justification,approved_date`. Absent, every grant reports as unowned, which is itself the finding.
- `include_read_only` (boolean, default `false`) — include read-scoped grants in the report body. They still appear in the evidence file either way.
## Reference files
- `references/1-grant-inventory-sources.md` — the collection procedure per plane, the exact queries, the credentials each one needs, and the owner registry template. Fill Part C before the first run.
- `references/2-blast-radius-rubric.md` — the scope-to-tier tables and the modifier arithmetic. Deterministic; edit the tables, not the code path.
- `references/3-finding-dispositions.md` — per finding class: the remediation, who approves it, and what the remediation itself breaks.
## Method
Six phases, fixed order. Phase 5 refuses to run if any of the three planes is missing, because a two-plane run produces confident findings from an incomplete picture — the failure mode this Skill exists to prevent.
### Phase 0 — Pin the audit's own posture
Record the identity and permissions the audit runs under into `run_dir/run-meta.json`. Then assert the audit credential holds no write scopes. An audit that runs under a credential with `full` or `crm.objects.*.write` cannot claim to be read-only, and its own token becomes a finding in the next audit. If the only available credential carries write scopes, record that fact in the report header rather than suppressing it.
### Phase 1 — Collect plane A: CRM-authorized grants
**Salesforce.** The token rows come from a single SOQL query:
```
sf data query --query "SELECT Id, AppName, UserId, CreatedDate, LastUsedDate, UseCount, AppMenuItemId FROM OauthToken" --result-format csv > raw/sfdc-oauthtoken.csv
```
That gives you who authorized what, when, and how recently — and **not** the scopes. This is the single most misread part of the collection: `OauthToken` rows carry usage, not capability. The scope set is a property of the connected app's OAuth policy, so Part A of `references/1-grant-inventory-sources.md` pairs the query with the Setup export from **Setup → Apps → Connected Apps → Connected Apps OAuth Usage** and the per-app scope list. Join on `AppName`. A token whose app no longer appears in the connected-app list is a finding, not a join failure.
**HubSpot.** Private-app tokens introspect one at a time against `POST /oauth/v2/private-apps/get/access-token-info`, which returns the hub id and the scope list for the token you present. Public-app installs are read from **Settings → Integrations → Connected Apps** in the portal; HubSpot exposes no clean public endpoint that lists every installed app for a portal, so this sub-plane is a manual export. Record it as manual in the evidence file — a plane collected by hand and a plane collected by API have different staleness properties and the report should not blur them.
### Phase 2 — Collect plane B: client-side agent configuration
For Claude Code, three scopes hold servers and they are separate files:
```
claude mcp list
claude mcp get <name>
```
- `local` (the default scope) and `user` scope both live in `~/.claude.json`
- `project` scope lives in the repository's `.mcp.json`
- Claude Desktop keeps its own `claude_desktop_config.json`
Walk every path in `agent_config_roots`, not just the first hit. Project-scoped servers awaiting approval still appear in `claude mcp list` marked `⏸ Pending approval` — collect them, because a pending server is a configured intent and the credential in its `env` block already exists. Plugin-provided servers and claude.ai connectors are in scope too.
Extract, for each server: transport, command or URL, every environment variable **name** (never its value), and whether authentication is a static header, a `headersHelper`, or OAuth through `/mcp`.
### Phase 3 — Collect plane C: declared capability
For each server from Phase 2 that touches the CRM, record its tool list and the four annotation hints per tool. Record them as *claims*.
Absence matters more than presence here, because of the specification's defaults: `readOnlyHint` defaults to `false`, `destructiveHint` defaults to `true`, `idempotentHint` to `false`, and `openWorldHint` to `true`. An unannotated tool is therefore presumed write-capable and destructive. That default is the correct reading and the report states it — an unannotated CRM server is not an unknown, it is a presumed writer.
### Phase 4 — Normalize and score
Every collected row becomes one grant record with the same shape regardless of plane. Scoring runs as code against the tables in `references/2-blast-radius-rubric.md` — no model judgment. The reason is reviewability: an access review gets re-run quarterly, and a reviewer signing off on a diff needs the same input to produce the same score. A model asked to rank the same 90 grants twice will not return the same ranking twice, which makes the quarter-over-quarter diff meaningless.
Model judgment appears in exactly one place: drafting the plain-language justification paragraph for each finding. It never sets a tier and never decides a disposition.
### Phase 5 — Reconcile the three planes
Join plane A to plane B on client id, app name, or credential fingerprint. The lists will not match. The mismatches are the output:
- **`orphan-grant`** — a live CRM authorization with no corresponding agent configuration and no registry owner. The token still refreshes. This is the shape that made the Salesloft Drift compromise expensive: the authorization outlived anyone's attention on it.
- **`unattributed-write`** — two or more agent servers configured against one service credential. Every CRM audit row attributes to a single identity, so post-incident you can establish that a write happened and not which agent made it.
- **`annotation-mismatch`** — a server whose tools all declare `readOnlyHint: true` while its credential carries write scopes. The gap between declared and granted capability, stated as a number.
- **`stale-grant`** — `LastUsedDate` older than `stale_days` with a live refresh token. Note the Salesforce behavior that generates most of these: deactivating a user does not revoke that user's OAuth authorizations. Offboarding leaves them live.
- **`scope-excess`** — granted scope tier above what the configured tool set needs. Salesforce `full` on an integration that reads three objects is the standard instance.
### Phase 6 — Report
Findings sorted by tier, then by staleness. Each carries its evidence path, so a reviewer who disputes a finding can read the raw row rather than argue with a summary.
## Output format
`run_dir/report.md`:
```markdown
# CRM agent access audit — 2026-08-04
Planes collected: A (salesforce, api) · B (3 config roots) · C (7 servers)
Audit credential: read-only ✔
Grants: 94 total · 31 write-capable · 12 unowned
## T1 — write-capable, unowned, or unattributed (4)
### T1-01 · orphan-grant · "Drift" (Salesforce connected app)
Scopes: api, refresh_token, offline_access
Last used: 2025-08-11 (359 days) · Use count: 14,203
Owner: none in registry · Config match: none in plane B
Evidence: raw/sfdc-oauthtoken.csv:88, raw/sfdc-connected-apps.csv:12
Action: revoke tokens for this app (see disposition R2 — org-wide, immediate)
### T1-02 · unattributed-write · SVC_AGENT_CRM (shared credential)
Used by: mcp-salesforce-ops, mcp-pipeline-report, internal-router
Scopes: api, refresh_token
Owner: revops-platform@ · Config match: 3 servers, 2 config roots
Evidence: raw/mcp-servers.jsonl:3,7,11
Action: split into per-server credentials (see disposition R4 — requires 3 app registrations)
## T2 — scope excess (9)
...
## Unreconciled
2 plane-B servers reference env var names with no matching plane-A grant:
mcp-attio-sync (ATTIO_API_KEY), mcp-notes (NOTION_TOKEN) — out of CRM scope, listed for completeness.
```
Alongside it, `run_dir/grants.jsonl` — one normalized record per grant, which is what the next quarter's run diffs against.
## Watch-outs
- **`OauthToken` has no scope column.** A run that reports scopes without joining the connected-app export is reporting invented data. **Guard:** Phase 4 hard-fails any Salesforce grant record whose `scope_source` field is not `connected-app-export`. Missing scopes render as `unknown` and score at the top tier until resolved, so the gap is loud rather than silent.
- **Tool annotations are the auditee's own testimony.** A compromised or careless server declares whatever it likes. **Guard:** plane C never lowers a tier. It can only raise one, via `annotation-mismatch`. Tiers come from token scopes.
- **Environment variable values are secrets and the audit reads config files full of them.** A report that quotes a config block verbatim leaks the credential into a document that gets emailed to auditors. **Guard:** the collector records env var **names** only and writes a literal `[redacted]` for every value at parse time, before anything reaches `run_dir`. Redacting at report time leaves the secret sitting in the raw file.
- **Blocking a connected app is org-wide and instant.** It revokes every user's authorization for that app simultaneously, which breaks running integrations without warning. **Guard:** dispositions are proposals, never actions. `references/3-finding-dispositions.md` states the breakage for each one, and R2 requires a named approver and a notification window before it fires.
- **A clean report can mean the collection failed.** Zero findings and a broken query look identical downstream. **Guard:** Phase 5 refuses to run on fewer than three planes, and the report header prints per-plane record counts. A plane with zero rows prints as `COLLECTION FAILED`, not as zero findings.
- **`Use Any API Client` overrides API Access Control.** An org that has enabled the allowlist still has a bypass wherever that permission is assigned. **Guard:** Part D of `references/1-grant-inventory-sources.md` collects assignees of that permission as grant records in their own right, tiered as write-capable regardless of which apps are allowlisted.
# Reference 1 — Grant inventory sources
Fill Part C before the first run. Parts A, B, and D are procedures; Part C is your data.
---
## Part A — Plane A: CRM-authorized grants
### A.1 Salesforce — token usage
Requires: Setup read access, `sf` CLI authenticated to the target org.
```
sf data query \
--query "SELECT Id, AppName, UserId, CreatedDate, LastUsedDate, UseCount, AppMenuItemId FROM OauthToken" \
--result-format csv > raw/sfdc-oauthtoken.csv
```
Returns one row per user-app authorization. `AppName` is the join key for A.2. `LastUsedDate` drives the staleness test. `UseCount` separates an integration doing real volume from one authorized and forgotten.
**This query returns no scopes.** Do not infer them. Scope capability comes from A.2.
### A.2 Salesforce — connected app scopes
Setup → Apps → Connected Apps → **Connected Apps OAuth Usage**. Export the list. For each app, open its detail and record the OAuth scopes from its **Permitted Users** and OAuth policy configuration.
Record per app:
| Field | Where it comes from |
|---|---|
| `app_name` | OAuth Usage list — join key to A.1 |
| `scopes` | Connected app detail → OAuth scopes |
| `permitted_users` | `All users may self-authorize` or `Admin approved users are pre-authorized` |
| `installed` | Whether the app appears as installed or only as authorized |
| `publisher` | Salesforce-supplied apps use namespaces such as `sf_com_apps`, `sf_chttr_apps` |
Two rows deserve immediate attention regardless of tier:
- **`All users may self-authorize`** — any user can grant this app access to their own data without an admin in the loop. Every self-authorizing app is a grant path you did not approve individually.
- **A duplicate or near-duplicate `app_name`** — two apps with the same display name means one of them is not what it claims to be. Investigate before scoring.
### A.3 HubSpot — private apps
For each private-app token you hold:
```
curl -X POST https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info \
-H "Content-Type: application/json" \
-d '{"tokenKey":"<token>"}'
```
Returns the hub id, the user, and the scope list. One call per token. You can only introspect a token you possess — tokens held by other teams have to be collected from those teams, which is why Part C exists.
### A.4 HubSpot — installed public apps
Settings → Integrations → **Connected Apps**. Export manually. HubSpot exposes no public endpoint that enumerates every installed app for a portal, so this sub-plane is hand-collected. Stamp it:
```
collection_method: manual
collected_at: <ISO8601>
collected_by: <name>
```
A hand-collected plane goes stale between runs in a way an API-collected plane does not. The report distinguishes them so a reviewer knows which half of the picture is a snapshot and which is live.
---
## Part B — Plane B: agent client configuration
### B.1 Claude Code
```
claude mcp list
claude mcp get <name>
```
Three scopes, three locations — walk all of them:
| Scope | File | Shared with |
|---|---|---|
| `local` (default) | `~/.claude.json` | you, this project only |
| `user` | `~/.claude.json` | you, all projects |
| `project` | `<repo>/.mcp.json` | everyone who clones the repo |
Servers marked `⏸ Pending approval` are in scope. Approval is not the gate that matters for an access audit — the credential in the entry's `env` block already exists whether or not the server is approved.
WebSocket (`type: "ws"`) servers do not appear in `claude mcp list`. Read them from the config files directly or via `claude mcp get <name>`.
### B.2 Claude Desktop
`claude_desktop_config.json`. Location differs per platform; on Windows it sits under `%APPDATA%\Claude\`. Same extraction as B.1.
### B.3 What to extract, per server
```json
{
"server_name": "mcp-salesforce-ops",
"transport": "stdio | http | sse | ws",
"endpoint": "<command+args, or URL>",
"config_root": "~/.claude.json#user",
"auth_mode": "oauth | static-header | headersHelper | env-credential",
"env_var_names": ["SFDC_CLIENT_ID", "SFDC_REFRESH_TOKEN"],
"env_var_values": "[redacted]",
"status": "connected | pending-approval | failed | disabled"
}
```
**Values are redacted at parse time, not at report time.** A config file read into memory and written to `run_dir` unredacted has already leaked, regardless of what the report prints.
---
## Part C — Owner registry (fill this in)
CSV at the path given as `owner_registry`. One row per grant you already know about. Grants found during collection that are absent here report as unowned — which is the intended behavior, not a gap in the Skill.
```csv
app_identifier,owner_email,business_justification,approved_date,review_interval_days
Salesloft,revops-platform@example.com,"Sequence sync — contacts + activities",2025-03-14,180
SVC_AGENT_CRM,revops-platform@example.com,"Shared agent service account — SPLIT PENDING",2026-01-09,90
mcp-pipeline-report,gtm-eng@example.com,"Read-only forecast Q&A from Claude Code",2026-05-02,180
Dataloader,dataops@example.com,"Bulk import tooling — admin use only",2024-11-20,365
```
`app_identifier` matches `AppName` from A.1, the private-app name from A.3, or `server_name` from B.3.
Two columns people skip and then need:
- **`business_justification`** — one line naming the objects touched and why. "Integration" is not a justification. At review time this is the sentence a reviewer either agrees with or does not.
- **`review_interval_days`** — per-grant, not org-wide. A write-capable grant on a shared credential deserves 90 days; a read-only reporting connector does not need the same cadence.
---
## Part D — API Access Control and its bypass
Salesforce **API Access Control** locks API access to an allowlist of approved connected apps. It is not on by default and cannot be self-enabled — you log a support case to have it turned on for the org.
If it is enabled, record the allowlist as its own plane-A source. If it is not, record that fact; it changes what an orphan grant means. Without the allowlist, an unrecognized connected app has API reach by default.
**Collect assignees of the `Use Any API Client` permission either way.** That permission bypasses API Access Control entirely — a user holding it reaches any connected app in the org regardless of the allowlist. Score every assignee as a write-capable grant in its own right.
```
sf data query --query "SELECT Assignee.Username, PermissionSet.Name FROM PermissionSetAssignment WHERE PermissionSet.PermissionsApiUserOnly = true" --result-format csv
```
Adjust the field to the permission your org actually uses; the point is that the permission-set assignment list belongs in the inventory next to the app list. Grant it through an expiring permission set assignment rather than a profile, and never to a business user.
# Reference 2 — Blast radius rubric
Scoring runs as code against these tables. Edit the tables; do not move the decision into a prompt. A quarterly access review is only useful if the same input produces the same score, and a model re-ranking 90 grants produces a different order each run — which turns the quarter-over-quarter diff into noise.
Base tier comes from the scope set. Modifiers adjust it. The result is T1 (highest) through T4.
---
## Part A — Salesforce scope tiers
| Scope | Base tier | Why |
|---|---|---|
| `full` | T1 | Everything the authorizing user can reach. On an admin's authorization, that is the org. |
| `api` | T2 | Read and write across standard and custom objects, bounded only by the user's own permissions. |
| `web`, `visualforce` | T3 | Session-scoped UI access. Reaches data, does not persist independently. |
| `openid`, `id`, `profile`, `email` | T4 | Identity only. |
| `chatter_api`, `content` | T3 | Bounded to Chatter and Files. Files carry more than people expect — raise to T2 if contracts or exports live there. |
| `custom_permissions` | T4 | Reads permission assignments; grants nothing itself. |
| *unknown / not collected* | **T1** | Fail loud. A missing scope set is not a low-risk scope set. |
`refresh_token` / `offline_access` is not a tier of its own — it is the **persistence modifier** below. On its own it grants no data access; combined with `api` it is what makes a stale grant still live 359 days later.
## Part B — HubSpot scope tiers
| Scope pattern | Base tier | Why |
|---|---|---|
| `crm.objects.*.write` (contacts, companies, deals, tickets) | T2 | Direct record mutation on core objects. |
| `crm.schemas.*.write` | **T1** | Changes property definitions, not just values. A schema change breaks every downstream consumer at once and is not row-level reversible. |
| `crm.export` | T2 | Bulk read-out. Read-only in the CRM sense, exfiltration-shaped in the incident sense. |
| `crm.objects.*.read` | T3 | Read-only on core objects. |
| `settings.users.write` | **T1** | Reaches the permission system itself. |
| `automation` | T2 | Workflow enrollment. See the enrollment blast-radius note below. |
| `oauth` | T4 | Token lifecycle only. |
| *unknown / not collected* | **T1** | Same rule as Salesforce. |
**The enrollment note.** A scope that writes properties reaches further than the properties. A backfill across 4,000 contacts can enroll all 4,000 in an active workflow whose trigger references the touched property. Score `crm.objects.*.write` against the workflows currently active in the portal, not against the property list in isolation.
---
## Part C — Modifiers
Applied in order. Tier movement is capped at two steps up and zero steps down — nothing in this table lowers a tier, because every entry describes an aggravating condition.
| Modifier | Condition | Effect |
|---|---|---|
| **Persistence** | `refresh_token` or `offline_access` present | +1 tier |
| **Unowned** | No row in the owner registry | +1 tier |
| **Unattributed** | Credential shared by 2+ agent servers | +1 tier |
| **Self-authorizing** | Salesforce `Permitted Users` = all users may self-authorize | +1 tier |
| **Annotation mismatch** | All tools declare `readOnlyHint: true`, credential carries write scopes | +1 tier |
| **Stale** | `LastUsedDate` older than `stale_days` **and** persistence modifier present | +1 tier |
| **Unannotated CRM server** | MCP server touching CRM with no tool annotations | +1 tier |
| **High volume** | `UseCount` in the top decile of collected grants | no tier change — sort key only |
### Why stale requires persistence
An unused grant with no refresh token expires on its own and is housekeeping. An unused grant *with* a live refresh token is an open door nobody is watching. Only the second one earns the modifier. Flagging both floods T2 with dormant rows and trains reviewers to skim.
### Why unannotated raises rather than lowers
The MCP specification's annotation defaults are `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. An unannotated tool is therefore specified as write-capable and destructive. Treating a missing annotation as "probably fine" inverts the spec's own default.
---
## Part D — Calibration
Set these to your org. The defaults are starting points, not findings.
```yaml
stale_days: 90 # match your access-review interval
high_volume_percentile: 90 # UseCount decile for the sort key
t1_response_hours: 24 # T1 findings need an owner decision inside this window
t2_response_days: 14
max_findings_in_body: 40 # beyond this the report links to grants.jsonl
```
**A note on `max_findings_in_body`.** A first run against an org that has never audited this produces 60-120 findings and the report becomes unreadable. Cap the body, keep every record in `grants.jsonl`, and print the truncation explicitly — a silently truncated list reads as a complete one.
---
## Part E — Worked example
Salesforce connected app, scopes `api` + `refresh_token`, no registry row, `LastUsedDate` 359 days ago, `Permitted Users` = admin approved.
```
base(api) = T2
+ persistence (refresh_token) → T1
+ unowned → T1 (capped, already at ceiling)
+ stale (359 > 90, persists) → T1
final = T1
```
Three independent modifiers all pointing at the same grant is the signature the reviewer should learn to read. One modifier is a configuration choice. Three is an integration nobody owns.
# Reference 3 — Finding dispositions
Every disposition here is a **proposal**. The Skill writes them into the report; a named human fires them. Each entry states what the remediation breaks, because the remediations in this file are mostly org-wide and immediate, and the cost of firing one blindly lands on people who were using the integration.
Fill the approver column before the first run. An unrouted finding is a finding nobody closes.
| Disposition | Approver | Notification window |
|---|---|---|
| R1 — scope reduction | | 5 business days |
| R2 — block connected app | | 24 hours |
| R3 — per-user token revoke | | none required |
| R4 — split shared credential | | 10 business days |
| R5 — assign owner | | none required |
| R6 — restrict permitted users | | 5 business days |
| R7 — remove agent config | | 2 business days |
---
## R1 — Scope reduction
**For:** `scope-excess`.
Re-register the connected app or private app with the narrowest scope set its configured tools actually use. In Salesforce that means editing the connected app's OAuth scopes; in HubSpot it means creating a new private app with the reduced scope set and rotating the integration onto it.
**What it breaks:** every call the integration makes that used the removed scope, immediately and without a deprecation path. HubSpot private-app scope changes issue a new token — the old one stops working, so this is a rotation, not an edit.
**Sequence:** reduce in a sandbox first, run the integration's own test path, then production. Do not reduce and rotate in one change window; if something breaks you will not know which half caused it.
---
## R2 — Block connected app (Salesforce)
**For:** `orphan-grant` at T1, or any app you cannot identify.
Setup → Connected Apps OAuth Usage → **Block**.
**What it breaks:** every user's authorization for that app, across the whole org, at once. There is no per-user variant of Block and no staged rollout. Anything running against that app fails on its next call.
**Before firing:** check `UseCount` and `LastUsedDate` from `raw/sfdc-oauthtoken.csv`. An app with 14,000 uses and a last-use date inside the week is load-bearing for somebody, whatever the registry says. High use count with no owner means the owner search was incomplete, not that the integration is abandoned.
**When to fire immediately anyway:** the app is unrecognized and its publisher cannot be established, or a vendor has disclosed a token compromise. In an incident, breaking a working integration is the cheaper error.
---
## R3 — Per-user token revoke
**For:** `stale-grant` tied to one user, and offboarding cleanup.
Revoke from the user's detail page under **OAuth Connected Apps**, or programmatically:
```
POST https://<MyDomain>.my.salesforce.com/services/oauth2/revoke
token=<refresh_or_access_token>
```
**What it breaks:** that one user's authorization for that one app. The narrowest tool here.
**The offboarding trap this exists for:** deactivating a Salesforce user does not revoke that user's OAuth authorizations. Every departed employee who ever authorized an integration leaves a live grant behind unless someone revokes it explicitly. Run R3 across the full app list for each departure, not just the apps you remember them using.
---
## R4 — Split a shared credential
**For:** `unattributed-write`.
Register one connected app or private app per agent server, rotate each server onto its own credential, then revoke the shared one.
**What it breaks:** nothing, if sequenced correctly — register all N, rotate all N, verify all N, then revoke. Revoking before verification breaks every consumer simultaneously, which is the failure that makes teams abandon the split halfway and leave the shared credential in place.
**Why it is worth the 10-day window:** CRM audit rows attribute to the authenticated identity. With one credential behind four servers, an audit trail proves that a write happened and cannot establish which agent made it. That distinction only matters once — during an incident — and it cannot be reconstructed after the fact.
---
## R5 — Assign an owner
**For:** every unowned grant, before any other disposition.
Add the row to the registry in `references/1-grant-inventory-sources.md` Part C with a real justification and a review interval.
**What it breaks:** nothing. It is also the disposition that closes the most findings on a first run, because an org that has never kept a registry flags a large set of legitimate integrations that simply were not written down. Do this pass first — the unowned set that survives an honest ownership search is smaller and much more interesting.
---
## R6 — Restrict permitted users
**For:** any app set to `All users may self-authorize`.
Set **Permitted Users** to `Admin approved users are pre-authorized`, then grant access through a profile or permission set.
**What it breaks:** access for every user who self-authorized and does not hold the new profile or permission set. Pull the distinct `UserId` list for that app from `raw/sfdc-oauthtoken.csv` first and pre-assign the permission set to those users. Skipping that step converts a configuration change into an outage for an unknown number of people.
**Do this before enabling API Access Control**, not after. Enabling the allowlist against a population of self-authorized users produces a support queue.
---
## R7 — Remove an agent configuration
**For:** `annotation-mismatch` where the server does not need write access, and for servers whose credential you are revoking.
```
claude mcp remove <name>
```
**What it breaks:** that server for that scope only. Check which scope holds it first — `claude mcp get <name>` names the config root. Removing a `project`-scoped server edits `.mcp.json` and lands in the repository, so it affects everyone who clones it; removing a `local` or `user` server touches only `~/.claude.json` on that machine.
**Removing the config does not revoke the credential.** The token in the removed entry's `env` block stays valid until you revoke it in the CRM. Pair R7 with R1 or R3 — on its own it removes the client and leaves the grant.