feat(detector): resolve tool versions from install metadata before exec and skip exect of Gatekeeper-rejected quarantined binaries#169
Merged
Conversation
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>
… 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>
There was a problem hiding this comment.
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/versionmetato derive versions from on-disk metadata (npm manifests,<tool>/versions/<v>layouts, Homebrew Cellar/Caskroom, macOS app bundles) without launching the tool. - Added
internal/execguardto prevent exec-based version probes for quarantined + Gatekeeper-rejected binaries on macOS (uses Apple toolsxattr+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.FromBinarywithout needing a--versioncommand stub, and (2) verify that a quarantined + spctl-rejected binary does not execute the version command and yieldsVersion=="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.
|
|
||
| ### 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 |
Member
There was a problem hiding this comment.
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) |
Member
There was a problem hiding this comment.
Here as well, we can pass a slice of args.
varunsh-coder
approved these changes
Jul 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Type of change
Testing
./stepsecurity-dev-machine-guard --verbose./stepsecurity-dev-machine-guard --json | python3 -m json.toolmake lintmake testRelated Issues