nanomind-analyst 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. nanomind_analyst-0.1.0/.gitignore +22 -0
  2. nanomind_analyst-0.1.0/CHANGELOG.md +21 -0
  3. nanomind_analyst-0.1.0/LICENSE +21 -0
  4. nanomind_analyst-0.1.0/PKG-INFO +123 -0
  5. nanomind_analyst-0.1.0/README.md +88 -0
  6. nanomind_analyst-0.1.0/pyproject.toml +70 -0
  7. nanomind_analyst-0.1.0/src/nanomind_analyst/__init__.py +12 -0
  8. nanomind_analyst-0.1.0/src/nanomind_analyst/artifacts.py +232 -0
  9. nanomind_analyst-0.1.0/src/nanomind_analyst/cli.py +121 -0
  10. nanomind_analyst-0.1.0/src/nanomind_analyst/daemon/__init__.py +32 -0
  11. nanomind_analyst-0.1.0/src/nanomind_analyst/daemon/_nlm.py +167 -0
  12. nanomind_analyst-0.1.0/src/nanomind_analyst/daemon/input_classifier/__init__.py +5 -0
  13. nanomind_analyst-0.1.0/src/nanomind_analyst/daemon/input_classifier/predictor.py +255 -0
  14. nanomind_analyst-0.1.0/src/nanomind_analyst/daemon/nanomind_guard_daemon.py +752 -0
  15. nanomind_analyst-0.1.0/src/nanomind_analyst/data/input-classifier-v1/classifier.joblib +0 -0
  16. nanomind_analyst-0.1.0/src/nanomind_analyst/data/input-classifier-v1/meta.json +36 -0
  17. nanomind_analyst-0.1.0/src/nanomind_analyst/install.py +212 -0
  18. nanomind_analyst-0.1.0/src/nanomind_analyst/launchd.py +197 -0
  19. nanomind_analyst-0.1.0/src/nanomind_analyst/lifecycle.py +132 -0
  20. nanomind_analyst-0.1.0/src/nanomind_analyst/paths.py +57 -0
  21. nanomind_analyst-0.1.0/tests/__init__.py +0 -0
  22. nanomind_analyst-0.1.0/tests/test_artifacts.py +183 -0
  23. nanomind_analyst-0.1.0/tests/test_cli.py +105 -0
  24. nanomind_analyst-0.1.0/tests/test_install.py +304 -0
  25. nanomind_analyst-0.1.0/tests/test_lifecycle.py +214 -0
@@ -0,0 +1,22 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ .venv-gate/
7
+ build/
8
+ dist/
9
+ *.whl
10
+ .coverage
11
+ .tox/
12
+
13
+ # Sensitive files — never commit
14
+ .env
15
+ .env.*
16
+ secrets.json
17
+ *.pem
18
+ *.key
19
+ *.p12
20
+ *.pfx
21
+ credentials/
22
+ secrets/
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - CLI: `install`, `uninstall`, `start`, `stop`, `restart`, `status`, `logs`.
8
+ - Vendors the NanoMind-Guard daemon serving the v3.0.0 Qwen3-1.7B Analyst NLM behind the v1 input-classifier gate.
9
+ - Fetches NLM weights from `opena2a/nanomind-security-analyst` at the pinned v3.0.0 commit and verifies SHA256 against wheel-baked constants. Uses `local_dir_use_symlinks=False` so the verify reads bytes at the install location, not a swappable cache symlink.
10
+ - Bundles the input-classifier-v1 artifacts (~5 KB) inside the wheel; daemon re-verifies SHA256 at every boot before `joblib.load()` runs.
11
+ - Per-user launchd LaunchAgent at `~/Library/LaunchAgents/org.opena2a.nanomind-analyst.plist`. No root, no `sudo`. `install` calls `bootout` then `bootstrap` so upgrades reload the new plist rather than silently keeping the old in-memory definition.
12
+ - Installer's healthz probe refuses to connect to `/tmp/nanomind-guard.sock` when the path is a symlink or owned by a different uid (defense against same-host socket squatting).
13
+ - `nanomind-analyst-daemon` entrypoint is wrapped with a Darwin-arm64 platform check so a direct invocation on Linux/Intel fails with a clear message instead of a torch/MPS stacktrace.
14
+ - Release workflow refuses to publish if the tag's commit is not an ancestor of `origin/main`, and if `pyproject.toml` version does not match the tag suffix.
15
+ - Apple Silicon (Darwin arm64) only. Linux/cloud daemon support is tracked separately; the NLM is bf16-MPS and fp16 yields 0% accuracy on Qwen3-1.7B.
16
+
17
+ ### Known limitations
18
+
19
+ - The daemon's default socket lives at `/tmp/nanomind-guard.sock`. On a multi-user macOS host, a different local user can prevent the daemon from binding by pre-creating the path (sticky-bit `/tmp` blocks the daemon's `unlink`). Migrating the default to a per-user path requires coordinated changes in `hackmyagent`'s IPC client; tracked separately.
20
+ - `uninstall --remove-artifacts` clears the user's Application Support dir but does not clear the Hugging Face cache at `~/.cache/huggingface/`. Symlinks are off (the install copies real files), so disk-space recovery requires both removals.
21
+ - GHA workflow steps reference upstream actions by tag (`actions/checkout@v4`, `pypa/gh-action-pypi-publish@release/v1`). Pinning to commit SHAs is a follow-up.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenA2A
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: nanomind-analyst
3
+ Version: 0.1.0
4
+ Summary: Installer for the NanoMind Analyst daemon: serves the Qwen3-1.7B security analyst NLM behind an input-classifier gate over a Unix socket.
5
+ Project-URL: Homepage, https://github.com/opena2a-org/nanomind/tree/main/packages/nanomind-analyst
6
+ Project-URL: Repository, https://github.com/opena2a-org/nanomind
7
+ Project-URL: Documentation, https://github.com/opena2a-org/nanomind/tree/main/packages/nanomind-analyst#readme
8
+ Project-URL: Model Card, https://huggingface.co/opena2a/nanomind-security-analyst
9
+ Project-URL: Bug Tracker, https://github.com/opena2a-org/nanomind/issues
10
+ Author-email: OpenA2A <hello@opena2a.org>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: agent-trust,ai-security,daemon,nanomind,opena2a
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: MacOS X
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: MacOS :: MacOS X
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Security
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: huggingface-hub<2.0,>=0.24
26
+ Requires-Dist: joblib>=1.3
27
+ Requires-Dist: scikit-learn>=1.3
28
+ Requires-Dist: sentence-transformers>=2.7
29
+ Requires-Dist: torch>=2.2
30
+ Requires-Dist: transformers>=4.43
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest-mock>=3.12; extra == 'test'
33
+ Requires-Dist: pytest>=8.0; extra == 'test'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # nanomind-analyst
37
+
38
+ Installer for the NanoMind Analyst daemon. The daemon serves the Qwen3-1.7B security analyst NLM behind an input-classifier gate over a Unix socket at `/tmp/nanomind-guard.sock`. Consumers (hackmyagent, opena2a-cli, ai-trust) connect to that socket for generative threat analysis on individual findings.
39
+
40
+ This package writes a per-user launchd LaunchAgent, fetches and verifies the model artifacts from Hugging Face, and manages the daemon lifecycle. Apple Silicon (Darwin arm64) only in v0.1.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install nanomind-analyst
46
+ nanomind-analyst install
47
+ ```
48
+
49
+ The install step:
50
+
51
+ 1. Verifies platform (Apple Silicon required).
52
+ 2. Copies the input-classifier-v1 artifacts (bundled in the wheel) into `~/Library/Application Support/nanomind-analyst/artifacts/input-classifier-v1/`. SHA256 verified before copy.
53
+ 3. Fetches the Analyst NLM (~3.4 GB) from `opena2a/nanomind-security-analyst` at the pinned v3.0.0 commit. SHA256 verified after fetch.
54
+ 4. Writes a launchd plist to `~/Library/LaunchAgents/org.opena2a.nanomind-analyst.plist`.
55
+ 5. Bootstraps the LaunchAgent into the user's gui session.
56
+ 6. Waits up to 60 seconds for the daemon to bind the socket and pass its healthz probe.
57
+
58
+ The fetch step is the long one (several minutes on first run; cached on subsequent runs).
59
+
60
+ ## Commands
61
+
62
+ | Command | What |
63
+ |---------|------|
64
+ | `nanomind-analyst install` | Full install flow. Idempotent. |
65
+ | `nanomind-analyst uninstall` | Stop, unload, remove plist. `--remove-artifacts` also deletes the 3.4 GB NLM. |
66
+ | `nanomind-analyst start` | Kickstart the loaded LaunchAgent. |
67
+ | `nanomind-analyst stop` | SIGTERM the daemon. Agent stays loaded; no auto-restart on clean exit. |
68
+ | `nanomind-analyst restart` | Stop then start. |
69
+ | `nanomind-analyst status` | Report whether the agent is loaded and healthz returns ready. |
70
+ | `nanomind-analyst logs` | Tail `~/Library/Logs/nanomind-analyst.log`. `--no-follow` for one-shot. |
71
+ | `nanomind-analyst --version` | Print the package version. |
72
+
73
+ ## What gets written where
74
+
75
+ | Path | Purpose |
76
+ |------|---------|
77
+ | `~/Library/LaunchAgents/org.opena2a.nanomind-analyst.plist` | LaunchAgent plist. |
78
+ | `~/Library/Application Support/nanomind-analyst/artifacts/input-classifier-v1/` | Classifier joblib + meta.json (~5 KB). |
79
+ | `~/Library/Application Support/nanomind-analyst/artifacts/nanomind-security-analyst/` | NLM weights, tokenizer, configs (~3.4 GB). |
80
+ | `~/Library/Logs/nanomind-analyst.log` | Daemon stdout + stderr. |
81
+ | `/tmp/nanomind-guard.sock` | Unix socket the daemon binds (0600, owner-only). |
82
+
83
+ No root, no `/opt`, no `sudo`.
84
+
85
+ ## Trust chain
86
+
87
+ The install does not depend on Hugging Face being trustworthy. The wheel manifest is the authoritative source of artifact identity:
88
+
89
+ 1. PyPI Trusted Publishing (OIDC) attests the wheel was built by the workflow at `.github/workflows/release-nanomind-analyst.yml` from a commit in `opena2a-org/nanomind` (SLSA v1 provenance).
90
+ 2. The wheel bakes `EXPECTED_NLM_SAFETENSORS_SHA256` and `EXPECTED_NLM_TOKENIZER_SHA256` constants in `artifacts.py`.
91
+ 3. At `install`, the fetched NLM files are SHA256-verified against those constants. A tampered Hugging Face artifact causes install to refuse.
92
+ 4. At daemon boot, `INPUT_CLASSIFIER_JOBLIB_SHA256` is re-verified before `joblib.load()` runs (joblib uses pickle = arbitrary code execution at deserialization).
93
+
94
+ Verify wheel provenance after publish:
95
+
96
+ ```bash
97
+ npm # no equivalent; use:
98
+ python -m pip download nanomind-analyst --no-deps --dest /tmp/verify
99
+ # Then inspect the wheel's METADATA + RECORD; PyPI surfaces attestations
100
+ # at https://pypi.org/project/nanomind-analyst/#files
101
+ ```
102
+
103
+ ## Linux / cloud daemon
104
+
105
+ Not supported in v0.1. The daemon is bf16 on Apple MPS; fp16 yields 0% accuracy on Qwen3-1.7B. Cross-platform inference is tracked in `opena2a-org/nanomind` issues with the labels `nanomind-analyst` + `platform-linux`.
106
+
107
+ ## Known limitations
108
+
109
+ - **Cold-boot latency.** Daemon takes ~30 seconds to load the NLM on first request after a system reboot. The install flow waits up to 60 seconds for this; subsequent restarts are faster (the launchd-managed process stays warm).
110
+ - **NLM latency floor.** The Analyst NLM emits ~400 tokens of structured output per request at ~15 ms/token on bf16 MPS. Floor is ~6 seconds per finding. Consumers (HMA, opena2a-cli) should batch or filter before invoking. The input-classifier gate bypasses the NLM on off-topic inputs (~92% bypass rate on benign user input).
111
+ - **Single-instance.** The daemon binds a single Unix socket. Multiple `nanomind-analyst install` runs on the same machine share the same socket; the LaunchAgent label is unique to the user.
112
+
113
+ ## Companion package
114
+
115
+ `hackmyagent` (npm) `--nanomind` flag uses this daemon. After installing this package, `hackmyagent nanomind setup` detects the `nanomind-analyst` CLI on PATH and shells out to `nanomind-analyst install`. If the CLI is missing, it prints the `pip install` instructions.
116
+
117
+ ## Model card
118
+
119
+ [opena2a/nanomind-security-analyst](https://huggingface.co/opena2a/nanomind-security-analyst). v3.0.0 (Qwen3-1.7B SFT LoRA r=64), Apache-2.0.
120
+
121
+ ## License
122
+
123
+ MIT.
@@ -0,0 +1,88 @@
1
+ # nanomind-analyst
2
+
3
+ Installer for the NanoMind Analyst daemon. The daemon serves the Qwen3-1.7B security analyst NLM behind an input-classifier gate over a Unix socket at `/tmp/nanomind-guard.sock`. Consumers (hackmyagent, opena2a-cli, ai-trust) connect to that socket for generative threat analysis on individual findings.
4
+
5
+ This package writes a per-user launchd LaunchAgent, fetches and verifies the model artifacts from Hugging Face, and manages the daemon lifecycle. Apple Silicon (Darwin arm64) only in v0.1.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install nanomind-analyst
11
+ nanomind-analyst install
12
+ ```
13
+
14
+ The install step:
15
+
16
+ 1. Verifies platform (Apple Silicon required).
17
+ 2. Copies the input-classifier-v1 artifacts (bundled in the wheel) into `~/Library/Application Support/nanomind-analyst/artifacts/input-classifier-v1/`. SHA256 verified before copy.
18
+ 3. Fetches the Analyst NLM (~3.4 GB) from `opena2a/nanomind-security-analyst` at the pinned v3.0.0 commit. SHA256 verified after fetch.
19
+ 4. Writes a launchd plist to `~/Library/LaunchAgents/org.opena2a.nanomind-analyst.plist`.
20
+ 5. Bootstraps the LaunchAgent into the user's gui session.
21
+ 6. Waits up to 60 seconds for the daemon to bind the socket and pass its healthz probe.
22
+
23
+ The fetch step is the long one (several minutes on first run; cached on subsequent runs).
24
+
25
+ ## Commands
26
+
27
+ | Command | What |
28
+ |---------|------|
29
+ | `nanomind-analyst install` | Full install flow. Idempotent. |
30
+ | `nanomind-analyst uninstall` | Stop, unload, remove plist. `--remove-artifacts` also deletes the 3.4 GB NLM. |
31
+ | `nanomind-analyst start` | Kickstart the loaded LaunchAgent. |
32
+ | `nanomind-analyst stop` | SIGTERM the daemon. Agent stays loaded; no auto-restart on clean exit. |
33
+ | `nanomind-analyst restart` | Stop then start. |
34
+ | `nanomind-analyst status` | Report whether the agent is loaded and healthz returns ready. |
35
+ | `nanomind-analyst logs` | Tail `~/Library/Logs/nanomind-analyst.log`. `--no-follow` for one-shot. |
36
+ | `nanomind-analyst --version` | Print the package version. |
37
+
38
+ ## What gets written where
39
+
40
+ | Path | Purpose |
41
+ |------|---------|
42
+ | `~/Library/LaunchAgents/org.opena2a.nanomind-analyst.plist` | LaunchAgent plist. |
43
+ | `~/Library/Application Support/nanomind-analyst/artifacts/input-classifier-v1/` | Classifier joblib + meta.json (~5 KB). |
44
+ | `~/Library/Application Support/nanomind-analyst/artifacts/nanomind-security-analyst/` | NLM weights, tokenizer, configs (~3.4 GB). |
45
+ | `~/Library/Logs/nanomind-analyst.log` | Daemon stdout + stderr. |
46
+ | `/tmp/nanomind-guard.sock` | Unix socket the daemon binds (0600, owner-only). |
47
+
48
+ No root, no `/opt`, no `sudo`.
49
+
50
+ ## Trust chain
51
+
52
+ The install does not depend on Hugging Face being trustworthy. The wheel manifest is the authoritative source of artifact identity:
53
+
54
+ 1. PyPI Trusted Publishing (OIDC) attests the wheel was built by the workflow at `.github/workflows/release-nanomind-analyst.yml` from a commit in `opena2a-org/nanomind` (SLSA v1 provenance).
55
+ 2. The wheel bakes `EXPECTED_NLM_SAFETENSORS_SHA256` and `EXPECTED_NLM_TOKENIZER_SHA256` constants in `artifacts.py`.
56
+ 3. At `install`, the fetched NLM files are SHA256-verified against those constants. A tampered Hugging Face artifact causes install to refuse.
57
+ 4. At daemon boot, `INPUT_CLASSIFIER_JOBLIB_SHA256` is re-verified before `joblib.load()` runs (joblib uses pickle = arbitrary code execution at deserialization).
58
+
59
+ Verify wheel provenance after publish:
60
+
61
+ ```bash
62
+ npm # no equivalent; use:
63
+ python -m pip download nanomind-analyst --no-deps --dest /tmp/verify
64
+ # Then inspect the wheel's METADATA + RECORD; PyPI surfaces attestations
65
+ # at https://pypi.org/project/nanomind-analyst/#files
66
+ ```
67
+
68
+ ## Linux / cloud daemon
69
+
70
+ Not supported in v0.1. The daemon is bf16 on Apple MPS; fp16 yields 0% accuracy on Qwen3-1.7B. Cross-platform inference is tracked in `opena2a-org/nanomind` issues with the labels `nanomind-analyst` + `platform-linux`.
71
+
72
+ ## Known limitations
73
+
74
+ - **Cold-boot latency.** Daemon takes ~30 seconds to load the NLM on first request after a system reboot. The install flow waits up to 60 seconds for this; subsequent restarts are faster (the launchd-managed process stays warm).
75
+ - **NLM latency floor.** The Analyst NLM emits ~400 tokens of structured output per request at ~15 ms/token on bf16 MPS. Floor is ~6 seconds per finding. Consumers (HMA, opena2a-cli) should batch or filter before invoking. The input-classifier gate bypasses the NLM on off-topic inputs (~92% bypass rate on benign user input).
76
+ - **Single-instance.** The daemon binds a single Unix socket. Multiple `nanomind-analyst install` runs on the same machine share the same socket; the LaunchAgent label is unique to the user.
77
+
78
+ ## Companion package
79
+
80
+ `hackmyagent` (npm) `--nanomind` flag uses this daemon. After installing this package, `hackmyagent nanomind setup` detects the `nanomind-analyst` CLI on PATH and shells out to `nanomind-analyst install`. If the CLI is missing, it prints the `pip install` instructions.
81
+
82
+ ## Model card
83
+
84
+ [opena2a/nanomind-security-analyst](https://huggingface.co/opena2a/nanomind-security-analyst). v3.0.0 (Qwen3-1.7B SFT LoRA r=64), Apache-2.0.
85
+
86
+ ## License
87
+
88
+ MIT.
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nanomind-analyst"
7
+ version = "0.1.0"
8
+ description = "Installer for the NanoMind Analyst daemon: serves the Qwen3-1.7B security analyst NLM behind an input-classifier gate over a Unix socket."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "OpenA2A", email = "hello@opena2a.org" }]
13
+ keywords = ["nanomind", "ai-security", "agent-trust", "opena2a", "daemon"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: MacOS X",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: MacOS :: MacOS X",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Security",
25
+ ]
26
+ dependencies = [
27
+ "huggingface_hub>=0.24,<2.0",
28
+ "joblib>=1.3",
29
+ "scikit-learn>=1.3",
30
+ "sentence-transformers>=2.7",
31
+ "torch>=2.2",
32
+ "transformers>=4.43",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/opena2a-org/nanomind/tree/main/packages/nanomind-analyst"
37
+ Repository = "https://github.com/opena2a-org/nanomind"
38
+ Documentation = "https://github.com/opena2a-org/nanomind/tree/main/packages/nanomind-analyst#readme"
39
+ "Model Card" = "https://huggingface.co/opena2a/nanomind-security-analyst"
40
+ "Bug Tracker" = "https://github.com/opena2a-org/nanomind/issues"
41
+
42
+ [project.scripts]
43
+ nanomind-analyst = "nanomind_analyst.cli:main"
44
+ # Wrapped entrypoint so a `nanomind-analyst-daemon` invocation on Linux/Intel
45
+ # fails fast with a clear platform message instead of crashing inside torch.
46
+ nanomind-analyst-daemon = "nanomind_analyst.daemon:guarded_daemon_main"
47
+
48
+ [project.optional-dependencies]
49
+ test = ["pytest>=8.0", "pytest-mock>=3.12"]
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/nanomind_analyst"]
53
+
54
+ [tool.hatch.build.targets.wheel.force-include]
55
+ "src/nanomind_analyst/data/input-classifier-v1/classifier.joblib" = "nanomind_analyst/data/input-classifier-v1/classifier.joblib"
56
+ "src/nanomind_analyst/data/input-classifier-v1/meta.json" = "nanomind_analyst/data/input-classifier-v1/meta.json"
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ include = [
60
+ "/src",
61
+ "/tests",
62
+ "/README.md",
63
+ "/CHANGELOG.md",
64
+ "/LICENSE",
65
+ "/pyproject.toml",
66
+ ]
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ addopts = "-ra --strict-markers"
@@ -0,0 +1,12 @@
1
+ """Installer for the NanoMind Analyst daemon.
2
+
3
+ The daemon serves the v3.0.0 Qwen3-1.7B Analyst NLM behind an input-classifier
4
+ gate over a Unix socket. This package writes the launchd plist, fetches and
5
+ verifies the model artifacts, and manages the daemon lifecycle.
6
+
7
+ Apple Silicon (Darwin arm64) only in v0.1. The daemon is bf16 on MPS; fp16
8
+ yields 0% accuracy on Qwen3-1.7B.
9
+ """
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,232 @@
1
+ """Fetch and verify the Analyst NLM artifacts from Hugging Face.
2
+
3
+ The trust chain for v0.1:
4
+
5
+ PyPI Trusted Publishing -> GHA OIDC -> wheel build
6
+ -> bakes EXPECTED_*_SHA256 constants below at build time
7
+ -> wheel ships with classifier.joblib + meta.json embedded as package data
8
+ -> daemon at runtime verifies fetched NLM weights against EXPECTED_*_SHA256
9
+
10
+ The wheel manifest is authoritative. Hugging Face is reduced to a CDN; if HF
11
+ serves tampered bytes, the SHA check refuses to install. The daemon then does
12
+ a second verify before joblib.load() runs (joblib uses pickle = ACE at
13
+ deserialization).
14
+
15
+ The pinned revision is the v3.0.0 release of opena2a/nanomind-security-analyst.
16
+ Bumping it requires recomputing EXPECTED_NLM_*_SHA256 from the live HF tree.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import os
22
+ import shutil
23
+ import stat
24
+ import sys
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Callable
28
+
29
+ HF_REPO_ID = "opena2a/nanomind-security-analyst"
30
+
31
+ # Pinned to the v3.0.0 release commit (model card: nanomind/MODEL_CARD links
32
+ # this exact commit). Bumping it requires recomputing the SHA constants below.
33
+ HF_REVISION = "13bc3112ec9666a37f301b83d3e8bce53da4e3c5"
34
+
35
+ # SHA256 of model.safetensors as published on Hugging Face at HF_REVISION.
36
+ # Source: https://huggingface.co/api/models/opena2a/nanomind-security-analyst
37
+ # /tree/main -> file=model.safetensors -> lfs.oid
38
+ EXPECTED_NLM_SAFETENSORS_SHA256 = (
39
+ "53cb9441e370b097f556a200efb483dc8fb977a4a57ed79aaae1b99c71426da0"
40
+ )
41
+
42
+ # SHA256 of tokenizer.json (also LFS at HF_REVISION).
43
+ EXPECTED_NLM_TOKENIZER_SHA256 = (
44
+ "be75606093db2094d7cd20f3c2f385c212750648bd6ea4fb2bf507a6a4c55506"
45
+ )
46
+
47
+ # SHA256 of the input-classifier-v1 artifacts. These files are shipped INSIDE
48
+ # the wheel (under nanomind_analyst/data/input-classifier-v1/) and copied into
49
+ # the user's Application Support dir during install. The daemon verifies them
50
+ # at boot before joblib.load() runs.
51
+ EXPECTED_CLASSIFIER_JOBLIB_SHA256 = (
52
+ "a9a699cf2adc1768d2d8777fdb2f9c5ce16fd087ead060ba9e2944a5bd5f9db6"
53
+ )
54
+ EXPECTED_CLASSIFIER_META_SHA256 = (
55
+ "79ac141c5b039e62ca7c7b111ba065545c2528b8c06524c9432410d8f11212b2"
56
+ )
57
+
58
+ # These are the NLM files the daemon actually needs. Anything else fetched by
59
+ # snapshot_download is fine but not required.
60
+ NLM_REQUIRED_FILES = (
61
+ "config.json",
62
+ "generation_config.json",
63
+ "model.safetensors",
64
+ "model.safetensors.index.json",
65
+ "tokenizer.json",
66
+ "tokenizer_config.json",
67
+ "chat_template.jinja",
68
+ )
69
+
70
+
71
+ class ArtifactError(Exception):
72
+ """Raised when artifact fetch or verification fails."""
73
+
74
+
75
+ @dataclass
76
+ class FetchProgress:
77
+ """Reports progress during fetch. The CLI prints these; tests assert on them."""
78
+
79
+ stage: str # "fetching" | "verifying" | "installing-classifier" | "done"
80
+ detail: str = ""
81
+
82
+
83
+ ProgressCallback = Callable[[FetchProgress], None]
84
+
85
+
86
+ def _sha256_file(path: Path) -> str:
87
+ h = hashlib.sha256()
88
+ with path.open("rb") as fh:
89
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
90
+ h.update(chunk)
91
+ return h.hexdigest()
92
+
93
+
94
+ def _verify(path: Path, expected: str, *, name: str) -> None:
95
+ if not path.exists():
96
+ raise ArtifactError(f"{name} missing after fetch: {path}")
97
+ # Defense in depth: reject symlinks at the artifact path. snapshot_download
98
+ # with local_dir_use_symlinks=False should never produce one, but if some
99
+ # future HF library version regresses, or if a hostile process slipped a
100
+ # symlink under target_dir between fetch and verify, we want SHA verify to
101
+ # read the file at this path, not whatever the symlink resolves to.
102
+ st = os.lstat(path)
103
+ if stat.S_ISLNK(st.st_mode):
104
+ raise ArtifactError(
105
+ f"{name} is a symlink ({path}); refusing to verify or load. The "
106
+ f"snapshot_download call requested local_dir_use_symlinks=False; "
107
+ f"the presence of a symlink here may indicate a tampered fetch."
108
+ )
109
+ actual = _sha256_file(path)
110
+ if actual != expected:
111
+ raise ArtifactError(
112
+ f"SHA256 mismatch on {name}: expected {expected}, got {actual}. "
113
+ f"Refusing to install. The Hugging Face artifact at {HF_REPO_ID}"
114
+ f"@{HF_REVISION[:7]} may have been retagged, or the wheel was "
115
+ f"built against a different revision. File: {path}"
116
+ )
117
+
118
+
119
+ def fetch_nlm(
120
+ *,
121
+ target_dir: Path,
122
+ progress: ProgressCallback | None = None,
123
+ hf_downloader: Callable[..., str] | None = None,
124
+ ) -> None:
125
+ """Download NLM artifacts from Hugging Face into target_dir.
126
+
127
+ `hf_downloader` is injected for tests; default is
128
+ huggingface_hub.snapshot_download.
129
+ """
130
+ if progress is not None:
131
+ progress(
132
+ FetchProgress(
133
+ stage="fetching",
134
+ detail=f"{HF_REPO_ID}@{HF_REVISION[:7]} -> {target_dir}",
135
+ )
136
+ )
137
+
138
+ target_dir.mkdir(parents=True, exist_ok=True)
139
+
140
+ downloader = hf_downloader
141
+ if downloader is None:
142
+ # Imported lazily so tests that inject a fake downloader don't pay the
143
+ # huggingface_hub import cost.
144
+ from huggingface_hub import snapshot_download as downloader # type: ignore[no-redef]
145
+
146
+ # local_dir_use_symlinks=False makes snapshot_download write real files
147
+ # into target_dir, not symlinks into ~/.cache/huggingface. We want SHA
148
+ # verify to read the bytes actually present at the install location, not
149
+ # follow a symlink the user (or an attacker on the cache dir) could swap
150
+ # under our feet between verify and the daemon's joblib.load.
151
+ downloader(
152
+ repo_id=HF_REPO_ID,
153
+ revision=HF_REVISION,
154
+ local_dir=str(target_dir),
155
+ allow_patterns=list(NLM_REQUIRED_FILES),
156
+ local_dir_use_symlinks=False,
157
+ )
158
+
159
+ if progress is not None:
160
+ progress(FetchProgress(stage="verifying", detail="model.safetensors"))
161
+ _verify(
162
+ target_dir / "model.safetensors",
163
+ EXPECTED_NLM_SAFETENSORS_SHA256,
164
+ name="NLM model.safetensors",
165
+ )
166
+
167
+ if progress is not None:
168
+ progress(FetchProgress(stage="verifying", detail="tokenizer.json"))
169
+ _verify(
170
+ target_dir / "tokenizer.json",
171
+ EXPECTED_NLM_TOKENIZER_SHA256,
172
+ name="NLM tokenizer.json",
173
+ )
174
+
175
+ for fname in NLM_REQUIRED_FILES:
176
+ if not (target_dir / fname).exists():
177
+ raise ArtifactError(
178
+ f"required NLM file missing after fetch: {fname}. Check the "
179
+ f"Hugging Face revision {HF_REVISION[:7]} or your network."
180
+ )
181
+
182
+
183
+ def install_classifier(
184
+ *,
185
+ source_dir: Path,
186
+ target_dir: Path,
187
+ progress: ProgressCallback | None = None,
188
+ ) -> None:
189
+ """Copy the wheel-embedded input-classifier-v1 files into target_dir.
190
+
191
+ Verifies SHA against EXPECTED_CLASSIFIER_*_SHA256 BEFORE copying, so a
192
+ tampered wheel cannot ship a poisoned pickle through this path. The
193
+ daemon will re-verify at boot before joblib.load() runs.
194
+ """
195
+ if progress is not None:
196
+ progress(
197
+ FetchProgress(
198
+ stage="installing-classifier",
199
+ detail=f"{source_dir} -> {target_dir}",
200
+ )
201
+ )
202
+
203
+ _verify(
204
+ source_dir / "classifier.joblib",
205
+ EXPECTED_CLASSIFIER_JOBLIB_SHA256,
206
+ name="input-classifier-v1 classifier.joblib (in wheel)",
207
+ )
208
+ _verify(
209
+ source_dir / "meta.json",
210
+ EXPECTED_CLASSIFIER_META_SHA256,
211
+ name="input-classifier-v1 meta.json (in wheel)",
212
+ )
213
+
214
+ target_dir.mkdir(parents=True, exist_ok=True)
215
+ for fname in ("classifier.joblib", "meta.json"):
216
+ shutil.copy2(source_dir / fname, target_dir / fname)
217
+
218
+
219
+ def wheel_classifier_source_dir() -> Path:
220
+ """Locate the input-classifier-v1 directory shipped inside the wheel."""
221
+ # Resolves to <site-packages>/nanomind_analyst/data/input-classifier-v1/
222
+ here = Path(__file__).resolve().parent
223
+ return here / "data" / "input-classifier-v1"
224
+
225
+
226
+ def python_executable() -> str:
227
+ """The interpreter that launched the installer.
228
+
229
+ Baked into the launchd plist so the daemon runs under the same Python the
230
+ user installed `nanomind-analyst` into (no `/usr/bin/env python3` ambiguity).
231
+ """
232
+ return sys.executable