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
7 changes: 4 additions & 3 deletions lib/core/services/app_logger.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
import 'package:intl/intl.dart';
Expand Down Expand Up @@ -38,7 +39,7 @@ class AppLogger {
final dateStr = DateFormat('yyyy-MM-dd').format(DateTime.now());
_logFile = File('${logsDir.path}/app_log_$dateStr.txt');
} catch (e) {
print('Failed to initialize log file: $e');
debugPrint('Failed to initialize log file: $e');
}
}

Expand All @@ -63,7 +64,7 @@ class AppLogger {
);
}
} catch (e) {
print('Failed to write to log file: $e');
debugPrint('Failed to write to log file: $e');
}
}

Expand Down Expand Up @@ -114,7 +115,7 @@ class _CustomOutput extends LogOutput {
void output(OutputEvent event) {
for (var line in event.lines) {
appLogger._addToBuffer(line);
print(line);
debugPrint(line);
}
}
}
15 changes: 7 additions & 8 deletions lib/data/datasources/api/gemini_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ class GeminiApiClient extends BaseApiClient {
),
data: {
'contents': _convertMessages(messages, systemPrompt),
if (systemPrompt != null && systemPrompt.isNotEmpty)
'systemInstruction': {
'parts': [{'text': systemPrompt}],
},
if (tools.isNotEmpty) 'tools': [{'functionDeclarations': _convertTools(tools)}],
'generationConfig': {
if (maxTokens != null) 'maxOutputTokens': maxTokens,
Expand Down Expand Up @@ -115,14 +119,9 @@ class GeminiApiClient extends BaseApiClient {

List<Map<String, dynamic>> _convertMessages(List<Message> messages, String? systemPrompt) {
final contents = <Map<String, dynamic>>[];

// Add system prompt as first user message if provided
if (systemPrompt != null && systemPrompt.isNotEmpty) {
contents.add({
'role': 'user',
'parts': [{'text': systemPrompt}],
});
}

// System prompt is passed separately via systemInstruction in the request body,
// so we do not inject it as a user message here.

for (final msg in messages) {
contents.add({
Expand Down
24 changes: 22 additions & 2 deletions lib/data/datasources/ssh/ssh_client_impl.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dartssh2/dartssh2.dart';
import '../../../core/errors/exceptions.dart';
import '../../../core/constants/app_constants.dart';
Expand Down Expand Up @@ -66,7 +67,6 @@ class SSHClientImpl {
Future<void> disconnect() async {
try {
_logger.info('Disconnecting from SSH server', tag: 'SSH');
_updateStatus(SSHConnectionStatus.disconnected);

_sftpClient?.close();
_session?.close();
Expand Down Expand Up @@ -195,7 +195,27 @@ class SSHClientImpl {
}

Future<String> _readPrivateKey(String path) async {
throw UnimplementedError('Private key reading not implemented');
try {
_logger.debug('Reading private key from: $path', tag: 'SSH');
// Read the private key file via SSH command on local device
// For mobile, the path is expected to be a local file path
final file = File(path);
if (!await file.exists()) {
throw SSHException(
message: 'Private key file not found at: $path',
);
}
final content = await file.readAsString();
_logger.debug('Private key read successfully', tag: 'SSH');
return content;
} catch (e) {
if (e is SSHException) rethrow;
_logger.error('Failed to read private key: ${e.toString()}', error: e, tag: 'SSH');
throw SSHException(
message: 'Failed to read private key file: ${e.toString()}',
details: e,
);
}
}

Future<void> dispose() async {
Expand Down
17 changes: 17 additions & 0 deletions lib/data/tools/bash_tool.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,27 @@ class BashTool extends BaseTool {
@override
bool get isReadOnly => false;

/// Dangerous command patterns that should be blocked for safety.
static final _dangerousPatterns = [
RegExp(r'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?/\s*$'),
RegExp(r'rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+/\s*$'),
Comment on lines +33 to +34

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🔴 Dangerous command patterns are trivially bypassed due to end-of-string $ anchor

The rm-related regexes in _dangerousPatterns at lib/data/tools/bash_tool.dart:33-34 use $ (end-of-string anchor), so they only match when rm -rf / is at the very end of the command string. Any appended text bypasses the check — e.g., rm -rf / ; echo done or rm -rf / #comment will not be caught. Since the LLM could easily generate compound commands, this safety feature is ineffective against common patterns.

Suggested change
RegExp(r'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?/\s*$'),
RegExp(r'rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+/\s*$'),
RegExp(r'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?/\s*($|[;|&])'),
RegExp(r'rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+/\s*($|[;|&])'),
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

RegExp(r'mkfs\.'),
RegExp(r'dd\s+.*of=/dev/'),
RegExp(r':(\s*)\{\s*:\|:\s*&\s*\}\s*;\s*:'),
];

@override
Future<String> execute(Map<String, dynamic> arguments) async {
final command = arguments['command'] as String;

// Check for dangerous commands
for (final pattern in _dangerousPatterns) {
if (pattern.hasMatch(command)) {
return 'Error: This command has been blocked for safety. '
'It matches a dangerous pattern that could cause data loss.';
}
}

final result = await sshRepository.executeCommand(command);

return result.fold(
Expand Down
116 changes: 59 additions & 57 deletions lib/presentation/blocs/chat/chat_bloc.dart

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🔴 User's new message is excluded from API call due to stale currentState.messages reference

At lib/presentation/blocs/chat/chat_bloc.dart:146, the messages sent to the LLM API use currentState.messages, which was captured at line 63 before the user message was appended. The PR introduced messagesWithUser at line 105 to fix the emit calls, but forgot to also use it for the API call. As a result, the AI model never sees the user's latest message — every API request is missing the prompt that triggered it.

(Refers to line 146)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
_logger.debug('Created user message: ${userMessage.id}', tag: 'ChatBloc');

// Add user message to UI
final messagesWithUser = [...currentState.messages, userMessage];
emit(currentState.copyWith(
messages: [...currentState.messages, userMessage],
messages: messagesWithUser,
));

// Save user message
Expand All @@ -122,7 +123,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);

emit(currentState.copyWith(
messages: [...currentState.messages, assistantMessage],
messages: [...messagesWithUser, assistantMessage],
isStreaming: true,
));

Expand Down Expand Up @@ -256,62 +257,63 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {

final result = await loadSession(event.sessionId);

result.fold(
(failure) async {
_logger.warning('Session not found: ${event.sessionId}', tag: 'ChatBloc');
// If session not found, create a new one
if (failure.toString().contains('Session not found')) {
// Get connection info
final providersResult = await getProviders();
providersResult.fold(
(error) {
_logger.error('No provider configured', tag: 'ChatBloc');
emit(ChatError(message: 'No provider configured. Please add a provider in settings.'));
},
(providers) {
if (providers.isEmpty) {
_logger.error('No providers available', tag: 'ChatBloc');
emit(ChatError(message: 'No provider configured. Please add a provider in settings.'));
return;
}

final defaultProvider = providers.firstWhere(
(p) => p.isDefault,
orElse: () => providers.first,
);

_logger.info('Creating new session with provider: ${defaultProvider.name}', tag: 'ChatBloc');
// Create new session
final newSession = Session(
id: event.sessionId,
name: 'New Chat',
sshConfigId: 'default',
providerId: defaultProvider.id,
workingDirectory: '/home',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
messages: [],
);

emit(ChatLoaded(
session: newSession,
messages: [],
));
},
);
} else {
_logger.error('Failed to load session: ${failure.toString()}', tag: 'ChatBloc');
emit(ChatError(message: failure.toString()));
}
},
(session) {
_logger.info('Session loaded successfully with ${session.messages.length} messages', tag: 'ChatBloc');
emit(ChatLoaded(
session: session,
messages: session.messages,
));
},
if (result.isRight()) {
final session = result.getOrElse(() => throw StateError('unreachable'));
_logger.info('Session loaded successfully with ${session.messages.length} messages', tag: 'ChatBloc');
emit(ChatLoaded(
session: session,
messages: session.messages,
));
return;
}

// Handle failure
final failure = result.fold((f) => f, (_) => throw StateError('unreachable'));
_logger.warning('Session not found: ${event.sessionId}', tag: 'ChatBloc');

if (!failure.toString().contains('Session not found')) {
_logger.error('Failed to load session: ${failure.toString()}', tag: 'ChatBloc');
emit(ChatError(message: failure.toString()));
return;
}

// Session not found — create a new one
final providersResult = await getProviders();

if (providersResult.isLeft()) {
_logger.error('No provider configured', tag: 'ChatBloc');
emit(ChatError(message: 'No provider configured. Please add a provider in settings.'));
return;
}

final providers = providersResult.getOrElse(() => []);
if (providers.isEmpty) {
_logger.error('No providers available', tag: 'ChatBloc');
emit(ChatError(message: 'No provider configured. Please add a provider in settings.'));
return;
}

final defaultProvider = providers.firstWhere(
(p) => p.isDefault,
orElse: () => providers.first,
);

_logger.info('Creating new session with provider: ${defaultProvider.name}', tag: 'ChatBloc');
final newSession = Session(
id: event.sessionId,
name: 'New Chat',
sshConfigId: 'default',
providerId: defaultProvider.id,
workingDirectory: '/home',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
messages: [],
);

emit(ChatLoaded(
session: newSession,
messages: [],
));
}

List<Tool> _getAvailableTools() {
Expand Down
Loading