@blamejs/core 0.11.5 → 0.11.6

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/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.11.x
10
10
 
11
+ - v0.11.6 (2026-05-19) — **`b.safeMountInfo` — canonical `/proc/self/mountinfo` parser.** New operator-facing primitive at `lib/safe-mount-info.js` that centralizes the field-4 ("root within source FS") parse discipline that bind-mount detection requires. Exposes `b.safeMountInfo.parse(text)` / `read(opts)` / `bestMatch(entries, path)` / `isBindMount(entry)`. The `isBindMount` predicate is the canonical bind-mount test — it consults field 4 only, NOT the options string (the kernel doesn't emit "bind" as a mount option per [Linux Documentation/filesystems/proc.rst §3.5](https://www.kernel.org/doc/Documentation/filesystems/proc.txt); ad-hoc parsers that scan options for the word "bind" miss every real bind-mount). Refusal codes: `safe-mount-info/read-failed` (non-Linux / restricted sandbox; returns `opts.fallback` default `null`), `safe-mount-info/parse-failed` (single malformed line — silent-skip by default, `opts.strict: true` upgrades to throw), `safe-mount-info/too-many-lines` (line cap, default 4096), `safe-mount-info/bad-input` (non-string / non-positive-integer arg). Audit emission on every refusal as `system.safe_mount_info.refused` (drop-silent per [CLAUDE.md rule §5](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md)). **Composition:** `lib/watcher.js` filesystem auto-probe now routes through `b.safeMountInfo.read() + bestMatch() + isBindMount()` instead of parsing inline — the single canonical parser means future container-escape detection / sealed-store path validation / sandbox auto-probe call sites inherit the discipline. **New codebase-patterns detector `mountinfo-not-via-safemountinfo`** flags direct `fs.readFileSync("/proc/self/mountinfo", ...)` in lib/ as a migration target; only `lib/safe-mount-info.js` itself reaches the kernel surface. Fuzz harness at `fuzz/safe-mount-info.fuzz.js` probes parse with adversarial bytes (malformed lines / oversize input / Unicode garbage / truncated headers). **References:** [Linux Documentation/filesystems/proc.rst §3.5](https://www.kernel.org/doc/Documentation/filesystems/proc.txt) · [CVE-2024-21626 runc leaky-vessels](https://nvd.nist.gov/vuln/detail/CVE-2024-21626) · [CVE-2022-0185 fsconfig](https://nvd.nist.gov/vuln/detail/CVE-2022-0185).
12
+
11
13
  - v0.11.5 (2026-05-19) — **`b.safeDecompress(buf, opts)` — bomb-resistant decompression primitive.** New operator-facing primitive at `lib/safe-decompress.js` that centralizes the bounded-output / bounded-ratio defense the v0.10.15 `gunzip-without-output-size-cap` detector enforces per-call-site. Accepts `gzip` / `deflate` / `deflate-raw` (RFC 1951) / `brotli` under an explicit algorithm allowlist (unknown algorithms refuse with `safe-decompress/unsupported-algorithm`); refuses bomb-class input via zlib's own `maxOutputLength` BEFORE allocation; AFTER decompression checks `output.length / input.length` against `maxRatio` (default 50:1) and overwrites + drops the buffer if the ratio is exceeded so operator-facing paths never see the bomb bytes. Pre-decompression input cap (`maxCompressedBytes`, default 4 MiB) defends against very-large compressed payloads whose zlib parse alone is expensive. Refusal codes: `safe-decompress/output-too-large` / `ratio-exceeded` / `decompress-failed` / `empty-input` / `oversized-input` / `unsupported-algorithm` / `bad-arg` / `bad-input`. Operators wire `opts.audit` to receive the `system.safe_decompress.refused` event with `{ code, algorithm, ctx, reason }` metadata; emission is drop-silent per [CLAUDE.md rule §5](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md). **Composition:** `lib/websocket.js` `_inflateMessage` now routes through `b.safeDecompress({ algorithm: "deflate-raw", maxRatio: 0, ... })` — WS already binds upstream via `maxMessageBytes` so the ratio cap is opt-out; future per-message-deflate sites adopt the same shape. Fuzz harness at `fuzz/safe-decompress.fuzz.js` probes the four-algorithm allowlist with adversarial bytes (bomb / malformed / truncated / bogus dictionary) to catch any uncaught error class outside the documented refusal surface. **References:** [RFC 1950 zlib](https://www.rfc-editor.org/rfc/rfc1950) · [RFC 1951 deflate](https://www.rfc-editor.org/rfc/rfc1951) · [RFC 1952 gzip](https://www.rfc-editor.org/rfc/rfc1952) · [RFC 7932 brotli](https://www.rfc-editor.org/rfc/rfc7932) · [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725) · [RFC 8460 §5.2 TLS-RPT decompression community guidance](https://www.rfc-editor.org/rfc/rfc8460#section-5.2).
12
14
 
13
15
  - v0.11.4 (2026-05-19) — **`b.audit.useStore({ record })` shadow store + WebSocket permessage-deflate bomb fix + 5 new codebase-patterns detectors + shape-matcher substrate.** **`b.audit.useStore({ record })`** registers an operator-supplied shadow store that receives a copy of every audit chain append AFTER the framework's tamper-evident chain commits. The operator's `record(row)` async function receives the fully-formed row — `{ _id, recordedAt, monotonicCounter, prevHash, rowHash, action, outcome, actorUserId, ..., metadata }` — so external destinations (AWS QLDB / Azure Confidential Ledger / Google Cloud Audit Logs / in-house WORM appliances / SIEM forwarders) see identical hashes for cross-store reconciliation. Shadow failures are drop-silent per [CLAUDE.md rule §5](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md) — the framework chain is authoritative and already committed; an unreachable shadow surfaces via `b.observability` as the `audit.shadow_failed` event but never crashes the request path. Composes with HIPAA §164.312(b) / PCI-DSS Req 10.5.3 (separation-of-duties retention) / SOX §404 / SEC 17a-4 WORM postures. Pass `null` or `{ record: null }` to unregister. **WebSocket permessage-deflate bomb fix:** `lib/websocket.js` `_inflateMessage` previously called `zlib.inflateRawSync` without `maxOutputLength` — a malicious peer could ship a small compressed frame that exploded into gigabytes BEFORE the framework's post-decompression `maxMessageBytes` check ran. The inflate now passes `maxOutputLength: this.maxMessageBytes` so zlib refuses mid-decompress; same CVE-2024-zlib / CVE-2025-0725 amplification class the `gunzip-without-output-size-cap` detector defends elsewhere. **New codebase-patterns detectors:** (1) `test-promise-settimeout-sleep` (scans the `test/` tree — first detector under the new test-scope walker — for the `await new Promise(r => setTimeout(r, N))` shape forbidden by [CLAUDE.md §11b](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md), with the migration backlog pre-allowlisted as a release-gate countdown); (2) `inflate-unzip-without-output-size-cap` (extends the v0.10.15 gunzip-cap detector to `zlib.inflateSync` / `inflateRawSync` / `unzipSync` / `createInflate` family — RFC 1951 deflate is the same bomb class); (3) `map-get-falsy-then-set-pre-node-26` (companion to `map-has-then-set-pre-node-26` — catches the `!M.get(k)` / `M.get(k) === undefined|null` semantically-identical variants); (4) `fs-existssync-then-read-toctou` (CodeQL `js/file-system-race` class — `fs.existsSync(p) + fs.readFile(p)` against the same path is symlink-swap-vulnerable; the canonical defense is `lib/atomic-file.js`'s open-by-fd-first pattern); (5) `buffer-from-string-on-auth-path` (flags `Buffer.from(String(x))` in `lib/` — auth-bearing sites become `b.safeBytes` migration targets in the next release). **Shape-matcher substrate** lands at `test/helpers/_shape-match.js` (test-only, never ships — `test/` is absent from package.json `files:` allowlist): token-aware traversal that tracks paren / brace / bracket depth + string / template-literal / regex / comment state, exposing `findCalls(source, calleeRegex)` / `findEnclosingTry(source, pos)` / `aliasesOf(source, chainRegex)`. Future releases convert the highest-bypass-risk regex-only detectors to AST-aware variants using this substrate, closing the class of regex-bypass via variable renaming / parens / line splits that surface-pattern detectors miss. **References:** [RFC 7692 §7.2.2 WebSocket permessage-deflate](https://www.rfc-editor.org/rfc/rfc7692#section-7.2.2) · [HIPAA §164.312(b) Audit Controls](https://www.law.cornell.edu/cfr/text/45/164.312) · [PCI-DSS v4.0 Req 10](https://www.pcisecuritystandards.org/) · [SEC 17a-4 WORM](https://www.sec.gov/files/rules/final/34-44238.pdf) · [SOX §404](https://www.sec.gov/about/laws/soa2002.pdf) · [CVE-2024-zlib decompression amplification](https://nvd.nist.gov/) · [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725) · [CodeQL js/file-system-race](https://codeql.github.com/codeql-query-help/javascript/js-file-system-race/).
package/index.js CHANGED
@@ -119,6 +119,7 @@ var safeSql = require("./lib/safe-sql");
119
119
  var chainWriter = require("./lib/chain-writer");
120
120
  var safeBuffer = require("./lib/safe-buffer");
121
121
  var safeDecompress = require("./lib/safe-decompress").safeDecompress;
122
+ var safeMountInfo = require("./lib/safe-mount-info");
122
123
  var lazyRequire = require("./lib/lazy-require");
123
124
  var frameworkError = require("./lib/framework-error");
124
125
  var nistCrosswalk = require("./lib/nist-crosswalk");
@@ -456,6 +457,7 @@ module.exports = {
456
457
  chainWriter: chainWriter,
457
458
  safeBuffer: safeBuffer,
458
459
  safeDecompress: safeDecompress,
460
+ safeMountInfo: safeMountInfo,
459
461
  lazyRequire: lazyRequire,
460
462
  frameworkError: frameworkError,
461
463
  httpClient: httpClient,
@@ -0,0 +1,306 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.safeMountInfo
4
+ * @nav Primitives
5
+ * @title Safe MountInfo
6
+ * @order 131
7
+ * @slug safe-mount-info
8
+ *
9
+ * @card
10
+ * Canonical /proc/self/mountinfo parser that always reads field 4
11
+ * (root-within-source-FS) — defeats the bind-mount detection bug
12
+ * class where ad-hoc parsers picked the wrong field.
13
+ *
14
+ * @intro
15
+ * Linux `/proc/self/mountinfo` is the per-process kernel-published
16
+ * mount table. The format is fixed per [kernel
17
+ * Documentation/filesystems/proc.rst §3.5](https://www.kernel.org/doc/Documentation/filesystems/proc.txt):
18
+ *
19
+ * <id> <parent> <major:minor> <root> <mountpoint> <options>
20
+ * [<optional-fields>...] - <fstype> <source> <super-options>
21
+ *
22
+ * The `<root>` field (positional index 3, 0-based) is "root within
23
+ * source FS" — `"/"` for a regular mount, a non-root path for a
24
+ * bind-mount (e.g. `/Users/me/data` mounted onto `/data` inside a
25
+ * container). Bind-mount detection MUST consult this field; ad-hoc
26
+ * parsers that scan the options string for the word "bind" miss
27
+ * the truth (kernel doesn't emit "bind" as an option — bind state
28
+ * is observable ONLY via field 4).
29
+ *
30
+ * Pre-v0.11.6 the only lib/ caller (lib/watcher.js) parsed mountinfo
31
+ * correctly inline. Future callers — container-escape detection,
32
+ * sealed-store path validation, sandbox auto-probe — would have to
33
+ * re-derive the discipline. This primitive centralizes it: a
34
+ * single canonical parser that ALWAYS reads field 4, ALWAYS
35
+ * handles the `" - "` optional-fields separator, ALWAYS skips
36
+ * malformed lines without throwing.
37
+ *
38
+ * Refusal posture:
39
+ * - `safe-mount-info/read-failed` — /proc/self/mountinfo
40
+ * unreadable (non-Linux, restricted sandbox, host filesystem
41
+ * hidden). Operators get the typed error AND opts.fallback
42
+ * value (default null) to take.
43
+ * - `safe-mount-info/parse-failed` — single malformed line
44
+ * within /proc/self/mountinfo. Silent-skip (per-line) by
45
+ * default; opts.strict: true upgrades to throw on first
46
+ * malformed line.
47
+ *
48
+ * Threat model:
49
+ * - **Container-escape detection** (CVE-2019-5736 Docker /
50
+ * CVE-2022-0185 fsconfig / CVE-2024-21626 leaky-vessels) —
51
+ * bind-mount + root-field analysis is the canonical signal.
52
+ * Wrong-field readers (operations on field 5 / 6 / options-
53
+ * indexOf-"bind") miss escape-attempt patterns.
54
+ * - **Sealed-store integrity** — sealed dbs / vault state
55
+ * atop a bind-mounted host directory cross trust boundaries
56
+ * on container restart. Detection requires reading field 4
57
+ * and matching the mount-point against operator-trusted paths.
58
+ *
59
+ * Composes:
60
+ * - lib/safe-decompress / lib/audit — operator-supplied audit
61
+ * handle receives `system.safe_mount_info.refused` events on
62
+ * read-failed and parse-failed (drop-silent per rule §5).
63
+ *
64
+ * RFC / kernel-doc citations:
65
+ * - [Linux Documentation/filesystems/proc.rst §3.5 — /proc/<pid>/mountinfo](https://www.kernel.org/doc/Documentation/filesystems/proc.txt)
66
+ * - [CVE-2024-21626](https://nvd.nist.gov/vuln/detail/CVE-2024-21626) — runc leaky-vessels (bind-mount detection)
67
+ * - [CVE-2022-0185](https://nvd.nist.gov/vuln/detail/CVE-2022-0185) — fsconfig integer underflow
68
+ */
69
+
70
+ var nodeFs = require("node:fs");
71
+ var lazyRequire = require("./lazy-require");
72
+ var validateOpts = require("./validate-opts");
73
+ var numericBounds = require("./numeric-bounds");
74
+ var { defineClass } = require("./framework-error");
75
+
76
+ var audit = lazyRequire(function () { return require("./audit"); });
77
+
78
+ var SafeMountInfoError = defineClass("SafeMountInfoError", { alwaysPermanent: true });
79
+
80
+ var DEFAULT_PATH = "/proc/self/mountinfo";
81
+
82
+ /**
83
+ * @primitive b.safeMountInfo.parse
84
+ * @signature b.safeMountInfo.parse(text, opts?)
85
+ * @since 0.11.6
86
+ * @status stable
87
+ * @related b.safeMountInfo.read, b.safeMountInfo.bestMatch
88
+ *
89
+ * Parse `/proc/self/mountinfo` text bytes into structured entries.
90
+ * Each entry carries `{ id, parent, devMajMin, root, mountPoint,
91
+ * options, fstype, source, superOptions }` — `root` is the
92
+ * positional field 4 ("root within source FS") that bind-mount
93
+ * detection requires.
94
+ *
95
+ * Malformed lines are skipped by default (operator's mountinfo MAY
96
+ * contain a stray line during a concurrent mount/unmount). Set
97
+ * `opts.strict: true` to throw on first malformed line.
98
+ *
99
+ * @opts
100
+ * strict: boolean, // default false; throw on malformed line
101
+ * maxLines: number, // default 4096; cap to bound parser work
102
+ *
103
+ * @example
104
+ * var entries = b.safeMountInfo.parse(rawText);
105
+ * var bindMounts = entries.filter(function (e) { return e.root !== "/"; });
106
+ */
107
+ function parse(text, opts) {
108
+ opts = opts || {};
109
+ validateOpts(opts, ["strict", "maxLines"], "safeMountInfo.parse");
110
+ if (typeof text !== "string") {
111
+ throw new SafeMountInfoError(
112
+ "safe-mount-info/bad-input",
113
+ "safeMountInfo.parse: text must be a string");
114
+ }
115
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxLines,
116
+ "safeMountInfo.parse: opts.maxLines",
117
+ SafeMountInfoError, "safe-mount-info/bad-arg");
118
+ var maxLines = (typeof opts.maxLines === "number") ? opts.maxLines : 4096; // allow:raw-byte-literal — line cap matches max kernel-published mount count
119
+ var strict = opts.strict === true;
120
+ var lines = text.split("\n");
121
+ // `text.split("\n").length` counts the trailing empty segment that
122
+ // `/proc/self/mountinfo` produces with its final newline. Adjust
123
+ // the count so the cap reflects ACTUAL records, not segments —
124
+ // otherwise exactly-`maxLines` valid records gets rejected as
125
+ // `too-many-lines` because the segment count is `maxLines + 1`.
126
+ var trailingEmpty = (lines.length > 0 && lines[lines.length - 1] === "");
127
+ var recordCount = trailingEmpty ? lines.length - 1 : lines.length;
128
+ if (recordCount > maxLines) {
129
+ throw new SafeMountInfoError(
130
+ "safe-mount-info/too-many-lines",
131
+ "safeMountInfo.parse: mountinfo has " + recordCount +
132
+ " lines, exceeds maxLines " + maxLines);
133
+ }
134
+ var out = [];
135
+ for (var i = 0; i < lines.length; i += 1) {
136
+ var ln = lines[i];
137
+ if (!ln) continue;
138
+ // Format: <id> <parent> <major:minor> <root> <mountpoint> <options>
139
+ // [<optional-fields>...] - <fstype> <source> <super-options>
140
+ // The separator " - " divides the optional-fields half from the
141
+ // post-fields half.
142
+ var sepIdx = ln.indexOf(" - ");
143
+ if (sepIdx === -1) {
144
+ if (strict) {
145
+ throw new SafeMountInfoError(
146
+ "safe-mount-info/parse-failed",
147
+ "safeMountInfo.parse: line " + (i + 1) + " missing ' - ' separator");
148
+ }
149
+ continue;
150
+ }
151
+ var preFields = ln.slice(0, sepIdx).split(" ");
152
+ var postFields = ln.slice(sepIdx + 3).split(" ");
153
+ if (preFields.length < 6 || postFields.length < 1) { // allow:raw-byte-literal — kernel-mandated minimum field counts
154
+ if (strict) {
155
+ throw new SafeMountInfoError(
156
+ "safe-mount-info/parse-failed",
157
+ "safeMountInfo.parse: line " + (i + 1) + " has " + preFields.length +
158
+ " pre-fields, " + postFields.length + " post-fields (need >=6, >=1)");
159
+ }
160
+ continue;
161
+ }
162
+ out.push({
163
+ id: preFields[0],
164
+ parent: preFields[1],
165
+ devMajMin: preFields[2],
166
+ root: preFields[3], // *** field 4 (0-indexed 3) — bind-mount detection
167
+ mountPoint: preFields[4],
168
+ options: preFields[5],
169
+ // optional-fields (variable length, between [6] and the " - ")
170
+ // are exposed via `optionalFields` for advanced callers.
171
+ optionalFields: preFields.slice(6, preFields.length).filter(function (f) { return f.length > 0; }),
172
+ fstype: postFields[0],
173
+ source: postFields[1] || null,
174
+ superOptions: postFields[2] || null,
175
+ });
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /**
181
+ * @primitive b.safeMountInfo.read
182
+ * @signature b.safeMountInfo.read(opts?)
183
+ * @since 0.11.6
184
+ * @status stable
185
+ * @related b.safeMountInfo.parse, b.safeMountInfo.bestMatch
186
+ *
187
+ * Read + parse `/proc/self/mountinfo` in one call. Returns the same
188
+ * array shape as `parse(text)`. On non-Linux platforms (where /proc
189
+ * doesn't exist) returns `opts.fallback` (default `null`); audit
190
+ * emission per `safe-mount-info.refused` with code `read-failed`.
191
+ *
192
+ * @opts
193
+ * path: string, // override path (default /proc/self/mountinfo)
194
+ * fallback: any, // returned on read failure (default null)
195
+ * audit: object, // optional b.audit handle for refusal events
196
+ * strict: boolean, // forwarded to parse()
197
+ * maxLines: number, // forwarded to parse()
198
+ *
199
+ * @example
200
+ * var entries = b.safeMountInfo.read();
201
+ * if (entries === null) {
202
+ * // non-Linux / sandboxed / no /proc
203
+ * }
204
+ */
205
+ function read(opts) {
206
+ opts = opts || {};
207
+ validateOpts(opts,
208
+ ["path", "fallback", "audit", "strict", "maxLines"],
209
+ "safeMountInfo.read");
210
+ var path = typeof opts.path === "string" && opts.path.length > 0
211
+ ? opts.path
212
+ : DEFAULT_PATH;
213
+ var text;
214
+ try { text = nodeFs.readFileSync(path, "utf8"); }
215
+ catch (e) {
216
+ _refuseEmit(opts, "safe-mount-info/read-failed",
217
+ "/proc/self/mountinfo unreadable: " + ((e && e.message) || String(e)));
218
+ return ("fallback" in opts) ? opts.fallback : null;
219
+ }
220
+ return parse(text, opts);
221
+ }
222
+
223
+ /**
224
+ * @primitive b.safeMountInfo.bestMatch
225
+ * @signature b.safeMountInfo.bestMatch(entries, path)
226
+ * @since 0.11.6
227
+ * @status stable
228
+ * @related b.safeMountInfo.read, b.safeMountInfo.isBindMount
229
+ *
230
+ * Find the mountinfo entry whose `mountPoint` is the longest prefix
231
+ * of `path`. Returns `null` when no entry covers `path`. The "longest
232
+ * prefix" semantic is what bind-mount detection / sealed-store-path
233
+ * validation needs — a mounted subdir wins over the root mount.
234
+ *
235
+ * @example
236
+ * var entries = b.safeMountInfo.read();
237
+ * var atPath = b.safeMountInfo.bestMatch(entries, "/var/lib/blamejs");
238
+ * if (atPath && atPath.root !== "/") {
239
+ * // path lives on a bind-mount (potentially crossing host/guest)
240
+ * }
241
+ */
242
+ function bestMatch(entries, path) {
243
+ if (!Array.isArray(entries) || entries.length === 0) return null;
244
+ if (typeof path !== "string" || path.length === 0) return null;
245
+ var best = null;
246
+ var bestLen = -1;
247
+ for (var i = 0; i < entries.length; i += 1) {
248
+ var e = entries[i];
249
+ if (!e || typeof e.mountPoint !== "string" || e.mountPoint.length === 0) continue;
250
+ var mp = e.mountPoint;
251
+ if (path === mp ||
252
+ (path.length > mp.length &&
253
+ path.indexOf(mp) === 0 &&
254
+ (mp === "/" || path.charCodeAt(mp.length) === 47 /* "/" */))) { // allow:raw-byte-literal — ASCII forward-slash
255
+ if (mp.length > bestLen) {
256
+ bestLen = mp.length;
257
+ best = e;
258
+ }
259
+ }
260
+ }
261
+ return best;
262
+ }
263
+
264
+ /**
265
+ * @primitive b.safeMountInfo.isBindMount
266
+ * @signature b.safeMountInfo.isBindMount(entry)
267
+ * @since 0.11.6
268
+ * @status stable
269
+ * @related b.safeMountInfo.bestMatch
270
+ *
271
+ * `true` when the mountinfo entry's `root` field is something other
272
+ * than `"/"` (i.e. the mount is a bind from a non-root path within
273
+ * the source filesystem). The canonical bind-mount test — does NOT
274
+ * consult the options string (the kernel doesn't emit "bind" there).
275
+ *
276
+ * @example
277
+ * var entries = b.safeMountInfo.read();
278
+ * var atData = b.safeMountInfo.bestMatch(entries, "/data");
279
+ * var isBind = b.safeMountInfo.isBindMount(atData);
280
+ */
281
+ function isBindMount(entry) {
282
+ if (!entry || typeof entry !== "object") return false;
283
+ return typeof entry.root === "string" && entry.root.length > 0 && entry.root !== "/";
284
+ }
285
+
286
+ function _refuseEmit(opts, code, message) {
287
+ var auditImpl = opts.audit || (audit() && audit().safeEmit ? audit() : null);
288
+ if (auditImpl && typeof auditImpl.safeEmit === "function") {
289
+ try {
290
+ auditImpl.safeEmit({
291
+ action: "system.safe_mount_info.refused",
292
+ outcome: "denied",
293
+ metadata: { code: code, reason: message },
294
+ });
295
+ } catch (_e) { /* drop-silent per rule §5 */ }
296
+ }
297
+ }
298
+
299
+ module.exports = {
300
+ parse: parse,
301
+ read: read,
302
+ bestMatch: bestMatch,
303
+ isBindMount: isBindMount,
304
+ SafeMountInfoError: SafeMountInfoError,
305
+ DEFAULT_PATH: DEFAULT_PATH,
306
+ };
package/lib/watcher.js CHANGED
@@ -49,6 +49,7 @@ var nodeFs = require("node:fs");
49
49
  var nodePath = require("node:path");
50
50
  var lazyRequire = require("./lazy-require");
51
51
  var validateOpts = require("./validate-opts");
52
+ var safeMountInfo = require("./safe-mount-info");
52
53
  var { WatcherError } = require("./framework-error");
53
54
 
54
55
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -240,50 +241,19 @@ function _detectAutoMode(rootPath) {
240
241
  try { inContainer = nodeFs.existsSync("/.dockerenv"); }
241
242
  catch (_e) { inContainer = false; }
242
243
 
243
- var mountInfoRaw = null;
244
- try { mountInfoRaw = nodeFs.readFileSync("/proc/self/mountinfo", "utf8"); }
245
- catch (_e) { mountInfoRaw = null; }
246
-
247
- if (!mountInfoRaw) {
248
- // No mountinfo available — fall back to fs. Operator can still
249
- // override explicitly via mode: "poll".
244
+ // Route through b.safeMountInfo — single canonical parser that
245
+ // ALWAYS reads field 4 ("root within source FS") for the bind-
246
+ // mount check below. Pre-v0.11.6 this parsed inline; centralizing
247
+ // it means future container-escape / sealed-store / sandbox call
248
+ // sites inherit the discipline.
249
+ var entries = safeMountInfo.read();
250
+ if (entries === null || entries.length === 0) {
250
251
  return { mode: "fs", reason: "no-mountinfo", fsType: null, inContainer: inContainer };
251
252
  }
252
-
253
- // Find the mount whose mount-point is the longest prefix of rootPath.
254
- var lines = mountInfoRaw.split("\n");
255
- var bestMatch = null;
256
- var bestLen = -1;
257
- for (var i = 0; i < lines.length; i += 1) {
258
- var ln = lines[i];
259
- if (!ln) continue;
260
- // Format: <id> <parent> <major:minor> <root> <mountpoint> <options>
261
- // [<optional-fields>...] - <fstype> <source> <super-options>
262
- // The separator " - " divides the optional-fields half from the post-fields half.
263
- var sepIdx = ln.indexOf(" - ");
264
- if (sepIdx === -1) continue;
265
- var preFields = ln.slice(0, sepIdx).split(" ");
266
- var postFields = ln.slice(sepIdx + 3).split(" ");
267
- if (preFields.length < 6 || postFields.length < 1) continue;
268
- var rootField = preFields[3]; // "/" for regular mount; bound-source path for bind
269
- var mountPoint = preFields[4];
270
- var fstype = postFields[0];
271
- if (typeof mountPoint !== "string" || mountPoint.length === 0) continue;
272
- if (rootPath === mountPoint ||
273
- (rootPath.length > mountPoint.length &&
274
- rootPath.indexOf(mountPoint) === 0 &&
275
- (mountPoint === "/" || rootPath.charCodeAt(mountPoint.length) === 47 /* / */))) {
276
- if (mountPoint.length > bestLen) {
277
- bestLen = mountPoint.length;
278
- bestMatch = { mountPoint: mountPoint, rootField: rootField, fstype: fstype };
279
- }
280
- }
281
- }
282
-
253
+ var bestMatch = safeMountInfo.bestMatch(entries, rootPath);
283
254
  if (!bestMatch) {
284
255
  return { mode: "fs", reason: "no-mount-match", fsType: null, inContainer: inContainer };
285
256
  }
286
-
287
257
  if (AUTO_PROBE_POLL_FSTYPES.has(bestMatch.fstype)) {
288
258
  return {
289
259
  mode: "poll",
@@ -292,16 +262,12 @@ function _detectAutoMode(rootPath) {
292
262
  inContainer: inContainer,
293
263
  };
294
264
  }
295
-
296
- // Bind-mount detection via mountinfo field 4 ("root"). For a regular
297
- // mount this is "/" the entire source filesystem is mounted. For a
298
- // bind-mount it's the path within the source filesystem that was
299
- // bound onto the mount point (e.g. "/Users/me/data" on a Docker
300
- // Desktop bind from macOS). When we're inside a container AND the
301
- // best-matching mount carries a non-"/" root, the mount is a bind
302
- // and inotify chains across the host/guest boundary are unreliable.
303
- // (Operator can still force fs via mode: "fs"; force poll via mode: "poll".)
304
- if (inContainer && bestMatch.rootField && bestMatch.rootField !== "/") {
265
+ // Bind-mount detection via field 4 — `b.safeMountInfo.isBindMount`
266
+ // is the canonical predicate (does NOT consult options string;
267
+ // the kernel doesn't emit "bind" there). When we're inside a
268
+ // container AND the best-matching mount is a bind, inotify
269
+ // chains across the host/guest boundary are unreliable.
270
+ if (inContainer && safeMountInfo.isBindMount(bestMatch)) {
305
271
  return {
306
272
  mode: "poll",
307
273
  reason: "container-bind-mount",
@@ -309,7 +275,6 @@ function _detectAutoMode(rootPath) {
309
275
  inContainer: inContainer,
310
276
  };
311
277
  }
312
-
313
278
  return {
314
279
  mode: "fs",
315
280
  reason: "native-fs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.5",
3
+ "version": "0.11.6",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:8b0da807-db01-45a8-b301-744f3623a4e7",
5
+ "serialNumber": "urn:uuid:47f41007-66d3-47f2-ba7e-e47db1d7370f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-19T22:45:04.845Z",
8
+ "timestamp": "2026-05-20T00:03:39.026Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.11.5",
22
+ "bom-ref": "@blamejs/core@0.11.6",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.11.5",
25
+ "version": "0.11.6",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.11.5",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.6",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.11.5",
57
+ "ref": "@blamejs/core@0.11.6",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]