@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 +27 -0
- package/npm/postinstall.js +107 -3
- package/npm/postinstall.test.js +112 -0
- package/package.json +1 -1
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`
|
package/npm/postinstall.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|