@symbols-cli/cli 0.0.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.
Files changed (41) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +103 -0
  3. package/dist/auth/client.js +531 -0
  4. package/dist/auth/credentials.js +293 -0
  5. package/dist/auth/hosts.js +85 -0
  6. package/dist/auth/loopback.js +108 -0
  7. package/dist/auth/pkce.js +33 -0
  8. package/dist/auth/wire.js +40 -0
  9. package/dist/commands/arm.js +154 -0
  10. package/dist/commands/curl.js +101 -0
  11. package/dist/commands/doctor.js +217 -0
  12. package/dist/commands/login.js +113 -0
  13. package/dist/commands/logout.js +78 -0
  14. package/dist/commands/mcp.js +33 -0
  15. package/dist/commands/project.js +145 -0
  16. package/dist/commands/status.js +78 -0
  17. package/dist/commands/sync.js +94 -0
  18. package/dist/commands/uninstall.js +149 -0
  19. package/dist/commands/up.js +176 -0
  20. package/dist/commands/update.js +120 -0
  21. package/dist/commands/watch.js +155 -0
  22. package/dist/commands/whoami.js +103 -0
  23. package/dist/index.js +147 -0
  24. package/dist/mcp/scopes.js +215 -0
  25. package/dist/mcp/server.js +366 -0
  26. package/dist/mcp/tools.js +646 -0
  27. package/dist/skills/bundle.js +441 -0
  28. package/dist/skills/claude-md.js +135 -0
  29. package/dist/skills/install.js +188 -0
  30. package/dist/skills/settings-merge.js +107 -0
  31. package/dist/sync/api.js +380 -0
  32. package/dist/sync/diff.js +172 -0
  33. package/dist/sync/ledger.js +319 -0
  34. package/dist/sync/paths.js +447 -0
  35. package/dist/sync/protect.js +108 -0
  36. package/dist/sync/reconcile.js +870 -0
  37. package/dist/sync/watcher.js +206 -0
  38. package/dist/util/log.js +58 -0
  39. package/dist/util/platform.js +79 -0
  40. package/dist/util/version.js +24 -0
  41. package/package.json +44 -0
@@ -0,0 +1,447 @@
1
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+ //
3
+ // This source code is proprietary and confidential. Unauthorized copying,
4
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
5
+ // ⭐ ONE OF THE TWO FILES THAT CARRY THE RISK. The other is `diff.ts`.
6
+ //
7
+ // Everything here answers one question: **given a path the SERVER supplied, may
8
+ // we write it, and where exactly?**
9
+ //
10
+ // That framing matters. The adversary is not the user and not the local agent —
11
+ // the agent runs as the same uid and can write anywhere directly, so no check
12
+ // here constrains it. The parties these rules defend against are:
13
+ //
14
+ // * **the server**, if it is ever compromised. `project_files.path` is stored
15
+ // text, reachable through REST `create_file`, which applies NO path screen.
16
+ // One compromised server otherwise becomes persistent code execution in every
17
+ // user's home directory, simultaneously. Server-side the blast radius was one
18
+ // sandboxed container; client-side it inverts.
19
+ // * **imported content** — a forked community notebook carries attacker-authored
20
+ // file paths verbatim.
21
+ //
22
+ // Four separate classes, and it takes all four:
23
+ //
24
+ // 1. ESCAPE — `..`, absolute paths, NUL, symlinked parents. Keeps writes inside
25
+ // the project root.
26
+ // 2. CONFIG-BEARING PATHS (S2) — the dangerous files live INSIDE the root, so
27
+ // class 1 does not touch them. `.claude/settings.json` is creatable today via
28
+ // REST and becomes project-scope Claude Code config after sync:
29
+ // `permissions.allow` silently widens what the agent may do without
30
+ // prompting, and hooks execute commands.
31
+ // 3. COLLISIONS (F3) — Postgres holds `A.py` and `a.py` as two rows; default
32
+ // APFS is case- AND normalization-insensitive, so both land on ONE file and
33
+ // the survivor's content is then pushed to both rows. Silent, no deletes,
34
+ // circuit-breaker blind.
35
+ // 4. TERMINAL INJECTION — `safe_dirname` passes `\x1b` through, so a hostile
36
+ // notebook name becomes an escape-sequence-injecting directory.
37
+ //
38
+ // ⚠ ORDERING IS LOAD-BEARING: normalization (casefold + NFC) runs BEFORE the
39
+ // deny-list, not after. `.Claude/settings.json` on APFS and an NFD-encoded `.git`
40
+ // both walk straight through an ASCII comparison, and both resolve to the
41
+ // dangerous file on disk.
42
+ import { promises as fs } from "node:fs";
43
+ import { resolve, sep, join, dirname, basename } from "node:path";
44
+ // ── ignore rules, ported verbatim from odin_notebook_writeback.rs:86-119 ─────
45
+ //
46
+ // Each entry was learned from an incident; this is not a tidy-up list.
47
+ const IGNORE_SEGMENTS = new Set([
48
+ ".git",
49
+ "node_modules",
50
+ "__pycache__",
51
+ ".ipynb_checkpoints",
52
+ ".venv",
53
+ ".mypy_cache",
54
+ ".pytest_cache",
55
+ ".cache",
56
+ ]);
57
+ /**
58
+ * Editor scratch names (vim/emacs/gedit write-temp + backup dances).
59
+ *
60
+ * Ignoring them keeps junk rows out AND makes the backup-rename dance degrade to
61
+ * a plain upsert of the real file. `4913` is vim's write-probe: it creates and
62
+ * deletes that exact filename to test writability, and a sync that treats it as
63
+ * user content produces a create/delete pair on every single save.
64
+ */
65
+ export function isEditorTemp(base) {
66
+ return (base.endsWith("~") ||
67
+ base.endsWith(".swp") ||
68
+ base.endsWith(".swo") ||
69
+ base.startsWith(".#") ||
70
+ (base.startsWith("#") && base.endsWith("#")) ||
71
+ base === "4913" ||
72
+ base.startsWith(".goutputstream-"));
73
+ }
74
+ /**
75
+ * The conflict sidecar suffix.
76
+ *
77
+ * ⚠ MUST STAY IN STEP WITH `diff.ts::conflictSidecarPath`, which builds the
78
+ * name. It is not imported from there because `paths.ts` is the lower layer and
79
+ * a cycle between the two files that carry the risk is worse than a coupling
80
+ * a test can pin — `paths.test.mjs` asserts the round trip
81
+ * (`isConflictSidecar(conflictSidecarPath(x))`) for a spread of names, so a
82
+ * change to either one that does not change the other fails immediately.
83
+ */
84
+ const CONFLICT_MARKER = ".server";
85
+ /**
86
+ * Is this one of the server-copy files a conflict leaves behind?
87
+ *
88
+ * ⚠ FOUND BY THE SOAK, and it was a compounding bug: the sidecar is written
89
+ * INSIDE the project, so the next sweep walked it, pushed it, and created a
90
+ * `strategy.server.py` row on the server. From there it spread — the row came
91
+ * back to every other machine, the local sidecar was rewritten each time the
92
+ * conflict was re-evaluated (making it diverge from its own pushed copy), and a
93
+ * conflict on the sidecar would have produced `strategy.server.server.py`.
94
+ *
95
+ * The cost is that a user's genuine `api.server.py` is not synced. That is
96
+ * accepted rather than hidden: `up.ts` already writes `*.server.*` into the
97
+ * project `.gitignore`, so the naming was committed to before this.
98
+ */
99
+ export function isConflictSidecar(rel) {
100
+ const base = rel.split("/").pop() ?? rel;
101
+ // ⚠ TWO SHAPES, because `conflictSidecarPath` produces two.
102
+ //
103
+ // with an extension: strategy.py -> strategy.server.py (infixed)
104
+ // without one: no-extension -> no-extension.server (appended)
105
+ // leading dot: .env -> .env.server (appended —
106
+ // a leading dot is the whole name, not an extension)
107
+ //
108
+ // The first version checked only the infixed shape, and the round-trip test
109
+ // caught `no-extension.server` on its first run. That is the coupling this
110
+ // pair exists to keep honest.
111
+ if (base.endsWith(CONFLICT_MARKER))
112
+ return true;
113
+ const dot = base.lastIndexOf(".");
114
+ if (dot <= 0)
115
+ return false;
116
+ return base.slice(0, dot).endsWith(CONFLICT_MARKER);
117
+ }
118
+ export function shouldIgnore(rel) {
119
+ if (rel.split("/").some((seg) => IGNORE_SEGMENTS.has(seg)))
120
+ return true;
121
+ const base = rel.split("/").pop() ?? rel;
122
+ // Our own write-temp files. A crashed pull can leave one behind, and syncing
123
+ // it would create a junk server row named after a pid and a timestamp.
124
+ if (base.startsWith(".symbols-tmp-"))
125
+ return true;
126
+ if (isConflictSidecar(rel))
127
+ return true;
128
+ return isEditorTemp(base);
129
+ }
130
+ // ── files this CLI (or Claude Code) OWNS, in both directions ─────────────────
131
+ /**
132
+ * Paths that are never synced either way, because something local writes them.
133
+ *
134
+ * ⚠ THIS IS A DIFFERENT QUESTION FROM `isWritableTarget`, and conflating them
135
+ * was a real bug: an earlier version ran the S2 deny-list over the whole path
136
+ * universe, so `<root>/.symbols/project.json` — a file this CLI had just written
137
+ * itself — was reported as a quarantined config-bearing path on EVERY sweep of
138
+ * EVERY project. A permanent frozen entry that is always there is worse than no
139
+ * report at all: it trains the user to stop reading `symbols status`, which is
140
+ * the one surface a real conflict shows up on.
141
+ *
142
+ * The two questions:
143
+ *
144
+ * `isCliManaged` "is this ours, so neither side should sync it?"
145
+ * -> excluded from the diff entirely
146
+ * `isWritableTarget` "may SERVER-SUPPLIED content be written here?"
147
+ * -> the S2 security backstop, enforced at the writer
148
+ *
149
+ * Both still apply to `.claude/**` and `.symbols/**`, which is defence in depth,
150
+ * not duplication: the first stops us fighting our own files, the second stops a
151
+ * compromised server planting one.
152
+ *
153
+ * ⚠ `CLAUDE.md` is here for a reason the plan names as an open gap: it is *also*
154
+ * a legal `project_files` row, and "which wins is undefined". Defined here — the
155
+ * LOCAL managed block wins, and a server row at that path is SURFACED as a frozen
156
+ * path rather than silently dropped, which is what the plan asks for.
157
+ */
158
+ export function isCliManaged(rel) {
159
+ const norm = normalizeForCompare(rel);
160
+ const segments = norm.split("/").filter(Boolean);
161
+ if (segments.some((s) => s === ".symbols" || s === ".claude"))
162
+ return true;
163
+ const base = segments[segments.length - 1] ?? "";
164
+ return base === "claude.md" || base === ".mcp.json";
165
+ }
166
+ // ── S2: config-bearing paths ─────────────────────────────────────────────────
167
+ /**
168
+ * ⚠ THE DENY-LIST IS THE SECONDARY CONTROL, NOT THE PRIMARY ONE.
169
+ *
170
+ * A deny-list is unwinnable as an enumeration. An adversarial review named six
171
+ * paths an earlier draft missed — every one of them a file some tool EXECUTES
172
+ * when it appears inside a project root:
173
+ *
174
+ * `conftest.py` runs on pytest collection
175
+ * `sitecustomize.py` runs at interpreter start
176
+ * `*.pth` runs at interpreter start
177
+ * `.envrc` direnv auto-executes it on `cd`
178
+ * `.vscode/tasks.json` runs on open, depending on trust settings
179
+ * `Makefile` one `make` away
180
+ * `package.json` `scripts` run on install/test
181
+ * `CLAUDE.md` ANYWHERE Claude Code reads it in every directory, not just root
182
+ *
183
+ * Naming five invites the sixth to be the incident. So the posture inverts: the
184
+ * primary control is `isWritableTarget` below, an ALLOW-LIST that quarantines
185
+ * anything not plainly inert. This list exists to give a precise reason for the
186
+ * most dangerous refusals, and as defence in depth if the allow-list is ever
187
+ * loosened.
188
+ */
189
+ const DENIED_EXACT = new Set([
190
+ ".mcp.json",
191
+ "claude.md",
192
+ ".envrc",
193
+ "conftest.py",
194
+ "sitecustomize.py",
195
+ "makefile",
196
+ "package.json",
197
+ ".gitattributes",
198
+ ".gitmodules",
199
+ ]);
200
+ const DENIED_PREFIX_SEGMENTS = [".claude", ".symbols", ".git", ".vscode", ".idea", ".config"];
201
+ const DENIED_SUFFIX = [".pth"];
202
+ /**
203
+ * Normalise a path for COMPARISON only — never for writing.
204
+ *
205
+ * Casefold + NFC, matching what a default macOS APFS volume does when it decides
206
+ * two names are the same file. Comparing raw bytes here is the F3 bug: the
207
+ * server's two distinct rows collapse to one file and the check never fires.
208
+ */
209
+ export function normalizeForCompare(rel) {
210
+ // ⚠ CASEFOLD, NOT `.toLowerCase()`. The difference is a security hole, and it
211
+ // was PROVEN on this project's own target filesystem:
212
+ //
213
+ // ".ſymbols".toLowerCase() === ".ſymbols" // U+017F LATIN SMALL LONG S
214
+ // APFS: writing `.ſymbols/x` OVERWRITES `.symbols/x`
215
+ //
216
+ // So a server-supplied path spelled with a long s walked straight through the
217
+ // S2 deny-list — `.symbols` was blocked, `.ſymbols` was not — and landed
218
+ // inside the CLI's OWN ledger directory. It also blinded the F3 collision
219
+ // detector, which is what stops two server rows overwriting one local file.
220
+ // Verified by writing both paths on this machine and reading the survivor.
221
+ //
222
+ // `.toLowerCase()` is a locale-ish lowercase, not Unicode case folding: it
223
+ // leaves ſ, fi and the Kelvin sign alone. JavaScript exposes no `toCasefold`,
224
+ // so NFKC does the compatibility work (ſ→s, fi→fi, K→K) and the lowercase
225
+ // does the rest; applied twice because NFKC can yield uppercase.
226
+ //
227
+ // NFKC groups MORE aggressively than APFS actually does. That is the correct
228
+ // direction for both consumers: this function only ever decides "deny" or
229
+ // "freeze as a collision", so over-grouping refuses more and never less.
230
+ // Under-grouping is what lets a write through.
231
+ return rel.normalize("NFKC").toLowerCase().normalize("NFKC").toLowerCase();
232
+ }
233
+ /**
234
+ * May server content be written to this project-relative path?
235
+ *
236
+ * ALLOW-LIST FIRST (the primary control), deny-list second (the precise reason).
237
+ */
238
+ export function isWritableTarget(rel) {
239
+ // Normalise BEFORE any comparison. See the ordering note at the top.
240
+ const norm = normalizeForCompare(rel);
241
+ const segments = norm.split("/").filter(Boolean);
242
+ if (segments.length === 0)
243
+ return { ok: false, reason: "empty path" };
244
+ for (const seg of segments) {
245
+ if (DENIED_PREFIX_SEGMENTS.includes(seg)) {
246
+ return {
247
+ ok: false,
248
+ reason: `'${seg}/' holds configuration a tool executes; server content is never written there`,
249
+ };
250
+ }
251
+ }
252
+ const base = segments[segments.length - 1] ?? "";
253
+ if (DENIED_EXACT.has(base)) {
254
+ return { ok: false, reason: `'${base}' is executed or read as configuration` };
255
+ }
256
+ if (DENIED_SUFFIX.some((s) => base.endsWith(s))) {
257
+ return { ok: false, reason: `'${base}' runs at Python interpreter start` };
258
+ }
259
+ // `CLAUDE.md` in ANY directory, not only the root — Claude Code reads the
260
+ // nearest one on every path. The exact-match table catches the basename, which
261
+ // is what makes this correct at any depth.
262
+ return { ok: true };
263
+ }
264
+ // ── class 1: escape ──────────────────────────────────────────────────────────
265
+ /** Windows reserved device names. Writing `CON.py` on Windows opens a device. */
266
+ const RESERVED = new Set([
267
+ "con", "prn", "aux", "nul",
268
+ "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
269
+ "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
270
+ ]);
271
+ /**
272
+ * Strip control characters from a server-supplied name.
273
+ *
274
+ * `safe_dirname` passes `\x1b` through, so a notebook named with an escape
275
+ * sequence becomes a directory that rewrites the user's terminal when listed —
276
+ * and worse, one whose displayed name differs from its real one.
277
+ */
278
+ export function stripControl(s) {
279
+ // eslint-disable-next-line no-control-regex
280
+ return s.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
281
+ }
282
+ /**
283
+ * Resolve a project-relative server path to an absolute local one, or refuse.
284
+ *
285
+ * Purely lexical — it does NOT touch the filesystem, so it is cheap enough to run
286
+ * on every path of a 5,000-file project. The filesystem checks (symlinked
287
+ * parents, `O_NOFOLLOW`) are `assertNoSymlinkedParents` and the writer's job.
288
+ */
289
+ export function resolveInRoot(root, rel) {
290
+ if (rel.includes("\0"))
291
+ return { ok: false, reason: "path contains NUL" };
292
+ if (rel !== stripControl(rel)) {
293
+ return { ok: false, reason: "path contains control characters" };
294
+ }
295
+ if (rel.startsWith("/") || /^[a-zA-Z]:/.test(rel)) {
296
+ return { ok: false, reason: "absolute paths are never accepted from the server" };
297
+ }
298
+ const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
299
+ if (segments.length === 0)
300
+ return { ok: false, reason: "empty path" };
301
+ for (const seg of segments) {
302
+ if (seg === "..")
303
+ return { ok: false, reason: "'..' is never accepted from the server" };
304
+ // Reserved names are refused on every platform, not just Windows: the row
305
+ // would otherwise sync fine on macOS and become unwritable the moment the
306
+ // same account is opened on a Windows machine.
307
+ const stem = (seg.split(".")[0] ?? "").toLowerCase();
308
+ if (RESERVED.has(stem)) {
309
+ return { ok: false, reason: `'${seg}' is a reserved device name` };
310
+ }
311
+ if (seg.endsWith(" ") || seg.endsWith(".")) {
312
+ return { ok: false, reason: `'${seg}' ends with a space or dot (unwritable on Windows)` };
313
+ }
314
+ }
315
+ const absRoot = resolve(root);
316
+ const abs = resolve(absRoot, segments.join(sep));
317
+ // The prefix assert, with the separator. Without the trailing `sep`,
318
+ // `/home/u/Symbols/ProjectEvil` passes a prefix test against
319
+ // `/home/u/Symbols/Project`.
320
+ if (abs !== absRoot && !abs.startsWith(absRoot + sep)) {
321
+ return { ok: false, reason: "resolves outside the project root" };
322
+ }
323
+ return { ok: true, abs };
324
+ }
325
+ /**
326
+ * Refuse if any parent component is a symlink.
327
+ *
328
+ * `resolveInRoot` is lexical, so it cannot see that `<root>/data` is a symlink to
329
+ * `/tmp`. `lstat` each component — NOT `stat`, which follows the link and reports
330
+ * the target, defeating the check entirely.
331
+ *
332
+ * Not TOCTOU-proof, and deliberately not: the only process positioned to win that
333
+ * race is the same-uid agent, which can already write anywhere directly. This
334
+ * defends against a symlink the SERVER caused to exist, or one an imported
335
+ * project shipped.
336
+ */
337
+ export async function assertNoSymlinkedParents(root, abs) {
338
+ const absRoot = resolve(root);
339
+ const rel = abs.slice(absRoot.length).split(sep).filter(Boolean);
340
+ let cursor = absRoot;
341
+ // Every component EXCEPT the final one — the final one is handled by the
342
+ // writer, which opens with O_NOFOLLOW and compares fstat to lstat.
343
+ for (const seg of rel.slice(0, -1)) {
344
+ cursor = join(cursor, seg);
345
+ try {
346
+ const st = await fs.lstat(cursor);
347
+ if (st.isSymbolicLink()) {
348
+ return { ok: false, reason: `'${cursor}' is a symlink; refusing to write through it` };
349
+ }
350
+ }
351
+ catch (err) {
352
+ // ENOENT is expected and fine — the directory has not been created yet.
353
+ if (err.code !== "ENOENT") {
354
+ return { ok: false, reason: `cannot stat '${cursor}': ${err.message}` };
355
+ }
356
+ // Nothing below a missing directory can be a symlink either.
357
+ return { ok: true };
358
+ }
359
+ }
360
+ return { ok: true };
361
+ }
362
+ /**
363
+ * Find server paths that would collide on a case- or normalization-insensitive
364
+ * filesystem.
365
+ *
366
+ * Run over the WHOLE project path set before writing anything. The caller must
367
+ * FREEZE every path in a returned group — never pick a winner. Picking one means
368
+ * the survivor's content is pushed back to the other row, silently replacing a
369
+ * file the user never touched, with no delete for the circuit breaker to catch.
370
+ */
371
+ export function findCollisions(paths) {
372
+ const byNorm = new Map();
373
+ for (const p of paths) {
374
+ const key = normalizeForCompare(p);
375
+ const list = byNorm.get(key);
376
+ if (list)
377
+ list.push(p);
378
+ else
379
+ byNorm.set(key, [p]);
380
+ }
381
+ const out = [];
382
+ for (const [normalized, group] of byNorm) {
383
+ // >1 DISTINCT path mapping to one key. The same path twice is a duplicate
384
+ // row, not a collision, and freezing on it would be a false positive.
385
+ const distinct = [...new Set(group)];
386
+ if (distinct.length > 1)
387
+ out.push({ normalized, paths: distinct.sort() });
388
+ }
389
+ return out.sort((a, b) => a.normalized.localeCompare(b.normalized));
390
+ }
391
+ // ── project directory naming ─────────────────────────────────────────────────
392
+ /**
393
+ * Port of `safe_dirname` (odin_notebook_writeback.rs:123-145) — **plus the
394
+ * control-character strip the original lacks**.
395
+ *
396
+ * ⚠ The dirname is computed SERVER-SIDE (`GET /api/cli/projects`) and this is the
397
+ * client's verification copy, not the authority. It exists so a client can detect
398
+ * a server that returns a dirname inconsistent with the name — which is what a
399
+ * path-injection attempt looks like from here.
400
+ */
401
+ export function safeDirname(name, fallback) {
402
+ const replaced = stripControl(name).replace(/[/\\]/g, "-");
403
+ const collapsed = replaced.replace(/\s+/g, " ");
404
+ const trimmed = collapsed.replace(/^[. ]+/, "").replace(/[. ]+$/, "");
405
+ const truncated = [...trimmed].slice(0, 120).join("");
406
+ return truncated.length > 0 ? truncated : fallback;
407
+ }
408
+ /**
409
+ * The `-2`/`-3` dedupe, which lives SEPARATELY from `safe_dirname` in the server
410
+ * (`materialize_all:1371-1382`) and is easy to miss when porting.
411
+ *
412
+ * ⚠ MIRRORS THE SERVER EXACTLY, INCLUDING TWO THINGS THAT LOOK LIKE BUGS:
413
+ *
414
+ * 1. **The comparison is case-SENSITIVE and un-normalized** — the server holds
415
+ * `used` as a plain `HashSet<String>` and tests `used.contains(&nm)`. So `A`
416
+ * and `a` are NOT deduped: both keep their own name, and they then collide on
417
+ * a case-insensitive filesystem. That is correct to reproduce, because the
418
+ * dirname must match what the server computed. The collision is F3's problem
419
+ * and `findCollisions` is what catches it — using normalized keys HERE would
420
+ * hide it while silently disagreeing with the server about the directory name.
421
+ * An earlier draft did exactly that.
422
+ *
423
+ * 2. **The suffix is probed, not counted.** The server loops
424
+ * `while used.contains(nm) { nm = base-i; i += 1 }`, so an existing literal
425
+ * `X-2` is skipped over. A per-name counter (the obvious implementation)
426
+ * returns `X-2` for the second `X` and collides with the real `X-2`.
427
+ * Case: `["X", "X-2", "X"]` -> server `["X", "X-2", "X-3"]`; a counter gives
428
+ * `["X", "X-2", "X-2"]`.
429
+ *
430
+ * ⚠ The server iterates `ORDER BY created_at, id`, so DELETING ONE NOTEBOOK
431
+ * RENAMES ITS SAME-NAMED SIBLINGS' DIRECTORIES. Not a bug to fix here — it is
432
+ * behaviour doctor must expect and the conformance matrix must cover.
433
+ */
434
+ export function dedupeDirnames(names) {
435
+ const used = new Set();
436
+ return names.map((base) => {
437
+ let nm = base;
438
+ let i = 2;
439
+ while (used.has(nm)) {
440
+ nm = `${base}-${i}`;
441
+ i += 1;
442
+ }
443
+ used.add(nm);
444
+ return nm;
445
+ });
446
+ }
447
+ export { basename, dirname };
@@ -0,0 +1,108 @@
1
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+ //
3
+ // This source code is proprietary and confidential. Unauthorized copying,
4
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
5
+ // Regime- and widget-backed files, marked read-only BEFORE the agent touches
6
+ // them.
7
+ //
8
+ // ## Why this is pre-emptive rather than reactive
9
+ //
10
+ // The server already refuses: `DELETE /api/notebooks/files/{id}` and a renaming
11
+ // PATCH both 409 when the file backs a live regime or widget
12
+ // (`routes/project_files.rs:144-151`, `:522-523`). So a purely reactive client
13
+ // is *safe* — it just eats the 409 and restores the file.
14
+ //
15
+ // It is still the wrong shape, for one reason the plan states directly: the
16
+ // agent should learn **why** before deleting, not after. A 409 arrives as an
17
+ // opaque failure in the middle of a sweep; a `0o444` file and a named line in
18
+ // `CLAUDE.md` arrive before the agent has formed the intent. The difference
19
+ // matters most on the case this exists for — a strategy file backing a LIVE
20
+ // regime, where "the delete failed" and "the delete was prevented" read the same
21
+ // in a transcript but only one of them tells the agent to stop.
22
+ //
23
+ // ## The read-only mark is a SPEED BUMP, never a control
24
+ //
25
+ // `chmod 0444` stops nothing: the agent runs as the same uid and can `chmod +w`
26
+ // in one command. It is a signal to a well-behaved tool (editors refuse, `rm`
27
+ // prompts), and it must never be described as containment — the same discipline
28
+ // the plan applies to `symbols curl`'s flag denylist.
29
+ //
30
+ // The ACTUAL protection is, in order:
31
+ // 1. the server's 409 (authoritative, and the only one an attacker cannot skip)
32
+ // 2. the local restore in `reconcile` when that 409 arrives
33
+ // 3. this mark, and the `CLAUDE.md` line naming it
34
+ import { promises as fs } from "node:fs";
35
+ import { artifacts } from "./api.js";
36
+ /**
37
+ * Fetch the protected set for a notebook.
38
+ *
39
+ * ⚠ A FAILURE HERE MUST NOT BE READ AS "NOTHING IS PROTECTED". Returning an
40
+ * empty map on error would silently downgrade every protected file to writable
41
+ * on any transient blip — the same "a failed read is not an affirmative absence"
42
+ * rule that governs deletes (`settle_vanish:513-519`). So this throws, and the
43
+ * caller decides; `reconcile` treats it as a reason to keep the previous marks.
44
+ */
45
+ export async function fetchProtected(notebookId) {
46
+ const raw = await artifacts(notebookId);
47
+ // The route wraps the map: `{"artifacts": {path: [...]}}`
48
+ // (`routes/project_files.rs:719`). Tolerate both shapes rather than guessing,
49
+ // because guessing wrong yields an empty set, which is the unsafe direction.
50
+ const map = raw && typeof raw === "object" && "artifacts" in raw
51
+ ? (raw.artifacts ?? {})
52
+ : raw;
53
+ const out = [];
54
+ for (const [path, entries] of Object.entries(map ?? {})) {
55
+ for (const e of entries ?? []) {
56
+ // The server's keys are raw `source_file_path` values, which carry the
57
+ // canonical leading slash. Normalise to the project-relative form the rest
58
+ // of the engine uses, or nothing will ever match.
59
+ const rel = path.replace(/^\/+/, "");
60
+ out.push({
61
+ path: rel,
62
+ kind: e.type,
63
+ name: e.name,
64
+ // ⚠ Widgets have NO `status` key (`routes/project_files.rs:689-692`,
65
+ // `:711-714`) while regimes do (`:667-671`). Under
66
+ // `exactOptionalPropertyTypes` an explicit `undefined` is not the same as
67
+ // an absent key, so it is spread conditionally rather than assigned.
68
+ ...(e.status === undefined ? {} : { status: e.status }),
69
+ });
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+ /**
75
+ * Apply (or lift) the read-only mark.
76
+ *
77
+ * Never throws for a missing file: a protected row whose local copy has not been
78
+ * materialized yet is normal on a first sync, and failing the whole sweep for it
79
+ * would make the first run of a project with one live regime fail entirely.
80
+ */
81
+ export async function applyReadOnly(abs, readOnly) {
82
+ try {
83
+ const st = await fs.stat(abs);
84
+ if (!st.isFile())
85
+ return;
86
+ // Preserve the group/other bits the user has; only the write bits move.
87
+ const mode = readOnly ? st.mode & ~0o222 : st.mode | 0o200;
88
+ if (mode !== st.mode)
89
+ await fs.chmod(abs, mode);
90
+ }
91
+ catch (err) {
92
+ if (err.code === "ENOENT")
93
+ return;
94
+ throw err;
95
+ }
96
+ }
97
+ /**
98
+ * The paths to name in `CLAUDE.md`, sorted and de-duplicated.
99
+ *
100
+ * ⚠ THERE IS DELIBERATELY NO SECOND RENDERER HERE. An earlier draft of this file
101
+ * formatted its own markdown section, which would have been a second copy of a
102
+ * decision `skills/claude-md.ts::renderManagedBody` already makes — and the
103
+ * managed block has exactly one writer for the same reason `diff.ts` does. This
104
+ * returns data; that file decides how it reads.
105
+ */
106
+ export function protectedPaths(entries) {
107
+ return [...new Set(entries.map((e) => e.path))].sort();
108
+ }