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 @@ -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 */; };
Expand All @@ -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 = "<group>"; };
AE848A4EF5AF955BAD08708B /* CommandModeDestructiveCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandModeDestructiveCommandTests.swift; sourceTree = "<group>"; };
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
Expand Down Expand Up @@ -104,6 +106,7 @@
7CDB0A272F3C4D5600FB7CAD /* Resources */,
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
AE848A4EF5AF955BAD08708B /* CommandModeDestructiveCommandTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
26 changes: 23 additions & 3 deletions Sources/Fluid/Services/CommandModeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading