From ece3271c6daf6113b4b5f48bfba2e2b1b18fbac4 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sat, 14 Feb 2026 10:21:55 +0100 Subject: [PATCH 1/3] Fix race condition in isFirstRun using atomic file creation Use os.OpenFile with os.O_EXCL|os.O_CREATE to atomically create the marker file. This eliminates the check-then-act race condition where multiple concurrent cagent processes could all see the marker file as missing and all proceed with first-run initialization. Fixes #1709 Assisted-By: cagent --- cmd/root/root.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/cmd/root/root.go b/cmd/root/root.go index 9f2993396..c1f2ee1b2 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -204,25 +204,26 @@ func (e RuntimeError) Unwrap() error { return e.Err } -// isFirstRun checks if this is the first time cagent is being run -// It creates a marker file in the user's config directory +// isFirstRun checks if this is the first time cagent is being run. +// It atomically creates a marker file in the user's config directory +// using os.O_EXCL to avoid a race condition when multiple processes +// start concurrently. func isFirstRun() bool { configDir := paths.GetConfigDir() markerFile := filepath.Join(configDir, ".cagent_first_run") - // Check if marker file exists - if _, err := os.Stat(markerFile); err == nil { - return false // File exists, not first run - } - - // Create marker file to indicate this run has happened + // Ensure the config directory exists before trying to create the marker file if err := os.MkdirAll(configDir, 0o755); err != nil { - return false // Can't create config dir, assume not first run + slog.Warn("Failed to create config directory for first run marker", "error", err) + return false } - if err := os.WriteFile(markerFile, []byte(""), 0o644); err != nil { - return false // Can't create marker file, assume not first run + // Atomically create the marker file. If it already exists, OpenFile returns an error. + f, err := os.OpenFile(markerFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return false // File already exists or other error, not first run } + f.Close() - return true // Successfully created marker, this is first run + return true } From 92d8b181d514df54bac8afee68881750140d8358 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sat, 14 Feb 2026 10:22:48 +0100 Subject: [PATCH 2/3] Validate input and handle errors in RunBangCommand Add input validation to reject empty commands and properly handle the error from exec.CommandContext instead of silently discarding it. When the command fails and produces no output, the error message is shown to the user. Fixes #1716 Assisted-By: cagent --- pkg/app/app.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index c49eed8b7..12893bea5 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -446,8 +446,18 @@ func (a *App) RunWithMessage(ctx context.Context, cancel context.CancelFunc, msg } func (a *App) RunBangCommand(ctx context.Context, command string) { - out, _ := exec.CommandContext(ctx, "/bin/sh", "-c", command).CombinedOutput() - a.events <- runtime.ShellOutput("$ " + command + "\n" + string(out)) + command = strings.TrimSpace(command) + if command == "" { + a.events <- runtime.ShellOutput("Error: empty command") + return + } + + out, err := exec.CommandContext(ctx, "/bin/sh", "-c", command).CombinedOutput() + output := "$ " + command + "\n" + string(out) + if err != nil && len(out) == 0 { + output = "$ " + command + "\nError: " + err.Error() + } + a.events <- runtime.ShellOutput(output) } func (a *App) Subscribe(ctx context.Context, program *tea.Program) { From 62bafde4c26eadaee49f22284c68032ab742070d Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sat, 14 Feb 2026 10:23:37 +0100 Subject: [PATCH 3/3] Add input validation and audit logging to shell tool Validate that the command is not empty before execution, and add debug-level logging for native (non-sandboxed) shell command execution to improve auditability. Fixes #1717 Assisted-By: cagent --- pkg/tools/builtin/shell.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/tools/builtin/shell.go b/pkg/tools/builtin/shell.go index 91978074d..83c1d2300 100644 --- a/pkg/tools/builtin/shell.go +++ b/pkg/tools/builtin/shell.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "runtime" @@ -137,6 +138,10 @@ func statusToString(status int32) string { } func (h *shellHandler) RunShell(ctx context.Context, params RunShellArgs) (*tools.ToolCallResult, error) { + if strings.TrimSpace(params.Cmd) == "" { + return tools.ResultError("Error: empty command"), nil + } + timeout := h.timeout if params.Timeout > 0 { timeout = time.Duration(params.Timeout) * time.Second @@ -152,6 +157,8 @@ func (h *shellHandler) RunShell(ctx context.Context, params RunShellArgs) (*tool return h.sandbox.runCommand(timeoutCtx, ctx, params.Cmd, cwd, timeout), nil } + slog.Debug("Executing native shell command", "command", params.Cmd, "cwd", cwd) + return h.runNativeCommand(timeoutCtx, ctx, params.Cmd, cwd, timeout), nil }