memgineering 0.4.0 → 0.4.2

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 (3) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/index.js +120 -19
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -11,6 +11,59 @@ language the reader wants. The bilingual rule the monorepo applies to
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.4.2] — 2026-08-10
15
+
16
+ ### Fixed
17
+
18
+ - **`setup --web` no longer prints the install key where it will be kept.** The
19
+ URL carries the key that authorizes reading your folders and writing into
20
+ `~/.claude`, and it was printed on every run — including under `--json`, so a
21
+ copy landed in the transcript of the agent that ran the command, which may be
22
+ stored, synced, or sent somewhere. It is now shown only when it is the way
23
+ in: the browser did not open, or you asked for it with `--print-url`.
24
+
25
+ **If you parse `--json`:** `url` is now present only when `opened` is
26
+ `false`. `port` and `opened` are always there. This is a smaller promise than
27
+ before and it is deliberate — the alternative was to keep handing out the key
28
+ to avoid changing a field.
29
+
30
+ - **The link is no longer described as working "only once".** It never did: the
31
+ key is single-use for applying the setup and reusable for reading, so a
32
+ person told "only once" and then able to reload had been told something
33
+ untrue about the thing protecting their machine. It now says what actually
34
+ ends it — applying, closing the tab, or an hour.
35
+
36
+ ## [0.4.1] — 2026-08-10
37
+
38
+ Security fixes in `setup --web`, from the 2026-08-09 audit
39
+ (`docs/security-audit-2026-08-09.md`). Every one was reproduced against
40
+ 0.3.0/0.4.0 rather than reasoned about. No command or flag changed.
41
+
42
+ ### Fixed
43
+
44
+ - **The setup screen no longer stays up for whoever is knocking.** Its idle
45
+ timer was reset before any request was checked, so anything that could reach
46
+ the port kept a writable install endpoint alive by pinging it — without ever
47
+ presenting the key. The timer now moves only for requests that passed the
48
+ origin and key checks, and there is a one-hour ceiling above it that nothing
49
+ can push back.
50
+ - **`--web` no longer reads a folder you did not mean.** Pointed at a system
51
+ directory it walked one and returned the title and first paragraph of every
52
+ markdown file under it. System folders are refused, the scan stops after
53
+ 5,000 notes rather than running for minutes on a large folder with no way to
54
+ cancel, and the folder step stops answering once the install is done.
55
+ - **A second submit no longer reports a failed install that succeeded.** Two
56
+ applies — the ordinary case of opening the setup link in a second tab —
57
+ both got past the already-applied guard, and the loser was told "Nothing
58
+ further was written" while everything had been written.
59
+ - **The screen cannot be embedded in another page**, and a request that changes
60
+ something must say where it came from.
61
+ - **`--web` no longer claims it opened a browser when it did not.** On a machine
62
+ with no default browser — or a sandbox, or `xdg-open` with nothing to hand it
63
+ — the opener exits non-zero and this reported success anyway, so an agent told
64
+ its user the screen was open while the desktop was unchanged and the printed
65
+ URL was the only way forward.
66
+
14
67
  ## [0.4.0] — 2026-08-10
15
68
 
16
69
  A template is not an answer, a revision that revises nothing is not written,
package/dist/index.js CHANGED
@@ -4122,6 +4122,7 @@ var init_setup_web_page = __esm({
4122
4122
  var setup_web_exports = {};
4123
4123
  __export(setup_web_exports, {
4124
4124
  browserOpener: () => browserOpener,
4125
+ disclosure: () => disclosure,
4125
4126
  runSetupWeb: () => runSetupWeb,
4126
4127
  startSetupWebServer: () => startSetupWebServer
4127
4128
  });
@@ -4130,10 +4131,11 @@ import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
4130
4131
  import { readdir as readdir6, stat as stat3 } from "fs/promises";
4131
4132
  import { createServer } from "http";
4132
4133
  import { homedir as homedir3 } from "os";
4133
- import { basename, isAbsolute as isAbsolute2, join as join14, resolve as resolve7 } from "path";
4134
+ import { basename, isAbsolute as isAbsolute2, join as join14, relative as relative2, resolve as resolve7 } from "path";
4134
4135
  async function startSetupWebServer(opts) {
4135
4136
  const token = randomBytes2(32).toString("hex");
4136
4137
  const idleMs = opts.idleMs ?? IDLE_TIMEOUT_MS;
4138
+ const maxMs = opts.maxMs ?? MAX_LIFETIME_MS;
4137
4139
  const lingerMs = opts.lingerMs ?? APPLY_LINGER_MS;
4138
4140
  const sockets = /* @__PURE__ */ new Set();
4139
4141
  const server = createServer();
@@ -4162,6 +4164,7 @@ async function startSetupWebServer(opts) {
4162
4164
  if (stopped) return;
4163
4165
  stopped = true;
4164
4166
  if (idleTimer) clearTimeout(idleTimer);
4167
+ clearTimeout(maxTimer);
4165
4168
  process.off("SIGINT", onInterrupt);
4166
4169
  await new Promise((resolve10) => {
4167
4170
  server.close(() => resolve10());
@@ -4175,14 +4178,15 @@ async function startSetupWebServer(opts) {
4175
4178
  idleTimer = setTimeout(() => void stop("idle"), idleMs);
4176
4179
  idleTimer.unref();
4177
4180
  };
4181
+ const maxTimer = setTimeout(() => void stop("idle"), maxMs);
4182
+ maxTimer.unref();
4178
4183
  function onInterrupt() {
4179
4184
  void stop("interrupted");
4180
4185
  }
4181
4186
  process.once("SIGINT", onInterrupt);
4182
4187
  const state = { detected: opts.detected, token, ownOrigins, applied: false };
4183
4188
  server.on("request", (req, res) => {
4184
- touch();
4185
- void handle(req, res, state, origin).then((outcome) => {
4189
+ void handle(req, res, state, origin, touch).then((outcome) => {
4186
4190
  if (outcome === "applied") setTimeout(() => void stop("applied"), lingerMs).unref();
4187
4191
  if (outcome === "cancelled") setTimeout(() => void stop("cancelled"), 50).unref();
4188
4192
  }).catch((err) => {
@@ -4209,18 +4213,32 @@ async function runSetupWeb(opts) {
4209
4213
  const server = await startSetupWebServer({ detected: opts.detected });
4210
4214
  const opened = opts.printUrl ? false : await openBrowser(server.url);
4211
4215
  printDual({
4212
- json: { url: server.url, port: server.port, opened },
4216
+ // The URL carries the key that authorizes reading folders and writing into
4217
+ // `~/.claude`, and it was printed on every run — in `--json` too, so it
4218
+ // landed in the transcript of the agent that ran the command, which may be
4219
+ // stored, synced or sent somewhere. That is a different exposure from the
4220
+ // one everybody thinks of with a local token: not a process on this machine
4221
+ // that could read those files anyway, but a copy of the key leaving it.
4222
+ //
4223
+ // So it is emitted only when it is the way in — the browser did not open,
4224
+ // or `--print-url` asked for it. When the browser DID open, the person is
4225
+ // already looking at the screen and nobody needs the string.
4226
+ json: disclosure(server.url, server.port, opened),
4213
4227
  human: () => {
4214
4228
  if (opened) {
4215
4229
  printHuman("Opened setup in your browser.\n");
4216
- printHuman(c.gray(` If nothing appeared: ${server.url}`));
4230
+ printHuman(
4231
+ c.gray(
4232
+ " Nothing appeared? Run `memgineering setup --web --print-url` and open\n the link yourself \u2014 it is not printed here on purpose, because it\n carries the key that authorizes the install."
4233
+ )
4234
+ );
4217
4235
  } else {
4218
4236
  printHuman("Open this in a browser to finish setting up:\n");
4219
4237
  printHuman(` ${server.url}`);
4220
4238
  }
4221
4239
  printHuman(
4222
4240
  c.gray(
4223
- "\n The link only works on this machine and only once \u2014 it carries a\n one-time key, and this screen closes when you are done.\n Ctrl-C to stop waiting."
4241
+ "\n The link works only on this machine, and only until this screen\n closes \u2014 applying the setup, closing the tab, or an hour, whichever\n comes first. Ctrl-C to stop waiting."
4224
4242
  )
4225
4243
  );
4226
4244
  }
@@ -4237,9 +4255,23 @@ async function runSetupWeb(opts) {
4237
4255
  else printHuman(`
4238
4256
  ${reason === "applied" ? c.green(ending[reason]) : c.gray(ending[reason])}`);
4239
4257
  }
4240
- async function handle(req, res, state, origin) {
4258
+ function disclosure(url, port, opened) {
4259
+ return opened ? { port, opened } : { port, opened, url };
4260
+ }
4261
+ async function handle(req, res, state, origin, touch = () => {
4262
+ }) {
4241
4263
  const url = new URL(req.url ?? "/", origin);
4242
4264
  const sender = req.headers.origin;
4265
+ if (sender === void 0 && req.method !== "GET" && req.method !== "HEAD") {
4266
+ sendJson(res, 403, {
4267
+ error: {
4268
+ code: "forbidden_origin",
4269
+ message: "a request that changes something must say where it came from",
4270
+ 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."
4271
+ }
4272
+ });
4273
+ return "served";
4274
+ }
4243
4275
  if (sender !== void 0 && !state.ownOrigins.has(sender)) {
4244
4276
  sendJson(res, 403, {
4245
4277
  error: {
@@ -4260,6 +4292,7 @@ async function handle(req, res, state, origin) {
4260
4292
  });
4261
4293
  return "served";
4262
4294
  }
4295
+ touch();
4263
4296
  if (req.method === "GET" && url.pathname === "/") {
4264
4297
  sendHtml(res, renderSetupPage());
4265
4298
  return "served";
@@ -4277,7 +4310,7 @@ async function handle(req, res, state, origin) {
4277
4310
  return "served";
4278
4311
  }
4279
4312
  if (req.method === "POST" && url.pathname === "/api/inspect") {
4280
- return inspectFromScreen(req, res);
4313
+ return inspectFromScreen(req, res, state);
4281
4314
  }
4282
4315
  if (req.method === "POST" && url.pathname === "/api/apply") {
4283
4316
  return applyFromScreen(req, res, state);
@@ -4354,7 +4387,24 @@ async function holdsNotes(dir) {
4354
4387
  }
4355
4388
  return false;
4356
4389
  }
4357
- async function inspectFromScreen(req, res) {
4390
+ function isSystemPath(path) {
4391
+ return SYSTEM_ROOTS.some((root) => {
4392
+ if (root === "/") return path === "/";
4393
+ const rel = relative2(root, path);
4394
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
4395
+ });
4396
+ }
4397
+ async function inspectFromScreen(req, res, state) {
4398
+ if (state.applied) {
4399
+ sendJson(res, 409, {
4400
+ error: {
4401
+ code: "already_applied",
4402
+ message: "setup is finished \u2014 this screen no longer reads folders",
4403
+ hint: "Run `memgineering link <folder>` to connect another set of notes, or `memgineering setup --web` again."
4404
+ }
4405
+ });
4406
+ return "served";
4407
+ }
4358
4408
  const parsed = await readJsonBody(req, res);
4359
4409
  if (parsed === void 0) return "served";
4360
4410
  const asked = parsed.path;
@@ -4368,7 +4418,17 @@ async function inspectFromScreen(req, res) {
4368
4418
  });
4369
4419
  return "served";
4370
4420
  }
4371
- const path = expandHome(asked.trim());
4421
+ const path = resolve7(expandHome(asked.trim()));
4422
+ if (isSystemPath(path)) {
4423
+ sendJson(res, 400, {
4424
+ error: {
4425
+ code: "invalid_input",
4426
+ message: `that is a system folder, not a notes folder: ${path}`,
4427
+ hint: "Point this at the FOLDER holding your notes \u2014 somewhere you write, like `~/Documents/Notes`."
4428
+ }
4429
+ });
4430
+ return "served";
4431
+ }
4372
4432
  const info = await stat3(path).catch(() => null);
4373
4433
  if (info === null) {
4374
4434
  sendJson(res, 200, {
@@ -4390,7 +4450,8 @@ async function inspectFromScreen(req, res) {
4390
4450
  }
4391
4451
  try {
4392
4452
  const target = await openLinkTarget(path, { quiet: true });
4393
- const rows = await target.preview(Number.MAX_SAFE_INTEGER);
4453
+ const rows = await target.preview(PREVIEW_CAP);
4454
+ const capped = target.notes.length > PREVIEW_CAP;
4394
4455
  const cfg = await loadConfig();
4395
4456
  sendJson(res, 200, {
4396
4457
  path,
@@ -4401,6 +4462,10 @@ async function inspectFromScreen(req, res) {
4401
4462
  // number is the one a person is agreeing to; the first is why it moved.
4402
4463
  scanned: target.notes.length,
4403
4464
  would_index: rows.length,
4465
+ // Named, so a number that stopped early is never read as a total. The
4466
+ // screen shows what a person is agreeing to, and "5,000" presented as the
4467
+ // whole of a 24,000-note folder is a worse answer than a slow one.
4468
+ ...capped ? { would_index_capped: PREVIEW_CAP } : {},
4404
4469
  excluded: target.scan.excluded.length,
4405
4470
  skipped_symlinks: target.scan.skippedSymlinks.length,
4406
4471
  refused: [
@@ -4430,14 +4495,18 @@ async function applyFromScreen(req, res, state) {
4430
4495
  });
4431
4496
  return "served";
4432
4497
  }
4498
+ state.applied = true;
4433
4499
  const parsed = await readJsonBody(req, res);
4434
- if (parsed === void 0) return "served";
4500
+ if (parsed === void 0) {
4501
+ state.applied = false;
4502
+ return "served";
4503
+ }
4435
4504
  const choice = readChoice(parsed, state.detected);
4436
4505
  if ("error" in choice) {
4506
+ state.applied = false;
4437
4507
  sendJson(res, 400, { error: choice.error });
4438
4508
  return "served";
4439
4509
  }
4440
- state.applied = true;
4441
4510
  const changes = await applySetup(choice.value, false);
4442
4511
  const brain = await connectBrain(choice.brain);
4443
4512
  const cfg = await loadConfig();
@@ -4656,17 +4725,34 @@ function sendHtml(res, html) {
4656
4725
  // The page is one self-contained file by design; say so, so that a future
4657
4726
  // edit that reaches for a CDN font fails visibly here instead of quietly
4658
4727
  // 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'"
4728
+ // `frame-ancestors 'none'` because a hostile page could and did load this
4729
+ // screen in an iframe. Nothing is exploitable through it while the token
4730
+ // stands between an attacker and the API — but the consent screen for
4731
+ // installing into somebody's agent should not be embeddable, and the day
4732
+ // the token moves out of the URL into a cookie it would become the whole
4733
+ // attack rather than a curiosity.
4734
+ "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'",
4735
+ // The header the older browsers read. Same statement, twice.
4736
+ "x-frame-options": "DENY"
4660
4737
  });
4661
4738
  res.end(html);
4662
4739
  }
4663
4740
  async function openBrowser(url) {
4664
4741
  const { file, args } = browserOpener(url);
4665
4742
  return new Promise((resolve10) => {
4743
+ let settled = false;
4744
+ const finish = (opened) => {
4745
+ if (settled) return;
4746
+ settled = true;
4747
+ resolve10(opened);
4748
+ };
4666
4749
  const child = spawn(file, args, { detached: true, stdio: "ignore" });
4667
- child.on("error", () => resolve10(false));
4750
+ child.on("error", () => finish(false));
4751
+ child.on("exit", (code) => {
4752
+ if (code !== null && code !== 0) finish(false);
4753
+ });
4668
4754
  child.unref();
4669
- setTimeout(() => resolve10(true), 300).unref();
4755
+ setTimeout(() => finish(true), 300).unref();
4670
4756
  });
4671
4757
  }
4672
4758
  function browserOpener(url) {
@@ -4674,7 +4760,7 @@ function browserOpener(url) {
4674
4760
  if (process.platform === "win32") return { file: "cmd", args: ["/c", "start", "", url] };
4675
4761
  return { file: "xdg-open", args: [url] };
4676
4762
  }
4677
- var IDLE_TIMEOUT_MS, APPLY_LINGER_MS, MAX_BODY_BYTES, MAX_CANDIDATES;
4763
+ var IDLE_TIMEOUT_MS, MAX_LIFETIME_MS, APPLY_LINGER_MS, MAX_BODY_BYTES, MAX_CANDIDATES, PREVIEW_CAP, SYSTEM_ROOTS;
4678
4764
  var init_setup_web = __esm({
4679
4765
  "src/commands/setup-web.ts"() {
4680
4766
  "use strict";
@@ -4686,9 +4772,24 @@ var init_setup_web = __esm({
4686
4772
  init_setup();
4687
4773
  init_setup_web_page();
4688
4774
  IDLE_TIMEOUT_MS = 10 * 60 * 1e3;
4775
+ MAX_LIFETIME_MS = 60 * 60 * 1e3;
4689
4776
  APPLY_LINGER_MS = 1e3;
4690
4777
  MAX_BODY_BYTES = 64 * 1024;
4691
4778
  MAX_CANDIDATES = 8;
4779
+ PREVIEW_CAP = 5e3;
4780
+ SYSTEM_ROOTS = [
4781
+ "/",
4782
+ "/usr",
4783
+ "/bin",
4784
+ "/sbin",
4785
+ "/etc",
4786
+ "/dev",
4787
+ "/proc",
4788
+ "/sys",
4789
+ "/System",
4790
+ "/Library",
4791
+ "/opt"
4792
+ ];
4692
4793
  }
4693
4794
  });
4694
4795
 
@@ -8251,7 +8352,7 @@ init_errors();
8251
8352
  init_config();
8252
8353
  init_vault();
8253
8354
  init_ui();
8254
- import { isAbsolute as isAbsolute3, relative as relative2, resolve as resolve9 } from "path";
8355
+ import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve9 } from "path";
8255
8356
  import { Command as Command17 } from "commander";
8256
8357
  var VIA_MEANS = {
8257
8358
  flag: "because --vault named it",
@@ -8328,7 +8429,7 @@ A pointer to an unlinked folder would fail on every command instead of at this o
8328
8429
  });
8329
8430
  return;
8330
8431
  }
8331
- const value = isInside(root, where) ? relative2(where, root) || "." : root;
8432
+ const value = isInside(root, where) ? relative3(where, root) || "." : root;
8332
8433
  const file = resolve9(where, BRAND.pointerFileName);
8333
8434
  await writeThenRename(file, `${value}
8334
8435
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memgineering",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",