Skip to content

Commit 63a058d

Browse files
committed
feat: refactor process argument handling to use ArgumentList for improved security and clarity across multiple commands
1 parent 3b34207 commit 63a058d

15 files changed

Lines changed: 94 additions & 69 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ FurLab settings db-servers set-password <name>
259259
- **No business logic in commands**: Commands parse input via `Settings`, then call the service method. Never put logic inside the `Execute` or `ExecuteAsync` method.
260260
- **One file per type**: Each `.cs` file should contain only one class, record, struct, or enum.
261261
- **Result pattern**: Return `OperationResult` / `OperationResult<T>` records — use `SuccessResult(...)` / `FailureResult(...)` factory methods
262-
- **External processes**: Always `UseShellExecute = false`, capture stdout/stderr via redirect — never shell out to `cmd.exe` or `powershell.exe`
262+
- **External processes**: Always `UseShellExecute = false`, capture stdout/stderr via redirect — never shell out to `cmd.exe` or `powershell.exe`. Pass arguments as `List<string>` (via `ProcessExecutionOptions.Arguments` or `ProcessStartInfo.ArgumentList`) — never as a single concatenated string and never via string interpolation (`$"..."`) to avoid command injection.
263263
- **Test naming**: `<MethodName>_<StateUnderTest>_<ExpectedBehavior>`
264264
- **Unit test attributes**: Use `[TestMethod(DisplayName = "<short description>")]` and `[Description("<detailed description>")]` on all test methods
265265
- **XML doc comments**: Required on all public members (enforced by `GenerateDocumentationFile true`)
@@ -285,14 +285,6 @@ O projeto usa o workflow **OpenSpec** para gerenciar features e mudanças:
285285
- **`openspec/changes/`** — mudanças em andamento
286286
- **`openspec/changes/archive/`** — mudanças implementadas e arquivadas
287287

288-
## Recent Changes
289-
290-
- migrate-cli-to-spectre-console-cli (2026-04-18): Migrated from `System.CommandLine` to `Spectre.Console.Cli` for better TUI support and cleaner command structure.
291-
- secure-credential-storage (2026-04-13): Implemented `ICredentialService` for encrypted storage of database passwords.
292-
- query-run-multi-server (2026-04-13): `query run` now supports executing scripts across multiple servers defined in settings.
293-
- docker-postgres (2026-04-05): Added `docker postgres` command.
294-
- opencode-default-model (2026-04-07): Define default model for OpenCode.
295-
296288
## Guard Rails (Agent Safety Protocol)
297289

298290
> **Mandatory Rule:** This repository enforces a strict **Manual Confirmation Policy** for all state-changing Git operations.

FurLab.CLI/Commands/Claude/Install/ClaudeInstallCommand.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ namespace FurLab.CLI.Commands.Claude.Install;
1010
/// </summary>
1111
public sealed class ClaudeInstallCommand : AsyncCommand<ClaudeInstallSettings>
1212
{
13-
private const string WingetInstallArguments = "install --id Anthropic.ClaudeCode -e --accept-package-agreements --accept-source-agreements";
13+
private static readonly List<string> WingetInstallArguments =
14+
[
15+
"install", "--id", "Anthropic.ClaudeCode", "-e", "--accept-package-agreements", "--accept-source-agreements"
16+
];
1417

1518
/// <inheritdoc/>
1619
protected override Task<int> ExecuteAsync(CommandContext context, ClaudeInstallSettings settings, CancellationToken cancellation)
@@ -29,20 +32,23 @@ protected override Task<int> ExecuteAsync(CommandContext context, ClaudeInstallS
2932
return Task.FromResult(0);
3033
}
3134

32-
private static (int ExitCode, string Output, string Error) RunProcess(string fileName, string arguments)
35+
private static (int ExitCode, string Output, string Error) RunProcess(string fileName, List<string> arguments)
3336
{
3437
try
3538
{
3639
using var process = new Process();
3740
process.StartInfo = new ProcessStartInfo
3841
{
3942
FileName = fileName,
40-
Arguments = arguments,
4143
RedirectStandardOutput = true,
4244
RedirectStandardError = true,
4345
UseShellExecute = false,
4446
CreateNoWindow = true
4547
};
48+
foreach (var arg in arguments)
49+
{
50+
process.StartInfo.ArgumentList.Add(arg);
51+
}
4652

4753
process.Start();
4854
var output = process.StandardOutput.ReadToEnd();

FurLab.CLI/Commands/Claude/Settings/McpDatabase/ClaudeSettingsMcpDatabaseCommand.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ namespace FurLab.CLI.Commands.Claude.Settings.McpDatabase;
1010
/// </summary>
1111
public sealed class ClaudeSettingsMcpDatabaseCommand : AsyncCommand<ClaudeSettingsMcpDatabaseSettings>
1212
{
13-
private const string McpDatabaseArguments = "mcp add --transport sse toolbox http://127.0.0.1:5000/mcp/sse --scope user";
13+
private static readonly List<string> McpDatabaseArguments =
14+
[
15+
"mcp", "add", "--transport", "sse", "toolbox", "http://127.0.0.1:5000/mcp/sse", "--scope", "user"
16+
];
1417

1518
/// <inheritdoc/>
1619
protected override Task<int> ExecuteAsync(CommandContext context, ClaudeSettingsMcpDatabaseSettings settings, CancellationToken cancellation)
@@ -19,20 +22,23 @@ protected override Task<int> ExecuteAsync(CommandContext context, ClaudeSettings
1922
return Task.FromResult(result.ExitCode);
2023
}
2124

22-
private static (int ExitCode, string Output, string Error) RunProcess(string fileName, string arguments)
25+
private static (int ExitCode, string Output, string Error) RunProcess(string fileName, List<string> arguments)
2326
{
2427
try
2528
{
2629
using var process = new Process();
2730
process.StartInfo = new ProcessStartInfo
2831
{
2932
FileName = fileName,
30-
Arguments = arguments,
3133
RedirectStandardOutput = true,
3234
RedirectStandardError = true,
3335
UseShellExecute = false,
3436
CreateNoWindow = true
3537
};
38+
foreach (var arg in arguments)
39+
{
40+
process.StartInfo.ArgumentList.Add(arg);
41+
}
3642

3743
process.Start();
3844
var output = process.StandardOutput.ReadToEnd();

FurLab.CLI/Commands/Database/Backup/DatabaseBackupCommand.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ private void BackupSingleDatabase(DatabaseBackupConfig config, DatabaseBackupSet
181181
throw new PostgresBinaryNotFoundException(FurLabConstants.PgDumpExecutable);
182182
}
183183

184-
var argumentList = BuildPgDumpArgumentList(config, settings, outputPath ?? string.Empty);
184+
var arguments = BuildPgDumpArguments(config, settings, outputPath ?? string.Empty);
185185

186186
var startInfo = new ProcessStartInfo
187187
{
@@ -191,7 +191,7 @@ private void BackupSingleDatabase(DatabaseBackupConfig config, DatabaseBackupSet
191191
UseShellExecute = false,
192192
CreateNoWindow = true
193193
};
194-
foreach (var arg in argumentList)
194+
foreach (var arg in arguments)
195195
{
196196
startInfo.ArgumentList.Add(arg);
197197
}
@@ -327,7 +327,7 @@ private void BackupSingleDatabaseInternal(DatabaseBackupConfig config, DatabaseB
327327
throw new PostgresBinaryNotFoundException(FurLabConstants.PgDumpExecutable);
328328
}
329329

330-
var argumentList = BuildPgDumpArgumentList(config, settings, config.OutputPath ?? string.Empty);
330+
var arguments = BuildPgDumpArguments(config, settings, config.OutputPath ?? string.Empty);
331331

332332
var startInfo = new ProcessStartInfo
333333
{
@@ -337,7 +337,7 @@ private void BackupSingleDatabaseInternal(DatabaseBackupConfig config, DatabaseB
337337
UseShellExecute = false,
338338
CreateNoWindow = true
339339
};
340-
foreach (var arg in argumentList)
340+
foreach (var arg in arguments)
341341
{
342342
startInfo.ArgumentList.Add(arg);
343343
}
@@ -373,7 +373,7 @@ private void BackupSingleDatabaseInternal(DatabaseBackupConfig config, DatabaseB
373373
}
374374
}
375375

376-
private List<string> BuildPgDumpArgumentList(DatabaseBackupConfig config, DatabaseBackupSettings settings, string outputPath)
376+
private List<string> BuildPgDumpArguments(DatabaseBackupConfig config, DatabaseBackupSettings settings, string outputPath)
377377
{
378378
var format = settings.Format ?? "c";
379379
var arguments = new List<string>

FurLab.CLI/Commands/Database/Restore/DatabaseRestoreCommand.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ private void RestoreSingleDatabase(DatabaseRestoreConfig config, DatabaseRestore
252252

253253
CreateDatabaseIfNeeded(config.Host, config.Port, config.Username ?? string.Empty, password, config.DatabaseName);
254254

255-
var argumentList = BuildPgRestoreArgumentList(config, settings, fullPath);
255+
var arguments = BuildPgRestoreArguments(config, settings, fullPath);
256256

257257
var startInfo = new ProcessStartInfo
258258
{
@@ -262,7 +262,7 @@ private void RestoreSingleDatabase(DatabaseRestoreConfig config, DatabaseRestore
262262
UseShellExecute = false,
263263
CreateNoWindow = true
264264
};
265-
foreach (var arg in argumentList)
265+
foreach (var arg in arguments)
266266
{
267267
startInfo.ArgumentList.Add(arg);
268268
}
@@ -410,7 +410,7 @@ private void RestoreSingleDatabaseInternal(DatabaseRestoreConfig config, Databas
410410

411411
CreateDatabaseIfNeeded(config.Host, config.Port, config.Username ?? string.Empty, config.Password ?? string.Empty, config.DatabaseName);
412412

413-
var argumentList = BuildPgRestoreArgumentList(config, settings, config.InputFile ?? string.Empty);
413+
var arguments = BuildPgRestoreArguments(config, settings, config.InputFile ?? string.Empty);
414414

415415
var startInfo = new ProcessStartInfo
416416
{
@@ -420,7 +420,7 @@ private void RestoreSingleDatabaseInternal(DatabaseRestoreConfig config, Databas
420420
UseShellExecute = false,
421421
CreateNoWindow = true
422422
};
423-
foreach (var arg in argumentList)
423+
foreach (var arg in arguments)
424424
{
425425
startInfo.ArgumentList.Add(arg);
426426
}
@@ -578,7 +578,7 @@ private void CreateDatabaseIfNeeded(string host, string port, string username, s
578578
/// <param name="settings">The command settings.</param>
579579
/// <param name="filePath">The path to the dump file.</param>
580580
/// <returns>The formatted argument string for pg_restore.</returns>
581-
private List<string> BuildPgRestoreArgumentList(DatabaseRestoreConfig config, DatabaseRestoreSettings settings, string filePath)
581+
private List<string> BuildPgRestoreArguments(DatabaseRestoreConfig config, DatabaseRestoreSettings settings, string filePath)
582582
{
583583
var arguments = new List<string>
584584
{

FurLab.CLI/Commands/OpenCode/Settings/DefaultModel/OpenCodeSettingsDefaultModelCommand.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,25 +152,34 @@ public static IReadOnlyList<string> GetAvailableModels()
152152
try
153153
{
154154
using var process = new Process();
155-
process.StartInfo = executable.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)
156-
? new ProcessStartInfo
155+
if (executable.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
156+
{
157+
process.StartInfo = new ProcessStartInfo
157158
{
158159
FileName = "pwsh.exe",
159-
Arguments = $"-NonInteractive -NoProfile -File \"{executable}\" models",
160160
RedirectStandardOutput = true,
161161
RedirectStandardError = true,
162162
UseShellExecute = false,
163163
CreateNoWindow = true
164-
}
165-
: new ProcessStartInfo
164+
};
165+
process.StartInfo.ArgumentList.Add("-NonInteractive");
166+
process.StartInfo.ArgumentList.Add("-NoProfile");
167+
process.StartInfo.ArgumentList.Add("-File");
168+
process.StartInfo.ArgumentList.Add(executable);
169+
process.StartInfo.ArgumentList.Add("models");
170+
}
171+
else
172+
{
173+
process.StartInfo = new ProcessStartInfo
166174
{
167175
FileName = executable,
168-
Arguments = "models",
169176
RedirectStandardOutput = true,
170177
RedirectStandardError = true,
171178
UseShellExecute = false,
172179
CreateNoWindow = true
173180
};
181+
process.StartInfo.ArgumentList.Add("models");
182+
}
174183

175184
process.Start();
176185
var output = process.StandardOutput.ReadToEnd();

FurLab.CLI/Commands/WindowsFeatures/Export/WindowsFeaturesExportCommand.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ private static List<string> GetEnabledFeatures()
4343
StartInfo = new ProcessStartInfo
4444
{
4545
FileName = "dism.exe",
46-
Arguments = "/online /get-features /format:list",
4746
UseShellExecute = false,
4847
RedirectStandardOutput = true,
4948
RedirectStandardError = true,
5049
CreateNoWindow = true
5150
}
5251
};
52+
process.StartInfo.ArgumentList.Add("/online");
53+
process.StartInfo.ArgumentList.Add("/get-features");
54+
process.StartInfo.ArgumentList.Add("/format:list");
5355

5456
process.Start();
5557
var output = process.StandardOutput.ReadToEnd();

FurLab.CLI/Commands/WindowsFeatures/Import/WindowsFeaturesImportCommand.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ protected override Task<int> ExecuteAsync(CommandContext context, WindowsFeature
3535
foreach (var feature in exportData.Features)
3636
{
3737
Console.Write($"Enabling {feature}... ");
38-
var result = RunDismCommand($"/online /enable-feature /featurename:{feature} /All");
38+
var result = RunDismCommand(["/online", "/enable-feature", $"/featurename:{feature}", "/All"]);
3939
if (result == 0)
4040
{
4141
Console.WriteLine("OK");
@@ -57,20 +57,23 @@ protected override Task<int> ExecuteAsync(CommandContext context, WindowsFeature
5757
return Task.FromResult(failCount > 0 ? 1 : 0);
5858
}
5959

60-
private static int RunDismCommand(string arguments)
60+
private static int RunDismCommand(List<string> arguments)
6161
{
6262
var process = new Process
6363
{
6464
StartInfo = new ProcessStartInfo
6565
{
6666
FileName = "dism.exe",
67-
Arguments = arguments,
6867
UseShellExecute = true,
6968
RedirectStandardOutput = false,
7069
RedirectStandardError = false,
7170
CreateNoWindow = false
7271
}
7372
};
73+
foreach (var arg in arguments)
74+
{
75+
process.StartInfo.ArgumentList.Add(arg);
76+
}
7477

7578
process.Start();
7679
process.WaitForExit();

FurLab.CLI/Commands/WindowsFeatures/List/WindowsFeaturesListCommand.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ protected override Task<int> ExecuteAsync(CommandContext context, WindowsFeature
1919
StartInfo = new ProcessStartInfo
2020
{
2121
FileName = "dism.exe",
22-
Arguments = "/online /get-features /format:table",
2322
UseShellExecute = false,
2423
RedirectStandardOutput = true,
2524
RedirectStandardError = true,
2625
CreateNoWindow = true
2726
}
2827
};
28+
process.StartInfo.ArgumentList.Add("/online");
29+
process.StartInfo.ArgumentList.Add("/get-features");
30+
process.StartInfo.ArgumentList.Add("/format:table");
2931

3032
process.Start();
3133

FurLab.CLI/Commands/Winget/Backup/WingetBackupCommand.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,18 @@ protected override Task<int> ExecuteAsync(CommandContext context, WingetBackupSe
2323
StartInfo = new ProcessStartInfo
2424
{
2525
FileName = "winget",
26-
Arguments = $"export -o \"{backupPath}\" --source winget",
2726
UseShellExecute = false,
2827
RedirectStandardOutput = true,
2928
RedirectStandardError = true,
3029
CreateNoWindow = true,
3130
StandardOutputEncoding = Encoding.UTF8
3231
}
3332
};
33+
process.StartInfo.ArgumentList.Add("export");
34+
process.StartInfo.ArgumentList.Add("-o");
35+
process.StartInfo.ArgumentList.Add(backupPath);
36+
process.StartInfo.ArgumentList.Add("--source");
37+
process.StartInfo.ArgumentList.Add("winget");
3438

3539
process.Start();
3640

0 commit comments

Comments
 (0)