Skip to content

Commit bf8e639

Browse files
fix(windows): stop console window flashes during scheduled scans
Build the agent as GUI-subsystem (-ldflags "-H windowsgui") so Task Scheduler can't allocate a console for it under /ru INTERACTIVE. AttachConsole(ATTACH_PARENT_PROCESS) at the top of main() restores os.Std* for interactive `agent.exe` runs from cmd/PowerShell; under Task Scheduler the parent has no console and this no-ops, preserving silent operation. Known ergonomic trade-offs for interactive use (documented in console_windows.go): the shell prompt returns immediately while output streams async, stdout pipes do not work (reattached handle is a console not a pipe), and $LASTEXITCODE is unreliable without Start-Process -Wait. Companion changes (independent of subsystem choice): - internal/winproc.HideWindow applied at every subprocess site (executor.Run, executor.RunInDir, config_windows icacls, aiagents/enrich/npm registry probes). Subprocess flashes are orthogonal to the agent's subsystem. - internal/schtasks: dropped the `cmd /c "... >>log 2>>err"` wrapper. Task action now invokes the binary directly with --install-dir. stepHome derived from logDir (ProgramData fallback) so it's never empty. - internal/detector/ide: VS Code-family resolveWindowsVersionFromDir reads resources\app\package.json before invoking bin\*.cmd. Renamed readProductInfoVersion -> readJSONVersion since the helper now serves both shapes. - internal/progress/filelog: teeLoop writes to file before origErr. io.MultiWriter aborts on the first error, so a broken origErr (GUI-subsystem agent with no parent console) used to drop the file write entirely, leaving agent.error.log at 0 bytes. Test TestStartWritesFileEvenWhenOrigStderrIsBroken locks in the fix. Tests: - internal/winproc: nil-safety, flag merge, idempotence on Windows. - internal/schtasks: TaskCommandFormat regression guard. - internal/detector: package.json fast-path precedence. - internal/progress/filelog: broken-origErr regression guard. Build: - Makefile build-windows / build-windows-arm64: -H windowsgui. - .goreleaser.yml: templated ldflag adds -H windowsgui for windows only. .gitignore: explicit paths for the compiled binary at both the repo root and the same-named source dir under cmd/. The previous `**/stepsecurity-dev-machine-guard` pattern matched the source directory too and silently dropped new files added inside it. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
1 parent 0e007ef commit bf8e639

19 files changed

Lines changed: 336 additions & 56 deletions

File tree

.gitignore

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@
2020
!docs/**/*.html
2121
!images/**/*.html
2222

23-
# Go build artifacts — never commit compiled binaries
24-
**/stepsecurity-dev-machine-guard
23+
# Go build artifacts — never commit compiled binaries.
24+
# Two explicit paths: the intended root output, and the stray output
25+
# `go build` produces when run from inside the source dir.
26+
/stepsecurity-dev-machine-guard
27+
cmd/stepsecurity-dev-machine-guard/stepsecurity-dev-machine-guard
2528
*.exe
2629
dist/
2730
stepsecurity-dev-machine-guard-linux
2831

2932
# Temporary files
3033
todo-remove/
34+
35+
# Agent runtime state — never tracked. Tests run from a subpkg can
36+
# drop config.json / agent.error.log / etc. here.
37+
**/.stepsecurity/

.goreleaser.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ builds:
2020
- -X github.com/step-security/dev-machine-guard/internal/buildinfo.GitCommit={{.FullCommit}}
2121
- -X github.com/step-security/dev-machine-guard/internal/buildinfo.ReleaseTag={{.Tag}}
2222
- -X github.com/step-security/dev-machine-guard/internal/buildinfo.ReleaseBranch={{.Branch}}
23+
# Windows-only: GUI subsystem suppresses Task Scheduler console flash.
24+
- '{{ if eq .Os "windows" }}-H windowsgui{{ end }}'
2325
env:
2426
- CGO_ENABLED=0
2527

Makefile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ LDFLAGS := -s -w \
1414
build:
1515
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/stepsecurity-dev-machine-guard
1616

17+
# -H windowsgui prevents Task Scheduler from allocating a console.
18+
# AttachParentConsole at startup restores stdio for interactive use.
1719
build-windows:
18-
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY).exe ./cmd/stepsecurity-dev-machine-guard
20+
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS) -H windowsgui" -o $(BINARY).exe ./cmd/stepsecurity-dev-machine-guard
1921

2022
build-windows-arm64:
21-
GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-arm64.exe ./cmd/stepsecurity-dev-machine-guard
23+
GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS) -H windowsgui" -o $(BINARY)-arm64.exe ./cmd/stepsecurity-dev-machine-guard
2224

2325
build-linux:
2426
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-linux ./cmd/stepsecurity-dev-machine-guard
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
//go:build !windows
2+
3+
package main
4+
5+
// AttachParentConsole is a no-op on non-Windows.
6+
func AttachParentConsole() {}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//go:build windows
2+
3+
package main
4+
5+
import (
6+
"os"
7+
"syscall"
8+
9+
"golang.org/x/sys/windows"
10+
)
11+
12+
// AttachParentConsole re-wires os.Std* to the parent's console when
13+
// one exists. The agent is GUI-subsystem (-H windowsgui) so Task
14+
// Scheduler launches don't allocate a console — the no-flash property.
15+
// The cost is no inherited stdio; this restores it for interactive
16+
// runs from cmd.exe / PowerShell. Under Task Scheduler the parent has
17+
// no console and this no-ops. Must run before any logging.
18+
//
19+
// Quirks for interactive use (also documented in README):
20+
// - Parent shell doesn't wait for GUI-subsystem children; output
21+
// streams async below the prompt. Use `Start-Process -Wait`.
22+
// - Pipes don't work — stdout is a console handle, not a pipe.
23+
// - $LASTEXITCODE / %ERRORLEVEL% unreliable without -Wait.
24+
func AttachParentConsole() {
25+
const ATTACH_PARENT_PROCESS uint32 = 0xFFFFFFFF
26+
27+
attach := windows.NewLazySystemDLL("kernel32.dll").NewProc("AttachConsole")
28+
if r1, _, _ := attach.Call(uintptr(ATTACH_PARENT_PROCESS)); r1 == 0 {
29+
return // no parent console; expected under Task Scheduler
30+
}
31+
32+
if h, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0); err == nil {
33+
os.Stdout = os.NewFile(uintptr(h), "/dev/stdout")
34+
}
35+
if h, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0); err == nil {
36+
os.Stderr = os.NewFile(uintptr(h), "/dev/stderr")
37+
}
38+
if h, err := syscall.Open("CONIN$", syscall.O_RDWR, 0); err == nil {
39+
os.Stdin = os.NewFile(uintptr(h), "/dev/stdin")
40+
}
41+
}

cmd/stepsecurity-dev-machine-guard/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ import (
3838
const hookReconcileTimeout = 30 * time.Second
3939

4040
func main() {
41+
// Windows GUI-subsystem build needs this to restore stdio for
42+
// interactive runs. No-op under Task Scheduler / non-Windows.
43+
// Must run before any logging.
44+
AttachParentConsole()
45+
4146
// Hook hot path. Agents invoke `_hook` on every event and any non-zero
4247
// exit is treated as a hook failure / block — so we MUST exit 0 even on
4348
// malformed args. Skip every line below this branch (CLI parsing,

internal/aiagents/enrich/npm/registry.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"os/exec"
88
"path/filepath"
99
"strings"
10+
11+
"github.com/step-security/dev-machine-guard/internal/winproc"
1012
)
1113

1214
// Source identifies which command produced the resolution. Empty when
@@ -25,6 +27,7 @@ var runFunc = execRun
2527

2628
func execRun(ctx context.Context, cwd, bin string, args ...string) (string, error) {
2729
cmd := exec.CommandContext(ctx, bin, args...)
30+
winproc.HideWindow(cmd)
2831
if cwd != "" {
2932
cmd.Dir = cwd
3033
}

internal/config/config_windows.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"os/exec"
99

10+
"github.com/step-security/dev-machine-guard/internal/winproc"
1011
"golang.org/x/sys/windows"
1112
)
1213

@@ -48,7 +49,9 @@ func hardenMachineConfigACL(path string) error {
4849
"/grant:r", "*S-1-5-32-545:R", // BUILTIN\Users = Read
4950
"/Q",
5051
}
51-
output, err := exec.Command("icacls", args...).CombinedOutput()
52+
cmd := exec.Command("icacls", args...)
53+
winproc.HideWindow(cmd)
54+
output, err := cmd.CombinedOutput()
5255
if err != nil {
5356
fmt.Fprintf(os.Stderr,
5457
"warning: icacls hardening of %q failed: %v\nicacls output:\n%s\n",

internal/detector/ide.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ func (d *IDEDetector) detectDarwin(ctx context.Context, spec ideSpec) (model.IDE
238238

239239
// Fallback: product-info.json (JetBrains IDEs)
240240
if version == "unknown" {
241-
version = readProductInfoVersion(d.exec, filepath.Join(spec.AppPath, "Contents", "Resources", "product-info.json"))
241+
version = readJSONVersion(d.exec, filepath.Join(spec.AppPath, "Contents", "Resources", "product-info.json"))
242242
}
243243

244244
// Fallback: Info.plist
@@ -328,7 +328,7 @@ func (d *IDEDetector) resolveLinuxVersion(ctx context.Context, spec ideSpec, ins
328328
}
329329

330330
// product-info.json at the root of the install dir (JetBrains, some Electron apps)
331-
if v := readProductInfoVersion(d.exec, filepath.Join(installDir, "product-info.json")); v != "unknown" {
331+
if v := readJSONVersion(d.exec, filepath.Join(installDir, "product-info.json")); v != "unknown" {
332332
return v
333333
}
334334

@@ -378,9 +378,14 @@ func (d *IDEDetector) resolveWindowsVersion(ctx context.Context, spec ideSpec, i
378378
return version
379379
}
380380

381-
// resolveWindowsVersionFromDir tries binary, product-info.json, and .eclipseproduct.
382-
// Does NOT query the registry (caller handles that to avoid redundant queries).
381+
// resolveWindowsVersionFromDir tries package.json, the binary,
382+
// product-info.json, .eclipseproduct (in order). package.json first
383+
// avoids the bin\*.cmd shell-out for VS Code-family Electron IDEs.
383384
func (d *IDEDetector) resolveWindowsVersionFromDir(ctx context.Context, spec ideSpec, installDir string) string {
385+
if v := readJSONVersion(d.exec, filepath.Join(installDir, "resources", "app", "package.json")); v != "unknown" {
386+
return v
387+
}
388+
384389
version := "unknown"
385390

386391
if spec.WinBinary != "" && spec.VersionFlag != "" {
@@ -391,7 +396,7 @@ func (d *IDEDetector) resolveWindowsVersionFromDir(ctx context.Context, spec ide
391396
}
392397

393398
if version == "unknown" {
394-
version = readProductInfoVersion(d.exec, filepath.Join(installDir, "product-info.json"))
399+
version = readJSONVersion(d.exec, filepath.Join(installDir, "product-info.json"))
395400
}
396401

397402
if version == "unknown" {
@@ -487,9 +492,10 @@ func runVersionCmd(ctx context.Context, exec executor.Executor, binary, flag str
487492
return "unknown"
488493
}
489494

490-
// readProductInfoVersion reads the "version" field from a JetBrains product-info.json file.
491-
// Returns "unknown" if the file does not exist or cannot be parsed.
492-
func readProductInfoVersion(exec executor.Executor, filePath string) string {
495+
// readJSONVersion reads top-level "version" from a JSON file (used
496+
// for JetBrains product-info.json and VS Code-family package.json).
497+
// Returns "unknown" if the file is missing or unparseable.
498+
func readJSONVersion(exec executor.Executor, filePath string) string {
493499
data, err := exec.ReadFile(filePath)
494500
if err != nil {
495501
return "unknown"

internal/detector/ide_test.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,33 @@ func TestIDEDetector_Windows_FindsEclipse_UserProfile_Glob(t *testing.T) {
382382
}
383383
}
384384

385+
// Version must come from package.json without shelling out to
386+
// bin\code.cmd. No command is mocked for the binary; falling through
387+
// fails the test.
388+
func TestIDEDetector_Windows_VSCode_PackageJSONFastPath(t *testing.T) {
389+
mock := executor.NewMock()
390+
mock.SetGOOS("windows")
391+
mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`)
392+
mock.SetEnv("PROGRAMFILES", `C:\Program Files`)
393+
394+
vscodePath := `C:\Program Files\Microsoft VS Code`
395+
mock.SetDir(vscodePath)
396+
mock.SetFile(vscodePath+`/bin\code.cmd`, []byte{})
397+
mock.SetFile(vscodePath+`/resources/app/package.json`,
398+
[]byte(`{"name":"Code","version":"1.115.0"}`))
399+
400+
det := NewIDEDetector(mock)
401+
results := det.Detect(context.Background())
402+
403+
found := findIDE(results, "vscode")
404+
if found == nil {
405+
t.Fatal("expected VS Code to be detected")
406+
}
407+
if found.Version != "1.115.0" {
408+
t.Errorf("version should come from package.json (1.115.0), got %s", found.Version)
409+
}
410+
}
411+
385412
func TestIDEDetector_Windows_VSCode_StillWorks(t *testing.T) {
386413
mock := executor.NewMock()
387414
mock.SetGOOS("windows")
@@ -441,15 +468,15 @@ func TestReadProductInfoVersion(t *testing.T) {
441468
mock.SetFile("/test/product-info.json",
442469
[]byte(`{"name":"GoLand","version":"2025.1.3","buildNumber":"251.26927.50"}`))
443470

444-
v := readProductInfoVersion(mock, "/test/product-info.json")
471+
v := readJSONVersion(mock, "/test/product-info.json")
445472
if v != "2025.1.3" {
446473
t.Errorf("expected 2025.1.3, got %s", v)
447474
}
448475
}
449476

450477
func TestReadProductInfoVersion_MissingFile(t *testing.T) {
451478
mock := executor.NewMock()
452-
v := readProductInfoVersion(mock, "/nonexistent/product-info.json")
479+
v := readJSONVersion(mock, "/nonexistent/product-info.json")
453480
if v != "unknown" {
454481
t.Errorf("expected unknown, got %s", v)
455482
}
@@ -459,7 +486,7 @@ func TestReadProductInfoVersion_InvalidJSON(t *testing.T) {
459486
mock := executor.NewMock()
460487
mock.SetFile("/test/product-info.json", []byte(`not json`))
461488

462-
v := readProductInfoVersion(mock, "/test/product-info.json")
489+
v := readJSONVersion(mock, "/test/product-info.json")
463490
if v != "unknown" {
464491
t.Errorf("expected unknown, got %s", v)
465492
}

0 commit comments

Comments
 (0)