diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index d72a6a01..2264c7c2 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 7C3697892ED70F9C005874CE /* DynamicNotchKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7C3697882ED70F9C005874CE /* DynamicNotchKit */; }; 7C5AF14B2F15041600DE21B0 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; }; 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; + 7227748E55EEC815B29C9CDA /* CommandModeDestructiveCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE848A4EF5AF955BAD08708B /* CommandModeDestructiveCommandTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; @@ -33,6 +34,7 @@ 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; + AE848A4EF5AF955BAD08708B /* CommandModeDestructiveCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandModeDestructiveCommandTests.swift; sourceTree = ""; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; @@ -104,6 +106,7 @@ 7CDB0A272F3C4D5600FB7CAD /* Resources */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, + AE848A4EF5AF955BAD08708B /* CommandModeDestructiveCommandTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -258,6 +261,7 @@ 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */, 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, + 7227748E55EEC815B29C9CDA /* CommandModeDestructiveCommandTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index b356788c..5d557843 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -432,7 +432,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.didRequireConfirmationThisRun = true self.pendingCommand = PendingCommand( id: tc.id, @@ -591,8 +591,28 @@ final class CommandModeService: ObservableObject { } } - private func isDestructiveCommand(_ command: String) -> Bool { - let cmd = command.lowercased() + nonisolated static func isDestructiveCommand(_ command: String) -> Bool { + // Inspect each chained segment independently so a destructive command + // hidden after a separator (for example "echo ok && killall Finder") + // is not masked by a benign leading command. "&&", "||", ";", "&" + // (background) and newlines all separate commands in the shell. Pipe + // ("|") is intentionally not a split point here; piped destructive + // commands are matched by the substring patterns in isDestructiveSegment. + // "&&" is normalized before the single "&" so it is not mistaken for two + // background separators. + let normalized = command + .replacingOccurrences(of: "&&", with: "\n") + .replacingOccurrences(of: "||", with: "\n") + .replacingOccurrences(of: ";", with: "\n") + .replacingOccurrences(of: "&", with: "\n") + + return normalized + .components(separatedBy: "\n") + .contains { isDestructiveSegment($0.trimmingCharacters(in: .whitespaces)) } + } + + private nonisolated static func isDestructiveSegment(_ segment: String) -> Bool { + let cmd = segment.lowercased() // Commands that start with these are destructive let destructivePrefixes = [ diff --git a/Tests/FluidDictationIntegrationTests/CommandModeDestructiveCommandTests.swift b/Tests/FluidDictationIntegrationTests/CommandModeDestructiveCommandTests.swift new file mode 100644 index 00000000..29137bc6 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/CommandModeDestructiveCommandTests.swift @@ -0,0 +1,70 @@ +@testable import FluidVoice_Debug +import XCTest + +final class CommandModeDestructiveCommandTests: XCTestCase { + // MARK: - Chained separators hide a destructive command + + func testAndSeparatorHidesDestructiveCommand() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("echo ok && killall Finder")) + } + + func testSemicolonSeparatorHidesDestructiveCommand() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("true; shred secret.txt")) + } + + func testOrSeparatorHidesDestructiveCommand() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("echo a || killall Finder")) + } + + func testNewlineSeparatorHidesDestructiveCommand() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("echo hi\nsudo reboot")) + } + + func testBackgroundSeparatorHidesDestructiveCommand() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("echo ok & killall Finder")) + } + + func testOrSeparatorWithRemoveIsDetected() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("foo || rm bar")) + } + + func testTrailingSegmentDeepInChainIsDetected() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("cd /tmp && echo hi && sudo rm -rf /")) + } + + // MARK: - Safe chains stay allowed + + func testSafeAndChainIsAllowed() { + XCTAssertFalse(CommandModeService.isDestructiveCommand("cd /tmp && ls")) + } + + func testSafeSemicolonChainIsAllowed() { + XCTAssertFalse(CommandModeService.isDestructiveCommand("echo a; echo b")) + } + + func testBackgroundedSafeCommandIsAllowed() { + XCTAssertFalse(CommandModeService.isDestructiveCommand("sleep 10 & echo done")) + } + + // MARK: - Existing single-command detection is preserved + + func testSingleRemoveCommandIsDetected() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("rm -rf /tmp/foo")) + } + + func testSingleSudoCommandIsDetected() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("sudo reboot")) + } + + func testSingleKillallCommandIsDetected() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("killall Finder")) + } + + func testPipedDestructiveCommandIsDetected() { + XCTAssertTrue(CommandModeService.isDestructiveCommand("cat foo | rm bar")) + } + + func testSafeSingleCommandIsAllowed() { + XCTAssertFalse(CommandModeService.isDestructiveCommand("ls -la")) + } +}