@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.
- package/LICENSE +8 -0
- package/README.md +103 -0
- package/dist/auth/client.js +531 -0
- package/dist/auth/credentials.js +293 -0
- package/dist/auth/hosts.js +85 -0
- package/dist/auth/loopback.js +108 -0
- package/dist/auth/pkce.js +33 -0
- package/dist/auth/wire.js +40 -0
- package/dist/commands/arm.js +154 -0
- package/dist/commands/curl.js +101 -0
- package/dist/commands/doctor.js +217 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logout.js +78 -0
- package/dist/commands/mcp.js +33 -0
- package/dist/commands/project.js +145 -0
- package/dist/commands/status.js +78 -0
- package/dist/commands/sync.js +94 -0
- package/dist/commands/uninstall.js +149 -0
- package/dist/commands/up.js +176 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/watch.js +155 -0
- package/dist/commands/whoami.js +103 -0
- package/dist/index.js +147 -0
- package/dist/mcp/scopes.js +215 -0
- package/dist/mcp/server.js +366 -0
- package/dist/mcp/tools.js +646 -0
- package/dist/skills/bundle.js +441 -0
- package/dist/skills/claude-md.js +135 -0
- package/dist/skills/install.js +188 -0
- package/dist/skills/settings-merge.js +107 -0
- package/dist/sync/api.js +380 -0
- package/dist/sync/diff.js +172 -0
- package/dist/sync/ledger.js +319 -0
- package/dist/sync/paths.js +447 -0
- package/dist/sync/protect.js +108 -0
- package/dist/sync/reconcile.js +870 -0
- package/dist/sync/watcher.js +206 -0
- package/dist/util/log.js +58 -0
- package/dist/util/platform.js +79 -0
- package/dist/util/version.js +24 -0
- 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
|
+
}
|