fix(installer): never create Cursor's permissions.json (it turns off Run Everything) - #787
fix(installer): never create Cursor's permissions.json (it turns off Run Everything)#787filip131311 wants to merge 7 commits into
Conversation
…Run Everything)
`argent init` created ~/.cursor/permissions.json as
`{ "mcpAllowlist": ["argent:*"] }` when the user had no such file. Cursor
derives its Run Mode from that file, so the write silently demoted users
who had picked "Run Everything (unsandboxed)" down to allowlist mode:
shouldPermissionsFileConstrainUnrestrictedMode() {
...
return mcpAllowlist.length > 0 || terminalAllowlist.length > 0;
}
surfaced in the UI as `Run Mode (Enforced by ~/.cursor/permissions.json)`.
The same non-emptiness also makes the file supersede the in-IDE MCP
allowlist and freeze its editor (`getEffectiveMcpAllowlist` ignores
composerState.mcpAllowedTools, `canAddToAllowlistFromIde("mcp")` returns
false), so the user's own "Always allow" entries stop being consulted.
addAllowlist is now append-only: it adds `argent:*` where the user already
keeps a file-based mcpAllowlist (there both effects are already their own
doing, and the rule changes no state) and otherwise leaves the file alone.
Nothing is lost by skipping — under "Run Everything" the allowlist is never
consulted, and everything auto-runs anyway.
An existing file is never deleted, argent-only shape included: a file an
earlier argent created is indistinguishable from one the user keeps on
purpose, and dropping the latter would take away an allowlist they want.
Writing `approvalMode: "unrestricted"` to lift the constraint instead was
rejected: Cursor treats a permissions-file approvalMode as authoritative
(`getModeFullAutoRun` returns true for it), so it would force Run Everything
ON for users who deliberately chose "Ask every time".
init reports a skip instead of claiming it added the rule.
Verified against the Cursor 3.15.6 bundle. The cursor-agent CLI is
unaffected: its permissions-file provider reports approvalMode
"unrestricted", and only a provider reporting "allowlist" downgrades.
dd2fb32 to
12ee0eb
Compare
latekvo
left a comment
There was a problem hiding this comment.
[Diplomat, Deepseek V4 Flash 0731]: Ran the full sweep across the changed lines and their call sites: claims vs code, nearest twin (add vs remove vs sibling adapters), non-happy paths, inputs, reachability both ways, lifetime, plus the absence pass (sibling / prose / symmetry / mutation). Replayed configureAllowlist end-to-end through a sandboxed HOME with only the Cursor adapter; replayed the package suite (558 tests pass, eslint --max-warnings 0 clean; tsc --build is blocked by type-drift from a cross-branch symlinked node_modules, not this PR). Returned one medium and three low findings below.
| try { | ||
| adapter.addAllowlist!(effectiveRoot, scope); | ||
| lines.push(`${pc.green("+")} ${adapter.name}`); | ||
| const note = adapter.addAllowlist!(effectiveRoot, scope); |
There was a problem hiding this comment.
[Diplomat, Deepseek V4 Flash 0731]: enabled is set before this loop from adaptersWithAllowlist.length > 0 plus the confirm/nonInteractive decision, so when Cursor is the only allowlist-capable adapter and its addAllowlist returns the new skip note (permissions.json absent, or a missing/empty mcpAllowlist), this line's note renders but no write happens while enabled stays true. Replayed end-to-end: with a fresh HOME, only the Cursor adapter, and nonInteractive=true, configureAllowlist returns enabled: true, prints the - Cursor (skipped ...) line, and creates nothing. That false enabled is what init.ts:253 records via track('installation:allowlist_decision', { is_enabled: true }), and the nonInteractive path (init-allowlist.ts:33-34) sets it true unconditionally with no human to catch the mismatch - so the analytics log the allowlist as enabled for an outcome that did not happen and, under Run Everything, never could.
There was a problem hiding this comment.
Accurate chain, but not new semantics from this PR: the event is named allowlist_decision and records the user's choice (or the nonInteractive default), not per-adapter write outcomes. enabled: true alongside a skipped adapter was already possible before this change via the (no config for this scope) branch a few lines up — decision ≠ outcome has always been the contract here. If we want outcome-level analytics, that's a separate per-adapter event, out of scope for this fix. Not changing.
| addAllowlist?(root: string, scope: "local" | "global"): void; | ||
| // Returns a short note when the rule was deliberately NOT added (or an | ||
| // earlier one was undone) so init can say so instead of claiming a write. | ||
| addAllowlist?(root: string, scope: "local" | "global"): string | void; |
There was a problem hiding this comment.
[Diplomat, Deepseek V4 Flash 0731]: The new string | void return is documented as 'so init can say so', but update.ts:750 calls addAllowlist and discards the value. On an argent update where Cursor's permissions.json is absent or empty - a machine that a pre-change build would have created the file for - the never-create skip is completely silent; a user refreshing allowlists (the stated intent at update.ts:744) gets no signal that the Cursor rule was not (re)added, even though the SKIP is now the default outcome for exactly this population.
There was a problem hiding this comment.
True, but consistent with update's existing reporting: the allowlist-refresh loop in update.ts has never surfaced per-adapter results — successes print nothing and errors are swallowed with catch { /* non-fatal */ }. The skip is exactly as silent as a success always was there. Surfacing allowlist outcomes on argent update would be a reasonable enhancement to that loop as a whole (all eight adapters, success and error included), but it's a separate change to update's reporting pattern, not a gap this PR introduces. Not changing here.
The empty/missing-mcpAllowlist skip note only mentioned the in-app allowlist override, but adding a first mcpAllowlist to such a file also newly constrains the run mode (an empty file does not) — name both effects. And drop the 'or an earlier one was undone' clause from the addAllowlist doc: no adapter undoes rules there; that is removeAllowlist's job.
Adopts the two ideas from PR #742 that survive the never-create fix: - --no-allowlist on init and update skips the editor auto-approve allowlist step entirely — a scriptable opt-out for CI images and provisioning, where the interactive prompt never runs. - update's allowlist refresh now passes refresh: true, and Cursor treats argent:* missing from a list the user maintains as a deliberate removal instead of re-adding it on every update. Re-opting in is argent init's allowlist step. The other adapters keep re-adding on refresh: their entries have no side effects beyond argent, so a refresh that restores them is what update promises. Unlike #742's createIfMissing:false (which made update read-only in a way the option name hid), the refresh option says what it means and is honored only where the distinction matters.
…t skips Review follow-ups on the refresh mechanism: - Zed's addAllowlist sets agent.tool_permissions.default = "allow", which governs every agent tool in Zed, not just argent's. On refresh it now declines to re-impose the value when the user changed it — the same override class the Cursor guard exists for. - Codex's addAllowlist force-wrote approval_mode = "approve" for every tool id. On refresh it now fills in only missing ids (new tools from an update), keeps entries the user restricted, and leaves a removed or emptied table alone. - update prints the per-adapter skip notes and a trace line for --no-allowlist, so a missing auto-approve entry after an update is explainable from the output; the notes no longer claim a removal the code cannot establish (absence also matches never-opted-in). - allowlist_decision telemetry gains decided_by (flag | default | prompt) so an automated --no-allowlist skip is distinguishable from a user declining the prompt. - The shared --no-allowlist spelling in installer help lives in one constant, matching the file's convention for shared flags.
… adapter Round-2 review follow-ups. The refresh guard now covers every adapter, under one rule: update's refresh never changes the approval state the user has — when an adapter's opted-in value is absent or was changed since init, it skips with a note instead of re-imposing it. - Claude Code: no longer re-adds the permissions rule (or creates settings.json for a user who declined at init) on refresh. - Windsurf/Kiro: a user-narrowed alwaysAllow/autoApprove stays theirs. - Gemini/opencode: an unset or falsified trust/tools toggle stays. - Zed: the guard now honors Zed's project-over-global settings merge, so a global opt-in no longer yields a false skip note (and bad re-init advice) on local-scope refreshes; the opt-in schema is read in one shared helper. - Codex: opt-in is derived from the entry values (at least one "approve"), not table presence, so an all-deny table stops new tool ids from arriving pre-approved; the refresh and init paths share one fill loop, and the tool-id subprocess is spawned only when a write can happen. The one write refresh still performs is Codex's backfill of tool ids a new version added, for users whose table shows opt-in.
…ewrite update rewrites the argent MCP entry on every run, and the Gemini, Kiro, Windsurf and Codex write() replaced the entry wholesale — dropping trust / autoApprove / alwaysAllow / the Codex tools table, which live inside the entry. The unconditional addAllowlist re-add that followed used to mask that; with update's refresh guard (which only re-reads the opt-in) one update silently removed auto-approval for those four adapters and blamed the user for opting out. write() now merges over the existing entry: command/args/env are argent's to author, every other key (the opt-in, a user timeout) is carried over. Four regression tests drive update's sequence — write, opt in, write again, refresh — and fail without the fix.
|
@latekvo i need actuall review |
There was a problem hiding this comment.
Pull request overview
This PR adjusts the installer’s editor allowlist behavior to avoid unintended changes to users’ IDE security/run-mode settings—most notably Cursor’s “Run Everything”—by making allowlist updates append-only and non-creating, and by making update refreshes non-overriding.
Changes:
- Cursor: prevent creating or expanding
~/.cursor/permissions.jsonin ways that would demote “Run Everything” or override the in-app MCP allowlist; only append when an existing non-empty MCP allowlist is already present. - Add
--no-allowlisttoinitandupdate, and implement “refresh” semantics so periodic updates don’t re-impose user-removed/narrowed opt-ins (with explanatory notes). - Extend telemetry for allowlist decisions with
decided_byto distinguish flag/default/prompt paths.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/telemetry/src/sanitize.ts | Allows new decided_by field for installation:allowlist_decision telemetry validation. |
| packages/telemetry/src/events.ts | Adds decided_by to the typed telemetry props for allowlist decisions. |
| packages/argent/src/installer-help.ts | Documents --no-allowlist on init and update help output. |
| packages/argent-installer/src/init-args.ts | Adds parsing for --no-allowlist on init. |
| packages/argent-installer/test/init-args.test.ts | Tests --no-allowlist parsing behavior. |
| packages/argent-installer/src/init-allowlist.ts | Implements skip behavior for --no-allowlist, tracks decidedBy, and surfaces adapter “skip notes”. |
| packages/argent-installer/src/init.ts | Wires noAllowlist into allowlist configuration and emits telemetry with decided_by. |
| packages/argent-installer/src/update.ts | Adds --no-allowlist support and refresh-mode allowlist updates with non-overriding notes. |
| packages/argent-installer/src/mcp-configs.ts | Updates adapter allowlist APIs (notes + refresh mode), adds Cursor “never create permissions.json” rule, and preserves entry-scoped opt-in keys on rewrites. |
| packages/argent-installer/test/mcp-configs.test.ts | Adds/updates test coverage for Cursor’s permissions behavior and per-adapter refresh guard semantics. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
|
||
| export interface InstallationAllowlistDecisionProps { | ||
| is_enabled: boolean; | ||
| /** "flag" = --no-allowlist, "default" = -y accepted defaults, "prompt" = the user answered. */ |
The bug
Reported by Magda: with Cursor's run mode set to Run Everything (unsandboxed), running
argent initsilently reverts it to Allowlist.Her diagnosis was exactly right.
argent initcreates~/.cursor/permissions.jsonas{ "mcpAllowlist": ["argent:*"] }when the user has no such file, and Cursor derives its Run Mode from that file (verified in the Cursor 3.15.6 bundle):The UI then reads
Run Mode (Enforced by ~/.cursor/permissions.json). It doesn't matter what we write — the file merely being non-empty is enough.Second effect, same write: once the file has an
mcpAllowlist, it supersedes the in-IDE MCP allowlist —getEffectiveMcpAllowlistreturns the file's list and ignorescomposerState.mcpAllowedTools, andcanAddToAllowlistFromIde("mcp")goesfalse(editor read-only). A user who had "Always allow"-ed other MCP servers silently stops getting them.Not to be confused with the older clobbering bug (argent rewriting the file and dropping the user's rules) — that one was fixed by the JSONC append path and is covered by existing tests.
The fix
addAllowlistbecomes append-only, never create:~/.cursor/permissions.jsonmcpAllowlistmcpAllowlistargent:*appendedargent:*appended (unchanged)Skipping costs those users nothing: under Run Everything the allowlist is never consulted, so the rule we were adding was already a no-op. Where the user already keeps a file-based allowlist, both effects are their own doing and
argent:*changes no state — so we still append there.Existing files are never deleted, argent-only shape included. A file an earlier argent created is indistinguishable from one the user keeps on purpose, and if they like that allowlist it should stay. Removing it is a manual step (or
argent uninstall, whoseremoveAllowlistalready prunes the entry and drops the file when nothing else is left).initnow reports the skip instead of claiming it added the rule:Rejected alternative
Writing
"approvalMode": "unrestricted"alongside the allowlist does silence the constraint — but Cursor treats a permissions-fileapprovalModeas authoritative, not permissive:That would push users who deliberately chose "Ask every time" into Run Everything. Disqualified.
Scope
IDE only. The
cursor-agentCLI is unaffected — its permissions-file provider reportsapprovalMode: "unrestricted"and only a provider reporting"allowlist"downgrades the merge.Already-demoted machines are not repaired by this PR: those users delete
~/.cursor/permissions.json(or drop theargent:*rule) once, and Run Everything returns — Cursor evaluates the constraint on read and never writes the demotion back, so nothing needs reconfiguring.Test plan
mcp-configs.test.ts: no file created (+ note), file withoutmcpAllowlistuntouched, existing argent-only file left in place, user-owned file withargent:*left byte-intactpackages/argent-installersuite: 558 passed;tsc --build,eslint --max-warnings 0, bothknipgates (215/215) clean🤖 Generated with Claude Code
Adopted from #742, generalized
Two ideas from #742 land here in reworked form (credit @softwarebyze):
--no-allowlistoninitandupdateskips the editor auto-approve allowlist step entirely — a scriptable opt-out for CI images and provisioning, where the prompt never runs. Documented in both--helpoutputs (the flags-sync test keeps them honest);allowlist_decisiontelemetry gainsdecided_by: flag | default | promptso an automated skip is distinguishable from a user declining.Update's allowlist refresh never changes the approval state the user has.
addAllowlisttakesrefresh: trueon update's re-run; every adapter now declines (with a note) to re-impose its opted-in value when it is absent or was changed since init:argent:*missing from an existing list is not re-appendedtool_permissions.defaultthe user reset from"allow"stays (the guard honors Zed's project-over-global settings merge)"approve"), a restricted entry keeps its mode, an all-deny or removed table stays; the one refresh write left anywhere is backfilling tool ids a new version added, for opted-in tablessettings.jsonnot created) on refreshalwaysAllow/autoApprovestays narrowedtrust/tools toggle staysUnlike fix(installer): stop Cursor updates from forcing a partial MCP allowlist #742's
createIfMissing:false(which silently made update read-only), the option says what it means, and each skip prints a note (- Cursor: skipped - argent:* is not on this allowlist; opt in via argent init) so a missing auto-approve entry after an update is explainable from the output.argent initremains the explicit (re-)opt-in path.Known limitations (follow-up material)
update-argenttool) spawnsargent update --yesdetached withstdio: "ignore", so the skip notes are not visible on that path and--no-allowlistcannot be passed there. The refresh guards themselves do apply, which removes the harmful overrides; durable observability there needs a log file or telemetry from update.Test plan (additions)
--no-allowlistparsing), 295 telemetry, 80 argent (flags-sync green)HOME:--helpshows the flag;init -y --no-allowlistwrites nothing and says so; removedargent:*/ reverted Zed default / deleted opencode entry all surviveargent updatewith notes printed; fully opted-in update prints zero notes and keeps state;update --no-allowlistprints its trace line