Skip to content

feat(mcp): add a zero-execution action flight plan - #1380

Draft
Mihir-Rabari wants to merge 46 commits into
risa-labs-inc:devfrom
Mihir-Rabari:feat/mcp-flight-plan
Draft

Mihir-Rabari wants to merge 46 commits into
risa-labs-inc:devfrom
Mihir-Rabari:feat/mcp-flight-plan

Conversation

@Mihir-Rabari

Copy link
Copy Markdown
Contributor

MCP Flight Plan

Agents can invoke a large MCP surface, but the operator previously had to infer the current outcome from separate kill-switch, policy, and activity views. This adds a zero-execution flight plan to the clickable MCP status surface.

Open MCP Activity Log → Flight Plan, select a registered tool, and BOSS shows its current four-stage route:

  • tool switch and access availability
  • effective policy
  • human approval requirement
  • whether the handler would run, wait, or be withheld

The dialog also shows the tool's risk classification, mutating/read-only classification, provider, description, and expected input schema. It receives registry and policy snapshots but has no invocation, approval, or ledger-writing path.

Validation

  • :composeApp:desktopTest --tests "ai.rever.boss.mcp.McpFlightPlanTest"
  • :composeApp:ktlintCheck :composeApp:detekt

@kshivang

Copy link
Copy Markdown
Contributor

Thanks Mihir-Rabari — the isolated feature commit c93ffcdc3 is a legitimate, focused addition and cherry-picks cleanly onto current dev, preserving your implementation and test credit. I reviewed the full submitted diff, current MCP admission/policy code, related open/closed PRs/issues, Claude feedback availability, and CI.

I’m converting this PR to draft because the submitted head is not safe to merge as-is:

  1. The branch is stale/stacked. Against current dev, the tip-to-tip diff is 137 files / 11k additions and includes unrelated historical changes plus two Supabase migrations (20260916130000_set_definer_search_paths.sql and 20260918000000_revoke_client_ciphertext_writes.sql). Please rebase onto current dev and retain only the flight-plan change (the clean cherry-pick of c93ffcdc3 is the expected shape). No migrations were applied and main was not touched.

  2. The forecast misclassifies recoverable policy-write faults. McpFlightPlanLauncher passes policyFault != null, but McpPolicyEngine.policyFor fails closed only for PersistedPolicyUnreadable. PolicyPersistFailed and ProviderPolicyPersistFailed leave invocation policy resolution unchanged (the call may still be ALLOW/ASK), so the dialog can show WITHHELD when the live route would not be withheld. Pass only the unreadable fault (or remove the separate boolean and derive it from the already-resolved policy), and add a regression test for a write-failure fault versus an unreadable-policy fault.

  3. The new status-item gate still checks tools.isNotEmpty(), not allTools.isNotEmpty(). If every registered tool is disabled or permission-denied, the MCP status item disappears, so the operator cannot open the flight plan that is meant to explain those blocked routes. Gate the item on registered tools (while preserving the existing empty-state behavior) and add a focused test for an all-blocked registry.

Please rerun the focused McpFlightPlanTest plus the relevant Compose/static checks on the rebased exact head; the currently reported CI is green, but it validates the stale submitted head rather than the clean current-dev delta. No Claude review is attached to #1380, and the related MCP PRs I found are adjacent—not duplicates: #624/#434 history/observations, #1365 session guard, and #1462/#1463 approval/activity-log UI.

@kshivang
kshivang marked this pull request as draft September 22, 2026 04:12
@Mihir-Rabari

Copy link
Copy Markdown
Contributor Author

Addressed the review on the current dev base. The branch now contains the isolated flight-plan cherry-pick plus a focused fix commit (20a3778), with no unrelated migrations. The forecast marks only PersistedPolicyUnreadable as blocking; regression tests cover both write-failure fault types and the unreadable fault. The status item now gates on all registered tools, with a test for an all-blocked registry and the empty state. Local Gradle tests could not start because this checkout lacks cached JxBrowser, SQLite JDBC, and Compose icon artifacts; CI is running against the pushed head.

@kshivang
kshivang force-pushed the dev branch 2 times, most recently from bdcf620 to bc9aa3b Compare September 22, 2026 04:42
@Mihir-Rabari
Mihir-Rabari marked this pull request as ready for review September 22, 2026 04:43
@Mihir-Rabari

Copy link
Copy Markdown
Contributor Author

Final update: CI on the rebased head 37d3821 passed the macOS, Ubuntu, and Windows build-test jobs, code quality, compatibility, database, contracts, version, and shell checks. The branch is ready for another review.

@Mihir-Rabari

Copy link
Copy Markdown
Contributor Author

Rebuilt on current dev (bc9aa3b): the branch now carries only the isolated flight-plan series, 10 files (+672/-2) covering the McpFlightPlan model, dialog, launcher, view, the activity-log hook, and focused tests. The unrelated 139-file aggregate diff is gone. Fresh CI is starting on ff1b71f; I will post the full matrix when it completes.

@Mihir-Rabari

Copy link
Copy Markdown
Contributor Author

Fresh CI on ff1b71f: build-test (macOS/Ubuntu/Windows), code quality, compatibility, database, contracts, version, and shell checks all passed. Ready for re-review.

fluck-boss and others added 13 commits September 23, 2026 20:51
Added McpApprovalScope enum and related functions for approval flags and labels. Implemented McpApprovalDialog composable to handle user interactions for tool approval requests.
…ApprovalCountdown.kt)

Implement McpApprovalDialog for AI agent tool approval with various scopes and user interactions.
risa-labs-inc#1594)

* fix(cli): keep an & in `boss <anything>` from running a second command

boss.bat's smart detection tested the argument with `echo %arg% | findstr`.
cmd substitutes %arg% into the line before it parses the line, so an & in
the argument ended the echo and ran the rest as its own command, once per
detection line. A file named R&D.txt routed nowhere, and
`boss "x&echo side-effect>marker.txt"` wrote marker.txt. The
"could not determine type" echoes read %arg% unquoted too.

The URL and TLD checks are now quoted string tests (a prefix substring
and a case-insensitive substring replacement), with no subshell, and the
error echoes quote the argument. Routing is otherwise unchanged.

test-boss-bat-urlencode.ps1 gains a source-shape check that
:detect_and_route reads %arg% only inside quotes, which the Ubuntu leg
enforces and which fails on the old script, plus live cmd.exe probes: the
payload creates no marker, R&D.txt routes to boss://file with its full
path, URL and domain detection give the same answers, and a mutation
check shows the old findstr line does run the payload.

Follows up risa-labs-inc#1347, whose empty-path half risa-labs-inc#1059 already fixed.

* boss.bat: drop literal ampersands from REM comments

Reword the two REM lines so they don't contain a bare & (flagged in review as a possible command split). Comment-only change.

---------

Co-authored-by: fluck-boss <62e43a68001@mail.instinct.com>
…lways Allow (risa-labs-inc#1613)

* fix(mcp): ask before a destructive shell command even under a saved Always Allow

A saved ALLOW on run_command (or any shell tool) went straight to AUTO_ALLOWED, and the
risk evaluator only ran on the ASK path to label the dialog. So one "Always Allow" for a
harmless command auto-ran every later call, including rm -rf, git push --force and mkfs,
which the evaluator itself rates CRITICAL.

- McpToolRegistryCore.invoke now passes the saved policy through
  askBeforeDestructiveShell: an ALLOW on a shell tool drops to ASK when the call's own
  args rate CRITICAL. Every shell call already rates HIGH, so CRITICAL is the line that
  keeps "Always Allow" meaning "don't ask for routine calls". The args evaluated are the
  ones executed, and shell tools are identified by the evaluator's own normalization
  (the new DefaultMcpRiskEvaluator.isShellTool). A provider-wide "Trust This Plugin"
  ALLOW is covered too. Non-shell tools, DENY and ASK are unchanged.
- The destructive-command matcher decides this now, so it reads each command of a chain
  for the flag shapes real calls take: rm with any recursive flag in any order or path
  (rm -fr, rm -r -f, /bin/rm -R, sudo rm --recursive), del/erase/rd/rmdir /s,
  Remove-Item -Recurse, and git push with a force flag anywhere after push. Runs of
  whitespace are collapsed. Every existing pattern still matches.
- WorkspaceMcpToolProviderTest's newline check now uses a benign second line: its old
  `rm -rf /` payload is stopped earlier, at the approval gate, under that test's
  provider-wide ALLOW.

Fixes risa-labs-inc#1577

* test(mcp): pin newline-chained and operand-first recursive rm as CRITICAL

Add additional cleanup commands to the risk evaluation test.

* fix(mcp): split shell chains before collapsing whitespace, read rm flags after operands

* fix(mcp): escalate on >= CRITICAL so a higher tier stays fail-closed

---------

Co-authored-by: fluck-boss <62e43a68001@mail.instinct.com>
…ot win (risa-labs-inc#1027) (risa-labs-inc#1622)

* fix(browser): take ResolvedHostsStore save snapshot under saveLock

`recordLoaded` captured `hosts.toList().sorted()` on the calling thread
and only acquired `saveLock` inside the launched save. Two recordLoaded
calls landing close together both captured the same snapshot, raced for
the lock, and whichever won LAST overwrote the newer in-memory state
with the older snapshot - silently dropping the host added by the first
save.

Moving the snapshot inside `saveLock.withLock` keeps the in-memory set
(already a ConcurrentHashMap.newKeySet) as the source of truth while
making the on-disk write reflect everything the caller could possibly
have added by the time the lock was taken.

Fixes risa-labs-inc#1307

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ci): ktlintFormat ResolvedHostsStore and its test

* test(browser): make ResolvedHostsStore tests fail on the pre-fix race; correct comments

The regression tests each ended with a fresh full-snapshot saveNowBlocking(), which
masked the out-of-order write they were meant to catch, so all passed on the old
call-site-snapshot code. Rework them to observe what the launched production saves
actually leave on disk via a new test-only awaitPendingSaves() seam (no forced full
write): a two-writer 'older never wins' test and a concurrent burst, both of which
fail on the pre-fix snapshot and pass with the snapshot taken under saveLock.

Also correct three production comments that overclaimed: the ConcurrentHashMap
toList() is a weakly-consistent iteration, not a stable snapshot; there is no
debounce; and saveNowBlocking is not the production write path (it runs inline).

---------

Co-authored-by: choksi2212 <manaschoksiwork@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
risa-labs-inc#1527)

* fix(terminal): queue concurrent send writers instead of fail-fast spin

TerminalSession.send's tryLock-fail path now queues writers on a
kotlinx.coroutines Mutex for a bounded 5s wait (injectable via
inputQueueTimeoutMillis); callers that outwait the bound are shed with
RESOURCE_EXHAUSTED carrying a google.rpc RetryInfo retry-delay via
StatusProto instead of an instant spin-inducing failure.

The queue wait suspends rather than parking a Dispatchers.IO thread, so a
stalled pipe cannot pin the pool under load, and coroutine cancellation
abandons a queued write instead of letting it through after the caller
gave up; cancellation surfaces as CANCELLED.

* fix(terminal): keep the NUL check on environment variable names

Added additional null character check for environment variable values.

---------

Co-authored-by: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com>
Co-authored-by: fluck-boss <62e43a68001@mail.instinct.com>
Co-authored-by: kshivang <kshivang@users.noreply.github.com>
…abs-inc#563)

* fix(wizard): skip a plugin only when it is actually installed, not merely recorded

The first-run wizard's skip guard called DynamicPluginManager.isInstalled, which
is pluginStates.containsKey. An entry is not a usable plugin. installPlugin
registers a DISABLED entry for a jar it rejected as binary-incompatible and then
deletes that jar, so the wizard announced "already installed, skipping" for a
plugin that was not there, added its id to installedIds, and finished reporting
success for something it never installed. The user picked it during setup and
did not get it.

This is the trap AGENTS.md already records for the dependency prompt, at a call
site that was not converted with it. The same file was already using the correct
predicate after the loop, for the dependency report.

It now uses PluginDependencyResolution.installedAndOnDisk, the codebase's single
definition: LOADED, or the jar exists and the plugin is not recorded as binary
incompatible. Both clauses carry their weight here. Requiring only an entry is
the bug above. Requiring only the jar would refuse to skip a running plugin whose
file has moved, because neither PluginJarReconciler nor the updater repoints the
manager's jarPath, and reinstalling it fails with "already loaded".

Recomputed per call rather than hoisted, since the loop installs plugins and a
snapshot taken before it would go stale exactly when a later entry depends on an
earlier one.

Six tests, written around the cases where the two predicates disagree, since the
cases where they agree would pass either way. The last one pins the disagreement
as a set difference, so a future change to either definition shows up as a
changed disagreement rather than silently converging.

4405 composeApp tests pass. ktlint and detekt clean.

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

* fix(wizard): reject unusable install results before reporting success

* fix(wizard): refuse to overwrite an installed plugin, rather than roll back

Takes the bounded alternative offered in review, and collapses four commits of my
own CI repairs into the change they were repairing.

**Refuse an occupied destination.** Nothing already at the installed path is
replaced. The previous attempt moved over it and rolled back on failure, and that
grew a tail of its own failure paths: a backup that will not rename, a promotion
that throws between backup and install, a restore that fails silently, a
fixed-name backup that can destroy the only original left by an earlier failure.
Each could lose the artifact the rollback existed to protect, and each needed an
injected file-operation seam to test. Refusing has no tail: nothing is
overwritten, so nothing needs undoing. It also makes cleanup unambiguous, because
everything at the destination afterwards belongs to this call.

**The signature travels with the jar.** `RemotePluginRepository` writes the
sidecar beside the DOWNLOAD path, so promotion is when it has to move. Moving
only the jar left every signed store install loading unsigned while its signature
sat at the staging name. `SignedArtifact` moves and deletes the jar with
whichever sidecar exists, carrying both suffixes the loader knows.

**Both downloads stage.** The Store path used a fixed `-downloading.jar`, which a
directory scan reads as a plugin and which two installs of one id collide on.
Both paths now use a unique name whose marker breaks the `.jar` suffix, with one
constant defining it so writer and reader cannot drift.

`isPluginResident` answers what `isInstalled` cannot: that reports whether an
entry exists, while `disablePlugin` leaves a DISABLED plugin resident in the
loader, which is the case that made this reachable at all.

11 tests over real files and real moves: an occupied destination refused with its
bytes intact and the loader never reached, a resident plugin refused before
anything moves, the signature arriving with its jar, a failure removing both and
leaving no orphan sidecar, a throwing loader not escaping the cleanup, and the
staging name being unique, unscannable and reversible.

Rebased onto current dev, which has now been rewritten four times today.

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

* fix(wizard): make promotion all-or-nothing, and refuse the whole footprint

Review found that refusing an occupied JAR does not make promotion
indivisible, and it was right. `SignedArtifact.moveTo` moved the jar and
then its sidecar, and `stageAndInstall` called it BEFORE the try/cleanup
block. If the second move failed - the final `.sig` path being a nonempty
directory is enough, and a permission change between the check and the move
will do it too - the jar was left at its scannable installed name with its
signature stranded at the staging name, nothing cleaned it up, and the
occupied-destination refusal then blocked every retry.

Three changes:

**Promotion is inside the failure boundary.** `moveTo` records each path as
it lands and deletes exactly those on failure, so a partial promotion leaves
the destination as it was found. Deleting rather than moving back is
deliberate: every path was established free beforehand, so deleting cannot
destroy an artifact this call did not create, whereas a move back has a
failure of its own that would leave the same mess. What is lost is a
download, which the retry re-fetches. The loader is never reached.

**The refusal covers the whole footprint**, not just the jar. A stale `.sig`
beside a jar removed by hand is still somebody else's file, and promoting an
unsigned download next to it would leave an old signature asserting bytes it
never vetted - which PluginSignatureSidecar's own contract calls out as the
thing never to do, and which is worse than no signature because the loader
reads it as present-and-invalid. Refusing also means every path this call
writes was established free, which is what makes the cleanup precise rather
than indiscriminate. The message names the obstructing file so the user
knows what to remove.

**The destination-ownership claim is corrected, not restated.** The check and
the move are two operations, so it holds against what is already on disk, not
against a second installer racing this one. `ATOMIC_MOVE` cannot close that
gap: on POSIX it is rename(2), which replaces the target silently - verified
with a probe on this platform rather than assumed. Promotion therefore uses a
plain non-replacing move, so a racing installer collides with
FileAlreadyExistsException and this call reverts, instead of the two
overwriting each other silently. Serialising installs is the only thing that
would make the race impossible, and that does not belong in this function;
the KDoc says so rather than implying a guarantee that is not there.

Three regression tests over real files and real moves: a blocked destination
sidecar (loader not called, no jar at the installed name, the obstruction and
its contents untouched, and the retry succeeds once it is removed); an
unsigned download meeting an old sidecar; and a promotion onto an occupied
path failing rather than replacing it.

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

* fix(test): call the two-argument assertContains, not the Iterable overload

`assertContains(charSequence, other, message)` binds the message to
`ignoreCase: Boolean`, so overload resolution falls through to the Iterable
and array candidates and the call does not compile. The message moves to a
comment; the assertion is unchanged.

This was already fixed in my working tree when 3d7f888 was pushed - the
amend that should have carried it was part of a compound command that was
denied, and I pushed the pre-fix commit without re-checking. The commit, not
the working tree, is what CI builds.

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>
…lag-shaped branch cannot reach git checkout (risa-labs-inc#950)

* fix(git): re-validate the remote-prefix-stripped checkout name so a flag-shaped remote branch cannot force-discard uncommitted work

Fixes risa-labs-inc#919. checkout() ran isSafeRefName on the name as it arrives, but
for a non-local name containing '/' it derives localName =
substringAfter('/') and passes THAT to 'git checkout <localName> --'
without re-validating. A remote-tracking branch literally named '-f'
(origin/-f) passes the guard, strips to '-f', and the argv becomes
[checkout, -f, --]: git parses -f as --force with an empty pathspec set
and force-checks-out HEAD over the worktree, silently discarding every
uncommitted modification. The post-strip name now passes the same
isSafeRefName gate before it can reach the command line. 2 regression
tests with a real lab repo: a planted origin/-f ref is refused with the
PRECIOUS uncommitted content intact (and a safe slashed name still
checks out carrying the change over), and a local branch whose whole
name is a flag is refused on arrival.

* fix(git): pass the window's project path on the top-bar checkout and pin the empty-strip and HEAD-stability contracts

Re-audit follow-up on risa-labs-inc#919: the top-bar onCheckout was the one write
verb of its cluster that omitted projectPathOverride, so with two
windows on different repos the branch picker's checkout ran against
whichever window last aligned the global current-project path - the
wrong repository. It now passes windowProjectPath exactly like its
neighboring pull/push/merge/rebase call sites. Two regression tests
join the suite: 'origin/' strips to empty and is refused with the
worktree intact, and a refusal leaves HEAD and the uncommitted content
untouched. 4/4 tests, detekt and ktlint green.
@fluck-boss

Copy link
Copy Markdown
Collaborator

@claude please review this exact head (ff1b71f) for merge into dev. Check that the change is correct and complete for its stated goal, that tests cover the risk being fixed (a test that passes without the fix is a finding), and flag any security issues (secrets, injection, auth or approval bypass, path handling). Keep the review focused on this diff.

@claude

claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Claude finished @fluck-boss's task in 6m 5s —— View job


Review — PR #1380 @ ff1b71f2

  • Gather the diff against the stated dev base (bc9aa3b) — 10 files, +672/-2, matches the claimed shape
  • Verify the policy-fault classification fix
  • Verify the status-item gate now uses registered tools
  • Check tests actually fail without the fixes
  • Security pass (approval bypass, injection, secrets, path handling, RBAC disclosure)

The branch is clean — git diff --stat bc9aa3b HEAD is exactly the 10 flight-plan files, no migrations, no stacked history. Both blocking items from the earlier review are genuinely fixed in the product code. My findings are about the tests not reaching the sites that were wrong, plus one correctness issue nobody has flagged yet.


✅ Both prior blockers are fixed in the code

Policy fault. mcpPolicyFaultBlocksInvocation (McpFlightPlan.kt:25) matches McpPolicyEngine.policyFor exactly — that resolver fails closed on one fault only:

// McpPolicyEngine.kt:210
if (_fault.value is McpPolicyFault.PersistedPolicyUnreadable) return McpPolicyAction.DENY

Status item. BossBottomBar.kt:469-472 now reads allTools, so an all-blocked registry keeps the entry reachable.

Delegation is right. The launcher calls policyEngine.policyFor(name, providerId, readOnly) — byte-identical to the live invoke path at McpToolRegistryImpl.kt:756, so session trust, provider DENY precedence and the #804 readOnly rule are inherited rather than re-derived. declaredReadOnly goes through the same single McpMutatingToolCatalog.isMutating call the gate uses. That is the correct shape for this feature and the main thing I'd have rejected it for.


🔴 F1 — McpActivityStatusItemTest passes without the fix (blocking, per your criterion)

McpActivityStatusVisibility.kt is a || b || c || d, and the test feeds it literals:

assertTrue(mcpActivityStatusShouldRender(false, true, false, false))

Revert BossBottomBar.kt:469 to McpToolRegistryImpl.tools and this test stays green. The bug was never in the boolean — it was in which flow is bound to the hasRegisteredTools parameter, and the test cannot observe that binding. As written it's a tautology test; the parameter name is the only thing carrying the intent.

The repo has the machinery for a real one — McpActivityLogLayoutTest, SpaceSaveAffordanceLayoutTest, BossTopRightBarTest all mount composables. Make McpActivityStatusItem internal (or have it take the two flows), register a tool into an McpToolRegistryCore, disable it so allTools is non-empty while tools is empty, and assert the item renders.

Fix this →

🟠 F2 — the policy-fault test also does not reach the site that was wrong

val blocks = mcpPolicyFaultBlocksInvocation(fault)   // test calls the predicate itself
val plan = mcpFlightPlan(input(policy = ALLOW, policyFaulted = blocks))

Revert McpFlightPlanLauncher.kt:43 to policyFault != null and this stays green too. This one is weaker than F1 — it does pin the predicate, so mutating McpFlightPlan.kt:25 to fault != null fails it, and that's the substance of the rule. But the launcher wiring is where the defect lived and it is still untested. Extracting a tiny non-composable flightPlansFor(allTools, tools, disabled, fault, policyOf) from the launcher would make both this and the schema/permission wiring testable in one move.

🟠 F3 — isPermitted is derived from the exposed set, so the tested input combination is unreachable in production

McpFlightPlanLauncher.kt:18,36:

val availableIds = availableTools.map { "${it.providerId}/${it.definition.name}" }.toSet()  // = tools
…
isPermitted = id in availableIds

tools is already allTools − disabled − permission-denied (McpToolRegistryImpl.kt:725). So a disabled but permitted tool arrives as (isEnabled = false, isPermitted = false). The test fixture's (enabled = false, isPermitted = true) is a state the launcher can never emit — so the production path for a disabled tool is the one combination with no coverage.

It produces the right label today only because availabilityCheckpoint tests !isEnabled first (McpFlightPlan.kt:85). Swap those two branches and every switched-off tool starts telling the operator "Current permissions do not expose this tool" — a governance UI confidently naming the wrong gate. That is an order-load-bearing check with no mutation test, which is precisely the trap AGENTS.md documents for pluginSectionAbsence ("All three orderings are mutation-verified: reversing each one fails a named test").

McpToolRegistryImpl.permittedTools() (:148) exists for exactly this reading — its KDoc: "it keeps the ones a user has switched off … but drops the ones they have no permission for." Using it makes the two flags orthogonal and the ordering no longer load-bearing.

Fix this →

🟡 F4 — an open Flight Plan goes stale after a policy change

The launcher collects allTools, tools, disabledToolNames and policyFault as Compose state, but resolves policy with a plain call:

policy = McpToolRegistryImpl.policyEngine.policyFor(...)   // reads _config.value, no subscription

McpPolicyEngine.config and .sessionTrustedTools are both public StateFlows (:124, :127) and neither is collected. So an "Always Allow" from an approval dialog, a section-policy write, or a session-trust revoke leaves the dialog showing the pre-change route until some other collected flow happens to emit. For a surface whose stated job is "its current four-stage route", that's worth closing — collecting the two flows (even just as recomposition keys) is a two-line change.

🟡 F5 — the fail-closed rule is now duplicated over a sealed interface

mcpPolicyFaultBlocksInvocation is a second copy of policyFor:210. McpPolicyFault is a sealed interface, so an exhaustive when would make a fourth fault type a compile error here instead of silently defaulting to "does not block" — the convention this repo already states for McpApprovalDisposition ("an exhaustive when … so a disposition the enum grows later is a compile error here rather than silently counted as a tool fault").

Worth saying in the KDoc that the drift is currently benign: because policyFor returns DENY for any fail-closed fault, a missed fault type degrades the checkpoint wording ("Current policy is DENY" instead of "Policy storage is unhealthy") and never the outcome. That's a good property of the design and it should be written down rather than rediscovered.

🟡 F6 — the KDoc that likely caused the original bug is still wrong

// McpToolRegistryImpl.kt:144
/** Non-null when the policy engine degraded to fail-closed state. */
val policyFault: StateFlow<McpPolicyFault?> get() = core.policyEngine.fault

That is false for PolicyPersistFailed and ProviderPolicyPersistFailed, and reads as a direct invitation to write policyFault != null — which is what the first revision did. One sentence here prevents the next occurrence.


Security pass

No approval-bypass, injection, secret or path-handling issues in this diff.

  • No execution path. McpFlightPlanDialog takes plans and onDismiss and nothing else — no handler, approval-bus, trust-grant or ledger callback exists to be reached. The "zero-execution" claim holds structurally, not just by convention.
  • No second authorization path. Policy is delegated to policyEngine.policyFor, not reimplemented. The one duplicated rule (F5) is display-only and cannot widen access.
  • Nothing rendered is interpreted. description and inputSchema go to plain Text; no shell, SQL or path is constructed.
  • onOpenFlightPlan: (() -> Unit)? = null is inserted before onDismiss, so the existing trailing-lambda call sites in McpActivityLogLayoutTest still bind correctly — consistent with green CI.

🔵 One posture note. Gating on allTools means an operator with zero exposed tools now gets the MCP status item and can enumerate every registered tool's name, description and input schema. This is within the documented posture — McpToolRegistryImpl.kt:55-62 sanctions unfiltered allTools in management UI as "metadata disclosure only, local to the signed-in user's own desktop app — it grants no invocation" — and it's the behaviour you explicitly asked for. But two things shift: the precedent surface (the Toolbox MCP tab) lives in the plugin-manager plugin, not this repo (setToolEnabled has no in-host caller), so it is installable and RBAC-hideable, whereas the bottom bar is always-present host UI; and inputSchema goes beyond the "name/description" the posture paragraph enumerates. Not a blocker — worth one line added to that KDoc so the posture doc stays accurate.

Style / conventions

Clean: no em-dashes in the diff, all new files newline-terminated, BossLogger not bypassed (nothing logs), the non-lazy verticalScroll + Close-outside-the-scroll-body layout matches its sibling McpActivityLogDialog exactly.


Verdict

The feature is well-shaped and the two blockers are really fixed. F1 is the one I'd hold on, since it is the exact failure mode you asked me to flag — the test for the all-blocked registry cannot fail for the bug it was added to catch. F3 is a correctness issue that hasn't been raised before and is cheap to close. F2/F4/F5/F6 are follow-ups I'd accept in this PR or the next.

I did not run Gradle — this checkout has no cached JxBrowser/Compose artifacts, matching what the author reported. The verification above is by reading the code against the live admission path; CI on this head is reported green.
· branch feat/mcp-flight-plan

choksi2212 and others added 2 commits September 24, 2026 02:45
…risa-labs-inc#987)

* perf(db): evaluate statement-constant policy calls once per statement

Supabase's advisor reports 68 policies that call auth.uid() or auth.jwt()
per row. Writing the call as a scalar subquery lets PostgreSQL hoist it
into an InitPlan, which is the documented fix and cannot change which rows
a policy admits, because both calls are STABLE.

Rewrites 80 policies across 29 tables, hoisting 86 call sites. Twelve of
them are authorize('literal') and twelve are is_user_admin(auth.uid()),
neither of which the advisor flags: it reads the policy expression but not
the body of a function the policy calls. For is_user_admin the advisor's
own remediation text is actively weaker, since rewriting only the inner
auth.uid() leaves a plpgsql SECURITY DEFINER call running once per row.

Thirty-three call sites are left alone because they take a column, so
their answer differs per row and hoisting them would be a security bug.

* test(db): pin cmd/permissive and add real row admission checks

arjun28115's review on risa-labs-inc#987 flagged two gaps. The roles-pinned assertion
never checked polcmd/polpermissive, so a future DROP/CREATE that flips
a policy to RESTRICTIVE would pass silently - added both to the same
80-row pinned list. Assertions 7-9 also never selected a row under a
claim, so they couldn't tell if the rewrite changed admission - replaced
with two real SELECTs under authenticated (plain user sees 1 of 2
fixture rows, admin claim sees both). plan(9) -> plan(8).

* fix(db): hoist the four terminal_sessions policies added after this PR

dev gained public.terminal_sessions (20260921120000) with four owner policies
that call auth.uid() once per row. Rebuilt on that dev, assertion 1 (every
statement-constant call in every public policy is hoisted) fails on exactly
those four, and the policy count moves from 110 to 114.

20260922130000 applies the same ALTER POLICY rewrite to the four. The pinned
lists go from 80 to 84 policies, the count to 114, and two assertions are
added: the four hoisted functions are STABLE or IMMUTABLE (9), and a user sees
only their own terminal session through the rewritten policy (10).

The header rule now says the function must be STABLE or IMMUTABLE, and why
is_user_admin(auth.uid()) fits, as said in review.

* fix(db): date the InitPlan hoist migrations after dev and the open PRs

20260918140000 and 20260922130000 sort before 20260922160000, the newest
on dev, so `supabase db push` against an environment that has applied
it would refuse them. Renamed to 20260923150000 and 20260923151000,
after dev and after every 20260923 migration in the open PRs (the
latest is risa-labs-inc#993's 20260923140000). The SQL is unchanged.

The terminal_sessions migration explained its separate file by the
first one running before that table existed, which is no longer true
once both run after 20260921120000. It now says why they stay apart.
The comments and the test header name the new timestamps.

* fix(db): move the InitPlan hoist migrations off risa-labs-inc#1355's timestamp

20260923150000 is also the version of risa-labs-inc#1355's
20260923150000_plugin_download_serve_rpc.sql, the stamp the maintainer
gave that PR before I picked the same one here. The two files are now
20260923160000 and 20260923161000, after dev's newest (20260923140000)
and after every migration in the open PRs. The SQL is unchanged; the
comments and the test header name the new stamps.
Vidhaankhare16 and others added 6 commits September 24, 2026 06:10
…, not URI.path (risa-labs-inc#1340)

* fix(windows): resolve the code source through the filesystem provider, not URI.path

`codeSource.location.toURI().path` returns only the URI's path component, and a
file on a Windows network share does not keep its server there - the server is
the URI's authority. `File` then resolved what was left against the current
drive, so a share install computed `C:\software\BOSS\app\BOSS.jar` for
`file://nas01/software/BOSS/app/BOSS.jar`. Nothing threw and nothing logged.

Four callers acted on that path: the `boss://` protocol command and
`shell\open\command` for every registered extension (both written straight into
the registry), the StartMenuInternet browser candidate, and the JAR updater,
whose `exists()` check failed so the update silently never ran. Three more used
`File(uri)`, which rejects an authority outright and failed safe to null,
costing the restart and the launcher their code-source fallback.

One helper, `CodeSourceLocation`, replaces all seven. `Paths.get(uri)` hands the
URI to the platform's filesystem provider, which restores the authority as a UNC
root, and returns exactly the previous result for a drive-letter install - so a
local install sees no behaviour change.

Routing through it also lets WindowsDefaultBrowserHandler reach its
development-executable lookup when the code source cannot be resolved, instead
of giving up on the one case that shape describes.

* fix(windows): answer the risa-labs-inc#1340 review - localhost, fail closed, log, hidden shares

- CodeSourceLocation drops a `localhost` authority before Paths.get, as
  OsOpenArguments.pathFromFileUrl does: the Unix provider rejects every
  authority, so file://localhost/... resolved to null where URI.path
  answered it. A real server authority is untouched.
- Each null from fileOf is logged at debug with its reason (no URL, the
  scheme, or the provider's exception class), never the path.
- WindowsDefaultBrowserHandler fails closed on an unresolvable code
  source, as WindowsProtocolHandler does, instead of registering a
  BOSS.exe found under user.dir. The dev lookup stays for a code source
  that resolved to a classes directory. The logged path is masked.
- UpdateInstaller: drop the dead try/catch around fileFor and its stale
  KDoc, reword getCurrentJarPath's message, and document that a hidden
  share (`apps$`) installs without a relaunch. The validator refuses `$`
  anywhere, which jpackage.app-path already hit before this change.
- Tests: a localhost URL, a network-share install resolved end to end
  from its code-source URL through windowsLauncherPathFor, and the
  hidden-share outcome pinned.
…-labs-inc#1638)

It is SECURITY DEFINER, takes a caller-supplied user id and checks nothing, so any
signed-in user could ask how many active API keys any other user holds. The table
already refuses that question: plugin_api_keys has RLS and its SELECT policy gives a
reader their own rows only. The function answered it anyway. Tracked in risa-labs-inc#1590.

It was the last of the five plugin-store SECURITY DEFINER RPCs a client could still
execute; the other four were closed by 20260909130000, 20260910000000 and
20260911010000. Its only caller is the plugin-store Edge Function's key-limit check,
which runs on the service-role client, so revoking client access loses nothing.

A revoke rather than an own-keys check, because a check keeps an RPC reachable by
every client for a question no client asks. Same shape as 20260923122000 (risa-labs-inc#994), and
the same durability note: CREATE OR REPLACE keeps the ACL, DROP and CREATE would hand
it back to authenticated through 20251023000014's default privileges.

Tests: nine assertions, including the call itself made as authenticated for another
user's id and refused with 42501 rather than answered with a 0 that would read as
"no keys". Not run locally for want of a Postgres here; Database (pgTAP) is the check.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
… store record (risa-labs-inc#1643)

importWorkspace (Open from File) saved a Space under whatever id its JSON carried, with
neither of the guards the MCP create path has (risa-labs-inc#926). An id of `Space_Themes` overwrote
the theme store and wiped every Space's theme; `Last_Session_Set` broke session restore;
`last-session` replaced the crash-recovery record - and that is exactly the id an
exported Last Session carries, so opening one on another machine replaced THAT
machine's record.

- withImportableId(), beside withStableId(), re-mints an id that is a slot or would save
  over a reserved record; importWorkspace uses it. Re-minted rather than refused: the
  person asked for a layout and never chose the id. Not folded into withStableId, which
  the load scan uses and where the real record carries `last-session` legitimately.
- isSpaceSlot folds case (risa-labs-inc#1590). Ids become file names, and APFS and NTFS fold case.
- reservedWorkspaceStoreFileName moves from the MCP provider to WorkspaceIdentity.kt,
  unchanged but for its KDoc: it must move with the load scan's skips, and it is no
  longer the MCP path's alone.

risa-labs-inc#1590's own example is already refused on the MCP path, by risa-labs-inc#926's case-insensitive
file-name check; the unguarded path was the import.

Tests drive the rule either side of a real WorkspaceFileManager, as
IdlessWorkspaceImportTest does, plus a source check that importWorkspace applies it.
Mutation-checked: each of the three changes, reverted, fails a named test.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
…ce boundary (risa-labs-inc#1619)

notification_post stored the agent-supplied `source` argument verbatim, so an
agent posting with source: "System" produced an inbox entry indistinguishable
from a genuine host notice; the string was the only origin signal and it came
from the payload.

Provenance is now stamped by the kernel-side boundary instead of trusted from
the argument:

- BossNotification carries an authoritative `origin` field
  (NotificationOrigin.HOST / AGENT). The serialized default is AGENT, so
  entries that predate the field or load with it missing fail closed: they
  cannot present as host origin.
- NotificationCenter.post takes `origin` as a required parameter with no
  default, so every call site must answer who is posting at this boundary; the
  answer is never derived from caller-supplied text.
- For AGENT posts the boundary demotes the caller-supplied label to a display
  string - "agent" bare, "agent: <label>" when one was offered, bounded to 80
  characters - so agent text cannot masquerade as system origin regardless of
  the string sent. HOST posts keep the host's own label.
- NotificationMcpToolProvider classifies every post that crosses the MCP
  boundary as AGENT, and both the post result and notifications_list expose
  `origin`, so a host notice and an agent notice stay distinguishable.

The demotion lives in NotificationCenter rather than in the MCP provider so
the contract cannot be bypassed by a caller that reaches post() without going
through the provider: the only route to host presentation is host code
explicitly claiming NotificationOrigin.HOST, which is a review-visible act.

Fixes risa-labs-inc#1587
…talled predicate (risa-labs-inc#1640)

- writeStagedDownload is tested on both halves of its contract: the whole stream lands,
  and a stream that dies mid-download removes its partial file and rethrows. The dying
  stream serves 16 KiB first, so bytes are on disk when it fails; a stream that failed
  on its first read would pass against a version that never deleted anything.
- Both install paths are pinned to stageAndInstall by a source test (comments stripped,
  since the KDoc names atomicMoveFrom on purpose). Reverting a call site to an inline
  atomicMoveFrom kept every behavioural test green; now it fails one.
- The resident refusal no longer deletes the download when the download IS the
  destination. stageAndInstall models that case explicitly, and in it the unconditional
  delete removed the installed jar - in the one refusal that exists because something
  is still running from that file.
- DynamicPluginManager.isInstalled is renamed hasEntry rather than deleted. It still
  had five production callers (PluginRemoval x3, DependentRestartPrompt, and the
  manager's own uninstall-cancelled log), so deleting it would have broken plugin
  removal. The name was the hazard: an entry survives a rejected, deleted jar, which is
  the reading risa-labs-inc#563 proved wrong. Its KDoc now says so and points at installedAndOnDisk.
- The KDoc displacement was already in order on the merged code; nothing to change.

Mutation-checked: each guard's removal fails exactly one named test.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
… and buffer sizes (risa-labs-inc#952)

* fix(plugin-store): cap the remote-JAR central-directory range fetches and buffer sizes

Fixes risa-labs-inc#914. extractManifestFromRemoteJar trusted the remote JAR's
central-directory fields as fetch bounds and allocation sizes with no
cap: the CD fetch spanned cdOffset..cdOffset+cdSize-1 straight from the
CD header, and each entry fetch sized 30 + fileNameLength + 256 +
compressedSize from the attacker-controlled compressedSize - each ending
in an unbounded response.arrayBuffer(). A crafted JAR declaring
compressedSize ~= 2GB for META-INF/boss-plugin/plugin.json made the
isolate buffer a ~2GB range response in ~256MB of edge RAM: a per-request
OOM/cost-abuse DoS remotely triggerable by anyone who can get a malicious
JAR served from a URL the function inspects. Every range fetch now takes
an explicit max-bytes cap (4MB for the CD/EOCD probes, 512KB for the
manifest entry - the same bound the host applies to manifest reads), the
declared entry size is refused outright above the manifest bound before
any fetch, and the response itself is re-checked against the cap so a
server that ignores or lies about the Range header cannot bypass it.
2 regression tests with crafted CD bytes: a 2GB CD size is refused
without any large fetch, and a 2GB plugin.json entry is refused before
its fetch with no range request ever exceeding 5MB. Suite 66/66 (64
baseline held).

* fix(plugin-store): bound the manifest deflate-inflate against CD-declared and actual uncompressed sizes

Closes the risa-labs-inc#914 follow-up found in self-re-audit: the compressed-size
caps alone left the decompression path unbounded. The CD's declared
uncompressedSize is as attacker-controlled as its compressedSize, and a
<30KB compressed plugin.json entry can inflate to gigabytes - a deflate
bomb that passes every fetch cap and OOMs the isolate through the
unbounded DecompressionStream accumulation loop. The declared
uncompressedSize is now refused above the 512KB manifest bound before
any inflate, and the accumulation loop enforces a hard byte counter that
cancels the stream the moment actual decompressed bytes cross the same
bound, so a lying declared size cannot smuggle a bomb either. Regression
test: a small compressed entry declaring a 2GB uncompressedSize fails
the extraction. Suite 67/67 (baseline 64 held).

* chore: remove local triage bookkeeping files from the branch (never meant to leave the workstation)

* fix(plugin-store): address PR risa-labs-inc#952 review - rebuild CD fixtures as valid 46-byte records, tighten first-test assertion, observe decompression cancel, cap the EOCD probe

The three tests built 44-byte central-directory records (omitting version-needed
and flags), so every field the parser reads landed two bytes early and the
crafted sizes never reached the cap checks at all - method read zero padding,
nameLen read the extra-field length, and the parser exited through "manifest
not found" before any cap could fire. Rebuild cdEntry as a proper 46-byte
fixed header so method at +10, compressedSize at +20, uncompressedSize at +24,
nameLen at +28 and localOffset at +42 line up with the parser's arithmetic,
and have it take an explicit method so the deflate-bomb test actually sets
method=8 where the parser reads it.

The first test's assertion accepted almost any error string ("cap" OR
"Refusing" OR "EOCD" OR "manifest" OR "not found" OR "Failed"), so removing
the cdSize span check still passed. Push the CD out of the 65KB tail window
with 70KB of padding so the code takes the downloadRange path, then assert on
the exact cap message. Removing the span check now fails the test with a
different message - mutation-verified.

The decompression reader's `await reader.cancel()` could surface as an
unhandled rejection if the stream had already errored. Wrap in try/catch so
the cancel is best-effort and the throw that follows remains the signal the
caller sees, with the stream already past its bound.

The initial tail fetch (the EOCD probe) lacked an explicit size cap: a server
ignoring Range returned the full body. Add MAX_EOCD_PROBE_BYTES (1MB, well
above any real EOCD + zip64 locator) and refuse above it with the same
post-read shape downloadRange uses for its cap.

Test 2 keeps its loose threw=true assertion because its maxRangeSpan < 5MB
check catches a missing per-entry cap once the downloadRange span cap is
also removed - mutation-verified together.

* fix: detach writer promises on the cancel path; pin guard messages in tests 2 and 3

* ci: retrigger against post-risa-labs-inc#1642 dev

---------

Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
Co-authored-by: Instinct Agent <agent@instinct.com>
Kanakk23 and others added 8 commits September 24, 2026 07:30
…sa-labs-inc#1024)

* feat(mcp): defensive system path bounds in workspace tools

* fix(mcp): address detekt complexity and return count in CLISecurityValidator

* fix(mcp): format CLISecurityValidator for ktlint

* fix(mcp): address review comments on system path bounds

- Add macOS system roots (/System, /Library, /usr/libexec) and canonical /private forms (/private/etc, /private/var/db) to RESTRICTED_POSIX_ROOTS
- Handle Windows extended and device namespace prefixes (\\?\C:\..., \\.\C:\...)
- Enforce MAX_OPEN_TARGET_PATH_LENGTH in normalizePath
- Add symlink regression tests covering the canonical path branch in WorkspaceMcpSecurityBoundsTest
- Drop /root from restricted roots to avoid blocking user home in root containers
- Restore original relative order of isAbsolute before isValidPath
- Relocate unit tests to CLISecurityValidatorTest and clean up KDoc comments
…risa-labs-inc#389) (risa-labs-inc#623)

* improve(onboarding): rewrite plugin descriptions to be benefit-driven

Replace technical jargon with user-facing descriptions that explain
what each plugin enables the user and their agent to do.

Addresses risa-labs-inc#389 acceptance criteria: explain plugin/tool choices in
terms of what users can do with them.

* feat(onboarding): add first session banner after wizard completion

Show a dismissible welcome banner immediately after the plugin
install wizard completes, giving new users three clear next
actions: open a workspace, connect an agent (with MCP address
127.0.0.1:7677 and one-click copy), and open the Toolbox.

The banner is shown once and permanently dismissed via
UserDataStorage. It slides in/out with AnimatedVisibility.

Addresses risa-labs-inc#389 acceptance criteria:
- Land user in a usable workspace with a clear first action
- Guide users through required workspace steps after setup

* refactor(onboarding): redesign welcome overlay as centered card dialog and fix workspace button

- Move FirstSessionBanner from BossAppScaffold to BossAppDialogs for proper dialog layering
- Rewrite as centered BossDialog with three action cards
- Fix "Open a Workspace" to open workspace picker via state.pendingWorkspacePrompt instead of opening Toolbox
- Force user to click "Get Started" to dismiss (onDismissRequest is empty)

* fix(onboarding): constrain welcome dialog width to 680dp

* feat(onboarding): add 4-step agent connection wizard to welcome overlay

Transform the "Connect an Agent" card into a guided
4-step wizard that walks new users through:
1. Picking their agent CLI (Claude Code, Gemini, Codex)
2. Running the exact connection command with copy button
3. Verifying the connection by asking the agent
4. Trying their first BOSS action

Uses exact commands from README:
- claude mcp add --scope user --transport sse boss http://127.0.0.1:7677
- gemini mcp add boss http://127.0.0.1:7677 --transport sse --scope user
- codex mcp add boss --url http://127.0.0.1:7677/mcp

Addresses the most critical new-user friction point in risa-labs-inc#389:
the agent connection flow was completely invisible before setup.

* fix(onboarding): improve welcome overlay layout and wizard UX

- Wrap dialog content in Surface with elevation for proper
  card appearance
- Increase dialog width to 780dp with 32dp padding
- Change agent picker from Column to Row for side-by-side
  buttons using weight(1f)
- Fix all command box text contrast with explicit onSurface
  color and 0.15f background opacity
- Update tip box text to accurately guide copy-open-run flow
- Add onOpenTerminal callback and left-aligned Toolbox shortcut
- Fix info boxes in Step 3 to use fillMaxWidth and weight(1f)

* feat(onboarding): add Getting Started entry in Settings to
reopen welcome guide

Add a "Getting Started" section to the Settings panel that
lets users reopen the first session welcome overlay at any time.

Implementation follows existing MenuActionsHandler SharedFlow
pattern:
- MenuActionsHandler: new reopenGettingStartedEvents SharedFlow
- BossAppMenuActionEffects: observer resets
  firstSessionBannerDismissed and sets showFirstSessionBanner=true
- SettingsSidebar: new GETTING_STARTED enum entry with
  PlayArrow icon
- SettingsWindow: settings panel with description and
  "Reopen Getting Started Guide" button

Addresses risa-labs-inc#389 acceptance criteria:
- Make skipped setup easy to find later through settings panel

* feat(onboarding): hint users about Settings → Getting Started on wizard completion

Add a WizardNote on the Complete screen of the plugin
install wizard telling users they can reopen the
getting started guide from Settings → Getting Started.

Closes the loop between first-run and re-discovery.

* fix(onboarding): address PR review findings 1-4

Finding 1 — Settings → Getting Started now correctly opens the
plugin install wizard via the existing triggerShowPluginWizard
event flow. Removed the redundant reopenGettingStartedEvents
SharedFlow and its observer from MenuActionsHandler and
BossAppMenuActionEffects entirely.

Finding 2 — Add pluginInstallWizardRequestGeneration counter to
BossAppState, incremented in the showPluginWizardEvents observer
alongside showPluginInstallWizard = true, mirroring the existing
terminalOnboardingRequestGeneration pattern exactly.

Finding 5 — Extract Getting Started settings section from inline
SettingsWindow.kt into its own GettingStartedSettings.kt under
components/settings/sections/, following the StartupSettings.kt
structural pattern. Uses SettingsSection(title=) wrapper, no
manual heading, no extra padding, SettingsButtonRow for the
action. Registered in SettingsSearchEntries.kt with searchable
labels (onboarding, setup wizard, welcome, plugin wizard).
Moved GETTING_STARTED to first position in SettingsSection enum
so it appears at the top of the Settings sidebar. Updated stale
enum description to match actual behavior.

Finding 3 — FirstSessionBanner.kt already removed by upstream
commit ce54e5a (no further action needed).

Finding 4 — Add PluginWizardEventTest verifying: the event
reaches the correct window only, fires once per call, and does
not reach a different windowId.

* style(onboarding): fix ktlint violations flagged by CI

- Wrap two description strings in PluginListProvider.kt that
  exceeded the 140-char line limit (Fluck Agent, Secret Manager)
- Fix SettingsSearchEntries.kt indentation and sync the
  Getting Started search label to match the UI exactly
  (Plugin Setup Wizard), resolving SettingsSearchIndexDriftTest
  failures in both directions
- Run ktlintFormat across the branch to clean up remaining
  trailing whitespace, import ordering, and brace/multiline
  style violations surfaced by CI

* style: wrap long description lines for detekt MaxLineLength

* style: ktlint-conformant wrap of long description lines

* fix: restore wrap-point spaces, spaced hyphens per AGENTS.md, AI Gateway wording; drop dead field and unused local/import

* style: remove blank first line left by dead-local removal

---------

Co-authored-by: Tanish Bansal <tanishbansal@Tanishs-MacBook-Air.local>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
…ble through the reload (risa-labs-inc#984)

`reload with late refusal leaves refusing manager untouched` failed on the
Windows runner with "Reload must fail when manager 2 refuses unload": the
reload had succeeded. DynamicPluginManager holds unload-aware components
by WeakReference, and the test registered its two listeners as inline
anonymous objects that nothing else referenced. A GC between registration
and the reload collected them, `ref.get() ?: continue` skipped them,
manager 1 never set the flag, manager 2 never refused, and the reload
went through. Which runner sees it is only where the GC lands.

Reproduced on Linux by forcing a GC before the reload (same failure), and
the forced GC stays in the test as the deterministic guard. The listeners
are now held in locals and used after the reload, so they are reachable
for its whole duration whatever the JIT decides about liveness. The
registration's KDoc now says the reference is weak and the caller owns
the lifetime, since the same shape is one `registerUnloadAware(object`
away in any new test.

Co-authored-by: Atharv Gaur <atharvanna71@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…t (per review)

Ask 1 - publish permittedToolNames from the registry core (applyExposed) and
facade, and derive the flight plan's isPermitted from that set instead of the
exposed tools set, which folds the kill-switch in: a user-disabled tool now
reports the tool-switch checkpoint, not a permission failure.

Ask 2 - new McpFlightPlanLauncherTest drives the launcher's real derivation
(mcpFlightPlanViews/mcpFlightPlanInput) through a real McpToolRegistryCore,
covering the disabled-but-permitted case the old exposed-set logic mislabeled.

Ask 3 - an open plan recomputes on policy change (key over the engine's
config/sessionTrustedTools, policyFor re-answered per tool), with a policy-change
test; the activity status item gained a registry-facing derivation
(mcpActivityStatusShowsFor over allTools) so its binding cannot silently regress
to the exposed set, with tests through a real core.
@Mihir-Rabari
Mihir-Rabari marked this pull request as ready for review September 24, 2026 13:12
@fluck-boss

Copy link
Copy Markdown
Collaborator

@claude please review this exact head (3883b4f) for merge into dev. Check that the change is correct and complete for its stated goal, that tests cover the risk being fixed (a test that passes without the fix is a finding), that the earlier review findings on this PR are addressed, and flag any security issues (secrets, injection, auth or approval bypass, path handling). Keep the review focused on this diff.

@claude

claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Claude finished @fluck-boss's task in 4m 11s —— View job


Review — PR #1380 @ 3883b4fa

  • Gather the diff against dev (12 files, +969/-2 — clean series on 88c0e9d, no migrations)
  • Re-check the three items from the CHANGES_REQUESTED review
  • Re-check F1–F6 from the prior pass
  • Correctness of the forecast against the live admission path
  • Security pass (approval bypass, injection, secrets, path handling, disclosure)

Item 1 (the one that mattered) is genuinely fixed, and fixed well. Item 3's staleness half is fixed. Item 3's test half is not — the mutation still survives. And re-reading the forecast against McpPolicyEngine and applyExposed turned up two cases where the plan tells the operator the wrong thing about a live route, which is the one kind of bug this surface cannot have.


✅ What's fixed

Item 1 / F3 — isPermitted now comes from the permission set. McpToolRegistryCore._permittedToolNames (McpToolRegistryImpl.kt:562-565) applies permitted() without the disabled filter, keyed "providerId/toolName", recomputed inside applyExposed so it moves with every access change, registration and toggle. The two gates are orthogonal now, so the branch order in availabilityCheckpoint stops being load-bearing — which is the right shape of fix, not a reordering.

Item 2 — the test drives the real derivation. McpFlightPlanLauncherTest.plansFor calls mcpFlightPlanViews (the composable's own call) over a real McpToolRegistryCore. Reverting permittedToolNames to a tools-derived id set fails a disabled tool the operator may run is blamed on the kill-switch, not on permissions at line 71. That test would not have passed before the fix. Confirmed.

Item 3b / F4 — the open plan no longer goes stale. policyEngine.config and .sessionTrustedTools are both collectAsState()'d, so a rule edit or trust revoke recomposes and mcpFlightPlanViews re-resolves every tool.

Delegation is still right. policyFor(name, providerId, readOnly) is byte-identical to McpToolRegistryImpl.kt:756; declaredReadOnly goes through the same single McpMutatingToolCatalog.isMutating call the gate uses. No second authorization path.


🔴 N1 — YOLO mode is not modelled, so the plan claims approval is required when it is not

McpPolicyEngine.policyFor deliberately does not consider YOLO — the bypass is applied later, at the approval step, recorded as YOLO_ALLOWED with policyApplied = ASK. The flight plan resolves policy and stops there, so with YOLO on an ASK tool renders:

Human approval — An operator must approve before the handler can start.
Handler — Held until the operator approves the request.
Human approval required

The live route is: runs immediately, no prompt. AGENTS.md calls YOLO "a live global bypass" that must keep a visible indicator; a forecast surface that reports a human gate while that bypass is live is the strongest version of the failure this feature exists to prevent — an operator checking the route before letting an agent loose gets told there is a human in the loop when there isn't.

Cheap to close: yoloMode is a public StateFlow<Boolean> (McpPolicyEngine.kt:305). Collect it in the launcher, put it on McpFlightPlanInput, and let ASK + YOLO produce READY_TO_RUN with a checkpoint naming the bypass. It is also the one case where the forecast should say why it is clear, not just that it is.

Fix this →

🟠 N2 — an unreadable kill-switch file is reported as "This tool is switched off"

applyExposed folds every registered tool name into _disabled while persistedSetUnreadable is up (McpToolRegistryImpl.kt:788-790) — a deliberate fail-closed, documented at :478. The launcher reads disabledToolNames, so in that state every tool reports:

Tool switch — This tool is switched off.

The outcome (WITHHELD) is right; the explanation is wrong, and it is wrong in the direction that sends the operator to the Toolbox toggle, which cannot fix it. This is the exact shape of bug the PR already handles correctly for policyFault — killSwitchFault is right there on the registry (McpToolRegistryImpl.kt:154) and is not consulted. A surface whose whole job is naming the gate should name this one.

Fix this →

🟠 F1 (still open) — the status-item test still passes if the binding regresses

The helper now takes the list rather than a boolean, and the KDoc states the contract — a real improvement. But the mutation is unchanged in outcome:

// BossBottomBar.kt:543
val allTools by McpToolRegistryImpl.allTools.collectAsState()   // revert to .tools

Both are StateFlow<List<RegisteredMcpTool>>, so it compiles, and every test calls mcpActivityStatusShowsFor(core.allTools.value, …) directly — the composable's binding is never executed. Green either way. By the criterion you set, this is still a finding.

McpActivityStatusItem is private; making it internal (or having it take the flow) and mounting it the way McpActivityLogLayoutTest does would close it in a few lines.

🟠 F2 (still open) — the launcher's fault mapping is untested end-to-end

McpFlightPlanTest computes mcpPolicyFaultBlocksInvocation(fault) itself and feeds the boolean; McpFlightPlanLauncherTest passes core.policyEngine.fault.value, which is null in all three tests. Revert McpFlightPlanLauncher.kt:83 to policyFault != null and nothing fails.

The predicate is pinned (mutating McpFlightPlan.kt:25 to fault != null fails policy write faults do not withhold a tool that remains allowed), which is the substance — so this is weaker than F1. McpToolRegistryCore takes an injectable policyEngine, so a core built over a corrupt policy file would drive plansFor with a real PersistedPolicyUnreadable and close it.

🟡 N3 — a policy change resets the operator's selection

var selectedId by remember(plans) { … } (McpFlightPlanDialog.kt:67). Everything in McpFlightPlanView is a data class of primitives/enums, so an equal recomputation is harmless — but a genuine policy change, trust revoke, registration or toggle produces a different plans and snaps the selection back to the first tool. Directly caused by the (correct) F4 fix. Keying on plans.map { it.id } keeps the selection across a policy edit, which is exactly when the operator is watching the route change.

🟡 F5 / F6 / posture note — unchanged from the last pass

  • mcpPolicyFaultBlocksInvocation is still an is check, not an exhaustive when over the sealed McpPolicyFault. Worth saying in its KDoc that the drift is benign — policyFor returns DENY for any fail-closed fault, so a missed type degrades wording, never outcome.
  • McpToolRegistryImpl.kt:156 still reads "Non-null when the policy engine degraded to fail-closed state", which is false for PolicyPersistFailed / ProviderPolicyPersistFailed and is the sentence that produced the original bug.
  • The posture paragraph at :56-63 still enumerates "name/description"; this diff now renders inputSchema from always-present host UI. One line keeps the posture doc accurate.

🔵 Nits

  • _permittedToolNames re-applies permitted() next to the existing permittedTools() (:812) rather than mapping over it — two copies of one filter, which this repo's own conventions treat as the thing to avoid.
  • McpFlightPlanLauncher's KDoc credits key(...) with subscribing the composable; collectAsState does that, and with no remember inside the block the key is a no-op. Harmless, but the comment explains the mechanism wrongly.
  • The launcher tests assert checkpoint states, never labels — the label is the user-visible payload and the thing item 1 was about.

Security pass

No approval-bypass, injection, secret or path-handling issue in this diff.

  • Zero-execution claim holds structurally. McpFlightPlanDialog(plans, onDismiss) — no handler, approval-bus, trust-grant or ledger callback exists to reach. McpFlightPlanView carries strings only, not the RegisteredMcpTool, so the handler lambda is not even in the dialog's object graph.
  • No second authorization path. Policy is delegated. The one duplicated rule is display-only and can only ever be more restrictive in wording, never wider in access.
  • Nothing rendered is interpreted — description and inputSchema go to plain Text; no shell, SQL or path constructed.
  • Additive parameter placement is safe: onOpenFlightPlan sits before the trailing onDismiss, so existing call sites bind unchanged.
  • Disclosure widens as intended but beyond the written posture. Gating on allTools means an operator with zero exposed tools can now enumerate every registered tool's name, description and input schema from always-present host UI. That is the behaviour you asked for and within the sanctioned "metadata disclosure only, grants no invocation" stance — but the precedent surface is the plugin-manager's MCP tab, which is installable and RBAC-hideable, and the KDoc enumerates name/description only. Documentation gap, not a vulnerability.

Style

Clean. README addition uses the spaced hyphen; em-dashes appear only in KDoc/comments, which the guard excludes. Files newline-terminated, @Suppress("LongMethod") justified in place, layout matches its sibling McpActivityLogDialog.


Verdict

Item 1 is properly fixed and its test is real. I'd hold on N1 — a governance forecast that reports a human approval gate while YOLO mode is bypassing it is worse than no forecast, and it's a two-field change. N2 is the same defect class the PR already solved once for policy faults. F1 remains the test-that-can't-fail you asked me to flag. F2, N3, F5, F6 are fine as follow-ups.

I did not run Gradle — this checkout has no cached JxBrowser/Compose artifacts, matching what the author reported. Everything above is read against the live admission path in McpPolicyEngine.policyFor and McpToolRegistryCore.applyExposed; CI on this head is reported green.
· branch feat/mcp-flight-plan

@Mihir-Rabari

Copy link
Copy Markdown
Contributor Author

Review asks, addressed at 3883b4f:

  1. Permission from the permission set, not the exposed set — the registry core now publishes permittedToolNames (recomputed in applyExposed from the same mcpToolPermitted rule, minus the user-disabled filter; keyed providerId/toolName), the facade re-exposes it, and the launcher's mcpFlightPlanInput derives isPermitted from it. A user-disabled tool now reports the tool-switch checkpoint, not a permission failure.

  2. Disabled-path test through the launcher's real derivation — new McpFlightPlanLauncherTest drives mcpFlightPlanViews/mcpFlightPlanInput (extracted from the composable, which now calls the same function) against a real McpToolRegistryCore. The disabled-but-permitted case asserts the registry's exposed set is empty while the plan still reports isPermitted = true and BLOCKS on the availability checkpoint; a second case drives the RBAC-denied path (requiresAdmin cleared by updateAccess).

  3. Stale plan / status-item binding — the launcher collects the policy engine's config/sessionTrustedTools and derives the plan inside key(...), so an open plan recomputes when a rule or session trust changes (re-derivation after a policy change reflects the new rule covers a DENY edit). The status item's visibility moved to mcpActivityStatusShowsFor(allTools, ...) over the registry snapshot itself, so the binding cannot silently regress to the exposed set; its tests now drive a real core, including the every-tool-blocked case where the exposed set is empty but the item must still render.

Hosted checks green on 3883b4f. Marking ready for review.

@fluck-boss fluck-boss left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @Mihir-Rabari - item 1 is fixed and its test is real. Before merge (from Claude's review of 3883b4f):

  1. YOLO mode isn't modelled. policyFor deliberately ignores YOLO (it's applied at the approval step), so with YOLO on, an ASK tool's plan says "Human approval required" when the call will actually run with no prompt. A forecast that shows a human gate while the bypass is live is the failure this feature exists to prevent. yoloMode is a public StateFlow (McpPolicyEngine.kt:305): put it on McpFlightPlanInput, and have ASK + YOLO give READY_TO_RUN with a checkpoint that names the bypass.
  2. An unreadable kill-switch file shows as "This tool is switched off". The outcome (WITHHELD) is right, but the explanation sends the operator to a Toolbox toggle that can't fix it. Consult killSwitchFault (McpToolRegistryImpl.kt:154) the same way you already handle policyFault.
  3. F1, still open: the status-item test still passes if BossBottomBar.kt:543 goes back to .tools. Please make it discriminate.

F2, N3, F5 and F6 are fine as follow-ups.

Changes requested + draft until then. Push, get the ubuntu and windows legs green, mark it ready for review, and it goes straight back into the merge queue.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes requested Review changes must be addressed before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.