sealcode 1.3.5 → 1.4.0

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.
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * sealcode@1.3.6 — Known-projects registry + self-check.
5
+ *
6
+ * Problem this solves
7
+ * -------------------
8
+ * Today the watcher is the only thing that re-locks a recipient's
9
+ * working copy on revoke/pause/expiry. If the watcher dies and the
10
+ * recipient never runs another `sealcode` command in that project,
11
+ * the plaintext sits there indefinitely.
12
+ *
13
+ * We can't stop a determined user from `kill -9`-ing the watcher and
14
+ * never running sealcode again on that project — that's a fundamental
15
+ * limit of any client-side enforcement. But we can close the much
16
+ * more common path: the recipient uses sealcode in OTHER projects (or
17
+ * for other commands) on the same machine. Every one of those
18
+ * invocations is an opportunity to notice "hey, project X is sitting
19
+ * unlocked with no watcher" and re-lock it.
20
+ *
21
+ * The registry
22
+ * ------------
23
+ * One JSON file at ~/.sealcode/projects.json. Maintained by the CLI
24
+ * on every unlock (add) and every lock (mark locked). Format:
25
+ *
26
+ * {
27
+ * "/Users/alice/work/proj-a": {
28
+ * "addedAt": "2026-05-21T12:00:00.000Z",
29
+ * "lastSeenAt": "2026-05-21T12:34:56.000Z",
30
+ * "lastState": "unlocked" | "locked"
31
+ * },
32
+ * ...
33
+ * }
34
+ *
35
+ * Self-check (called from cli.js on EVERY invocation, including
36
+ * commands like `sealcode --help` and `sealcode whoami`):
37
+ * - Walk the registry.
38
+ * - For each entry whose lastState is "unlocked":
39
+ * - If the project no longer exists on disk, drop it.
40
+ * - If the watcher state file is missing or stale AND a session
41
+ * is cached, attempt a panic-lock in the background.
42
+ * - Print a single-line warning so the user sees it.
43
+ *
44
+ * The self-check is purely best-effort and never throws. It runs
45
+ * synchronously (cheap — just file stat-ing) and any async lock
46
+ * happens out-of-band so the user's command isn't delayed.
47
+ */
48
+
49
+ const fs = require('fs');
50
+ const os = require('os');
51
+ const path = require('path');
52
+
53
+ const REGISTRY_DIR = path.join(os.homedir(), '.sealcode');
54
+ const REGISTRY_FILE = path.join(REGISTRY_DIR, 'projects.json');
55
+
56
+ function ensureDir() {
57
+ try { fs.mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 }); } catch (_) { /* ignore */ }
58
+ }
59
+
60
+ function readRegistry() {
61
+ try {
62
+ const raw = fs.readFileSync(REGISTRY_FILE, 'utf8');
63
+ const obj = JSON.parse(raw);
64
+ if (obj && typeof obj === 'object') return obj;
65
+ } catch (_) { /* missing or corrupt — start fresh */ }
66
+ return {};
67
+ }
68
+
69
+ function writeRegistry(obj) {
70
+ ensureDir();
71
+ // Atomic-ish write: write to .tmp, rename. Avoids torn writes if
72
+ // two CLI invocations race.
73
+ const tmp = REGISTRY_FILE + '.' + process.pid + '.tmp';
74
+ try {
75
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
76
+ fs.renameSync(tmp, REGISTRY_FILE);
77
+ } catch (_) {
78
+ try { fs.unlinkSync(tmp); } catch (_) { /* ignore */ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Mark a project as currently unlocked. Called from runUnlock.
84
+ */
85
+ function markUnlocked(projectRoot) {
86
+ if (!projectRoot) return;
87
+ const reg = readRegistry();
88
+ const abs = path.resolve(projectRoot);
89
+ const now = new Date().toISOString();
90
+ reg[abs] = {
91
+ addedAt: reg[abs]?.addedAt || now,
92
+ lastSeenAt: now,
93
+ lastState: 'unlocked',
94
+ };
95
+ writeRegistry(reg);
96
+ }
97
+
98
+ /**
99
+ * Mark a project as currently locked. Called from runLock + finalLock
100
+ * + softLock paths.
101
+ */
102
+ function markLocked(projectRoot) {
103
+ if (!projectRoot) return;
104
+ const reg = readRegistry();
105
+ const abs = path.resolve(projectRoot);
106
+ const now = new Date().toISOString();
107
+ reg[abs] = {
108
+ addedAt: reg[abs]?.addedAt || now,
109
+ lastSeenAt: now,
110
+ lastState: 'locked',
111
+ };
112
+ writeRegistry(reg);
113
+ }
114
+
115
+ /**
116
+ * Remove a project from the registry (e.g. when it's deleted or the
117
+ * user explicitly unlinks).
118
+ */
119
+ function forget(projectRoot) {
120
+ if (!projectRoot) return;
121
+ const reg = readRegistry();
122
+ const abs = path.resolve(projectRoot);
123
+ if (reg[abs]) {
124
+ delete reg[abs];
125
+ writeRegistry(reg);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Return a list of currently-known projects.
131
+ */
132
+ function list() {
133
+ const reg = readRegistry();
134
+ return Object.entries(reg).map(([projectRoot, meta]) => ({
135
+ projectRoot,
136
+ ...meta,
137
+ }));
138
+ }
139
+
140
+ /**
141
+ * Return projects whose lastState is "unlocked" but:
142
+ * - the directory still exists, AND
143
+ * - either no watcher state file exists OR the watcher's pid is
144
+ * not alive OR the watch state hasn't been touched in > maxStaleMin.
145
+ *
146
+ * Synchronous, cheap. Used by selfCheck and by the supervisor.
147
+ */
148
+ function findOrphanedUnlocks({ maxStaleMin = 5 } = {}) {
149
+ const out = [];
150
+ const reg = readRegistry();
151
+ const cutoff = Date.now() - maxStaleMin * 60_000;
152
+
153
+ for (const [projectRoot, meta] of Object.entries(reg)) {
154
+ if (!meta || meta.lastState !== 'unlocked') continue;
155
+ try {
156
+ if (!fs.existsSync(projectRoot)) continue;
157
+ } catch (_) { continue; }
158
+
159
+ // Look up the watch state. We can't import cli-watch here (circular),
160
+ // so we replicate the path math.
161
+ const id = projectIdHash(projectRoot);
162
+ const watchFile = path.join(os.homedir(), '.sealcode', 'sessions', `${id}.watch.json`);
163
+ let watcherAlive = false;
164
+ try {
165
+ const st = JSON.parse(fs.readFileSync(watchFile, 'utf8'));
166
+ const mtimeMs = fs.statSync(watchFile).mtimeMs;
167
+ const pid = st && st.pid;
168
+ if (pid && mtimeMs > cutoff) {
169
+ try { process.kill(pid, 0); watcherAlive = true; } catch (_) { watcherAlive = false; }
170
+ }
171
+ } catch (_) { watcherAlive = false; }
172
+
173
+ if (!watcherAlive) {
174
+ out.push({ projectRoot, lastSeenAt: meta.lastSeenAt });
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /**
181
+ * Identical hash that keystore.projectId uses. Inlined here to avoid
182
+ * a circular dep between cli-registry and keystore (keystore is loaded
183
+ * very early on every CLI invocation).
184
+ */
185
+ function projectIdHash(projectRoot) {
186
+ const crypto = require('crypto');
187
+ return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16);
188
+ }
189
+
190
+ /**
191
+ * Synchronous self-check called at the top of every CLI invocation
192
+ * (from cli.js). NEVER throws. Returns the number of orphaned
193
+ * unlocked projects detected. Prints a one-line warning per project
194
+ * unless `quiet` is true.
195
+ *
196
+ * The actual re-lock is deferred to a background detached process so
197
+ * the user's command isn't blocked. We invoke `sealcode panic` in a
198
+ * detached child for each orphan.
199
+ *
200
+ * `quiet` is set for short-running commands (where, version, help)
201
+ * and for the panic command itself to avoid recursion.
202
+ */
203
+ function selfCheck({ quiet = false, skipBackgroundLock = false } = {}) {
204
+ let orphans;
205
+ try {
206
+ orphans = findOrphanedUnlocks();
207
+ } catch (_) { return 0; }
208
+
209
+ if (orphans.length === 0) return 0;
210
+
211
+ if (!quiet) {
212
+ try {
213
+ const ui = require('./ui');
214
+ for (const o of orphans) {
215
+ ui.warn(
216
+ `\u26a0 ${o.projectRoot} appears unlocked but its watcher is not running. `
217
+ + `Auto-locking in the background.`,
218
+ );
219
+ }
220
+ } catch (_) { /* ui module may not be loadable in some edge cases */ }
221
+ }
222
+
223
+ if (skipBackgroundLock) return orphans.length;
224
+
225
+ // Detached background panic-lock per orphan. We CANNOT just call
226
+ // runLock here — we don't have the session key. The `panic` command
227
+ // (and its lock pathway) is what knows how to find the cached
228
+ // session and re-encrypt. If there's no session cached, the panic
229
+ // command is a no-op + the registry gets corrected to "locked".
230
+ const { spawn } = require('child_process');
231
+ const node = process.execPath;
232
+ const cli = path.resolve(__dirname, '..', 'bin', 'sealcode.js');
233
+ for (const o of orphans) {
234
+ try {
235
+ const child = spawn(node, [cli, 'panic', '--project', o.projectRoot, '--from-selfcheck'], {
236
+ detached: true,
237
+ stdio: 'ignore',
238
+ windowsHide: true,
239
+ cwd: o.projectRoot,
240
+ });
241
+ child.unref();
242
+ } catch (_) { /* best-effort */ }
243
+ }
244
+
245
+ return orphans.length;
246
+ }
247
+
248
+ module.exports = {
249
+ markUnlocked,
250
+ markLocked,
251
+ forget,
252
+ list,
253
+ findOrphanedUnlocks,
254
+ selfCheck,
255
+ REGISTRY_FILE,
256
+ };
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `sealcode remove` — permanently remove sealcode from a project.
5
+ *
6
+ * This is the destructive sibling of `sealcode link --remove`. Where
7
+ * `link --remove` only forgets the dashboard association, `remove`
8
+ * tears the whole vault out:
9
+ *
10
+ * 1. Re-verify the passphrase. We do this even if a session is
11
+ * cached, because removal is irreversible and "the laptop was
12
+ * already unlocked" is a real attack. `--session-ok` overrides
13
+ * for power users.
14
+ * 2. Require a typed confirmation (`yes-remove`). No accidental
15
+ * `sealcode rem<TAB>` deletions.
16
+ * 3. If the project is linked to a sealcode.dev account, hit the
17
+ * server's remove-notice endpoint FIRST so the legitimate owner
18
+ * gets an email and an audit-log row before any local data is
19
+ * touched. If we can't reach the server we refuse by default;
20
+ * `--offline` is the explicit override.
21
+ * 4. Decrypt every locked blob into plaintext (so the user keeps
22
+ * their source). `--burn` skips this step and removes the
23
+ * ciphertext without ever materializing plaintext.
24
+ * 5. Delete the locked dir, the sealcode config file (link block
25
+ * included), and the session cache for this project root.
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const ui = require('./ui');
32
+ const { hidden, question } = require('./prompt');
33
+ const { ApiError, request, clientInfo } = require('./api');
34
+ const { SealcodeError } = require('./errors');
35
+ const { isInitialized, unwrap, clearSession } = require('./keystore');
36
+ const { runUnlock } = require('./open');
37
+ const { readManifest } = require('./manifest');
38
+ const { getLink, clearLink } = require('./link-state');
39
+ const { CONFIG_FILE, LEGACY_CONFIG_FILE } = require('./config');
40
+
41
+ function readActiveConfig(projectRoot) {
42
+ // Mirror getActiveConfig from cli.js without the dependency cycle.
43
+ for (const name of [CONFIG_FILE, LEGACY_CONFIG_FILE]) {
44
+ const p = path.join(projectRoot, name);
45
+ if (fs.existsSync(p)) {
46
+ try {
47
+ const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
48
+ if (cfg && typeof cfg.lockedDir === 'string') return { ...cfg, _file: name };
49
+ } catch (_) { /* fallthrough */ }
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function passphraseFromEnv() {
56
+ return process.env.SEALCODE_PASSPHRASE || process.env.VAULTLINE_PASSPHRASE || '';
57
+ }
58
+
59
+ async function verifyPassphrase(projectRoot, lockedDir) {
60
+ // Prefer the env var so CI / scripted teardown isn't blocked on a
61
+ // TTY. Otherwise prompt — `remove` is interactive by default.
62
+ const pp = passphraseFromEnv() || (await hidden('Passphrase (to confirm ownership):'));
63
+ if (!pp) throw new SealcodeError('SEALCODE_NO_KEY');
64
+ try {
65
+ return await unwrap(projectRoot, lockedDir, { passphrase: pp });
66
+ } catch (err) {
67
+ if (err && err.message === 'SEALCODE_WRONG_KEY') {
68
+ throw new SealcodeError('SEALCODE_WRONG_KEY');
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Send the remove-notice to the server. Returns one of:
76
+ * { status: 'sent' } — server accepted, owner email queued.
77
+ * { status: 'offline'} — could not reach the server (network error).
78
+ * { status: 'rejected', code, message } — server returned 4xx/5xx.
79
+ *
80
+ * We never throw from here so the caller can centralize policy.
81
+ */
82
+ async function tryNotifyServer({ projectId, localFingerprint, burn }) {
83
+ try {
84
+ await request('POST', `/api/v1/projects/${encodeURIComponent(projectId)}/remove-notice`, {
85
+ auth: true,
86
+ body: {
87
+ localFingerprint,
88
+ burn: !!burn,
89
+ // Best-effort device context for the owner's email. Nothing
90
+ // here is used for an access decision; we just want the
91
+ // notification to be useful ("My laptop did this — fine" vs
92
+ // "what's that hostname?").
93
+ device: clientInfo(),
94
+ occurredAt: new Date().toISOString(),
95
+ },
96
+ // Keep the timeout short — a slow / dead server should not let
97
+ // a hostile actor stall the alert until they're ready.
98
+ timeoutMs: 10000,
99
+ });
100
+ return { status: 'sent' };
101
+ } catch (err) {
102
+ if (err instanceof ApiError && err.status === 0) {
103
+ return { status: 'offline', message: err.message };
104
+ }
105
+ if (err instanceof ApiError) {
106
+ return { status: 'rejected', code: err.apiCode, message: err.message };
107
+ }
108
+ return { status: 'offline', message: err && err.message ? err.message : String(err) };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Removes the locked dir, the config file, and any cached session for
114
+ * this project root. Idempotent — safe to re-run after a partial
115
+ * failure to clean up leftover bits. Returns a list of removed paths
116
+ * for the human-readable summary.
117
+ */
118
+ function destroyVaultArtifacts(projectRoot, lockedDir) {
119
+ const removed = [];
120
+ const tryRemove = (rel) => {
121
+ const abs = path.join(projectRoot, rel);
122
+ try {
123
+ const st = fs.lstatSync(abs);
124
+ if (st.isDirectory()) {
125
+ fs.rmSync(abs, { recursive: true, force: true });
126
+ } else {
127
+ fs.unlinkSync(abs);
128
+ }
129
+ removed.push(rel);
130
+ } catch (_) { /* nothing there or already gone */ }
131
+ };
132
+ tryRemove(lockedDir);
133
+ tryRemove(CONFIG_FILE);
134
+ tryRemove(LEGACY_CONFIG_FILE);
135
+ try {
136
+ clearSession(projectRoot);
137
+ removed.push('~/.sealcode/sessions/<this-project>');
138
+ } catch (_) { /* ignore */ }
139
+ return removed;
140
+ }
141
+
142
+ async function runRemove({
143
+ projectRoot,
144
+ confirm = null,
145
+ burn = false,
146
+ offline = false,
147
+ sessionOk = false,
148
+ keepLink = false,
149
+ }) {
150
+ const config = readActiveConfig(projectRoot);
151
+ if (!config || !isInitialized(projectRoot, config.lockedDir)) {
152
+ throw new SealcodeError('SEALCODE_NO_MANIFEST', {
153
+ detail: `Nothing to remove: ${projectRoot} doesn't have a sealcode vault.`,
154
+ });
155
+ }
156
+
157
+ // ---- 1. Re-verify passphrase (or accept --session-ok) ----
158
+ let K;
159
+ if (sessionOk) {
160
+ const { loadSession } = require('./keystore');
161
+ K = loadSession(projectRoot);
162
+ if (!K) {
163
+ // --session-ok was promised but no session exists. Fall back to
164
+ // a passphrase prompt rather than silently degrading: a missing
165
+ // session is exactly the case where we WANT extra verification.
166
+ K = await verifyPassphrase(projectRoot, config.lockedDir);
167
+ }
168
+ } else {
169
+ K = await verifyPassphrase(projectRoot, config.lockedDir);
170
+ }
171
+
172
+ // ---- 2. Typed confirmation ----
173
+ const link = getLink(projectRoot);
174
+ if (!confirm) {
175
+ process.stdout.write(
176
+ [
177
+ '',
178
+ ui.c.bold('You are about to permanently remove sealcode from:'),
179
+ ` ${projectRoot}`,
180
+ '',
181
+ link
182
+ ? `This project is linked to ${ui.c.cyan(link.projectName || link.projectId)} (${link.projectId}).`
183
+ : 'This project is not linked to any sealcode.dev account.',
184
+ link
185
+ ? ui.c.yellow(' → The project owner will be notified by email.')
186
+ : ui.c.dim(' → No notification will be sent.'),
187
+ '',
188
+ burn
189
+ ? ui.c.red("--burn: plaintext will NOT be recovered. Your source will be gone.")
190
+ : 'Your source files will be decrypted back to plaintext before removal.',
191
+ '',
192
+ ].join('\n'),
193
+ );
194
+ const typed = await question('Type yes-remove to confirm');
195
+ if (typed !== 'yes-remove') {
196
+ ui.warn('aborted; nothing changed.');
197
+ return { aborted: true };
198
+ }
199
+ } else if (confirm !== 'yes-remove') {
200
+ throw new Error(
201
+ "Refusing to remove: pass --confirm yes-remove (exactly) to acknowledge this is irreversible.",
202
+ );
203
+ }
204
+
205
+ // ---- 3. Notify the server (if linked) ----
206
+ let notifyOutcome = null;
207
+ if (link) {
208
+ if (offline) {
209
+ // Owner explicitly chose to skip the alert. We still want this
210
+ // to show up somewhere visible, so log to stderr.
211
+ ui.warn(
212
+ `--offline: skipping the server notification. The owner of "${link.projectName || link.projectId}" will NOT be emailed.`,
213
+ );
214
+ notifyOutcome = { status: 'skipped' };
215
+ } else {
216
+ notifyOutcome = await tryNotifyServer({
217
+ projectId: link.projectId,
218
+ localFingerprint: link.localFingerprint || null,
219
+ burn,
220
+ });
221
+ if (notifyOutcome.status === 'offline') {
222
+ throw new SealcodeError('SEALCODE_REMOVE_OFFLINE', {
223
+ detail:
224
+ `Could not reach sealcode.dev to notify the project owner that this vault is being removed. ` +
225
+ `\`sealcode remove\` is refused so a hostile actor can't bypass the alert by cutting the network. ` +
226
+ `Re-run with \`--offline\` if you are genuinely offline and accept this.`,
227
+ });
228
+ }
229
+ if (notifyOutcome.status === 'rejected') {
230
+ throw new SealcodeError('SEALCODE_REMOVE_REJECTED', {
231
+ detail:
232
+ `The server rejected the remove notice (${notifyOutcome.code}: ${notifyOutcome.message}). ` +
233
+ `This usually means your bearer token is invalid or the project was already deleted from the dashboard. ` +
234
+ `Run \`sealcode whoami\` and \`sealcode link --info\` to triage.`,
235
+ });
236
+ }
237
+ }
238
+ }
239
+
240
+ // ---- 4. Decrypt to plaintext (unless --burn) ----
241
+ let decryptedFiles = 0;
242
+ if (!burn) {
243
+ // Force a full unlock so every blob lands as a plaintext file
244
+ // before we wipe lockedDir. We ignore allowedPaths / watermark
245
+ // policy — `remove` is a full restore, not a scoped session.
246
+ const manifest = readManifest(projectRoot, config.lockedDir, K);
247
+ decryptedFiles = manifest.files.length;
248
+ await runUnlock({
249
+ projectRoot,
250
+ config,
251
+ K,
252
+ removeStubs: true,
253
+ log: () => {},
254
+ });
255
+ }
256
+
257
+ // ---- 5. Destroy local artifacts ----
258
+ if (link && !keepLink) {
259
+ clearLink(projectRoot);
260
+ }
261
+ const removed = destroyVaultArtifacts(projectRoot, config.lockedDir);
262
+
263
+ // ---- Summary ----
264
+ const lines = ['', ui.c.green('✓ sealcode removed from this project.'), ''];
265
+ if (!burn) lines.push(` Restored ${decryptedFiles} files to plaintext.`);
266
+ if (burn) lines.push(` ${ui.c.red('--burn:')} ciphertext discarded without decrypting.`);
267
+ lines.push(` Removed: ${removed.join(', ')}`);
268
+ if (link && notifyOutcome && notifyOutcome.status === 'sent') {
269
+ lines.push(` Notified the owner of "${link.projectName || link.projectId}" by email.`);
270
+ }
271
+ lines.push('');
272
+ process.stdout.write(lines.join('\n'));
273
+ return {
274
+ aborted: false,
275
+ decryptedFiles,
276
+ removed,
277
+ notified: notifyOutcome && notifyOutcome.status === 'sent',
278
+ };
279
+ }
280
+
281
+ module.exports = { runRemove, _internal: { destroyVaultArtifacts, tryNotifyServer } };