diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 76437fb..080fc0b 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -10,9 +10,9 @@ body:
- type: input
id: version
attributes:
- label: Script Version
- description: "Run: ./stepsecurity-dev-machine-guard.sh --version"
- placeholder: "1.8.1"
+ label: Version
+ description: "Run: ./stepsecurity-dev-machine-guard --version"
+ placeholder: "1.9.0"
validations:
required: true
- type: input
@@ -28,7 +28,7 @@ body:
attributes:
label: Command Run
description: The exact command you ran
- placeholder: "./stepsecurity-dev-machine-guard.sh --json"
+ placeholder: "./stepsecurity-dev-machine-guard --json"
validations:
required: true
- type: textarea
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 12ef8a7..6372866 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -11,10 +11,11 @@
## Testing
- [ ] Tested on macOS (version: ___)
-- [ ] Script runs without errors: `./stepsecurity-dev-machine-guard.sh --verbose`
-- [ ] JSON output is valid: `./stepsecurity-dev-machine-guard.sh --json | python3 -m json.tool`
+- [ ] Binary runs without errors: `./stepsecurity-dev-machine-guard --verbose`
+- [ ] JSON output is valid: `./stepsecurity-dev-machine-guard --json | python3 -m json.tool`
- [ ] No secrets or credentials included
-- [ ] ShellCheck passes (if script was modified)
+- [ ] Lint passes: `make lint`
+- [ ] Tests pass: `make test`
## Related Issues
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9d2a3d3..ff0be71 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,11 +9,10 @@ jobs:
release:
name: Build, Sign & Release
runs-on: ubuntu-latest
- environment: release
permissions:
- contents: write # create tag, release, and upload assets
- id-token: write # Sigstore OIDC keyless signing
- attestations: write # SLSA build provenance
+ contents: write # create tag, release, and upload assets
+ id-token: write # OIDC token for cosign keyless signing and build provenance
+ attestations: write # SLSA build provenance
steps:
- name: Harden the runner (Audit all outbound calls)
@@ -23,13 +22,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
- - name: Extract version from script
+ - name: Extract version from source
id: version
run: |
- version=$(grep -m1 '^AGENT_VERSION=' stepsecurity-dev-machine-guard.sh | sed 's/AGENT_VERSION="//;s/"//')
+ version=$(grep -m1 'Version.*=' internal/buildinfo/version.go | sed 's/.*"\(.*\)".*/\1/')
if [ -z "$version" ]; then
- echo "::error::Could not extract AGENT_VERSION from script"
+ echo "::error::Could not extract Version from internal/buildinfo/version.go"
exit 1
fi
tag="v${version}"
@@ -40,52 +41,98 @@ jobs:
- name: Check tag does not already exist
run: |
if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
- echo "::error::Tag ${{ steps.version.outputs.tag }} already exists. Bump AGENT_VERSION in the script before releasing."
+ echo "::error::Tag ${{ steps.version.outputs.tag }} already exists. Bump Version in internal/buildinfo/version.go before releasing."
exit 1
fi
+ - name: Create tag
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
+ git push origin "${{ steps.version.outputs.tag }}"
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v6
+ with:
+ distribution: goreleaser
+ version: latest
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
- name: Install cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- - name: Sign script with Sigstore (keyless)
+ - name: Prepare release artifacts for signing
run: |
- cosign sign-blob stepsecurity-dev-machine-guard.sh \
- --bundle stepsecurity-dev-machine-guard.sh.bundle \
- --yes
+ # Copy binaries to match the exact names users download from the release.
+ # GoReleaser uploads as name_template (e.g. stepsecurity-dev-machine-guard_darwin_amd64)
+ # but keeps them in build subdirs locally. We copy to dist/ with release names
+ # so cosign signs the same bytes users verify against.
+ AMD64_SRC=$(find dist -type f -name 'stepsecurity-dev-machine-guard' -path '*darwin_amd64*' | head -1)
+ ARM64_SRC=$(find dist -type f -name 'stepsecurity-dev-machine-guard' -path '*darwin_arm64*' | head -1)
- - name: Verify signature
+ for label in "amd64:${AMD64_SRC}" "arm64:${ARM64_SRC}"; do
+ name="${label%%:*}"
+ path="${label#*:}"
+ if [ -z "$path" ] || [ ! -f "$path" ]; then
+ echo "::error::Binary not found for ${name}"
+ find dist -type f
+ exit 1
+ fi
+ done
+
+ cp "$AMD64_SRC" dist/stepsecurity-dev-machine-guard_darwin_amd64
+ cp "$ARM64_SRC" dist/stepsecurity-dev-machine-guard_darwin_arm64
+ echo "Prepared release artifacts for signing"
+
+ - name: Sign artifacts with Sigstore (keyless)
run: |
- cosign verify-blob stepsecurity-dev-machine-guard.sh \
- --bundle stepsecurity-dev-machine-guard.sh.bundle \
- --certificate-identity-regexp "github.com/step-security/dev-machine-guard" \
- --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
+ cosign sign-blob dist/stepsecurity-dev-machine-guard_darwin_amd64 \
+ --bundle dist/stepsecurity-dev-machine-guard_darwin_amd64.bundle --yes
+ cosign sign-blob dist/stepsecurity-dev-machine-guard_darwin_arm64 \
+ --bundle dist/stepsecurity-dev-machine-guard_darwin_arm64.bundle --yes
+ cosign sign-blob stepsecurity-dev-machine-guard.sh \
+ --bundle dist/stepsecurity-dev-machine-guard.sh.bundle --yes
- name: Generate checksums
run: |
- sha256sum stepsecurity-dev-machine-guard.sh > checksums.txt
- sha256sum stepsecurity-dev-machine-guard.sh.bundle >> checksums.txt
- echo "Checksums:"
- cat checksums.txt
+ # Separate checksum file for cosign-signed artifacts (script + bundles).
+ # GoReleaser already generates checksums for the Go binaries in its own SHA256SUMS file.
+ sha256sum dist/stepsecurity-dev-machine-guard_darwin_amd64 > dist/cosign-checksums.txt
+ sha256sum dist/stepsecurity-dev-machine-guard_darwin_arm64 >> dist/cosign-checksums.txt
+ sha256sum stepsecurity-dev-machine-guard.sh >> dist/cosign-checksums.txt
- - name: Create tag
+ - name: Upload signature bundles and checksums to release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
- git push origin "${{ steps.version.outputs.tag }}"
+ gh release upload "${{ steps.version.outputs.tag }}" \
+ dist/stepsecurity-dev-machine-guard_darwin_amd64.bundle \
+ dist/stepsecurity-dev-machine-guard_darwin_arm64.bundle \
+ dist/stepsecurity-dev-machine-guard.sh.bundle \
+ dist/cosign-checksums.txt \
+ --clobber
- - name: Create GitHub Release
- uses: step-security/action-gh-release@d45511d7589f080cf54961ff056b9705a74fd160 # v2.5.0
- with:
- tag_name: ${{ steps.version.outputs.tag }}
- name: ${{ steps.version.outputs.tag }}
- generate_release_notes: true
- files: |
- stepsecurity-dev-machine-guard.sh
- stepsecurity-dev-machine-guard.sh.bundle
- checksums.txt
+ - name: Mark release as immutable (not a draft, not a prerelease)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release edit "${{ steps.version.outputs.tag }}" \
+ --draft=false \
+ --prerelease=false \
+ --latest
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
- subject-path: stepsecurity-dev-machine-guard.sh
+ subject-path: |
+ dist/stepsecurity-dev-machine-guard_darwin_amd64
+ dist/stepsecurity-dev-machine-guard_darwin_arm64
+ stepsecurity-dev-machine-guard.sh
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..8bec676
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,44 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - uses: golangci/golangci-lint-action@v6
+ with:
+ version: latest
+
+ test:
+ name: Test
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - run: make test
+
+ smoke:
+ name: Smoke Tests
+ runs-on: macos-latest
+ needs: test
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - run: make smoke
diff --git a/.gitignore b/.gitignore
index 5a7cd7e..e50978c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,5 +17,9 @@
!docs/**/*.html
!images/**/*.html
+# Go build artifacts
+/stepsecurity-dev-machine-guard
+dist/
+
# Temporary files
todo-remove/
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 0000000..74747e1
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,34 @@
+version: 2
+project_name: stepsecurity-dev-machine-guard
+
+builds:
+ - id: stepsecurity-dev-machine-guard
+ main: ./cmd/stepsecurity-dev-machine-guard
+ binary: stepsecurity-dev-machine-guard
+ goos:
+ - darwin
+ goarch:
+ - amd64
+ - arm64
+ mod_timestamp: "{{ .CommitTimestamp }}"
+ flags:
+ - -trimpath
+ ldflags:
+ - -s -w
+ - -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}}
+ env:
+ - CGO_ENABLED=0
+
+archives:
+ - format: binary
+ name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}"
+
+checksum:
+ name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS"
+ algorithm: sha256
+
+release:
+ extra_files:
+ - glob: stepsecurity-dev-machine-guard.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ae7f08..d4a2343 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1.
+## [1.9.0] - 2026-04-03
+
+Migrated from shell script to a compiled Go binary. All existing scanning features, detection logic, CLI flags, output formats, and enterprise telemetry are preserved — this release changes the implementation, not the functionality.
+
+### Added
+- **Go binary**: Single compiled binary (`stepsecurity-dev-machine-guard`) replaces the shell script. Zero external dependencies, no runtime required.
+- **`configure` / `configure show` commands**: Interactive setup and display of enterprise credentials, search directories, and preferences. Saved to `~/.stepsecurity/config.json`.
+
## [1.8.2] - 2026-03-17
### Added
@@ -44,5 +52,6 @@ First open-source release. The scanning engine was previously an internal enterp
- Execution log capture and base64 encoding
- Instance locking to prevent concurrent runs
+[1.9.0]: https://github.com/step-security/dev-machine-guard/compare/v1.8.2...v1.9.0
[1.8.2]: https://github.com/step-security/dev-machine-guard/compare/v1.8.1...v1.8.2
[1.8.1]: https://github.com/step-security/dev-machine-guard/releases/tag/v1.8.1
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b2ce56c..99b5e56 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -9,21 +9,15 @@ Thank you for your interest in contributing! Dev Machine Guard is an open-source
To add detection for a new AI tool, IDE, or framework:
1. Open an issue using the [Feature Request](.github/ISSUE_TEMPLATE/feature_request.yml) template, or
-2. Submit a PR modifying `stepsecurity-dev-machine-guard.sh`
+2. Submit a PR modifying the appropriate detector in `internal/detector/`
**How to add a new IDE/desktop app:**
-Find the `detect_ide_installations()` function and add an entry to the `apps` array:
-```bash
-"App Name|type_id|Vendor|/Applications/App.app|Contents/MacOS/binary|--version"
-```
+Find the IDE detector in `internal/detector/ide.go` and add an entry to the apps list. See [Adding Detections](docs/adding-detections.md) for the full guide.
**How to add a new AI CLI tool:**
-Find the `detect_ai_cli_tools()` function and add an entry to the `tools` array:
-```bash
-"tool-name|Vendor|binary1,binary2|~/.config-dir1,~/.config-dir2"
-```
+Find the AI CLI detector in `internal/detector/ai_cli.go` and add an entry to the tools list. See [Adding Detections](docs/adding-detections.md) for the full guide.
### Improve Documentation
@@ -37,37 +31,37 @@ Documentation lives in the `docs/` folder. Improvements, corrections, and new gu
cd dev-machine-guard
```
-2. Make the script executable:
+2. Build the binary:
```bash
- chmod +x stepsecurity-dev-machine-guard.sh
+ make build
```
3. Run locally:
```bash
# Pretty output with progress messages
- ./stepsecurity-dev-machine-guard.sh --verbose
+ ./stepsecurity-dev-machine-guard --verbose
# JSON output
- ./stepsecurity-dev-machine-guard.sh --json
+ ./stepsecurity-dev-machine-guard --json
# HTML report
- ./stepsecurity-dev-machine-guard.sh --html report.html
+ ./stepsecurity-dev-machine-guard --html report.html
```
## Code Style
-- The script must pass [ShellCheck](https://www.shellcheck.net/) (our CI runs it on every PR)
-- Follow the existing code patterns (section headers, function naming, JSON construction)
-- Use `print_progress` for status messages (they respect the `--verbose` flag)
-- Use `print_error` for error messages (always shown)
+- Go source code in `internal/` must pass `golangci-lint` (our CI runs it on every PR)
+- Follow the existing code patterns (package structure, naming conventions, JSON struct tags)
+- Use the `progress` package for status messages (they respect the `--verbose` flag)
+- Use standard Go error handling patterns
## Pull Request Process
1. Fork the repository
2. Create a feature branch (`git checkout -b add-new-tool-detection`)
-3. Make your changes
-4. Test locally: `./stepsecurity-dev-machine-guard.sh --verbose`
-5. Ensure ShellCheck passes: `shellcheck stepsecurity-dev-machine-guard.sh`
+3. Edit Go source in `internal/` (not the legacy shell script)
+4. Test locally: `./stepsecurity-dev-machine-guard --verbose`
+5. Ensure lint and tests pass: `make lint && make test && make smoke`
6. Submit a PR using our [PR template](.github/pull_request_template.md)
## Reporting Issues
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3f99b0a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,27 @@
+BINARY := stepsecurity-dev-machine-guard
+MODULE := github.com/step-security/dev-machine-guard
+VERSION := $(shell grep -m1 'Version' internal/buildinfo/version.go | sed 's/.*"//;s/".*//')
+COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
+BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
+TAG := $(shell git describe --tags --exact-match 2>/dev/null || echo "dev")
+LDFLAGS := -s -w \
+ -X $(MODULE)/internal/buildinfo.GitCommit=$(COMMIT) \
+ -X $(MODULE)/internal/buildinfo.ReleaseTag=$(TAG) \
+ -X $(MODULE)/internal/buildinfo.ReleaseBranch=$(BRANCH)
+
+.PHONY: build test lint clean smoke
+
+build:
+ go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/stepsecurity-dev-machine-guard
+
+test:
+ go test ./... -v -race -count=1
+
+lint:
+ golangci-lint run ./...
+
+clean:
+ rm -f $(BINARY)
+
+smoke: build
+ bash tests/test_smoke_go.sh
diff --git a/README.md b/README.md
index be4e1ce..483910b 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,10 @@
+
-
+
@@ -26,17 +27,16 @@ Developer machines are the new attack surface. They hold high-value assets — G
-
**EDR and traditional MDM solutions** monitor device posture and compliance, but they have **zero visibility** into the developer tooling layer:
-| Capability | EDR / MDM | Dev Machine Guard |
-|-----------------------------------|:---------:|:-----------------:|
-| IDE extension audit | | Yes |
-| AI agent & tool inventory | | Yes |
-| MCP server config audit | | Yes |
-| Node.js package scanning | | Yes |
-| Device posture & compliance | Yes | |
-| Malware / virus detection | Yes | |
+| Capability | EDR / MDM | Dev Machine Guard |
+| --------------------------- | :-------: | :---------------: |
+| IDE extension audit | | Yes |
+| AI agent & tool inventory | | Yes |
+| MCP server config audit | | Yes |
+| Node.js package scanning | | Yes |
+| Device posture & compliance | Yes | |
+| Malware / virus detection | Yes | |
**Dev Machine Guard is complementary to EDR/MDM — not a replacement.** Deploy it alongside your existing tools via MDM (Jamf, Kandji, Intune) or run it standalone.
@@ -46,46 +46,205 @@ Developer machines are the new attack surface. They hold high-value assets — G
## Quick Start
+### Install from release (recommended)
+
+Download the latest binary for your platform from [GitHub Releases](https://github.com/step-security/dev-machine-guard/releases):
+
+```bash
+# Apple Silicon (M1/M2/M3/M4)
+curl -sSL https://github.com/step-security/dev-machine-guard/releases/latest/download/stepsecurity-dev-machine-guard_darwin_arm64 -o stepsecurity-dev-machine-guard
+chmod +x stepsecurity-dev-machine-guard
+
+# Intel Mac
+curl -sSL https://github.com/step-security/dev-machine-guard/releases/latest/download/stepsecurity-dev-machine-guard_darwin_amd64 -o stepsecurity-dev-machine-guard
+chmod +x stepsecurity-dev-machine-guard
+
+# Run the scan
+./stepsecurity-dev-machine-guard
+```
+
+### Build from source
+
```bash
-# Clone the repository
git clone https://github.com/step-security/dev-machine-guard.git
cd dev-machine-guard
+make build
+./stepsecurity-dev-machine-guard
+```
-# Make the script executable
-chmod +x stepsecurity-dev-machine-guard.sh
+Requires Go 1.24+. The binary has zero external dependencies.
-# Run the scan
-./stepsecurity-dev-machine-guard.sh
+## Usage
+
+```
+stepsecurity-dev-machine-guard [COMMAND] [OPTIONS]
+```
+
+### Commands
+
+| Command | Description |
+| ---------------- | --------------------------------------------------------------- |
+| _(none)_ | Run a scan (community mode, pretty output) |
+| `configure` | Interactively set all settings (enterprise, scan, output) |
+| `configure show` | Show current configuration (API key masked) |
+| `install` | Install launchd for periodic scanning (enterprise) |
+| `uninstall` | Remove launchd configuration (enterprise) |
+| `send-telemetry` | Upload scan results to the StepSecurity dashboard (enterprise) |
+
+### Output Formats
+
+| Flag | Description |
+| ------------- | ---------------------------------------- |
+| `--pretty` | Pretty terminal output (default) |
+| `--json` | JSON output to stdout |
+| `--html FILE` | Self-contained HTML report saved to FILE |
+
+### Options
+
+| Flag | Description |
+| ---------------------------- | ------------------------------------------------------------- |
+| `--search-dirs DIR [DIR...]` | Search DIRs instead of `$HOME` (replaces default; repeatable) |
+| `--enable-npm-scan` | Enable Node.js package scanning |
+| `--disable-npm-scan` | Disable Node.js package scanning |
+| `--verbose` | Show progress messages (suppressed by default) |
+| `--color=WHEN` | Color mode: `auto` \| `always` \| `never` (default: `auto`) |
+| `-v`, `--version` | Show version |
+| `-h`, `--help` | Show help |
+
+### Examples
+
+```bash
+# Pretty terminal output (default)
+./stepsecurity-dev-machine-guard
+
+# JSON output
+./stepsecurity-dev-machine-guard --json
+./stepsecurity-dev-machine-guard --json | python3 -m json.tool # formatted
+./stepsecurity-dev-machine-guard --json > scan.json # to file
+
+# HTML report
+./stepsecurity-dev-machine-guard --html report.html
+
+# Verbose scan with npm packages — shows progress spinners and timing
+./stepsecurity-dev-machine-guard --verbose --enable-npm-scan
+
+# Scan specific directories instead of $HOME
+./stepsecurity-dev-machine-guard --search-dirs /Volumes/code
+./stepsecurity-dev-machine-guard --search-dirs /tmp /opt # multiple dirs
+
+# Pipe JSON through jq to extract just AI tools
+./stepsecurity-dev-machine-guard --json | jq '.ai_agents_and_tools'
+
+# Count IDE extensions
+./stepsecurity-dev-machine-guard --json | jq '.summary.ide_extensions_count'
+
+# Check for MCP configs (exit 1 if any found — useful in CI)
+count=$(./stepsecurity-dev-machine-guard --json | jq '.summary.mcp_configs_count')
+[ "$count" -gt 0 ] && echo "MCP servers detected!" && exit 1
+
+# Disable colors for piping or logging
+./stepsecurity-dev-machine-guard --color=never 2>&1 | tee scan.log
+
+# Enterprise: configure all settings interactively
+./stepsecurity-dev-machine-guard configure
+
+# Enterprise: view saved configuration (API key masked)
+./stepsecurity-dev-machine-guard configure show
+
+# Enterprise: install scheduled scanning via launchd
+./stepsecurity-dev-machine-guard install
+
+# Enterprise: one-time telemetry upload
+./stepsecurity-dev-machine-guard send-telemetry
+
+# Enterprise: remove scheduled scanning
+./stepsecurity-dev-machine-guard uninstall
+```
+
+## Configuration
+
+Run `configure` to set up enterprise credentials and default search directories:
+
+```bash
+./stepsecurity-dev-machine-guard configure
+```
+
+This interactively prompts for all configurable settings:
+
+| Setting | Description | Default |
+| ------------------ | ------------------------------------------- | --------------- |
+| Customer ID | Your StepSecurity customer identifier | _(not set)_ |
+| API Endpoint | StepSecurity backend URL | _(not set)_ |
+| API Key | Authentication key for telemetry uploads | _(not set)_ |
+| Scan Frequency | How often launchd runs scans (hours) | _(not set)_ |
+| Search Directories | Comma-separated list of directories to scan | `$HOME` |
+| Enable NPM Scan | Node.js package scanning | `auto` |
+| Color Mode | Terminal color output | `auto` |
+| Output Format | Default output format | `pretty` |
+| HTML Output File | Default path for HTML reports | _(not set)_ |
+| Quiet Mode | Suppress progress messages | `false` |
+
+View current settings:
+
+```bash
+./stepsecurity-dev-machine-guard configure show
+```
+
+```
+Configuration (~/.stepsecurity/config.json):
+
+ Customer ID: my-company
+ API Endpoint: https://api.stepsecurity.io
+ API Key: ***a1b2
+ Scan Frequency: 4 hours
+ Search Directories: $HOME, /Volumes/code
+ Enable NPM Scan: auto
+ Color Mode: auto
+ Output Format: pretty
+ Quiet Mode: false
```
-Or run directly without cloning:
+Configuration is saved to `~/.stepsecurity/config.json` with `0600` permissions (owner read/write only).
+
+**CLI flags always override config file values** — this matches the shell script behavior. For example, if your config has `output_format: json`, running `./stepsecurity-dev-machine-guard --pretty` uses pretty output. To clear a value during configuration, enter a single dash (`-`).
+
+### Verbose and Quiet Mode
+
+By default in community mode, progress messages (spinners, step details) are **suppressed** — you only see the final output. This keeps stdout clean for piping.
```bash
-curl -sSL https://raw.githubusercontent.com/step-security/dev-machine-guard/main/stepsecurity-dev-machine-guard.sh -o stepsecurity-dev-machine-guard.sh
-chmod +x stepsecurity-dev-machine-guard.sh
-./stepsecurity-dev-machine-guard.sh
+# Default: quiet — clean output, no progress spinners
+./stepsecurity-dev-machine-guard --json > scan.json
+
+# Verbose: show progress spinners and step timing
+./stepsecurity-dev-machine-guard --verbose
+
+# Save quiet=true in config so it persists across runs
+./stepsecurity-dev-machine-guard configure
```
+In enterprise mode (`send-telemetry`, `install`), progress is **always shown** regardless of the quiet setting — the output is captured as execution logs and sent to the backend for debugging.
+
## What It Detects
See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detections.
-| Category | Examples |
-|-----------------------|-------------------------------------------------------|
-| IDEs & Desktop Apps | VS Code, Cursor, Windsurf, Zed, Claude, Copilot |
-| AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro, Aider |
-| AI Agents | Claude Cowork, OpenClaw, GPT-Engineer |
-| AI Frameworks | Ollama, LM Studio, LocalAI |
-| MCP Server Configs | Claude Desktop, Cursor, Windsurf, Zed, Codex |
-| IDE Extensions | VS Code, Cursor |
-| Node.js Packages | npm, yarn, pnpm, bun (opt-in) |
+| Category | Examples |
+| ------------------- | ---------------------------------------------------------------------------------------- |
+| IDEs & Desktop Apps | VS Code, Cursor, Windsurf, Antigravity, Zed, Claude, Copilot |
+| AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro, GitHub Copilot CLI, Aider, OpenCode |
+| AI Agents | Claude Cowork, OpenClaw, ClawdBot, GPT-Engineer |
+| AI Frameworks | Ollama, LM Studio, LocalAI, Text Generation WebUI |
+| MCP Server Configs | Claude Desktop, Claude Code, Cursor, Windsurf, Antigravity, Zed, Open Interpreter, Codex |
+| IDE Extensions | VS Code, Cursor (name, publisher, version, install date) |
+| Node.js Packages | npm, yarn, pnpm, bun (opt-in) |
## Output Formats
### Pretty Terminal Output (default)
```bash
-./stepsecurity-dev-machine-guard.sh
+./stepsecurity-dev-machine-guard
```
@@ -95,62 +254,73 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec
### JSON Output
```bash
-./stepsecurity-dev-machine-guard.sh --json
-./stepsecurity-dev-machine-guard.sh --json | python3 -m json.tool # formatted
-./stepsecurity-dev-machine-guard.sh --json > scan.json # to file
+./stepsecurity-dev-machine-guard --json
```
+See [examples/sample-output.json](examples/sample-output.json) for the full schema, or [Reading Scan Results](docs/reading-scan-results.md) for the schema reference.
+
### HTML Report
```bash
-./stepsecurity-dev-machine-guard.sh --html report.html
+./stepsecurity-dev-machine-guard --html report.html
```
-### Additional Options
+## Community vs Enterprise
+
+| Feature | Community (Free) | Enterprise |
+| ----------------------------- | :--------------: | :--------: |
+| AI agent & tool inventory | Yes | Yes |
+| IDE extension scanning | Yes | Yes |
+| MCP server config audit | Yes | Yes |
+| Pretty / JSON / HTML output | Yes | Yes |
+| Node.js package scanning | Opt-in | Default on |
+| Interactive configuration | Yes | Yes |
+| Centralized dashboard | | Yes |
+| Policy enforcement & alerting | | Yes |
+| Scheduled scans via launchd | | Yes |
+| Historical trends & reporting | | Yes |
+
+Enterprise mode requires a StepSecurity subscription. [Start a 14-day free trial](https://www.stepsecurity.io/start-free) by installing the StepSecurity GitHub App.
+
+### Enterprise Setup
```bash
-./stepsecurity-dev-machine-guard.sh --verbose # Show progress messages
-./stepsecurity-dev-machine-guard.sh --enable-npm-scan --json # Include npm packages
-./stepsecurity-dev-machine-guard.sh --help # Full usage
-```
+# 1. Configure credentials (interactive)
+./stepsecurity-dev-machine-guard configure
-## Community vs Enterprise
+# 2. Install scheduled scanning via launchd
+./stepsecurity-dev-machine-guard install
-| Feature | Community (Free) | Enterprise |
-|---------------------------------|:-------------------:|:---------------------------:|
-| AI agent & tool inventory | Yes | Yes |
-| IDE extension scanning | Yes | Yes |
-| MCP server config audit | Yes | Yes |
-| Pretty / JSON / HTML output | Yes | Yes |
-| Node.js package scanning | Opt-in | Default on |
-| Centralized dashboard | | Yes |
-| Policy enforcement & alerting | | Yes |
-| Scheduled scans via launchd | | Yes |
-| Historical trends & reporting | | Yes |
+# 3. Or run a one-time telemetry upload
+./stepsecurity-dev-machine-guard send-telemetry
-Enterprise mode requires a StepSecurity subscription. [Start a 14-day free trial](https://www.stepsecurity.io/start-free) by installing the StepSecurity GitHub App.
+# 4. Uninstall scheduled scanning
+./stepsecurity-dev-machine-guard uninstall
+```
-**Open-source commitment:** StepSecurity enterprise customers use the exact same script from this repository. There is no separate closed-source version — all scanning capabilities are developed and maintained here in the open. Enterprise mode adds centralized infrastructure (dashboard, policy engine, alerting) on top of the same open-source scanning engine.
+**Open-source commitment:** StepSecurity enterprise customers use the exact same binary from this repository. There is no separate closed-source version — all scanning capabilities are developed and maintained here in the open. Enterprise mode adds centralized infrastructure (dashboard, policy engine, alerting) on top of the same open-source scanning engine.
## How It Works
-
+
-Dev Machine Guard is a lightweight bash script that scans your developer environment. Here's what it does and — importantly — what it does **not** do:
+Dev Machine Guard is a single compiled binary that scans your developer environment. Here's what it does and — importantly — what it does **not** do:
**What it collects:**
+
- Installed IDEs, AI tools, and their versions
- IDE extension names, publishers, and versions
- MCP server configuration (server names and commands only)
- Node.js package listings (opt-in)
**What it does NOT collect:**
+
- Source code, file contents, or project data
- Secrets, credentials, API keys, or tokens
- Browsing history or personal files
@@ -158,25 +328,56 @@ Dev Machine Guard is a lightweight bash script that scans your developer environ
**In community mode**, all data stays on your machine. Nothing is sent anywhere.
-**In enterprise mode**, scan data is sent to the StepSecurity backend for centralized visibility. The script source code is fully open — you can audit exactly what is collected and transmitted.
+**In enterprise mode**, scan data is sent to the StepSecurity backend for centralized visibility. The source code is fully open — you can audit exactly what is collected and transmitted.
-## Why a Shell Script?
+## Building from Source
-Dev Machine Guard is intentionally implemented as a single bash script rather than a compiled binary in Go, Python, or another language. There are two key reasons:
+```bash
+# Build
+make build
+
+# Run unit tests (with race detector)
+make test
+
+# Run integration smoke tests
+make smoke
-1. **Zero-dependency deployment.** A shell script runs natively on macOS with no runtime, interpreter, or package manager required. This makes deployment across hundreds or thousands of developer machines via MDM (Jamf, Kandji, Intune) straightforward — push the script and it just works.
+# Run linter
+make lint
+
+# Clean build artifacts
+make clean
+```
-2. **Full transparency.** Developer machines are highly privileged environments with access to source code, credentials, and cloud infrastructure. A compiled binary is opaque — customers have to trust what it does. A readable shell script lets security teams review exactly what runs on their machines, line by line, before deploying it. No hidden telemetry, no opaque logic, no blind trust required.
+### Project Structure
+
+```
+cmd/stepsecurity-dev-machine-guard/ # Binary entry point
+internal/
+├── buildinfo/ # Version and build metadata
+├── cli/ # Argument parser
+├── config/ # Configuration file management and configure command
+├── detector/ # All scanners (IDE, AI CLI, agents, frameworks, MCP, extensions, Node.js)
+├── device/ # Device info (hostname, serial, OS version)
+├── executor/ # OS abstraction interface (enables mocked unit tests)
+├── launchd/ # macOS launchd install/uninstall
+├── lock/ # PID-file instance locking
+├── model/ # JSON struct types
+├── output/ # Formatters (JSON, pretty, HTML)
+├── progress/ # Progress spinner and logging
+├── scan/ # Community mode orchestrator
+└── telemetry/ # Enterprise mode orchestrator and S3 upload
+```
## How It Compares
Dev Machine Guard is **not a replacement** for dependency scanners, vulnerability databases, or endpoint security tools. It covers a different layer — the developer tooling surface — that these tools were never designed to inspect.
-| Tool Category | What It Does Well | What It Misses |
-|---|---|---|
-| **`npm audit` / `yarn audit`** | Flags known CVEs in declared dependencies | Has no visibility into IDEs, AI tools, MCP servers, or IDE extensions |
-| **OWASP Dep-Check / Snyk / Socket** | Deep dependency vulnerability and supply-chain risk analysis | Does not scan the broader developer tooling layer (AI agents, IDE extensions, MCP configs) |
-| **EDR / MDM (CrowdStrike, Jamf, Intune)** | Device posture, compliance, and malware detection | Zero visibility into developer-specific tooling like IDE extensions, MCP servers, or AI agent configurations |
+| Tool Category | What It Does Well | What It Misses |
+| ----------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
+| **`npm audit` / `yarn audit`** | Flags known CVEs in declared dependencies | Has no visibility into IDEs, AI tools, MCP servers, or IDE extensions |
+| **OWASP Dep-Check / Snyk / Socket** | Deep dependency vulnerability and supply-chain risk analysis | Does not scan the broader developer tooling layer (AI agents, IDE extensions, MCP configs) |
+| **EDR / MDM (CrowdStrike, Jamf, Intune)** | Device posture, compliance, and malware detection | Zero visibility into developer-specific tooling like IDE extensions, MCP servers, or AI agent configurations |
Dev Machine Guard fills the gap by inventorying what is actually running in your developer environment. Deploy it alongside your existing security stack for complete coverage.
@@ -201,6 +402,7 @@ We welcome contributions! Whether it's adding detection for a new AI tool, impro
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
**Quick contribution ideas:**
+
- Add a new AI tool or IDE to the detection list
- Improve [documentation](docs/)
- Report bugs or request features via [issues](https://github.com/step-security/dev-machine-guard/issues)
diff --git a/SECURITY.md b/SECURITY.md
index 835d2ee..7a85b86 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -19,7 +19,7 @@ We will acknowledge your report within 48 hours and provide a detailed response
## Scope
This policy covers:
-- The `stepsecurity-dev-machine-guard.sh` script
+- The `stepsecurity-dev-machine-guard` binary and Go source code in `internal/`
- The StepSecurity backend API (for enterprise mode)
## Supported Versions
diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go
new file mode 100644
index 0000000..916d038
--- /dev/null
+++ b/cmd/stepsecurity-dev-machine-guard/main.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/step-security/dev-machine-guard/internal/buildinfo"
+ "github.com/step-security/dev-machine-guard/internal/cli"
+ "github.com/step-security/dev-machine-guard/internal/config"
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/launchd"
+ "github.com/step-security/dev-machine-guard/internal/progress"
+ "github.com/step-security/dev-machine-guard/internal/scan"
+ "github.com/step-security/dev-machine-guard/internal/telemetry"
+)
+
+func main() {
+ // Load persisted config (~/.stepsecurity/config.json) before parsing CLI
+ config.Load()
+
+ cfg, err := cli.Parse(os.Args[1:])
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %s\n", err)
+ os.Exit(1)
+ }
+
+ // Apply saved config values if CLI didn't explicitly override them.
+ // CLI flags always win over config file values (same as the shell script).
+ if len(config.SearchDirs) > 0 && len(cfg.SearchDirs) == 1 && cfg.SearchDirs[0] == "$HOME" {
+ cfg.SearchDirs = config.SearchDirs
+ }
+ if cfg.EnableNPMScan == nil && config.EnableNPMScan != nil {
+ cfg.EnableNPMScan = config.EnableNPMScan
+ }
+ if cfg.ColorMode == "auto" && config.ColorMode != "" {
+ cfg.ColorMode = config.ColorMode
+ }
+ if !cfg.OutputFormatSet && config.OutputFormat != "" {
+ cfg.OutputFormat = config.OutputFormat
+ cfg.OutputFormatSet = true // treat saved format as explicitly set
+ if config.OutputFormat == "html" && cfg.HTMLOutputFile == "" && config.HTMLOutputFile != "" {
+ cfg.HTMLOutputFile = config.HTMLOutputFile
+ }
+ }
+
+ exec := executor.NewReal()
+ quiet := !cfg.Verbose
+ // Apply saved quiet preference
+ if config.Quiet != nil && *config.Quiet {
+ quiet = true
+ }
+ // --verbose always overrides quiet config
+ if cfg.Verbose {
+ quiet = false
+ }
+ if cfg.OutputFormat == "json" {
+ quiet = true
+ }
+ // Enterprise commands (send-telemetry, install) always show progress
+ if cfg.Command == "send-telemetry" || cfg.Command == "install" {
+ quiet = false
+ }
+ log := progress.NewLogger(quiet)
+
+ switch cfg.Command {
+ case "configure":
+ if err := config.RunConfigure(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+
+ case "configure show":
+ config.ShowConfigure()
+
+ case "send-telemetry":
+ if !config.IsEnterpriseMode() {
+ log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0])
+ os.Exit(1)
+ }
+ if err := telemetry.Run(exec, log, cfg); err != nil {
+ log.Error("%v", err)
+ os.Exit(1)
+ }
+
+ case "install":
+ fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
+ if !config.IsEnterpriseMode() {
+ log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0])
+ os.Exit(1)
+ }
+ if err := launchd.Install(exec, log); err != nil {
+ log.Error("%v", err)
+ os.Exit(1)
+ }
+ log.Progress("Sending initial telemetry...")
+ fmt.Println()
+ if err := telemetry.Run(exec, log, cfg); err != nil {
+ log.Error("%v", err)
+ os.Exit(1)
+ }
+
+ case "uninstall":
+ fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
+ if err := launchd.Uninstall(exec, log); err != nil {
+ log.Error("%v", err)
+ os.Exit(1)
+ }
+
+ default:
+ // Community mode or auto-detect enterprise
+ if cfg.OutputFormatSet || cfg.HTMLOutputFile != "" {
+ // Output format flag was explicitly set — community mode
+ if err := scan.Run(exec, log, cfg); err != nil {
+ log.Error("%v", err)
+ os.Exit(1)
+ }
+ } else if config.IsEnterpriseMode() {
+ if err := telemetry.Run(exec, log, cfg); err != nil {
+ log.Error("%v", err)
+ os.Exit(1)
+ }
+ } else {
+ if err := scan.Run(exec, log, cfg); err != nil {
+ log.Error("%v", err)
+ os.Exit(1)
+ }
+ }
+ }
+}
diff --git a/docs/adding-detections.md b/docs/adding-detections.md
index 92b0300..1abef5a 100644
--- a/docs/adding-detections.md
+++ b/docs/adding-detections.md
@@ -10,13 +10,13 @@ This guide walks you through adding new detections to Dev Machine Guard. Whether
Dev Machine Guard uses array-driven detection. Each detection category has a function that iterates over a defined array of entries. To add a new detection, you add an entry to the appropriate array and (optionally) handle any special cases.
-The main script file is `stepsecurity-dev-machine-guard.sh` (community mode). The enterprise script (`stepsecurity-agent-*.sh`) uses the same detection functions.
+The detection code lives in the `internal/detector/` directory, with each detector category in its own `.go` file.
---
## 1. Adding a New IDE or Desktop App
-### Function: `detect_ide_installations()`
+### File: `internal/detector/ide.go`
### Format String
@@ -35,39 +35,26 @@ The main script file is `stepsecurity-dev-machine-guard.sh` (community mode). Th
### Example: Adding a hypothetical "CodeForge" IDE
-Find the `apps` array inside `detect_ide_installations()` and add:
+Find the `apps` array inside the IDE detector and add:
-```bash
-local apps=(
- # ... existing entries ...
- "CodeForge|codeforge|CodeForge Inc|/Applications/CodeForge.app|Contents/MacOS/CodeForge|--version"
-)
-```
-
-If the app stores its version in `Info.plist` instead of a CLI binary, leave the binary path and version command empty:
-
-```bash
-"CodeForge|codeforge|CodeForge Inc|/Applications/CodeForge.app|||"
+```go
+{
+ Name: "CodeForge",
+ TypeID: "codeforge",
+ Vendor: "CodeForge Inc",
+ AppPath: "/Applications/CodeForge.app",
+ BinaryPath: "Contents/MacOS/CodeForge",
+ VersionCommand: "--version",
+}
```
-The scanner will automatically fall back to reading `CFBundleShortVersionString` from `Info.plist`.
-
-### Also update the pretty formatter
-
-If you want your IDE to display a friendly name in pretty output, add a case to the `format_pretty_output()` function:
-
-```bash
-case "$ide_type" in
- # ... existing cases ...
- codeforge) display_name="CodeForge" ;;
-esac
-```
+If the app stores its version in `Info.plist` instead of a CLI binary, leave the binary path and version command empty. The scanner will automatically fall back to reading `CFBundleShortVersionString` from `Info.plist`.
---
## 2. Adding a New AI CLI Tool
-### Function: `detect_ai_cli_tools()`
+### File: `internal/detector/ai_cli.go`
### Format String
@@ -84,11 +71,13 @@ esac
### Example: Adding a hypothetical "DevPilot" CLI
-```bash
-local tools=(
- # ... existing entries ...
- "devpilot|DevPilot Inc|devpilot,dp|~/.devpilot,~/.config/devpilot"
-)
+```go
+{
+ Name: "devpilot",
+ Vendor: "DevPilot Inc",
+ Binaries: []string{"devpilot", "dp"},
+ ConfigDirs: []string{"~/.devpilot", "~/.config/devpilot"},
+}
```
The scanner will:
@@ -96,24 +85,11 @@ The scanner will:
2. If found, run `devpilot --version` (or `dp --version`) to get the version
3. Check if `~/.devpilot` or `~/.config/devpilot` exists as a config directory
-### Special version handling
-
-If the tool requires non-standard version extraction (e.g., the `--version` flag produces output that needs to be verified or parsed differently), add a case to the `case` statement inside the function:
-
-```bash
-case "$tool_name" in
- # ... existing cases ...
- devpilot)
- version=$(run_as_logged_in_user "$logged_in_user" "$binary_name version 2>/dev/null | head -1" || echo "unknown")
- ;;
-esac
-```
-
---
## 3. Adding a New AI Agent
-### Function: `detect_general_ai_agents()`
+### File: `internal/detector/agent.go`
### Format String
@@ -125,16 +101,18 @@ esac
|-------|-------------|
| agent-name | Unique name for the agent (e.g., `openclaw`, `gpt-engineer`) |
| Vendor | The company or organization (e.g., "OpenSource", "Anthropic") |
-| detection_paths | Comma-separated paths (directories or files) that indicate the agent is installed. Use `$user_home` for the home directory variable. |
+| detection_paths | Comma-separated paths (directories or files) that indicate the agent is installed |
| binary_names | Comma-separated binary names for version extraction |
### Example: Adding a hypothetical "AutoDev" agent
-```bash
-local agents=(
- # ... existing entries ...
- "autodev|AutoDev Inc|$user_home/.autodev|autodev"
-)
+```go
+{
+ Name: "autodev",
+ Vendor: "AutoDev Inc",
+ DetectionPaths: []string{"~/.autodev"},
+ Binaries: []string{"autodev"},
+}
```
The scanner will:
@@ -142,24 +120,11 @@ The scanner will:
2. If not found, check if `autodev` binary exists in PATH
3. If found either way, try to run `autodev --version` for version info
-### Special case: Agents within existing apps
-
-The `detect_general_ai_agents()` function includes a special case for Claude Cowork (a mode within Claude Desktop). If your agent is a mode within an existing app rather than a standalone tool, add a similar special case block after the main detection loop:
-
-```bash
-# Check for special agent modes (like Claude Cowork)
-local some_app_path="/Applications/SomeApp.app"
-if [ -d "$some_app_path" ]; then
- # Check version to determine if agent mode is available
- # ... version check logic ...
-fi
-```
-
---
## 4. Adding a New MCP Config Source
-### Function: `collect_mcp_configs()`
+### File: `internal/detector/mcp.go`
### Format String
@@ -170,61 +135,53 @@ fi
| Field | Description |
|-------|-------------|
| source_name | Unique identifier for the source (e.g., `claude_desktop`, `cursor`) |
-| config_path | Full path to the config file. Use `$user_home` for the home directory variable. |
+| config_path | Full path to the config file. Use `~` for the home directory. |
| Vendor | The company or organization (e.g., "Anthropic", "Cursor") |
### Example: Adding a hypothetical "CodeAssist" MCP config
-```bash
-local config_sources=(
- # ... existing entries ...
- "codeassist|$user_home/.codeassist/mcp_config.json|CodeAssist Inc"
-)
+```go
+{
+ Source: "codeassist",
+ ConfigPath: "~/.codeassist/mcp_config.json",
+ Vendor: "CodeAssist Inc",
+}
```
The scanner will:
1. Check if the config file exists at the specified path
2. Read the file contents
-3. In enterprise mode: filter with `jq` to extract only server names and commands, then base64-encode
+3. In enterprise mode: filter to extract only server names and commands, then base64-encode
4. In community mode: display the server information locally
-### Handling non-JSON formats
-
-If the config file is not JSON (e.g., YAML or TOML), the `jq` filtering step will be skipped automatically, and the raw content will be used. The StepSecurity backend handles parsing of multiple config formats.
-
-### Handling JSONC (JSON with comments)
-
-If the tool uses JSONC (like Zed's `settings.json`), add a special case to strip comments before parsing:
-
-```bash
-if [ "$source_name" = "codeassist" ] && [ "$perl_available" = true ]; then
- json_input=$(echo "$config_content" | perl -0777 -pe 's{/\*.*?\*/}{}gs; s{//[^\n]*}{}g')
-fi
-```
-
---
## 5. Testing Your Changes Locally
-After making changes, test locally with all three output formats:
+After making changes, build and test locally with all three output formats:
```bash
+# Build the binary
+make build
+
# Pretty output with progress messages
-./stepsecurity-dev-machine-guard.sh --verbose
+./stepsecurity-dev-machine-guard --verbose
# JSON output (validate it is well-formed)
-./stepsecurity-dev-machine-guard.sh --json | python3 -m json.tool
+./stepsecurity-dev-machine-guard --json | python3 -m json.tool
# HTML report
-./stepsecurity-dev-machine-guard.sh --html test-report.html
+./stepsecurity-dev-machine-guard --html test-report.html
```
-### Run ShellCheck
+### Run the linter and tests
-The CI pipeline runs ShellCheck on every PR. Run it locally before submitting:
+The CI pipeline runs `golangci-lint` and tests on every PR. Run them locally before submitting:
```bash
-shellcheck stepsecurity-dev-machine-guard.sh
+make lint
+make test
+make smoke
```
### Verify your new detection appears
@@ -236,7 +193,7 @@ shellcheck stepsecurity-dev-machine-guard.sh
### If you do not have the tool installed
-You can still verify your format string is correct by:
+You can still verify your detection is correct by:
1. Creating a test directory or dummy binary that matches the detection path
2. Running the scanner against it
3. Cleaning up after testing
@@ -258,8 +215,7 @@ After adding a new detection, update the following:
2. Create a feature branch: `git checkout -b add-detection-codeforge`
3. Make your changes
4. Test locally (all three output formats)
-5. Run ShellCheck
+5. Run `make lint` and `make test`
6. Submit a PR using the [PR template](https://github.com/step-security/dev-machine-guard/blob/main/.github/pull_request_template.md)
See [CONTRIBUTING.md](../CONTRIBUTING.md) for full contribution guidelines.
-
diff --git a/docs/community-mode.md b/docs/community-mode.md
index 7b6f872..37c4972 100644
--- a/docs/community-mode.md
+++ b/docs/community-mode.md
@@ -11,16 +11,16 @@ Community mode is the free, open-source way to run Dev Machine Guard locally on
```bash
git clone https://github.com/step-security/dev-machine-guard.git
cd dev-machine-guard
-chmod +x stepsecurity-dev-machine-guard.sh
-./stepsecurity-dev-machine-guard.sh
+make build
+./stepsecurity-dev-machine-guard
```
-Or run directly without cloning:
+Or download a pre-built binary without cloning:
```bash
-curl -sSL https://raw.githubusercontent.com/step-security/dev-machine-guard/main/stepsecurity-dev-machine-guard.sh -o stepsecurity-dev-machine-guard.sh
-chmod +x stepsecurity-dev-machine-guard.sh
-./stepsecurity-dev-machine-guard.sh
+curl -sSL https://github.com/step-security/dev-machine-guard/releases/latest/download/stepsecurity-dev-machine-guard_darwin_arm64 -o stepsecurity-dev-machine-guard
+chmod +x stepsecurity-dev-machine-guard
+./stepsecurity-dev-machine-guard
```
---
@@ -32,7 +32,7 @@ Dev Machine Guard supports three mutually exclusive output formats.
### Pretty Terminal Output (default)
```bash
-./stepsecurity-dev-machine-guard.sh
+./stepsecurity-dev-machine-guard
```
Pretty mode prints a styled, human-readable report directly to your terminal, including sections for Device information, Summary counts, AI Agents and Tools, IDEs and Desktop Apps, MCP Servers, and IDE Extensions.
@@ -41,13 +41,13 @@ Pretty mode prints a styled, human-readable report directly to your terminal, in
```bash
# Print JSON to stdout
-./stepsecurity-dev-machine-guard.sh --json
+./stepsecurity-dev-machine-guard --json
# Pipe through python for formatted output
-./stepsecurity-dev-machine-guard.sh --json | python3 -m json.tool
+./stepsecurity-dev-machine-guard --json | python3 -m json.tool
# Save to a file
-./stepsecurity-dev-machine-guard.sh --json > scan.json
+./stepsecurity-dev-machine-guard --json > scan.json
```
JSON mode writes a single JSON object to stdout. This is useful for scripting, piping into other tools, or storing results for later analysis. See [Reading Scan Results](reading-scan-results.md) for the full schema reference.
@@ -55,7 +55,7 @@ JSON mode writes a single JSON object to stdout. This is useful for scripting, p
### HTML Report
```bash
-./stepsecurity-dev-machine-guard.sh --html report.html
+./stepsecurity-dev-machine-guard --html report.html
```
HTML mode generates a self-contained HTML file with a styled report. The report can be opened in any browser and is suitable for sharing with team leads or printing.
@@ -82,7 +82,7 @@ HTML mode generates a self-contained HTML file with a styled report. The report
### Basic scan with pretty output
```bash
-./stepsecurity-dev-machine-guard.sh
+./stepsecurity-dev-machine-guard
```
Runs the scan and prints a styled report to the terminal. Progress messages are suppressed by default.
@@ -90,7 +90,7 @@ Runs the scan and prints a styled report to the terminal. Progress messages are
### Verbose scan to see what is happening
```bash
-./stepsecurity-dev-machine-guard.sh --verbose
+./stepsecurity-dev-machine-guard --verbose
```
Same as above, but progress messages (e.g., "Detecting IDE installations...", "Found: Cursor (Cursor) v0.50.1") are printed to stderr so you can follow along.
@@ -98,7 +98,7 @@ Same as above, but progress messages (e.g., "Detecting IDE installations...", "F
### JSON scan piped to jq
```bash
-./stepsecurity-dev-machine-guard.sh --json | jq '.ai_agents_and_tools[] | .name'
+./stepsecurity-dev-machine-guard --json | jq '.ai_agents_and_tools[] | .name'
```
Extracts the name of every detected AI tool.
@@ -106,7 +106,7 @@ Extracts the name of every detected AI tool.
### JSON scan with npm packages included
```bash
-./stepsecurity-dev-machine-guard.sh --json --enable-npm-scan > full-scan.json
+./stepsecurity-dev-machine-guard --json --enable-npm-scan > full-scan.json
```
Produces a comprehensive JSON scan including globally installed npm/yarn/pnpm/bun packages and per-project dependency listings.
@@ -114,7 +114,7 @@ Produces a comprehensive JSON scan including globally installed npm/yarn/pnpm/bu
### HTML report without colors in progress messages
```bash
-./stepsecurity-dev-machine-guard.sh --html report.html --verbose --color=never
+./stepsecurity-dev-machine-guard --html report.html --verbose --color=never
```
Generates an HTML report while showing progress messages without ANSI color codes (useful when piping stderr to a log file).
@@ -126,7 +126,7 @@ Generates an HTML report while showing progress messages without ANSI color code
In community mode:
- **No data leaves your machine.** There is no backend, no API calls, no telemetry.
-- The script source code is fully open. You can audit exactly what it does.
+- The source code is fully open. You can audit exactly what it does.
- All output is written to stdout (JSON, pretty) or to a local file (HTML). Nothing is transmitted over the network.
If you need centralized visibility across a fleet of developer machines, [start a 14-day free trial](https://www.stepsecurity.io/start-free) by installing the StepSecurity GitHub App.
@@ -139,4 +139,3 @@ If you need centralized visibility across a fleet of developer machines, [start
- [Adding Detections](adding-detections.md) -- contribute new tool or IDE detections
- [SCAN_COVERAGE.md](../SCAN_COVERAGE.md) -- full catalog of supported detections
- [CONTRIBUTING.md](../CONTRIBUTING.md) -- how to contribute to the project
-
diff --git a/docs/reading-scan-results.md b/docs/reading-scan-results.md
index c718dda..95dca5d 100644
--- a/docs/reading-scan-results.md
+++ b/docs/reading-scan-results.md
@@ -184,7 +184,7 @@ When you run with `--json`, the scanner outputs a single JSON object to stdout.
| Field | Type | Description |
|-------|------|-------------|
-| `agent_version` | string | Version of the agent script |
+| `agent_version` | string | Version of the scanner binary |
| `scan_timestamp` | number | Unix timestamp (seconds) of the scan |
| `scan_timestamp_iso` | string | ISO 8601 timestamp |
| `device` | object | Device identification information |
@@ -238,25 +238,25 @@ The HTML report is styled with StepSecurity branding (purple accent colors) and
### Extract all AI tool names
```bash
-./stepsecurity-dev-machine-guard.sh --json | jq -r '.ai_agents_and_tools[].name'
+./stepsecurity-dev-machine-guard --json | jq -r '.ai_agents_and_tools[].name'
```
### Count extensions per IDE
```bash
-./stepsecurity-dev-machine-guard.sh --json | jq '[.ide_extensions[] | .ide_type] | group_by(.) | map({(.[0]): length}) | add'
+./stepsecurity-dev-machine-guard --json | jq '[.ide_extensions[] | .ide_type] | group_by(.) | map({(.[0]): length}) | add'
```
### Check if a specific extension is installed
```bash
-./stepsecurity-dev-machine-guard.sh --json | jq '.ide_extensions[] | select(.id == "ms-python.python")'
+./stepsecurity-dev-machine-guard --json | jq '.ide_extensions[] | select(.id == "ms-python.python")'
```
### Export extension list as CSV
```bash
-./stepsecurity-dev-machine-guard.sh --json | jq -r '.ide_extensions[] | [.id, .version, .publisher, .ide_type] | @csv'
+./stepsecurity-dev-machine-guard --json | jq -r '.ide_extensions[] | [.id, .version, .publisher, .ide_type] | @csv'
```
---
diff --git a/docs/release-process.md b/docs/release-process.md
index 322a25b..73c35c8 100644
--- a/docs/release-process.md
+++ b/docs/release-process.md
@@ -8,24 +8,25 @@ This document describes how releases are created, signed, and verified.
## Overview
-Releases are created via a manually triggered GitHub Actions workflow that requires approval from the `release` environment. The workflow:
+Releases are created via a manually triggered GitHub Actions workflow (`workflow_dispatch`) that requires approval from the `release` environment. The workflow uses [GoReleaser](https://goreleaser.com/) to:
-1. Extracts the version from `AGENT_VERSION` in the script
-2. Verifies the tag does not already exist (immutability)
-3. Signs the script with [Sigstore](https://www.sigstore.dev/) cosign (keyless)
-4. Generates SHA256 checksums
-5. Creates a Git tag and GitHub Release
-6. Attaches the script, Sigstore bundle, and checksums as release assets
-7. Generates SLSA build provenance attestation
+1. Read the version from `internal/buildinfo/version.go` (`const Version = "1.9.0"`)
+2. Verify the tag does not already exist (immutability)
+3. Build platform-specific binaries with GoReleaser
+4. Sign the binaries with [Sigstore](https://www.sigstore.dev/) cosign (keyless)
+5. Generate SHA256 checksums
+6. Create a Git tag and GitHub Release
+7. Attach binaries, Sigstore bundles, and checksums as release assets
+8. Generate SLSA build provenance attestation
## How to Create a Release
### 1. Bump the version
-Update `AGENT_VERSION` in `stepsecurity-dev-machine-guard.sh`:
+Update `Version` in `internal/buildinfo/version.go`:
-```bash
-AGENT_VERSION="1.9.0"
+```go
+const Version = "1.9.0"
```
Update the [CHANGELOG.md](../CHANGELOG.md) with a new section for the version.
@@ -45,19 +46,20 @@ The workflow uses a GitHub Environment called `release` that requires approval.
### 4. Verify the release
-Once approved, the workflow will create the tag, sign the script, create the GitHub Release, and upload the artifacts. Check the [Releases page](https://github.com/step-security/dev-machine-guard/releases) to confirm.
+Once approved, the workflow will create the tag, build the binaries, sign them, create the GitHub Release, and upload the artifacts. Check the [Releases page](https://github.com/step-security/dev-machine-guard/releases) to confirm.
---
## Release Artifacts
-Each release includes three artifacts:
+Each release includes the following artifacts:
| Artifact | Description |
|----------|-------------|
-| `stepsecurity-dev-machine-guard.sh` | The scanner script |
-| `stepsecurity-dev-machine-guard.sh.bundle` | Sigstore cosign bundle (signature, certificate, and Rekor transparency log entry) |
-| `checksums.txt` | SHA256 checksums of the script and bundle |
+| `stepsecurity-dev-machine-guard_darwin_amd64` | macOS Intel binary |
+| `stepsecurity-dev-machine-guard_darwin_arm64` | macOS Apple Silicon binary |
+| `checksums.txt` | SHA256 checksums of all release artifacts |
+| `*.bundle` | Sigstore cosign bundles (signature, certificate, and Rekor transparency log entry) |
---
@@ -74,24 +76,24 @@ brew install cosign
# Other platforms: https://docs.sigstore.dev/cosign/system_config/installation/
```
-### Verify the script signature
+### Verify the binary signature
```bash
# Download the release artifacts
-gh release download v1.8.1 --repo step-security/dev-machine-guard
+gh release download v1.9.0 --repo step-security/dev-machine-guard
-# Verify the Sigstore signature
-cosign verify-blob stepsecurity-dev-machine-guard.sh \
- --bundle stepsecurity-dev-machine-guard.sh.bundle \
+# Verify the Sigstore signature (example for Apple Silicon)
+cosign verify-blob stepsecurity-dev-machine-guard_darwin_arm64 \
+ --bundle stepsecurity-dev-machine-guard_darwin_arm64.bundle \
--certificate-identity-regexp "github.com/step-security/dev-machine-guard" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
```
A successful verification confirms:
-- The script was signed by the `step-security/dev-machine-guard` GitHub Actions workflow
+- The binary was signed by the `step-security/dev-machine-guard` GitHub Actions workflow
- The signature is recorded in the [Rekor transparency log](https://search.sigstore.dev/)
-- The script has not been tampered with since signing
+- The binary has not been tampered with since signing
### Verify the checksum
@@ -102,7 +104,7 @@ sha256sum -c checksums.txt
### Verify build provenance
```bash
-gh attestation verify stepsecurity-dev-machine-guard.sh \
+gh attestation verify stepsecurity-dev-machine-guard_darwin_arm64 \
--repo step-security/dev-machine-guard
```
@@ -136,4 +138,3 @@ The release workflow requires a GitHub Environment named `release` with required
- [VERSIONING.md](../VERSIONING.md) — versioning scheme
- [Sigstore documentation](https://docs.sigstore.dev/) — how keyless signing works
- [SLSA](https://slsa.dev/) — supply chain integrity framework
-
diff --git a/examples/sample-output.json b/examples/sample-output.json
index e605abe..6244830 100644
--- a/examples/sample-output.json
+++ b/examples/sample-output.json
@@ -1,5 +1,5 @@
{
- "agent_version": "1.8.2",
+ "agent_version": "1.9.0",
"scan_timestamp": 1741305600,
"scan_timestamp_iso": "2026-03-07T00:00:00Z",
"device": {
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..ec60e84
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/step-security/dev-machine-guard
+
+go 1.24
diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go
new file mode 100644
index 0000000..1d5dfc0
--- /dev/null
+++ b/internal/buildinfo/version.go
@@ -0,0 +1,27 @@
+package buildinfo
+
+import "fmt"
+
+const (
+ Version = "1.9.0"
+ AgentURL = "https://github.com/step-security/dev-machine-guard"
+)
+
+// Build-time variables set via -ldflags by goreleaser or Makefile.
+var (
+ GitCommit string // short commit hash (Makefile) or full commit (goreleaser)
+ ReleaseTag string // e.g., "v1.9.0" (goreleaser only)
+ ReleaseBranch string // e.g., "main" (goreleaser only)
+)
+
+// VersionString returns the display version, including the git commit if available.
+func VersionString() string {
+ if GitCommit != "" {
+ short := GitCommit
+ if len(short) > 7 {
+ short = short[:7]
+ }
+ return fmt.Sprintf("%s (%s)", Version, short)
+ }
+ return Version
+}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
new file mode 100644
index 0000000..7953d37
--- /dev/null
+++ b/internal/cli/cli.go
@@ -0,0 +1,158 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/step-security/dev-machine-guard/internal/buildinfo"
+)
+
+// Config holds all parsed CLI flags.
+type Config struct {
+ Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show"
+ OutputFormat string // "pretty", "json", "html"
+ OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted)
+ HTMLOutputFile string // set by --html (not persisted)
+ ColorMode string // "auto", "always", "never"
+ Verbose bool // --verbose
+ EnableNPMScan *bool // nil=auto, true/false=explicit
+ SearchDirs []string // defaults to ["$HOME"]
+}
+
+// Parse parses CLI arguments and returns a Config.
+func Parse(args []string) (*Config, error) {
+ cfg := &Config{
+ OutputFormat: "pretty",
+ ColorMode: "auto",
+ SearchDirs: []string{"$HOME"},
+ }
+
+ searchDirsSet := false
+ i := 0
+
+ for i < len(args) {
+ arg := args[i]
+ switch {
+ case arg == "install" || arg == "--install":
+ cfg.Command = "install"
+ case arg == "uninstall" || arg == "--uninstall":
+ cfg.Command = "uninstall"
+ case arg == "send-telemetry" || arg == "--send-telemetry":
+ cfg.Command = "send-telemetry"
+ case arg == "configure":
+ // Check for "configure show" subcommand
+ if i+1 < len(args) && args[i+1] == "show" {
+ cfg.Command = "configure show"
+ i++
+ } else {
+ cfg.Command = "configure"
+ }
+ case arg == "--pretty":
+ cfg.OutputFormat = "pretty"
+ cfg.OutputFormatSet = true
+ case arg == "--json":
+ cfg.OutputFormat = "json"
+ cfg.OutputFormatSet = true
+ case arg == "--html":
+ cfg.OutputFormat = "html"
+ cfg.OutputFormatSet = true
+ i++
+ if i >= len(args) {
+ return nil, fmt.Errorf("--html requires a file path argument")
+ }
+ cfg.HTMLOutputFile = args[i]
+ case arg == "--enable-npm-scan":
+ v := true
+ cfg.EnableNPMScan = &v
+ case arg == "--disable-npm-scan":
+ v := false
+ cfg.EnableNPMScan = &v
+ case strings.HasPrefix(arg, "--color="):
+ mode := strings.TrimPrefix(arg, "--color=")
+ if mode != "auto" && mode != "always" && mode != "never" {
+ return nil, fmt.Errorf("invalid color mode: %s (must be auto, always, or never)", mode)
+ }
+ cfg.ColorMode = mode
+ case arg == "--search-dirs":
+ i++
+ if i >= len(args) || strings.HasPrefix(args[i], "--") {
+ return nil, fmt.Errorf("--search-dirs requires at least one directory path argument")
+ }
+ if !searchDirsSet {
+ cfg.SearchDirs = nil
+ searchDirsSet = true
+ }
+ // Greedily consume non-flag arguments
+ for i < len(args) && !strings.HasPrefix(args[i], "--") {
+ cfg.SearchDirs = append(cfg.SearchDirs, args[i])
+ i++
+ }
+ continue // skip the i++ at the bottom
+ case arg == "--verbose":
+ cfg.Verbose = true
+ case arg == "-v" || arg == "--version" || arg == "version":
+ fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString())
+ os.Exit(0)
+ case arg == "-h" || arg == "--help" || arg == "help":
+ printHelp()
+ os.Exit(0)
+ default:
+ return nil, fmt.Errorf("Unknown option: %s\nRun '%s --help' for usage information.", arg, filepath.Base(os.Args[0]))
+ }
+ i++
+ }
+
+ return cfg, nil
+}
+
+func printHelp() {
+ name := filepath.Base(os.Args[0])
+ fmt.Fprintf(os.Stdout, `StepSecurity Dev Machine Guard v%s
+
+Usage: %s [COMMAND] [OPTIONS]
+
+Commands:
+ configure Configure enterprise settings and search directories
+ configure show Show current configuration
+ install Install launchd for periodic scanning (enterprise)
+ uninstall Remove launchd configuration (enterprise)
+ send-telemetry Upload scan results to the StepSecurity dashboard (enterprise)
+
+Output formats (community mode, mutually exclusive):
+ --pretty Pretty terminal output (default)
+ --json JSON output to stdout
+ --html FILE HTML report saved to FILE
+
+Options:
+ --search-dirs DIR [DIR...] Search DIRs instead of $HOME (replaces default; repeatable)
+ --enable-npm-scan Enable Node.js package scanning
+ --disable-npm-scan Disable Node.js package scanning
+ --verbose Show progress messages (suppressed by default)
+ --color=WHEN Color mode: auto | always | never (default: auto)
+ -v, --version Show version
+ -h, --help Show this help
+
+Examples:
+ %s # Pretty terminal output
+ %s --json | python3 -m json.tool # Formatted JSON
+ %s --json > scan.json # JSON to file
+ %s --html report.html # HTML report
+ %s --verbose --enable-npm-scan # Verbose with npm scan
+ %s --search-dirs /Volumes/code # Search only /Volumes/code
+ %s --search-dirs /tmp /opt # Multiple dirs, one flag
+ %s --search-dirs "/path/with spaces" --search-dirs /opt # Mixed styles
+ %s configure # Set up enterprise config and search dirs
+ %s send-telemetry # Enterprise telemetry
+
+Configuration:
+ Config file: ~/.stepsecurity/config.json
+ Run '%s configure' to set enterprise credentials and search directories interactively.
+
+%s
+`, buildinfo.Version, name,
+ name, name, name, name, name, name, name, name,
+ name, name, name,
+ buildinfo.AgentURL)
+}
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
new file mode 100644
index 0000000..32cd6c5
--- /dev/null
+++ b/internal/cli/cli_test.go
@@ -0,0 +1,183 @@
+package cli
+
+import (
+ "testing"
+)
+
+func TestParse_Defaults(t *testing.T) {
+ cfg, err := Parse([]string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.OutputFormat != "pretty" {
+ t.Errorf("expected pretty, got %s", cfg.OutputFormat)
+ }
+ if cfg.ColorMode != "auto" {
+ t.Errorf("expected auto, got %s", cfg.ColorMode)
+ }
+ if cfg.Verbose {
+ t.Error("expected verbose=false")
+ }
+ if cfg.EnableNPMScan != nil {
+ t.Error("expected EnableNPMScan=nil")
+ }
+ if len(cfg.SearchDirs) != 1 || cfg.SearchDirs[0] != "$HOME" {
+ t.Errorf("expected [$HOME], got %v", cfg.SearchDirs)
+ }
+}
+
+func TestParse_JSONFlag(t *testing.T) {
+ cfg, err := Parse([]string{"--json"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.OutputFormat != "json" {
+ t.Errorf("expected json, got %s", cfg.OutputFormat)
+ }
+}
+
+func TestParse_HTMLFlag(t *testing.T) {
+ cfg, err := Parse([]string{"--html", "report.html"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.OutputFormat != "html" {
+ t.Errorf("expected html, got %s", cfg.OutputFormat)
+ }
+ if cfg.HTMLOutputFile != "report.html" {
+ t.Errorf("expected report.html, got %s", cfg.HTMLOutputFile)
+ }
+}
+
+func TestParse_HTMLMissingFile(t *testing.T) {
+ _, err := Parse([]string{"--html"})
+ if err == nil {
+ t.Error("expected error for --html without file")
+ }
+}
+
+func TestParse_Verbose(t *testing.T) {
+ cfg, err := Parse([]string{"--verbose"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cfg.Verbose {
+ t.Error("expected verbose=true")
+ }
+}
+
+func TestParse_Color(t *testing.T) {
+ for _, mode := range []string{"auto", "always", "never"} {
+ cfg, err := Parse([]string{"--color=" + mode})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.ColorMode != mode {
+ t.Errorf("expected %s, got %s", mode, cfg.ColorMode)
+ }
+ }
+}
+
+func TestParse_InvalidColor(t *testing.T) {
+ _, err := Parse([]string{"--color=invalid"})
+ if err == nil {
+ t.Error("expected error for invalid color mode")
+ }
+}
+
+func TestParse_NPMScan(t *testing.T) {
+ cfg, err := Parse([]string{"--enable-npm-scan"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.EnableNPMScan == nil || !*cfg.EnableNPMScan {
+ t.Error("expected EnableNPMScan=true")
+ }
+
+ cfg, err = Parse([]string{"--disable-npm-scan"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.EnableNPMScan == nil || *cfg.EnableNPMScan {
+ t.Error("expected EnableNPMScan=false")
+ }
+}
+
+func TestParse_SearchDirs(t *testing.T) {
+ cfg, err := Parse([]string{"--search-dirs", "/tmp", "/opt"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cfg.SearchDirs) != 2 || cfg.SearchDirs[0] != "/tmp" || cfg.SearchDirs[1] != "/opt" {
+ t.Errorf("expected [/tmp /opt], got %v", cfg.SearchDirs)
+ }
+}
+
+func TestParse_SearchDirsMultiple(t *testing.T) {
+ cfg, err := Parse([]string{"--search-dirs", "/a", "/b", "--search-dirs", "/c"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cfg.SearchDirs) != 3 {
+ t.Errorf("expected 3 dirs, got %d: %v", len(cfg.SearchDirs), cfg.SearchDirs)
+ }
+}
+
+func TestParse_SearchDirsStopsAtFlag(t *testing.T) {
+ cfg, err := Parse([]string{"--search-dirs", "/tmp", "--verbose"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cfg.SearchDirs) != 1 || cfg.SearchDirs[0] != "/tmp" {
+ t.Errorf("expected [/tmp], got %v", cfg.SearchDirs)
+ }
+ if !cfg.Verbose {
+ t.Error("expected verbose=true")
+ }
+}
+
+func TestParse_SearchDirsMissing(t *testing.T) {
+ _, err := Parse([]string{"--search-dirs"})
+ if err == nil {
+ t.Error("expected error for --search-dirs without args")
+ }
+}
+
+func TestParse_ConfigureCommand(t *testing.T) {
+ cfg, err := Parse([]string{"configure"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Command != "configure" {
+ t.Errorf("expected configure, got %s", cfg.Command)
+ }
+}
+
+func TestParse_EnterpriseCommands(t *testing.T) {
+ for _, cmd := range []string{"install", "uninstall", "send-telemetry"} {
+ cfg, err := Parse([]string{cmd})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Command != cmd {
+ t.Errorf("expected %s, got %s", cmd, cfg.Command)
+ }
+ }
+}
+
+func TestParse_UnknownOption(t *testing.T) {
+ _, err := Parse([]string{"--bogus"})
+ if err == nil {
+ t.Error("expected error for unknown option")
+ }
+}
+
+func TestParse_FlagCombinations(t *testing.T) {
+ cfg, err := Parse([]string{"--json", "--verbose", "--color=never"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.OutputFormat != "json" || !cfg.Verbose || cfg.ColorMode != "never" {
+ t.Errorf("unexpected config: %+v", cfg)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..9db2440
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,366 @@
+package config
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Default placeholders (replaced by backend for enterprise installation scripts).
+var (
+ CustomerID = "{{CUSTOMER_ID}}"
+ APIEndpoint = "{{API_ENDPOINT}}"
+ APIKey = "{{API_KEY}}"
+ ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}"
+ SearchDirs []string
+ EnableNPMScan *bool // nil=auto
+ ColorMode string // "" means auto
+ OutputFormat string // "" means default (pretty)
+ HTMLOutputFile string // "" means not set
+ Quiet *bool // nil=default behavior
+)
+
+// ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json.
+type ConfigFile struct {
+ CustomerID string `json:"customer_id,omitempty"`
+ APIEndpoint string `json:"api_endpoint,omitempty"`
+ APIKey string `json:"api_key,omitempty"`
+ ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"`
+ SearchDirs []string `json:"search_dirs,omitempty"`
+ EnableNPMScan *bool `json:"enable_npm_scan,omitempty"`
+ ColorMode string `json:"color_mode,omitempty"`
+ OutputFormat string `json:"output_format,omitempty"`
+ HTMLOutputFile string `json:"html_output_file,omitempty"`
+ Quiet *bool `json:"quiet,omitempty"`
+}
+
+// configDir returns ~/.stepsecurity.
+func configDir() string {
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".stepsecurity")
+}
+
+// ConfigFilePath returns the path to the config file.
+func ConfigFilePath() string {
+ return filepath.Join(configDir(), "config.json")
+}
+
+// Load reads the config file and applies values to the package-level variables.
+// Values already set (not placeholders) are not overridden — build-time values take precedence.
+func Load() {
+ data, err := os.ReadFile(ConfigFilePath())
+ if err != nil {
+ return // no config file, use defaults
+ }
+
+ var cfg ConfigFile
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return
+ }
+
+ if cfg.CustomerID != "" && isPlaceholder(CustomerID) {
+ CustomerID = cfg.CustomerID
+ }
+ if cfg.APIEndpoint != "" && isPlaceholder(APIEndpoint) {
+ APIEndpoint = cfg.APIEndpoint
+ }
+ if cfg.APIKey != "" && isPlaceholder(APIKey) {
+ APIKey = cfg.APIKey
+ }
+ if cfg.ScanFrequencyHours != "" && isPlaceholder(ScanFrequencyHours) {
+ ScanFrequencyHours = cfg.ScanFrequencyHours
+ }
+ if len(cfg.SearchDirs) > 0 {
+ SearchDirs = cfg.SearchDirs
+ }
+ if cfg.EnableNPMScan != nil && EnableNPMScan == nil {
+ EnableNPMScan = cfg.EnableNPMScan
+ }
+ if cfg.ColorMode != "" && ColorMode == "" {
+ ColorMode = cfg.ColorMode
+ }
+ if cfg.OutputFormat != "" && OutputFormat == "" {
+ OutputFormat = cfg.OutputFormat
+ }
+ if cfg.HTMLOutputFile != "" && HTMLOutputFile == "" {
+ HTMLOutputFile = cfg.HTMLOutputFile
+ }
+ if cfg.Quiet != nil && Quiet == nil {
+ Quiet = cfg.Quiet
+ }
+}
+
+// IsEnterpriseMode returns true if valid enterprise credentials are configured.
+func IsEnterpriseMode() bool {
+ return APIKey != "" && !strings.Contains(APIKey, "{{")
+}
+
+// RunConfigure interactively prompts for config values and saves to the config file.
+func RunConfigure() error {
+ reader := bufio.NewReader(os.Stdin)
+
+ // Load existing config to show current values
+ existing := loadExisting()
+
+ fmt.Println("StepSecurity Dev Machine Guard — Configuration")
+ fmt.Println()
+ fmt.Println("Enter new values or press Enter to keep the current value.")
+ fmt.Println("To clear a value, enter a single dash (-).")
+ fmt.Println()
+
+ existing.CustomerID = promptValue(reader, "Customer ID", existing.CustomerID)
+ existing.APIEndpoint = promptValue(reader, "API Endpoint", existing.APIEndpoint)
+ existing.APIKey = promptSecret(reader, "API Key", existing.APIKey)
+ existing.ScanFrequencyHours = promptValue(reader, "Scan Frequency (hours)", existing.ScanFrequencyHours)
+
+ // Search dirs
+ currentDirs := ""
+ if len(existing.SearchDirs) > 0 {
+ currentDirs = strings.Join(existing.SearchDirs, ", ")
+ }
+ dirsInput := promptValue(reader, "Search Directories (comma-separated)", currentDirs)
+ if dirsInput != "" {
+ dirs := strings.Split(dirsInput, ",")
+ existing.SearchDirs = nil
+ for _, d := range dirs {
+ d = strings.TrimSpace(d)
+ if d != "" {
+ existing.SearchDirs = append(existing.SearchDirs, d)
+ }
+ }
+ } else {
+ existing.SearchDirs = nil
+ }
+
+ // Enable npm scan
+ currentNPM := "auto"
+ if existing.EnableNPMScan != nil {
+ if *existing.EnableNPMScan {
+ currentNPM = "true"
+ } else {
+ currentNPM = "false"
+ }
+ }
+ npmInput := promptValue(reader, "Enable NPM Scan (auto/true/false)", currentNPM)
+ switch strings.ToLower(npmInput) {
+ case "true":
+ v := true
+ existing.EnableNPMScan = &v
+ case "false":
+ v := false
+ existing.EnableNPMScan = &v
+ default:
+ existing.EnableNPMScan = nil // auto
+ }
+
+ // Color mode
+ currentColor := existing.ColorMode
+ if currentColor == "" {
+ currentColor = "auto"
+ }
+ colorInput := promptValue(reader, "Color Mode (auto/always/never)", currentColor)
+ switch strings.ToLower(colorInput) {
+ case "always", "never":
+ existing.ColorMode = strings.ToLower(colorInput)
+ default:
+ existing.ColorMode = "" // auto (omit from config)
+ }
+
+ // Output format
+ currentFormat := existing.OutputFormat
+ if currentFormat == "" {
+ currentFormat = "pretty"
+ }
+ formatInput := promptValue(reader, "Output Format (pretty/json/html)", currentFormat)
+ switch strings.ToLower(formatInput) {
+ case "json", "html":
+ existing.OutputFormat = strings.ToLower(formatInput)
+ default:
+ existing.OutputFormat = "" // pretty is the default (omit from config)
+ }
+
+ // HTML output file (only relevant when output_format is html)
+ if existing.OutputFormat == "html" {
+ existing.HTMLOutputFile = promptValue(reader, "HTML Output File", existing.HTMLOutputFile)
+ }
+
+ // Quiet mode
+ currentQuiet := "false"
+ if existing.Quiet != nil && *existing.Quiet {
+ currentQuiet = "true"
+ }
+ quietInput := promptValue(reader, "Quiet Mode (true/false)", currentQuiet)
+ switch strings.ToLower(quietInput) {
+ case "true":
+ v := true
+ existing.Quiet = &v
+ default:
+ existing.Quiet = nil // false is the default (omit from config)
+ }
+
+ // Save
+ if err := save(existing); err != nil {
+ return fmt.Errorf("saving configuration: %w", err)
+ }
+
+ fmt.Println()
+ fmt.Printf("Configuration saved to %s\n", ConfigFilePath())
+ return nil
+}
+
+// promptSecret shows a masked current value but keeps the real value on Enter.
+func promptSecret(reader *bufio.Reader, label, current string) string {
+ masked := maskSecret(current)
+ if masked != "(not set)" {
+ fmt.Printf(" %s [%s]: ", label, masked)
+ } else {
+ fmt.Printf(" %s: ", label)
+ }
+
+ line, _ := reader.ReadString('\n')
+ line = strings.TrimSpace(line)
+
+ if line == "-" {
+ return "" // clear value
+ }
+ if line == "" {
+ return current // keep real value
+ }
+ return line
+}
+
+func promptValue(reader *bufio.Reader, label, current string) string {
+ if current != "" {
+ fmt.Printf(" %s [%s]: ", label, current)
+ } else {
+ fmt.Printf(" %s: ", label)
+ }
+
+ line, _ := reader.ReadString('\n')
+ line = strings.TrimSpace(line)
+
+ if line == "-" {
+ return "" // clear value
+ }
+ if line == "" {
+ return current // keep existing
+ }
+ return line
+}
+
+func loadExisting() *ConfigFile {
+ data, err := os.ReadFile(ConfigFilePath())
+ if err != nil {
+ return &ConfigFile{}
+ }
+ var cfg ConfigFile
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return &ConfigFile{}
+ }
+ return &cfg
+}
+
+func save(cfg *ConfigFile) error {
+ dir := configDir()
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(ConfigFilePath(), data, 0o600)
+}
+
+// ShowConfigure prints the current configuration to stdout.
+func ShowConfigure() {
+ cfg := loadExisting()
+
+ fmt.Printf("Configuration (%s):\n\n", ConfigFilePath())
+ fmt.Printf(" %-24s %s\n", "Customer ID:", displayValue(cfg.CustomerID))
+ fmt.Printf(" %-24s %s\n", "API Endpoint:", displayValue(cfg.APIEndpoint))
+ fmt.Printf(" %-24s %s\n", "API Key:", maskSecret(cfg.APIKey))
+ fmt.Printf(" %-24s %s\n", "Scan Frequency:", displayFrequency(cfg.ScanFrequencyHours))
+ fmt.Printf(" %-24s %s\n", "Search Directories:", displayDirs(cfg.SearchDirs))
+ fmt.Printf(" %-24s %s\n", "Enable NPM Scan:", displayNPMScan(cfg.EnableNPMScan))
+ fmt.Printf(" %-24s %s\n", "Color Mode:", displayColorMode(cfg.ColorMode))
+ fmt.Printf(" %-24s %s\n", "Output Format:", displayOutputFormat(cfg.OutputFormat))
+ if cfg.OutputFormat == "html" {
+ fmt.Printf(" %-24s %s\n", "HTML Output File:", displayValue(cfg.HTMLOutputFile))
+ }
+ fmt.Printf(" %-24s %s\n", "Quiet Mode:", displayQuiet(cfg.Quiet))
+}
+
+func displayValue(v string) string {
+ if v == "" {
+ return "(not set)"
+ }
+ return v
+}
+
+func maskSecret(v string) string {
+ if v == "" {
+ return "(not set)"
+ }
+ if len(v) <= 6 {
+ return "***"
+ }
+ return "***" + v[len(v)-4:]
+}
+
+func displayFrequency(v string) string {
+ if v == "" {
+ return "(not set)"
+ }
+ if v == "1" {
+ return v + " hour"
+ }
+ return v + " hours"
+}
+
+func displayDirs(dirs []string) string {
+ if len(dirs) == 0 {
+ return "(not set — defaults to $HOME)"
+ }
+ return strings.Join(dirs, ", ")
+}
+
+func displayNPMScan(v *bool) string {
+ if v == nil {
+ return "auto"
+ }
+ if *v {
+ return "true"
+ }
+ return "false"
+}
+
+func displayColorMode(v string) string {
+ if v == "" {
+ return "auto"
+ }
+ return v
+}
+
+func displayOutputFormat(v string) string {
+ if v == "" {
+ return "pretty"
+ }
+ return v
+}
+
+func displayQuiet(v *bool) string {
+ if v == nil || !*v {
+ return "false"
+ }
+ return "true"
+}
+
+func isPlaceholder(v string) bool {
+ return strings.Contains(v, "{{")
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..a24495d
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,119 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestIsEnterpriseMode_Placeholder(t *testing.T) {
+ APIKey = "{{API_KEY}}"
+ if IsEnterpriseMode() {
+ t.Error("placeholder should not be enterprise mode")
+ }
+}
+
+func TestIsEnterpriseMode_Empty(t *testing.T) {
+ APIKey = ""
+ if IsEnterpriseMode() {
+ t.Error("empty should not be enterprise mode")
+ }
+}
+
+func TestIsEnterpriseMode_Valid(t *testing.T) {
+ APIKey = "sk-test-123456"
+ defer func() { APIKey = "{{API_KEY}}" }()
+ if !IsEnterpriseMode() {
+ t.Error("valid API key should be enterprise mode")
+ }
+}
+
+func TestIsPlaceholder(t *testing.T) {
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"{{API_KEY}}", true},
+ {"{{CUSTOMER_ID}}", true},
+ {"real-value", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ if got := isPlaceholder(tt.input); got != tt.want {
+ t.Errorf("isPlaceholder(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestSaveAndLoad(t *testing.T) {
+ // Use a temp directory
+ tmpDir := t.TempDir()
+ origConfigDir := configDir
+ // Override configDir for test
+ tmpConfigPath := filepath.Join(tmpDir, "config.json")
+
+ cfg := &ConfigFile{
+ CustomerID: "test-customer",
+ APIEndpoint: "https://api.example.com",
+ APIKey: "sk-test-key",
+ ScanFrequencyHours: "4",
+ SearchDirs: []string{"/tmp", "/opt/code"},
+ }
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(tmpConfigPath, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ // Read it back
+ readData, err := os.ReadFile(tmpConfigPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var loaded ConfigFile
+ if err := json.Unmarshal(readData, &loaded); err != nil {
+ t.Fatal(err)
+ }
+
+ if loaded.CustomerID != "test-customer" {
+ t.Errorf("customer_id: expected test-customer, got %s", loaded.CustomerID)
+ }
+ if loaded.APIKey != "sk-test-key" {
+ t.Errorf("api_key: expected sk-test-key, got %s", loaded.APIKey)
+ }
+ if len(loaded.SearchDirs) != 2 {
+ t.Errorf("search_dirs: expected 2 dirs, got %d", len(loaded.SearchDirs))
+ }
+
+ _ = origConfigDir
+}
+
+func TestConfigFile_JSON(t *testing.T) {
+ cfg := ConfigFile{
+ CustomerID: "cust-123",
+ APIKey: "key-456",
+ }
+
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var parsed map[string]any
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatal(err)
+ }
+
+ if parsed["customer_id"] != "cust-123" {
+ t.Error("customer_id not serialized correctly")
+ }
+ // Empty fields should be omitted
+ if _, ok := parsed["api_endpoint"]; ok {
+ t.Error("empty api_endpoint should be omitted")
+ }
+}
diff --git a/internal/detector/agent.go b/internal/detector/agent.go
new file mode 100644
index 0000000..b5efcd8
--- /dev/null
+++ b/internal/detector/agent.go
@@ -0,0 +1,147 @@
+package detector
+
+import (
+ "context"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+type agentSpec struct {
+ Name string
+ Vendor string
+ DetectionPaths []string // relative to home dir
+ Binaries []string
+}
+
+var agentDefinitions = []agentSpec{
+ {"openclaw", "OpenSource", []string{".openclaw"}, []string{"openclaw"}},
+ {"clawdbot", "OpenSource", []string{".clawdbot"}, []string{"clawdbot"}},
+ {"moltbot", "OpenSource", []string{".moltbot"}, []string{"moltbot"}},
+ {"moldbot", "OpenSource", []string{".moldbot"}, []string{"moldbot"}},
+ {"gpt-engineer", "OpenSource", []string{".gpt-engineer"}, []string{"gpt-engineer"}},
+}
+
+// AgentDetector detects general-purpose AI agents.
+type AgentDetector struct {
+ exec executor.Executor
+}
+
+func NewAgentDetector(exec executor.Executor) *AgentDetector {
+ return &AgentDetector{exec: exec}
+}
+
+func (d *AgentDetector) Detect(ctx context.Context, searchDirs []string) []model.AITool {
+ homeDir := getHomeDir(d.exec)
+ var results []model.AITool
+
+ for _, spec := range agentDefinitions {
+ installPath, found := d.findAgent(spec, homeDir)
+ if !found {
+ continue
+ }
+
+ version := d.getVersion(ctx, spec)
+
+ results = append(results, model.AITool{
+ Name: spec.Name,
+ Vendor: spec.Vendor,
+ Type: "general_agent",
+ Version: version,
+ InstallPath: installPath,
+ })
+ }
+
+ // Claude Cowork special case
+ if tool, ok := d.detectClaudeCowork(ctx); ok {
+ results = append(results, tool)
+ }
+
+ return results
+}
+
+func (d *AgentDetector) findAgent(spec agentSpec, homeDir string) (string, bool) {
+ // Check detection paths
+ for _, relPath := range spec.DetectionPaths {
+ fullPath := homeDir + "/" + relPath
+ if d.exec.DirExists(fullPath) || d.exec.FileExists(fullPath) {
+ return fullPath, true
+ }
+ }
+
+ // Check binaries in PATH
+ for _, bin := range spec.Binaries {
+ if path, err := d.exec.LookPath(bin); err == nil {
+ return path, true
+ }
+ }
+
+ return "", false
+}
+
+func (d *AgentDetector) getVersion(ctx context.Context, spec agentSpec) string {
+ for _, bin := range spec.Binaries {
+ if _, err := d.exec.LookPath(bin); err == nil {
+ stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, bin, "--version")
+ if err == nil {
+ lines := strings.SplitN(stdout, "\n", 2)
+ if len(lines) > 0 {
+ v := strings.TrimSpace(lines[0])
+ if v != "" {
+ return v
+ }
+ }
+ }
+ }
+ }
+ return "unknown"
+}
+
+// detectClaudeCowork checks for Claude Cowork (a mode within Claude Desktop 0.7+).
+func (d *AgentDetector) detectClaudeCowork(ctx context.Context) (model.AITool, bool) {
+ claudePath := "/Applications/Claude.app"
+ if !d.exec.DirExists(claudePath) {
+ return model.AITool{}, false
+ }
+
+ version := readPlistVersion(ctx, d.exec, claudePath+"/Contents/Info.plist")
+ if version == "unknown" {
+ return model.AITool{}, false
+ }
+
+ // Check if version >= 0.7 (supports Cowork)
+ if !isCoworkVersion(version) {
+ return model.AITool{}, false
+ }
+
+ return model.AITool{
+ Name: "claude-cowork",
+ Vendor: "Anthropic",
+ Type: "general_agent",
+ Version: version,
+ InstallPath: claudePath,
+ }, true
+}
+
+// isCoworkVersion returns true if version is 0.7+ or 1.0+.
+var versionRe = regexp.MustCompile(`^(\d+)\.(\d+)`)
+
+func isCoworkVersion(version string) bool {
+ m := versionRe.FindStringSubmatch(version)
+ if len(m) < 3 {
+ return false
+ }
+ major, err1 := strconv.Atoi(m[1])
+ minor, err2 := strconv.Atoi(m[2])
+ if err1 != nil || err2 != nil {
+ return false
+ }
+ if major >= 1 {
+ return true
+ }
+ return major == 0 && minor >= 7
+}
diff --git a/internal/detector/agent_test.go b/internal/detector/agent_test.go
new file mode 100644
index 0000000..9a35bbc
--- /dev/null
+++ b/internal/detector/agent_test.go
@@ -0,0 +1,98 @@
+package detector
+
+import (
+ "context"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+func TestAgentDetector_FindsOpenclaw(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetDir("/Users/testuser/.openclaw")
+ mock.SetPath("openclaw", "/usr/local/bin/openclaw")
+ mock.SetCommand("0.5.2\n", "", 0, "openclaw", "--version")
+
+ det := NewAgentDetector(mock)
+ results := det.Detect(context.Background(), []string{"/Users/testuser"})
+
+ found := false
+ for _, r := range results {
+ if r.Name == "openclaw" {
+ found = true
+ if r.Type != "general_agent" {
+ t.Errorf("expected general_agent, got %s", r.Type)
+ }
+ if r.InstallPath != "/Users/testuser/.openclaw" {
+ t.Errorf("expected /Users/testuser/.openclaw, got %s", r.InstallPath)
+ }
+ }
+ }
+ if !found {
+ t.Error("openclaw not found")
+ }
+}
+
+func TestAgentDetector_ClaudeCowork(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetDir("/Applications/Claude.app")
+ mock.SetFile("/Applications/Claude.app/Contents/Info.plist", []byte{})
+ mock.SetCommand("0.7.5", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Claude.app/Contents/Info.plist")
+
+ det := NewAgentDetector(mock)
+ results := det.Detect(context.Background(), []string{"/Users/testuser"})
+
+ found := false
+ for _, r := range results {
+ if r.Name == "claude-cowork" {
+ found = true
+ if r.Vendor != "Anthropic" {
+ t.Errorf("expected Anthropic, got %s", r.Vendor)
+ }
+ if r.Version != "0.7.5" {
+ t.Errorf("expected 0.7.5, got %s", r.Version)
+ }
+ }
+ }
+ if !found {
+ t.Error("claude-cowork not found")
+ }
+}
+
+func TestAgentDetector_ClaudeCowork_OldVersion(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetDir("/Applications/Claude.app")
+ mock.SetFile("/Applications/Claude.app/Contents/Info.plist", []byte{})
+ mock.SetCommand("0.6.9", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Claude.app/Contents/Info.plist")
+
+ det := NewAgentDetector(mock)
+ results := det.Detect(context.Background(), []string{"/Users/testuser"})
+
+ for _, r := range results {
+ if r.Name == "claude-cowork" {
+ t.Error("claude-cowork should not be detected for version 0.6.9")
+ }
+ }
+}
+
+func TestIsCoworkVersion(t *testing.T) {
+ tests := []struct {
+ version string
+ want bool
+ }{
+ {"0.7.0", true},
+ {"0.7.5", true},
+ {"0.9.0", true},
+ {"1.0.0", true},
+ {"1.5.2", true},
+ {"0.6.9", false},
+ {"0.1.0", false},
+ {"unknown", false},
+ }
+ for _, tt := range tests {
+ got := isCoworkVersion(tt.version)
+ if got != tt.want {
+ t.Errorf("isCoworkVersion(%q) = %v, want %v", tt.version, got, tt.want)
+ }
+ }
+}
diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go
new file mode 100644
index 0000000..9ae34d6
--- /dev/null
+++ b/internal/detector/aicli.go
@@ -0,0 +1,181 @@
+package detector
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+type cliToolSpec struct {
+ Name string
+ Vendor string
+ Binaries []string // binary names or paths (~ expanded at runtime)
+ ConfigDirs []string // config directory candidates (~ expanded)
+ VersionFlag string // flag to get version; defaults to "--version"
+ VerifyFunc func(ctx context.Context, exec executor.Executor, binary string) bool
+}
+
+var cliToolDefinitions = []cliToolSpec{
+ {
+ Name: "claude-code",
+ Vendor: "Anthropic",
+ Binaries: []string{"claude", "~/.claude/local/claude", "~/.local/bin/claude"},
+ ConfigDirs: []string{"~/.claude"},
+ },
+ {
+ Name: "codex",
+ Vendor: "OpenAI",
+ Binaries: []string{"codex"},
+ ConfigDirs: []string{"~/.codex"},
+ },
+ {
+ Name: "gemini-cli",
+ Vendor: "Google",
+ Binaries: []string{"gemini"},
+ ConfigDirs: []string{"~/.gemini"},
+ },
+ {
+ Name: "amazon-q-cli",
+ Vendor: "Amazon",
+ Binaries: []string{"kiro-cli", "kiro", "q"},
+ ConfigDirs: []string{"~/.q", "~/.kiro", "~/.aws/q"},
+ VerifyFunc: func(ctx context.Context, exec executor.Executor, binary string) bool {
+ stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, binary, "--version")
+ if err != nil {
+ return false
+ }
+ lower := strings.ToLower(stdout)
+ return strings.Contains(lower, "amazon") || strings.Contains(lower, "kiro") || strings.Contains(lower, "q developer")
+ },
+ },
+ {
+ Name: "github-copilot-cli",
+ Vendor: "Microsoft",
+ Binaries: []string{"copilot", "gh-copilot"},
+ ConfigDirs: []string{"~/.config/github-copilot"},
+ },
+ {
+ Name: "microsoft-ai-shell",
+ Vendor: "Microsoft",
+ Binaries: []string{"aish", "ai"},
+ ConfigDirs: []string{"~/.aish"},
+ },
+ {
+ Name: "aider",
+ Vendor: "OpenSource",
+ Binaries: []string{"aider"},
+ ConfigDirs: []string{"~/.aider"},
+ },
+ {
+ Name: "opencode",
+ Vendor: "OpenSource",
+ Binaries: []string{"opencode", "~/.opencode/bin/opencode"},
+ ConfigDirs: []string{"~/.config/opencode"},
+ VersionFlag: "-v",
+ },
+}
+
+// AICLIDetector detects AI CLI tools.
+type AICLIDetector struct {
+ exec executor.Executor
+}
+
+func NewAICLIDetector(exec executor.Executor) *AICLIDetector {
+ return &AICLIDetector{exec: exec}
+}
+
+func (d *AICLIDetector) Detect(ctx context.Context) []model.AITool {
+ homeDir := getHomeDir(d.exec)
+ var results []model.AITool
+
+ for _, spec := range cliToolDefinitions {
+ binaryPath, found := d.findBinary(ctx, spec, homeDir)
+ if !found {
+ continue
+ }
+
+ // Verify if needed (e.g., amazon-q-cli)
+ if spec.VerifyFunc != nil && !spec.VerifyFunc(ctx, d.exec, binaryPath) {
+ continue
+ }
+
+ version := d.getVersion(ctx, spec, binaryPath)
+ configDir := d.findConfigDir(spec, homeDir)
+
+ results = append(results, model.AITool{
+ Name: spec.Name,
+ Vendor: spec.Vendor,
+ Type: "cli_tool",
+ Version: version,
+ BinaryPath: binaryPath,
+ ConfigDir: configDir,
+ })
+ }
+
+ return results
+}
+
+func (d *AICLIDetector) findBinary(ctx context.Context, spec cliToolSpec, homeDir string) (string, bool) {
+ for _, bin := range spec.Binaries {
+ expanded := expandTilde(bin, homeDir)
+ if strings.Contains(expanded, "/") {
+ // Absolute/relative path - check if it exists
+ if d.exec.FileExists(expanded) {
+ return expanded, true
+ }
+ continue
+ }
+ // Search in PATH
+ if path, err := d.exec.LookPath(expanded); err == nil {
+ return path, true
+ }
+ }
+ return "", false
+}
+
+func (d *AICLIDetector) getVersion(ctx context.Context, spec cliToolSpec, binaryPath string) string {
+ flag := "--version"
+ if spec.VersionFlag != "" {
+ flag = spec.VersionFlag
+ }
+ stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, binaryPath, flag)
+ if err != nil {
+ return "unknown"
+ }
+ lines := strings.SplitN(stdout, "\n", 2)
+ if len(lines) > 0 {
+ v := strings.TrimSpace(lines[0])
+ if v != "" {
+ return v
+ }
+ }
+ return "unknown"
+}
+
+func (d *AICLIDetector) findConfigDir(spec cliToolSpec, homeDir string) string {
+ for _, dir := range spec.ConfigDirs {
+ expanded := expandTilde(dir, homeDir)
+ if d.exec.DirExists(expanded) {
+ return expanded
+ }
+ }
+ return ""
+}
+
+func expandTilde(path, homeDir string) string {
+ if strings.HasPrefix(path, "~/") {
+ return homeDir + path[1:]
+ }
+ return path
+}
+
+func getHomeDir(exec executor.Executor) string {
+ u, err := exec.CurrentUser()
+ if err != nil {
+ return "/tmp"
+ }
+ return u.HomeDir
+}
diff --git a/internal/detector/aicli_test.go b/internal/detector/aicli_test.go
new file mode 100644
index 0000000..ba32d49
--- /dev/null
+++ b/internal/detector/aicli_test.go
@@ -0,0 +1,72 @@
+package detector
+
+import (
+ "context"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+func TestAICLIDetector_FindsClaude(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetPath("claude", "/usr/local/bin/claude")
+ mock.SetCommand("1.0.12\n", "", 0, "/usr/local/bin/claude", "--version")
+ mock.SetDir("/Users/testuser/.claude")
+
+ det := NewAICLIDetector(mock)
+ results := det.Detect(context.Background())
+
+ found := false
+ for _, r := range results {
+ if r.Name == "claude-code" {
+ found = true
+ if r.Version != "1.0.12" {
+ t.Errorf("expected version 1.0.12, got %s", r.Version)
+ }
+ if r.BinaryPath != "/usr/local/bin/claude" {
+ t.Errorf("expected /usr/local/bin/claude, got %s", r.BinaryPath)
+ }
+ if r.ConfigDir != "/Users/testuser/.claude" {
+ t.Errorf("expected config dir /Users/testuser/.claude, got %s", r.ConfigDir)
+ }
+ if r.Type != "cli_tool" {
+ t.Errorf("expected type cli_tool, got %s", r.Type)
+ }
+ }
+ }
+ if !found {
+ t.Error("claude-code not found in results")
+ }
+}
+
+func TestAICLIDetector_NoToolsFound(t *testing.T) {
+ mock := executor.NewMock()
+ det := NewAICLIDetector(mock)
+ results := det.Detect(context.Background())
+
+ if len(results) != 0 {
+ t.Errorf("expected 0 tools, got %d", len(results))
+ }
+}
+
+func TestAICLIDetector_VersionUnknown(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetPath("codex", "/usr/local/bin/codex")
+ mock.SetCommand("", "", 1, "/usr/local/bin/codex", "--version")
+
+ det := NewAICLIDetector(mock)
+ results := det.Detect(context.Background())
+
+ found := false
+ for _, r := range results {
+ if r.Name == "codex" {
+ found = true
+ if r.Version != "unknown" {
+ t.Errorf("expected unknown, got %s", r.Version)
+ }
+ }
+ }
+ if !found {
+ t.Error("codex not found")
+ }
+}
diff --git a/internal/detector/extension.go b/internal/detector/extension.go
new file mode 100644
index 0000000..c27715a
--- /dev/null
+++ b/internal/detector/extension.go
@@ -0,0 +1,144 @@
+package detector
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+type ideExtensionSpec struct {
+ IDEName string
+ IDEType string
+ ExtDir string // relative to home (~/.vscode/extensions)
+}
+
+var extensionDirs = []ideExtensionSpec{
+ {"VS Code", "vscode", "~/.vscode/extensions"},
+ {"Cursor", "openvsx", "~/.cursor/extensions"},
+}
+
+// ExtensionDetector collects IDE extensions.
+type ExtensionDetector struct {
+ exec executor.Executor
+}
+
+func NewExtensionDetector(exec executor.Executor) *ExtensionDetector {
+ return &ExtensionDetector{exec: exec}
+}
+
+func (d *ExtensionDetector) Detect(_ context.Context, searchDirs []string) []model.Extension {
+ homeDir := getHomeDir(d.exec)
+ var results []model.Extension
+
+ for _, spec := range extensionDirs {
+ extDir := expandTilde(spec.ExtDir, homeDir)
+ exts := d.collectFromDir(extDir, spec.IDEType)
+ results = append(results, exts...)
+ }
+
+ return results
+}
+
+func (d *ExtensionDetector) collectFromDir(extDir, ideType string) []model.Extension {
+ if !d.exec.DirExists(extDir) {
+ return nil
+ }
+
+ // Load obsolete extensions
+ obsolete := d.loadObsolete(extDir)
+
+ entries, err := d.exec.ReadDir(extDir)
+ if err != nil {
+ return nil
+ }
+
+ var results []model.Extension
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ dirname := entry.Name()
+
+ // Skip special entries
+ if dirname == "extensions.json" || dirname == ".obsolete" {
+ continue
+ }
+
+ // Skip obsolete extensions
+ if obsolete[dirname] {
+ continue
+ }
+
+ ext := parseExtensionDir(dirname, ideType)
+ if ext == nil {
+ continue
+ }
+
+ // Get install date from directory modification time
+ info, err := d.exec.Stat(extDir + "/" + dirname)
+ if err == nil {
+ ext.InstallDate = info.ModTime().Unix()
+ }
+
+ results = append(results, *ext)
+ }
+
+ return results
+}
+
+// parseExtensionDir parses "publisher.name-version[-platform]" directory name.
+func parseExtensionDir(dirname, ideType string) *model.Extension {
+ // Find publisher (everything before first dot)
+ dotIdx := strings.Index(dirname, ".")
+ if dotIdx < 1 {
+ return nil
+ }
+ publisher := dirname[:dotIdx]
+ rest := dirname[dotIdx+1:]
+
+ // Remove platform suffixes
+ for _, suffix := range []string{"-darwin-arm64", "-darwin-x64", "-universal", "-linux-x64", "-linux-arm64", "-win32-x64", "-win32-arm64"} {
+ if strings.HasSuffix(rest, suffix) {
+ rest = strings.TrimSuffix(rest, suffix)
+ break
+ }
+ }
+
+ // Split name-version at last hyphen
+ lastHyphen := strings.LastIndex(rest, "-")
+ if lastHyphen < 1 {
+ return nil
+ }
+ name := rest[:lastHyphen]
+ version := rest[lastHyphen+1:]
+
+ if publisher == "" || name == "" || version == "" {
+ return nil
+ }
+
+ return &model.Extension{
+ ID: publisher + "." + name,
+ Name: name,
+ Version: version,
+ Publisher: publisher,
+ IDEType: ideType,
+ }
+}
+
+// loadObsolete reads the .obsolete file and returns a set of dirname -> true.
+func (d *ExtensionDetector) loadObsolete(extDir string) map[string]bool {
+ obsoleteFile := extDir + "/.obsolete"
+ data, err := d.exec.ReadFile(obsoleteFile)
+ if err != nil {
+ return nil
+ }
+
+ var obsoleteMap map[string]bool
+ if err := json.Unmarshal(data, &obsoleteMap); err != nil {
+ return nil
+ }
+ return obsoleteMap
+}
diff --git a/internal/detector/extension_test.go b/internal/detector/extension_test.go
new file mode 100644
index 0000000..96bdb94
--- /dev/null
+++ b/internal/detector/extension_test.go
@@ -0,0 +1,82 @@
+package detector
+
+import (
+ "testing"
+)
+
+func TestParseExtensionDir_Valid(t *testing.T) {
+ tests := []struct {
+ dirname string
+ publisher string
+ name string
+ version string
+ }{
+ {"ms-python.python-2024.22.0", "ms-python", "python", "2024.22.0"},
+ {"esbenp.prettier-vscode-10.4.0", "esbenp", "prettier-vscode", "10.4.0"},
+ {"dbaeumer.vscode-eslint-3.0.10", "dbaeumer", "vscode-eslint", "3.0.10"},
+ }
+
+ for _, tt := range tests {
+ ext := parseExtensionDir(tt.dirname, "vscode")
+ if ext == nil {
+ t.Errorf("parseExtensionDir(%q) returned nil", tt.dirname)
+ continue
+ }
+ if ext.Publisher != tt.publisher {
+ t.Errorf("publisher: expected %s, got %s", tt.publisher, ext.Publisher)
+ }
+ if ext.Name != tt.name {
+ t.Errorf("name: expected %s, got %s", tt.name, ext.Name)
+ }
+ if ext.Version != tt.version {
+ t.Errorf("version: expected %s, got %s", tt.version, ext.Version)
+ }
+ }
+}
+
+func TestParseExtensionDir_PlatformSuffix(t *testing.T) {
+ ext := parseExtensionDir("ms-python.python-2024.22.0-darwin-arm64", "vscode")
+ if ext == nil {
+ t.Fatal("expected non-nil")
+ }
+ if ext.Version != "2024.22.0" {
+ t.Errorf("expected 2024.22.0, got %s", ext.Version)
+ }
+}
+
+func TestParseExtensionDir_Universal(t *testing.T) {
+ ext := parseExtensionDir("ms-toolsai.jupyter-2024.5.0-universal", "vscode")
+ if ext == nil {
+ t.Fatal("expected non-nil")
+ }
+ if ext.Version != "2024.5.0" {
+ t.Errorf("expected 2024.5.0, got %s", ext.Version)
+ }
+}
+
+func TestParseExtensionDir_Invalid(t *testing.T) {
+ tests := []string{
+ "nopublisher",
+ ".nodot-prefix-1.0.0",
+ "pub.",
+ }
+ for _, tt := range tests {
+ ext := parseExtensionDir(tt, "vscode")
+ if ext != nil {
+ t.Errorf("parseExtensionDir(%q) should return nil, got %+v", tt, ext)
+ }
+ }
+}
+
+func TestParseExtensionDir_ID(t *testing.T) {
+ ext := parseExtensionDir("ms-python.python-2024.22.0", "vscode")
+ if ext == nil {
+ t.Fatal("expected non-nil")
+ }
+ if ext.ID != "ms-python.python" {
+ t.Errorf("expected ms-python.python, got %s", ext.ID)
+ }
+ if ext.IDEType != "vscode" {
+ t.Errorf("expected vscode, got %s", ext.IDEType)
+ }
+}
diff --git a/internal/detector/framework.go b/internal/detector/framework.go
new file mode 100644
index 0000000..5482564
--- /dev/null
+++ b/internal/detector/framework.go
@@ -0,0 +1,116 @@
+package detector
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+type frameworkSpec struct {
+ Name string
+ BinaryName string
+ ProcessName string
+}
+
+var frameworkDefinitions = []frameworkSpec{
+ {"ollama", "ollama", "ollama"},
+ {"localai", "local-ai", "local-ai"},
+ {"lm-studio", "lm-studio", "lm-studio"},
+ {"text-generation-webui", "textgen", "textgen"},
+}
+
+// FrameworkDetector detects AI frameworks and runtimes.
+type FrameworkDetector struct {
+ exec executor.Executor
+}
+
+func NewFrameworkDetector(exec executor.Executor) *FrameworkDetector {
+ return &FrameworkDetector{exec: exec}
+}
+
+func (d *FrameworkDetector) Detect(ctx context.Context) []model.AITool {
+ var results []model.AITool
+
+ for _, spec := range frameworkDefinitions {
+ binaryPath, err := d.exec.LookPath(spec.BinaryName)
+ if err != nil {
+ continue
+ }
+
+ version := d.getVersion(ctx, binaryPath)
+ isRunning := d.isProcessRunning(ctx, spec.ProcessName)
+
+ results = append(results, model.AITool{
+ Name: spec.Name,
+ Vendor: "Unknown",
+ Type: "framework",
+ Version: version,
+ BinaryPath: binaryPath,
+ IsRunning: &isRunning,
+ })
+ }
+
+ // LM Studio as a GUI application
+ if tool, ok := d.detectLMStudioApp(ctx); ok {
+ // Avoid duplicating if already found via binary
+ found := false
+ for _, r := range results {
+ if r.Name == "lm-studio" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ results = append(results, tool)
+ }
+ }
+
+ return results
+}
+
+func (d *FrameworkDetector) getVersion(ctx context.Context, binaryPath string) string {
+ stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, binaryPath, "--version")
+ if err != nil {
+ return "unknown"
+ }
+ lines := strings.SplitN(stdout, "\n", 2)
+ if len(lines) > 0 {
+ v := strings.TrimSpace(lines[0])
+ if v != "" {
+ return v
+ }
+ }
+ return "unknown"
+}
+
+func (d *FrameworkDetector) isProcessRunning(ctx context.Context, processName string) bool {
+ _, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-x", processName)
+ return exitCode == 0
+}
+
+func (d *FrameworkDetector) detectLMStudioApp(ctx context.Context) (model.AITool, bool) {
+ appPath := "/Applications/LM Studio.app"
+ if !d.exec.DirExists(appPath) {
+ return model.AITool{}, false
+ }
+
+ version := readPlistVersion(ctx, d.exec, appPath+"/Contents/Info.plist")
+
+ isRunning := false
+ _, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-f", "LM Studio")
+ if exitCode == 0 {
+ isRunning = true
+ }
+
+ return model.AITool{
+ Name: "lm-studio",
+ Vendor: "LM Studio",
+ Type: "framework",
+ Version: version,
+ BinaryPath: appPath,
+ IsRunning: &isRunning,
+ }, true
+}
diff --git a/internal/detector/framework_test.go b/internal/detector/framework_test.go
new file mode 100644
index 0000000..8c519a9
--- /dev/null
+++ b/internal/detector/framework_test.go
@@ -0,0 +1,76 @@
+package detector
+
+import (
+ "context"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+func TestFrameworkDetector_FindsOllama(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetPath("ollama", "/usr/local/bin/ollama")
+ mock.SetCommand("0.5.4\n", "", 0, "/usr/local/bin/ollama", "--version")
+ mock.SetCommand("12345\n", "", 0, "pgrep", "-x", "ollama")
+
+ det := NewFrameworkDetector(mock)
+ results := det.Detect(context.Background())
+
+ found := false
+ for _, r := range results {
+ if r.Name == "ollama" {
+ found = true
+ if r.Type != "framework" {
+ t.Errorf("expected framework, got %s", r.Type)
+ }
+ if r.IsRunning == nil || !*r.IsRunning {
+ t.Error("expected is_running=true")
+ }
+ }
+ }
+ if !found {
+ t.Error("ollama not found")
+ }
+}
+
+func TestFrameworkDetector_NotRunning(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetPath("ollama", "/usr/local/bin/ollama")
+ mock.SetCommand("0.5.4\n", "", 0, "/usr/local/bin/ollama", "--version")
+ mock.SetCommand("", "", 1, "pgrep", "-x", "ollama") // not running
+
+ det := NewFrameworkDetector(mock)
+ results := det.Detect(context.Background())
+
+ for _, r := range results {
+ if r.Name == "ollama" {
+ if r.IsRunning == nil || *r.IsRunning {
+ t.Error("expected is_running=false")
+ }
+ }
+ }
+}
+
+func TestFrameworkDetector_LMStudioApp(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetDir("/Applications/LM Studio.app")
+ mock.SetFile("/Applications/LM Studio.app/Contents/Info.plist", []byte{})
+ mock.SetCommand("0.3.1", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/LM Studio.app/Contents/Info.plist")
+ mock.SetCommand("", "", 1, "pgrep", "-f", "LM Studio") // not running
+
+ det := NewFrameworkDetector(mock)
+ results := det.Detect(context.Background())
+
+ found := false
+ for _, r := range results {
+ if r.Name == "lm-studio" {
+ found = true
+ if r.Version != "0.3.1" {
+ t.Errorf("expected 0.3.1, got %s", r.Version)
+ }
+ }
+ }
+ if !found {
+ t.Error("lm-studio not found")
+ }
+}
diff --git a/internal/detector/ide.go b/internal/detector/ide.go
new file mode 100644
index 0000000..975eb26
--- /dev/null
+++ b/internal/detector/ide.go
@@ -0,0 +1,97 @@
+package detector
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+type ideSpec struct {
+ AppName string
+ IDEType string
+ Vendor string
+ AppPath string
+ BinaryPath string // relative to AppPath
+ VersionFlag string
+}
+
+var ideDefinitions = []ideSpec{
+ {"Visual Studio Code", "vscode", "Microsoft", "/Applications/Visual Studio Code.app", "Contents/Resources/app/bin/code", "--version"},
+ {"Cursor", "cursor", "Cursor", "/Applications/Cursor.app", "Contents/Resources/app/bin/cursor", "--version"},
+ {"Windsurf", "windsurf", "Codeium", "/Applications/Windsurf.app", "Contents/MacOS/Windsurf", "--version"},
+ {"Antigravity", "antigravity", "Google", "/Applications/Antigravity.app", "Contents/MacOS/Antigravity", "--version"},
+ {"Zed", "zed", "Zed", "/Applications/Zed.app", "Contents/MacOS/zed", ""},
+ {"Claude", "claude_desktop", "Anthropic", "/Applications/Claude.app", "", ""},
+ {"Microsoft Copilot", "microsoft_copilot_desktop", "Microsoft", "/Applications/Copilot.app", "", ""},
+}
+
+// IDEDetector detects installed IDEs and AI desktop apps.
+type IDEDetector struct {
+ exec executor.Executor
+}
+
+func NewIDEDetector(exec executor.Executor) *IDEDetector {
+ return &IDEDetector{exec: exec}
+}
+
+func (d *IDEDetector) Detect(ctx context.Context) []model.IDE {
+ var results []model.IDE
+
+ for _, spec := range ideDefinitions {
+ if !d.exec.DirExists(spec.AppPath) {
+ continue
+ }
+
+ version := "unknown"
+
+ // Try version from binary
+ if spec.BinaryPath != "" && spec.VersionFlag != "" {
+ binaryFull := spec.AppPath + "/" + spec.BinaryPath
+ if d.exec.FileExists(binaryFull) {
+ stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, binaryFull, spec.VersionFlag)
+ if err == nil {
+ lines := strings.SplitN(stdout, "\n", 2)
+ if len(lines) > 0 {
+ v := strings.TrimSpace(lines[0])
+ if v != "" {
+ version = v
+ }
+ }
+ }
+ }
+ }
+
+ // Fallback: Info.plist
+ if version == "unknown" {
+ version = readPlistVersion(ctx, d.exec, spec.AppPath+"/Contents/Info.plist")
+ }
+
+ results = append(results, model.IDE{
+ IDEType: spec.IDEType,
+ Version: version,
+ InstallPath: spec.AppPath,
+ Vendor: spec.Vendor,
+ IsInstalled: true,
+ })
+ }
+
+ return results
+}
+
+// readPlistVersion reads CFBundleShortVersionString from an Info.plist.
+func readPlistVersion(ctx context.Context, exec executor.Executor, plistPath string) string {
+ if !exec.FileExists(plistPath) {
+ return "unknown"
+ }
+ stdout, _, _, err := exec.Run(ctx, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", plistPath)
+ if err == nil {
+ v := strings.TrimSpace(stdout)
+ if v != "" {
+ return v
+ }
+ }
+ return "unknown"
+}
diff --git a/internal/detector/ide_test.go b/internal/detector/ide_test.go
new file mode 100644
index 0000000..2178d5f
--- /dev/null
+++ b/internal/detector/ide_test.go
@@ -0,0 +1,79 @@
+package detector
+
+import (
+ "context"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+func TestIDEDetector_FindsVSCode(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetDir("/Applications/Visual Studio Code.app")
+ mock.SetFile("/Applications/Visual Studio Code.app/Contents/Info.plist", []byte{})
+ mock.SetCommand("1.96.0\n", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Visual Studio Code.app/Contents/Info.plist")
+
+ det := NewIDEDetector(mock)
+ results := det.Detect(context.Background())
+
+ if len(results) != 1 {
+ t.Fatalf("expected 1 IDE, got %d", len(results))
+ }
+ if results[0].IDEType != "vscode" {
+ t.Errorf("expected vscode, got %s", results[0].IDEType)
+ }
+ if results[0].Version != "1.96.0" {
+ t.Errorf("expected 1.96.0, got %s", results[0].Version)
+ }
+ if results[0].Vendor != "Microsoft" {
+ t.Errorf("expected Microsoft, got %s", results[0].Vendor)
+ }
+ if !results[0].IsInstalled {
+ t.Error("expected is_installed=true")
+ }
+}
+
+func TestIDEDetector_NotInstalled(t *testing.T) {
+ mock := executor.NewMock()
+ det := NewIDEDetector(mock)
+ results := det.Detect(context.Background())
+
+ if len(results) != 0 {
+ t.Errorf("expected 0 IDEs, got %d", len(results))
+ }
+}
+
+func TestIDEDetector_VersionFromBinary(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetDir("/Applications/Cursor.app")
+ mock.SetFile("/Applications/Cursor.app/Contents/Resources/app/bin/cursor", []byte{})
+ mock.SetCommand("0.50.1\n1234abcd\nx64", "", 0, "/Applications/Cursor.app/Contents/Resources/app/bin/cursor", "--version")
+
+ det := NewIDEDetector(mock)
+ results := det.Detect(context.Background())
+
+ if len(results) != 1 {
+ t.Fatalf("expected 1 IDE, got %d", len(results))
+ }
+ if results[0].Version != "0.50.1" {
+ t.Errorf("expected 0.50.1, got %s", results[0].Version)
+ }
+}
+
+func TestIDEDetector_MultipleIDEs(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetDir("/Applications/Visual Studio Code.app")
+ mock.SetFile("/Applications/Visual Studio Code.app/Contents/Info.plist", []byte{})
+ mock.SetCommand("1.96.0", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Visual Studio Code.app/Contents/Info.plist")
+
+ mock.SetDir("/Applications/Claude.app")
+ mock.SetFile("/Applications/Claude.app/Contents/Info.plist", []byte{})
+ mock.SetCommand("0.7.1", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Claude.app/Contents/Info.plist")
+
+ det := NewIDEDetector(mock)
+ results := det.Detect(context.Background())
+
+ if len(results) != 2 {
+ t.Fatalf("expected 2 IDEs, got %d", len(results))
+ }
+}
diff --git a/internal/detector/mcp.go b/internal/detector/mcp.go
new file mode 100644
index 0000000..b6e1f6e
--- /dev/null
+++ b/internal/detector/mcp.go
@@ -0,0 +1,204 @@
+package detector
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+type mcpConfigSpec struct {
+ SourceName string
+ ConfigPath string // relative to home; uses ~ prefix
+ Vendor string
+}
+
+var mcpConfigDefinitions = []mcpConfigSpec{
+ {"claude_desktop", "~/Library/Application Support/Claude/claude_desktop_config.json", "Anthropic"},
+ {"claude_code", "~/.claude/settings.json", "Anthropic"},
+ {"claude_code", "~/.claude.json", "Anthropic"},
+ {"cursor", "~/.cursor/mcp.json", "Cursor"},
+ {"windsurf", "~/.codeium/windsurf/mcp_config.json", "Codeium"},
+ {"antigravity", "~/.gemini/antigravity/mcp_config.json", "Google"},
+ {"zed", "~/.config/zed/settings.json", "Zed"},
+ {"open_interpreter", "~/.config/open-interpreter/config.yaml", "OpenSource"},
+ {"codex", "~/.codex/config.toml", "OpenAI"},
+}
+
+// MCPDetector collects MCP configuration files.
+type MCPDetector struct {
+ exec executor.Executor
+}
+
+func NewMCPDetector(exec executor.Executor) *MCPDetector {
+ return &MCPDetector{exec: exec}
+}
+
+// Detect finds MCP configs. If enterprise is true, includes base64-encoded content.
+// Returns community-mode MCPConfig structs (enterprise mode uses MCPConfigEnterprise separately).
+func (d *MCPDetector) Detect(_ context.Context, userIdentity string, enterprise bool) []model.MCPConfig {
+ homeDir := getHomeDir(d.exec)
+ var results []model.MCPConfig
+
+ for _, spec := range mcpConfigDefinitions {
+ configPath := expandTilde(spec.ConfigPath, homeDir)
+
+ if !d.exec.FileExists(configPath) {
+ continue
+ }
+
+ results = append(results, model.MCPConfig{
+ ConfigSource: spec.SourceName,
+ ConfigPath: configPath,
+ Vendor: spec.Vendor,
+ })
+ }
+
+ return results
+}
+
+// DetectEnterprise returns enterprise-mode MCP configs with base64 content.
+func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnterprise {
+ homeDir := getHomeDir(d.exec)
+ var results []model.MCPConfigEnterprise
+
+ for _, spec := range mcpConfigDefinitions {
+ configPath := expandTilde(spec.ConfigPath, homeDir)
+
+ if !d.exec.FileExists(configPath) {
+ continue
+ }
+
+ content, err := d.exec.ReadFile(configPath)
+ if err != nil || len(content) == 0 {
+ continue
+ }
+
+ // Filter JSON configs to extract only MCP-relevant fields
+ filteredContent := d.filterMCPContent(spec.SourceName, configPath, content)
+ contentBase64 := base64.StdEncoding.EncodeToString(filteredContent)
+
+ results = append(results, model.MCPConfigEnterprise{
+ ConfigSource: spec.SourceName,
+ ConfigPath: configPath,
+ Vendor: spec.Vendor,
+ ConfigContentBase64: contentBase64,
+ })
+ }
+
+ return results
+}
+
+// filterMCPContent extracts MCP-relevant fields from a config file.
+func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content []byte) []byte {
+ if !strings.HasSuffix(configPath, ".json") {
+ return content // Return as-is for TOML/YAML
+ }
+
+ jsonInput := content
+
+ // Strip JSONC comments for Zed
+ if sourceName == "zed" {
+ jsonInput = stripJSONCComments(jsonInput)
+ }
+
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(jsonInput, &raw); err != nil {
+ return content // Can't parse, return as-is
+ }
+
+ filtered := d.extractMCPServers(raw)
+ if filtered == nil {
+ return content
+ }
+
+ out, err := json.Marshal(filtered)
+ if err != nil {
+ return content
+ }
+ return out
+}
+
+// extractMCPServers extracts mcpServers/context_servers, keeping only command/args/serverUrl/url.
+func (d *MCPDetector) extractMCPServers(raw map[string]json.RawMessage) map[string]any {
+ // Try mcpServers
+ if servers, ok := raw["mcpServers"]; ok {
+ return map[string]any{"mcpServers": filterServerFields(servers)}
+ }
+ // Try context_servers
+ if servers, ok := raw["context_servers"]; ok {
+ return map[string]any{"context_servers": filterServerFields(servers)}
+ }
+ return nil
+}
+
+// filterServerFields keeps only command, args, serverUrl, url from each server entry.
+func filterServerFields(serversRaw json.RawMessage) map[string]any {
+ var servers map[string]map[string]any
+ if err := json.Unmarshal(serversRaw, &servers); err != nil {
+ return nil
+ }
+
+ result := make(map[string]any)
+ allowedKeys := map[string]bool{"command": true, "args": true, "serverUrl": true, "url": true}
+
+ for name, serverConfig := range servers {
+ filtered := make(map[string]any)
+ for k, v := range serverConfig {
+ if allowedKeys[k] {
+ filtered[k] = v
+ }
+ }
+ result[name] = filtered
+ }
+ return result
+}
+
+// stripJSONCComments removes // and /* */ comments from JSONC content,
+// respecting quoted strings (won't strip // inside "https://...").
+func stripJSONCComments(input []byte) []byte {
+ var out []byte
+ i := 0
+ for i < len(input) {
+ // Skip over strings — don't modify content inside quotes
+ if input[i] == '"' {
+ out = append(out, input[i])
+ i++
+ for i < len(input) {
+ out = append(out, input[i])
+ if input[i] == '\\' && i+1 < len(input) {
+ i++
+ out = append(out, input[i])
+ } else if input[i] == '"' {
+ break
+ }
+ i++
+ }
+ i++
+ continue
+ }
+ // Block comment
+ if i+1 < len(input) && input[i] == '/' && input[i+1] == '*' {
+ i += 2
+ for i+1 < len(input) && !(input[i] == '*' && input[i+1] == '/') {
+ i++
+ }
+ i += 2 // skip */
+ continue
+ }
+ // Line comment
+ if i+1 < len(input) && input[i] == '/' && input[i+1] == '/' {
+ i += 2
+ for i < len(input) && input[i] != '\n' {
+ i++
+ }
+ continue
+ }
+ out = append(out, input[i])
+ i++
+ }
+ return out
+}
diff --git a/internal/detector/mcp_test.go b/internal/detector/mcp_test.go
new file mode 100644
index 0000000..682dd5c
--- /dev/null
+++ b/internal/detector/mcp_test.go
@@ -0,0 +1,83 @@
+package detector
+
+import (
+ "context"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+func TestMCPDetector_FindsConfigs(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetFile("/Users/testuser/Library/Application Support/Claude/claude_desktop_config.json", []byte(`{"mcpServers":{}}`))
+ mock.SetFile("/Users/testuser/.cursor/mcp.json", []byte(`{"mcpServers":{}}`))
+
+ det := NewMCPDetector(mock)
+ results := det.Detect(context.Background(), "testuser", false)
+
+ if len(results) != 2 {
+ t.Fatalf("expected 2 configs, got %d", len(results))
+ }
+ if results[0].ConfigSource != "claude_desktop" {
+ t.Errorf("expected claude_desktop, got %s", results[0].ConfigSource)
+ }
+ if results[1].ConfigSource != "cursor" {
+ t.Errorf("expected cursor, got %s", results[1].ConfigSource)
+ }
+}
+
+func TestMCPDetector_NoConfigs(t *testing.T) {
+ mock := executor.NewMock()
+ det := NewMCPDetector(mock)
+ results := det.Detect(context.Background(), "testuser", false)
+
+ if len(results) != 0 {
+ t.Errorf("expected 0 configs, got %d", len(results))
+ }
+}
+
+func TestStripJSONCComments(t *testing.T) {
+ input := []byte(`{
+ // This is a comment
+ "key": "value", /* block comment */
+ "key2": "value2"
+}`)
+
+ result := stripJSONCComments(input)
+ if len(result) == 0 {
+ t.Error("expected non-empty result")
+ }
+ // Should not contain comments
+ if containsString(string(result), "//") || containsString(string(result), "/*") {
+ t.Error("comments not stripped")
+ }
+}
+
+func containsString(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
+}
+
+func containsSubstr(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
+
+func TestMCPDetector_Enterprise(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetFile("/Users/testuser/Library/Application Support/Claude/claude_desktop_config.json",
+ []byte(`{"mcpServers":{"server1":{"command":"node","args":["server.js"],"env":{"SECRET":"key"}}}}`))
+
+ det := NewMCPDetector(mock)
+ results := det.DetectEnterprise(context.Background())
+
+ if len(results) != 1 {
+ t.Fatalf("expected 1 enterprise config, got %d", len(results))
+ }
+ if results[0].ConfigContentBase64 == "" {
+ t.Error("expected non-empty base64 content")
+ }
+}
diff --git a/internal/detector/nodepm.go b/internal/detector/nodepm.go
new file mode 100644
index 0000000..a26402e
--- /dev/null
+++ b/internal/detector/nodepm.go
@@ -0,0 +1,60 @@
+package detector
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+type pmSpec struct {
+ Name string
+ Binary string
+ VersionCmd string
+}
+
+var packageManagers = []pmSpec{
+ {"npm", "npm", "--version"},
+ {"yarn", "yarn", "--version"},
+ {"pnpm", "pnpm", "--version"},
+ {"bun", "bun", "--version"},
+}
+
+// NodePMDetector detects installed Node.js package managers.
+type NodePMDetector struct {
+ exec executor.Executor
+}
+
+func NewNodePMDetector(exec executor.Executor) *NodePMDetector {
+ return &NodePMDetector{exec: exec}
+}
+
+func (d *NodePMDetector) DetectManagers(ctx context.Context) []model.PkgManager {
+ var results []model.PkgManager
+
+ for _, pm := range packageManagers {
+ path, err := d.exec.LookPath(pm.Binary)
+ if err != nil {
+ continue
+ }
+
+ version := "unknown"
+ stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, pm.Binary, pm.VersionCmd)
+ if err == nil {
+ v := strings.TrimSpace(stdout)
+ if v != "" {
+ version = v
+ }
+ }
+
+ results = append(results, model.PkgManager{
+ Name: pm.Name,
+ Version: version,
+ Path: path,
+ })
+ }
+
+ return results
+}
diff --git a/internal/detector/nodepm_test.go b/internal/detector/nodepm_test.go
new file mode 100644
index 0000000..cb4687d
--- /dev/null
+++ b/internal/detector/nodepm_test.go
@@ -0,0 +1,76 @@
+package detector
+
+import (
+ "context"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+func TestNodePMDetector_FindsNPM(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetPath("npm", "/usr/local/bin/npm")
+ mock.SetCommand("10.2.0\n", "", 0, "npm", "--version")
+
+ det := NewNodePMDetector(mock)
+ results := det.DetectManagers(context.Background())
+
+ if len(results) < 1 {
+ t.Fatal("expected at least 1 package manager")
+ }
+ if results[0].Name != "npm" {
+ t.Errorf("expected npm, got %s", results[0].Name)
+ }
+ if results[0].Version != "10.2.0" {
+ t.Errorf("expected 10.2.0, got %s", results[0].Version)
+ }
+}
+
+func TestNodePMDetector_Multiple(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetPath("npm", "/usr/local/bin/npm")
+ mock.SetCommand("10.2.0\n", "", 0, "npm", "--version")
+ mock.SetPath("yarn", "/usr/local/bin/yarn")
+ mock.SetCommand("1.22.19\n", "", 0, "yarn", "--version")
+
+ det := NewNodePMDetector(mock)
+ results := det.DetectManagers(context.Background())
+
+ if len(results) != 2 {
+ t.Fatalf("expected 2 package managers, got %d", len(results))
+ }
+}
+
+func TestNodePMDetector_NoneFound(t *testing.T) {
+ mock := executor.NewMock()
+ det := NewNodePMDetector(mock)
+ results := det.DetectManagers(context.Background())
+
+ if len(results) != 0 {
+ t.Errorf("expected 0 package managers, got %d", len(results))
+ }
+}
+
+func TestDetectProjectPM(t *testing.T) {
+ tests := []struct {
+ name string
+ file string
+ expected string
+ }{
+ {"bun lock", "/project/bun.lock", "bun"},
+ {"pnpm lock", "/project/pnpm-lock.yaml", "pnpm"},
+ {"yarn lock", "/project/yarn.lock", "yarn"},
+ {"npm lock", "/project/package-lock.json", "npm"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetFile(tt.file, []byte{})
+ got := DetectProjectPM(mock, "/project")
+ if got != tt.expected {
+ t.Errorf("expected %s, got %s", tt.expected, got)
+ }
+ })
+ }
+}
diff --git a/internal/detector/nodeproject.go b/internal/detector/nodeproject.go
new file mode 100644
index 0000000..fb9db5e
--- /dev/null
+++ b/internal/detector/nodeproject.go
@@ -0,0 +1,84 @@
+package detector
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+const maxNodeProjects = 1000
+
+// NodeProjectDetector scans for Node.js projects.
+type NodeProjectDetector struct {
+ exec executor.Executor
+}
+
+func NewNodeProjectDetector(exec executor.Executor) *NodeProjectDetector {
+ return &NodeProjectDetector{exec: exec}
+}
+
+// CountProjects counts the number of Node.js projects found under the given directories.
+// It finds package.json files (excluding node_modules) up to a limit.
+func (d *NodeProjectDetector) CountProjects(_ context.Context, searchDirs []string) int {
+ count := 0
+ for _, dir := range searchDirs {
+ count += d.countInDir(dir)
+ if count >= maxNodeProjects {
+ return maxNodeProjects
+ }
+ }
+ return count
+}
+
+func (d *NodeProjectDetector) countInDir(dir string) int {
+ count := 0
+ _ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return nil // skip inaccessible dirs
+ }
+ if entry.IsDir() {
+ name := entry.Name()
+ // Skip node_modules, hidden dirs, and other irrelevant dirs
+ if name == "node_modules" || name == ".git" || name == ".cache" ||
+ strings.HasPrefix(name, ".") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if entry.Name() == "package.json" {
+ count++
+ if count >= maxNodeProjects {
+ return filepath.SkipAll
+ }
+ }
+ return nil
+ })
+ return count
+}
+
+// DetectProjectPM detects which package manager a project uses based on lock files.
+func DetectProjectPM(exec executor.Executor, projectDir string) string {
+ if strings.Contains(projectDir, "/.bun/install/") {
+ return "bun"
+ }
+ if exec.FileExists(filepath.Join(projectDir, "bun.lock")) || exec.FileExists(filepath.Join(projectDir, "bun.lockb")) {
+ return "bun"
+ }
+ if exec.FileExists(filepath.Join(projectDir, "pnpm-lock.yaml")) {
+ return "pnpm"
+ }
+ if exec.FileExists(filepath.Join(projectDir, "yarn.lock")) {
+ // Distinguish Yarn Classic from Yarn Berry
+ if exec.FileExists(filepath.Join(projectDir, ".yarnrc.yml")) || exec.DirExists(filepath.Join(projectDir, ".yarn", "releases")) {
+ return "yarn-berry"
+ }
+ return "yarn"
+ }
+ if exec.FileExists(filepath.Join(projectDir, "package-lock.json")) {
+ return "npm"
+ }
+ return "npm" // default
+}
diff --git a/internal/detector/nodescan.go b/internal/detector/nodescan.go
new file mode 100644
index 0000000..141affd
--- /dev/null
+++ b/internal/detector/nodescan.go
@@ -0,0 +1,327 @@
+package detector
+
+import (
+ "context"
+ "encoding/base64"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+ "github.com/step-security/dev-machine-guard/internal/progress"
+)
+
+const defaultMaxProjectScanBytes = 500 * 1024 * 1024 // 500MB total limit
+
+// getMaxProjectScanBytes returns the size limit, overridable via
+// STEPSEC_MAX_NODE_SCAN_BYTES environment variable.
+func getMaxProjectScanBytes() int64 {
+ if v := os.Getenv("STEPSEC_MAX_NODE_SCAN_BYTES"); v != "" {
+ if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
+ return n
+ }
+ }
+ return defaultMaxProjectScanBytes
+}
+
+// NodeScanner performs enterprise-mode node scanning (raw output, base64 encoded).
+type NodeScanner struct {
+ exec executor.Executor
+ log *progress.Logger
+}
+
+func NewNodeScanner(exec executor.Executor, log *progress.Logger) *NodeScanner {
+ return &NodeScanner{exec: exec, log: log}
+}
+
+// ScanGlobalPackages runs npm/yarn/pnpm list -g and returns raw base64-encoded results.
+func (s *NodeScanner) ScanGlobalPackages(ctx context.Context) []model.NodeScanResult {
+ var results []model.NodeScanResult
+
+ s.log.Progress(" Checking npm global packages...")
+ if r, ok := s.scanNPMGlobal(ctx); ok {
+ results = append(results, r)
+ }
+
+ s.log.Progress(" Checking yarn global packages...")
+ if r, ok := s.scanYarnGlobal(ctx); ok {
+ results = append(results, r)
+ }
+
+ s.log.Progress(" Checking pnpm global packages...")
+ if r, ok := s.scanPnpmGlobal(ctx); ok {
+ results = append(results, r)
+ }
+
+ return results
+}
+
+func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult, bool) {
+ if _, err := s.exec.LookPath("npm"); err != nil {
+ return model.NodeScanResult{}, false
+ }
+
+ version := s.getVersion(ctx, "npm", "--version")
+ prefix := s.getOutput(ctx, "npm", "config", "get", "prefix")
+ if prefix == "" {
+ return model.NodeScanResult{}, false
+ }
+
+ start := time.Now()
+ stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "npm", "list", "-g", "--json", "--depth=3")
+ duration := time.Since(start).Milliseconds()
+
+ errMsg := ""
+ if exitCode != 0 {
+ errMsg = "npm list -g command failed with exit code"
+ }
+
+ return model.NodeScanResult{
+ ProjectPath: prefix,
+ PackageManager: "npm",
+ PMVersion: version,
+ WorkingDirectory: prefix,
+ RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)),
+ RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)),
+ Error: errMsg,
+ ExitCode: exitCode,
+ ScanDurationMs: duration,
+ }, true
+}
+
+func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult, bool) {
+ if _, err := s.exec.LookPath("yarn"); err != nil {
+ return model.NodeScanResult{}, false
+ }
+
+ version := s.getVersion(ctx, "yarn", "--version")
+ globalDir := s.getOutput(ctx, "yarn", "global", "dir")
+ if globalDir == "" {
+ return model.NodeScanResult{}, false
+ }
+
+ start := time.Now()
+ stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "bash", "-c", "cd '"+globalDir+"' && yarn list --json --depth=0")
+ duration := time.Since(start).Milliseconds()
+
+ errMsg := ""
+ if exitCode != 0 {
+ errMsg = "yarn global list command failed"
+ }
+
+ return model.NodeScanResult{
+ ProjectPath: globalDir,
+ PackageManager: "yarn",
+ PMVersion: version,
+ WorkingDirectory: globalDir,
+ RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)),
+ RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)),
+ Error: errMsg,
+ ExitCode: exitCode,
+ ScanDurationMs: duration,
+ }, true
+}
+
+func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult, bool) {
+ if _, err := s.exec.LookPath("pnpm"); err != nil {
+ return model.NodeScanResult{}, false
+ }
+
+ version := s.getVersion(ctx, "pnpm", "--version")
+ globalDir := s.getOutput(ctx, "pnpm", "root", "-g")
+ if globalDir == "" {
+ return model.NodeScanResult{}, false
+ }
+ globalDir = filepath.Dir(globalDir)
+
+ start := time.Now()
+ stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", "--depth=3")
+ duration := time.Since(start).Milliseconds()
+
+ errMsg := ""
+ if exitCode != 0 {
+ errMsg = "pnpm list -g command failed"
+ }
+
+ return model.NodeScanResult{
+ ProjectPath: globalDir,
+ PackageManager: "pnpm",
+ PMVersion: version,
+ WorkingDirectory: globalDir,
+ RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)),
+ RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)),
+ Error: errMsg,
+ ExitCode: exitCode,
+ ScanDurationMs: duration,
+ }, true
+}
+
+// projectEntry holds a discovered package.json with its modification time for sorting.
+type projectEntry struct {
+ dir string
+ modTime int64
+}
+
+// ScanProjects finds package.json files, sorts by most recently modified, then scans.
+// Respects the size limit (default 500MB, override via STEPSEC_MAX_NODE_SCAN_BYTES).
+func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []model.NodeScanResult {
+ // Phase 1: Discover all package.json files
+ var projects []projectEntry
+ for _, dir := range searchDirs {
+ s.log.Progress(" Searching in: %s", dir)
+ _ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if entry.IsDir() {
+ name := entry.Name()
+ if name == "node_modules" || name == ".git" || name == ".cache" ||
+ strings.HasPrefix(name, ".") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if entry.Name() != "package.json" {
+ return nil
+ }
+ projectDir := filepath.Dir(path)
+ if strings.Contains(projectDir, "/node_modules/") {
+ return nil
+ }
+ // Get modification time for sorting
+ modTime := int64(0)
+ if info, err := entry.Info(); err == nil {
+ modTime = info.ModTime().Unix()
+ }
+ projects = append(projects, projectEntry{dir: projectDir, modTime: modTime})
+ return nil
+ })
+ }
+
+ // Phase 2: Sort by modification time descending (most recent first)
+ sort.Slice(projects, func(i, j int) bool {
+ return projects[i].modTime > projects[j].modTime
+ })
+
+ // Phase 3: Scan in order, respecting limits
+ maxBytes := getMaxProjectScanBytes()
+ var results []model.NodeScanResult
+ totalSize := int64(0)
+
+ for i, p := range projects {
+ if i >= maxNodeProjects {
+ s.log.Progress(" Reached maximum of %d projects, stopping search", maxNodeProjects)
+ break
+ }
+ if totalSize > maxBytes {
+ s.log.Progress(" Reached data size limit (%d bytes collected, limit: %d bytes)", totalSize, maxBytes)
+ s.log.Progress(" Skipping remaining projects (prioritized by most recently modified)")
+ break
+ }
+
+ s.log.Progress(" Found project: %s", p.dir)
+ pm := DetectProjectPM(s.exec, p.dir)
+ s.log.Progress(" Package manager: %s", pm)
+
+ r := s.scanProject(ctx, p.dir)
+ resultSize := int64(len(r.RawStdoutBase64)) + int64(len(r.RawStderrBase64))
+
+ if totalSize+resultSize > maxBytes {
+ s.log.Progress(" Reached data size limit (%d bytes collected, limit: %d bytes)", totalSize, maxBytes)
+ s.log.Progress(" Skipping remaining projects (prioritized by most recently modified)")
+ break
+ }
+
+ totalSize += resultSize
+ results = append(results, r)
+ }
+
+ return results
+}
+
+func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.NodeScanResult {
+ pm := DetectProjectPM(s.exec, projectDir)
+ version := ""
+
+ var cmd string
+ var args []string
+
+ switch pm {
+ case "npm":
+ version = s.getVersion(ctx, "npm", "--version")
+ cmd = "npm"
+ args = []string{"ls", "--json", "--depth=3"}
+ case "yarn":
+ version = s.getVersion(ctx, "yarn", "--version")
+ cmd = "yarn"
+ args = []string{"list", "--json"}
+ case "yarn-berry":
+ version = s.getVersion(ctx, "yarn", "--version")
+ cmd = "yarn"
+ args = []string{"info", "--all", "--json"}
+ case "pnpm":
+ version = s.getVersion(ctx, "pnpm", "--version")
+ cmd = "pnpm"
+ args = []string{"ls", "--json", "--depth=3"}
+ case "bun":
+ version = s.getVersion(ctx, "bun", "--version")
+ cmd = "bun"
+ args = []string{"pm", "ls", "--all"}
+ default:
+ return model.NodeScanResult{
+ ProjectPath: projectDir,
+ PackageManager: pm,
+ Error: "unsupported package manager",
+ ExitCode: 1,
+ }
+ }
+
+ start := time.Now()
+ shellCmd := "cd " + shellQuote(projectDir) + " && " + cmd
+ for _, a := range args {
+ shellCmd += " " + a
+ }
+ stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 30*time.Second, "bash", "-c", shellCmd)
+ duration := time.Since(start).Milliseconds()
+
+ errMsg := ""
+ if exitCode != 0 {
+ errMsg = cmd + " command failed with exit code"
+ }
+
+ return model.NodeScanResult{
+ ProjectPath: projectDir,
+ PackageManager: pm,
+ PMVersion: version,
+ WorkingDirectory: projectDir,
+ RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)),
+ RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)),
+ Error: errMsg,
+ ExitCode: exitCode,
+ ScanDurationMs: duration,
+ }
+}
+
+func (s *NodeScanner) getVersion(ctx context.Context, binary, flag string) string {
+ stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, flag)
+ if err != nil {
+ return "unknown"
+ }
+ return strings.TrimSpace(stdout)
+}
+
+func (s *NodeScanner) getOutput(ctx context.Context, binary string, args ...string) string {
+ stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, args...)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(stdout)
+}
+
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
+}
diff --git a/internal/device/device.go b/internal/device/device.go
new file mode 100644
index 0000000..ba0d366
--- /dev/null
+++ b/internal/device/device.go
@@ -0,0 +1,88 @@
+package device
+
+import (
+ "context"
+ "strings"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+// Gather collects device information (hostname, serial, OS version, user identity).
+func Gather(ctx context.Context, exec executor.Executor) model.Device {
+ hostname, _ := exec.Hostname()
+ serial := getSerialNumber(ctx, exec)
+ osVersion := getOSVersion(ctx, exec)
+ userIdentity := getDeveloperIdentity(exec)
+
+ return model.Device{
+ Hostname: hostname,
+ SerialNumber: serial,
+ OSVersion: osVersion,
+ Platform: "darwin",
+ UserIdentity: userIdentity,
+ }
+}
+
+func getSerialNumber(ctx context.Context, exec executor.Executor) string {
+ // Try ioreg first
+ stdout, _, _, err := exec.Run(ctx, "ioreg", "-l")
+ if err == nil {
+ for _, line := range strings.Split(stdout, "\n") {
+ if strings.Contains(line, "IOPlatformSerialNumber") {
+ parts := strings.Split(line, "=")
+ if len(parts) >= 2 {
+ serial := strings.TrimSpace(parts[1])
+ serial = strings.Trim(serial, "\" ")
+ if serial != "" {
+ return serial
+ }
+ }
+ }
+ }
+ }
+
+ // Fallback: system_profiler
+ stdout, _, _, err = exec.Run(ctx, "system_profiler", "SPHardwareDataType")
+ if err == nil {
+ for _, line := range strings.Split(stdout, "\n") {
+ if strings.Contains(line, "Serial") {
+ parts := strings.Split(line, ":")
+ if len(parts) >= 2 {
+ serial := strings.TrimSpace(parts[1])
+ if serial != "" {
+ return serial
+ }
+ }
+ }
+ }
+ }
+
+ return "unknown"
+}
+
+func getOSVersion(ctx context.Context, exec executor.Executor) string {
+ stdout, _, _, err := exec.Run(ctx, "sw_vers", "-productVersion")
+ if err == nil {
+ v := strings.TrimSpace(stdout)
+ if v != "" {
+ return v
+ }
+ }
+ return "unknown"
+}
+
+func getDeveloperIdentity(exec executor.Executor) string {
+ // Check environment variables in order of preference
+ for _, key := range []string{"USER_EMAIL", "DEVELOPER_EMAIL", "STEPSEC_DEVELOPER_EMAIL"} {
+ if v := exec.Getenv(key); v != "" {
+ return v
+ }
+ }
+ // Fallback to current username
+ u, err := exec.CurrentUser()
+ if err == nil {
+ return u.Username
+ }
+ return "unknown"
+}
diff --git a/internal/device/device_test.go b/internal/device/device_test.go
new file mode 100644
index 0000000..9289ad9
--- /dev/null
+++ b/internal/device/device_test.go
@@ -0,0 +1,59 @@
+package device
+
+import (
+ "context"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+func TestGather_BasicFields(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetHostname("test-mac.local")
+ mock.SetCommand("SERIAL123\n \"IOPlatformSerialNumber\" = \"SERIAL123\"\n", "", 0, "ioreg", "-l")
+ mock.SetCommand("15.1\n", "", 0, "sw_vers", "-productVersion")
+ mock.SetUsername("devuser")
+
+ dev := Gather(context.Background(), mock)
+
+ if dev.Hostname != "test-mac.local" {
+ t.Errorf("hostname: expected test-mac.local, got %s", dev.Hostname)
+ }
+ if dev.OSVersion != "15.1" {
+ t.Errorf("os_version: expected 15.1, got %s", dev.OSVersion)
+ }
+ if dev.Platform != "darwin" {
+ t.Errorf("platform: expected darwin, got %s", dev.Platform)
+ }
+ if dev.UserIdentity != "devuser" {
+ t.Errorf("user_identity: expected devuser, got %s", dev.UserIdentity)
+ }
+}
+
+func TestGather_FallbackSerial(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetHostname("test")
+ // ioreg fails, system_profiler returns serial
+ mock.SetCommand("", "", 1, "ioreg", "-l")
+ mock.SetCommand("Hardware:\n Serial Number (system): FB123\n", "", 0, "system_profiler", "SPHardwareDataType")
+ mock.SetCommand("14.0\n", "", 0, "sw_vers", "-productVersion")
+
+ dev := Gather(context.Background(), mock)
+ if dev.SerialNumber != "FB123" {
+ t.Errorf("serial: expected FB123, got %s", dev.SerialNumber)
+ }
+}
+
+func TestGather_EmailIdentity(t *testing.T) {
+ mock := executor.NewMock()
+ mock.SetHostname("test")
+ mock.SetCommand("", "", 1, "ioreg", "-l")
+ mock.SetCommand("", "", 1, "system_profiler", "SPHardwareDataType")
+ mock.SetCommand("", "", 1, "sw_vers", "-productVersion")
+ mock.SetEnv("USER_EMAIL", "dev@example.com")
+
+ dev := Gather(context.Background(), mock)
+ if dev.UserIdentity != "dev@example.com" {
+ t.Errorf("identity: expected dev@example.com, got %s", dev.UserIdentity)
+ }
+}
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
new file mode 100644
index 0000000..49d7248
--- /dev/null
+++ b/internal/executor/executor.go
@@ -0,0 +1,150 @@
+package executor
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// Executor defines the interface for all OS interactions.
+// Every detector depends on this interface, enabling full unit-test coverage via mocks.
+type Executor interface {
+ // Run executes a command and returns stdout, stderr, and exit code.
+ Run(ctx context.Context, name string, args ...string) (stdout, stderr string, exitCode int, err error)
+ // RunWithTimeout executes a command with a timeout.
+ RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int, err error)
+ // RunAsUser runs a shell command as a specific user (for root -> user delegation).
+ RunAsUser(ctx context.Context, username, command string) (string, error)
+ // LookPath searches for an executable in PATH.
+ LookPath(name string) (string, error)
+ // FileExists checks if a file exists and is not a directory.
+ FileExists(path string) bool
+ // DirExists checks if a directory exists.
+ DirExists(path string) bool
+ // ReadFile reads a file's contents.
+ ReadFile(path string) ([]byte, error)
+ // ReadDir lists directory entries.
+ ReadDir(path string) ([]os.DirEntry, error)
+ // Stat returns file info.
+ Stat(path string) (os.FileInfo, error)
+ // Hostname returns the system hostname.
+ Hostname() (string, error)
+ // Getenv reads an environment variable.
+ Getenv(key string) string
+ // IsRoot returns true if the process is running as root.
+ IsRoot() bool
+ // CurrentUser returns the current OS user.
+ CurrentUser() (*user.User, error)
+ // HomeDir returns the home directory for a given username.
+ HomeDir(username string) (string, error)
+ // Glob returns filenames matching a pattern.
+ Glob(pattern string) ([]string, error)
+ // GOOS returns the runtime operating system.
+ GOOS() string
+}
+
+// Real implements Executor using actual OS calls.
+type Real struct{}
+
+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...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ err := cmd.Run()
+ exitCode := 0
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ exitCode = exitErr.ExitCode()
+ } else {
+ return stdout.String(), stderr.String(), -1, err
+ }
+ }
+ return stdout.String(), stderr.String(), exitCode, nil
+}
+
+func (r *Real) RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (string, string, int, error) {
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ stdout, stderr, code, err := r.Run(ctx, name, args...)
+ if ctx.Err() == context.DeadlineExceeded {
+ return stdout, stderr, 124, fmt.Errorf("command timed out after %s", timeout)
+ }
+ return stdout, stderr, code, err
+}
+
+func (r *Real) RunAsUser(ctx context.Context, username, command string) (string, error) {
+ if !r.IsRoot() {
+ stdout, _, _, err := r.Run(ctx, "bash", "-c", command)
+ return strings.TrimSpace(stdout), err
+ }
+ stdout, _, _, err := r.Run(ctx, "sudo", "-H", "-u", username, "bash", "-l", "-c", command)
+ return strings.TrimSpace(stdout), err
+}
+
+func (r *Real) LookPath(name string) (string, error) {
+ return exec.LookPath(name)
+}
+
+func (r *Real) FileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+
+func (r *Real) DirExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.IsDir()
+}
+
+func (r *Real) ReadFile(path string) ([]byte, error) {
+ return os.ReadFile(path)
+}
+
+func (r *Real) ReadDir(path string) ([]os.DirEntry, error) {
+ return os.ReadDir(path)
+}
+
+func (r *Real) Stat(path string) (os.FileInfo, error) {
+ return os.Stat(path)
+}
+
+func (r *Real) Hostname() (string, error) {
+ return os.Hostname()
+}
+
+func (r *Real) Getenv(key string) string {
+ return os.Getenv(key)
+}
+
+func (r *Real) IsRoot() bool {
+ return os.Getuid() == 0
+}
+
+func (r *Real) CurrentUser() (*user.User, error) {
+ return user.Current()
+}
+
+func (r *Real) HomeDir(username string) (string, error) {
+ u, err := user.Lookup(username)
+ if err != nil {
+ return "", err
+ }
+ return u.HomeDir, nil
+}
+
+func (r *Real) Glob(pattern string) ([]string, error) {
+ return filepath.Glob(pattern)
+}
+
+func (r *Real) GOOS() string {
+ return runtime.GOOS
+}
diff --git a/internal/executor/mock.go b/internal/executor/mock.go
new file mode 100644
index 0000000..b1ab5df
--- /dev/null
+++ b/internal/executor/mock.go
@@ -0,0 +1,288 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+// Mock implements Executor for unit testing.
+type Mock struct {
+ mu sync.RWMutex
+
+ // Command results: key is "name arg1 arg2 ..."
+ commands map[string]cmdResult
+
+ // File system stubs
+ files map[string][]byte
+ dirs map[string]bool
+ dirEnts map[string][]os.DirEntry
+ fileInfos map[string]os.FileInfo
+
+ // Path lookup stubs
+ paths map[string]string
+
+ // Environment
+ env map[string]string
+ hostname string
+ isRoot bool
+ username string
+ homeDir string
+ goos string
+
+ // Glob stubs
+ globs map[string][]string
+}
+
+type cmdResult struct {
+ Stdout string
+ Stderr string
+ ExitCode int
+ Err error
+}
+
+func NewMock() *Mock {
+ return &Mock{
+ commands: make(map[string]cmdResult),
+ files: make(map[string][]byte),
+ dirs: make(map[string]bool),
+ dirEnts: make(map[string][]os.DirEntry),
+ fileInfos: make(map[string]os.FileInfo),
+ paths: make(map[string]string),
+ env: make(map[string]string),
+ globs: make(map[string][]string),
+ hostname: "test-host",
+ username: "testuser",
+ homeDir: "/Users/testuser",
+ goos: "darwin",
+ }
+}
+
+// --- Stub setters ---
+
+func (m *Mock) SetCommand(stdout, stderr string, exitCode int, name string, args ...string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ key := cmdKey(name, args...)
+ m.commands[key] = cmdResult{Stdout: stdout, Stderr: stderr, ExitCode: exitCode}
+}
+
+func (m *Mock) SetCommandError(err error, name string, args ...string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ key := cmdKey(name, args...)
+ m.commands[key] = cmdResult{Err: err}
+}
+
+func (m *Mock) SetFile(path string, content []byte) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.files[path] = content
+}
+
+func (m *Mock) SetDir(path string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.dirs[path] = true
+}
+
+func (m *Mock) SetDirEntries(path string, entries []os.DirEntry) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.dirEnts[path] = entries
+ m.dirs[path] = true
+}
+
+func (m *Mock) SetFileInfo(path string, info os.FileInfo) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.fileInfos[path] = info
+}
+
+func (m *Mock) SetPath(name, path string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.paths[name] = path
+}
+
+func (m *Mock) SetEnv(key, value string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.env[key] = value
+}
+
+func (m *Mock) SetHostname(h string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.hostname = h
+}
+
+func (m *Mock) SetIsRoot(v bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.isRoot = v
+}
+
+func (m *Mock) SetUsername(u string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.username = u
+}
+
+func (m *Mock) SetHomeDir(h string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.homeDir = h
+}
+
+func (m *Mock) SetGlob(pattern string, matches []string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.globs[pattern] = matches
+}
+
+// --- Executor interface ---
+
+func (m *Mock) Run(_ context.Context, name string, args ...string) (string, string, int, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ key := cmdKey(name, args...)
+ if r, ok := m.commands[key]; ok {
+ return r.Stdout, r.Stderr, r.ExitCode, r.Err
+ }
+ return "", "", -1, fmt.Errorf("mock: no command stub for %q", key)
+}
+
+func (m *Mock) RunWithTimeout(ctx context.Context, _ time.Duration, name string, args ...string) (string, string, int, error) {
+ return m.Run(ctx, name, args...)
+}
+
+func (m *Mock) RunAsUser(ctx context.Context, _ string, command string) (string, error) {
+ stdout, _, _, err := m.Run(ctx, "bash", "-c", command)
+ return stdout, err
+}
+
+func (m *Mock) LookPath(name string) (string, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if p, ok := m.paths[name]; ok {
+ return p, nil
+ }
+ return "", fmt.Errorf("mock: %q not found in PATH", name)
+}
+
+func (m *Mock) FileExists(path string) bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ _, ok := m.files[path]
+ return ok
+}
+
+func (m *Mock) DirExists(path string) bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.dirs[path]
+}
+
+func (m *Mock) ReadFile(path string) ([]byte, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if data, ok := m.files[path]; ok {
+ return data, nil
+ }
+ return nil, fmt.Errorf("mock: file not found: %s", path)
+}
+
+func (m *Mock) ReadDir(path string) ([]os.DirEntry, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if ents, ok := m.dirEnts[path]; ok {
+ return ents, nil
+ }
+ return nil, fmt.Errorf("mock: directory not found: %s", path)
+}
+
+func (m *Mock) Stat(path string) (os.FileInfo, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if info, ok := m.fileInfos[path]; ok {
+ return info, nil
+ }
+ // Fall back: check files map
+ if _, ok := m.files[path]; ok {
+ return &mockFileInfo{name: filepath.Base(path), size: int64(len(m.files[path]))}, nil
+ }
+ return nil, fmt.Errorf("mock: stat: %s not found", path)
+}
+
+func (m *Mock) Hostname() (string, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.hostname, nil
+}
+
+func (m *Mock) Getenv(key string) string {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.env[key]
+}
+
+func (m *Mock) IsRoot() bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.isRoot
+}
+
+func (m *Mock) CurrentUser() (*user.User, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return &user.User{
+ Username: m.username,
+ HomeDir: m.homeDir,
+ }, nil
+}
+
+func (m *Mock) HomeDir(_ string) (string, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.homeDir, nil
+}
+
+func (m *Mock) Glob(pattern string) ([]string, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if matches, ok := m.globs[pattern]; ok {
+ return matches, nil
+ }
+ return nil, nil
+}
+
+func (m *Mock) GOOS() string {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.goos
+}
+
+// --- helpers ---
+
+func cmdKey(name string, args ...string) string {
+ parts := append([]string{name}, args...)
+ return fmt.Sprintf("%v", parts)
+}
+
+type mockFileInfo struct {
+ name string
+ size int64
+ dir bool
+}
+
+func (fi *mockFileInfo) Name() string { return fi.name }
+func (fi *mockFileInfo) Size() int64 { return fi.size }
+func (fi *mockFileInfo) IsDir() bool { return fi.dir }
+func (fi *mockFileInfo) ModTime() time.Time { return time.Time{} }
+func (fi *mockFileInfo) Mode() os.FileMode { return 0o644 }
+func (fi *mockFileInfo) Sys() any { return nil }
diff --git a/internal/launchd/launchd.go b/internal/launchd/launchd.go
new file mode 100644
index 0000000..e173f4f
--- /dev/null
+++ b/internal/launchd/launchd.go
@@ -0,0 +1,189 @@
+package launchd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "text/template"
+
+ "github.com/step-security/dev-machine-guard/internal/config"
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/progress"
+)
+
+const (
+ label = "com.stepsecurity.agent"
+ daemonPlistPath = "/Library/LaunchDaemons/com.stepsecurity.agent.plist"
+ systemLogDir = "/var/log/stepsecurity"
+)
+
+func agentPlistPath() string {
+ homeDir, _ := os.UserHomeDir()
+ return homeDir + "/Library/LaunchAgents/com.stepsecurity.agent.plist"
+}
+
+// Install configures launchd for periodic scanning. If already installed, upgrades.
+func Install(exec executor.Executor, log *progress.Logger) error {
+ ctx := context.Background()
+
+ // Check for existing installation and upgrade
+ if isConfigured(ctx, exec) {
+ log.Progress("Existing agent installation detected. Upgrading...")
+ if err := doUninstall(ctx, exec, log); err != nil {
+ log.Progress("Warning: failed to remove previous installation: %v", err)
+ }
+ log.Progress("Previous installation removed. Installing new version...")
+ }
+
+ binaryPath, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("determining binary path: %w", err)
+ }
+
+ hours, _ := strconv.Atoi(config.ScanFrequencyHours)
+ if hours <= 0 {
+ hours = 4
+ }
+ intervalSeconds := hours * 3600
+
+ plistPath := daemonPlistPath
+ logDir := systemLogDir
+
+ if !exec.IsRoot() {
+ plistPath = agentPlistPath()
+ homeDir, _ := os.UserHomeDir()
+ logDir = homeDir + "/.stepsecurity"
+ }
+
+ // Ensure directories exist
+ if err := os.MkdirAll(logDir, 0o755); err != nil {
+ return fmt.Errorf("creating log directory: %w", err)
+ }
+ if !exec.IsRoot() {
+ homeDir, _ := os.UserHomeDir()
+ if err := os.MkdirAll(homeDir+"/Library/LaunchAgents", 0o755); err != nil {
+ return fmt.Errorf("creating LaunchAgents directory: %w", err)
+ }
+ }
+
+ // Generate plist
+ plistData := plistTemplateData{
+ Label: label,
+ BinaryPath: binaryPath,
+ IntervalSeconds: intervalSeconds,
+ LogDir: logDir,
+ }
+
+ f, err := os.Create(plistPath)
+ if err != nil {
+ return fmt.Errorf("creating plist file: %w", err)
+ }
+ defer f.Close()
+
+ tmpl, err := template.New("plist").Parse(plistTmpl)
+ if err != nil {
+ return fmt.Errorf("parsing plist template: %w", err)
+ }
+ if err := tmpl.Execute(f, plistData); err != nil {
+ return fmt.Errorf("writing plist: %w", err)
+ }
+
+ if exec.IsRoot() {
+ _ = os.Chmod(plistPath, 0o644)
+ }
+
+ // Load plist
+ _, _, exitCode, err := exec.Run(ctx, "launchctl", "load", plistPath)
+ if err != nil || exitCode != 0 {
+ return fmt.Errorf("failed to load launchd configuration")
+ }
+
+ log.Progress("launchd configuration completed successfully")
+ log.Progress(" Plist: %s", plistPath)
+ log.Progress(" Logs: %s/agent.log", logDir)
+ log.Progress("Installation complete!")
+ log.Progress("The agent will now run automatically every %d hours", hours)
+
+ return nil
+}
+
+// Uninstall removes the launchd configuration.
+func Uninstall(exec executor.Executor, log *progress.Logger) error {
+ ctx := context.Background()
+
+ if !isConfigured(ctx, exec) {
+ log.Progress("Agent is not currently configured for periodic execution")
+ return nil
+ }
+
+ return doUninstall(ctx, exec, log)
+}
+
+func doUninstall(ctx context.Context, exec executor.Executor, log *progress.Logger) error {
+ plistPath := daemonPlistPath
+ if !exec.IsRoot() {
+ plistPath = agentPlistPath()
+ }
+
+ // Unload
+ stdout, _, _, _ := exec.Run(ctx, "launchctl", "list")
+ if strings.Contains(stdout, label) {
+ _, _, _, _ = exec.Run(ctx, "launchctl", "unload", plistPath)
+ log.Progress("Unloaded launchd agent")
+ }
+
+ // Remove plist
+ if exec.FileExists(plistPath) {
+ os.Remove(plistPath)
+ log.Progress("Removed plist file: %s", plistPath)
+ }
+
+ log.Progress("launchd configuration removed successfully")
+ return nil
+}
+
+func isConfigured(ctx context.Context, exec executor.Executor) bool {
+ plistPath := daemonPlistPath
+ if !exec.IsRoot() {
+ plistPath = agentPlistPath()
+ }
+
+ if !exec.FileExists(plistPath) {
+ return false
+ }
+
+ stdout, _, _, _ := exec.Run(ctx, "launchctl", "list")
+ return strings.Contains(stdout, label)
+}
+
+type plistTemplateData struct {
+ Label string
+ BinaryPath string
+ IntervalSeconds int
+ LogDir string
+}
+
+const plistTmpl = `
+
+
+
+ Label
+ {{.Label}}
+ ProgramArguments
+
+ {{.BinaryPath}}
+ send-telemetry
+
+ StartInterval
+ {{.IntervalSeconds}}
+ RunAtLoad
+
+ StandardOutPath
+ {{.LogDir}}/agent.log
+ StandardErrorPath
+ {{.LogDir}}/agent.error.log
+
+
+`
diff --git a/internal/lock/lock.go b/internal/lock/lock.go
new file mode 100644
index 0000000..9654605
--- /dev/null
+++ b/internal/lock/lock.go
@@ -0,0 +1,76 @@
+package lock
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "syscall"
+
+ "github.com/step-security/dev-machine-guard/internal/executor"
+)
+
+const lockFilePath = "/tmp/stepsecurity-dev-machine-guard.lock"
+
+// Lock represents an acquired instance lock.
+type Lock struct {
+ path string
+}
+
+// Acquire obtains an exclusive instance lock using atomic file creation.
+// Returns error if another instance is running.
+func Acquire(_ executor.Executor) (*Lock, error) {
+ // Check for existing lock file
+ if data, err := os.ReadFile(lockFilePath); err == nil {
+ pidStr := strings.TrimSpace(string(data))
+ if pid, err := strconv.Atoi(pidStr); err == nil && pid > 0 {
+ if isProcessAlive(pid) {
+ return nil, fmt.Errorf("another instance is already running (PID %d)", pid)
+ }
+ }
+ // Stale lock — remove before attempting atomic create
+ _ = os.Remove(lockFilePath)
+ }
+
+ // Atomic create: O_CREATE|O_EXCL fails if file already exists,
+ // preventing two processes from both creating the lock.
+ f, err := os.OpenFile(lockFilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ if errors.Is(err, os.ErrExist) {
+ // Another process created the lock between our check and create
+ return nil, fmt.Errorf("another instance is already running (lock file created concurrently)")
+ }
+ return nil, fmt.Errorf("creating lock file: %w", err)
+ }
+
+ _, err = fmt.Fprintf(f, "%d", os.Getpid())
+ f.Close()
+ if err != nil {
+ _ = os.Remove(lockFilePath)
+ return nil, fmt.Errorf("writing PID to lock file: %w", err)
+ }
+
+ return &Lock{path: lockFilePath}, nil
+}
+
+// Release removes the lock file.
+func (l *Lock) Release() {
+ if l != nil && l.path != "" {
+ _ = os.Remove(l.path)
+ }
+}
+
+// isProcessAlive checks if a process with the given PID exists.
+// Returns true if the process is alive (signal 0 succeeds or returns EPERM).
+func isProcessAlive(pid int) bool {
+ err := syscall.Kill(pid, 0)
+ if err == nil {
+ return true // process exists and we can signal it
+ }
+ // EPERM means the process exists but we don't have permission to signal it
+ if errors.Is(err, syscall.EPERM) {
+ return true
+ }
+ return false // ESRCH or other error — process doesn't exist
+}
diff --git a/internal/model/model.go b/internal/model/model.go
new file mode 100644
index 0000000..066a1ab
--- /dev/null
+++ b/internal/model/model.go
@@ -0,0 +1,98 @@
+package model
+
+// ScanResult is the community-mode JSON output structure.
+type ScanResult struct {
+ AgentVersion string `json:"agent_version"`
+ AgentURL string `json:"agent_url"`
+ ScanTimestamp int64 `json:"scan_timestamp"`
+ ScanTimestampISO string `json:"scan_timestamp_iso"`
+ Device Device `json:"device"`
+ AIAgentsAndTools []AITool `json:"ai_agents_and_tools"`
+ IDEInstallations []IDE `json:"ide_installations"`
+ IDEExtensions []Extension `json:"ide_extensions"`
+ MCPConfigs []MCPConfig `json:"mcp_configs"`
+ NodePkgManagers []PkgManager `json:"node_package_managers"`
+ NodePackages []any `json:"node_packages"`
+ Summary Summary `json:"summary"`
+}
+
+type Device struct {
+ Hostname string `json:"hostname"`
+ SerialNumber string `json:"serial_number"`
+ OSVersion string `json:"os_version"`
+ Platform string `json:"platform"`
+ UserIdentity string `json:"user_identity"`
+}
+
+// AITool represents a detected AI agent, CLI tool, framework, or general agent.
+// Fields are conditionally present based on type (cli_tool, general_agent, framework).
+type AITool struct {
+ Name string `json:"name"`
+ Vendor string `json:"vendor"`
+ Type string `json:"type"`
+ Version string `json:"version"`
+ BinaryPath string `json:"binary_path,omitempty"`
+ InstallPath string `json:"install_path,omitempty"`
+ ConfigDir string `json:"config_dir,omitempty"`
+ IsRunning *bool `json:"is_running,omitempty"`
+}
+
+type IDE struct {
+ IDEType string `json:"ide_type"`
+ Version string `json:"version"`
+ InstallPath string `json:"install_path"`
+ Vendor string `json:"vendor"`
+ IsInstalled bool `json:"is_installed"`
+}
+
+type Extension struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Publisher string `json:"publisher"`
+ InstallDate int64 `json:"install_date"`
+ IDEType string `json:"ide_type"`
+}
+
+// MCPConfig represents a detected MCP server configuration (community mode).
+type MCPConfig struct {
+ ConfigSource string `json:"config_source"`
+ ConfigPath string `json:"config_path"`
+ Vendor string `json:"vendor"`
+}
+
+// MCPConfigEnterprise includes base64-encoded content for enterprise mode.
+type MCPConfigEnterprise struct {
+ ConfigSource string `json:"config_source"`
+ ConfigPath string `json:"config_path"`
+ Vendor string `json:"vendor"`
+ ConfigContentBase64 string `json:"config_content_base64,omitempty"`
+}
+
+type PkgManager struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Path string `json:"path"`
+}
+
+type Summary struct {
+ AIAgentsAndToolsCount int `json:"ai_agents_and_tools_count"`
+ IDEInstallationsCount int `json:"ide_installations_count"`
+ IDEExtensionsCount int `json:"ide_extensions_count"`
+ MCPConfigsCount int `json:"mcp_configs_count"`
+ NodeProjectsCount int `json:"node_projects_count"`
+}
+
+// NodeScanResult holds raw scan output for enterprise telemetry.
+// Used for both global packages and per-project scans.
+type NodeScanResult struct {
+ ProjectPath string `json:"project_path"`
+ PackageManager string `json:"package_manager"`
+ PMVersion string `json:"package_manager_version"`
+ WorkingDirectory string `json:"working_directory"`
+ RawStdoutBase64 string `json:"raw_stdout_base64"`
+ RawStderrBase64 string `json:"raw_stderr_base64"`
+ Error string `json:"error"`
+ ExitCode int `json:"exit_code"`
+ ScanDurationMs int64 `json:"scan_duration_ms"`
+}
diff --git a/internal/output/html.go b/internal/output/html.go
new file mode 100644
index 0000000..4c8afc0
--- /dev/null
+++ b/internal/output/html.go
@@ -0,0 +1,226 @@
+package output
+
+import (
+ "fmt"
+ "html/template"
+ "os"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/buildinfo"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+type htmlData struct {
+ ScanTime string
+ Version string
+ Device model.Device
+ AITools []model.AITool
+ IDEInstallations []model.IDE
+ IDEExtensions []model.Extension
+ MCPConfigs []model.MCPConfig
+ NodePkgManagers []model.PkgManager
+ Summary model.Summary
+}
+
+func typeLabel(t string) string {
+ switch t {
+ case "cli_tool":
+ return "CLI Tool"
+ case "general_agent":
+ return "Agent"
+ case "framework":
+ return "Framework"
+ default:
+ return t
+ }
+}
+
+// HTML generates a self-contained HTML report file.
+func HTML(outputFile string, result *model.ScanResult) error {
+ f, err := os.Create(outputFile)
+ if err != nil {
+ return fmt.Errorf("creating HTML file: %w", err)
+ }
+ defer f.Close()
+
+ scanTime := time.Unix(result.ScanTimestamp, 0).Format("2006-01-02 15:04:05")
+
+ data := htmlData{
+ ScanTime: scanTime,
+ Version: buildinfo.Version,
+ Device: result.Device,
+ AITools: result.AIAgentsAndTools,
+ IDEInstallations: result.IDEInstallations,
+ IDEExtensions: result.IDEExtensions,
+ MCPConfigs: result.MCPConfigs,
+ NodePkgManagers: result.NodePkgManagers,
+ Summary: result.Summary,
+ }
+
+ funcMap := template.FuncMap{
+ "ideDisplayName": ideDisplayName,
+ "typeLabel": typeLabel,
+ }
+
+ tmpl, err := template.New("report").Funcs(funcMap).Parse(htmlTemplate)
+ if err != nil {
+ return fmt.Errorf("parsing HTML template: %w", err)
+ }
+
+ return tmpl.Execute(f, data)
+}
+
+const htmlTemplate = `
+
+
+
+
+StepSecurity Dev Machine Guard Report
+
+
+
+
+
+
+
Scanned at {{.ScanTime}} · Agent v{{.Version}}
+
+
+
{{.Summary.AIAgentsAndToolsCount}}
AI Agents and Tools
+
{{.Summary.IDEInstallationsCount}}
IDEs & Desktop Apps
+
{{.Summary.IDEExtensionsCount}}
IDE Extensions
+
{{.Summary.MCPConfigsCount}}
MCP Servers
+
{{.Summary.NodeProjectsCount}}
Node.js Projects
+
+
+
+
Hostname{{.Device.Hostname}}
+
Serial{{.Device.SerialNumber}}
+
macOS{{.Device.OSVersion}}
+
User{{.Device.UserIdentity}}
+
+
+
+
AI Agents and Tools {{.Summary.AIAgentsAndToolsCount}}
+
+ | Name | Version | Type | Vendor |
+ {{if .AITools}}{{range .AITools}}| {{.Name}} | {{.Version}} | {{typeLabel .Type}} | {{.Vendor}} |
+ {{end}}{{else}}| None detected |
{{end}}
+
+
+
+
+
IDE & AI Desktop Apps {{.Summary.IDEInstallationsCount}}
+
+ | Name | Version | Vendor | Path |
+ {{if .IDEInstallations}}{{range .IDEInstallations}}| {{ideDisplayName .IDEType}} | {{.Version}} | {{.Vendor}} | {{.InstallPath}} |
+ {{end}}{{else}}| None detected |
{{end}}
+
+
+
+
+
MCP Servers {{.Summary.MCPConfigsCount}}
+
+ | Source | Vendor |
+ {{if .MCPConfigs}}{{range .MCPConfigs}}| {{.ConfigSource}} | {{.Vendor}} |
+ {{end}}{{else}}| None detected |
{{end}}
+
+
+
+
+
IDE Extensions {{.Summary.IDEExtensionsCount}}
+
+ | Extension ID | Version | Publisher | IDE |
+ {{if .IDEExtensions}}{{range .IDEExtensions}}| {{.ID}} | {{.Version}} | {{.Publisher}} | {{.IDEType}} |
+ {{end}}{{else}}| None detected |
{{end}}
+
+
+
+
+
Node.js Packages
+
+ | Package Manager | Version | Path |
+ {{if .NodePkgManagers}}{{range .NodePkgManagers}}| {{.Name}} | {{.Version}} | {{.Path}} |
+ {{end}}{{else}}| No packages found (use --enable-npm-scan) |
{{end}}
+
+
+
+
+
+
+`
diff --git a/internal/output/html_test.go b/internal/output/html_test.go
new file mode 100644
index 0000000..9098a92
--- /dev/null
+++ b/internal/output/html_test.go
@@ -0,0 +1,86 @@
+package output
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+func TestHTML_GeneratesFile(t *testing.T) {
+ tmpFile := os.TempDir() + "/test-dmg-report.html"
+ defer os.Remove(tmpFile)
+
+ result := &model.ScanResult{
+ AgentVersion: "1.9.0",
+ ScanTimestamp: 1700000000,
+ ScanTimestampISO: "2023-11-14T22:13:20Z",
+ Device: model.Device{
+ Hostname: "test-host",
+ SerialNumber: "ABC123",
+ OSVersion: "14.1",
+ Platform: "darwin",
+ UserIdentity: "testuser",
+ },
+ AIAgentsAndTools: []model.AITool{},
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ NodePkgManagers: []model.PkgManager{},
+ NodePackages: []any{},
+ Summary: model.Summary{},
+ }
+
+ if err := HTML(tmpFile, result); err != nil {
+ t.Fatal(err)
+ }
+
+ content, err := os.ReadFile(tmpFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ html := string(content)
+ if !strings.Contains(html, "") {
+ t.Error("missing tag")
+ }
+ if !strings.Contains(html, "StepSecurity") {
+ t.Error("missing StepSecurity title")
+ }
+}
+
+func TestHTML_ContainsData(t *testing.T) {
+ tmpFile := os.TempDir() + "/test-dmg-data.html"
+ defer os.Remove(tmpFile)
+
+ result := &model.ScanResult{
+ ScanTimestamp: 1700000000,
+ Device: model.Device{
+ Hostname: "my-host",
+ },
+ AIAgentsAndTools: []model.AITool{
+ {Name: "claude-code", Vendor: "Anthropic", Type: "cli_tool", Version: "1.0"},
+ },
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ NodePkgManagers: []model.PkgManager{},
+ NodePackages: []any{},
+ Summary: model.Summary{AIAgentsAndToolsCount: 1},
+ }
+
+ _ = HTML(tmpFile, result)
+ content, _ := os.ReadFile(tmpFile)
+ html := string(content)
+
+ if !strings.Contains(html, "claude-code") {
+ t.Error("missing AI tool name")
+ }
+ if !strings.Contains(html, "my-host") {
+ t.Error("missing hostname")
+ }
+}
diff --git a/internal/output/json.go b/internal/output/json.go
new file mode 100644
index 0000000..cbe03f2
--- /dev/null
+++ b/internal/output/json.go
@@ -0,0 +1,16 @@
+package output
+
+import (
+ "encoding/json"
+ "io"
+
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+// JSON writes the scan result as formatted JSON to the given writer.
+func JSON(w io.Writer, result *model.ScanResult) error {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ enc.SetEscapeHTML(false)
+ return enc.Encode(result)
+}
diff --git a/internal/output/json_test.go b/internal/output/json_test.go
new file mode 100644
index 0000000..f7cd061
--- /dev/null
+++ b/internal/output/json_test.go
@@ -0,0 +1,180 @@
+package output
+
+import (
+ "bytes"
+ "encoding/json"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+func TestJSON_ValidOutput(t *testing.T) {
+ result := &model.ScanResult{
+ AgentVersion: "1.9.0",
+ AgentURL: "https://github.com/step-security/dev-machine-guard",
+ ScanTimestamp: 1700000000,
+ ScanTimestampISO: "2023-11-14T22:13:20Z",
+ Device: model.Device{
+ Hostname: "test-host",
+ SerialNumber: "ABC123",
+ OSVersion: "14.1",
+ Platform: "darwin",
+ UserIdentity: "testuser",
+ },
+ AIAgentsAndTools: []model.AITool{},
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ NodePkgManagers: []model.PkgManager{},
+ NodePackages: []any{},
+ Summary: model.Summary{},
+ }
+
+ var buf bytes.Buffer
+ if err := JSON(&buf, result); err != nil {
+ t.Fatal(err)
+ }
+
+ // Validate it's valid JSON
+ var parsed map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+
+ // Check required top-level keys
+ requiredKeys := []string{
+ "agent_version", "agent_url", "scan_timestamp", "scan_timestamp_iso",
+ "device", "ai_agents_and_tools", "ide_installations",
+ "ide_extensions", "mcp_configs", "summary",
+ }
+ for _, key := range requiredKeys {
+ if _, ok := parsed[key]; !ok {
+ t.Errorf("missing required key: %s", key)
+ }
+ }
+}
+
+func TestJSON_DeviceFields(t *testing.T) {
+ result := &model.ScanResult{
+ Device: model.Device{
+ Hostname: "my-host",
+ SerialNumber: "SN123",
+ OSVersion: "15.0",
+ Platform: "darwin",
+ UserIdentity: "dev",
+ },
+ AIAgentsAndTools: []model.AITool{},
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ NodePkgManagers: []model.PkgManager{},
+ NodePackages: []any{},
+ }
+
+ var buf bytes.Buffer
+ if err := JSON(&buf, result); err != nil {
+ t.Fatal(err)
+ }
+
+ var parsed map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
+ t.Fatal(err)
+ }
+
+ device, ok := parsed["device"].(map[string]any)
+ if !ok {
+ t.Fatal("device is not an object")
+ }
+
+ for _, key := range []string{"hostname", "os_version", "serial_number", "platform", "user_identity"} {
+ if _, ok := device[key]; !ok {
+ t.Errorf("missing device field: %s", key)
+ }
+ }
+}
+
+func TestJSON_SummaryFields(t *testing.T) {
+ result := &model.ScanResult{
+ AIAgentsAndTools: []model.AITool{},
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ NodePkgManagers: []model.PkgManager{},
+ NodePackages: []any{},
+ Summary: model.Summary{
+ AIAgentsAndToolsCount: 5,
+ IDEInstallationsCount: 3,
+ IDEExtensionsCount: 10,
+ MCPConfigsCount: 2,
+ NodeProjectsCount: 0,
+ },
+ }
+
+ var buf bytes.Buffer
+ if err := JSON(&buf, result); err != nil {
+ t.Fatal(err)
+ }
+
+ var parsed map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
+ t.Fatal(err)
+ }
+
+ summary, ok := parsed["summary"].(map[string]any)
+ if !ok {
+ t.Fatal("summary is not an object")
+ }
+
+ for _, key := range []string{
+ "ai_agents_and_tools_count", "ide_installations_count",
+ "ide_extensions_count", "mcp_configs_count", "node_projects_count",
+ } {
+ v, ok := summary[key]
+ if !ok {
+ t.Errorf("missing summary field: %s", key)
+ continue
+ }
+ if _, ok := v.(float64); !ok {
+ t.Errorf("summary.%s is not numeric", key)
+ }
+ }
+}
+
+func TestJSON_AIToolSchema(t *testing.T) {
+ running := true
+ result := &model.ScanResult{
+ AIAgentsAndTools: []model.AITool{
+ {Name: "test-tool", Vendor: "TestVendor", Type: "cli_tool", Version: "1.0"},
+ {Name: "test-fw", Vendor: "Unknown", Type: "framework", Version: "2.0", IsRunning: &running},
+ },
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ NodePkgManagers: []model.PkgManager{},
+ NodePackages: []any{},
+ Summary: model.Summary{AIAgentsAndToolsCount: 2},
+ }
+
+ var buf bytes.Buffer
+ _ = JSON(&buf, result)
+
+ var parsed map[string]any
+ _ = json.Unmarshal(buf.Bytes(), &parsed)
+
+ items, ok := parsed["ai_agents_and_tools"].([]any)
+ if !ok {
+ t.Fatal("ai_agents_and_tools is not an array")
+ }
+ for i, item := range items {
+ obj, ok := item.(map[string]any)
+ if !ok {
+ t.Fatalf("item %d is not an object", i)
+ }
+ if _, ok := obj["name"]; !ok {
+ t.Errorf("item %d missing name", i)
+ }
+ if _, ok := obj["type"]; !ok {
+ t.Errorf("item %d missing type", i)
+ }
+ }
+}
diff --git a/internal/output/pretty.go b/internal/output/pretty.go
new file mode 100644
index 0000000..73f6c58
--- /dev/null
+++ b/internal/output/pretty.go
@@ -0,0 +1,211 @@
+package output
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/buildinfo"
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+// Pretty writes human-readable formatted output.
+func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error {
+ c := setupColors(colorMode)
+
+ scanTime := time.Unix(result.ScanTimestamp, 0).Format("2006-01-02 15:04:05")
+
+ title := fmt.Sprintf("StepSecurity Dev Machine Guard v%s", buildinfo.Version)
+ url := buildinfo.AgentURL
+ boxWidth := 58
+ titlePad := boxWidth - 2 - len(title)
+ urlPad := boxWidth - 2 - len(url)
+
+ // Banner
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, " %s┌%s┐%s\n", c.purple, strings.Repeat("─", boxWidth), c.reset)
+ fmt.Fprintf(w, " %s│%s %s%s%s%*s%s│%s\n", c.purple, c.reset, c.bold, title, c.reset, titlePad, "", c.purple, c.reset)
+ fmt.Fprintf(w, " %s│%s %s%s%s%*s%s│%s\n", c.purple, c.reset, c.dim, url, c.reset, urlPad, "", c.purple, c.reset)
+ fmt.Fprintf(w, " %s└%s┘%s\n", c.purple, strings.Repeat("─", boxWidth), c.reset)
+ fmt.Fprintf(w, " %sScanned at %s%s\n", c.dim, scanTime, c.reset)
+ fmt.Fprintln(w)
+
+ // DEVICE
+ fmt.Fprintf(w, " %s%sDEVICE%s\n", c.purple, c.bold, c.reset)
+ fmt.Fprintf(w, " %-16s %s\n", "Hostname", result.Device.Hostname)
+ fmt.Fprintf(w, " %-16s %s\n", "Serial", result.Device.SerialNumber)
+ fmt.Fprintf(w, " %-16s %s\n", "macOS", result.Device.OSVersion)
+ fmt.Fprintf(w, " %-16s %s\n", "User", result.Device.UserIdentity)
+ fmt.Fprintln(w)
+
+ // SUMMARY
+ fmt.Fprintf(w, " %s%sSUMMARY%s\n", c.purple, c.bold, c.reset)
+ fmt.Fprintf(w, " %-24s %s%d%s\n", "AI Agents and Tools", c.green, result.Summary.AIAgentsAndToolsCount, c.reset)
+ fmt.Fprintf(w, " %-24s %s%d%s\n", "IDEs & Desktop Apps", c.green, result.Summary.IDEInstallationsCount, c.reset)
+ fmt.Fprintf(w, " %-24s %s%d%s\n", "IDE Extensions", c.green, result.Summary.IDEExtensionsCount, c.reset)
+ fmt.Fprintf(w, " %-24s %s%d%s\n", "MCP Servers", c.green, result.Summary.MCPConfigsCount, c.reset)
+ if len(result.NodePkgManagers) > 0 {
+ fmt.Fprintf(w, " %-24s %s%d%s\n", "Node.js Projects", c.green, result.Summary.NodeProjectsCount, c.reset)
+ }
+ fmt.Fprintln(w)
+
+ // AI AGENTS AND TOOLS
+ printSectionHeader(w, c, "AI AGENTS AND TOOLS", result.Summary.AIAgentsAndToolsCount)
+ if len(result.AIAgentsAndTools) > 0 {
+ for _, t := range result.AIAgentsAndTools {
+ typeLabel := t.Type
+ switch t.Type {
+ case "cli_tool":
+ typeLabel = "cli"
+ case "general_agent":
+ typeLabel = "agent"
+ case "framework":
+ typeLabel = "framework"
+ }
+ fmt.Fprintf(w, " %-24s %sv%-20s %-12s %s%s\n",
+ truncate(t.Name, 24), c.dim, truncate(t.Version, 20), "["+typeLabel+"]", t.Vendor, c.reset)
+ }
+ } else {
+ fmt.Fprintf(w, " %sNone detected%s\n", c.dim, c.reset)
+ }
+ fmt.Fprintln(w)
+
+ // IDE & AI DESKTOP APPS
+ printSectionHeader(w, c, "IDE & AI DESKTOP APPS", result.Summary.IDEInstallationsCount)
+ if len(result.IDEInstallations) > 0 {
+ for _, ide := range result.IDEInstallations {
+ displayName := ideDisplayName(ide.IDEType)
+ fmt.Fprintf(w, " %-24s %sv%-20s %s%s\n",
+ truncate(displayName, 24), c.dim, truncate(ide.Version, 20), ide.Vendor, c.reset)
+ }
+ } else {
+ fmt.Fprintf(w, " %sNone detected%s\n", c.dim, c.reset)
+ }
+ fmt.Fprintln(w)
+
+ // MCP SERVERS
+ printSectionHeader(w, c, "MCP SERVERS", result.Summary.MCPConfigsCount)
+ if len(result.MCPConfigs) > 0 {
+ for _, cfg := range result.MCPConfigs {
+ fmt.Fprintf(w, " %-24s %s%s%s\n", cfg.ConfigSource, c.dim, cfg.Vendor, c.reset)
+ }
+ } else {
+ fmt.Fprintf(w, " %sNone detected%s\n", c.dim, c.reset)
+ }
+ fmt.Fprintln(w)
+
+ // IDE EXTENSIONS
+ printSectionHeader(w, c, "IDE EXTENSIONS", result.Summary.IDEExtensionsCount)
+ if len(result.IDEExtensions) > 0 {
+ // Group by IDE type
+ groups := make(map[string][]model.Extension)
+ for _, ext := range result.IDEExtensions {
+ groups[ext.IDEType] = append(groups[ext.IDEType], ext)
+ }
+ for ideType, exts := range groups {
+ displayType := ideType
+ switch ideType {
+ case "vscode":
+ displayType = "VSCode"
+ case "openvsx":
+ displayType = "Cursor"
+ }
+ fmt.Fprintf(w, " %s%s%s%s%*s%s%d found%s\n",
+ c.purple, c.bold, displayType, c.reset, 33-len(displayType), "", c.green, len(exts), c.reset)
+ for _, ext := range exts {
+ fmt.Fprintf(w, " %-42s %sv%-14s %s%s\n",
+ truncate(ext.ID, 42), c.dim, truncate(ext.Version, 14), ext.Publisher, c.reset)
+ }
+ }
+ } else {
+ fmt.Fprintf(w, " %sNone detected%s\n", c.dim, c.reset)
+ }
+ fmt.Fprintln(w)
+
+ // NODE.JS PACKAGE MANAGERS (only if npm scan was enabled)
+ if len(result.NodePkgManagers) > 0 {
+ printSectionHeader(w, c, "NODE.JS PACKAGE MANAGERS", len(result.NodePkgManagers))
+ for _, pm := range result.NodePkgManagers {
+ fmt.Fprintf(w, " %-24s %sv%s%s\n", pm.Name, c.dim, pm.Version, c.reset)
+ }
+ fmt.Fprintln(w)
+
+ printSectionHeader(w, c, "NODE.JS PROJECTS", result.Summary.NodeProjectsCount)
+ fmt.Fprintln(w)
+ }
+
+ return nil
+}
+
+func printSectionHeader(w io.Writer, c *colors, title string, count int) {
+ padding := 35 - len(title)
+ if padding < 1 {
+ padding = 1
+ }
+ fmt.Fprintf(w, " %s%s%s%s%*s%s%d found%s\n", c.purple, c.bold, title, c.reset, padding, "", c.green, count, c.reset)
+}
+
+type colors struct {
+ purple string
+ green string
+ bold string
+ dim string
+ reset string
+}
+
+func setupColors(mode string) *colors {
+ useColors := false
+ switch mode {
+ case "always":
+ useColors = true
+ case "never":
+ useColors = false
+ default: // auto
+ fi, err := os.Stdout.Stat()
+ if err == nil && fi.Mode()&os.ModeCharDevice != 0 {
+ useColors = true
+ }
+ }
+
+ if !useColors {
+ return &colors{}
+ }
+
+ return &colors{
+ purple: "\033[0;35m",
+ green: "\033[0;32m",
+ bold: "\033[1m",
+ dim: "\033[2m",
+ reset: "\033[0m",
+ }
+}
+
+func truncate(s string, max int) string {
+ if len(s) > max {
+ return s[:max-3] + "..."
+ }
+ return s
+}
+
+func ideDisplayName(ideType string) string {
+ switch ideType {
+ case "vscode":
+ return "Visual Studio Code"
+ case "cursor":
+ return "Cursor"
+ case "windsurf":
+ return "Windsurf"
+ case "antigravity":
+ return "Antigravity"
+ case "zed":
+ return "Zed"
+ case "claude_desktop":
+ return "Claude"
+ case "microsoft_copilot_desktop":
+ return "Microsoft Copilot"
+ default:
+ return ideType
+ }
+}
diff --git a/internal/output/pretty_test.go b/internal/output/pretty_test.go
new file mode 100644
index 0000000..2fa656c
--- /dev/null
+++ b/internal/output/pretty_test.go
@@ -0,0 +1,119 @@
+package output
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/step-security/dev-machine-guard/internal/model"
+)
+
+func TestPretty_ContainsHeaders(t *testing.T) {
+ result := &model.ScanResult{
+ AgentVersion: "1.9.0",
+ ScanTimestamp: 1700000000,
+ ScanTimestampISO: "2023-11-14T22:13:20Z",
+ Device: model.Device{
+ Hostname: "test-host",
+ SerialNumber: "ABC123",
+ OSVersion: "14.1",
+ Platform: "darwin",
+ UserIdentity: "testuser",
+ },
+ AIAgentsAndTools: []model.AITool{},
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ Summary: model.Summary{},
+ }
+
+ var buf bytes.Buffer
+ _ = Pretty(&buf, result, "never")
+
+ output := buf.String()
+ for _, header := range []string{"DEVICE", "SUMMARY", "AI AGENTS", "IDE & AI DESKTOP APPS", "MCP SERVERS", "IDE EXTENSIONS"} {
+ if !strings.Contains(output, header) {
+ t.Errorf("output missing header: %s", header)
+ }
+ }
+}
+
+func TestPretty_ContainsBanner(t *testing.T) {
+ result := &model.ScanResult{
+ AgentVersion: "1.9.0",
+ ScanTimestamp: 1700000000,
+ Device: model.Device{Hostname: "test"},
+ AIAgentsAndTools: []model.AITool{},
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ }
+
+ var buf bytes.Buffer
+ _ = Pretty(&buf, result, "never")
+ output := buf.String()
+
+ if !strings.Contains(output, "StepSecurity Dev Machine Guard") {
+ t.Error("output missing banner title")
+ }
+}
+
+func TestPretty_ShowsDeviceInfo(t *testing.T) {
+ result := &model.ScanResult{
+ ScanTimestamp: 1700000000,
+ Device: model.Device{
+ Hostname: "my-host",
+ SerialNumber: "SN123",
+ OSVersion: "14.1",
+ UserIdentity: "dev-user",
+ },
+ AIAgentsAndTools: []model.AITool{},
+ IDEInstallations: []model.IDE{},
+ IDEExtensions: []model.Extension{},
+ MCPConfigs: []model.MCPConfig{},
+ }
+
+ var buf bytes.Buffer
+ _ = Pretty(&buf, result, "never")
+ output := buf.String()
+
+ for _, expected := range []string{"my-host", "SN123", "14.1", "dev-user"} {
+ if !strings.Contains(output, expected) {
+ t.Errorf("output missing device info: %s", expected)
+ }
+ }
+}
+
+func TestTruncate(t *testing.T) {
+ tests := []struct {
+ input string
+ max int
+ want string
+ }{
+ {"short", 10, "short"},
+ {"a very long string", 10, "a very ..."},
+ {"exactly10!", 10, "exactly10!"},
+ }
+ for _, tt := range tests {
+ got := truncate(tt.input, tt.max)
+ if got != tt.want {
+ t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.want)
+ }
+ }
+}
+
+func TestIdeDisplayName(t *testing.T) {
+ tests := map[string]string{
+ "vscode": "Visual Studio Code",
+ "cursor": "Cursor",
+ "claude_desktop": "Claude",
+ "microsoft_copilot_desktop": "Microsoft Copilot",
+ "unknown": "unknown",
+ }
+ for input, expected := range tests {
+ got := ideDisplayName(input)
+ if got != expected {
+ t.Errorf("ideDisplayName(%q) = %q, want %q", input, got, expected)
+ }
+ }
+}
diff --git a/internal/progress/progress.go b/internal/progress/progress.go
new file mode 100644
index 0000000..9a04793
--- /dev/null
+++ b/internal/progress/progress.go
@@ -0,0 +1,133 @@
+package progress
+
+import (
+ "fmt"
+ "os"
+ "sync"
+ "time"
+)
+
+// Logger handles progress output to stderr.
+// Logging format matches the shell script:
+// [scanning] message — progress (suppressed in quiet mode)
+// [error] message — errors (never suppressed)
+// ⠋ label... (Xms) — spinner animation
+// ✓ label (Xms) — step done
+// ○ label (skipped) — step skipped
+type Logger struct {
+ quiet bool
+ spinner *spinner
+}
+
+// NewLogger creates a Logger. If quiet is true, progress messages are suppressed.
+func NewLogger(quiet bool) *Logger {
+ return &Logger{quiet: quiet}
+}
+
+// NewNoop returns a Logger that suppresses all output.
+func NewNoop() *Logger {
+ return &Logger{quiet: true}
+}
+
+// Progress prints a progress message to stderr (suppressed in quiet mode).
+// Format: [scanning] message
+func (l *Logger) Progress(format string, args ...any) {
+ if l.quiet {
+ return
+ }
+ fmt.Fprintf(os.Stderr, "\033[2m[scanning]\033[0m %s\n", fmt.Sprintf(format, args...))
+}
+
+// Error always prints to stderr regardless of quiet mode.
+// Format: [error] message
+func (l *Logger) Error(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "\033[0;31m[error]\033[0m %s\n", fmt.Sprintf(format, args...))
+}
+
+// StepStart begins a labeled progress step with a spinner.
+func (l *Logger) StepStart(label string) {
+ if l.quiet {
+ return
+ }
+ l.spinner = newSpinner(label)
+ l.spinner.run()
+}
+
+// StepDone completes the current step, showing elapsed time.
+func (l *Logger) StepDone(elapsed time.Duration) {
+ if l.quiet || l.spinner == nil {
+ return
+ }
+ l.spinner.stopDone(elapsed)
+ l.spinner = nil
+}
+
+// StepSkip marks the current step as skipped.
+func (l *Logger) StepSkip(reason string) {
+ if l.quiet || l.spinner == nil {
+ return
+ }
+ l.spinner.stopSkip(reason)
+ l.spinner = nil
+}
+
+// spinner renders an animated progress indicator on stderr.
+type spinner struct {
+ label string
+ startedAt time.Time
+ stopCh chan stopMsg
+ wg sync.WaitGroup
+}
+
+type stopMsg struct {
+ kind string // "done" or "skip"
+ reason string
+ elapsed time.Duration
+}
+
+var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
+
+func newSpinner(label string) *spinner {
+ return &spinner{
+ label: label,
+ startedAt: time.Now(),
+ stopCh: make(chan stopMsg, 1),
+ }
+}
+
+func (s *spinner) run() {
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+ i := 0
+ ticker := time.NewTicker(120 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case msg := <-s.stopCh:
+ switch msg.kind {
+ case "done":
+ ms := msg.elapsed.Milliseconds()
+ fmt.Fprintf(os.Stderr, "\r ✓ %s (%dms)\033[K\n", s.label, ms)
+ case "skip":
+ fmt.Fprintf(os.Stderr, "\r ○ %s (skipped)\033[K\n", s.label)
+ }
+ return
+ case <-ticker.C:
+ ms := time.Since(s.startedAt).Milliseconds()
+ fmt.Fprintf(os.Stderr, "\r %s %s... (%dms)\033[K", spinnerFrames[i%len(spinnerFrames)], s.label, ms)
+ i++
+ }
+ }
+ }()
+}
+
+func (s *spinner) stopDone(elapsed time.Duration) {
+ s.stopCh <- stopMsg{kind: "done", elapsed: elapsed}
+ s.wg.Wait()
+}
+
+func (s *spinner) stopSkip(reason string) {
+ s.stopCh <- stopMsg{kind: "skip", reason: reason}
+ s.wg.Wait()
+}
diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go
new file mode 100644
index 0000000..a7183c8
--- /dev/null
+++ b/internal/scan/scanner.go
@@ -0,0 +1,166 @@
+package scan
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/buildinfo"
+ "github.com/step-security/dev-machine-guard/internal/cli"
+ "github.com/step-security/dev-machine-guard/internal/detector"
+ "github.com/step-security/dev-machine-guard/internal/device"
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/model"
+ "github.com/step-security/dev-machine-guard/internal/output"
+ "github.com/step-security/dev-machine-guard/internal/progress"
+)
+
+// Run executes a community-mode scan and outputs results.
+func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
+ ctx := context.Background()
+
+ // Resolve search directories
+ searchDirs := resolveSearchDirs(exec, cfg.SearchDirs)
+
+ // Gather device info
+ log.StepStart("Gathering device information")
+ start := time.Now()
+ dev := device.Gather(ctx, exec)
+ log.StepDone(time.Since(start))
+
+ // Detect IDE installations
+ log.StepStart("Detecting IDE installations")
+ start = time.Now()
+ ideDetector := detector.NewIDEDetector(exec)
+ ides := ideDetector.Detect(ctx)
+ log.StepDone(time.Since(start))
+
+ // Detect AI agents and tools
+ log.StepStart("Detecting AI agents and tools")
+ start = time.Now()
+ cliDetector := detector.NewAICLIDetector(exec)
+ cliTools := cliDetector.Detect(ctx)
+ agentDetector := detector.NewAgentDetector(exec)
+ agents := agentDetector.Detect(ctx, searchDirs)
+ fwDetector := detector.NewFrameworkDetector(exec)
+ frameworks := fwDetector.Detect(ctx)
+ aiTools := mergeAITools(cliTools, agents, frameworks)
+ log.StepDone(time.Since(start))
+
+ // Collect MCP configurations
+ log.StepStart("Collecting MCP configurations")
+ start = time.Now()
+ mcpDetector := detector.NewMCPDetector(exec)
+ mcpConfigs := mcpDetector.Detect(ctx, dev.UserIdentity, false)
+ log.StepDone(time.Since(start))
+
+ // Collect IDE extensions
+ log.StepStart("Collecting IDE extensions")
+ start = time.Now()
+ extDetector := detector.NewExtensionDetector(exec)
+ extensions := extDetector.Detect(ctx, searchDirs)
+ log.StepDone(time.Since(start))
+
+ // Node.js scanning (community mode defaults to off, explicit flag overrides)
+ npmEnabled := false
+ if cfg.EnableNPMScan != nil {
+ npmEnabled = *cfg.EnableNPMScan
+ }
+ // auto: disabled in community mode
+
+ var pkgManagers []model.PkgManager
+ nodeProjectsCount := 0
+
+ if npmEnabled {
+ log.StepStart("Detecting package managers")
+ start = time.Now()
+ npmDetector := detector.NewNodePMDetector(exec)
+ pkgManagers = npmDetector.DetectManagers(ctx)
+ log.StepDone(time.Since(start))
+
+ log.StepStart("Scanning Node.js projects")
+ start = time.Now()
+ projectDetector := detector.NewNodeProjectDetector(exec)
+ nodeProjectsCount = projectDetector.CountProjects(ctx, searchDirs)
+ log.StepDone(time.Since(start))
+ } else {
+ log.StepStart("Node.js package scanning")
+ log.StepSkip("disabled (use --enable-npm-scan to enable)")
+ }
+
+ // Ensure no nil slices (JSON must emit [] not null)
+ if aiTools == nil {
+ aiTools = []model.AITool{}
+ }
+ if ides == nil {
+ ides = []model.IDE{}
+ }
+ if extensions == nil {
+ extensions = []model.Extension{}
+ }
+ if pkgManagers == nil {
+ pkgManagers = []model.PkgManager{}
+ }
+
+ // Build result
+ now := time.Now()
+ result := &model.ScanResult{
+ AgentVersion: buildinfo.Version,
+ AgentURL: buildinfo.AgentURL,
+ ScanTimestamp: now.Unix(),
+ ScanTimestampISO: now.UTC().Format(time.RFC3339),
+ Device: dev,
+ AIAgentsAndTools: aiTools,
+ IDEInstallations: ides,
+ IDEExtensions: extensions,
+ MCPConfigs: mcpConfigsToCommunity(mcpConfigs),
+ NodePkgManagers: pkgManagers,
+ NodePackages: []any{},
+ Summary: model.Summary{
+ AIAgentsAndToolsCount: len(aiTools),
+ IDEInstallationsCount: len(ides),
+ IDEExtensionsCount: len(extensions),
+ MCPConfigsCount: len(mcpConfigs),
+ NodeProjectsCount: nodeProjectsCount,
+ },
+ }
+
+ // Output
+ switch cfg.OutputFormat {
+ case "json":
+ return output.JSON(os.Stdout, result)
+ case "html":
+ return output.HTML(cfg.HTMLOutputFile, result)
+ default:
+ return output.Pretty(os.Stdout, result, cfg.ColorMode)
+ }
+}
+
+func resolveSearchDirs(exec executor.Executor, dirs []string) []string {
+ resolved := make([]string, 0, len(dirs))
+ for _, d := range dirs {
+ if d == "$HOME" {
+ u, err := exec.CurrentUser()
+ if err == nil {
+ d = u.HomeDir
+ }
+ }
+ resolved = append(resolved, d)
+ }
+ return resolved
+}
+
+func mergeAITools(cli, agents, frameworks []model.AITool) []model.AITool {
+ result := make([]model.AITool, 0, len(cli)+len(agents)+len(frameworks))
+ result = append(result, cli...)
+ result = append(result, agents...)
+ result = append(result, frameworks...)
+ return result
+}
+
+func mcpConfigsToCommunity(configs []model.MCPConfig) []model.MCPConfig {
+ if configs == nil {
+ return []model.MCPConfig{}
+ }
+ return configs
+}
diff --git a/internal/telemetry/logcapture.go b/internal/telemetry/logcapture.go
new file mode 100644
index 0000000..3a83722
--- /dev/null
+++ b/internal/telemetry/logcapture.go
@@ -0,0 +1,100 @@
+package telemetry
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "os"
+ "sync"
+)
+
+// LogCapture captures all stderr output during telemetry execution.
+// The captured output is base64-encoded and included in the execution_logs payload.
+type LogCapture struct {
+ buf bytes.Buffer
+ mu sync.Mutex
+ origErr *os.File
+ pipeRead *os.File
+ pipeWrite *os.File
+ done chan struct{}
+}
+
+// StartCapture redirects stderr to a tee that writes to both the original
+// stderr and an in-memory buffer for later base64 encoding.
+func StartCapture() *LogCapture {
+ lc := &LogCapture{
+ origErr: os.Stderr,
+ done: make(chan struct{}),
+ }
+
+ r, w, err := os.Pipe()
+ if err != nil {
+ return lc // fallback: no capture
+ }
+ lc.pipeRead = r
+ lc.pipeWrite = w
+
+ // Redirect stderr to the pipe
+ os.Stderr = w
+
+ // Tee: read from pipe, write to both original stderr and buffer
+ go func() {
+ defer close(lc.done)
+ buf := make([]byte, 4096)
+ for {
+ n, err := r.Read(buf)
+ if n > 0 {
+ lc.mu.Lock()
+ lc.buf.Write(buf[:n])
+ lc.mu.Unlock()
+ _, _ = lc.origErr.Write(buf[:n])
+ }
+ if err != nil {
+ break
+ }
+ }
+ }()
+
+ return lc
+}
+
+// Finalize stops capture and returns the base64-encoded output.
+// Safe to call multiple times — subsequent calls return the cached result.
+func (lc *LogCapture) Finalize() string {
+ lc.mu.Lock()
+ defer lc.mu.Unlock()
+
+ if lc.pipeWrite == nil {
+ // Already finalized or never started
+ return base64.StdEncoding.EncodeToString(lc.buf.Bytes())
+ }
+
+ // Close write end so the reader goroutine exits
+ lc.pipeWrite.Close()
+ lc.pipeWrite = nil
+ lc.mu.Unlock()
+ <-lc.done
+ lc.mu.Lock()
+
+ // Restore stderr
+ os.Stderr = lc.origErr
+ lc.pipeRead.Close()
+
+ return base64.StdEncoding.EncodeToString(lc.buf.Bytes())
+}
+
+// Write allows direct writing to the capture buffer (for banner etc.)
+// while also writing to stderr.
+func (lc *LogCapture) Write(p []byte) (n int, err error) {
+ if lc.pipeWrite != nil {
+ return lc.pipeWrite.Write(p)
+ }
+ return lc.origErr.Write(p)
+}
+
+// Fprintf is a convenience method.
+func (lc *LogCapture) Fprintf(format string, args ...any) {
+ msg := fmt.Sprintf(format, args...)
+ _, _ = io.WriteString(lc, msg)
+}
diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
new file mode 100644
index 0000000..4edc629
--- /dev/null
+++ b/internal/telemetry/telemetry.go
@@ -0,0 +1,407 @@
+package telemetry
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/step-security/dev-machine-guard/internal/buildinfo"
+ "github.com/step-security/dev-machine-guard/internal/cli"
+ "github.com/step-security/dev-machine-guard/internal/config"
+ "github.com/step-security/dev-machine-guard/internal/detector"
+ "github.com/step-security/dev-machine-guard/internal/device"
+ "github.com/step-security/dev-machine-guard/internal/executor"
+ "github.com/step-security/dev-machine-guard/internal/lock"
+ "github.com/step-security/dev-machine-guard/internal/model"
+ "github.com/step-security/dev-machine-guard/internal/progress"
+)
+
+// Payload is the enterprise telemetry JSON structure.
+type Payload struct {
+ CustomerID string `json:"customer_id"`
+ DeviceID string `json:"device_id"`
+ SerialNumber string `json:"serial_number"`
+ UserIdentity string `json:"user_identity"`
+ Hostname string `json:"hostname"`
+ Platform string `json:"platform"`
+ OSVersion string `json:"os_version"`
+ AgentVersion string `json:"agent_version"`
+ CollectedAt int64 `json:"collected_at"`
+ NoUserLoggedIn bool `json:"no_user_logged_in"`
+
+ IDEExtensions []model.Extension `json:"ide_extensions"`
+ IDEInstallations []model.IDE `json:"ide_installations"`
+ NodePkgManagers []model.PkgManager `json:"node_package_managers"`
+ NodeGlobalPackages []model.NodeScanResult `json:"node_global_packages"`
+ NodeProjects []model.NodeScanResult `json:"node_projects"`
+ AIAgents []model.AITool `json:"ai_agents"`
+ MCPConfigs []model.MCPConfigEnterprise `json:"mcp_configs"`
+
+ ExecutionLogs *ExecutionLogs `json:"execution_logs,omitempty"`
+ PerformanceMetrics *PerformanceMetrics `json:"performance_metrics,omitempty"`
+}
+
+type ExecutionLogs struct {
+ OutputBase64 string `json:"output_base64"`
+ StartTime int64 `json:"start_time"`
+ EndTime int64 `json:"end_time"`
+ ExitCode int `json:"exit_code"`
+ AgentVersion string `json:"agent_version"`
+}
+
+type PerformanceMetrics struct {
+ ExtensionsCount int `json:"extensions_count"`
+ NodePackagesScanMs int64 `json:"node_packages_scan_ms"`
+ NodeGlobalPkgsCount int `json:"node_global_packages_count"`
+ NodeProjectsCount int `json:"node_projects_count"`
+}
+
+// Run executes enterprise telemetry: scan, build payload, upload to S3.
+// Output format matches the shell script's sample_log:
+//
+// ==========================================
+// StepSecurity Device Agent v1.9.0
+// ==========================================
+// [scanning] Lock acquired (PID: 32560)
+// [scanning] Device ID (Serial): ...
+// ...
+func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
+ ctx := context.Background()
+ startTime := time.Now()
+
+ // Start capturing all stderr output for execution_logs.
+ // Defer Finalize immediately to ensure stderr is always restored,
+ // even on early returns (e.g., lock failure).
+ capture := StartCapture()
+ defer capture.Finalize()
+
+ // Banner (matches shell script format)
+ fmt.Fprintf(os.Stderr, "==========================================\n")
+ fmt.Fprintf(os.Stderr, "StepSecurity Device Agent v%s\n", buildinfo.Version)
+ fmt.Fprintf(os.Stderr, "==========================================\n\n")
+
+ // Acquire lock
+ lk, err := lock.Acquire(exec)
+ if err != nil {
+ return fmt.Errorf("acquiring lock: %w", err)
+ }
+ defer func() {
+ lk.Release()
+ log.Progress("Lock released (PID: %d)", os.Getpid())
+ }()
+ log.Progress("Lock acquired (PID: %d)", os.Getpid())
+
+ // Device info
+ log.Progress("Gathering device information...")
+ dev := device.Gather(ctx, exec)
+ log.Progress("Device ID (Serial): %s", dev.SerialNumber)
+ log.Progress("OS Version: %s", dev.OSVersion)
+ log.Progress("Developer: %s", dev.UserIdentity)
+
+ // Resolve search dirs
+ searchDirs := resolveSearchDirs(exec, cfg.SearchDirs)
+ fmt.Fprintln(os.Stderr)
+
+ // Detect IDEs
+ log.Progress("Detecting IDE and AI desktop app installations...")
+ ideDetector := detector.NewIDEDetector(exec)
+ ides := ideDetector.Detect(ctx)
+ for _, ide := range ides {
+ log.Progress(" Found: %s (%s) v%s at %s", ideDisplayName(ide.IDEType), ide.Vendor, ide.Version, ide.InstallPath)
+ }
+ if len(ides) == 0 {
+ log.Progress(" No IDEs or AI desktop apps found")
+ }
+ fmt.Fprintln(os.Stderr)
+
+ // Collect extensions
+ log.Progress("Scanning extensions...")
+ extDetector := detector.NewExtensionDetector(exec)
+ extensions := extDetector.Detect(ctx, searchDirs)
+ log.Progress("Found total of %d IDE extensions", len(extensions))
+ fmt.Fprintln(os.Stderr)
+
+ // Detect AI tools
+ log.Progress("Detecting AI agents and tools...")
+ fmt.Fprintln(os.Stderr)
+
+ log.Progress("Detecting AI CLI tools...")
+ cliTools := detector.NewAICLIDetector(exec).Detect(ctx)
+ for _, t := range cliTools {
+ log.Progress(" Found: %s (%s) v%s at %s", t.Name, t.Vendor, t.Version, t.BinaryPath)
+ }
+ if len(cliTools) == 0 {
+ log.Progress(" No AI CLI tools found")
+ }
+ fmt.Fprintln(os.Stderr)
+
+ log.Progress("Detecting general-purpose AI agents...")
+ agents := detector.NewAgentDetector(exec).Detect(ctx, searchDirs)
+ for _, a := range agents {
+ log.Progress(" Found: %s (%s) at %s", a.Name, a.Vendor, a.InstallPath)
+ }
+ if len(agents) == 0 {
+ log.Progress(" No general-purpose AI agents found")
+ }
+ fmt.Fprintln(os.Stderr)
+
+ log.Progress("Detecting AI frameworks and runtimes...")
+ frameworks := detector.NewFrameworkDetector(exec).Detect(ctx)
+ for _, f := range frameworks {
+ running := "false"
+ if f.IsRunning != nil && *f.IsRunning {
+ running = "true"
+ }
+ log.Progress(" Found: %s v%s at %s (running: %s)", f.Name, f.Version, f.BinaryPath, running)
+ }
+ if len(frameworks) == 0 {
+ log.Progress(" No AI frameworks found")
+ }
+ fmt.Fprintln(os.Stderr)
+
+ allAI := append(append(cliTools, agents...), frameworks...)
+
+ // MCP configs
+ log.Progress("Collecting MCP configuration files...")
+ mcpDetector := detector.NewMCPDetector(exec)
+ mcpConfigs := mcpDetector.DetectEnterprise(ctx)
+ for _, c := range mcpConfigs {
+ log.Progress(" Found: %s config (%s)", c.ConfigSource, c.Vendor)
+ }
+ if len(mcpConfigs) == 0 {
+ log.Progress(" No MCP config files found")
+ }
+ fmt.Fprintln(os.Stderr)
+
+ // Node.js scanning
+ npmEnabled := true
+ if cfg.EnableNPMScan != nil {
+ npmEnabled = *cfg.EnableNPMScan
+ }
+
+ var pkgManagers []model.PkgManager
+ var globalPkgs []model.NodeScanResult
+ var nodeProjects []model.NodeScanResult
+ var nodeScanMs int64
+
+ if npmEnabled {
+ log.Progress("Node.js package scanning is ENABLED")
+
+ log.Progress("Detecting Node.js package managers...")
+ npmDetector := detector.NewNodePMDetector(exec)
+ pkgManagers = npmDetector.DetectManagers(ctx)
+ for _, pm := range pkgManagers {
+ log.Progress(" Found: %s v%s at %s", pm.Name, pm.Version, pm.Path)
+ }
+ fmt.Fprintln(os.Stderr)
+
+ log.Progress("Scanning globally installed packages...")
+ nodeScanner := detector.NewNodeScanner(exec, log)
+ globalPkgs = nodeScanner.ScanGlobalPackages(ctx)
+ log.Progress(" Found %d global package location(s)", len(globalPkgs))
+ fmt.Fprintln(os.Stderr)
+
+ log.Progress("Searching for Node.js projects...")
+ scanStart := time.Now()
+ nodeProjects = nodeScanner.ScanProjects(ctx, searchDirs)
+ nodeScanMs = time.Since(scanStart).Milliseconds()
+ log.Progress(" Found %d Node.js projects", len(nodeProjects))
+ log.Progress(" Scan duration: %dms", nodeScanMs)
+ fmt.Fprintln(os.Stderr)
+ } else {
+ log.Progress("Node.js package scanning is DISABLED")
+ fmt.Fprintln(os.Stderr)
+ }
+
+ if globalPkgs == nil {
+ globalPkgs = []model.NodeScanResult{}
+ }
+ if nodeProjects == nil {
+ nodeProjects = []model.NodeScanResult{}
+ }
+
+ // Finalize execution logs before building payload
+ execLogsBase64 := capture.Finalize()
+ endTime := time.Now()
+
+ // Build payload
+ payload := &Payload{
+ CustomerID: config.CustomerID,
+ DeviceID: dev.SerialNumber,
+ SerialNumber: dev.SerialNumber,
+ UserIdentity: dev.UserIdentity,
+ Hostname: dev.Hostname,
+ Platform: "darwin",
+ OSVersion: dev.OSVersion,
+ AgentVersion: buildinfo.Version,
+ CollectedAt: endTime.Unix(),
+ NoUserLoggedIn: dev.UserIdentity == "" || dev.UserIdentity == "unknown",
+
+ IDEExtensions: extensions,
+ IDEInstallations: ides,
+ NodePkgManagers: pkgManagers,
+ NodeGlobalPackages: globalPkgs,
+ NodeProjects: nodeProjects,
+ AIAgents: allAI,
+ MCPConfigs: mcpConfigs,
+
+ ExecutionLogs: &ExecutionLogs{
+ OutputBase64: execLogsBase64,
+ StartTime: startTime.Unix(),
+ EndTime: endTime.Unix(),
+ ExitCode: 0,
+ AgentVersion: buildinfo.Version,
+ },
+
+ PerformanceMetrics: &PerformanceMetrics{
+ ExtensionsCount: len(extensions),
+ NodePackagesScanMs: nodeScanMs,
+ NodeGlobalPkgsCount: len(globalPkgs),
+ NodeProjectsCount: len(nodeProjects),
+ },
+ }
+
+ // Upload to S3
+ log.Progress("Requesting upload URL from backend...")
+ if err := uploadToS3(ctx, log, payload); err != nil {
+ return fmt.Errorf("uploading telemetry: %w", err)
+ }
+
+ fmt.Fprintln(os.Stderr)
+ log.Progress("Telemetry collection completed successfully")
+ return nil
+}
+
+func uploadToS3(ctx context.Context, log *progress.Logger, payload *Payload) error {
+ payloadJSON, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("marshaling payload: %w", err)
+ }
+
+ // Request upload URL
+ reqBody, _ := json.Marshal(map[string]string{
+ "device_id": payload.DeviceID,
+ })
+
+ uploadURLEndpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/telemetry/upload-url",
+ config.APIEndpoint, config.CustomerID)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURLEndpoint, bytes.NewReader(reqBody))
+ if err != nil {
+ return fmt.Errorf("creating upload URL request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+config.APIKey)
+ req.Header.Set("X-Agent-Version", buildinfo.Version)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("requesting upload URL: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var urlResp struct {
+ UploadURL string `json:"upload_url"`
+ S3Key string `json:"s3_key"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&urlResp); err != nil {
+ return fmt.Errorf("decoding upload URL response: %w", err)
+ }
+
+ if urlResp.UploadURL == "" {
+ return fmt.Errorf("empty upload URL in response")
+ }
+
+ // Upload payload to S3
+ log.Progress("Uploading telemetry to S3...")
+ putReq, err := http.NewRequestWithContext(ctx, http.MethodPut, urlResp.UploadURL, bytes.NewReader(payloadJSON))
+ if err != nil {
+ return fmt.Errorf("creating S3 PUT request: %w", err)
+ }
+ putReq.Header.Set("Content-Type", "application/json")
+
+ putResp, err := client.Do(putReq)
+ if err != nil {
+ return fmt.Errorf("uploading to S3: %w", err)
+ }
+ defer putResp.Body.Close()
+ _, _ = io.Copy(io.Discard, putResp.Body)
+
+ if putResp.StatusCode != http.StatusOK {
+ return fmt.Errorf("S3 upload failed with status %d", putResp.StatusCode)
+ }
+ log.Progress("Uploaded to S3")
+
+ // Notify backend
+ log.Progress("Notifying backend of upload...")
+ notifyBody, _ := json.Marshal(map[string]string{
+ "s3_key": urlResp.S3Key,
+ "device_id": payload.DeviceID,
+ })
+
+ notifyEndpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/telemetry/process-uploaded",
+ config.APIEndpoint, config.CustomerID)
+
+ notifyReq, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyEndpoint, bytes.NewReader(notifyBody))
+ if err != nil {
+ return fmt.Errorf("creating notify request: %w", err)
+ }
+ notifyReq.Header.Set("Content-Type", "application/json")
+ notifyReq.Header.Set("Authorization", "Bearer "+config.APIKey)
+ notifyReq.Header.Set("X-Agent-Version", buildinfo.Version)
+
+ notifyResp, err := client.Do(notifyReq)
+ if err != nil {
+ return fmt.Errorf("notifying backend: %w", err)
+ }
+ defer notifyResp.Body.Close()
+ _, _ = io.Copy(io.Discard, notifyResp.Body)
+
+ if notifyResp.StatusCode != http.StatusOK && notifyResp.StatusCode != http.StatusCreated {
+ return fmt.Errorf("backend notification failed with status %d", notifyResp.StatusCode)
+ }
+ log.Progress("Backend processing initiated (HTTP %d)", notifyResp.StatusCode)
+
+ return nil
+}
+
+func resolveSearchDirs(exec executor.Executor, dirs []string) []string {
+ resolved := make([]string, 0, len(dirs))
+ for _, d := range dirs {
+ if d == "$HOME" {
+ u, err := exec.CurrentUser()
+ if err == nil {
+ d = u.HomeDir
+ }
+ }
+ resolved = append(resolved, d)
+ }
+ return resolved
+}
+
+func ideDisplayName(ideType string) string {
+ switch ideType {
+ case "vscode":
+ return "Visual Studio Code"
+ case "cursor":
+ return "Cursor"
+ case "windsurf":
+ return "Windsurf"
+ case "antigravity":
+ return "Antigravity"
+ case "zed":
+ return "Zed"
+ case "claude_desktop":
+ return "Claude"
+ case "microsoft_copilot_desktop":
+ return "Microsoft Copilot"
+ default:
+ return ideType
+ }
+}
diff --git a/tests/test_smoke_go.sh b/tests/test_smoke_go.sh
new file mode 100755
index 0000000..fecb07a
--- /dev/null
+++ b/tests/test_smoke_go.sh
@@ -0,0 +1,358 @@
+#!/bin/bash
+#
+# Smoke tests for the Go binary (stepsecurity-dev-machine-guard)
+# Adapted from tests/test_smoke.sh
+#
+# Usage: bash tests/test_smoke_go.sh
+#
+
+set -uo pipefail
+
+BINARY="./stepsecurity-dev-machine-guard"
+
+#==============================================================================
+# Test framework
+#==============================================================================
+
+PASS_COUNT=0
+FAIL_COUNT=0
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+BOLD='\033[1m'
+RESET='\033[0m'
+
+pass() {
+ PASS_COUNT=$((PASS_COUNT + 1))
+ printf " ${GREEN}PASS${RESET} %s\n" "$1"
+}
+
+fail() {
+ FAIL_COUNT=$((FAIL_COUNT + 1))
+ printf " ${RED}FAIL${RESET} %s\n" "$1"
+ if [ -n "${2:-}" ]; then
+ printf " %s\n" "$2"
+ fi
+}
+
+assert_eq() {
+ local label="$1" expected="$2" actual="$3"
+ if [ "$expected" = "$actual" ]; then
+ pass "$label"
+ else
+ fail "$label" "expected=$expected actual=$actual"
+ fi
+}
+
+assert_contains() {
+ local label="$1" haystack="$2" needle="$3"
+ if echo "$haystack" | grep -q "$needle"; then
+ pass "$label"
+ else
+ fail "$label" "output does not contain: $needle"
+ fi
+}
+
+section() {
+ printf "\n${BOLD}── %s${RESET}\n" "$1"
+}
+
+#==============================================================================
+# 0. Binary check
+#==============================================================================
+
+section "Binary check"
+
+if [ -f "$BINARY" ] && [ -x "$BINARY" ]; then
+ pass "Binary exists and is executable"
+else
+ fail "Binary exists and is executable"
+ printf "\n Build the binary first: make build\n\n"
+ exit 1
+fi
+
+#==============================================================================
+# 1. CLI basics
+#==============================================================================
+
+section "CLI basics"
+
+HELP_RC=0
+HELP_OUTPUT=$("$BINARY" --help 2>&1) || HELP_RC=$?
+assert_eq "--help exits 0" "0" "$HELP_RC"
+assert_contains "--help shows usage" "$HELP_OUTPUT" "Usage:"
+
+VERSION_RC=0
+VERSION_OUTPUT=$("$BINARY" --version 2>&1) || VERSION_RC=$?
+assert_eq "--version exits 0" "0" "$VERSION_RC"
+assert_contains "--version prints version string" "$VERSION_OUTPUT" "StepSecurity Dev Machine Guard v"
+
+#==============================================================================
+# 2. Pretty output (default)
+#==============================================================================
+
+section "Pretty output (default)"
+
+PRETTY_OUTPUT=$("$BINARY" --pretty --color=never 2>&1 || true)
+assert_contains "Runs successfully (produces output)" "$PRETTY_OUTPUT" "StepSecurity"
+assert_contains "Output contains DEVICE header" "$PRETTY_OUTPUT" "DEVICE"
+assert_contains "Output contains AI AGENTS header" "$PRETTY_OUTPUT" "AI AGENTS"
+assert_contains "Output contains SUMMARY header" "$PRETTY_OUTPUT" "SUMMARY"
+
+#==============================================================================
+# 3. JSON output
+#==============================================================================
+
+section "JSON output"
+
+JSON_OUTPUT=$("$BINARY" --json 2>/dev/null || true)
+
+# Validate well-formed JSON
+if echo "$JSON_OUTPUT" | python3 -m json.tool >/dev/null 2>&1; then
+ pass "Output is valid JSON"
+else
+ fail "Output is valid JSON"
+fi
+
+# Top-level keys
+for key in agent_version device ai_agents_and_tools ide_installations ide_extensions mcp_configs summary; do
+ if echo "$JSON_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert '$key' in d" 2>/dev/null; then
+ pass "JSON has top-level key: $key"
+ else
+ fail "JSON has top-level key: $key"
+ fi
+done
+
+# Scan metadata fields
+for key in scan_timestamp scan_timestamp_iso agent_version; do
+ if echo "$JSON_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert '$key' in d" 2>/dev/null; then
+ pass "JSON has scan metadata field: $key"
+ else
+ fail "JSON has scan metadata field: $key"
+ fi
+done
+
+# device object fields
+for key in hostname os_version serial_number platform user_identity; do
+ if echo "$JSON_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert '$key' in d['device']" 2>/dev/null; then
+ pass "device has field: $key"
+ else
+ fail "device has field: $key"
+ fi
+done
+
+# summary has numeric count fields
+SUMMARY_CHECK=$(echo "$JSON_OUTPUT" | python3 -c "
+import sys, json
+d = json.load(sys.stdin)
+s = d['summary']
+counts = ['ai_agents_and_tools_count', 'ide_installations_count', 'ide_extensions_count', 'mcp_configs_count', 'node_projects_count']
+for c in counts:
+ assert c in s, f'missing {c}'
+ assert isinstance(s[c], int), f'{c} is not int'
+print('ok')
+" 2>&1)
+assert_eq "summary has numeric count fields" "ok" "$SUMMARY_CHECK"
+
+#==============================================================================
+# 4. HTML output
+#==============================================================================
+
+section "HTML output"
+
+HTML_TMP="/tmp/test-dmg-go-report-$$.html"
+"$BINARY" --html "$HTML_TMP" >/dev/null 2>&1 || true
+
+if [ -f "$HTML_TMP" ] && [ -s "$HTML_TMP" ]; then
+ pass "HTML file exists and is non-empty"
+else
+ fail "HTML file exists and is non-empty"
+fi
+
+HTML_CONTENT=""
+if [ -f "$HTML_TMP" ]; then
+ HTML_CONTENT=$(cat "$HTML_TMP")
+fi
+assert_contains "HTML contains tag" "$HTML_CONTENT" ""
+
+rm -f "$HTML_TMP"
+pass "Cleaned up temp HTML file"
+
+#==============================================================================
+# 5. Flag combinations
+#==============================================================================
+
+section "Flag combinations"
+
+VERBOSE_OUT=$("$BINARY" --pretty --verbose --color=never 2>&1 || true)
+if [ -n "$VERBOSE_OUT" ]; then
+ pass "--verbose runs and produces output"
+else
+ fail "--verbose runs and produces output"
+fi
+
+JSON_VERBOSE_OUT=$("$BINARY" --json --verbose 2>/dev/null || true)
+if echo "$JSON_VERBOSE_OUT" | python3 -m json.tool >/dev/null 2>&1; then
+ pass "--json --verbose produces valid JSON"
+else
+ fail "--json --verbose produces valid JSON"
+fi
+
+COLOR_JSON_OUT=$("$BINARY" --color=never --json 2>/dev/null || true)
+if echo "$COLOR_JSON_OUT" | python3 -m json.tool >/dev/null 2>&1; then
+ pass "--color=never --json produces valid JSON"
+else
+ fail "--color=never --json produces valid JSON"
+fi
+
+# Invalid flag must exit non-zero
+BOGUS_RC=0
+"$BINARY" --bogus-flag >/dev/null 2>&1 || BOGUS_RC=$?
+if [ "$BOGUS_RC" -ne 0 ]; then
+ pass "Invalid flag exits non-zero"
+else
+ fail "Invalid flag exits non-zero" "exit code was 0"
+fi
+
+BOGUS_ERR=$("$BINARY" --bogus-flag 2>&1 || true)
+assert_contains "Invalid flag shows error" "$BOGUS_ERR" "Unknown option"
+
+#==============================================================================
+# 6. JSON schema validation
+#==============================================================================
+
+section "JSON schema validation"
+
+# ai_tools items have name and type
+AI_SCHEMA=$(echo "$JSON_OUTPUT" | python3 -c "
+import sys, json
+d = json.load(sys.stdin)
+items = d['ai_agents_and_tools']
+if len(items) == 0:
+ print('ok_empty')
+else:
+ for i, item in enumerate(items):
+ assert 'name' in item, f'ai_tools[{i}] missing name'
+ assert 'type' in item, f'ai_tools[{i}] missing type'
+ print('ok')
+" 2>&1)
+if [ "$AI_SCHEMA" = "ok" ] || [ "$AI_SCHEMA" = "ok_empty" ]; then
+ pass "ai_agents_and_tools items have name and type"
+else
+ fail "ai_agents_and_tools items have name and type" "$AI_SCHEMA"
+fi
+
+# ide_installations items have ide_type and version
+IDE_SCHEMA=$(echo "$JSON_OUTPUT" | python3 -c "
+import sys, json
+d = json.load(sys.stdin)
+items = d['ide_installations']
+if len(items) == 0:
+ print('ok_empty')
+else:
+ for i, item in enumerate(items):
+ assert 'ide_type' in item, f'ide_installations[{i}] missing ide_type'
+ assert 'version' in item, f'ide_installations[{i}] missing version'
+ print('ok')
+" 2>&1)
+if [ "$IDE_SCHEMA" = "ok" ] || [ "$IDE_SCHEMA" = "ok_empty" ]; then
+ pass "ide_installations items have ide_type and version"
+else
+ fail "ide_installations items have ide_type and version" "$IDE_SCHEMA"
+fi
+
+# ide_extensions items have name, publisher, and ide_type
+EXT_SCHEMA=$(echo "$JSON_OUTPUT" | python3 -c "
+import sys, json
+d = json.load(sys.stdin)
+items = d['ide_extensions']
+if len(items) == 0:
+ print('ok_empty')
+else:
+ for i, item in enumerate(items):
+ assert 'name' in item, f'ide_extensions[{i}] missing name'
+ assert 'publisher' in item, f'ide_extensions[{i}] missing publisher'
+ assert 'ide_type' in item, f'ide_extensions[{i}] missing ide_type'
+ print('ok')
+" 2>&1)
+if [ "$EXT_SCHEMA" = "ok" ] || [ "$EXT_SCHEMA" = "ok_empty" ]; then
+ pass "ide_extensions items have name, publisher, and ide_type"
+else
+ fail "ide_extensions items have name, publisher, and ide_type" "$EXT_SCHEMA"
+fi
+
+# mcp_configs items have config_source, config_path, and vendor
+MCP_SCHEMA=$(echo "$JSON_OUTPUT" | python3 -c "
+import sys, json
+d = json.load(sys.stdin)
+items = d['mcp_configs']
+if len(items) == 0:
+ print('ok_empty')
+else:
+ for i, item in enumerate(items):
+ assert 'config_source' in item, f'mcp_configs[{i}] missing config_source'
+ assert 'config_path' in item, f'mcp_configs[{i}] missing config_path'
+ assert 'vendor' in item, f'mcp_configs[{i}] missing vendor'
+ print('ok')
+" 2>&1)
+if [ "$MCP_SCHEMA" = "ok" ] || [ "$MCP_SCHEMA" = "ok_empty" ]; then
+ pass "mcp_configs items have config_source, config_path, and vendor"
+else
+ fail "mcp_configs items have config_source, config_path, and vendor" "$MCP_SCHEMA"
+fi
+
+#==============================================================================
+# 7. Search dirs
+#==============================================================================
+
+section "Search dirs"
+
+SEARCH_RC=0
+SEARCH_OUT=$("$BINARY" --search-dirs /tmp --json 2>/dev/null || true)
+if echo "$SEARCH_OUT" | python3 -m json.tool >/dev/null 2>&1; then
+ pass "--search-dirs /tmp --json produces valid JSON"
+else
+ fail "--search-dirs /tmp --json produces valid JSON"
+fi
+
+# Missing dir arg
+SEARCH_ERR_RC=0
+"$BINARY" --search-dirs 2>/dev/null || SEARCH_ERR_RC=$?
+if [ "$SEARCH_ERR_RC" -ne 0 ]; then
+ pass "--search-dirs without arg exits non-zero"
+else
+ fail "--search-dirs without arg exits non-zero"
+fi
+
+#==============================================================================
+# 8. Configure command
+#==============================================================================
+
+section "Configure command"
+
+# configure should be a recognized command (not "Unknown option")
+assert_contains "--help mentions configure" "$HELP_OUTPUT" "configure"
+
+# configure with empty stdin should not crash (sends EOF immediately)
+CONFIGURE_RC=0
+echo "" | "$BINARY" configure >/dev/null 2>&1 || CONFIGURE_RC=$?
+assert_eq "configure exits 0 with empty input" "0" "$CONFIGURE_RC"
+
+# Config file should be created
+CONFIG_PATH="$HOME/.stepsecurity/config.json"
+if [ -f "$CONFIG_PATH" ]; then
+ pass "configure creates config file"
+else
+ fail "configure creates config file"
+fi
+
+#==============================================================================
+# Summary
+#==============================================================================
+
+printf "\n${BOLD}══════════════════════════════════════${RESET}\n"
+printf " Total: %d ${GREEN}Passed: %d${RESET} ${RED}Failed: %d${RESET}\n" \
+ $((PASS_COUNT + FAIL_COUNT)) "$PASS_COUNT" "$FAIL_COUNT"
+printf "${BOLD}══════════════════════════════════════${RESET}\n\n"
+
+[ "$FAIL_COUNT" -eq 0 ]