Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
7C5AF14C2F15041600DE21B0 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
7C9A71022F58B00000FB7CAF /* TranscribeCpp in Frameworks */ = {isa = PBXBuildFile; productRef = 7C9A71012F58B00000FB7CAF /* TranscribeCpp */; };
7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; };
1A6BEFDDF86DAE98C18BC3BF /* CommandModeDestructiveCommandGapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B2C564157D276092AC9C7D /* CommandModeDestructiveCommandGapTests.swift */; };
7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; };
CD1C7A0000000000000000B2 /* CustomDictionaryManualEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */; };
7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; };
Expand Down Expand Up @@ -68,6 +69,7 @@
DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.swift; sourceTree = "<group>"; };
7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = "<group>"; };
96B2C564157D276092AC9C7D /* CommandModeDestructiveCommandGapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandModeDestructiveCommandGapTests.swift; sourceTree = "<group>"; };
7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = "<group>"; };
CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDictionaryManualEntryTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -145,6 +147,7 @@
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
96B2C564157D276092AC9C7D /* CommandModeDestructiveCommandGapTests.swift */,
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */,
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */,
A11A00000000000000000001 /* AnalyticsDatabaseTests.swift */,
Expand Down Expand Up @@ -314,6 +317,7 @@
7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */,
CD1C7A0000000000000000B2 /* CustomDictionaryManualEntryTests.swift in Sources */,
7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */,
1A6BEFDDF86DAE98C18BC3BF /* CommandModeDestructiveCommandGapTests.swift in Sources */,
86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */,
272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */,
A11A00000000000000000002 /* AnalyticsDatabaseTests.swift in Sources */,
Expand Down
47 changes: 45 additions & 2 deletions Sources/Fluid/Services/CommandModeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +610 to +613

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Quoted executables bypass confirmation

When a destructive executable is quoted, such as "/bin/rm" -rf ~/Documents, this parser retains the closing quote and derives rm" instead of rm. The classifier therefore returns false, while /bin/zsh -c resolves and executes /bin/rm without confirmation.

Knowledge Base Used: AI Enhancement Pipeline

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/Fluid/Services/CommandModeService.swift
Line: 610-613

Comment:
**Quoted executables bypass confirmation**

When a destructive executable is quoted, such as `"/bin/rm" -rf ~/Documents`, this parser retains the closing quote and derives `rm"` instead of `rm`. The classifier therefore returns false, while `/bin/zsh -c` resolves and executes `/bin/rm` without confirmation.

**Knowledge Base Used:** [AI Enhancement Pipeline](https://app.greptile.com/altic/-/custom-context/knowledge-base/altic-dev/fluidvoice/-/docs/ai-enhancement.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

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) }) {

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 Diskutil arguments trigger false positives

Searching the entire command for each subcommand name also matches ordinary arguments, so a benign command such as diskutil info /Volumes/EraseDisk is suspended behind manual confirmation. Parse and compare the actual diskutil subcommand token instead.

Knowledge Base Used: AI Enhancement Pipeline

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/Fluid/Services/CommandModeService.swift
Line: 639

Comment:
**Diskutil arguments trigger false positives**

Searching the entire command for each subcommand name also matches ordinary arguments, so a benign command such as `diskutil info /Volumes/EraseDisk` is suspended behind manual confirmation. Parse and compare the actual diskutil subcommand token instead.

**Knowledge Base Used:** [AI Enhancement Pipeline](https://app.greptile.com/altic/-/custom-context/knowledge-base/altic-dev/fluidvoice/-/docs/ai-enhancement.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

return true
}
}

return false
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
@testable import FluidVoice_Debug
import Foundation
import XCTest

/// Covers three confirm-gate bypass classes in `CommandModeService.isDestructiveCommand`:
/// the primary command invoked via an absolute path, `find -delete`/`-exec rm`, and
/// `diskutil`'s destructive subcommands. None of the three require anything adversarial-
/// looking from the model -- an absolute path, `find`, and `diskutil` are all ordinary,
/// unremarkable tool choices.
final class CommandModeDestructiveCommandGapTests: XCTestCase {
// MARK: - Regression: existing bare-command detection still works

func testBareDestructiveCommandsAreStillCaught() {
let cases = [
"rm -rf ~/Documents",
"sudo reboot",
"mv secret.txt /tmp/",
"chmod 000 /etc/hosts",
"killall Finder",
"rmdir ~/Documents",
]
for command in cases {
XCTAssertTrue(
CommandModeService.isDestructiveCommand(command),
"expected \"\(command)\" to require confirmation"
)
}
}

// MARK: - Fix 1: absolute-path invocation

func testAbsolutePathInvocationIsCaught() {
let cases = [
"/usr/bin/sudo reboot",
"/bin/mv secret.txt /tmp/",
"/bin/chmod 000 /etc/hosts",
"/usr/bin/killall Finder",
"/bin/rmdir ~/Documents",
"/bin/rm somefile.txt", // rm with no dash flag -- the "rm -" fallback doesn't apply here
"/bin/rm -rf ~/Documents", // still caught (now redundantly, by both the old fallback and the new check)
]
for command in cases {
XCTAssertTrue(
CommandModeService.isDestructiveCommand(command),
"expected \"\(command)\" to require confirmation despite the absolute path"
)
}
}

// MARK: - Fix 2: find -delete / find -exec rm

func testFindDeleteAndExecRmAreCaught() {
let cases = [
"find ~/Documents -delete",
"find ~/Documents -type f -delete",
"find / -name '*.important' -exec rm {} \\;",
]
for command in cases {
XCTAssertTrue(
CommandModeService.isDestructiveCommand(command),
"expected \"\(command)\" to require confirmation"
)
}
}

// MARK: - Fix 3: diskutil destructive subcommands

func testDiskutilDestructiveSubcommandsAreCaught() {
let cases = [
"diskutil eraseDisk JHFS+ Untitled disk0",
"diskutil secureErase 0 /dev/disk0",
"diskutil eraseVolume APFS Wiped /Volumes/Backup",
"diskutil reformat /dev/disk2s1",
"diskutil partitionDisk disk0 1 JHFS+ Untitled 100%",
"diskutil zeroDisk /dev/disk0",
]
for command in cases {
XCTAssertTrue(
CommandModeService.isDestructiveCommand(command),
"expected \"\(command)\" to require confirmation"
)
}
}

// MARK: - No new false positives on benign commands

func testBenignCommandsAreNotFlagged() {
let cases = [
"ls -la",
"git status",
"git commit -m \"fix bug\"",
"find . -name '*.txt'", // find WITHOUT -delete or -exec rm
"find . -type f -name '*.log' -exec cat {} \\;", // -exec, but not rm
"diskutil list", // read-only
"diskutil info disk0", // read-only
"diskutil activity", // read-only
"echo hello world",
"cat README.md",
"curl -s https://example.com",
"python3 script.py",
"/usr/bin/python3 --version", // absolute path but not a destructive command name
]
for command in cases {
XCTAssertFalse(
CommandModeService.isDestructiveCommand(command),
"expected \"\(command)\" NOT to require confirmation"
)
}
}
}
Loading