From b4123463d3e6d61d81cd6400046b12ee1deb2a84 Mon Sep 17 00:00:00 2001 From: Thando Mini Date: Thu, 11 Jun 2026 13:18:23 +0200 Subject: [PATCH] fix(criteria,engine): route shell commands through shellexec, not direct sh -c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit criteria/db.go (evaluateMigrationSucceeds) and engine/investigator.go (runCommand) both shelled out via exec.CommandContext(ctx, "sh", "-c", cmd). On native Windows `sh` is not on PATH, so both paths silently broke even for read-only commands the doctor / status / report commands indirectly invoke. shellexec already exists for exactly this — it picks sh on Unix and cmd.exe on Windows, honouring the NXD_SHELL override. Replace both with shellexec.CommandContext(ctx, cmd). No behaviour change on Unix; Windows pipelines now use the configured shell. These were the last two `exec.Command*("sh", ...)` direct invocations outside internal/shellexec itself. Future regressions can be caught by the existing grep-style audit. Surfaced by the 2026-06-11 architecture audit (ARCH-H5). --- internal/criteria/db.go | 9 +++++++-- internal/engine/investigator.go | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/criteria/db.go b/internal/criteria/db.go index 96f5b0a..d665114 100644 --- a/internal/criteria/db.go +++ b/internal/criteria/db.go @@ -5,12 +5,13 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" "strings" "time" "github.com/jackc/pgx/v5" + + "github.com/tzone85/nexus-dispatch/internal/shellexec" ) // readDatabaseURL returns the DATABASE_URL value from .nxd-db/connect.env in workDir, @@ -43,7 +44,11 @@ func evaluateMigrationSucceeds(ctx context.Context, workDir string, c Criterion) return Result{Criterion: c, Passed: false, Message: "no .nxd-db/connect.env in worktree — devdb not provisioned for this story"} } - cmd := exec.CommandContext(ctx, "sh", "-c", c.Command) + // shellexec.CommandContext picks the right shell per OS (sh on Unix, + // cmd.exe on Windows; NXD_SHELL overrides). Direct exec.Command("sh", ...) + // would silently fail on native Windows even for read-only criteria + // evaluation against an externally-provisioned DB. + cmd := shellexec.CommandContext(ctx, c.Command) cmd.Dir = workDir cmd.Env = append(os.Environ(), "DATABASE_URL="+dsn) out, err := cmd.CombinedOutput() diff --git a/internal/engine/investigator.go b/internal/engine/investigator.go index acf9f66..ed6e3d6 100644 --- a/internal/engine/investigator.go +++ b/internal/engine/investigator.go @@ -5,12 +5,12 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "strings" "github.com/tzone85/nexus-dispatch/internal/agent" "github.com/tzone85/nexus-dispatch/internal/llm" + "github.com/tzone85/nexus-dispatch/internal/shellexec" ) const ( @@ -266,7 +266,12 @@ func (inv *Investigator) handleRunCommand(ctx context.Context, repoPath string, return fmt.Sprintf("error: command not in allowlist: %s", params.Command) } - cmd := exec.CommandContext(ctx, "sh", "-c", params.Command) + // Investigator commands come from the operator-configured allowlist + // (see isCommandAllowed above). Route through shellexec so Windows + // pipelines (cmd.exe) and NXD_SHELL overrides work the same as the + // gemma runtime's run_command tool — direct `sh -c` would silently + // fail on native Windows. + cmd := shellexec.CommandContext(ctx, params.Command) cmd.Dir = repoPath output, err := cmd.CombinedOutput()