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
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
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.
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 ~/Documentsall skip the check their bare-name equivalents trigger.rmhappens to have a partial fallback (cmd.contains("rm -")) that catches most/bin/rm -rfcases, 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, sincerminside-execnever sits next to a matched separator.find ~/Documents -deleteskips detection with no path trick needed at all.3.
diskutil's destructive subcommands.dd,mkfs,formatare the only disk-operation prefixes.diskutil eraseDisk,diskutil secureErase,diskutil eraseVolumeare 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
isDestructiveCommandplus every helper it calls (its own diff moves the function fromprivate functononisolated static funcfor 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: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 barermare all recognized as the same command regardless of how the model referenced it, plus dedicated checks for thefindanddiskutilcases. Scoped narrowly —diskutil list/diskutil info(read-only) are explicitly not flagged.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
swiftlint lint --strict --config .swiftlint.yml Sources/Fluid/Services/CommandModeService.swift— 0 violations. Same for the new test file.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)xcodebuild -project Fluid.xcodeproj -scheme Fluid -configuration Debug buildon 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, ordiskutil, none of which are unusual or adversarial-looking on their own — skip that one safety check silently.