Skip to content

isDestructiveCommand's confirm gate has three bypass classes PR #434 doesn't cover: absolute-path invocation, find -delete/-exec rm, and diskutil #861

Description

@manjunathbhaskar

Description

CommandModeService.isDestructiveCommand (Sources/Fluid/Services/CommandModeService.swift:562, current main) decides whether an AI-issued shell command in Command Mode requires user confirmation before running. Three ways a plausible (not adversarial-looking) model output skips it entirely:

1. The primary command invoked via an absolute path. The prefix list only matches bare names ("sudo ", "mv ", "chmod ", etc.). /usr/bin/sudo reboot, /bin/mv secret.txt /tmp/, /bin/chmod 000 /etc/hosts, /usr/bin/killall Finder, /bin/rmdir ~/Documents all skip the check their bare-name equivalents trigger. rm happens to have a partial fallback (cmd.contains("rm -")) that catches most /bin/rm -rf cases, but not /bin/rm somefile (no dash flag), and no other command has an equivalent fallback at all.

2. find -delete / find ... -exec rm .... Never matches any prefix or the |/;/&& patterns, since rm inside -exec never sits next to a matched separator. find ~/Documents -delete skips detection with no path trick needed at all.

3. diskutil's destructive subcommands. dd , mkfs, format are the only disk-operation prefixes. diskutil eraseDisk, diskutil secureErase, diskutil eraseVolume are a completely different binary and aren't covered anywhere.

Relationship to #434

#434 ("flag pipe-to-shell, output-redirect, and whitespace-prefixed destructive commands in the confirm gate") is open and does extensive work on this same function, but on the pipe-target side of the classification, not the leading-command matching logic. I checked this rather than assumed it: fetched #434's actual branch, mechanically extracted its patched isDestructiveCommand plus every helper it calls (its own diff moves the function from private func to nonisolated static func for testability, which is also what I did independently before checking #434 had already made the same call), and reran the same adversarial inputs against #434's real code:

$ swift pr434_wrapped.swift
=== against PR #434's actual patched code: absolute-path bypass ===
caught("sudo reboot")=true   bypass("/usr/bin/sudo reboot")=false   STILL BYPASSED
caught("mv secret.txt /tmp/")=true   bypass("/bin/mv secret.txt /tmp/")=false   STILL BYPASSED
caught("chmod 000 /etc/hosts")=true   bypass("/bin/chmod 000 /etc/hosts")=false   STILL BYPASSED
caught("killall Finder")=true   bypass("/usr/bin/killall Finder")=false   STILL BYPASSED
caught("rmdir ~/Documents")=true   bypass("/bin/rmdir ~/Documents")=false   STILL BYPASSED

=== against PR #434's actual patched code: find -delete/-exec ===
flagged=false   cmd="find ~/Documents -delete"
flagged=false   cmd="find ~/Documents -type f -delete"
flagged=false   cmd="find / -name '*.important' -exec rm {} \;"

=== against PR #434's actual patched code: diskutil ===
flagged=false   cmd="diskutil eraseDisk JHFS+ Untitled disk0"
flagged=false   cmd="diskutil secureErase 0 /dev/disk0"

All three classes survive #434's hardening unchanged, so this isn't overlapping work — it's a genuinely separate gap in the same function. I'm filing this as an issue rather than a competing PR against #434 to avoid a merge conflict on the same lines; happy to have the patch below folded into #434 or landed separately, whichever you'd prefer.

Proposed fix

Resolves the leading command to its bare name the same way a shell would (last path component of the first token), so /bin/rm, /usr/bin/rm, and bare rm are all recognized as the same command regardless of how the model referenced it, plus dedicated checks for the find and diskutil cases. Scoped narrowly — diskutil list/diskutil info (read-only) are explicitly not flagged.

--- a/Sources/Fluid/Services/CommandModeService.swift
+++ b/Sources/Fluid/Services/CommandModeService.swift
@@ -437,7 +437,7 @@ final class CommandModeService: ObservableObject {
                 }

                 // Check if we need confirmation for destructive commands
-                if SettingsStore.shared.commandModeConfirmBeforeExecute, self.isDestructiveCommand(tc.command) {
+                if SettingsStore.shared.commandModeConfirmBeforeExecute, Self.isDestructiveCommand(tc.command) {
                     self.pendingCommand = PendingCommand(
                         id: tc.id,
                         command: tc.command,
@@ -559,7 +559,7 @@ final class CommandModeService: ObservableObject {
         }
     }

-    private func isDestructiveCommand(_ command: String) -> Bool {
+    nonisolated static func isDestructiveCommand(_ command: String) -> Bool {
         let cmd = command.lowercased()

         // Commands that start with these are destructive
@@ -598,6 +598,49 @@ final class CommandModeService: ObservableObject {
             return true
         }

+        // The prefix list above only matches a bare command name. A model
+        // that reaches for `/bin/rm`, `/usr/bin/sudo`, etc. (not unusual —
+        // absolute paths are a normal way to disambiguate a binary) skips
+        // every check above except the `rm -` fallback, which only happens
+        // to catch `rm` and only when it carries a `-` flag. Resolve the
+        // leading token to its bare command name the same way a shell would
+        // (last path component) so `/bin/rm`, `/usr/bin/rm`, and bare `rm`
+        // are all recognized as the same command regardless of how the
+        // model referenced it.
+        let leadingToken = cmd
+            .drop(while: { $0 == " " || $0 == "\t" })
+            .prefix(while: { $0 != " " && $0 != "\t" })
+        let commandName = (leadingToken as NSString).lastPathComponent
+        let destructiveCommandNames: Set = [
+            "rm", "rmdir", "mv", "sudo", "kill", "pkill", "killall",
+            "chmod", "chown", "chgrp", "dd", "mkfs", "shred", "truncate",
+        ]
+        if destructiveCommandNames.contains(commandName) {
+            return true
+        }
+
+        // `find -delete` / `find ... -exec rm ...` deletes without ever
+        // matching "rm -" or any `|`/`;`/`&&` pattern above, since `rm`
+        // inside `-exec` never sits next to a matched separator.
+        if commandName == "find", cmd.contains(" -delete") || (cmd.contains("-exec") && cmd.contains("rm ")) {
+            return true
+        }
+
+        // diskutil's erase/reformat/partition subcommands are as destructive
+        // as `dd`/`mkfs`/`format` but are a different binary entirely and
+        // weren't covered by any check above. Scoped to the destructive
+        // subcommands specifically so read-only uses (`diskutil list`,
+        // `diskutil info`) are not flagged.
+        if commandName == "diskutil" {
+            let destructiveDiskutilSubcommands = [
+                "erasedisk", "erasevolume", "secureerase",
+                "reformat", "partitiondisk", "zerodisk", "unmountdisk",
+            ]
+            if destructiveDiskutilSubcommands.contains(where: { cmd.contains($0) }) {
+                return true
+            }
+        }
+
         return false
     }

Plus a new test file, Tests/FluidDictationIntegrationTests/CommandModeDestructiveCommandGapTests.swift, covering: the three fixed bypass classes, a regression check that the original bare-command detection still works, and a false-positive check on benign commands (diskutil list, find . -name '*.txt', /usr/bin/python3 --version, etc.) so the gate doesn't get more aggressive than it needs to be.

Testing

  • Tested on Apple Silicon Mac
  • Tested on Intel Mac
  • Tested on macOS version: 26.1
  • Ran linter locally: swiftlint lint --strict --config .swiftlint.yml Sources/Fluid/Services/CommandModeService.swift — 0 violations. Same for the new test file.
  • Ran formatter locally: swiftformat --config .swiftformat --lint — clean on the changed region (the file has 37 pre-existing formatting violations elsewhere, none touched by this change, confirmed by running the same lint against the unmodified original file first)
  • Ran tests locally:
xcodebuild test -project Fluid.xcodeproj -scheme Fluid -destination 'platform=macOS' \
  -only-testing:FluidDictationIntegrationTests/CommandModeDestructiveCommandGapTests \
  CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO

Test Suite 'CommandModeDestructiveCommandGapTests' passed at 2026-08-14 12:34:45.767.
	 Executed 5 tests, with 0 failures (0 unexpected) in 0.006 (0.008) seconds
  • xcodebuild -project Fluid.xcodeproj -scheme Fluid -configuration Debug build on macOS 26.1 / Xcode 26.3 (Apple Silicon): BUILD SUCCEEDED, no new warnings.

Notes

This is a defense-in-depth gap, not a way to get code execution FluidVoice doesn't already grant — Command Mode already gives its configured LLM real shell access by design, and the confirm gate exists to catch the model's own mistakes or a manipulated/hallucinated response before something destructive runs unattended. All three classes here let a plausible model output — using an absolute path, or find, or diskutil, none of which are unusual or adversarial-looking on their own — skip that one safety check silently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions