@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,441 @@
|
|
|
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
|
+
// S1 — fetch, VERIFY, and atomically install the skills bundle.
|
|
6
|
+
//
|
|
7
|
+
// ## Why this file is a security boundary and not a downloader
|
|
8
|
+
//
|
|
9
|
+
// Skills are instructions the agent executes. They are delivered to every
|
|
10
|
+
// laptop, persisted, and auto-refreshed. Server-side, one compromise hurt one
|
|
11
|
+
// sandboxed container; client-side it is persistent code execution in every
|
|
12
|
+
// user's home directory, simultaneously. The blast radius inverts, and deleting
|
|
13
|
+
// gVisor is only acceptable if Symbols adds no attack surface beyond vanilla
|
|
14
|
+
// Claude Code. An unsigned auto-updating instruction channel is exactly such
|
|
15
|
+
// surface.
|
|
16
|
+
//
|
|
17
|
+
// Four controls, and the difference between them matters:
|
|
18
|
+
//
|
|
19
|
+
// 1. SIGNATURE (authenticity). Detached ed25519 over the manifest's exact
|
|
20
|
+
// bytes, against keys PINNED IN THIS FILE. This is the only control that
|
|
21
|
+
// survives a compromised API host.
|
|
22
|
+
// 2. STAMP (integrity of what lands on disk). The signed manifest names the
|
|
23
|
+
// bundle's content hash; we recompute it from the EXTRACTED TREE and refuse
|
|
24
|
+
// on mismatch. Verifying after extraction means a bug in our own tar reader
|
|
25
|
+
// is caught too — verifying the archive's bytes would not catch that.
|
|
26
|
+
// 3. SEQUENCE (freshness). A signature proves a bundle was ours, not that it
|
|
27
|
+
// is current. Without a monotonic floor, a correctly-signed OLD bundle
|
|
28
|
+
// replays a fixed skill defect back onto every laptop.
|
|
29
|
+
// 4. sha256 of the tarball (TRANSPORT only). Catches truncation and a broken
|
|
30
|
+
// proxy. ⚠ It is NOT authenticity: a compromised server serves a matching
|
|
31
|
+
// hash for a malicious tarball trivially. Never describe it as more.
|
|
32
|
+
//
|
|
33
|
+
// ## Why raw ed25519 rather than minisign
|
|
34
|
+
//
|
|
35
|
+
// minisign's file format is ed25519 plus a framing convention. The framing buys
|
|
36
|
+
// interoperability with the `minisign` tool — which would become a NEW runtime
|
|
37
|
+
// prerequisite on every user's machine, against a plan whose distribution
|
|
38
|
+
// section fought to keep the prerequisite list at "Node >= 20". Node's `crypto`
|
|
39
|
+
// speaks ed25519 natively, so the raw form needs no dependency on either side,
|
|
40
|
+
// and both ends are ours. We keep the one property the framing exists for by
|
|
41
|
+
// signing the manifest's EXACT SERVED BYTES: no canonical-JSON step, so there is
|
|
42
|
+
// no canonicalisation bug to have.
|
|
43
|
+
import { promises as fs } from "node:fs";
|
|
44
|
+
import { createHash, createPublicKey, verify as edVerify } from "node:crypto";
|
|
45
|
+
import { gunzipSync } from "node:zlib";
|
|
46
|
+
import { join, dirname, relative, sep } from "node:path";
|
|
47
|
+
import { randomBytes } from "node:crypto";
|
|
48
|
+
import { requestBytes } from "../auth/client.js";
|
|
49
|
+
import { readBundleSequence, recordBundleSequence } from "../auth/credentials.js";
|
|
50
|
+
import { resolveInRoot } from "../sync/paths.js";
|
|
51
|
+
import { bundleRoot, installedManifestPath, symbolsHome } from "../util/platform.js";
|
|
52
|
+
/**
|
|
53
|
+
* ⚠ TWO KEYS: CURRENT AND NEXT. Shipping one means a key compromise bricks every
|
|
54
|
+
* installed client at once — they would all refuse the re-signed bundle, and the
|
|
55
|
+
* fix would require every user to upgrade the CLI before they could get skills
|
|
56
|
+
* again. With a next key already pinned, rotation is a re-publish.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ AN EMPTY ARRAY REFUSES EVERY BUNDLE, and that was the correct state until
|
|
59
|
+
* the keys existed. It is no longer empty — but the rule that produced it still
|
|
60
|
+
* holds: **the private halves have never been in this tree and must never be.**
|
|
61
|
+
* These were generated ON THE REPO ADMIN'S MACHINE by
|
|
62
|
+
* `scripts/admin_provision_signing.sh`, and the private halves have never been
|
|
63
|
+
* anywhere else — not on a developer laptop, not in a chat, not in this repo.
|
|
64
|
+
* That is the point of the script: only the PUBLIC halves below travelled.
|
|
65
|
+
*
|
|
66
|
+
* The private key lives in CI as `SYMBOLS_BUNDLE_SIGNING_KEY`, gated behind the
|
|
67
|
+
* `plugin-signing` environment (`.github/workflows/plugin-bundle.yml:85`).
|
|
68
|
+
* Signing on the API host would be signed-by-the-thing-you-are-authenticating-
|
|
69
|
+
* against.
|
|
70
|
+
*
|
|
71
|
+
* What is below is the PUBLIC half only: 32 ed25519 bytes each. Publishing them
|
|
72
|
+
* is the point — a pinned public key is what lets a client refuse a bundle the
|
|
73
|
+
* server did not actually publish.
|
|
74
|
+
*
|
|
75
|
+
* ⚠ ROTATION IS A RE-PUBLISH, NOT AN UPGRADE, and that is why there are two.
|
|
76
|
+
* If `current` is ever compromised, re-sign with `next` and every installed CLI
|
|
77
|
+
* accepts it immediately, because it already pins both. With one key, a
|
|
78
|
+
* compromise would brick every client at once: they would all refuse the
|
|
79
|
+
* re-signed bundle, and the fix would require each user to upgrade the CLI
|
|
80
|
+
* before they could get skills again — during an incident.
|
|
81
|
+
*
|
|
82
|
+
* After rotating: mint a new `next`, pin it, and ship it BEFORE it is needed.
|
|
83
|
+
* A `next` that is only generated at rotation time is not a spare.
|
|
84
|
+
*
|
|
85
|
+
* To regenerate — the private half never touches this tree:
|
|
86
|
+
*
|
|
87
|
+
* openssl genpkey -algorithm ed25519 -out ~/.symbols-signing/bundle.pem
|
|
88
|
+
* chmod 600 ~/.symbols-signing/bundle.pem
|
|
89
|
+
* scripts/publish_plugin_bundle.sh --print-pubkey ~/.symbols-signing/bundle.pem
|
|
90
|
+
*/
|
|
91
|
+
export const PINNED_KEYS = [
|
|
92
|
+
// current — signs today's bundles
|
|
93
|
+
{ id: "29a7617f41749dbf", raw: "vpxVMHkQtIdGcR4s2WuGVcM4QNtUXx+R3INj0qUX1Is=" },
|
|
94
|
+
// next — the spare, pinned in advance so rotation needs no client upgrade
|
|
95
|
+
{ id: "862ff69db775ec27", raw: "rQvWnNXMSVMGW7K+EK4YZPm/tkmbLVkUYStuzQoj2ik=" },
|
|
96
|
+
];
|
|
97
|
+
/** DER SPKI prefix for ed25519 — 12 bytes, then the 32 raw key bytes. */
|
|
98
|
+
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
99
|
+
export class BundleVerificationError extends Error {
|
|
100
|
+
constructor(message) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.name = "BundleVerificationError";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const SCHEMA = "symbols.skills-bundle/1";
|
|
106
|
+
// ── signature ────────────────────────────────────────────────────────────────
|
|
107
|
+
function keyObject(raw) {
|
|
108
|
+
const bytes = Buffer.from(raw, "base64");
|
|
109
|
+
if (bytes.length !== 32) {
|
|
110
|
+
throw new BundleVerificationError(`pinned key is ${bytes.length} bytes, expected 32`);
|
|
111
|
+
}
|
|
112
|
+
return createPublicKey({
|
|
113
|
+
key: Buffer.concat([SPKI_ED25519_PREFIX, bytes]),
|
|
114
|
+
format: "der",
|
|
115
|
+
type: "spki",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Verify a manifest envelope and return the manifest it carries.
|
|
120
|
+
*
|
|
121
|
+
* `keys` is a parameter ONLY so the security property is unit-testable without
|
|
122
|
+
* shipping a private key — the same reason `curl.ts` exports `resolveTarget`.
|
|
123
|
+
* Production callers use `PINNED_KEYS` and there is no env var, no flag, and no
|
|
124
|
+
* config file that can change them: a bypass switch on this check would hand the
|
|
125
|
+
* exact capability back to the adversary the check exists for.
|
|
126
|
+
*/
|
|
127
|
+
export function verifyManifestEnvelope(envelope, keys = PINNED_KEYS) {
|
|
128
|
+
if (keys.length === 0) {
|
|
129
|
+
throw new BundleVerificationError("no bundle signing keys are pinned in this build, so no bundle can be trusted. " +
|
|
130
|
+
"Skills are instructions the agent executes; installing them unverified would make " +
|
|
131
|
+
"one server compromise persistent code execution here. See PINNED_KEYS in skills/bundle.ts.");
|
|
132
|
+
}
|
|
133
|
+
if (!envelope || typeof envelope.manifest !== "string" || typeof envelope.signature !== "string") {
|
|
134
|
+
throw new BundleVerificationError("manifest response is not a signed envelope");
|
|
135
|
+
}
|
|
136
|
+
const bytes = Buffer.from(envelope.manifest, "base64");
|
|
137
|
+
const sig = Buffer.from(envelope.signature, "base64");
|
|
138
|
+
if (bytes.length === 0)
|
|
139
|
+
throw new BundleVerificationError("manifest is empty");
|
|
140
|
+
if (sig.length !== 64) {
|
|
141
|
+
// A missing signature arrives as an empty string, which is the "unsigned
|
|
142
|
+
// bundle" case the plan requires be refused. Same branch, same refusal.
|
|
143
|
+
throw new BundleVerificationError(`signature is ${sig.length} bytes, expected 64 — the bundle is unsigned or the signature is malformed`);
|
|
144
|
+
}
|
|
145
|
+
// TRY EVERY PINNED KEY. `key_id` in the envelope is a hint, and trusting a
|
|
146
|
+
// hint to choose the verifying key would let an attacker point us at whichever
|
|
147
|
+
// key they had.
|
|
148
|
+
const accepted = keys.some((k) => {
|
|
149
|
+
try {
|
|
150
|
+
return edVerify(null, bytes, keyObject(k.raw), sig);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
if (!accepted) {
|
|
157
|
+
throw new BundleVerificationError("the bundle manifest is not signed by any key this CLI pins. Refusing it. " +
|
|
158
|
+
"Either the server is serving something we did not publish, or this CLI predates a key rotation " +
|
|
159
|
+
"(`npm i -g @symbols-cli/cli`).");
|
|
160
|
+
}
|
|
161
|
+
let manifest;
|
|
162
|
+
try {
|
|
163
|
+
manifest = JSON.parse(bytes.toString("utf8"));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new BundleVerificationError("the signed manifest is not valid JSON");
|
|
167
|
+
}
|
|
168
|
+
if (manifest.schema !== SCHEMA) {
|
|
169
|
+
throw new BundleVerificationError(`manifest schema '${manifest.schema}' is not '${SCHEMA}' — this CLI cannot read it`);
|
|
170
|
+
}
|
|
171
|
+
if (!Number.isInteger(manifest.sequence) || manifest.sequence < 1) {
|
|
172
|
+
throw new BundleVerificationError("manifest carries no usable sequence number");
|
|
173
|
+
}
|
|
174
|
+
if (!/^[0-9a-f]{64}$/.test(manifest.stamp)) {
|
|
175
|
+
throw new BundleVerificationError("manifest carries no usable content stamp");
|
|
176
|
+
}
|
|
177
|
+
if (!Array.isArray(manifest.plugins) || manifest.plugins.length === 0) {
|
|
178
|
+
throw new BundleVerificationError("manifest names no plugins");
|
|
179
|
+
}
|
|
180
|
+
return manifest;
|
|
181
|
+
}
|
|
182
|
+
// ── the stamp ────────────────────────────────────────────────────────────────
|
|
183
|
+
/**
|
|
184
|
+
* `stage_plugins.sh`'s content stamp, recomputed over an extracted tree.
|
|
185
|
+
*
|
|
186
|
+
* The shell is
|
|
187
|
+
* find . -type f ! -name .stamp ! -name .gitkeep -print0 | LC_ALL=C sort -z
|
|
188
|
+
* | xargs -0 sha256sum | sha256sum | cut -d' ' -f1
|
|
189
|
+
* so the digest is over `<hex> <./path>\n` lines in byte order.
|
|
190
|
+
*
|
|
191
|
+
* ⚠ `.signing/` is excluded because the signed manifest is placed in the bundle
|
|
192
|
+
* dir on the API host AFTER staging stamped it. `publish_plugin_bundle.sh`
|
|
193
|
+
* recomputes the stamp with this same exclusion and fails the build if it stops
|
|
194
|
+
* matching `.stamp`, so the two implementations cannot drift silently — the
|
|
195
|
+
* build breaks before any client refuses.
|
|
196
|
+
*/
|
|
197
|
+
export async function computeBundleStamp(root) {
|
|
198
|
+
const files = [];
|
|
199
|
+
const walk = async (dir) => {
|
|
200
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
const abs = join(dir, entry.name);
|
|
203
|
+
if (entry.isDirectory())
|
|
204
|
+
await walk(abs);
|
|
205
|
+
else if (entry.isFile())
|
|
206
|
+
files.push(abs);
|
|
207
|
+
// Symlinks, sockets and fifos are skipped, matching `find -type f`. The
|
|
208
|
+
// extractor refuses to create them in the first place.
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
await walk(root);
|
|
212
|
+
const rows = files
|
|
213
|
+
.map((abs) => ["./" + relative(root, abs).split(sep).join("/"), abs])
|
|
214
|
+
.filter(([rel]) => rel !== "./.stamp" && rel !== "./.gitkeep" && !rel.startsWith("./.signing/"))
|
|
215
|
+
// LC_ALL=C sort is byte order. localeCompare is NOT byte order.
|
|
216
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
217
|
+
const outer = createHash("sha256");
|
|
218
|
+
for (const [rel, abs] of rows) {
|
|
219
|
+
const hex = createHash("sha256").update(await fs.readFile(abs)).digest("hex");
|
|
220
|
+
outer.update(`${hex} ${rel}\n`);
|
|
221
|
+
}
|
|
222
|
+
return outer.digest("hex");
|
|
223
|
+
}
|
|
224
|
+
function octal(block, offset, length) {
|
|
225
|
+
const raw = block.subarray(offset, offset + length).toString("ascii").replace(/\0.*$/, "").trim();
|
|
226
|
+
if (raw === "")
|
|
227
|
+
return 0;
|
|
228
|
+
const n = Number.parseInt(raw, 8);
|
|
229
|
+
if (!Number.isFinite(n) || n < 0)
|
|
230
|
+
throw new BundleVerificationError("malformed tar header field");
|
|
231
|
+
return n;
|
|
232
|
+
}
|
|
233
|
+
function cstr(block, offset, length) {
|
|
234
|
+
const slice = block.subarray(offset, offset + length);
|
|
235
|
+
const end = slice.indexOf(0);
|
|
236
|
+
return slice.subarray(0, end === -1 ? slice.length : end).toString("utf8");
|
|
237
|
+
}
|
|
238
|
+
/** Every tar header carries its own checksum. A bad one is a corrupt archive. */
|
|
239
|
+
function checksumOk(header) {
|
|
240
|
+
const declared = octal(header, 148, 8);
|
|
241
|
+
let signed = 0;
|
|
242
|
+
for (let i = 0; i < 512; i += 1) {
|
|
243
|
+
// The checksum field itself is summed as spaces.
|
|
244
|
+
signed += i >= 148 && i < 156 ? 0x20 : header[i];
|
|
245
|
+
}
|
|
246
|
+
return signed === declared;
|
|
247
|
+
}
|
|
248
|
+
export function readTar(buf) {
|
|
249
|
+
const out = [];
|
|
250
|
+
let offset = 0;
|
|
251
|
+
let pendingLongName = null;
|
|
252
|
+
while (offset + 512 <= buf.length) {
|
|
253
|
+
const header = buf.subarray(offset, offset + 512);
|
|
254
|
+
if (header.every((b) => b === 0))
|
|
255
|
+
break; // the end-of-archive marker
|
|
256
|
+
if (!checksumOk(header)) {
|
|
257
|
+
throw new BundleVerificationError(`tar header checksum mismatch at offset ${offset}`);
|
|
258
|
+
}
|
|
259
|
+
const size = octal(header, 124, 12);
|
|
260
|
+
const type = String.fromCharCode(header[156]);
|
|
261
|
+
const dataStart = offset + 512;
|
|
262
|
+
const dataEnd = dataStart + size;
|
|
263
|
+
if (dataEnd > buf.length)
|
|
264
|
+
throw new BundleVerificationError("tar entry runs past end of archive");
|
|
265
|
+
const data = buf.subarray(dataStart, dataEnd);
|
|
266
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
267
|
+
// GNU long name: this entry's DATA is the next entry's path.
|
|
268
|
+
if (type === "L") {
|
|
269
|
+
pendingLongName = data.toString("utf8").replace(/\0+$/, "");
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// PAX extended header. Only `path=` matters to us; `g` is global and ignored.
|
|
273
|
+
if (type === "x" || type === "g") {
|
|
274
|
+
if (type === "x") {
|
|
275
|
+
const text = data.toString("utf8");
|
|
276
|
+
const m = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(text);
|
|
277
|
+
if (m)
|
|
278
|
+
pendingLongName = m[1];
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const prefix = cstr(header, 345, 155);
|
|
283
|
+
const name = cstr(header, 0, 100);
|
|
284
|
+
const path = pendingLongName ?? (prefix ? `${prefix}/${name}` : name);
|
|
285
|
+
pendingLongName = null;
|
|
286
|
+
if (type === "5") {
|
|
287
|
+
out.push({ path, kind: "dir", mode: octal(header, 100, 8), data: Buffer.alloc(0) });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (type === "0" || type === "\0") {
|
|
291
|
+
out.push({ path, kind: "file", mode: octal(header, 100, 8), data: Buffer.from(data) });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// ⚠ EVERYTHING ELSE IS REFUSED, LOUDLY. '1' and '2' are hard and symbolic
|
|
295
|
+
// links: a link named `.claude/settings.json` pointing at the user's real
|
|
296
|
+
// one would turn a bundle write into a config write, which is exactly the S2
|
|
297
|
+
// class. '3'/'4'/'6'/'7' are devices, fifos and contiguous files, none of
|
|
298
|
+
// which a skills bundle has any business containing.
|
|
299
|
+
throw new BundleVerificationError(`tar entry '${path}' has type '${type}'; only plain files and directories are accepted`);
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Extract into `dest`, refusing anything that could escape it.
|
|
305
|
+
*
|
|
306
|
+
* Path safety reuses `sync/paths.ts::resolveInRoot` — ONE implementation of the
|
|
307
|
+
* arbitrary-local-write rules, not a second copy that drifts. It rejects `..`,
|
|
308
|
+
* absolute paths, NUL, control characters, Windows reserved device names, and
|
|
309
|
+
* anything resolving outside the root.
|
|
310
|
+
*/
|
|
311
|
+
export async function extractTarGz(targz, dest) {
|
|
312
|
+
const entries = readTar(gunzipSync(targz));
|
|
313
|
+
if (entries.length === 0)
|
|
314
|
+
throw new BundleVerificationError("the bundle archive is empty");
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
// `append_dir_all(".", …)` produces "./" for the root itself.
|
|
317
|
+
const rel = entry.path.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
318
|
+
if (rel === "" || rel === ".")
|
|
319
|
+
continue;
|
|
320
|
+
const resolved = resolveInRoot(dest, rel);
|
|
321
|
+
if (!resolved.ok || !resolved.abs) {
|
|
322
|
+
throw new BundleVerificationError(`bundle entry '${entry.path}' refused: ${resolved.reason}`);
|
|
323
|
+
}
|
|
324
|
+
if (entry.kind === "dir") {
|
|
325
|
+
await fs.mkdir(resolved.abs, { recursive: true, mode: 0o755 });
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
await fs.mkdir(dirname(resolved.abs), { recursive: true, mode: 0o755 });
|
|
329
|
+
// The archived exec bit is preserved but nothing else is: an archive cannot
|
|
330
|
+
// ask for setuid, setgid, or a world-writable file here.
|
|
331
|
+
const mode = (entry.mode & 0o111) !== 0 ? 0o755 : 0o644;
|
|
332
|
+
await fs.writeFile(resolved.abs, entry.data, { mode });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const defaultTransport = {
|
|
336
|
+
async manifest() {
|
|
337
|
+
const { bytes } = await requestBytes("/api/cli/bundle/manifest");
|
|
338
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
339
|
+
},
|
|
340
|
+
async tarball() {
|
|
341
|
+
const { bytes } = await requestBytes("/api/cli/bundle.tgz");
|
|
342
|
+
return bytes;
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
/** The manifest of whatever is currently unpacked locally, if anything. */
|
|
346
|
+
export async function readInstalledManifest() {
|
|
347
|
+
try {
|
|
348
|
+
return JSON.parse(await fs.readFile(installedManifestPath(), "utf8"));
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Fetch, verify and install the bundle. Returns without writing when the local
|
|
356
|
+
* copy already carries the manifest's stamp.
|
|
357
|
+
*
|
|
358
|
+
* ORDER IS THE DESIGN:
|
|
359
|
+
* manifest -> signature -> ROLLBACK FLOOR -> download -> transport hash ->
|
|
360
|
+
* extract to a temp dir -> recompute the stamp -> ATOMIC RENAME -> raise floor
|
|
361
|
+
*
|
|
362
|
+
* The floor is checked BEFORE the download so a replayed old bundle costs
|
|
363
|
+
* nothing, and raised only AFTER the install lands so a crash mid-install cannot
|
|
364
|
+
* lock the machine out of the version it failed to install.
|
|
365
|
+
*/
|
|
366
|
+
export async function installBundle(opts = {}) {
|
|
367
|
+
const transport = opts.transport ?? defaultTransport;
|
|
368
|
+
// ONE fetch. An earlier draft fetched the envelope twice — once to verify and
|
|
369
|
+
// once for `tgz_sha256` — which would have let a server serve a signed
|
|
370
|
+
// manifest to the check and a different envelope to the download.
|
|
371
|
+
const envelope = await transport.manifest();
|
|
372
|
+
const manifest = verifyManifestEnvelope(envelope, opts.keys ?? PINNED_KEYS);
|
|
373
|
+
// 3. FRESHNESS. A signature says "we published this", never "this is current".
|
|
374
|
+
const floor = await readBundleSequence();
|
|
375
|
+
if (manifest.sequence < floor) {
|
|
376
|
+
throw new BundleVerificationError(`refusing a skills bundle at sequence ${manifest.sequence}; this machine has already ` +
|
|
377
|
+
`accepted ${floor}. A correctly-signed OLD bundle re-introduces every skill defect fixed since.`);
|
|
378
|
+
}
|
|
379
|
+
const root = bundleRoot();
|
|
380
|
+
const localStamp = await computeBundleStamp(root).catch(() => null);
|
|
381
|
+
if (localStamp === manifest.stamp) {
|
|
382
|
+
await recordBundleSequence(manifest.sequence);
|
|
383
|
+
await writeInstalledManifest(manifest);
|
|
384
|
+
return { manifest, root, changed: false };
|
|
385
|
+
}
|
|
386
|
+
const targz = await transport.tarball();
|
|
387
|
+
// 4. TRANSPORT ONLY. This catches a truncated download; it cannot catch a
|
|
388
|
+
// hostile one, because whoever served the bytes also served this hash.
|
|
389
|
+
if (envelope.tgz_sha256) {
|
|
390
|
+
const got = createHash("sha256").update(targz).digest("hex");
|
|
391
|
+
if (got !== envelope.tgz_sha256) {
|
|
392
|
+
throw new BundleVerificationError(`bundle download is corrupt: sha256 ${got} != advertised ${envelope.tgz_sha256}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Extract beside the destination, on the same filesystem, so the swap is a
|
|
396
|
+
// rename and not a copy.
|
|
397
|
+
await fs.mkdir(symbolsHome(), { recursive: true, mode: 0o700 });
|
|
398
|
+
const staging = `${root}.staging-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
399
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
400
|
+
await fs.mkdir(staging, { recursive: true, mode: 0o755 });
|
|
401
|
+
try {
|
|
402
|
+
await extractTarGz(targz, staging);
|
|
403
|
+
// 2. INTEGRITY OF WHAT LANDED. After extraction, deliberately.
|
|
404
|
+
const extracted = await computeBundleStamp(staging);
|
|
405
|
+
if (extracted !== manifest.stamp) {
|
|
406
|
+
throw new BundleVerificationError(`extracted bundle hashes to ${extracted}, but the signed manifest says ${manifest.stamp}`);
|
|
407
|
+
}
|
|
408
|
+
// ── the atomic rename, mirroring odin_skill_sync.rs:220-237 ──────────────
|
|
409
|
+
// A truncated SKILL.md is worse than a stale one, because stale still
|
|
410
|
+
// parses: the agent reads it, believes it, and acts on instructions that are
|
|
411
|
+
// merely out of date rather than obviously broken.
|
|
412
|
+
const old = `${root}.old`;
|
|
413
|
+
await fs.rm(old, { recursive: true, force: true });
|
|
414
|
+
if (await exists(root))
|
|
415
|
+
await fs.rename(root, old);
|
|
416
|
+
await fs.rename(staging, root);
|
|
417
|
+
await fs.rm(old, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
421
|
+
}
|
|
422
|
+
await writeInstalledManifest(manifest);
|
|
423
|
+
await recordBundleSequence(manifest.sequence);
|
|
424
|
+
return { manifest, root, changed: true };
|
|
425
|
+
}
|
|
426
|
+
async function writeInstalledManifest(manifest) {
|
|
427
|
+
const path = installedManifestPath();
|
|
428
|
+
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
429
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
430
|
+
await fs.writeFile(tmp, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o644 });
|
|
431
|
+
await fs.rename(tmp, path);
|
|
432
|
+
}
|
|
433
|
+
async function exists(path) {
|
|
434
|
+
try {
|
|
435
|
+
await fs.stat(path);
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
// `<project>/CLAUDE.md` — write ONLY between the managed markers.
|
|
6
|
+
//
|
|
7
|
+
// ## Why markers and not a file
|
|
8
|
+
//
|
|
9
|
+
// `CLAUDE.md` is the single most valuable file in a project directory: it is
|
|
10
|
+
// where the user records the things they had to learn the hard way. It is also a
|
|
11
|
+
// file Claude Code reads in EVERY directory, so it is the most attractive write
|
|
12
|
+
// target in the tree. Two rules follow, and they point the same way:
|
|
13
|
+
//
|
|
14
|
+
// * we own a marked region and nothing else, so a `symbols up` can never eat a
|
|
15
|
+
// paragraph the user wrote;
|
|
16
|
+
// * server content NEVER lands here at all (S2). `CLAUDE.md` is on
|
|
17
|
+
// `sync/paths.ts`'s deny-list by basename, at any depth. What this file
|
|
18
|
+
// writes is generated locally from the manifest — not fetched, not synced.
|
|
19
|
+
//
|
|
20
|
+
// ## Freeze on ambiguity, never guess
|
|
21
|
+
//
|
|
22
|
+
// Malformed markers — an end before a begin, two begins, a begin with no end —
|
|
23
|
+
// are refused rather than repaired. "Repair" means choosing which of the user's
|
|
24
|
+
// bytes are ours, and the only safe answer to that question is to stop and say
|
|
25
|
+
// so. This is the same posture the sync engine takes on a path collision.
|
|
26
|
+
import { promises as fs } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
export const BEGIN = "<!-- symbols:begin managed -->";
|
|
29
|
+
export const END = "<!-- symbols:end managed -->";
|
|
30
|
+
export function projectClaudeMdPath(projectDir) {
|
|
31
|
+
return join(projectDir, "CLAUDE.md");
|
|
32
|
+
}
|
|
33
|
+
export class MarkerError extends Error {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "MarkerError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Replace the managed region of `text`, appending the block if it is absent.
|
|
41
|
+
*
|
|
42
|
+
* Pure and exported so the property — everything outside the markers survives
|
|
43
|
+
* byte for byte — is testable without a filesystem.
|
|
44
|
+
*/
|
|
45
|
+
export function applyManagedBlock(text, body) {
|
|
46
|
+
const begins = countOccurrences(text, BEGIN);
|
|
47
|
+
const ends = countOccurrences(text, END);
|
|
48
|
+
if (begins > 1 || ends > 1) {
|
|
49
|
+
throw new MarkerError(`CLAUDE.md contains ${begins} begin and ${ends} end markers. Refusing to guess which region is ours.`);
|
|
50
|
+
}
|
|
51
|
+
if (begins !== ends) {
|
|
52
|
+
throw new MarkerError(`CLAUDE.md has an unmatched symbols marker (${begins} begin, ${ends} end). ` +
|
|
53
|
+
`Fix or remove the stray marker and re-run.`);
|
|
54
|
+
}
|
|
55
|
+
const block = `${BEGIN}\n${body.replace(/\s+$/, "")}\n${END}`;
|
|
56
|
+
if (begins === 0) {
|
|
57
|
+
const separator = text.length === 0 ? "" : text.endsWith("\n\n") ? "" : text.endsWith("\n") ? "\n" : "\n\n";
|
|
58
|
+
return `${text}${separator}${block}\n`;
|
|
59
|
+
}
|
|
60
|
+
const start = text.indexOf(BEGIN);
|
|
61
|
+
const stop = text.indexOf(END);
|
|
62
|
+
if (stop < start) {
|
|
63
|
+
throw new MarkerError("CLAUDE.md's symbols end marker precedes its begin marker.");
|
|
64
|
+
}
|
|
65
|
+
return text.slice(0, start) + block + text.slice(stop + END.length);
|
|
66
|
+
}
|
|
67
|
+
export async function writeManagedBlock(projectDir, body) {
|
|
68
|
+
const path = projectClaudeMdPath(projectDir);
|
|
69
|
+
let before = "";
|
|
70
|
+
let created = false;
|
|
71
|
+
try {
|
|
72
|
+
before = await fs.readFile(path, "utf8");
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err.code !== "ENOENT")
|
|
76
|
+
throw err;
|
|
77
|
+
created = true;
|
|
78
|
+
}
|
|
79
|
+
const after = applyManagedBlock(before, body);
|
|
80
|
+
if (after === before) {
|
|
81
|
+
return { path, created: false, changed: false, preservedBytes: outsideBytes(before) };
|
|
82
|
+
}
|
|
83
|
+
// Write-then-rename. A half-written CLAUDE.md is read by the agent on its very
|
|
84
|
+
// next turn, and a truncated instruction file is worse than a stale one.
|
|
85
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
86
|
+
await fs.writeFile(tmp, after, { mode: 0o644 });
|
|
87
|
+
await fs.rename(tmp, path);
|
|
88
|
+
return { path, created, changed: true, preservedBytes: outsideBytes(after) };
|
|
89
|
+
}
|
|
90
|
+
function countOccurrences(haystack, needle) {
|
|
91
|
+
let n = 0;
|
|
92
|
+
let from = 0;
|
|
93
|
+
for (;;) {
|
|
94
|
+
const at = haystack.indexOf(needle, from);
|
|
95
|
+
if (at === -1)
|
|
96
|
+
return n;
|
|
97
|
+
n += 1;
|
|
98
|
+
from = at + needle.length;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function outsideBytes(text) {
|
|
102
|
+
const start = text.indexOf(BEGIN);
|
|
103
|
+
const stop = text.indexOf(END);
|
|
104
|
+
if (start === -1 || stop === -1)
|
|
105
|
+
return Buffer.byteLength(text, "utf8");
|
|
106
|
+
return Buffer.byteLength(text.slice(0, start) + text.slice(stop + END.length), "utf8");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The managed body itself: what the agent is told about this project.
|
|
110
|
+
*
|
|
111
|
+
* Deliberately short and factual. Anything that needs to be long belongs in a
|
|
112
|
+
* SKILL, which is versioned, signed and reviewed; `CLAUDE.md` is loaded into
|
|
113
|
+
* every single turn and its cost is paid on all of them.
|
|
114
|
+
*/
|
|
115
|
+
export function renderManagedBody(opts) {
|
|
116
|
+
const lines = [];
|
|
117
|
+
lines.push("## Symbols");
|
|
118
|
+
lines.push("");
|
|
119
|
+
lines.push(`This directory is the Symbols project **${opts.projectName}** (notebook \`${opts.notebookId}\`).`);
|
|
120
|
+
lines.push("Files here sync with the Symbols app. Edits land in the app; edits in the app land here.");
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push("- `symbols status` — what is out of sync · `symbols sync` — reconcile now · `symbols doctor` — check the install");
|
|
123
|
+
lines.push("- Deleting a file here DELETES IT IN THE APP. There is no undo and no server-side copy afterwards.");
|
|
124
|
+
lines.push("- `.symbols/` is the CLI's own state. Do not edit it; changing `project.json` retargets sync at a different notebook.");
|
|
125
|
+
if (opts.protectedPaths && opts.protectedPaths.length > 0) {
|
|
126
|
+
lines.push("");
|
|
127
|
+
lines.push("**Regime-backed files — a deployed strategy reads these. Ask before changing or deleting one:**");
|
|
128
|
+
for (const p of opts.protectedPaths)
|
|
129
|
+
lines.push(`- \`${p}\``);
|
|
130
|
+
}
|
|
131
|
+
lines.push("");
|
|
132
|
+
lines.push(`Skills: ${opts.plugins.map((p) => `${p.name} ${p.version}`).join(" · ")}. ` +
|
|
133
|
+
"Managed by `symbols` — edit outside these markers only.");
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|