Skip to content

fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id - #548

Merged
kshivang merged 3 commits into
risa-labs-inc:devfrom
Antriksh1984:fix/plugin-storage-cross-plugin-access
Sep 14, 2026
Merged

kshivang merged 3 commits into
risa-labs-inc:devfrom
Antriksh1984:fix/plugin-storage-cross-plugin-access

Conversation

@Antriksh1984

@Antriksh1984 Antriksh1984 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

TrackingPluginContext - the only PluginContext a plugin's own code ever holds (every registration path in DynamicPluginManager wraps createSandboxedContext in a TrackingPluginContext before calling register()) - exposed the raw, unscoped PluginStorageFactory straight through from its delegate:

override val pluginStorageFactory: PluginStorageFactory? get() = delegate.pluginStorageFactory

That factory's createStorage(pluginId: String) takes an arbitrary pluginId per call, and the desktop implementation (PluginStorageFactoryImpl) persists purely off that string - ~/.boss/plugin-data/{pluginId}/storage.properties - with no check anywhere that it names the calling plugin.

API impact: a plugin could ask the supported context API for another plugin's persisted data with:

context.pluginStorageFactory?.createStorage("victim-plugin-id")
  • and then read, overwrite, or clear() it. This scopes the supported API only. In-process JVM plugins retain direct host-class and filesystem access; this wrapper is not a security sandbox.

Fix

Added ScopedPluginStorageFactory, which binds createStorage to the context's own pluginId (the manifest identity registered by the host) and ignores whatever id the caller passes in:

private class ScopedPluginStorageFactory(
    private val ownPluginId: String,
    private val delegate: PluginStorageFactory,
) : PluginStorageFactory {
    override fun createStorage(pluginId: String): PluginStorageProvider = delegate.createStorage(ownPluginId)
}

This mirrors the same pattern DownloadCenterProviderImpl.forPlugin already uses in this codebase to keep one plugin's transfer ids from colliding with or addressing another's - identity bound once at construction from a source the plugin doesn't control, never trusted from a per-call argument.

SandboxedPluginContext is deliberately untouched: it already passes downloadCenterProvider through unscoped in the same way, since TrackingPluginContext wraps it and is the only context a plugin ever actually sees (confirmed by tracing both registration paths in DynamicPluginManager). This matches that existing, working precedent rather than adding a second, redundant scoping layer.

Tests

composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/TrackingPluginContextStorageScopeTest.kt (new): the underlying factory is only ever asked for the context's own plugin id regardless of what a caller requests, and a write made through one plugin's storage reference never lands in another plugin's real storage. Also confirms a null delegate factory stays null rather than becoming a scoped wrapper around nothing.

Validation: deno... n/a (Kotlin). ktlintCheck and detekt both pass, TrackingPluginContextStorageScopeTest (3/3) and the pre-existing TrackingPluginContextMcpTest (3/3, unaffected) both pass, verified in an isolated worktree built fresh off dev.

Test plan

  • ktlintCheck / detekt
  • TrackingPluginContextStorageScopeTest - 3/3 passing
  • TrackingPluginContextMcpTest (pre-existing, unaffected) - 3/3 passing

🤖 Generated with Claude Code

Review follow-up

Original production fix and three tests are by @Antriksh1984. Agent commit d8b0c6b strengthens the test fake to share stores by ID, like production, and confirms an attempted cross-plugin write remains visible through the caller's own context. The original requested-ID assertions already detected the missing wrapper; the old fake did not independently establish persistence isolation. This test repair is agent work, not contributor credit.

Original head passed all nine hosted checks. Current-head focused tests and root ktlintCheck/detekt are queued under the shared build lock; current-head CI and Claude review are pending. API pin remains 1.0.88. Other-group #529 addresses persistence transactions and #565 addresses artifact cache paths; neither is superseded by this identity-binding change.

Claude reviewed d8b0c6b: correct routing and no blocking defect. Follow-up asserts returned provider identity and documents sandbox-wrapper and JVM-boundary limits. Workspace plugin caller audit found no derived storage sub-namespaces. Current-head validation remains pending.

Current-head review — 2026-09-14

Current head b9a6d4ca080dfff8f4d54199259081c61aa22383 has all ten hosted checks passing, including macOS/Linux/Windows builds, root ktlint/detekt and compatibility. Downloaded the macOS test artifact and verified 3 storage-scope and 3 MCP-context tests with zero failures/errors/skips. These are current-head executed tests, superseding the prior pending-validation handoff. Fresh main includes merged #626; the relevant implementation is not already present in main/dev. No native UI execution is claimed.

All production storage scoping remains @Antriksh1984’s original contribution. Prior agent test-fake/identity/documentation repairs and their authorship are preserved by the contributor’s rebase; this sweep made no production-code changes.

Workspace caller audit, including Flow’s STORAGE_NAMESPACE, confirms own manifest IDs rather than derived sub-namespaces. Per-call arbitrary-ID warning and prefix exemptions remain declined: neither is needed for correct scoping, and logging every arbitrary caller string introduces noise. This scopes the supported API, not JVM/filesystem access. Other-group #529 persistence transactions and #565 cache paths are distinct, untouched work.

Current-head Claude review completed: https://github.com/risa-labs-inc/BossConsole/actions/runs/34804612938 — no unresolved correctness defect or blocker. Its checkout called f91633d current dev; this sweep independently refreshed later dev and verified subsequent database audit/executable-download changes do not affect storage routing, dependencies or these tests.

@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review the current head of this PR against dev for correctness and meaningful regression coverage. Check plugin-context wrapper routing for #548; for #552 check native owner selection, nested directory mode restoration and native-peer disposal. Related #611 is being compared for consolidation by this group; please do not mutate other PRs.

@claude

claude Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 4m 30s —— View job


Review: PR #548 - scope PluginStorageFactory.createStorage to the caller's own id

Verdict: the fix is correct and the routing claim holds. Two things I'd change before merge (one log line, one comment), and one framing correction in the PR/KDoc wording. No blocking defect found.


The routing claim checks out

I traced it rather than taking it on trust:

  • TrackingPluginContext is constructed at exactly two places, DynamicPluginManager.kt:1012 and :1093, and stored in trackingContexts (:192).
  • Every instance.register(...) in the manager - :1021, :1113, :1618, :1753, :2411 - is passed a value read out of that map. There is no register(baseContext) anywhere.
  • SandboxedPluginContext has one construction site, DefaultPlugin.kt:938, reached only through the createSandboxedContext lambda wired at DefaultPlugin.kt:351, whose result is wrapped immediately at both call sites.
  • No host code reads pluginStorageFactory other than these three contexts, and createStorage( has no in-repo caller besides PluginStorageFactoryImpl itself - so nothing in the host was relying on the per-call id, and nothing else breaks.

So scoping at the Tracking layer does close the sanctioned route, and skipping SandboxedPluginContext is defensible on today's code.

1. The silent redirect should not be silent - and check boss_plugins for sub-namespacing

TrackingPluginContext.kt:576 drops the caller's argument with no signal at all:

override fun createStorage(pluginId: String): PluginStorageProvider = delegate.createStorage(ownPluginId)

createStorage(pluginId: String) is a public API that takes an id per call, and the only id a plugin can't fabricate is its own - so the plausible legitimate use of the parameter is sub-namespacing (createStorage("$myId.cache"), "$myId.session"), which the desktop implementation supports naturally since it maps the string straight to a directory (PluginStorageFactoryProvider.kt:43-46 → plugin-data/{id}/storage.properties). A plugin doing that now gets:

  • every namespace collapsed into one keyspace, so keys that used to live in separate files can overwrite each other;
  • data already written under plugin-data/<id>.cache/ unreachable, which presents to the user as silent data loss;
  • no log line anywhere saying a redirect happened, so this is diagnosed only by someone reading this source.

I can't settle whether any shipped plugin does this: plugins live in the separate boss_plugins repo and there are zero in-repo callers. Two asks:

  1. Grep boss_plugins for createStorage( before this merges. If any plugin passes a derived id, the change needs a migration or a prefix allowance (ownId or ownId + a separator no plugin id can contain - note AGENTS.md's own prefix trap: boss-plugin-terminal is a prefix of boss-plugin-terminal-tab, so startsWith on a bare id is not safe here).
  2. Either way, log a WARN when the requested id differs from ownPluginId, naming both. This repo's own convention is that refusals stay visible - AGENTS.md spends a paragraph on Plugin unload refused existing because a refusal used to leave the log stopping mid-sequence. A capability quietly handed back as a different capability is the same class of thing. Fix this →

2. Say in the sandbox layer why it is not scoped

SandboxedPluginContext.kt:216-217 stays a bare pass-through, and the PR reasons that's fine because the Tracking wrapper is always above it. Today that's true (verified above). But AGENTS.md records the three-wrapper rule precisely because a missed layer here has shipped before ("Missing the sandbox one returns null for every plugin, silently - that has happened before with mcpToolRegistry"), and the next person to add a context construction site has nothing in that file telling them the pass-through is load-bearing on an invariant maintained elsewhere. One comment, no behaviour change. Fix this →

3. Framing: this hardens an API surface, it is not a confinement boundary

The PR says "a real cross-plugin data exposure with zero sandbox or capability check in the way". The first half is fair; the second oversells what the fix buys, and the KDoc at TrackingPluginContext.kt:560-571 inherits that reading. In-process JVM plugins are not capability-isolated:

  • PluginClassLoader is child-first with parent fallback for every host-provided class (PluginClassLoader.kt:398-402 - "that is the normal path for every host-provided class"), so a plugin can reach PluginStorageFactoryImpl.getInstance() directly;
  • and with no SecurityManager in the picture, File(System.getProperty("user.home"), ".boss/plugin-data/<victim>/storage.properties") is just a file read.

What this PR genuinely fixes is that the sanctioned, innocent-looking API no longer offers cross-plugin access - a plugin can no longer do it by accident or without looking like it is misbehaving. That is worth doing; it should just be written down that way so nobody later treats pluginStorageFactory as an isolation boundary.

Related, worth one line in the KDoc: "a source the plugin does not control" is not quite right either - pluginId comes from manifest.pluginId, i.e. the plugin's own plugin.json. It's trustworthy here only because id uniqueness is enforced at install/load, not because the plugin can't author it. A side-loaded jar declaring another plugin's id is a different (pre-existing) problem, not one this predicate solves.

4. by lazy is the right call, and dodges the neighbouring hazard - say so

Switching from get() = to by lazy (:390-392) is a real improvement: the wrapper identity is now stable across reads instead of a fresh allocation per access. It does read delegate.pluginStorageFactory, which is exactly what the comment 60 lines above warns against for downloadCenterProvider ("reading the property is what forces it, so a presence test built one unread instance with its own collector per window"). It's harmless here - DefaultPlugin.kt:839 forces to a process-wide PluginStorageFactoryImpl.getInstance() singleton with no collectors - but given the adjacent comment explicitly flags that hazard, a reader will wonder whether this one repeats it. Worth half a sentence in the comment. Also note the lazy permanently caches null-ness; fine today, since the delegate's property is non-null in production.

5. Coverage: meaningful, with two cheap additions

The tests are real regression coverage, not shape assertions - test 1 fails on revert, since the raw delegate would record plugin.b in requestedIds. Test 2 additionally pins that A's write is visible in A's own store and absent from B's, which is the property that actually matters. Test 3 pinning the null pass-through is the kind of thing usually skipped, good.

Gaps:

  • Nothing pins the invariant the whole fix rests on - that no registration path hands a plugin an unwrapped context. It lives in a comment (:22-25 of the test, :388 of the source). That's the assumption a future refactor will break, and it's the one thing here that cannot be caught by these tests.
  • The returned provider's getPluginId() is never asserted. That's the observable signal a plugin sees after a redirect, and it's one line against the existing FakeStorageProvider.
  • Minor: test 2's assertEquals(listOf("plugin.a"), factory.requestedIds) at :166 depends on statement order - moving the pluginB request up breaks it confusingly. Asserting "plugin.b" !in requestedIds (already there at :165) carries the meaning without the ordering coupling.
  • Nits: kotlinx.coroutines.runBlocking is fully qualified inline four times rather than imported; observeString throws NotImplementedError (fine, but it means a future test touching it fails obscurely).

Verification I could not do here

This runner has no Gradle dependency cache and no network for it, so I did not compile or run anything - ktlintCheck, detekt and the two test classes are the author's claims, not confirmed by me. Everything above is from reading the tree. The file ends with a newline and the style matches the surrounding code.

On #552 and #611

The head of this PR is two files only - TrackingPluginContext.kt and the new test - so there is no native owner selection, nested-directory mode restoration or native-peer disposal code in this diff to review; those belong to #552 and I have no visibility into that branch from this run (the gh CLI is not available to me here). Same for the #611 consolidation comparison - I can't read either branch, so I have no opinion to offer on whether they should be consolidated. I have not touched any other PR, as asked. If you want #552 and #611 reviewed, @claude on those PRs directly and I'll have their diffs in context.
· branch fix/plugin-storage-cross-plugin-access

@kshivang

Copy link
Copy Markdown
Contributor

@Antriksh1984 Claude's review of d8b0c6b found the production fix correct, wrapper routing intact, and no blocking defect.

Applied follow-ups: the test now asserts returned getPluginId(); the sandbox pass-through comment states the Tracking wrapper invariant; scoped-factory KDoc clarifies that this binds the host-registered manifest identity and is API hardening, not a JVM/filesystem security boundary. Earlier agent d8b0c6b made the fake cache by ID and verified the caller still sees its own write. Your production scoping implementation is unchanged.

I searched boss_plugins createStorage callers, including flow-tab's STORAGE_NAMESPACE and plugin-manager/editor/docker/kubernetes/deepseek-harness/tool-evolver/atlas. The callers found use their own plugin ID; flow-tab's namespace equals its pluginId. No derived sub-namespace requiring migration was found in the workspace. This does not prove that every external third-party plugin follows the same convention.

I did not add the suggested per-call WARN naming both IDs: the mismatched caller value is arbitrary plugin-supplied text, and adding unbounded logging is not necessary for this correctness fix. The supported API's returned provider identity remains observable and is now tested. No unsafe bare-prefix allowance was introduced. Broader registration-wiring tests and cosmetic suggestions remain optional follow-ups.

Current-head local focused tests plus root ktlint/detekt remain waiting on the shared build lock; final hosted CI is pending. Original-head CI was all green. This is not final validation approval, and no repeat Claude request is being made.

@kshivang

Copy link
Copy Markdown
Contributor

@Antriksh1984 Your scoped storage-factory fix remains useful and correct. Claude verified the registration routing with no blocking defect. The original production implementation is unchanged; agent follow-ups strengthened cached-store and returned-identity tests and clarified the API/JVM boundary. Reviewed/pushed head is 12df017.

Final validation handoff under the user's updated merge policy: required CI on this new head is still queued (run 34736836354), and local storage/MCP tests plus root ktlintCheck/detekt have not started behind the shared build lock (roughly 50 waiting wrappers observed). These are infrastructure/validation blockers, not an author correctness defect. Original 0cf52df passed all nine hosted checks, but that cannot be substituted for current-head validation.

Next steps: obtain current-head green platform tests, compatibility and code-quality checks; if using local validation, run python3 /Users/kshivang/Development/Boss/.pr-review-sessions/with-build-lock.py ./gradlew :composeApp:desktopTest --tests '*TrackingPluginContextStorageScopeTest' --tests '*TrackingPluginContextMcpTest' ktlintCheck detekt from the isolated worktree. Then refresh dev and mergeability before marking ready. The completed Claude review and evidence-based disposition are already posted; no repeated request is needed for the small documented follow-ups.

Marking draft rather than bypassing checks or waiting through an unbounded queue. No score reduction for queue latency; no merge performed for this PR.

@kshivang
kshivang marked this pull request as draft September 13, 2026 04:06
@Antriksh1984
Antriksh1984 marked this pull request as ready for review September 13, 2026 09:10
@Antriksh1984

Copy link
Copy Markdown
Contributor Author

Confirmed current head 12df01750 has all 9 required checks green and mergeStateStatus: CLEAN against dev. Restoring ready-for-review per the stated exit condition.

Antriksh1984 and others added 3 commits September 13, 2026 18:29
… plugin's own id

TrackingPluginContext (the only PluginContext a plugin's own code ever
holds - every registration path wraps createSandboxedContext in one
before calling register()) exposed the raw, unscoped
PluginStorageFactory straight through from its delegate. That factory
takes an arbitrary pluginId per call and the desktop implementation
persists purely off that string
(~/.boss/plugin-data/{pluginId}/storage.properties), with no check
anywhere that it names the caller. Any installed plugin could reach
another plugin's persisted data with nothing more than:

    context.pluginStorageFactory?.createStorage("victim-plugin-id")

- read, overwrite, or clear() it.

Added ScopedPluginStorageFactory, which binds createStorage to the
context's own pluginId (a construction-time value the plugin does not
control) and ignores whatever id the caller passes in - the same
pattern DownloadCenterProviderImpl.forPlugin already uses to keep one
plugin's transfer ids from colliding with or addressing another's.

SandboxedPluginContext is untouched: it already passed
downloadCenterProvider through unscoped in the same way, since
TrackingPluginContext wraps it and is the only context a plugin ever
sees - matching that existing, working precedent rather than adding a
second, redundant scoping layer.

Added tests/TrackingPluginContextStorageScopeTest.kt: the underlying
factory is only ever asked for the context's own plugin id regardless
of what a caller requests, and a write made through one plugin's
storage reference never lands in another plugin's real storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Antriksh1984
Antriksh1984 force-pushed the fix/plugin-storage-cross-plugin-access branch from 12df017 to b9a6d4c Compare September 13, 2026 13:01
@Antriksh1984

Copy link
Copy Markdown
Contributor Author

All five open PRs went CONFLICTING/DIRTY simultaneously after dev picked up #450 (the main.kt startup-phase refactor) plus several other merges - same tangled-history symptom as #484 hit earlier: git log origin/dev..HEAD on each branch showed 100+ unrelated commits, meaning a stray old merge or an out-of-date rebase point, not a real content conflict with the actual feature diff.

Fixed by rebuilding this branch: identified the commits genuinely unique to this PR, cherry-picked them in order onto a fresh origin/dev, and dropped the rest. Content is unchanged - same diff, same review history applies. Verified clean compile, detekt and the relevant test suites before pushing. mergeable is back to MERGEABLE with no conflicts.

@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review current head b9a6d4ca0 for merge into dev. Read your previous review and contributor follow-up first; focus on unresolved correctness/regressions and changed code. This is one current-head follow-up after contributor rebasing/repairs. For #548 verify storage identity routing and tests; for #552 verify callback once-only delivery, normalized filters, owner/mode/disposal and tests. Prior review triage remains in the thread; no native UI verification is claimed.

@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 3m 11s —— View job


Review: PR #548 at current head b9a6d4ca0

Verdict: no unresolved correctness defect. The rebase is clean, the routing invariant still holds against the new dev, the three follow-ups from my last review that were accepted are in, and the one that was declined was declined with a rationale I accept. Nothing blocking from my side; the remaining items are non-blocking follow-ups.


The rebase is clean and the diff is unchanged in substance

origin/dev is f91633d (#505, the kernel IPC identity fix) and git merge-base --is-ancestor origin/dev HEAD passes, so this branch is exactly three linear commits on top of current dev with no merge residue - the tangled-history symptom described in the contributor's last comment is gone. The diff is still 3 files / +217 / -2, matching what I reviewed at d8b0c6b.

One caveat on evidence: 12df01750 is not reachable in this checkout, so I compared the content at this head against my notes from the prior review rather than diffing the two heads directly. Everything I flagged as present then is present now, and nothing new appears.

Storage identity routing - re-verified against the new dev

I re-traced it rather than assuming the prior trace survived the rebase:

  • TrackingPluginContext is constructed at exactly two sites, DynamicPluginManager.kt:1012 (split-brain/OOP path) and :1093 (normal path). Both pass pluginId = manifest.pluginId and store the result in trackingContexts (:1018, :1099).
  • baseContext (the createSandboxedContext result) appears at only :1010 and :1091, and in both cases its only use is as the delegate argument. It is never passed to plugin code.
  • All five instance.register(...) sites - :1021, :1113, :1618, :1753, :2411 - take a value read out of trackingContexts. The other two map reads (:1671, :2445) are unregisterAll(). There is no register(baseContext) anywhere in the tree.
  • Three PluginContext implementations exist repo-wide (DefaultPlugin.kt:154, TrackingPluginContext.kt:305, SandboxedPluginContext.kt:66) and only the tracking one reaches plugin code.
  • createStorage has exactly two in-repo implementations (TrackingPluginContext.kt:578, PluginStorageFactoryProvider.kt:43) and zero in-repo callers, so nothing in the host depended on the per-call id and nothing else breaks.

So the sanctioned route is closed, and the SandboxedPluginContext pass-through is still safe on today's wiring.

The by lazy at :390-392 is still the right shape: it reads delegate.pluginStorageFactory, which resolves through DefaultPlugin.kt:838 → createPluginStorageFactory() → PluginStorageFactoryImpl.getInstance(), a process-wide singleton with no collectors - so it does not repeat the per-window-instance hazard the downloadCenterProvider comment 60 lines above warns about. Worth noting the underlying storageCache (PluginStorageFactoryProvider.kt:41) is also keyed by id and process-wide, so two windows' tracking contexts for the same plugin still share one provider. Behaviour unchanged there.

Follow-ups from my last review

Item Status
Sandbox pass-through comment explaining the invariant Done, SandboxedPluginContext.kt:216-217
KDoc reframe: API hardening, not a confinement boundary Done - "this scopes the supported API only: in-process plugins can still access JVM/filesystem facilities, so this wrapper is not a security sandbox"
Assert the returned provider's getPluginId() Done, test 1 line 144
WARN on requested-id mismatch Declined, with reasoning
boss_plugins audit for derived sub-namespaces Done by maintainer - all callers use their own id; flow-tab's STORAGE_NAMESPACE equals its pluginId

On the declined WARN: I'll take that. The audit answered the concrete question I raised (nothing in the workspace passes a derived id, so no migration is owed), and "arbitrary plugin-supplied text into an unbounded log line" is a fair objection to the shape I suggested. If it ever does bite an external plugin, the bounded version is one warn per (context, requestedId) pair rather than per call - recording that here so the option is on the record, not asking for it now.

Test coverage at this head

I read the test against a mental revert, which is the only question that matters:

  • Test 1 (createStorage always uses the context's own plugin id) fails on revert twice over - the raw delegate records "plugin.b" in requestedIds, and getPluginId() returns "plugin.b". The identity assertion added in b9a6d4c is the one a plugin author would actually observe.
  • Test 2 (a write ... never lands in another plugin's storage) is now a real isolation test rather than a shape assertion, and that is entirely down to 23bc872 making FakeStorageFactory cache by id the way PluginStorageFactoryImpl does. Without the shared store, A's write and B's read hit different objects and the test would have passed on a broken build. With it, a revert makes A's "stolen" visible through B's own context. Good repair.
  • Test 3 (null delegate) does not fail on revert - it can't, both shapes return null. That's fine; its job is pinning that the wrapper isn't built around a !!, and it's the kind of case usually skipped.

Still uncovered, and still the one thing these tests structurally cannot catch: nothing pins that no registration path hands a plugin an unwrapped context. That invariant lives in a comment (TrackingPluginContext.kt:388, test :22-25, SandboxedPluginContext.kt:216) and is exactly what a future refactor breaks silently. I verified it by hand above; it is not a merge blocker, but it's the natural next test.

Non-blocking observations

  1. The manifest id is now the sole path component, and it is unsanitized. PluginStorageProviderImpl resolves BossDirectories.resolve("plugin-data/$pluginId") with no validation, so a pluginId containing ../ escapes plugin-data/. This PR strictly narrows that - before it, any plugin could hand an arbitrary traversal string to createStorage; now only a side-loaded jar's own manifest can - so it is a pre-existing issue and not a reason to hold this. Worth an id-shape check at load somewhere, separately.
  2. The KDoc's "manifest identity registered by the host" is closer than the earlier wording, but the id still originates in the plugin's own plugin.json; it is trustworthy because uniqueness is enforced at install/load, not because the plugin can't author it. Cosmetic.
  3. Test nits from last time that remain: assertEquals(listOf("plugin.a"), factory.requestedIds) at :166 couples to statement order (the !in assert at :165 already carries the meaning), and kotlinx.coroutines.runBlocking is fully qualified inline four times rather than imported. observeString throwing NotImplementedError is fine but will fail obscurely if a future test touches it.

What I could not verify

This runner has no Gradle dependency cache and no network to populate one, so I did not compile or run anything - ktlintCheck, detekt and the two test classes remain the author's and CI's claims, not mine. I also can't see the boss-plugin-api jar (1.0.88), so I cannot confirm FakeStorageProvider implements every PluginStorageProvider member; a compile is the check for that. Line lengths are within the 140 limit from .editorconfig, imports are sorted, and both files end with a newline.

On #552

Out of scope of this diff. The head here is three files - TrackingPluginContext.kt, SandboxedPluginContext.kt, and the new test - with no callback delivery, filter normalization, owner selection, mode restoration or native-peer disposal code in it. I have no visibility into that branch from this run and the gh CLI is not available to me here, so I have no opinion to offer on it; @claude on #552 directly and I'll have its diff in context. I have not touched any other PR. No native UI verification is claimed here either.
· branch fix/plugin-storage-cross-plugin-access

@kshivang

Copy link
Copy Markdown
Contributor

@Antriksh1984 The current-head recheck is complete: your original storage scoping is correct, the rebase preserves the prior agent repairs byte-for-byte, and the exact-head hosted artifacts confirm 3 storage-scope plus 3 MCP tests pass. All ten CI checks pass. Latest Claude review finds no blocker; I independently checked newer dev and the workspace callers (including Flow’s own-ID namespace). Contributor credit is preserved. Proceeding with a locked squash merge into dev; native persistence/restart checks remain unperformed, and this is supported-API scoping rather than a JVM sandbox.

@kshivang
kshivang merged commit 19791b6 into risa-labs-inc:dev Sep 14, 2026
10 checks passed
kshivang added a commit that referenced this pull request Sep 14, 2026
… plugin's own id (#548)

* fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id

TrackingPluginContext (the only PluginContext a plugin's own code ever
holds - every registration path wraps createSandboxedContext in one
before calling register()) exposed the raw, unscoped
PluginStorageFactory straight through from its delegate. That factory
takes an arbitrary pluginId per call and the desktop implementation
persists purely off that string
(~/.boss/plugin-data/{pluginId}/storage.properties), with no check
anywhere that it names the caller. Any installed plugin could reach
another plugin's persisted data with nothing more than:

    context.pluginStorageFactory?.createStorage("victim-plugin-id")

- read, overwrite, or clear() it.

Added ScopedPluginStorageFactory, which binds createStorage to the
context's own pluginId (a construction-time value the plugin does not
control) and ignores whatever id the caller passes in - the same
pattern DownloadCenterProviderImpl.forPlugin already uses to keep one
plugin's transfer ids from colliding with or addressing another's.

SandboxedPluginContext is untouched: it already passed
downloadCenterProvider through unscoped in the same way, since
TrackingPluginContext wraps it and is the only context a plugin ever
sees - matching that existing, working precedent rather than adding a
second, redundant scoping layer.

Added tests/TrackingPluginContextStorageScopeTest.kt: the underlying
factory is only ever asked for the context's own plugin id regardless
of what a caller requests, and a write made through one plugin's
storage reference never lands in another plugin's real storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(plugins): model shared storage in isolation regression

* test(plugins): assert scoped provider identity and document boundary

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
kshivang added a commit that referenced this pull request Sep 14, 2026
)

* test(db): gate the two client-grant shapes that produced #547 three times (#596)

* test(db): gate the two client-grant shapes that produced #547 three times

`ALTER DEFAULT PRIVILEGES` in 20251023000014_grants.sql hands every later
table client grants, and three tables then shipped with a permissive write
policy on top of that: plugin_downloads (#488), completed_authentications
(#536) and secret_access_log / plugin_api_key_logs (#538). Each was caught by
a person reading a migration.

identity-disclosure audit. That audit asks who can READ identity. Neither it
nor the default-privileges change reaches the shape above: a default-privilege
revoke is not retroactive, CHECK 3 filters `cmd in ('SELECT','ALL')` so an
INSERT policy is invisible to it, and CHECK 3b filters `not relrowsecurity`
while both #538 tables had RLS enabled with a permissive policy.

Adds supabase/audit/client_grant_audit.sql with two exact catalog checks:

* G1 names any public table a client role can reach without RLS. No allowlist,
  because all 34 tables in the schema enable RLS today.
* G2 names any INSERT/UPDATE/DELETE/ALL policy reachable by anon,
  authenticated or PUBLIC whose predicate is the literal `true`.

G2 keys on the predicate, not on a missing `TO` clause. The missing `TO` is how
all three slipped past review, but 101 of the 128 policies in this schema omit
it, so gating on that would report most of the schema. Predicate plus write
plus client role matches two policies in the whole schema, and both are the
ones #538 removes.

G2 is asserted as a subset rather than as HEALTHY while those two are still on
dev, so the gate passes before and after #538 and still fails on anything new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(db): exclude restrictive policies from write lint

* docs(db): state audit limits and cross-suite regression coverage

* docs(db): name the exact companion policy assertion

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(browser): actually warn before downloading an executable file (#484)

* fix(browser): actually warn before downloading an executable file

warnForExecutables defaults to true and its KDoc promises a warning
before a recognized executable finishes downloading, but the branch
that was supposed to show it was an empty TODO block ("for now, just
proceed") - so the download always proceeded silently regardless of
the setting, for every user, with no way to turn the (nonexistent)
warning off since there is no Settings UI for it either.

confirmExecutableDownload mirrors pickSaveFile's own pattern exactly,
since both are called from the same synchronous StartDownloadCallback
and both need a blocking answer before it returns: a Swing confirm
dialog run via SwingUtilities.invokeAndWait, failing closed (treated
as cancel) if the dialog itself cannot be shown, matching how a null
save path from pickSaveFile already cancels the same callback.

Declining releases the claimed save path and cancels the download the
same way the existing parent-directory-failure bail does, just above
it in the same callback.

* fix(browser): close executable consent and cancellation gaps

* fix(browser): bind download consent to a usable window and explicit choice

* feat(browser): add a user-facing toggle for the executable download warning

Settings > Browser > Downloads > "Warn before downloading executable
files", on by default. BrowserSettings.warnForExecutables is persisted
through the existing BrowserSettingsManager JSON store alongside the
other browser toggles, and FluckEngine reads it live at download time
instead of the unpersisted DownloadSettings copy, so a change applies
to the next download without restarting BOSS. Disabling it skips only
the executable warning; filename checks, save-path handling and
cancellation cleanup are unchanged.

Addresses the maintainer's follow-up on #484.

* fix(settings): index the executable-download-warning toggle for search

SettingsSearchIndexDriftTest failed CI on all three platforms: the new
"Warn before downloading executable files" toggle (Settings > Browser >
Downloads) had no entry in SettingsSearchIndex, so Settings search could
not find it. Added a Downloads group under the FLUCK section, extracted
into its own downloadsEntries() helper alongside the existing
fileAndLinkEntries() to keep browserEntries() under detekt's LongMethod
threshold.

* fix(browser): pin the warning toggle's persistence and volatility

- BrowserSettingsManager: settingsFile is now an internal var with a
  reloadForTest() seam (same pattern as DefaultAppsSettingsManager), so
  the persisted-value tests exercise the real saveSettings/load path
  instead of re-stating the assignment; covers save+reload round-trips,
  a file predating the key, and a corrupt file
- ExecutableDownloadConsentTest: the live-read test re-implemented the
  call site, so it now pins the StartDownloadCallback argument textually
  (SettingsSearchIndexDriftTest idiom) - a revert to the unpersisted
  downloadSettings copy now fails the build
- BrowserSettings.warnForExecutables is @volatile: written on the Compose
  UI thread, read on the JxBrowser download callback thread
- DownloadSettings.warnForExecutables removed: no reader left it; the
  live flag is BrowserSettings.warnForExecutables

* test(browser): make the persistence tests measure the reload, not the assignment

Addressing the second Claude review of 70a4874:

- default test asserted process-global state that the test's own
  @beforeeach causes to be loaded from the developer's real settings
  file - it would pass in CI and fail on a machine with the toggle off.
  It now pins the persisted shape's default; the in-memory direction is
  carried by the predating-key reload test.
- the round-trip test moved the in-memory value away between save and
  reload, so deleting the load-apply in BrowserSettingsManager now
  fails it.
- the whole persisted shape (all eleven fields, including the four
  system-property mirrors) is snapshotted in @beforeeach - after the
  first access that may trigger the manager's real-file load - and
  restored in @AfterEach, since reloadForTest() rewrites all of them.
- reloadForTest KDoc states the deliberate no-reset divergence from
  DefaultAppsSettingsManager.resetForTest.

* fix(browser): test the download dialog's owner resolution and surface it

R1: the dialog-owner resolution (active window, else a fallback, else
refuse) was the only new decision in the executable-download warning
with no test - the branch that silently cancels a user's download had
nothing pinning it. Extracted pickDialogOwner(activeWindow, candidates),
mirroring resolveActionableWindowIdFrom's own pure-function split, and
added cases covering active-window-wins, non-displayable-active falling
back, and no-usable-owner refusing.

R2: the fallback resolved exactly one window id and refused outright if
it wasn't displayable, even when another registered window was open -
reachable in the interval where a window has been disposed but its EDT
unregister hasn't run yet. pickDialogOwner now takes an ordered list of
candidates and scans all of them; WindowFocusManager.
candidateWindowsForDialogOwner() supplies that list (the resolved
fallback window first, then every other registered window), covered by
a test where the first candidate is stale and a later one is usable.

R3: nothing called toFront()/requestFocus() on the resolved owner, so a
background BOSS could still show this modal somewhere the operator
doesn't see, with the JxBrowser callback thread waiting behind it.
Already running on the EDT inside confirmExecutableDownloadOnEdt's own
Runnable, so this calls AWT directly rather than needing invokeLater.

* fix(review): preserve current release metadata in download consent PR

* fix(review): tighten executable consent cleanup and default coverage

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* test: harden source convention scans (#508)

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>

* fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id (#548)

* fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id

TrackingPluginContext (the only PluginContext a plugin's own code ever
holds - every registration path wraps createSandboxedContext in one
before calling register()) exposed the raw, unscoped
PluginStorageFactory straight through from its delegate. That factory
takes an arbitrary pluginId per call and the desktop implementation
persists purely off that string
(~/.boss/plugin-data/{pluginId}/storage.properties), with no check
anywhere that it names the caller. Any installed plugin could reach
another plugin's persisted data with nothing more than:

    context.pluginStorageFactory?.createStorage("victim-plugin-id")

- read, overwrite, or clear() it.

Added ScopedPluginStorageFactory, which binds createStorage to the
context's own pluginId (a construction-time value the plugin does not
control) and ignores whatever id the caller passes in - the same
pattern DownloadCenterProviderImpl.forPlugin already uses to keep one
plugin's transfer ids from colliding with or addressing another's.

SandboxedPluginContext is untouched: it already passed
downloadCenterProvider through unscoped in the same way, since
TrackingPluginContext wraps it and is the only context a plugin ever
sees - matching that existing, working precedent rather than adding a
second, redundant scoping layer.

Added tests/TrackingPluginContextStorageScopeTest.kt: the underlying
factory is only ever asked for the context's own plugin id regardless
of what a caller requests, and a write made through one plugin's
storage reference never lands in another plugin's real storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(plugins): model shared storage in isolation regression

* test(plugins): assert scoped provider identity and document boundary

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(browser): validate co-browse control payloads before splicing into JS (#549)

Validate remote co-browse control objects before embedding them in page JavaScript. Reject unquoted expressions recursively and preserve valid control messages and escaped string data.

Original diagnosis, object guard and six regression tests by Antriksh. Prior review repair adds recursive token validation and four regressions; contributor replayed all three commits cleanly onto dev. All ten hosted checks and ten focused tests pass.

Co-authored-by: Antriksh <antrikshsingh850@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(kernel): require verified caller identity for the Supabase proxy and Download bridges (#550)

* fix(kernel): require verified caller identity for the Supabase proxy and Download bridges

SupabaseServiceBridge is a generic, unrestricted Postgrest proxy backed by
the signed-in user's own RLS-scoped Supabase session - its rpc() reaches
the same create_secret/update_secret RPCs SecretServiceBridge exists to
protect, and select() can read any table RLS exposes. Neither select nor
rpc checked caller identity (BossConsole#53), so any local process reaching
the kernel's IPC socket had the same reach as a legitimately installed
plugin, attributable to nobody. Both now require a verified process
identity, matching SecretServiceBridge's established pattern, with audit
logging on every call.

DownloadServiceBridge.openFile/revealInFolder shell out to the OS's own
"open with default application" command for any path that exists on disk,
with no check that the path has anything to do with a download - before
this, an unauthenticated caller on the IPC bridge could make BOSS execute
any file on disk via the OS default-handler mechanism, completely
bypassing BossConsole#484's executable-download-warning consent dialog
(that path never goes through it at all). Every RPC on the bridge now
requires a verified caller identity, and openFile/revealInFolder are
additionally confined to a path this provider's own downloads list is
currently tracking - canonical-path compared, so a symlink or a `..`
cannot walk outside it - turning "open any file that exists" into "open a
file BOSS itself downloaded".

Both bridges get real gRPC-server-and-client tests (matching
PluginUIServiceBridgeTest's pattern): an anonymous caller is refused with
PERMISSION_DENIED on every RPC, the underlying provider is asserted never
invoked on a refusal, and DownloadServiceBridge additionally covers an
authenticated caller naming a path outside the tracked set.

* fix(kernel): revalidate download streams and use checked paths

* fix(kernel): capture stream identity and reject empty download paths

* style(tests): align download regression expression wrapping

* fix(kernel): clear code-quality on the download/supabase caller-identity branch

detekt's default MaxLineLength (120) rejected a 128-char assertEquals in
DownloadServiceBridgeTest that ktlint's wider 140-char .editorconfig limit had
let through - wrap it. Also finishes the finding-6 KDoc reword the bot review
flagged as ungrammatical ("this is the path does not provide" -> "this path
provides no executable-consent step") and reflows that paragraph.

No behavior change; addresses the last remaining code-quality blocker noted
in the PR's review thread.

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): give a plugin's file panel an owner and its own directory mode (#551) (#552)

* fix(plugins): give a plugin's file panel an owner and its own directory mode (#551)

FilePickerProviderFactory created every panel as FileDialog(null as Frame?, ...)
and showed it with a bare isVisible = true. Two consequences, and they are
different failures.

No owner. The panel has no window to be modal to and none to come forward over,
so it can open behind the Compose window. onResult(null) then reaches the plugin
looking exactly like a cancel.

No directory mode. apple.awt.fileDialogForDirectories is process-wide and read
by the native peer at creation, and a modal panel runs a nested event loop on
the EDT that keeps dispatching. NativeFileDialogs already recorded this file as
one of three that create a LOAD dialog without stating the flag, and the
consequence it named: such a panel opened inside the browser's folder panel
comes up as a directory chooser. That part is a defect independent of the
reported symptom.

NativeFileDialogs had already solved both for the browser's own panels, and its
comment says the durable fix is to route every FileDialog in the tree through
one helper. This extracts that helper rather than adding a third copy of it.
NativeFileDialogs now delegates to it and loses its private duplicates, so the
two cannot drift.

The property discipline is split out as withDirectoryMode so it is testable
without a display: an absent property is cleared rather than written back as
"false", a present one is restored verbatim, nesting unwinds in reverse, and a
throwing body still restores. Seven tests cover it. The owner selection and the
panel itself are not covered and cannot be from a headless test.

The catch blocks now log at error rather than warn and say that the plugin will
see the failure as a user cancel, since onResult(null) is the only channel this
API has and the log is the only place the difference exists.

570 tests in the browser package pass, including the existing NativeFileDialogs
suite. ktlint and detekt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(plugins): preserve save filters from PR 611

Consolidate Jaspreet Singh’s save-filter addition into arjun28115’s shared owned-dialog implementation. Remove comments left behind by the helper extraction.

Co-authored-by: Jaspreet Singh <jaspreetsinghupkar@gmail.com>

* test(pickers): preserve pre-existing directory flag in teardown

* fix(pickers): deliver one result per pick, and accept a dotted extension

The two shared-provider defects handed back on #552. Neither came from the
ownership change; both live in the picker this PR touches, and both are silent.

1. `onResult` was called inside the same `try` that catches picker failures, so
   a plugin whose callback threw received its path and then a `null` immediately
   after. `null` is indistinguishable from a cancel, so one pick produced two
   answers and the second one was wrong. Delivery now goes through `deliverOnce`,
   which sets its flag before invoking, so a throw from the plugin's callback
   cannot be retried by the catch. The two throws are told apart by when they
   happen: a picker failure arrives before delivery and still reports the cancel.

2. The API takes `List<String>` and says nothing about the spelling, so plugins
   pass both `csv` and `.csv`, and every consumer appended its own dot. The macOS
   filter tested `name.endsWith(".$ext")` and Swing's FileNameExtensionFilter adds
   the dot itself, so `.csv` was matched as `..csv`. No file is named that, so the
   filter hid every valid choice instead of narrowing to them. `normalizedExtensions`
   strips a leading dot and drops blanks, and both platforms and both methods now
   route through it.

   Normalising rather than declaring a bare-extension contract, because the
   contract is not checkable at the plugin boundary and the failure it produces
   is a panel that lists nothing, which reads as "no matching files here".

Both fixes are in Open and Save. The Save path also gains a comment saying what
its filter does NOT do: a native SAVE panel narrows what is listed and does not
constrain what the user types, so the returned path may still carry any
extension or none.

The three helpers are `internal` and pure so the rules are testable without an
AWT panel on the EDT. The provider calls exactly these, so reverting a call site
is a compile error rather than a silently unexercised test. 11 tests cover one
delivery per pick, a throwing callback, a failure before delivery, csv/.csv,
mixed case, whitespace, blank-only and empty filter lists.

ktlint 1.8.0 clean; no line over 120 columns. No local test run is claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pickers): repair what CI caught on the rebased head

Two failures, both mine, neither caused by the rebase.

`compileTestKotlinDesktop` failed at FilePickerDeliveryTest:34. `assertEquals`
takes one type parameter for both arguments, and `listOf("...")` against a
`MutableList<String?>` gives it nothing to unify, so inference failed outright.
The expectation is now spelled `listOf<String?>(...)`, matching the two
assertions further down that already were. The `emptyList()` expectations are
spelled `emptyList<String>()` for the same reason, before they hit it too.

`detekt` failed on SpreadOperator at both FileNameExtensionFilter constructions.
The rule is not new and the code is not either: renaming `filters` to
`extensions` moved the element out of whatever suppressed it before. Suppressed
at the two functions with the reasoning written out, matching what
NativeFileDialogs.onOpenFiles in this same PR already does. The spread is over a
plugin's declared filter list, a handful of entries, and FileNameExtensionFilter
is Java varargs with no collection overload to prefer.

Worth saying plainly: a type error is something a compile would have caught and
I shipped it without one. There is no working Gradle path on this machine, so
ktlint is the only gate I can run locally, and it does not type-check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Jaspreet Singh <jaspreetsinghupkar@gmail.com>

* feat(mcp): trust a whole plugin's tools at once, not one at a time (#542)

* feat(mcp): trust a whole plugin's tools at once, not one at a time

Adds a "Trust This Plugin" option to the MCP tool approval dialog - a
persisted, provider-wide ALLOW covering every tool a plugin contributes,
not just the one call being approved. Answers the actual friction: a
plugin with several mutating tools currently means approving each one
individually, in the same session, one at a time.

McpToolPolicyConfig gains a second rule map, providerRules, keyed by
providerId rather than tool name. policyFor(toolName, providerId) now
resolves both, with DENY - at either level - always winning first and
unconditionally: a provider-wide DENY is not "most specific wins" and
must not be overridable by a narrower per-tool ALLOW, the same posture
DENY already has against session trust everywhere else in this engine.
Below that: session trust, then an explicit tool-specific rule (ALLOW
or ASK), then the provider's own ALLOW, then the risk-based default.
providerId is an optional parameter so an existing caller that only
ever checked a tool name keeps compiling.

setToolPolicy, the new setProviderPolicy and revokeProviderPolicy now
share one persistAndPublish helper for the persist-and-fault-report
shape, so a future change to how a write failure is logged or reported
cannot update one path and silently miss the others - the mistake a
similar policy engine made once already by not sharing this logic.
revokeProviderPolicy removes the key rather than rewriting it to some
neutral value, for the same reason.

McpApprovalDecision.Approved gains a trustProvider flag alongside the
existing trustForSession one. Persisting it is a synchronous disk write,
so it runs under withContext(Dispatchers.IO) rather than on whatever
dispatcher delivered the MCP call - and a failed persist doesn't block
the call the operator already approved past; it falls back to session
trust for that one tool instead; a new PROVIDER_TRUST_PERSIST_FAILED
ledger disposition records that the durable grant did not save.

The new button sits in its own row below the main actions, not next to
"Approve Once" - it is the broadest grant this dialog can make, so it
should take a deliberate reach rather than risk a fast click landing on
it by mistake. A caption names the scope. Revocation is a new
McpProviderTrustDialog (list + per-row revoke, mirroring the shape an
equivalent per-tool dialog would need) reachable from a "Trusted
plugins (n)" button in the bottom bar, next to the existing session-
trust revoke button - only shown once a provider is actually trusted.

Test plan: McpProviderPolicyTest pins policyFor's full precedence table
(provider ALLOW/DENY against tool rules, session trust, the
policy-unreadable fault) and persistence (round-trips through a real
file; revoke removes the key, survives reload; revoking one provider
leaves another's trust untouched). McpGovernedInvocationTest adds two
integration cases through the real registry: approving one tool with
trustProvider lets a sibling tool from the same provider through with
no second prompt and no session-trust call, and revoking provider trust
makes its tools ask again. No Compose-level test for the dialogs
themselves - no test infrastructure exists yet for this UI layer on
this branch (no ScrimmedModalSemanticsTest-style harness), noted here
rather than left unstated.

Verified: full :composeApp:allTests passes except the two pre-existing,
environment-specific Windows symlink-privilege failures already known
from earlier this week (DesktopFileScannerTest, DiscardDownloadContainmentTest).
ktlintCheck/detekt clean.

* test(mcp): pin the provider-trust persist-failure fallback

A failed provider-wide persist must still run the already-approved call,
record PROVIDER_TRUST_PERSIST_FAILED, session-trust only that one tool,
and leave the provider's other tools asking. Mirrors the blocked-write
setup the per-tool failure case in McpPersistentApprovalTest uses.

Agent-side regression coverage added during sweep
BOSS_SWEEP_20260911_mcp-policy; not part of the author's submission.

* fix(mcp): guard provider-wide trust writes against a stale approval dialog

trustProvider ("Trust This Plugin") called setProviderPolicy directly,
with none of the revocation/DENY/availability guards validateApproval
already applies before setToolPolicy's persisted write. A revoke or DENY
landing while the dialog was open (e.g. an operator resetting the tool
from "Persisted MCP policies") could be silently overwritten: the call
in hand was correctly withheld, but the provider-wide ALLOW still
persisted, leaving every other tool that provider contributes
AUTO_ALLOWED from an authorization the reset was supposed to invalidate.

setProviderPolicy now takes the same preserveDeny/expectedRevocation
guards setToolPolicy has, checked under the same lock, plus an optional
toolName since the revocation check is inherently per-tool. The
trustProvider branch in approvedAuthorization pre-checks
isAvailable/revocationVersion/policyFor before writing (mirroring
validateApproval exactly) and, on a false return, disambiguates a
guard refusal (POLICY_DENIED, call does not run) from a genuine disk
failure (PROVIDER_TRUST_PERSIST_FAILED, call still runs - the existing,
deliberate fallback for that case).

New regression test in McpGovernedInvocationTest reproduces the review's
exact scenario (a revokePersistedPolicy landing while a prompt is open)
and confirms it fails without the fix. AGENTS.md's governed-MCP section
now documents provider-wide trust as its own scope, including the
deliberate asymmetry between a stale-dialog refusal and a disk-failure
fallback.

* fix(mcp): invalidate queued grants when provider trust is reset

* fix(mcp): clear code-quality on the provider-scoped-policy branch

detekt flagged setProviderPolicy's preserveDeny guard as ComplexCondition
(4 boolean/comparison operators inline) and a 121-char assertEquals in
McpProviderRevocationTest as MaxLineLength (over detekt's default 120, though
under ktlint's wider 140-char .editorconfig limit).

Extract the DENY check into isProviderOrToolDenied() - same behavior, named
and testable on its own - and wrap the long assertEquals. No behavior change.

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(keymap): make an explicit Ctrl chord fire on the Control key off macOS (#553) (#559)

Make explicit Ctrl bindings fire on Control off macOS, with shared capture/matching rules and platform-aware chord identity for conflict detection and migration. Preserve distinct macOS modifiers and reject unsupported Super capture.

Original implementation and contributor follow-up by @arjun28115. Earlier review-agent work integrated dev, rejected unsupported Super captures, and corrected comments; those repairs are separately credited in the PR description.

Closes #553.

* fix(chrome): restore a minimized window before focusing it (#560) (#561)

Restore iconified frames before focusing through either WindowFocusManager entry point, preserving maximized state.

Original shared helper and headless mask tests by @arjun28115 (#561). Independent equivalent implementation and missing-target regression by @Sahvendra7 (#580), retained through consolidation. Earlier review-agent work integrated that regression and clarified restore ordering and documentation.

Closes #560.

Co-authored-by: Sahvendra <shyamajanidhaka@gmail.com>

* fix(cli): prevent readiness queue lost commands (#633)

* fix(cli): prevent readiness queue lost commands

* fix(cli): close initialization queue race and guard deferred logging

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(cli): queue external command approval prompts

* fix(cli): guard queued approvals against double clicks and floods

* fix(cli): log command holding only after enqueue succeeds

---------

Co-authored-by: arjun28115 <singlaarjun28@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Antriksh <antrikshsingh850@gmail.com>
Co-authored-by: Shikhar Goel <sgoel2_be24@thapar.edu>
Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Jaspreet Singh <jaspreetsinghupkar@gmail.com>
Co-authored-by: Sahvendra <shyamajanidhaka@gmail.com>
Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com>
Co-authored-by: ayush <workside@gamil.com>
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.

2 participants