@nano-step/nano-brain 2026.6.207 → 2026.6.209

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.
package/README.md CHANGED
@@ -84,6 +84,33 @@ curl -X POST http://localhost:3100/api/v1/query \
84
84
  -d '{"workspace":"<hash>","query":"database decision"}'
85
85
  ```
86
86
 
87
+ ## Verifying Downloads
88
+
89
+ Every release ships a `SHA256SUMS` asset alongside the four platform binaries.
90
+ You can verify a downloaded binary against the published checksums using
91
+ standard tooling:
92
+
93
+ ```bash
94
+ TAG=v2026.6.2.1 # any release tag
95
+ curl -fLO https://github.com/nano-step/nano-brain/releases/download/$TAG/SHA256SUMS
96
+ curl -fLO https://github.com/nano-step/nano-brain/releases/download/$TAG/nano-brain-linux-amd64
97
+ sha256sum -c SHA256SUMS --ignore-missing
98
+ # nano-brain-linux-amd64: OK
99
+ ```
100
+
101
+ `npm install @nano-step/nano-brain` (and the unscoped `nano-brain` alias)
102
+ performs this verification **automatically** during postinstall — a SHA-256
103
+ mismatch aborts the install with exit code 1 and removes the partial binary.
104
+
105
+ For air-gapped installs or environments where a corporate proxy modifies the
106
+ download stream, set `NANO_BRAIN_SKIP_SHA_VERIFY=1` before running `npm install`
107
+ to bypass the check (a warning is printed so the bypass is visible in CI logs).
108
+
109
+ Releases tagged before this feature shipped do not have a `SHA256SUMS` asset;
110
+ installs of those versions succeed with a single WARN line and no verification.
111
+ See issue [#320](https://github.com/nano-step/nano-brain/issues/320) for the
112
+ threat model and rationale.
113
+
87
114
  ## Configuration
88
115
 
89
116
  Config file: `~/.nano-brain/config.yml`
@@ -5,6 +5,7 @@ const https = require("https");
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
7
  const os = require("os");
8
+ const crypto = require("crypto");
8
9
  const { execSync } = require("child_process");
9
10
 
10
11
  const VERSION = require("../package.json").version;
@@ -55,6 +56,82 @@ function download(url, dest) {
55
56
  });
56
57
  }
57
58
 
59
+ function downloadWithHash(url, dest) {
60
+ return new Promise((resolve, reject) => {
61
+ const file = fs.createWriteStream(dest);
62
+ const hash = crypto.createHash("sha256");
63
+ https.get(url, (res) => {
64
+ if (res.statusCode === 301 || res.statusCode === 302) {
65
+ file.close();
66
+ fs.unlinkSync(dest);
67
+ return downloadWithHash(res.headers.location, dest).then(resolve).catch(reject);
68
+ }
69
+ if (res.statusCode !== 200) {
70
+ file.close();
71
+ fs.unlinkSync(dest);
72
+ return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
73
+ }
74
+ res.on("data", (chunk) => hash.update(chunk));
75
+ res.pipe(file);
76
+ file.on("finish", () => {
77
+ file.close(() => resolve(hash.digest("hex")));
78
+ });
79
+ res.on("error", (err) => {
80
+ file.close();
81
+ if (fs.existsSync(dest)) fs.unlinkSync(dest);
82
+ reject(err);
83
+ });
84
+ }).on("error", (err) => {
85
+ if (fs.existsSync(dest)) fs.unlinkSync(dest);
86
+ reject(err);
87
+ });
88
+ });
89
+ }
90
+
91
+ function parseSHA256Line(content, targetFilename) {
92
+ if (typeof content !== "string" || !content) return null;
93
+ const lines = content.split(/\r?\n/);
94
+ const re = /^([a-f0-9]{64})\s+(.+?)\s*$/;
95
+ for (const line of lines) {
96
+ const m = line.match(re);
97
+ if (m && m[2] === targetFilename) return m[1];
98
+ }
99
+ return null;
100
+ }
101
+
102
+ async function verifySHA256(tag, assetName, binPath, computedHex) {
103
+ const sumsUrl = `https://github.com/${REPO}/releases/download/${tag}/SHA256SUMS`;
104
+ const sumsPath = `${binPath}.SHA256SUMS.tmp`;
105
+ let sumsContent;
106
+ try {
107
+ await download(sumsUrl, sumsPath);
108
+ sumsContent = fs.readFileSync(sumsPath, "utf8");
109
+ } catch (err) {
110
+ console.warn(`⚠ SHA256SUMS not available for ${tag} (${err.message}); skipping integrity verification.`);
111
+ return;
112
+ } finally {
113
+ if (fs.existsSync(sumsPath)) {
114
+ try { fs.unlinkSync(sumsPath); } catch (_) {}
115
+ }
116
+ }
117
+
118
+ const expectedHex = parseSHA256Line(sumsContent, assetName);
119
+ if (!expectedHex) {
120
+ console.warn(`⚠ ${assetName} not listed in SHA256SUMS for ${tag}; skipping integrity verification.`);
121
+ return;
122
+ }
123
+
124
+ if (expectedHex.toLowerCase() !== computedHex.toLowerCase()) {
125
+ if (fs.existsSync(binPath)) fs.unlinkSync(binPath);
126
+ throw new Error(
127
+ `SECURITY: SHA-256 mismatch for ${assetName}\n` +
128
+ ` expected: ${expectedHex}\n` +
129
+ ` computed: ${computedHex}\n` +
130
+ `Binary has been deleted. Build from source: CGO_ENABLED=0 go build -o nano-brain ./cmd/nano-brain`
131
+ );
132
+ }
133
+ }
134
+
58
135
  // npm normalizes leading zeros in semver numeric identifiers (e.g. tag
59
136
  // v2026.6.0101 publishes as 2026.6.101). Auto-tag uses a fixed-width 4-digit
60
137
  // patch {DD}{NN} for new tags, but older history has 1-3 digit patches. Try
@@ -112,6 +189,11 @@ async function main() {
112
189
  const binName = os.platform() === "win32" ? "nano-brain.exe" : "nano-brain";
113
190
  const binPath = path.join(__dirname, binName);
114
191
 
192
+ const skipVerify = !!process.env.NANO_BRAIN_SKIP_SHA_VERIFY;
193
+ if (skipVerify) {
194
+ console.warn("⚠ NANO_BRAIN_SKIP_SHA_VERIFY is set; binary integrity check will be skipped.");
195
+ }
196
+
115
197
  if (fs.existsSync(binPath)) {
116
198
  try {
117
199
  const output = execSync(`"${binPath}" version --json`, { timeout: 5000 }).toString();
@@ -131,11 +213,20 @@ async function main() {
131
213
  for (const tag of candidateTagsForVersion(VERSION)) {
132
214
  const url = `https://github.com/${REPO}/releases/download/${tag}/${assetName}`;
133
215
  try {
134
- await download(url, binPath);
216
+ if (skipVerify) {
217
+ await download(url, binPath);
218
+ } else {
219
+ const computedHex = await downloadWithHash(url, binPath);
220
+ await verifySHA256(tag, assetName, binPath, computedHex);
221
+ }
135
222
  fs.chmodSync(binPath, 0o755);
136
223
  console.log(`nano-brain v${VERSION} installed successfully from ${tag}.`);
137
224
  return;
138
225
  } catch (err) {
226
+ if (err && typeof err.message === "string" && err.message.startsWith("SECURITY:")) {
227
+ console.error(err.message);
228
+ process.exit(1);
229
+ }
139
230
  lastErr = err;
140
231
  }
141
232
  }
@@ -144,12 +235,21 @@ async function main() {
144
235
  const tag = await resolveTagFromAPI(VERSION, assetName);
145
236
  if (tag) {
146
237
  const url = `https://github.com/${REPO}/releases/download/${tag}/${assetName}`;
147
- await download(url, binPath);
238
+ if (skipVerify) {
239
+ await download(url, binPath);
240
+ } else {
241
+ const computedHex = await downloadWithHash(url, binPath);
242
+ await verifySHA256(tag, assetName, binPath, computedHex);
243
+ }
148
244
  fs.chmodSync(binPath, 0o755);
149
245
  console.log(`nano-brain v${VERSION} installed successfully from ${tag} (API fallback).`);
150
246
  return;
151
247
  }
152
248
  } catch (err) {
249
+ if (err && typeof err.message === "string" && err.message.startsWith("SECURITY:")) {
250
+ console.error(err.message);
251
+ process.exit(1);
252
+ }
153
253
  lastErr = err;
154
254
  }
155
255
 
@@ -158,4 +258,8 @@ async function main() {
158
258
  process.exit(0);
159
259
  }
160
260
 
161
- main();
261
+ if (require.main === module) {
262
+ main();
263
+ }
264
+
265
+ module.exports = { parseSHA256Line, downloadWithHash, verifySHA256 };
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert");
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+ const crypto = require("node:crypto");
9
+ const { parseSHA256Line } = require("./postinstall");
10
+
11
+ test("parseSHA256Line: returns hash for matching filename in single-line content", () => {
12
+ const content = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 nano-brain-linux-amd64\n";
13
+ assert.strictEqual(
14
+ parseSHA256Line(content, "nano-brain-linux-amd64"),
15
+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
16
+ );
17
+ });
18
+
19
+ test("parseSHA256Line: returns correct hash with multiple entries", () => {
20
+ const content = [
21
+ "1111111111111111111111111111111111111111111111111111111111111111 nano-brain-linux-amd64",
22
+ "2222222222222222222222222222222222222222222222222222222222222222 nano-brain-linux-arm64",
23
+ "3333333333333333333333333333333333333333333333333333333333333333 nano-brain-darwin-amd64",
24
+ "4444444444444444444444444444444444444444444444444444444444444444 nano-brain-darwin-arm64",
25
+ "",
26
+ ].join("\n");
27
+ assert.strictEqual(
28
+ parseSHA256Line(content, "nano-brain-darwin-amd64"),
29
+ "3333333333333333333333333333333333333333333333333333333333333333",
30
+ );
31
+ });
32
+
33
+ test("parseSHA256Line: returns null when filename is not listed", () => {
34
+ const content = "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeeeffff nano-brain-linux-amd64\n";
35
+ assert.strictEqual(parseSHA256Line(content, "nano-brain-windows-amd64"), null);
36
+ });
37
+
38
+ test("parseSHA256Line: returns null for empty input", () => {
39
+ assert.strictEqual(parseSHA256Line("", "anything"), null);
40
+ assert.strictEqual(parseSHA256Line(null, "anything"), null);
41
+ assert.strictEqual(parseSHA256Line(undefined, "anything"), null);
42
+ });
43
+
44
+ test("parseSHA256Line: returns null for malformed content (no hex)", () => {
45
+ assert.strictEqual(parseSHA256Line("not a checksum line\n", "anything"), null);
46
+ assert.strictEqual(parseSHA256Line("ZZZZ nano-brain-linux-amd64\n", "nano-brain-linux-amd64"), null);
47
+ });
48
+
49
+ test("parseSHA256Line: ignores blank lines and unrelated text", () => {
50
+ const content = [
51
+ "",
52
+ "# Comment line that should be ignored",
53
+ "",
54
+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 nano-brain-linux-amd64",
55
+ "",
56
+ ].join("\n");
57
+ assert.strictEqual(
58
+ parseSHA256Line(content, "nano-brain-linux-amd64"),
59
+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
60
+ );
61
+ });
62
+
63
+ test("parseSHA256Line: handles CRLF line endings", () => {
64
+ const content =
65
+ "1111111111111111111111111111111111111111111111111111111111111111 nano-brain-linux-amd64\r\n" +
66
+ "2222222222222222222222222222222222222222222222222222222222222222 nano-brain-linux-arm64\r\n";
67
+ assert.strictEqual(
68
+ parseSHA256Line(content, "nano-brain-linux-arm64"),
69
+ "2222222222222222222222222222222222222222222222222222222222222222",
70
+ );
71
+ });
72
+
73
+ test("parseSHA256Line: rejects hashes shorter than 64 hex chars", () => {
74
+ const content = "abc nano-brain-linux-amd64\n";
75
+ assert.strictEqual(parseSHA256Line(content, "nano-brain-linux-amd64"), null);
76
+ });
77
+
78
+ test("parseSHA256Line: filename match is exact, not substring", () => {
79
+ const content = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 nano-brain-linux-amd64-extra\n";
80
+ assert.strictEqual(parseSHA256Line(content, "nano-brain-linux-amd64"), null);
81
+ });
82
+
83
+ test("parseSHA256Line: hash format is independent of computed digest format", () => {
84
+ const payload = Buffer.from("the quick brown fox jumps over the lazy dog");
85
+ const computed = crypto.createHash("sha256").update(payload).digest("hex");
86
+ const line = `${computed} testfile\n`;
87
+ assert.strictEqual(parseSHA256Line(line, "testfile"), computed);
88
+ assert.strictEqual(
89
+ parseSHA256Line(line, "testfile").toLowerCase(),
90
+ computed.toLowerCase(),
91
+ "hash comparison must be lowercase-insensitive in caller",
92
+ );
93
+ });
94
+
95
+ test("end-to-end: full integrity flow with mocked content matches expected hash", () => {
96
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nb-sha-test-"));
97
+ try {
98
+ const fakeBinary = Buffer.from("\x7fELF fake binary contents for test\n".repeat(100));
99
+ const binPath = path.join(tmpDir, "nano-brain-linux-amd64");
100
+ fs.writeFileSync(binPath, fakeBinary);
101
+ const expectedHash = crypto.createHash("sha256").update(fakeBinary).digest("hex");
102
+ const sumsContent = `${expectedHash} nano-brain-linux-amd64\n`;
103
+ const parsed = parseSHA256Line(sumsContent, "nano-brain-linux-amd64");
104
+ assert.strictEqual(parsed, expectedHash, "parse must round-trip a real SHA-256 hex");
105
+
106
+ const corrupted = Buffer.concat([fakeBinary, Buffer.from("X")]);
107
+ const corruptedHash = crypto.createHash("sha256").update(corrupted).digest("hex");
108
+ assert.notStrictEqual(corruptedHash, expectedHash, "different content must produce different hash");
109
+ } finally {
110
+ fs.rmSync(tmpDir, { recursive: true, force: true });
111
+ }
112
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano-step/nano-brain",
3
- "version": "2026.6.207",
3
+ "version": "2026.6.209",
4
4
  "description": "Persistent memory and code intelligence for AI coding agents",
5
5
  "bin": {
6
6
  "nano-brain": "npm/run.js"