Skip to content

fix(installer): never create Cursor's permissions.json (it turns off Run Everything) - #787

Open
filip131311 wants to merge 7 commits into
mainfrom
filip/cursor-permissions-run-mode
Open

fix(installer): never create Cursor's permissions.json (it turns off Run Everything)#787
filip131311 wants to merge 7 commits into
mainfrom
filip/cursor-permissions-run-mode

Conversation

@filip131311

@filip131311 filip131311 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

The bug

Reported by Magda: with Cursor's run mode set to Run Everything (unsandboxed), running argent init silently reverts it to Allowlist.

Her diagnosis was exactly right. argent init creates ~/.cursor/permissions.json as { "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):

shouldPermissionsFileConstrainUnrestrictedMode(){
  
  if (t === "unrestricted") return false;
  if (t === "allowlist" || t === "manual") return true;
  return mcpAllowlist.length > 0 || terminalAllowlist.length > 0;   // ← our write
}

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 — getEffectiveMcpAllowlist returns the file's list and ignores composerState.mcpAllowedTools, and canAddToAllowlistFromIde("mcp") goes false (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

addAllowlist becomes append-only, never create:

~/.cursor/permissions.json before after
absent created → run mode demoted left alone
exists, no mcpAllowlist key added → in-IDE allowlist taken over left alone
exists with mcpAllowlist argent:* appended argent:* 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, whose removeAllowlist already prunes the entry and drops the file when nothing else is left).

init now reports the skip instead of claiming it added the rule:

- Cursor (skipped - creating ~/.cursor/permissions.json would turn off Cursor's "Run Everything" mode)

Rejected alternative

Writing "approvalMode": "unrestricted" alongside the allowlist does silence the constraint — but Cursor treats a permissions-file approvalMode as authoritative, not permissive:

getModeFullAutoRun(e){ switch(){ case "unrestricted": return true;   // forces it ON

That would push users who deliberately chose "Ask every time" into Run Everything. Disqualified.

Scope

IDE only. The cursor-agent CLI is unaffected — its permissions-file provider reports approvalMode: "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 the argent:* rule) once, and Run Everything returns — Cursor evaluates the constraint on read and never writes the demotion back, so nothing needs reconfiguring.

Test plan

  • 4 new cases in mcp-configs.test.ts: no file created (+ note), file without mcpAllowlist untouched, existing argent-only file left in place, user-owned file with argent:* left byte-intact
  • Existing Cursor allowlist tests (comment/foreign-rule preservation, remove round-trip, argent-only detection) unchanged and green
  • packages/argent-installer suite: 558 passed; tsc --build, eslint --max-warnings 0, both knip gates (215/215) clean

🤖 Generated with Claude Code


Adopted from #742, generalized

Two ideas from #742 land here in reworked form (credit @softwarebyze):

  • --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 prompt never runs. Documented in both --help outputs (the flags-sync test keeps them honest); allowlist_decision telemetry gains decided_by: flag | default | prompt so an automated skip is distinguishable from a user declining.

  • Update's allowlist refresh never changes the approval state the user has. addAllowlist takes refresh: true on 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:

    • Cursor: argent:* missing from an existing list is not re-appended
    • Zed: a tool_permissions.default the user reset from "allow" stays (the guard honors Zed's project-over-global settings merge)
    • Codex: opt-in derives from entry values (≥1 "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 tables
    • Claude Code: the permissions rule is not re-added (and settings.json not created) on refresh
    • Windsurf/Kiro: a narrowed alwaysAllow/autoApprove stays narrowed
    • Gemini/opencode: an unset/falsified trust/tools toggle stays

    Unlike 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 init remains the explicit (re-)opt-in path.

Known limitations (follow-up material)

  • No consent bit is persisted anywhere, so refresh infers intent from file shape — it cannot tell "removed on purpose" from "never added" and errs toward not writing.
  • The agent-triggered auto-update (update-argent tool) spawns argent update --yes detached with stdio: "ignore", so the skip notes are not visible on that path and --no-allowlist cannot 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)

  • 571 installer tests (13 new: per-adapter refresh guards incl. Zed scope-merge and Codex all-deny, --no-allowlist parsing), 295 telemetry, 80 argent (flags-sync green)
  • Live from a packed tgz in a sandboxed HOME: --help shows the flag; init -y --no-allowlist writes nothing and says so; removed argent:* / reverted Zed default / deleted opencode entry all survive argent update with notes printed; fully opted-in update prints zero notes and keeps state; update --no-allowlist prints its trace line

…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.
@filip131311
filip131311 force-pushed the filip/cursor-permissions-run-mode branch from dd2fb32 to 12ee0eb Compare August 13, 2026 21:36
@filip131311
filip131311 requested a review from latekvo August 13, 2026 21:47

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread packages/argent-installer/src/mcp-configs.ts Outdated
Comment thread packages/argent-installer/src/mcp-configs.ts Outdated
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.
@latekvo latekvo closed this Aug 21, 2026
@latekvo latekvo reopened this Aug 21, 2026
@latekvo latekvo closed this Aug 21, 2026
@latekvo latekvo reopened this Aug 21, 2026
…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.
@filip131311
filip131311 requested a lite review from Copilot August 21, 2026 15:13
@filip131311

Copy link
Copy Markdown
Collaborator Author

@latekvo i need actuall review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.json in 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-allowlist to init and update, 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_by to 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. */
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.

3 participants