memgineering 0.4.0 → 0.4.1

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
@@ -11,6 +11,37 @@ language the reader wants. The bilingual rule the monorepo applies to
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.4.1] — 2026-08-10
15
+
16
+ Security fixes in `setup --web`, from the 2026-08-09 audit
17
+ (`docs/security-audit-2026-08-09.md`). Every one was reproduced against
18
+ 0.3.0/0.4.0 rather than reasoned about. No command or flag changed.
19
+
20
+ ### Fixed
21
+
22
+ - **The setup screen no longer stays up for whoever is knocking.** Its idle
23
+ timer was reset before any request was checked, so anything that could reach
24
+ the port kept a writable install endpoint alive by pinging it — without ever
25
+ presenting the key. The timer now moves only for requests that passed the
26
+ origin and key checks, and there is a one-hour ceiling above it that nothing
27
+ can push back.
28
+ - **`--web` no longer reads a folder you did not mean.** Pointed at a system
29
+ directory it walked one and returned the title and first paragraph of every
30
+ markdown file under it. System folders are refused, the scan stops after
31
+ 5,000 notes rather than running for minutes on a large folder with no way to
32
+ cancel, and the folder step stops answering once the install is done.
33
+ - **A second submit no longer reports a failed install that succeeded.** Two
34
+ applies — the ordinary case of opening the setup link in a second tab —
35
+ both got past the already-applied guard, and the loser was told "Nothing
36
+ further was written" while everything had been written.
37
+ - **The screen cannot be embedded in another page**, and a request that changes
38
+ something must say where it came from.
39
+ - **`--web` no longer claims it opened a browser when it did not.** On a machine
40
+ with no default browser — or a sandbox, or `xdg-open` with nothing to hand it
41
+ — the opener exits non-zero and this reported success anyway, so an agent told
42
+ its user the screen was open while the desktop was unchanged and the printed
43
+ URL was the only way forward.
44
+
14
45
  ## [0.4.0] — 2026-08-10
15
46
 
16
47
  A template is not an answer, a revision that revises nothing is not written,
package/dist/index.js CHANGED
@@ -4130,10 +4130,11 @@ import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
4130
4130
  import { readdir as readdir6, stat as stat3 } from "fs/promises";
4131
4131
  import { createServer } from "http";
4132
4132
  import { homedir as homedir3 } from "os";
4133
- import { basename, isAbsolute as isAbsolute2, join as join14, resolve as resolve7 } from "path";
4133
+ import { basename, isAbsolute as isAbsolute2, join as join14, relative as relative2, resolve as resolve7 } from "path";
4134
4134
  async function startSetupWebServer(opts) {
4135
4135
  const token = randomBytes2(32).toString("hex");
4136
4136
  const idleMs = opts.idleMs ?? IDLE_TIMEOUT_MS;
4137
+ const maxMs = opts.maxMs ?? MAX_LIFETIME_MS;
4137
4138
  const lingerMs = opts.lingerMs ?? APPLY_LINGER_MS;
4138
4139
  const sockets = /* @__PURE__ */ new Set();
4139
4140
  const server = createServer();
@@ -4162,6 +4163,7 @@ async function startSetupWebServer(opts) {
4162
4163
  if (stopped) return;
4163
4164
  stopped = true;
4164
4165
  if (idleTimer) clearTimeout(idleTimer);
4166
+ clearTimeout(maxTimer);
4165
4167
  process.off("SIGINT", onInterrupt);
4166
4168
  await new Promise((resolve10) => {
4167
4169
  server.close(() => resolve10());
@@ -4175,14 +4177,15 @@ async function startSetupWebServer(opts) {
4175
4177
  idleTimer = setTimeout(() => void stop("idle"), idleMs);
4176
4178
  idleTimer.unref();
4177
4179
  };
4180
+ const maxTimer = setTimeout(() => void stop("idle"), maxMs);
4181
+ maxTimer.unref();
4178
4182
  function onInterrupt() {
4179
4183
  void stop("interrupted");
4180
4184
  }
4181
4185
  process.once("SIGINT", onInterrupt);
4182
4186
  const state = { detected: opts.detected, token, ownOrigins, applied: false };
4183
4187
  server.on("request", (req, res) => {
4184
- touch();
4185
- void handle(req, res, state, origin).then((outcome) => {
4188
+ void handle(req, res, state, origin, touch).then((outcome) => {
4186
4189
  if (outcome === "applied") setTimeout(() => void stop("applied"), lingerMs).unref();
4187
4190
  if (outcome === "cancelled") setTimeout(() => void stop("cancelled"), 50).unref();
4188
4191
  }).catch((err) => {
@@ -4237,9 +4240,20 @@ async function runSetupWeb(opts) {
4237
4240
  else printHuman(`
4238
4241
  ${reason === "applied" ? c.green(ending[reason]) : c.gray(ending[reason])}`);
4239
4242
  }
4240
- async function handle(req, res, state, origin) {
4243
+ async function handle(req, res, state, origin, touch = () => {
4244
+ }) {
4241
4245
  const url = new URL(req.url ?? "/", origin);
4242
4246
  const sender = req.headers.origin;
4247
+ if (sender === void 0 && req.method !== "GET" && req.method !== "HEAD") {
4248
+ sendJson(res, 403, {
4249
+ error: {
4250
+ code: "forbidden_origin",
4251
+ message: "a request that changes something must say where it came from",
4252
+ hint: "This screen answers the page it opened on this machine. If you are driving it directly, send `Origin` matching the URL `memgineering setup --web` printed."
4253
+ }
4254
+ });
4255
+ return "served";
4256
+ }
4243
4257
  if (sender !== void 0 && !state.ownOrigins.has(sender)) {
4244
4258
  sendJson(res, 403, {
4245
4259
  error: {
@@ -4260,6 +4274,7 @@ async function handle(req, res, state, origin) {
4260
4274
  });
4261
4275
  return "served";
4262
4276
  }
4277
+ touch();
4263
4278
  if (req.method === "GET" && url.pathname === "/") {
4264
4279
  sendHtml(res, renderSetupPage());
4265
4280
  return "served";
@@ -4277,7 +4292,7 @@ async function handle(req, res, state, origin) {
4277
4292
  return "served";
4278
4293
  }
4279
4294
  if (req.method === "POST" && url.pathname === "/api/inspect") {
4280
- return inspectFromScreen(req, res);
4295
+ return inspectFromScreen(req, res, state);
4281
4296
  }
4282
4297
  if (req.method === "POST" && url.pathname === "/api/apply") {
4283
4298
  return applyFromScreen(req, res, state);
@@ -4354,7 +4369,24 @@ async function holdsNotes(dir) {
4354
4369
  }
4355
4370
  return false;
4356
4371
  }
4357
- async function inspectFromScreen(req, res) {
4372
+ function isSystemPath(path) {
4373
+ return SYSTEM_ROOTS.some((root) => {
4374
+ if (root === "/") return path === "/";
4375
+ const rel = relative2(root, path);
4376
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
4377
+ });
4378
+ }
4379
+ async function inspectFromScreen(req, res, state) {
4380
+ if (state.applied) {
4381
+ sendJson(res, 409, {
4382
+ error: {
4383
+ code: "already_applied",
4384
+ message: "setup is finished \u2014 this screen no longer reads folders",
4385
+ hint: "Run `memgineering link <folder>` to connect another set of notes, or `memgineering setup --web` again."
4386
+ }
4387
+ });
4388
+ return "served";
4389
+ }
4358
4390
  const parsed = await readJsonBody(req, res);
4359
4391
  if (parsed === void 0) return "served";
4360
4392
  const asked = parsed.path;
@@ -4368,7 +4400,17 @@ async function inspectFromScreen(req, res) {
4368
4400
  });
4369
4401
  return "served";
4370
4402
  }
4371
- const path = expandHome(asked.trim());
4403
+ const path = resolve7(expandHome(asked.trim()));
4404
+ if (isSystemPath(path)) {
4405
+ sendJson(res, 400, {
4406
+ error: {
4407
+ code: "invalid_input",
4408
+ message: `that is a system folder, not a notes folder: ${path}`,
4409
+ hint: "Point this at the FOLDER holding your notes \u2014 somewhere you write, like `~/Documents/Notes`."
4410
+ }
4411
+ });
4412
+ return "served";
4413
+ }
4372
4414
  const info = await stat3(path).catch(() => null);
4373
4415
  if (info === null) {
4374
4416
  sendJson(res, 200, {
@@ -4390,7 +4432,8 @@ async function inspectFromScreen(req, res) {
4390
4432
  }
4391
4433
  try {
4392
4434
  const target = await openLinkTarget(path, { quiet: true });
4393
- const rows = await target.preview(Number.MAX_SAFE_INTEGER);
4435
+ const rows = await target.preview(PREVIEW_CAP);
4436
+ const capped = target.notes.length > PREVIEW_CAP;
4394
4437
  const cfg = await loadConfig();
4395
4438
  sendJson(res, 200, {
4396
4439
  path,
@@ -4401,6 +4444,10 @@ async function inspectFromScreen(req, res) {
4401
4444
  // number is the one a person is agreeing to; the first is why it moved.
4402
4445
  scanned: target.notes.length,
4403
4446
  would_index: rows.length,
4447
+ // Named, so a number that stopped early is never read as a total. The
4448
+ // screen shows what a person is agreeing to, and "5,000" presented as the
4449
+ // whole of a 24,000-note folder is a worse answer than a slow one.
4450
+ ...capped ? { would_index_capped: PREVIEW_CAP } : {},
4404
4451
  excluded: target.scan.excluded.length,
4405
4452
  skipped_symlinks: target.scan.skippedSymlinks.length,
4406
4453
  refused: [
@@ -4430,14 +4477,18 @@ async function applyFromScreen(req, res, state) {
4430
4477
  });
4431
4478
  return "served";
4432
4479
  }
4480
+ state.applied = true;
4433
4481
  const parsed = await readJsonBody(req, res);
4434
- if (parsed === void 0) return "served";
4482
+ if (parsed === void 0) {
4483
+ state.applied = false;
4484
+ return "served";
4485
+ }
4435
4486
  const choice = readChoice(parsed, state.detected);
4436
4487
  if ("error" in choice) {
4488
+ state.applied = false;
4437
4489
  sendJson(res, 400, { error: choice.error });
4438
4490
  return "served";
4439
4491
  }
4440
- state.applied = true;
4441
4492
  const changes = await applySetup(choice.value, false);
4442
4493
  const brain = await connectBrain(choice.brain);
4443
4494
  const cfg = await loadConfig();
@@ -4656,17 +4707,34 @@ function sendHtml(res, html) {
4656
4707
  // The page is one self-contained file by design; say so, so that a future
4657
4708
  // edit that reaches for a CDN font fails visibly here instead of quietly
4658
4709
  // making a local setup screen talk to the internet.
4659
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'"
4710
+ // `frame-ancestors 'none'` because a hostile page could and did load this
4711
+ // screen in an iframe. Nothing is exploitable through it while the token
4712
+ // stands between an attacker and the API — but the consent screen for
4713
+ // installing into somebody's agent should not be embeddable, and the day
4714
+ // the token moves out of the URL into a cookie it would become the whole
4715
+ // attack rather than a curiosity.
4716
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'",
4717
+ // The header the older browsers read. Same statement, twice.
4718
+ "x-frame-options": "DENY"
4660
4719
  });
4661
4720
  res.end(html);
4662
4721
  }
4663
4722
  async function openBrowser(url) {
4664
4723
  const { file, args } = browserOpener(url);
4665
4724
  return new Promise((resolve10) => {
4725
+ let settled = false;
4726
+ const finish = (opened) => {
4727
+ if (settled) return;
4728
+ settled = true;
4729
+ resolve10(opened);
4730
+ };
4666
4731
  const child = spawn(file, args, { detached: true, stdio: "ignore" });
4667
- child.on("error", () => resolve10(false));
4732
+ child.on("error", () => finish(false));
4733
+ child.on("exit", (code) => {
4734
+ if (code !== null && code !== 0) finish(false);
4735
+ });
4668
4736
  child.unref();
4669
- setTimeout(() => resolve10(true), 300).unref();
4737
+ setTimeout(() => finish(true), 300).unref();
4670
4738
  });
4671
4739
  }
4672
4740
  function browserOpener(url) {
@@ -4674,7 +4742,7 @@ function browserOpener(url) {
4674
4742
  if (process.platform === "win32") return { file: "cmd", args: ["/c", "start", "", url] };
4675
4743
  return { file: "xdg-open", args: [url] };
4676
4744
  }
4677
- var IDLE_TIMEOUT_MS, APPLY_LINGER_MS, MAX_BODY_BYTES, MAX_CANDIDATES;
4745
+ var IDLE_TIMEOUT_MS, MAX_LIFETIME_MS, APPLY_LINGER_MS, MAX_BODY_BYTES, MAX_CANDIDATES, PREVIEW_CAP, SYSTEM_ROOTS;
4678
4746
  var init_setup_web = __esm({
4679
4747
  "src/commands/setup-web.ts"() {
4680
4748
  "use strict";
@@ -4686,9 +4754,24 @@ var init_setup_web = __esm({
4686
4754
  init_setup();
4687
4755
  init_setup_web_page();
4688
4756
  IDLE_TIMEOUT_MS = 10 * 60 * 1e3;
4757
+ MAX_LIFETIME_MS = 60 * 60 * 1e3;
4689
4758
  APPLY_LINGER_MS = 1e3;
4690
4759
  MAX_BODY_BYTES = 64 * 1024;
4691
4760
  MAX_CANDIDATES = 8;
4761
+ PREVIEW_CAP = 5e3;
4762
+ SYSTEM_ROOTS = [
4763
+ "/",
4764
+ "/usr",
4765
+ "/bin",
4766
+ "/sbin",
4767
+ "/etc",
4768
+ "/dev",
4769
+ "/proc",
4770
+ "/sys",
4771
+ "/System",
4772
+ "/Library",
4773
+ "/opt"
4774
+ ];
4692
4775
  }
4693
4776
  });
4694
4777
 
@@ -8251,7 +8334,7 @@ init_errors();
8251
8334
  init_config();
8252
8335
  init_vault();
8253
8336
  init_ui();
8254
- import { isAbsolute as isAbsolute3, relative as relative2, resolve as resolve9 } from "path";
8337
+ import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve9 } from "path";
8255
8338
  import { Command as Command17 } from "commander";
8256
8339
  var VIA_MEANS = {
8257
8340
  flag: "because --vault named it",
@@ -8328,7 +8411,7 @@ A pointer to an unlinked folder would fail on every command instead of at this o
8328
8411
  });
8329
8412
  return;
8330
8413
  }
8331
- const value = isInside(root, where) ? relative2(where, root) || "." : root;
8414
+ const value = isInside(root, where) ? relative3(where, root) || "." : root;
8332
8415
  const file = resolve9(where, BRAND.pointerFileName);
8333
8416
  await writeThenRename(file, `${value}
8334
8417
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memgineering",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "description": "One memory for the AI you connect. Recall, remember, and revise a brain your agents share — stored in your own folder.",
6
6
  "license": "Apache-2.0",