Skip to content
Draft
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
6 changes: 5 additions & 1 deletion lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/generated/l10n/l10n.dart';
import 'package:server_box/view/page/home.dart';
import 'package:server_box/view/widget/ai/ai_fab_overlay.dart';

part 'intro.dart';

Expand Down Expand Up @@ -108,7 +109,10 @@ class _MyAppState extends State<MyApp> {
return MaterialApp(
key: ValueKey(locale),
navigatorKey: AppNavigator.key,
builder: ResponsivePoints.builder,
builder: (context, child) {
final responsiveChild = ResponsivePoints.builder(context, child);
return AiFabOverlay(child: responsiveChild);
},
locale: locale,
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
supportedLocales: AppLocalizations.supportedLocales,
Expand Down
16 changes: 16 additions & 0 deletions lib/data/model/ai/ask_ai_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,27 @@ class AskAiCommand {
required this.command,
this.description = '',
this.toolName,
this.risk,
this.needsConfirmation,
this.why,
this.prechecks,
});

final String command;
final String description;
final String? toolName;

/// Optional risk hint returned by the model/tool, e.g. `low|medium|high`.
final String? risk;

/// Optional explicit confirmation requirement returned by the model/tool.
final bool? needsConfirmation;

/// Optional explanation for why this command is suggested.
final String? why;

/// Optional pre-check commands / steps.
final List<String>? prechecks;
}

@immutable
Expand Down
8 changes: 6 additions & 2 deletions lib/data/model/app/menu/container.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ enum ContainerMenu {
restart,
rm,
logs,
terminal
terminal,
askAi
//stats,
;

Expand All @@ -20,10 +21,11 @@ enum ContainerMenu {
rm,
logs,
terminal,
askAi,
//stats,
];
}
return [start, rm, logs];
return [start, rm, logs, askAi];
}

IconData get icon => switch (this) {
Expand All @@ -33,6 +35,7 @@ enum ContainerMenu {
ContainerMenu.rm => Icons.delete,
ContainerMenu.logs => Icons.logo_dev,
ContainerMenu.terminal => Icons.terminal,
ContainerMenu.askAi => Icons.smart_toy_outlined,
// DockerMenuType.stats => Icons.bar_chart,
};

Expand All @@ -43,6 +46,7 @@ enum ContainerMenu {
ContainerMenu.rm => libL10n.delete,
ContainerMenu.logs => libL10n.log,
ContainerMenu.terminal => l10n.terminal,
ContainerMenu.askAi => l10n.askAi,
// DockerMenuType.stats => s.stats,
};
}
64 changes: 64 additions & 0 deletions lib/data/provider/ai/ai_context.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:meta/meta.dart';

@immutable
class AiContextSnapshot {
const AiContextSnapshot({
required this.title,
required this.scenario,
required this.blocks,
this.spiId,
this.updatedAtMs,
});

final String title;
final String scenario;
final List<String> blocks;
final String? spiId;
final int? updatedAtMs;

AiContextSnapshot copyWith({
String? title,
String? scenario,
List<String>? blocks,
String? spiId,
int? updatedAtMs,
}) {
return AiContextSnapshot(
title: title ?? this.title,
scenario: scenario ?? this.scenario,
blocks: blocks ?? this.blocks,
spiId: spiId ?? this.spiId,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
);
}
}

final aiContextProvider = NotifierProvider<AiContextNotifier, AiContextSnapshot>(AiContextNotifier.new);

class AiContextNotifier extends Notifier<AiContextSnapshot> {
@override
AiContextSnapshot build() {
return const AiContextSnapshot(
title: 'Ask AI',
scenario: 'general',
blocks: [],
updatedAtMs: 0,
);
}

void setContext({
required String title,
required String scenario,
required List<String> blocks,
String? spiId,
}) {
state = AiContextSnapshot(
title: title,
scenario: scenario,
blocks: blocks,
spiId: spiId,
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
);
}
}
186 changes: 186 additions & 0 deletions lib/data/provider/ai/ai_safety.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import 'package:meta/meta.dart';
import 'package:server_box/data/model/server/server_private_info.dart';

@immutable
enum AiRedactionMode {
placeholder,
none,
}

@immutable
enum AiCommandRisk {
low,
medium,
high,
}

extension AiCommandRiskX on AiCommandRisk {
static AiCommandRisk? tryParse(Object? raw) {
if (raw is! String) return null;
final s = raw.trim().toLowerCase();
return switch (s) {
'low' => AiCommandRisk.low,
'medium' => AiCommandRisk.medium,
'high' => AiCommandRisk.high,
_ => null,
};
}

AiCommandRisk max(AiCommandRisk other) => index >= other.index ? this : other;
}

abstract final class AiSafety {
const AiSafety._();

static String redact(
String input, {
AiRedactionMode mode = AiRedactionMode.placeholder,
Spi? spi,
}) {
if (mode == AiRedactionMode.none) return input;
if (input.isEmpty) return input;

var out = input;

out = _redactPrivateKeyBlocks(out);
out = _redactBearerTokens(out);
out = _redactApiKeys(out);

if (spi != null) {
out = _redactSpiIdentity(out, spi);
}

return out;
}

static List<String> redactBlocks(
List<String> blocks, {
AiRedactionMode mode = AiRedactionMode.placeholder,
Spi? spi,
}) {
if (blocks.isEmpty) return const [];
return [
for (final b in blocks) redact(b, mode: mode, spi: spi),
];
}

static AiCommandRisk classifyRisk(String command) {
final raw = command.trim();
if (raw.isEmpty) return AiCommandRisk.low;

final s = raw.toLowerCase();

// High-risk destructive patterns.
if (_rxForkBomb.hasMatch(s)) return AiCommandRisk.high;
if (_rxMkfs.hasMatch(s)) return AiCommandRisk.high;
if (_rxDdToBlockDevice.hasMatch(s)) return AiCommandRisk.high;
if (_rxRmRf.hasMatch(s)) return AiCommandRisk.high;
if (_rxChmodChownRoot.hasMatch(s)) return AiCommandRisk.high;
if (_rxIptablesFlush.hasMatch(s) || _rxNftFlush.hasMatch(s)) return AiCommandRisk.high;
if (_rxDockerSystemPruneAll.hasMatch(s) || _rxPodmanSystemPruneAll.hasMatch(s)) return AiCommandRisk.high;

// Medium-risk operational patterns.
if (_rxRebootShutdown.hasMatch(s)) return AiCommandRisk.medium;
if (_rxSystemctlStopRestart.hasMatch(s)) return AiCommandRisk.medium;
if (_rxKill.hasMatch(s)) return AiCommandRisk.medium;
if (_rxDockerStopRm.hasMatch(s) || _rxPodmanStopRm.hasMatch(s)) return AiCommandRisk.medium;

return AiCommandRisk.low;
}

static String _redactPrivateKeyBlocks(String input) {
return input.replaceAllMapped(_rxPrivateKeyBlock, (_) => '<PRIVATE_KEY_BLOCK>');
}

static String _redactBearerTokens(String input) {
var out = input;
out = out.replaceAllMapped(
_rxAuthorizationBearer,
(m) => '${m.group(1)}Bearer <TOKEN>',
);
out = out.replaceAllMapped(
_rxBearerInline,
(m) => 'Bearer <TOKEN>',
);
return out;
}

static String _redactApiKeys(String input) {
// Keep it conservative; only match common patterns with clear prefixes.
var out = input;
out = out.replaceAllMapped(_rxOpenAiKey, (_) => '<API_KEY>');
out = out.replaceAllMapped(_rxAwsAccessKeyId, (_) => '<AWS_ACCESS_KEY_ID>');
return out;
}

static String _redactSpiIdentity(String input, Spi spi) {
var out = input;

final ip = spi.ip;
final user = spi.user;
final port = spi.port;

if (user.isNotEmpty && ip.isNotEmpty) {
out = out.replaceAll('$user@$ip:$port', '<USER_AT_HOST_PORT>');
out = out.replaceAll('$user@$ip', '<USER_AT_HOST>');
}

if (ip.isNotEmpty) {
out = out.replaceAll(ip, '<IP>');
}

if (user.isNotEmpty) {
out = out.replaceAll(user, '<USER>');
}

return out;
}
}

final _rxPrivateKeyBlock = RegExp(
r'-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----',
multiLine: true,
);

final _rxAuthorizationBearer = RegExp(
r'(authorization\s*:\s*)bearer\s+[^\s\n\r]+',
multiLine: true,
caseSensitive: false,
);

final _rxBearerInline = RegExp(
r'\bbearer\s+[^\s\n\r]+',
caseSensitive: false,
);

final _rxOpenAiKey = RegExp(r'\bsk-[A-Za-z0-9]{16,}\b');

final _rxAwsAccessKeyId = RegExp(r'\bAKIA[0-9A-Z]{16}\b');

final _rxForkBomb = RegExp(r':\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:');

final _rxMkfs = RegExp(r'\bmkfs(\.[a-z0-9_-]+)?\b');

final _rxDdToBlockDevice = RegExp(r'\bdd\b[^\n\r]*\bof\s*=\s*/dev/');

final _rxRmRf = RegExp(r'\brm\b[^\n\r]*\s-[a-z-]*r[a-z-]*f[a-z-]*\b');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Risk classification regex fails to detect rm -fr command variant

The _rxRmRf regex pattern in ai_safety.dart fails to detect the common rm -fr command variant (where -f comes before -r), allowing it to be classified as low risk instead of high risk.

Click to expand

Analysis

The regex pattern at line 166 is:

final _rxRmRf = RegExp(r'\brm\b[^\n\r]*\s-[a-z-]*r[a-z-]*f[a-z-]*\b');

This pattern requires r to appear before f in the flags ([a-z-]*r[a-z-]*f[a-z-]*).

Commands that match (high risk detected):

  • rm -rf /
  • rm -rfi /

Commands that DON'T match (incorrectly classified as low risk):

  • rm -fr /
  • rm -fR / ✗ (though case is lowered, -fr order still fails)

Impact

rm -fr is equally common and dangerous as rm -rf. Users could execute a destructive command suggested by AI without the appropriate high-risk countdown confirmation dialog, since AiSafety.classifyRisk('rm -fr /') returns AiCommandRisk.low instead of AiCommandRisk.high.

Recommendation

Modify the regex to match either flag order, e.g.:

final _rxRmRf = RegExp(r'\brm\b[^\n\r]*\s-[a-z-]*([rf][a-z-]*[rf])[a-z-]*\b');

Or use two patterns:

final _rxRmRf = RegExp(r'\brm\b[^\n\r]*\s-[a-z-]*(rf|fr)[a-z-]*\b');

Recommendation: Change the regex to detect both rm -rf and rm -fr variants: RegExp(r'\brm\b[^\n\r]*\s-[a-z-]*(rf|fr)[a-z-]*\b')

Open in Devin Review

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

_rxRmRf pattern misses rm -fr and similar orderings.

The pattern \s-[a-z-]*r[a-z-]*f[a-z-]*\b requires r to appear before f in the flags. Commands like rm -fr /, rm -f -r /, or rm --force -r / won't match. Consider matching both flags independently.

🔧 Suggested fix to match both flag orderings
-final _rxRmRf = RegExp(r'\brm\b[^\n\r]*\s-[a-z-]*r[a-z-]*f[a-z-]*\b');
+// Match rm with both -r/-R and -f/--force in any order
+final _rxRmRf = RegExp(
+  r'\brm\b[^\n\r]*(?=.*\s-[a-z-]*r)(?=.*\s-[a-z-]*f)',
+);

Or use two separate checks in classifyRisk:

// Check for rm with both recursive and force flags
if (_rxRmRecursive.hasMatch(s) && _rxRmForce.hasMatch(s)) return AiCommandRisk.high;
🤖 Prompt for AI Agents
In `@lib/data/provider/ai/ai_safety.dart` at line 166, The _rxRmRf regex only
matches when the 'r' flag appears before 'f' (e.g., "-rf"), so update detection
to handle any flag ordering by either creating two regexes (e.g., _rxRmRecursive
and _rxRmForce) that independently detect recursive and force flags and then
modify classifyRisk to treat a string as high risk when both patterns match, or
change _rxRmRf to a pattern that asserts presence of both flags in any order;
reference _rxRmRf and classifyRisk when applying the fix.


final _rxChmodChownRoot = RegExp(r'\b(chmod|chown)\b[^\n\r]*\s-\w*r\w*\b[^\n\r]*\s/\b');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current regex for detecting recursive chmod/chown on the root directory is a good start but doesn't handle the long-form --recursive flag. This could cause a high-risk command to be missed by the risk classifier. Adding an alternative for --recursive will make the detection more robust.

Suggested change
final _rxChmodChownRoot = RegExp(r'\b(chmod|chown)\b[^\n\r]*\s-\w*r\w*\b[^\n\r]*\s/\b');
final _rxChmodChownRoot = RegExp(r'\b(chmod|chown)\b[^\n\r]*\s(-\w*r\w*|--recursive)\b[^\n\r]*\s/\b');


final _rxIptablesFlush = RegExp(r'\biptables\b[^\n\r]*(\s-(f|x)\b|\s--flush\b)');

final _rxNftFlush = RegExp(r'\bnft\b[^\n\r]*\bflush\s+ruleset\b');

final _rxDockerSystemPruneAll = RegExp(r'\bdocker\b[^\n\r]*\bsystem\s+prune\b[^\n\r]*\s-a\b');

final _rxPodmanSystemPruneAll = RegExp(r'\bpodman\b[^\n\r]*\bsystem\s+prune\b[^\n\r]*\s-a\b');

final _rxRebootShutdown = RegExp(r'\b(reboot|poweroff|halt|shutdown)\b');

final _rxSystemctlStopRestart = RegExp(r'\bsystemctl\b[^\n\r]*\b(stop|restart)\b');

final _rxKill = RegExp(r'\b(kill|killall|pkill)\b');

final _rxDockerStopRm = RegExp(r'\bdocker\b[^\n\r]*\b(stop|rm)\b');

final _rxPodmanStopRm = RegExp(r'\bpodman\b[^\n\r]*\b(stop|rm)\b');
Loading