Skip to content

feat(detector): resolve tool versions from install metadata before exec and skip exect of Gatekeeper-rejected quarantined binaries#169

Merged
ashishkurmi merged 4 commits into
step-security:mainfrom
ashishkurmi:main
Jul 17, 2026
Merged

feat(detector): resolve tool versions from install metadata before exec and skip exect of Gatekeeper-rejected quarantined binaries#169
ashishkurmi merged 4 commits into
step-security:mainfrom
ashishkurmi:main

Conversation

@ashishkurmi

Copy link
Copy Markdown
Member

What does this PR do?

Type of change

  • Bug fix
  • Enhancement
  • Documentation

Testing

  • Tested on macOS (version: ___)
  • 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
  • Lint passes: make lint
  • Tests pass: make test

Related Issues

ashishkurmi and others added 2 commits July 15, 2026 17:43
Executing third-party binaries for --version popped macOS Gatekeeper
"could not verify" dialogs when a tool ships un-notarized native code
(cursor-agent dlopens merkle-tree-napi.darwin-arm64.node on any
invocation, including --version).

- new internal/versionmeta package resolves versions from on-disk
  metadata: npm package.json manifests (with a package-name identity
  guard so foreign manifests like corepack's yarn shim are rejected),
  <tool>/versions/<v> install layouts, Homebrew Cellar/Caskroom paths
  (with _N bottle-revision stripping), and macOS app-bundle Info.plist
- wired static-first into the AI CLI / agent / framework detectors,
  Node and Python package-manager probes, and configaudit yarn/bun;
  the --version exec is unchanged as the fallback
- copilot VerifyFunc now confirms identity from the npm manifest
  before exec'ing the binary
- every remaining exec fallback logs a stderr line
  ("exec fallback: running ...") so rollouts can track which tools
  still get executed; detectors gain a chainable WithLogger

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… on macOS

Metadata-first version resolution removed most third-party execs, but a
tool with no readable install metadata still fell back to running
`--version` — and if that binary is quarantined (com.apple.quarantine,
set by browser downloads and Homebrew casks) and not notarized, macOS
shows the "could not verify ... free of malware / Move to Bin" dialog
in the user's session.

New internal/execguard package, checked before every version-probe exec
fallback (AI CLI/agent/framework, node+python PM, configaudit yarn/bun)
and the aicli identity VerifyFuncs:

- quarantine gate first: xattr -p com.apple.quarantine on the resolved
  binary and its containing directory. Unquarantined binaries are safe
  by definition (Gatekeeper only assesses quarantined files) — verified
  that brew formula binaries are spctl-rejected yet unquarantined, so
  gating on spctl alone would have broken their version probes.
- quarantined binaries get Gatekeeper's silent verdict via
  `spctl --assess --type execute`; rejected (or spctl failure,
  conservatively) -> skip the exec, log a warning, report "unknown".

Only Apple-provided utilities (/usr/bin/xattr, /usr/sbin/spctl) are
executed by the guard itself. Non-macOS platforms are unaffected.
Generalizes the existing IsAppleCLTStub guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ashishkurmi ashishkurmi changed the title a feat(detector): resolve tool versions from install metadata before exec and skip exect of Gatekeeper-rejected quarantined binaries Jul 16, 2026
… claim

Review feedback fixes:

- nodepm, pythonpm, and configaudit yarn/bun assessed the resolved
  absolute path with execguard but then executed the bare command name,
  letting a PATH re-resolution at exec time reach a binary the guard
  never assessed. All version-probe fallbacks now run the exact path
  that was assessed (bare name kept only when LookPath itself failed).
- versionmeta's PlistBuddy read used bare Run; switched to
  RunWithTimeout(10s) per the external-command timeout convention.
- CHANGELOG no longer claims the guard means a scan "can never" trigger
  the Gatekeeper dialog: spctl assesses the launched binary, not native
  plugins it may dlopen later — metadata-first resolution remains the
  primary defense; the guard removes the main scan-triggered vector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces risk and side effects from version probing by resolving tool versions from install metadata before executing third-party binaries, and by adding a macOS Gatekeeper-aware pre-exec guard to avoid triggering “could not verify … free of malware” dialogs.

Changes:

  • Added internal/versionmeta to derive versions from on-disk metadata (npm manifests, <tool>/versions/<v> layouts, Homebrew Cellar/Caskroom, macOS app bundles) without launching the tool.
  • Added internal/execguard to prevent exec-based version probes for quarantined + Gatekeeper-rejected binaries on macOS (uses Apple tools xattr + spctl).
  • Updated multiple detectors (AI CLIs/agents/frameworks, Node/Python PMs, bun/yarn config audits) to prefer metadata-first resolution and to log exec fallbacks via injected progress.Logger.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/versionmeta/versionmeta.go New metadata-based version resolver used to avoid exec for version detection.
internal/versionmeta/versionmeta_test.go Unit tests covering npm manifest, versions-dir, Homebrew, app bundle, and shim parsing.
internal/execguard/execguard.go New macOS pre-exec Gatekeeper/quarantine guard to avoid UI popups.
internal/execguard/execguard_test.go Unit tests validating quarantine + spctl assessment behavior.
internal/detector/aicli.go Uses versionmeta first, execguard before exec fallback; adds logger injection and verify-function logging.
internal/detector/aicli_test.go Adds regression tests for cursor-agent metadata version + quarantined exec skip + exec fallback behavior.
internal/detector/agent.go Metadata-first version resolution + Gatekeeper guard + logger injection.
internal/detector/framework.go Metadata-first version resolution + Gatekeeper guard + logger injection.
internal/detector/nodepm.go Metadata-first PM version resolution, exec guard, and logger injection; passes logger into fallback resolver.
internal/detector/nodepm_fallback.go Adds metadata-first + exec guard to fallback resolution, plus logger plumbing.
internal/detector/nodepm_test.go Updates command stubs to match absolute-path execution behavior.
internal/detector/nodepm_fallback_test.go Updates fallback calls to pass a logger (noop) and adjusts stubs accordingly.
internal/detector/pythonpm.go Metadata-first PM version resolution, exec guard, and logger injection.
internal/detector/pythonpm_test.go Updates command stubs to match absolute-path execution behavior.
internal/detector/configaudit/yarn.go Adds metadata-first yarn version, exec guard, and logger injection for audits.
internal/detector/configaudit/yarn_test.go Updates yarn version command stub to match absolute-path execution behavior.
internal/detector/configaudit/bunfig.go Adds metadata-first bun version, exec guard, and logger injection for audits.
internal/detector/configaudit/bunfig_test.go Updates bun version command stub to match absolute-path execution behavior.
internal/telemetry/telemetry.go Threads logger into detectors via WithLogger(log) so exec fallbacks/guards can be surfaced.
internal/scan/scanner.go Threads logger into detectors via WithLogger(log) in community scan path.
CHANGELOG.md Documents the new metadata-first version detection and Gatekeeper pre-exec guard behavior.
Comments suppressed due to low confidence (1)

internal/detector/nodepm.go:74

  • New behavior here (metadata-first version resolution + Gatekeeper quarantine guard that can skip exec fallbacks) isn't directly covered by unit tests in this detector. There are existing tests for NodePMDetector; consider adding cases that (1) return a version via versionmeta.FromBinary without needing a --version command stub, and (2) verify that a quarantined + spctl-rejected binary does not execute the version command and yields Version=="unknown".
		version := ""
		if path != "" {
			// Static-first, exec-last (AGENTS.md §3.4): the manager's own
			// package.json (npm/yarn/pnpm) or Homebrew layout (bun) carries
			// the version without launching anything.
			version = versionmeta.FromBinary(ctx, d.exec, path)
		}
		if path != "" && version == "" && !execguard.SafeToExec(ctx, d.exec, path) {
			d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path)
		} else if path != "" && version == "" {
			// Run the exact absolute path the guard assessed, not the bare
			// name — a PATH re-resolution at exec time could pick a
			// different (unassessed) binary.
			d.log.Progress("exec fallback: running %s %s (no metadata version source)", path, pm.VersionCmd)
			stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, path, pm.VersionCmd)
			if err == nil {
				version = strings.TrimSpace(stdout)
			}
		}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread CHANGELOG.md

### Changed

- **Metadata-first version detection**: tool version probes (AI CLIs, AI agents, AI frameworks, Node and Python package managers) now resolve versions from on-disk metadata — npm `package.json` manifests, `<tool>/versions/<v>` install layouts, Homebrew Cellar/Caskroom paths, and macOS app bundles — before falling back to executing `<tool> --version`. Executing third-party binaries could trigger macOS Gatekeeper "could not verify" popups when a tool ships un-notarized native code (e.g. cursor-agent's `merkle-tree-napi.darwin-arm64.node`); the exec fallback is unchanged, so tools without metadata are still detected exactly as before. Each remaining exec fallback is logged to stderr (`exec fallback: running <binary> ...`) so rollouts can track which tools still get executed.
Comment on lines 60 to 78
version := "unknown"
stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, pm.Binary, pm.VersionCmd)
if err == nil {
v := parsePythonVersion(pm.Name, stdout)
if v != "" {
version = v
// Static-first, exec-last (AGENTS.md §3.4): Homebrew/pipx-style
// layouts carry the version in the install path.
if v := versionmeta.FromBinary(ctx, d.exec, path); v != "" {
version = v
} else if !execguard.SafeToExec(ctx, d.exec, path) {
d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path)
} else {
// Run the exact absolute path the guard assessed, not the bare
// name — a PATH re-resolution at exec time could pick a
// different (unassessed) binary.
d.log.Progress("exec fallback: running %s %s (no metadata version source)", path, pm.VersionCmd)
stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, path, pm.VersionCmd)
if err == nil {
if v := parsePythonVersion(pm.Name, stdout); v != "" {
version = v
}
}
}
Comment on lines +51 to +52
_, _, exitCode, err := exec.RunWithTimeout(ctx, probeTimeout, "/usr/sbin/spctl", "--assess", "--type", "execute", resolved)
return err == nil && exitCode == 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can pass a slice of arguments instead of passing them all as separate strings.

Comment on lines +61 to +62
}
_, _, exitCode, err := exec.RunWithTimeout(ctx, probeTimeout, "/usr/bin/xattr", "-p", "com.apple.quarantine", path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here as well, we can pass a slice of args.

@ashishkurmi
ashishkurmi merged commit 50b23ab into step-security:main Jul 17, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants