Skip to content

feat(nanomind): shell out nanomind setup to nanomind-analyst installer#180

Merged
thebenignhacker merged 1 commit into
mainfrom
feat/nanomind-setup-shell-out
May 11, 2026
Merged

feat(nanomind): shell out nanomind setup to nanomind-analyst installer#180
thebenignhacker merged 1 commit into
mainfrom
feat/nanomind-setup-shell-out

Conversation

@thebenignhacker
Copy link
Copy Markdown
Contributor

Summary

  • `hackmyagent nanomind setup` previously printed manual install instructions. With the `nanomind-analyst` PyPI package now live (0.1.0 bootstrap-published 2026-05-11), this PR makes setup drive the installer end-to-end when it is on $PATH.
  • Path resolution is gated by `realpathSync` + parent-directory-not-group/world-writable check, narrowing the TOCTOU window between resolve and exec and rejecting symlink-redirected binaries in user-writable PATH dirs.
  • After the installer exits 0, HMA re-probes the daemon socket and prints its own diagnostic when the installer's exit code disagrees with daemon health — so a misbehaving installer cannot launder a fake "success" through inherited stdio.

Behavior matrix

State Outcome
Daemon already healthy Short-circuits, returns true
Non-darwin platform Prints "Apple Silicon required" refusal, returns false
Darwin, installer absent on PATH Prints `pip install nanomind-analyst && nanomind-analyst install` one-liner, returns false
Darwin, installer on PATH, parent dir 0755+ Shells out to `nanomind-analyst install` with inherited stdio; re-probes daemon; returns true iff daemon ends up healthy
Darwin, installer resolves to group/world-writable parent dir Rejected (treats as not-on-PATH); prints pip install one-liner; returns false
Darwin, installer exits 0 but daemon still cold Prints HMA-owned diagnostic line; returns false

Adversarial review

Phase 4.5 subagent surfaced 3 P1/P2 — all closed in the same commit:

Finding Severity Fix
TOCTOU between `which` and `execFileSync` P1 realpath + parent-dir-permission check
stdio-inherit lets installer forge "success" banner P1 Post-install `isDaemonHealthy()` + HMA-owned stderr diagnostic
`which` resolves through symlinks (spoofing) P2 realpath rejects symlinks redirecting outside accepted prefix
Test #3 was a tautology that passed even if shell-out silently no-op'd P2 Added shim test with sentinel-file assertion + symlink-rejection test
Quiet mode swallows audit trail (no log line on failure) P2 Deferred — no programmatic callers in this release

Test plan

  • 23/23 `security-analyst.test.ts` pass (3 new for setup behavior + 1 sentinel-shim + 1 symlink-rejection)
  • Full suite: 2115 pass, 16 skipped (pre-existing), 0 failed
  • Manual smoke: daemon-absent + installer-absent → pip install one-liner, exit 0
  • Manual smoke: stub shim on PATH → resolves via /usr/bin/which, execs shim with `install`, returns false post-install (daemon still absent)
  • HMA self-scan: 98/100, 1 LOW only
  • Scanner golden path on intentionally-vulnerable `test/hma/`: 0/100, 34 critical (expected, unchanged)

Release note

Part of the unpublished 0.23.0 batch. No version bump — this commit updates the existing 0.23.0 CHANGELOG entry rather than creating a new version line, per the publish-batching rule. The cli-v0.23.0 tag push (next workstream) will publish the daemon-routing rewrite (PR #179) and this setup shell-out as a single 0.23.0 npm release.

`hackmyagent nanomind setup` used to print manual install instructions for
the NanoMind-Guard daemon. Now that `nanomind-analyst` is on PyPI, the
setup command drives install end-to-end when the installer is on PATH.

Resolution is gated by a path-safety check (realpath; parent dir must not
be group- or world-writable) so a symlink dropped into a user-writable
PATH dir or a brew prefix with group write cannot redirect to an
attacker-controlled binary. The TOCTOU window between resolve and exec
is narrowed to the same window that exists for any binary in a 0755 dir
the user owns.

Inherited stdio means the installer's terminal output reaches the user
directly. If the installer exits 0 but the daemon socket is still cold,
HMA prints its own diagnostic so a misbehaving installer cannot launder
a fake "success" past the user.

When `nanomind-analyst` is absent on PATH, HMA prints the pip / pipx
one-liner. On non-Darwin platforms the previous refusal is unchanged.

Three new tests cover the meaningful branches: daemon-already-healthy
short-circuit, shim on PATH (asserts a sentinel file the shim wrote so
a future regression that no-op'd execFileSync would fail CI), and
symlink-with-group-writable-parent rejection.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

Security Review: PR #180 - NanoMind Setup Shell-Out Implementation

VERDICT: APPROVE

SUMMARY

This PR transitions nanomind setup from printing manual instructions to driving the nanomind-analyst PyPI installer end-to-end when it's on $PATH. The implementation includes robust security mitigations: resolution via absolute /usr/bin/which path, realpath validation to reject symlink redirection, parent directory permission checks (rejecting group/world-writable dirs), and post-install daemon health verification to prevent installer stdout laundering. After verification against the full source files, all potential command injection and TOCTOU vectors are adequately mitigated. The shell-out uses execFileSync with array arguments (not shell-interpolated), the which binary is invoked by absolute path, and the parent-directory check narrows the TOCTOU window to user-controlled prefixes. No unmitigated CRITICAL or HIGH findings remain.

FINDINGS

None. All security-critical patterns verified and adequately mitigated:

Verified Mitigations (Not Reported)

  1. Command injection via which resolution — Line 272: execFileSync('/usr/bin/which', ['nanomind-analyst'], ...) passes arguments as array elements, not shell-interpolated. The which binary is invoked by absolute path /usr/bin/which, preventing $PATH pollution attacks.

  2. Command injection in installer invocation — Line 221: execFileSync(installerPath, ['install'], { stdio: 'inherit' }) passes ['install'] as an array. No shell is spawned, no string concatenation, no interpolation of untrusted input.

  3. TOCTOU between which and execFileSync — Lines 283-297: The parent directory permission check ((st.mode & 0o022) !== 0) rejects paths in group/world-writable directories. This narrows the TOCTOU window to user-controlled prefixes (Homebrew, pipx, ~/.local/bin at 0755). While the window isn't zero, an attacker would need write access to a directory the user trusts, which is outside the threat model for command-line tools. Additional defense: realpathSync at line 278 resolves symlinks before the permission check, preventing symlink-redirected paths from bypassing the guard.

  4. Stdio inheritance allowing installer to forge success — Lines 237-244: Post-install health check via isDaemonHealthy() re-probes the socket after the installer exits. If the installer reported success (exit 0) but the daemon is not responding, HMA prints its own diagnostic at lines 239-244, preventing a malicious installer from laundering a fake success through inherited stdio.

  5. Platform-specific code paths — Lines 166-188: Darwin check at line 166 (if (process.platform !== 'darwin')) ensures the shell-out logic only executes on macOS. Non-Darwin platforms print the manual install instructions and return false. The platform check precedes all filesystem operations, preventing Windows-specific attacks like where.exe hijacking.


Reviewed 5 files changed (18600 bytes)

@thebenignhacker thebenignhacker merged commit e77d93c into main May 11, 2026
1 check passed
@thebenignhacker thebenignhacker deleted the feat/nanomind-setup-shell-out branch May 11, 2026 21:23
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.

1 participant