Skip to content

security: scope Move to Cloud Always consent to account and team (SW-16) - #29184

Merged
teamleaderleo merged 8 commits into
feat-cmux-nextfrom
sw16/scoped-cloud-move-consent
Nov 1, 2026
Merged

teamleaderleo merged 8 commits into
feat-cmux-nextfrom
sw16/scoped-cloud-move-consent

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

SW-16: make Move to Cloud consent accountable to the signed-in destination

An agent can request Always when moving a chat's folder to Cloud. Previously the app stored approved folder paths in cmux.cloud.moveAlwaysFolders and silently reused them after the person switched Cloud accounts, teams or backend origins. That allowed a previous approval to authorize a new destination.

Change

  • Store grants as (user ID, team ID, Cloud API origin, folder) in CloudMoveAlwaysGrants, with no token data.
  • Require a signed-in user and selected team before offering the persistent Always choice; Move remains a one-time gesture.
  • Capture the Cloud scope when the agent asks and check it again after folder lookup and when the person clicks the card. A stale card cannot start a move or persist a grant in a new Cloud scope.
  • Ask first next time revokes only the grant in the original scope.
  • Deliberately retire the old folder-only preference. It lacks the originating account/team and cannot be migrated safely; the user approves again once after upgrade.
  • Preserve unfinished-move retry offers separately.

Validation

  • CloudMoveAlwaysGrantsTests.swift: user switch, team switch, API origin switch, folder mismatch, missing identity, corrupt preference, revocation isolation, persistence, and legacy preference refusal.
  • Updated the existing CloudMoveTests card expectation for signed-out or unselected-team states.
  • A standalone Swift 6 compilation and local deterministic assertion harness for the grant helper only passed (legacy data, user/team/origin/folder isolation, revoke). The native macOS app tests and full build require the project's fleet Mac runner and remain unexecuted in this session. CI and owner review are required before merge.

Threat and deliberately bounded scope

  • Assumes a process/agent can request a Cloud move and the person may switch accounts or teams between requests.
  • This fixes persistent consent reuse, not the separate caller-provenance concern in CloudMoveAsks.isAgent / action routing (an unmarked agent CLI invocation could be treated as a person's run), nor the remote-deletion-ack gap in CloudAccessRevokes. See private security audit HQ #5286.
  • A deeper consent contract should bind the authenticated actor, chat/session, canonical source path, exact destination machine/generation, authorized operation, expiry, and a server-verifiable approval receipt. Account switching, revocation, replay, and lost acknowledgments need end-to-end tests.
  • This branch modifies no backend permissions or production policy. No deploy/merge.

Base: feat-cmux-next at 0ef18a094a45571024219225ad84b5ceff9db1fb (source reviewed first). Owner/reviewer: NEXT Mac Cloud consent/action-routing lane. HQ brief: SW-16.

Next action: fleet-run CloudMoveAlwaysGrantsTests + CloudMoveTests, verify stale account-switch card with an app-level fixture, and review before enabling Always again.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Fixes a security gap where the "Always move to Cloud" consent was stored per folder only, so a prior approval could silently authorize a move after the person switched Cloud accounts, teams, or backend origins. Persistent consent is now scoped to the signed-in user, team, Cloud API origin, and folder.

  • Stores grants as (user ID, team ID, API origin, folder) with no token data; "Always" is only offered when signed in with a selected team, while "Move" stays a one-time gesture.
  • Re-checks the Cloud scope after folder lookup and when the card is clicked, so a stale card can't start a move or persist a grant in a new scope.
  • "Ask first next time" revokes only the grant in the original scope, even after a switch; the old folder-only preference is retired and the person approves once again.
  • Updates DebugCloudMove to read and seed the scoped grants and its restore journey to expect the new scope label, fixing a compile error from the removed folder-only preference.
  • Adds tests covering account, team, and origin switches, revocation, corrupt data, and legacy preference refusal; the full Mac test suite still needs the fleet runner and CI, so owner review is required before merge.

Written for commit 3405e00. Summary will update on new commits.

View guided diff Turn on auto-fix

@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 31, 2026
…ke after a switch

DebugCloudMove still read the removed CloudMoveAsks.defaultsKey, so the
app did not compile. It now reads, seeds and drops the scoped grants
through CloudMoveAlwaysGrants.stored/store (seeded under a made-up rj-
account, team and origin), and the restore journey checks the new scope
label. "Ask first next time" is no longer refused after a Cloud account
or team switch: it only revokes the original scope's grant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo
teamleaderleo marked this pull request as ready for review November 1, 2026 02:36

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMoveTests.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMoveTests.swift:314">
P2: This assertion no longer pins the card's buttons: it derives the expected list from the same auth state the app reads, so a fixture that is signed out passes with no "always" check at all. Assert the fixture's signed-in state explicitly, then assert the full action list. Also, the `canRemember` predicate differs from the production `Scope` check (empty IDs), so it can disagree with the app.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAlwaysGrants.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAlwaysGrants.swift:61">
P3: `stored(in:)` decodes all grants as one array, so a single unreadable entry silently drops every "Always" grant, and the next `remember` overwrites the blob. Decode per entry, as `CloudMovedChats` already does, so only the bad entry is lost.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugCloudMove.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugCloudMove.swift:84">
P3: The grant `id` now digests the whole grant, not just the folder, so the comment in restore-journey.py that grants are "named by their folder's digest only" is now wrong. Update that comment to match.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift:147">
P2: The launch-offer Retry only works once. After `moveStarted` clears `offered`, a Retry on the card that follows a failed attempt compares the offer's `scope: nil` against the signed-in scope and fails with "noMove". Mark launch-offer asks explicitly, for example with a flag on `Ask`, and skip the scope check for them. Don't rely on `offered`, which `moveStarted` clears.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

#expect(Self.text(card, "state") == "ask")
#expect(Self.actionIDs(card) == ["move", "notNow", "always"])
// A signed-out chat can approve one move, but has no Cloud identity for an "Always" grant.
let canRemember = move.services.cloud.auth.isSignedIn && move.services.cloud.auth.user != nil && move.services.cloud.auth.teamID != nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This assertion no longer pins the card's buttons: it derives the expected list from the same auth state the app reads, so a fixture that is signed out passes with no "always" check at all. Assert the fixture's signed-in state explicitly, then assert the full action list. Also, the canRemember predicate differs from the production Scope check (empty IDs), so it can disagree with the app.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMoveTests.swift, line 314:

<comment>This assertion no longer pins the card's buttons: it derives the expected list from the same auth state the app reads, so a fixture that is signed out passes with no "always" check at all. Assert the fixture's signed-in state explicitly, then assert the full action list. Also, the `canRemember` predicate differs from the production `Scope` check (empty IDs), so it can disagree with the app.</comment>

<file context>
@@ -310,7 +310,9 @@ struct CloudMoveTests {
             #expect(Self.text(card, "state") == "ask")
-            #expect(Self.actionIDs(card) == ["move", "notNow", "always"])
+            // A signed-out chat can approve one move, but has no Cloud identity for an "Always" grant.
+            let canRemember = move.services.cloud.auth.isSignedIn && move.services.cloud.auth.user != nil && move.services.cloud.auth.teamID != nil
+            #expect(Self.actionIDs(card) == (canRemember ? ["move", "notNow", "always"] : ["move", "notNow"]))
             let id = try #require(Self.text(card, "id"))
</file context>

// An old card cannot grant or start a move after a Cloud account/team change.
// Launch offers are a separate Retry flow with a pinned machine. Declining and
// "Ask first next time" (which revokes the original scope's grant) only take authority away.
if !offered.contains(tab), ask.scope != Self.scope(services), ![.notNow, .dismiss, .askFirst].contains(answer) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The launch-offer Retry only works once. After moveStarted clears offered, a Retry on the card that follows a failed attempt compares the offer's scope: nil against the signed-in scope and fails with "noMove". Mark launch-offer asks explicitly, for example with a flag on Ask, and skip the scope check for them. Don't rely on offered, which moveStarted clears.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift, line 147:

<comment>The launch-offer Retry only works once. After `moveStarted` clears `offered`, a Retry on the card that follows a failed attempt compares the offer's `scope: nil` against the signed-in scope and fails with "noMove". Mark launch-offer asks explicitly, for example with a flag on `Ask`, and skip the scope check for them. Don't rely on `offered`, which `moveStarted` clears.</comment>

<file context>
@@ -136,15 +141,23 @@ final class CloudMoveAsks {
+        // An old card cannot grant or start a move after a Cloud account/team change.
+        // Launch offers are a separate Retry flow with a pinned machine. Declining and
+        // "Ask first next time" (which revokes the original scope's grant) only take authority away.
+        if !offered.contains(tab), ask.scope != Self.scope(services), ![.notNow, .dismiss, .askFirst].contains(answer) {
+            show(tab, .failed, CloudMoveAskStrings.noMove, [.dismiss], services: services)
+            return
</file context>


/// Every grant kept in `defaults`; a missing or damaged preference reads as none.
static func stored(in defaults: UserDefaults) -> [Grant] {
guard let data = defaults.data(forKey: defaultsKey),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: stored(in:) decodes all grants as one array, so a single unreadable entry silently drops every "Always" grant, and the next remember overwrites the blob. Decode per entry, as CloudMovedChats already does, so only the bad entry is lost.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAlwaysGrants.swift, line 61:

<comment>`stored(in:)` decodes all grants as one array, so a single unreadable entry silently drops every "Always" grant, and the next `remember` overwrites the blob. Decode per entry, as `CloudMovedChats` already does, so only the bad entry is lost.</comment>

<file context>
@@ -0,0 +1,79 @@
+
+    /// Every grant kept in `defaults`; a missing or damaged preference reads as none.
+    static func stored(in defaults: UserDefaults) -> [Grant] {
+        guard let data = defaults.data(forKey: defaultsKey),
+              let entries = try? JSONDecoder().decode([Grant].self, from: data) else { return [] }
+        return entries
</file context>

"move_retries": .array(moveRetries),
"bring_back_retries": .array(retries(cloud.bringBackRetries)),
"grants": .array(folders.map { .object(["id": .string(digest($0)), "scope": .string("folder"), "kind": .string("always_move")]) }),
"grants": .array(grants.map { .object(["id": .string(digest($0)), "scope": .string("account_team_origin_folder"), "kind": .string("always_move")]) }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The grant id now digests the whole grant, not just the folder, so the comment in restore-journey.py that grants are "named by their folder's digest only" is now wrong. Update that comment to match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugCloudMove.swift, line 84:

<comment>The grant `id` now digests the whole grant, not just the folder, so the comment in restore-journey.py that grants are "named by their folder's digest only" is now wrong. Update that comment to match.</comment>

<file context>
@@ -80,7 +81,7 @@ extension DebugCloudMove {
             "move_retries": .array(moveRetries),
             "bring_back_retries": .array(retries(cloud.bringBackRetries)),
-            "grants": .array(folders.map { .object(["id": .string(digest($0)), "scope": .string("folder"), "kind": .string("always_move")]) }),
+            "grants": .array(grants.map { .object(["id": .string(digest($0)), "scope": .string("account_team_origin_folder"), "kind": .string("always_move")]) }),
             // Whether each dismissed offer still matches its tab's Retry (a new Retry offers again).
             "dismissed": .array(dismissed.keys.sorted().map { tab in
</file context>

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review c57c5cd: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr29184 -f ref=sw16/scoped-cloud-move-consent -f platform=macos -f backend_mode=local

red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on 16b7cc2 to compare; do not merge until green; see lane-rules

Shipped in:

@blacksmith-sh

blacksmith-sh Bot commented Nov 1, 2026

Copy link
Copy Markdown

Found 5 test failures on Blacksmith runners:

Failures

Test View Logs
nightly must build and hand off the macOS 26 helper and Xcode 26.5 app before publishin
g/
nightly must build and hand off the macOS 26 helper and Xcode 26.5 app before publishin
g
View Logs
test_helper_never_takes_a_hosted_mac_and_skips_compile_on_hit (main.WorkflowTests.t
est_helper_never_takes_a_hosted_mac_and_skips_compile_on_hit)/
test_helper_never_takes_a_hosted_mac_and_skips_compile_on_hit (main.WorkflowTests.t
est_helper_never_takes_a_hosted_mac_and_skips_compile_on_hit)
View Logs
test_the_nightly_is_covered (main.NightlyStepCoverageTests.test_the_nightly_is_cove
red)/
test_the_nightly_is_covered (main.NightlyStepCoverageTests.test_the_nightly_is_cove
red)
View Logs
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)/
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)
View Logs
test_under_githubs_limit_with_headroom (main.NightlyWorkflowSize.test_under_githubs
_limit_with_headroom)/
test_under_githubs_limit_with_headroom (main.NightlyWorkflowSize.test_under_githubs
_limit_with_headroom)
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (claude opus, correctness first): SHIP at 3405e00

Read the diff against feat-cmux-next (6 files). Grants are keyed on user, team, API origin and folder under a new key (cmux.cloud.moveAlwaysGrants.v2). The old folder-only cmux.cloud.moveAlwaysFolders is never read, so an approval stored before this change cannot silently allow a move; the person approves once again. The scope is captured when the agent asks and checked again after the folder lookup and on the click. Signed out, or with no team selected, the card offers only Move and Not now. "Ask first next time" after a switch revokes only the original scope's grant. A corrupt preference reads as no grants. The DebugCloudMove seed and read, and restore-journey.py, follow the new shape.

CI at this head, compared by test name with feat-cmux-next:

  • swift test: CloudMoveAlwaysGrantsTests and CloudMoveStateDebugTests pass. CloudMoveTests skips here ("needs the same-tree cmux-tui"), as it does on base. No crash or signal. Of the 33 failing tests, 31 also fail on base tip Swift suite runs 41303556638, 41298073172, 41282022128 and 41275096815. The other two are timing budgets in code this PR doesn't touch: MarkdownTableWidthMemoryTests.aWriteCostsWellUnderAMillisecond (2.4 ms against a 2 ms budget) and SidebarGroupToggleScalingTests.aToggleThatWaitsCostsOnlyItsOwnWork (its sibling toggleCostDoesNotGrowWithWorkspaces fails on base).
  • cmux-next checks: the failing steps (String tables on CmuxNextApps store.updates.available, Script tests, Checks job structure, Tier routing on AgentPageHibernationTests, Daemon capabilities export) all name files outside this diff.
  • nightly-guards and tooling-tests: the whats-new digest, nightly helper hand-off, nightly step coverage and tbd stubs reds are base reds.

Follow-ups: none blocking.

@teamleaderleo
teamleaderleo merged commit e6886a5 into feat-cmux-next Nov 1, 2026
170 of 181 checks passed
@teamleaderleo
teamleaderleo deleted the sw16/scoped-cloud-move-consent branch November 1, 2026 13:23
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (independent Codex gpt-6.1-sol, high, via the review router; full diff from merge base 16b7cc2 to this head): SHIP at 3405e00.

No P1/P2 findings.

lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…l, RoomMembership, IncognitoSpaceMarks out of CmuxNextApp (#29184 #30921 #30666 #30881 #30828) (#31036)

* base red: arch app_lines, move five App files into their packages

The feat-cmux-next tip is red on the arch ratchet's app_lines row: +295
CmuxNextApp lines over the last green arch tip 73dc0d8. This moves
five self-contained App files into the package that owns their domain,
with no behavior change (public API, the App calls it):

- CloudMoveAlwaysGrants (#29184) and CloudMoveCLI -> CmuxNextCloud/Move,
  beside CloudMoveFolder and CloudMoveRetries. CmuxNextCloud now depends
  on CmuxNextDaemon for ProcessRunner (both service layer).
- VideoControl (#30921) -> CmuxNextAgentPane, beside AgentVideoFrames.
  A struct with an instance methods() (namespace convention); the App
  registers VideoControl().methods(). CmuxNextAgentPane now depends on
  CmuxNextControl (ui -> service).
- RoomMembership (#30666 added isHidden) -> CmuxNextDaemon/Store, beside
  PersonalStore whose pins it reads. A pure value type.
- IncognitoSpaceMarks (#30881) -> CmuxNextBridge, beside
  SidebarMapping+Spaces that takes its hidden set. Still MainActor
  (Bridge has the UI default isolation).

Tests that name the moved types import their new module.

* base red: arch global_mutable_statics, IncognitoChats' pending purges are the app's instance (#30828)

#30828 added 'private static var pending' to the IncognitoChats enum,
which raises the arch ratchet's global_mutable_statics row (+1,
CmuxNextApp). IncognitoChats is now a final class that AppServices owns
(services.incognitoChats); WorkspaceClose.willClose and the quit's
windows and endLocalAgents steps call that one instance. The pure
key(of:key:incognito:) stays static. No behavior change: one app, one
set of pending purges, drained at quit as before.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 3405e004 Deployed Nov 1, 2026 by teamleaderleo via wait for same-key tree publisher #94993
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant