Skip to content
Open
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
11 changes: 9 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@
!docs/**/*.html
!images/**/*.html

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

# Temporary files
todo-remove/

# Agent runtime state — never tracked. Tests run from a subpkg can
# drop config.json / agent.error.log / etc. here.
**/.stepsecurity/
2 changes: 2 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ builds:
- -X github.com/step-security/dev-machine-guard/internal/buildinfo.GitCommit={{.FullCommit}}
- -X github.com/step-security/dev-machine-guard/internal/buildinfo.ReleaseTag={{.Tag}}
- -X github.com/step-security/dev-machine-guard/internal/buildinfo.ReleaseBranch={{.Branch}}
# Windows-only: GUI subsystem suppresses Task Scheduler console flash.
- '{{ if eq .Os "windows" }}-H windowsgui{{ end }}'
env:
- CGO_ENABLED=0

Expand Down
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ LDFLAGS := -s -w \
build:
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/stepsecurity-dev-machine-guard

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

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

build-linux:
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-linux ./cmd/stepsecurity-dev-machine-guard
Expand Down
6 changes: 6 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/console_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !windows

package main

// AttachParentConsole is a no-op on non-Windows.
func AttachParentConsole() {}
41 changes: 41 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/console_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//go:build windows

package main

import (
"os"
"syscall"

"golang.org/x/sys/windows"
)

// AttachParentConsole re-wires os.Std* to the parent's console when
// one exists. The agent is GUI-subsystem (-H windowsgui) so Task
// Scheduler launches don't allocate a console — the no-flash property.
// The cost is no inherited stdio; this restores it for interactive
// runs from cmd.exe / PowerShell. Under Task Scheduler the parent has
// no console and this no-ops. Must run before any logging.
//
// Quirks for interactive use (also documented in README):
// - Parent shell doesn't wait for GUI-subsystem children; output
// streams async below the prompt. Use `Start-Process -Wait`.
// - Pipes don't work — stdout is a console handle, not a pipe.
// - $LASTEXITCODE / %ERRORLEVEL% unreliable without -Wait.
func AttachParentConsole() {
const ATTACH_PARENT_PROCESS uint32 = 0xFFFFFFFF

attach := windows.NewLazySystemDLL("kernel32.dll").NewProc("AttachConsole")
if r1, _, _ := attach.Call(uintptr(ATTACH_PARENT_PROCESS)); r1 == 0 {
return // no parent console; expected under Task Scheduler
}

if h, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0); err == nil {
os.Stdout = os.NewFile(uintptr(h), "/dev/stdout")
}
if h, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0); err == nil {
os.Stderr = os.NewFile(uintptr(h), "/dev/stderr")
}
if h, err := syscall.Open("CONIN$", syscall.O_RDWR, 0); err == nil {
os.Stdin = os.NewFile(uintptr(h), "/dev/stdin")
}
}
5 changes: 5 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import (
const hookReconcileTimeout = 30 * time.Second

func main() {
// Windows GUI-subsystem build needs this to restore stdio for
// interactive runs. No-op under Task Scheduler / non-Windows.
// Must run before any logging.
AttachParentConsole()

// Hook hot path. Agents invoke `_hook` on every event and any non-zero
// exit is treated as a hook failure / block — so we MUST exit 0 even on
// malformed args. Skip every line below this branch (CLI parsing,
Expand Down
3 changes: 3 additions & 0 deletions internal/aiagents/enrich/npm/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"os/exec"
"path/filepath"
"strings"

"github.com/step-security/dev-machine-guard/internal/winproc"
)

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

func execRun(ctx context.Context, cwd, bin string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, bin, args...)
winproc.HideWindow(cmd)
if cwd != "" {
cmd.Dir = cwd
}
Expand Down
5 changes: 4 additions & 1 deletion internal/config/config_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"os/exec"

"github.com/step-security/dev-machine-guard/internal/winproc"
"golang.org/x/sys/windows"
)

Expand Down Expand Up @@ -48,7 +49,9 @@ func hardenMachineConfigACL(path string) error {
"/grant:r", "*S-1-5-32-545:R", // BUILTIN\Users = Read
"/Q",
}
output, err := exec.Command("icacls", args...).CombinedOutput()
cmd := exec.Command("icacls", args...)
winproc.HideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Fprintf(os.Stderr,
"warning: icacls hardening of %q failed: %v\nicacls output:\n%s\n",
Expand Down
22 changes: 14 additions & 8 deletions internal/detector/ide.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func (d *IDEDetector) detectDarwin(ctx context.Context, spec ideSpec) (model.IDE

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

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

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

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

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

version := "unknown"

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

if version == "unknown" {
version = readProductInfoVersion(d.exec, filepath.Join(installDir, "product-info.json"))
version = readJSONVersion(d.exec, filepath.Join(installDir, "product-info.json"))
}

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

// readProductInfoVersion reads the "version" field from a JetBrains product-info.json file.
// Returns "unknown" if the file does not exist or cannot be parsed.
func readProductInfoVersion(exec executor.Executor, filePath string) string {
// readJSONVersion reads top-level "version" from a JSON file (used
// for JetBrains product-info.json and VS Code-family package.json).
// Returns "unknown" if the file is missing or unparseable.
func readJSONVersion(exec executor.Executor, filePath string) string {
data, err := exec.ReadFile(filePath)
if err != nil {
return "unknown"
Expand Down
33 changes: 30 additions & 3 deletions internal/detector/ide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,33 @@ func TestIDEDetector_Windows_FindsEclipse_UserProfile_Glob(t *testing.T) {
}
}

// Version must come from package.json without shelling out to
// bin\code.cmd. No command is mocked for the binary; falling through
// fails the test.
func TestIDEDetector_Windows_VSCode_PackageJSONFastPath(t *testing.T) {
mock := executor.NewMock()
mock.SetGOOS("windows")
mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`)
mock.SetEnv("PROGRAMFILES", `C:\Program Files`)

vscodePath := `C:\Program Files\Microsoft VS Code`
mock.SetDir(vscodePath)
mock.SetFile(vscodePath+`/bin\code.cmd`, []byte{})
mock.SetFile(vscodePath+`/resources/app/package.json`,
[]byte(`{"name":"Code","version":"1.115.0"}`))

Comment on lines +394 to +399
det := NewIDEDetector(mock)
results := det.Detect(context.Background())

found := findIDE(results, "vscode")
if found == nil {
t.Fatal("expected VS Code to be detected")
}
if found.Version != "1.115.0" {
t.Errorf("version should come from package.json (1.115.0), got %s", found.Version)
}
}

func TestIDEDetector_Windows_VSCode_StillWorks(t *testing.T) {
mock := executor.NewMock()
mock.SetGOOS("windows")
Expand Down Expand Up @@ -441,15 +468,15 @@ func TestReadProductInfoVersion(t *testing.T) {
mock.SetFile("/test/product-info.json",
[]byte(`{"name":"GoLand","version":"2025.1.3","buildNumber":"251.26927.50"}`))

v := readProductInfoVersion(mock, "/test/product-info.json")
v := readJSONVersion(mock, "/test/product-info.json")
if v != "2025.1.3" {
t.Errorf("expected 2025.1.3, got %s", v)
}
}

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

v := readProductInfoVersion(mock, "/test/product-info.json")
v := readJSONVersion(mock, "/test/product-info.json")
if v != "unknown" {
t.Errorf("expected unknown, got %s", v)
}
Expand Down
5 changes: 5 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"strings"
"sync"
"time"

"github.com/step-security/dev-machine-guard/internal/winproc"
)

// Executor defines the interface for all OS interactions.
Expand Down Expand Up @@ -82,6 +84,7 @@ func NewReal() *Real { return &Real{} }

func (r *Real) Run(ctx context.Context, name string, args ...string) (string, string, int, error) {
cmd := exec.CommandContext(ctx, name, args...)
winproc.HideWindow(cmd)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
Expand Down Expand Up @@ -111,6 +114,7 @@ func (r *Real) RunInDir(ctx context.Context, dir string, timeout time.Duration,
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
winproc.HideWindow(cmd)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
Expand Down Expand Up @@ -266,6 +270,7 @@ func (r *Real) IsAppleCLTStub(_ context.Context, binPath string) bool {
probeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(probeCtx, "xcode-select", "-p")
winproc.HideWindow(cmd)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
Expand Down
11 changes: 6 additions & 5 deletions internal/progress/filelog/filelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ package filelog
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
Expand Down Expand Up @@ -221,14 +220,16 @@ func (c *Capture) Stop() error {

func (c *Capture) teeLoop() {
defer close(c.done)
dst := io.MultiWriter(c.origErr, c.file)
buf := make([]byte, 4096)
for {
n, err := c.pipeRead.Read(buf)
if n > 0 {
// Best-effort: a failed write to dst (file full, disk
// removed) must not stall the agent.
_, _ = dst.Write(buf[:n])
// File first, origErr second. io.MultiWriter aborts on the
// first error, so an invalid origErr (GUI-subsystem agent
// with no parent console) used to drop the file write too.
// Both ignored — neither failure should stall the agent.
_, _ = c.file.Write(buf[:n])
_, _ = c.origErr.Write(buf[:n])
}
if err != nil {
return
Expand Down
48 changes: 48 additions & 0 deletions internal/progress/filelog/filelog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,54 @@ func TestStartStopWritesFile(t *testing.T) {
}
}

// Regression guard: a broken origErr (closed/invalid handle, as
// GUI-subsystem agents get under Task Scheduler) must not block the
// file write. Previously io.MultiWriter aborted the loop, leaving
// agent.error.log empty despite a successful scan.
func TestStartWritesFileEvenWhenOrigStderrIsBroken(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent.error.log")

origStderr := os.Stderr
t.Cleanup(func() { os.Stderr = origStderr })

// Simulate the invalid-handle state by opening then immediately
// closing a file, then assigning the closed handle as os.Stderr.
// Writes through that handle will return os.ErrClosed — analogous
// to ERROR_INVALID_HANDLE on Windows under GUI-subsystem.
f, err := os.Open(os.DevNull)
if err != nil {
t.Fatalf("open devnull: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close devnull: %v", err)
}
os.Stderr = f

cap, err := Start(path, DefaultMaxBytes)
if err != nil {
t.Fatalf("Start: %v", err)
}

// Write through the now-piped os.Stderr. The data flows: pipe ->
// teeLoop -> file (must succeed) + origErr (will fail, ignored).
if _, err := fmt.Fprintln(os.Stderr, "hello via broken origErr"); err != nil {
t.Fatalf("Fprintln: %v", err)
}

if err := cap.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !bytes.Contains(data, []byte("hello via broken origErr")) {
t.Errorf("file missing payload despite broken origErr: %q", data)
}
}

func TestRotateIfOverCap_OverwritesExistingPrev(t *testing.T) {
// Regression guard: on Windows, os.Rename fails when the destination
// already exists, so a stale .prev would block all subsequent
Expand Down
Loading
Loading