sealcode 1.3.4 → 1.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "Lock your source code in your own git repo. Stop AI agents, scrapers, and curious eyes from reading what's yours.",
5
5
  "keywords": [
6
6
  "encryption",
@@ -41,7 +41,8 @@
41
41
  ],
42
42
  "scripts": {
43
43
  "test": "node test/roundtrip.js",
44
- "selftest": "node test/roundtrip.js"
44
+ "selftest": "node test/roundtrip.js",
45
+ "preuninstall": "node ./bin/sealcode.js preuninstall-check || true"
45
46
  },
46
47
  "engines": {
47
48
  "node": ">=18"
package/src/cli-grants.js CHANGED
@@ -628,6 +628,7 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
628
628
  sp.succeed(
629
629
  `unlocked ${ui.c.bold(ures.count)} files${sk} ${ui.c.dim(`(locked at ${ures.sealedAt})`)}`,
630
630
  );
631
+ try { require('./cli-registry').markUnlocked(projectRoot); } catch (_) { /* ignore */ }
631
632
  if (ures.policy.mode === 'ro') {
632
633
  ui.hint(' Read-only grant: files are 0444. Any modification will trigger an immediate re-lock.');
633
634
  }
@@ -735,7 +736,10 @@ async function runLockdown({ projectRoot, confirm = true } = {}) {
735
736
  { auth: true },
736
737
  );
737
738
  const grants = Array.isArray(list.grants) ? list.grants : [];
738
- const live = grants.filter((g) => g.status === 'active' || g.status === 'paused');
739
+ // The serialized payload uses `state` (not `status`) values are
740
+ // computed by describeGrant() and include "active", "paused",
741
+ // "expired", "revoked", "pending".
742
+ const live = grants.filter((g) => g.state === 'active' || g.state === 'paused');
739
743
  if (live.length === 0) {
740
744
  ui.ok('Lockdown complete — no active grants to revoke.');
741
745
  return;
@@ -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
+ };
@@ -34,6 +34,10 @@ const path = require('path');
34
34
  const { execFileSync } = require('child_process');
35
35
 
36
36
  const SERVICE_NAME = 'dev.sealcode.watcher';
37
+ // sealcode@1.3.6 — Windows Task Scheduler name. No dots allowed in path
38
+ // components, so we use a hyphen-cased name. Stored at the user level
39
+ // (no Administrator prompt required).
40
+ const WIN_TASK_NAME = 'SealCodeWatcherSupervisor';
37
41
  const ui = require('./ui');
38
42
 
39
43
  function launchdPath() {
@@ -129,6 +133,77 @@ WantedBy=timers.target
129
133
  }
130
134
  }
131
135
 
136
+ /**
137
+ * sealcode@1.3.6 — Windows scheduled task. Runs at user logon AND every
138
+ * 1 minute thereafter. We use `schtasks` (built-in, no admin required
139
+ * for user-level tasks) instead of an actual Windows Service (which
140
+ * requires Administrator and a service wrapper).
141
+ *
142
+ * /SC ONLOGON → fires when the user logs in (so the watcher
143
+ * supervisor is running before they start work)
144
+ * /RI 1 → repeat interval of 1 minute (combined with /DU)
145
+ * /DU 9999:59 → for ~10000 hours = effectively forever
146
+ * /F → force overwrite if a task already exists (idempotent)
147
+ *
148
+ * Two separate tasks are needed: ONLOGON does NOT support /RI on its
149
+ * own, so we create:
150
+ * - "SealCodeWatcherSupervisor-Logon" (one-shot at logon)
151
+ * - "SealCodeWatcherSupervisor-Minute" (every minute)
152
+ *
153
+ * The supervise command itself is idempotent so dual-invocation is safe.
154
+ */
155
+ function writeWindowsTasks() {
156
+ const node = nodeBin();
157
+ const cli = cliEntry();
158
+ const logDir = path.join(os.homedir(), '.sealcode', 'logs');
159
+ mkdirP(logDir);
160
+
161
+ // Wrap in a tiny .cmd shim so schtasks doesn't have to deal with
162
+ // node + cli paths containing spaces (Windows path quoting via
163
+ // schtasks /TR is famously fragile). The shim writes output to a
164
+ // log file so we have evidence the supervisor ran.
165
+ const shimPath = path.join(os.homedir(), '.sealcode', 'supervise.cmd');
166
+ const shim = `@echo off\r\n`
167
+ + `"${node}" "${cli}" supervise >> "${path.join(logDir, 'supervise.out.log')}" 2>> "${path.join(logDir, 'supervise.err.log')}"\r\n`;
168
+ mkdirP(path.dirname(shimPath));
169
+ fs.writeFileSync(shimPath, shim, { mode: 0o644 });
170
+
171
+ // Task 1 — run once at every user logon.
172
+ try {
173
+ execFileSync('schtasks', [
174
+ '/Create', '/F',
175
+ '/TN', `${WIN_TASK_NAME}-Logon`,
176
+ '/TR', shimPath,
177
+ '/SC', 'ONLOGON',
178
+ '/RL', 'LIMITED',
179
+ ], { stdio: 'inherit' });
180
+ } catch (err) {
181
+ ui.warn(`schtasks (logon) failed: ${err.message || err}`);
182
+ }
183
+
184
+ // Task 2 — repeat every 1 minute forever.
185
+ // schtasks doesn't have an "every minute" trigger directly — we use
186
+ // /SC MINUTE /MO 1 which runs every minute.
187
+ try {
188
+ execFileSync('schtasks', [
189
+ '/Create', '/F',
190
+ '/TN', `${WIN_TASK_NAME}-Minute`,
191
+ '/TR', shimPath,
192
+ '/SC', 'MINUTE',
193
+ '/MO', '1',
194
+ '/RL', 'LIMITED',
195
+ ], { stdio: 'inherit' });
196
+ } catch (err) {
197
+ ui.warn(`schtasks (minute) failed: ${err.message || err}`);
198
+ }
199
+ }
200
+
201
+ function removeWindowsTasks() {
202
+ try { execFileSync('schtasks', ['/Delete', '/F', '/TN', `${WIN_TASK_NAME}-Logon`], { stdio: 'ignore' }); } catch (_) { /* ok */ }
203
+ try { execFileSync('schtasks', ['/Delete', '/F', '/TN', `${WIN_TASK_NAME}-Minute`], { stdio: 'ignore' }); } catch (_) { /* ok */ }
204
+ try { fs.unlinkSync(path.join(os.homedir(), '.sealcode', 'supervise.cmd')); } catch (_) { /* ok */ }
205
+ }
206
+
132
207
  function runInstallService() {
133
208
  if (process.platform === 'darwin') {
134
209
  writePlist();
@@ -141,11 +216,14 @@ function runInstallService() {
141
216
  ui.ok(`Installed systemd user units: sealcode-watcher.{service,timer}`);
142
217
  return;
143
218
  }
144
- ui.warn(
145
- `'sealcode install-service' is not yet supported on ${process.platform}. `
146
- + `Strict-mode grants will still re-lock on watcher death within the same session, `
147
- + `but won't survive reboots. Tracked at https://sealcode.dev/docs/team#service-windows`,
148
- );
219
+ if (process.platform === 'win32') {
220
+ writeWindowsTasks();
221
+ ui.ok(`Installed Windows scheduled tasks: ${WIN_TASK_NAME}-{Logon,Minute}`);
222
+ ui.hint(' They run as your user, with no Administrator prompt, every minute.');
223
+ ui.hint(` Logs: ${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log')}`);
224
+ return;
225
+ }
226
+ ui.warn(`'sealcode install-service' is not supported on ${process.platform}.`);
149
227
  process.exitCode = 1;
150
228
  }
151
229
 
@@ -163,60 +241,95 @@ function runUninstallService() {
163
241
  ui.ok('Removed systemd user units.');
164
242
  return;
165
243
  }
244
+ if (process.platform === 'win32') {
245
+ removeWindowsTasks();
246
+ ui.ok('Removed Windows scheduled tasks.');
247
+ return;
248
+ }
166
249
  ui.hint('Nothing to uninstall on this platform.');
167
250
  }
168
251
 
169
252
  /**
170
- * Called by the supervisor every minute (launchd / systemd timer).
171
- * Walks ~/.sealcode/sessions/*.watch.json, finds any whose pid is dead,
172
- * and re-spawns a watcher for that code. Cheap, idempotent, silent.
253
+ * Called by the supervisor every minute (launchd / systemd timer /
254
+ * Windows schtasks). Two responsibilities:
255
+ *
256
+ * 1. Stale-watch-state sweep — pre-1.3.6 behavior. Walk
257
+ * ~/.sealcode/sessions/*.watch.json, find any whose pid is dead,
258
+ * and remove the stale watch state file. This makes cli-guard
259
+ * lock the next time the user runs sealcode in that project.
260
+ *
261
+ * 2. Registry-based orphan lock (sealcode@1.3.6+). The session-file
262
+ * sweep above is good but only catches projects whose watch.json
263
+ * file is still present on disk. If the user killed the watcher
264
+ * AND wiped the session dir (the Saad scenario), there's nothing
265
+ * in ~/.sealcode/sessions/ to walk — but the project is still
266
+ * unlocked. The known-projects registry at ~/.sealcode/projects.json
267
+ * lets us catch this: anything marked `unlocked` with no live
268
+ * watcher gets a detached panic-lock dispatched.
269
+ *
270
+ * Cheap, idempotent, silent. Runs as the logged-in user.
173
271
  */
174
272
  function runSupervise() {
273
+ // -----------------------------------------------------------------
274
+ // Step 1 — old session-file sweep
275
+ // -----------------------------------------------------------------
175
276
  const dir = path.join(os.homedir(), '.sealcode', 'sessions');
176
277
  let files;
177
278
  try {
178
279
  files = fs.readdirSync(dir).filter((f) => f.endsWith('.watch.json'));
179
280
  } catch (_) {
180
- return;
281
+ files = [];
181
282
  }
182
- const { spawn } = require('child_process');
183
- let respawned = 0;
283
+ let cleared = 0;
184
284
  for (const f of files) {
185
285
  let state;
186
286
  try {
187
287
  state = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
188
- } catch (_) {
189
- continue;
190
- }
191
- if (!state || !state.code || !state.pid) continue;
288
+ } catch (_) { continue; }
289
+ if (!state || !state.pid) continue;
192
290
  let alive = false;
193
291
  try { process.kill(state.pid, 0); alive = true; } catch (_) { alive = false; }
194
292
  if (alive) continue;
195
- // We don't know the project root from the watch state file alone,
196
- // but the file name encodes the sha256(projectAbs).slice(0,16) which
197
- // we can't reverse. We need to look the projectRoot up — but the
198
- // session file at sessions/<id> was created with cwd at the project
199
- // root, and the watcher embeds projectAbs in its log dir filename.
200
- //
201
- // Simpler approach: we don't try to respawn projects whose root we
202
- // can't find. Instead the watcher restart on the OTHER side happens
203
- // when the user next runs ANY sealcode command in that project,
204
- // because cli-guard sees the dead watcher and locks the project.
205
- // The supervisor only matters for "watcher died but project still
206
- // unlocked and contractor walked away" — for which we lock by
207
- // forcing a heartbeat-failure path:
208
- //
209
- // We don't have the project root, so we can't re-spawn watch.
210
- // But we CAN remove the stale watch.json so cli-guard fires next
211
- // time the user runs anything. That's enough.
212
- try { fs.unlinkSync(path.join(dir, f)); respawned += 1; } catch (_) { /* ignore */ }
293
+ try { fs.unlinkSync(path.join(dir, f)); cleared += 1; } catch (_) { /* ignore */ }
213
294
  }
214
- // Best-effort log line so the user can see the supervisor is running.
215
- if (respawned > 0) {
295
+
296
+ // -----------------------------------------------------------------
297
+ // Step 2 — registry-based orphan lock (1.3.6+)
298
+ // -----------------------------------------------------------------
299
+ let orphansHandled = 0;
300
+ try {
301
+ const registry = require('./cli-registry');
302
+ const orphans = registry.findOrphanedUnlocks({ maxStaleMin: 5 });
303
+ if (orphans.length > 0) {
304
+ const { spawn } = require('child_process');
305
+ const node = process.execPath;
306
+ const cli = cliEntry();
307
+ for (const o of orphans) {
308
+ try {
309
+ const child = spawn(
310
+ node,
311
+ [cli, 'panic', '--project', o.projectRoot, '--from-selfcheck'],
312
+ {
313
+ detached: true,
314
+ stdio: 'ignore',
315
+ windowsHide: true,
316
+ cwd: o.projectRoot,
317
+ },
318
+ );
319
+ child.unref();
320
+ orphansHandled += 1;
321
+ } catch (_) { /* best-effort */ }
322
+ }
323
+ }
324
+ } catch (_) { /* registry module may not load on partial installs */ }
325
+
326
+ if (cleared > 0 || orphansHandled > 0) {
216
327
  try {
328
+ const logDir = path.join(os.homedir(), '.sealcode', 'logs');
329
+ fs.mkdirSync(logDir, { recursive: true });
217
330
  fs.appendFileSync(
218
- path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log'),
219
- `${new Date().toISOString()} cleared ${respawned} stale watch state files\n`,
331
+ path.join(logDir, 'supervise.out.log'),
332
+ `${new Date().toISOString()} cleared=${cleared} orphan_locks=${orphansHandled}\n`,
220
333
  );
221
334
  } catch (_) { /* ignore */ }
222
335
  }
package/src/cli-watch.js CHANGED
@@ -612,20 +612,72 @@ async function runWatch({
612
612
  });
613
613
  }
614
614
 
615
- // Signal handling. In strict mode, ANY exit path (SIGINT, SIGTERM)
616
- // must re-lock the project before we die otherwise the contractor
617
- // could just Ctrl-C the watcher to keep plaintext.
615
+ // Signal handling. We hit this path for clean exits (Ctrl-C, terminal
616
+ // close, OS shutdown, `pm2 stop`). The signals we DON'T hit on are
617
+ // SIGKILL / kill -9 / Stop-Process -Force by design that's the
618
+ // signal the server-side dead-watcher sweeper is built to detect.
619
+ //
620
+ // Behavior:
621
+ // - strict + lenient both re-lock on clean exit (sealcode@1.3.6 —
622
+ // previously only strict locked; lenient left plaintext for
623
+ // "convenience" but that was a foot-gun. A clean shutdown is a
624
+ // signal that the recipient is walking away, and the right
625
+ // default is to leave their disk safe.)
626
+ // - both POST /api/v1/access/exit so the server records a
627
+ // `gracefullyExitedAt` timestamp and the sweeper skips them. The
628
+ // POST is best-effort; if the network is down (laptop closing
629
+ // mid-VPN drop), we still tear down locally and the sweeper will
630
+ // eventually email — fail-safe in the alerting direction.
618
631
  let stopped = false;
619
632
  const cleanup = async (reason) => {
620
633
  if (stopped) return;
621
634
  stopped = true;
622
635
  if (exfilCtrl) exfilCtrl.stop();
623
636
  deleteWatchState(projectRoot);
624
- if (strict) {
625
- appendLog(projectRoot, { type: 'strict_exit_lock', reason });
626
- await finalLock(projectRoot, config, trimmedCode, `strict:${reason}`, json, daemon).catch(() => {});
627
- } else {
628
- if (!json && !daemon) ui.say('\n' + ui.c.dim('stopped watching (lenient mode files left as-is)'));
637
+
638
+ appendLog(projectRoot, { type: 'graceful_exit_lock', reason, strict });
639
+
640
+ // Best-effort: re-lock the recipient's plaintext before we die.
641
+ // Errors are swallowed we'd rather exit and let the sweeper alert
642
+ // than block the OS shutdown waiting on a runLock that hung on a
643
+ // disk write.
644
+ let relocked = false;
645
+ try {
646
+ const K = loadSession(projectRoot);
647
+ if (K) {
648
+ const sm = loadSessionMeta(projectRoot);
649
+ const preserveUnseen = !!sm && sm.meta && sm.meta.source === 'grant';
650
+ await runLock({ projectRoot, config, K, preserveUnseen }).catch(() => {});
651
+ try { K.fill(0); } catch (_) { /* ignore */ }
652
+ relocked = true;
653
+ }
654
+ } catch (_) { /* best-effort */ }
655
+ try { clearSession(projectRoot); } catch (_) { /* ignore */ }
656
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
657
+
658
+ // Best-effort: tell the server we exited cleanly so the sweeper
659
+ // doesn't dead-watcher-alert. Hard-bounded timeout: if the server
660
+ // is slow/unreachable we'd rather die quickly than block shutdown.
661
+ try {
662
+ const timeoutMs = 1500;
663
+ const exitPromise = request('POST', `/api/v1/access/exit`, {
664
+ body: {
665
+ code: trimmedCode,
666
+ reason: reason === 'sigint' || reason === 'sigterm' || reason === 'sighup' ? reason : 'manual',
667
+ watcherPid: process.pid,
668
+ watcherVersion: pkg.version,
669
+ relockedFiles: relocked,
670
+ },
671
+ });
672
+ await Promise.race([
673
+ exitPromise,
674
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
675
+ ]).catch(() => {});
676
+ } catch (_) { /* best-effort */ }
677
+
678
+ if (!json && !daemon) {
679
+ if (relocked) ui.say('\n' + ui.c.dim('stopped watching — files re-locked'));
680
+ else ui.say('\n' + ui.c.dim('stopped watching'));
629
681
  }
630
682
  process.exit(0);
631
683
  };
@@ -959,17 +1011,18 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
959
1011
  process.exit(2);
960
1012
  }
961
1013
 
962
- // sealcode@1.1.0 — for grant-derived sessions that had a path scope
963
- // (or any future "subset-only" policy), preserve out-of-scope blobs
964
- // so the contractor's re-lock doesn't wipe the project for everyone.
1014
+ // sealcode@1.3.6ALWAYS preserve unseen blobs for grant-derived
1015
+ // sessions, not just path-scoped ones. The recipient-side re-lock
1016
+ // (revoke / pause / device-mismatch / expiry) is supposed to wipe
1017
+ // THEIR plaintext copy; it must not wipe the encrypted blobs that
1018
+ // were sealed by the OWNER. Without preserveUnseen, any race that
1019
+ // makes collectFiles return fewer paths than the manifest expected
1020
+ // permanently destroys those blobs (the "revoked → 1 file restored"
1021
+ // bug). Owner-side passphrase re-locks still use preserveUnseen=false
1022
+ // since the owner has all plaintext on hand by definition.
965
1023
  const _sessionMeta = loadSessionMeta(projectRoot);
966
1024
  const _preserveUnseen =
967
- !!_sessionMeta &&
968
- _sessionMeta.meta &&
969
- _sessionMeta.meta.source === 'grant' &&
970
- _sessionMeta.meta.policy &&
971
- Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
972
- _sessionMeta.meta.policy.allowedPaths.length > 0;
1025
+ !!_sessionMeta && _sessionMeta.meta && _sessionMeta.meta.source === 'grant';
973
1026
 
974
1027
  let res;
975
1028
  try {
@@ -981,6 +1034,7 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
981
1034
  }
982
1035
  clearSession(projectRoot);
983
1036
  deleteWatchState(projectRoot);
1037
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
984
1038
  if (daemon) {
985
1039
  appendLog(projectRoot, { type: 'locked', count: res.count, reason: label });
986
1040
  } else if (json) {
package/src/cli.js CHANGED
@@ -269,6 +269,7 @@ function build() {
269
269
  sp.succeed(`locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')}`);
270
270
  const stubs = Object.keys(res.stubs);
271
271
  if (stubs.length) ui.hint(` stubs placed: ${stubs.join(', ')}`);
272
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
272
273
  } catch (err) {
273
274
  process.exitCode = reportError(err);
274
275
  }
@@ -380,6 +381,7 @@ function build() {
380
381
  });
381
382
  const _skipped = res.skipped > 0 ? ` ${ui.c.dim(`(${res.skipped} outside grant scope)`)}` : '';
382
383
  sp.succeed(`unlocked ${ui.c.bold(res.count)} files${_skipped} ${ui.c.dim(`(locked at ${res.sealedAt})`)}`);
384
+ try { require('./cli-registry').markUnlocked(projectRoot); } catch (_) { /* ignore */ }
383
385
  if (res.policy && res.policy.mode === 'ro') {
384
386
  ui.hint(' Read-only grant: files set to 0444. Any edit triggers an immediate re-lock.');
385
387
  }
@@ -519,19 +521,112 @@ function build() {
519
521
  program
520
522
  .command('panic')
521
523
  .description('Re-lock immediately and wipe plaintext (for "shut my laptop NOW" moments).')
522
- .action(async () => {
524
+ .option('--from-selfcheck', '[internal] called by the self-check background job — never prompts, silent on no-op')
525
+ .action(async (opts) => {
523
526
  try {
524
527
  const projectRoot = resolveProject(program.opts());
525
528
  const config = getActiveConfig(projectRoot);
529
+
530
+ // sealcode@1.3.6 — when invoked by the registry self-check we
531
+ // MUST NOT prompt for a passphrase (would hang the detached
532
+ // background process forever). If there's no cached session,
533
+ // we silently correct the registry and exit — the user can
534
+ // run `sealcode lock` manually if they want a real lock.
535
+ if (opts.fromSelfcheck) {
536
+ const cached = loadSession(projectRoot);
537
+ if (!cached) {
538
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
539
+ return;
540
+ }
541
+ try {
542
+ const sm = require('./keystore').loadSessionMeta(projectRoot);
543
+ const preserveUnseen = !!sm && sm.meta && sm.meta.source === 'grant';
544
+ await runLock({ projectRoot, config, K: cached, preserveUnseen });
545
+ } catch (_) { /* swallow — background job */ }
546
+ try { cached.fill(0); } catch (_) { /* ignore */ }
547
+ try { clearSession(projectRoot); } catch (_) { /* ignore */ }
548
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
549
+ return;
550
+ }
551
+
526
552
  const K = await resolveKey(projectRoot, config);
527
553
  const res = await runLock({ projectRoot, config, K });
528
554
  clearSession(projectRoot);
555
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
529
556
  process.stdout.write(`✓ panic-locked ${res.count} files. Session cleared.\n`);
530
557
  } catch (err) {
531
558
  process.exitCode = reportError(err);
532
559
  }
533
560
  });
534
561
 
562
+ // -------- preuninstall-check (internal, called by npm preuninstall) --------
563
+ //
564
+ // sealcode@1.3.6 — when the user runs `npm uninstall -g sealcode`, npm
565
+ // executes this command FIRST (before deleting files). We use the small
566
+ // window to:
567
+ // 1. Walk the known-projects registry and re-lock any unlocked ones.
568
+ // 2. Print a clear warning so the user understands the implication.
569
+ //
570
+ // Bypassable with `npm uninstall --ignore-scripts`. We accept that —
571
+ // the goal is to raise the floor against accidental "I'll just remove
572
+ // sealcode" actions, not to defeat a determined attacker who reads
573
+ // our docs.
574
+ //
575
+ // ALWAYS exit 0 (via `|| true` in package.json) so npm uninstall
576
+ // succeeds even if our hook fails. We do NOT want to leave the user
577
+ // stuck with a partial uninstall.
578
+ program
579
+ .command('preuninstall-check', { hidden: true })
580
+ .description('[internal] npm preuninstall hook — re-locks any unlocked projects before uninstall')
581
+ .action(async () => {
582
+ try {
583
+ const registry = require('./cli-registry');
584
+ const orphans = registry.findOrphanedUnlocks({ maxStaleMin: 0 });
585
+ const allUnlocked = registry.list().filter((p) => p.lastState === 'unlocked');
586
+
587
+ const total = allUnlocked.length;
588
+ if (total === 0) {
589
+ // Nothing to do. Silent exit so npm uninstall stays quiet.
590
+ return;
591
+ }
592
+
593
+ process.stderr.write(
594
+ `\n \x1b[33m\u26a0 sealcode preuninstall: ${total} project(s) are still unlocked.\x1b[0m\n`
595
+ + `\n Re-locking before uninstall so you don't leave plaintext lying around:\n`,
596
+ );
597
+
598
+ // Re-lock each one. We use the same panic-from-selfcheck flow:
599
+ // it uses ONLY the cached session (no passphrase prompt — npm
600
+ // hooks have no TTY anyway) and is a no-op if the session was
601
+ // already cleared. We run them SYNCHRONOUSLY here (not detached)
602
+ // because npm will delete our files the moment we return.
603
+ const { spawnSync } = require('child_process');
604
+ for (const p of allUnlocked) {
605
+ process.stderr.write(` - ${p.projectRoot} ... `);
606
+ const r = spawnSync(
607
+ process.execPath,
608
+ [path.resolve(__dirname, '..', 'bin', 'sealcode.js'),
609
+ 'panic', '--project', p.projectRoot, '--from-selfcheck'],
610
+ { stdio: 'ignore', timeout: 30_000 },
611
+ );
612
+ process.stderr.write((r.status === 0 ? '\x1b[32mlocked\x1b[0m' : '\x1b[31mfailed\x1b[0m') + '\n');
613
+ }
614
+
615
+ if (orphans.length > 0) {
616
+ process.stderr.write(
617
+ `\n \x1b[33mNote:\x1b[0m ${orphans.length} of these had no running watcher already. `
618
+ + `Any active access grants on those projects can no longer be revoked from the dashboard.\n`,
619
+ );
620
+ }
621
+
622
+ process.stderr.write(`\n`);
623
+ } catch (_) {
624
+ // Never fail the npm uninstall. Worst case: user uninstalls
625
+ // sealcode and we couldn't lock — the registry self-check on
626
+ // some future reinstall would still flag it.
627
+ }
628
+ });
629
+
535
630
  // -------- install-hook --------
536
631
  program
537
632
  .command('install-hook')
@@ -1061,6 +1156,34 @@ async function run(argv) {
1061
1156
  if (bare || helpish) {
1062
1157
  ui.maybeShowWelcome(pkg.version);
1063
1158
  }
1159
+
1160
+ // sealcode@1.3.6 — registry self-check on EVERY invocation. Scans
1161
+ // ~/.sealcode/projects.json for projects marked unlocked whose
1162
+ // watcher is no longer alive, and spawns a detached panic-lock for
1163
+ // each. Closes the "recipient killed the watcher and walked away"
1164
+ // hole for anyone who uses sealcode at all on the same machine
1165
+ // afterwards.
1166
+ //
1167
+ // Quiet for short/info commands so we don't spam warnings during
1168
+ // `sealcode --version` etc. NEVER throws — wrapped in try.
1169
+ //
1170
+ // We skip the self-check when the invocation IS the self-check (the
1171
+ // detached `panic --from-selfcheck` child) to prevent infinite spawn
1172
+ // recursion.
1173
+ try {
1174
+ const first = userArgs[0];
1175
+ const isPanicFromSelfcheck =
1176
+ first === 'panic' && userArgs.includes('--from-selfcheck');
1177
+ const isSupervise = first === 'supervise';
1178
+ const quiet =
1179
+ bare || helpish || isSupervise || isPanicFromSelfcheck ||
1180
+ first === 'version' || first === '-V' || first === '--version' ||
1181
+ first === 'where' || first === 'whoami';
1182
+ if (!isPanicFromSelfcheck && !isSupervise) {
1183
+ require('./cli-registry').selfCheck({ quiet });
1184
+ }
1185
+ } catch (_) { /* never block the user's command */ }
1186
+
1064
1187
  await program.parseAsync(argv);
1065
1188
  }
1066
1189
 
package/src/seal.js CHANGED
@@ -97,16 +97,33 @@ async function runLock({
97
97
  const lockedDir = config.lockedDir;
98
98
  const lockedRoot = path.join(projectRoot, lockedDir);
99
99
 
100
- // Snapshot the existing manifest BEFORE we wipe lockedRoot, so we can
101
- // copy unseen-but-still-relevant blobs forward.
100
+ // sealcode@1.3.6 ALWAYS read the previous manifest, not just when
101
+ // preserveUnseen was requested. We use it for two purposes:
102
+ //
103
+ // 1. The original preserveUnseen feature (carry path-scoped blobs
104
+ // forward) — unchanged.
105
+ // 2. A data-loss SAFETY GUARD against the "revoke race" bug.
106
+ //
107
+ // The bug: recipient has 553 plaintext files. Owner revokes. Watcher
108
+ // wakes, calls runLock, but collectFiles momentarily returns just a
109
+ // handful of files (filesystem race on Windows, antivirus scan in
110
+ // progress, watcher woke mid-softLock-followed-by-finalLock, etc.).
111
+ // Without a guard, rmIfExists(lockedRoot) would wipe the 553 existing
112
+ // blobs and we'd write 3 new ones — permanent data loss for the owner
113
+ // who shared the project.
114
+ //
115
+ // The guard: if we had a real manifest with N entries and we're about
116
+ // to write < ceil(N/2) plaintext blobs (and preserveUnseen wasn't
117
+ // already requested), we auto-promote to preserveUnseen=true and log
118
+ // a warning. This means in the worst case we keep slightly-stale
119
+ // ciphertext (recoverable) instead of nuking the locked repo
120
+ // (unrecoverable for whoever doesn't have a separate copy).
102
121
  let prevManifest = null;
103
- if (preserveUnseen) {
104
- try {
105
- prevManifest = readManifest(projectRoot, lockedDir, K);
106
- } catch (_) {
107
- // No manifest yet (first init) or unreadable — fall through.
108
- prevManifest = null;
109
- }
122
+ try {
123
+ prevManifest = readManifest(projectRoot, lockedDir, K);
124
+ } catch (_) {
125
+ // No manifest yet (first init) or unreadable — fall through.
126
+ prevManifest = null;
110
127
  }
111
128
 
112
129
  const files = await collectFiles(projectRoot, config);
@@ -114,6 +131,26 @@ async function runLock({
114
131
  throw new Error('SEALCODE_NOTHING_TO_LOCK');
115
132
  }
116
133
 
134
+ // Auto-promote preserveUnseen on suspicious shrink. Threshold: only
135
+ // engage when the previous manifest had at least 5 entries (avoids
136
+ // false positives on tiny projects) AND the new plaintext set is
137
+ // less than half the previous. We deliberately don't refuse the
138
+ // operation — that would leave the watcher in a bad state. We just
139
+ // make it non-destructive.
140
+ if (
141
+ !preserveUnseen
142
+ && prevManifest
143
+ && Array.isArray(prevManifest.files)
144
+ && prevManifest.files.length >= 5
145
+ && files.length < Math.ceil(prevManifest.files.length / 2)
146
+ ) {
147
+ log(
148
+ ` [safety] previous manifest had ${prevManifest.files.length} files but only ${files.length} `
149
+ + `plaintext on disk — auto-preserving unseen blobs to avoid data loss.`
150
+ );
151
+ preserveUnseen = true;
152
+ }
153
+
117
154
  // Preserve any existing keystore files when re-locking. On first init we
118
155
  // wipe and persist the bootstrap output fresh.
119
156
  const existingSalt = !bootstrapOutput && fs.existsSync(path.join(lockedRoot, SALT_NAME));