sealcode 0.1.0 → 1.1.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.
package/src/cli-grants.js CHANGED
@@ -13,8 +13,16 @@
13
13
  * that doesn't need auth — it's used by the developer receiving the code.
14
14
  */
15
15
 
16
+ const path = require('path');
17
+
16
18
  const { ApiError, clientInfo, request } = require('./api');
17
19
  const { getLink } = require('./link-state');
20
+ const { loadConfig } = require('./config');
21
+ const { detectPreset } = require('./presets');
22
+ const { isInitialized, loadSession, saveSession } = require('./keystore');
23
+ const { getDeviceFingerprint, getDeviceInfo } = require('./device');
24
+ const keypair = require('./keypair');
25
+ const shareCrypto = require('./share-crypto');
18
26
  const ui = require('./ui');
19
27
 
20
28
  function requireLink(projectRoot) {
@@ -38,6 +46,86 @@ function formatRemaining(ms) {
38
46
  return `${Math.floor(hr / 24)}d ${hr % 24}h`;
39
47
  }
40
48
 
49
+ function getActiveConfig(projectRoot) {
50
+ const fromFile = loadConfig(projectRoot);
51
+ if (fromFile) return fromFile;
52
+ const preset = detectPreset(projectRoot);
53
+ return {
54
+ version: 1,
55
+ preset: preset.id,
56
+ lockedDir: preset.lockedDir,
57
+ include: preset.include,
58
+ exclude: preset.exclude,
59
+ stubs: preset.stubs || {},
60
+ _implicit: true,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * sealcode@1.0.0: fetch the recipient's published X25519 pubkey and wrap
66
+ * the project master key K to it. Returns `{ wrappedKey, wrappedKeyRecipient }`
67
+ * suitable for splatting into the create-grant request body.
68
+ *
69
+ * Returns `null` if the recipient hasn't published a pubkey yet OR the
70
+ * server doesn't support the endpoint (pre-1.0 deploy). The caller falls
71
+ * back to the legacy "share-the-passphrase-out-of-band" flow.
72
+ *
73
+ * Will throw if we have K but the wrap itself fails (which would indicate
74
+ * a real cryptographic bug; we don't want to silently downgrade in that
75
+ * case).
76
+ */
77
+ async function maybeWrapKeyForRecipient({ projectRoot, recipientEmail, verbose = false }) {
78
+ if (!recipientEmail) return null;
79
+
80
+ // Need the master key K to wrap. If there's no live session on this
81
+ // machine, we can't wrap — owner has to `sealcode unlock` first.
82
+ const config = getActiveConfig(projectRoot);
83
+ if (!isInitialized(projectRoot, config.lockedDir)) return null;
84
+ const K = loadSession(projectRoot);
85
+ if (!K) {
86
+ if (verbose) {
87
+ ui.hint('(no cached session — skipping team-key wrap; recipient will need the passphrase)');
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // Fetch their pubkey.
93
+ let pub;
94
+ try {
95
+ pub = await request(
96
+ 'GET',
97
+ `/api/v1/users/pubkey?email=${encodeURIComponent(recipientEmail)}`,
98
+ { auth: true },
99
+ );
100
+ } catch (err) {
101
+ if (err instanceof ApiError) {
102
+ if (err.status === 404) {
103
+ // Either the user doesn't exist or hasn't published a pubkey
104
+ // yet. We still mint the code; the recipient just falls back to
105
+ // the legacy flow when they redeem.
106
+ if (verbose) {
107
+ ui.hint(
108
+ `(recipient hasn't published a pubkey yet — falling back to passphrase-share flow)`,
109
+ );
110
+ }
111
+ return null;
112
+ }
113
+ if (err.status === 0) {
114
+ // Network blip — let the caller surface this; we don't want to
115
+ // silently strip security.
116
+ throw err;
117
+ }
118
+ }
119
+ throw err;
120
+ }
121
+ if (!pub || !pub.publicKey || pub.algo !== keypair.ALGO) {
122
+ return null;
123
+ }
124
+
125
+ const wrapped = shareCrypto.wrapForRecipient(K, pub.publicKey);
126
+ return { wrappedKey: wrapped, wrappedKeyRecipient: recipientEmail };
127
+ }
128
+
41
129
  async function runShare({
42
130
  projectRoot,
43
131
  hours,
@@ -48,9 +136,38 @@ async function runShare({
48
136
  strict = false,
49
137
  heartbeatSec = 300,
50
138
  offlineGraceSec = 1800,
139
+ // sealcode@1.1.0 — admin precision controls. All optional.
140
+ paths = null, // string[] | null → allowedPaths
141
+ mode = 'rw', // 'rw' | 'ro'
142
+ watermark = null, // string | null → template
143
+ idleAutoLockMinutes = 0, // 0 = disabled
144
+ allowedIpCidrs = null, // string[] | null → CIDRs
145
+ allowedCountries = null, // string[] | null → ISO-2 codes
146
+ singleDeviceEnforce = false,
147
+ ndaText = null,
51
148
  json = false,
52
149
  }) {
53
150
  const link = requireLink(projectRoot);
151
+
152
+ // sealcode@1.0.0: if `--email <addr>` is supplied AND we can reach the
153
+ // recipient's pubkey AND we have K cached, wrap K to them. This makes
154
+ // `sealcode redeem` self-sufficient on the recipient side (no
155
+ // out-of-band passphrase exchange).
156
+ let wrap = null;
157
+ try {
158
+ wrap = await maybeWrapKeyForRecipient({
159
+ projectRoot,
160
+ recipientEmail: email,
161
+ verbose: !json,
162
+ });
163
+ } catch (err) {
164
+ // Network errors during pubkey lookup shouldn't be silent — degrade
165
+ // loudly so the owner knows the recipient will need the passphrase.
166
+ if (!json) {
167
+ ui.warn(`Pubkey lookup failed (${err.message || err}) — minting code without wrapped key.`);
168
+ }
169
+ }
170
+
54
171
  const body = {
55
172
  developerEmail: email || '',
56
173
  developerLabel: label || '',
@@ -60,6 +177,18 @@ async function runShare({
60
177
  strictMode: !!strict,
61
178
  heartbeatIntervalSeconds: Math.max(30, Math.floor(heartbeatSec)),
62
179
  offlineGraceSeconds: Math.max(60, Math.floor(offlineGraceSec)),
180
+ ...(wrap ? wrap : {}),
181
+ // sealcode@1.1.0 — admin precision controls (server validates again)
182
+ ...(paths && paths.length ? { allowedPaths: paths } : {}),
183
+ mode: mode === 'ro' ? 'ro' : 'rw',
184
+ ...(watermark ? { watermark } : {}),
185
+ idleAutoLockMinutes: Math.max(0, Math.floor(idleAutoLockMinutes || 0)),
186
+ ...(allowedIpCidrs && allowedIpCidrs.length ? { allowedIpCidrs } : {}),
187
+ ...(allowedCountries && allowedCountries.length
188
+ ? { allowedCountries: allowedCountries.map((c) => c.toUpperCase()) }
189
+ : {}),
190
+ singleDeviceEnforce: !!singleDeviceEnforce,
191
+ ...(ndaText ? { ndaText } : {}),
63
192
  };
64
193
 
65
194
  let res;
@@ -88,6 +217,8 @@ async function runShare({
88
217
  codePrefix: g.codePrefix,
89
218
  projectId: g.projectId,
90
219
  expiresAt: g.expiresAt,
220
+ wrappedFor: g.wrappedFor ?? null,
221
+ hasWrappedKey: !!g.hasWrappedKey,
91
222
  },
92
223
  null,
93
224
  2,
@@ -97,11 +228,17 @@ async function runShare({
97
228
  }
98
229
 
99
230
  const meta = [
100
- `${ui.c.dim('project ')} ${ui.c.bold(link.projectName || link.projectId)}`,
101
- `${ui.c.dim('expires ')} ${g.expiresAt}`,
231
+ `${ui.c.dim('project ')} ${ui.c.bold(link.projectName || link.projectId)}`,
232
+ `${ui.c.dim('expires ')} ${g.expiresAt}`,
102
233
  ];
103
- if (g.autoLockAt) meta.push(`${ui.c.dim('auto-lock ')} ${g.autoLockAt}`);
104
- if (email) meta.push(`${ui.c.dim('email ')} ${email}`);
234
+ if (g.autoLockAt) meta.push(`${ui.c.dim('auto-lock ')} ${g.autoLockAt}`);
235
+ if (email) meta.push(`${ui.c.dim('email ')} ${email}`);
236
+ if (strict) meta.push(`${ui.c.dim('strict ')} ${ui.c.yellow('yes — watcher required')}`);
237
+ if (g.hasWrappedKey) {
238
+ meta.push(`${ui.c.dim('wrapped to ')} ${ui.c.green(g.wrappedFor)} ${ui.c.dim('(no passphrase exchange)')}`);
239
+ } else if (email) {
240
+ meta.push(`${ui.c.dim('wrapped to ')} ${ui.c.yellow('— (recipient hasn\'t published a pubkey; passphrase still needed)')}`);
241
+ }
105
242
 
106
243
  ui.box('Access code — copy now, shown only once', [
107
244
  ` ${ui.c.bold(ui.c.green(g.code))}`,
@@ -109,8 +246,14 @@ async function runShare({
109
246
  ...meta,
110
247
  ], { tone: 'green' });
111
248
 
112
- ui.hint('Share it with the developer over a secure channel. They redeem with:');
113
- process.stdout.write(` ${ui.c.cyan(`sealcode redeem ${g.code}`)}\n\n`);
249
+ if (g.hasWrappedKey) {
250
+ ui.hint('Recipient runs:');
251
+ process.stdout.write(` ${ui.c.cyan(`sealcode redeem ${g.code}`)}\n`);
252
+ process.stdout.write(` ${ui.c.cyan(`sealcode unlock`)} ${ui.c.dim('# no passphrase prompt — K is delivered by the redeem call')}\n\n`);
253
+ } else {
254
+ ui.hint('Share it with the developer over a secure channel. They redeem with:');
255
+ process.stdout.write(` ${ui.c.cyan(`sealcode redeem ${g.code}`)}\n\n`);
256
+ }
114
257
  }
115
258
 
116
259
  async function runGrants({ projectRoot, json = false }) {
@@ -156,17 +299,113 @@ async function runRevoke({ grantId }) {
156
299
  process.stdout.write(`✓ revoked grant ${grantId}\n`);
157
300
  }
158
301
 
159
- async function runRedeem({ code, json = false }) {
302
+ async function runRedeem({ projectRoot, code, json = false, agreeNda = false }) {
160
303
  if (!code) throw new Error('Usage: sealcode redeem <CODE>');
161
304
  const trimmed = code.trim();
162
- const res = await request('POST', '/api/v1/access/redeem', {
163
- body: { code: trimmed, client: clientInfo() },
164
- });
305
+
306
+ // Include the device fingerprint so the server pins it to the grant
307
+ // on first redeem (and warns on mismatch on subsequent redeems).
308
+ const client = { ...clientInfo(), deviceFingerprint: getDeviceFingerprint() };
309
+
310
+ // First call without the NDA flag — server tells us whether one is
311
+ // required by including `nda_required: true` and `ndaText` in the
312
+ // 409-NDA response (or in the success payload's policy block). We
313
+ // use a separate pre-flight to a metadata endpoint to avoid the
314
+ // "race the server pins the device fingerprint before user has
315
+ // even agreed" problem.
316
+ let res;
317
+ try {
318
+ res = await request('POST', '/api/v1/access/redeem', {
319
+ body: { code: trimmed, client, ndaAccepted: !!agreeNda },
320
+ });
321
+ } catch (err) {
322
+ if (err instanceof ApiError && err.status === 409 && err.apiCode === 'nda_required') {
323
+ const ndaText = (err.raw && (err.raw.ndaText || err.raw.error?.ndaText)) || '(no text provided by owner)';
324
+ ui.box('Acceptance required', [
325
+ ui.c.dim('The owner of this project requires you to accept the following before unlocking:'),
326
+ '',
327
+ ...String(ndaText).split('\n'),
328
+ ], { tone: 'yellow' });
329
+ const { question } = require('./prompt');
330
+ const reply = await question('Type "I agree" to continue (or Ctrl-C to abort): ');
331
+ if (String(reply).trim().toLowerCase() !== 'i agree') {
332
+ ui.fail('Did not accept — aborting redeem.');
333
+ process.exitCode = 1;
334
+ return;
335
+ }
336
+ res = await request('POST', '/api/v1/access/redeem', {
337
+ body: { code: trimmed, client, ndaAccepted: true },
338
+ });
339
+ } else {
340
+ throw err;
341
+ }
342
+ }
165
343
  const g = res.grant;
344
+
345
+ // sealcode@1.0.0: if the server returned a wrapped K AND we're running
346
+ // inside a project root that has a manifest, unwrap it and cache the
347
+ // session so subsequent `sealcode unlock` skips the passphrase prompt.
348
+ // This is the magic "no passphrase exchange needed" path.
349
+ let cachedK = false;
350
+ let unwrapNote = null;
351
+ if (g.wrappedKey) {
352
+ const kp = keypair.read();
353
+ if (!kp) {
354
+ unwrapNote = ui.c.yellow(
355
+ '(received a wrapped key, but no local keypair was found. '
356
+ + 'Run `sealcode login` to generate one.)',
357
+ );
358
+ } else if (g.wrappedKeyRecipient && g.wrappedKeyRecipient.toLowerCase() !== readMyEmail().toLowerCase()) {
359
+ unwrapNote = ui.c.yellow(
360
+ `(received a wrapped key, but it was wrapped for ${g.wrappedKeyRecipient}; `
361
+ + `you are logged in as ${readMyEmail()}. Ask the owner to re-share.)`,
362
+ );
363
+ } else {
364
+ try {
365
+ const K = shareCrypto.unwrapWithRecipient(g.wrappedKey, kp.privateKey, kp.publicKey);
366
+ if (projectRoot) {
367
+ // Cache K against THIS project root. The grant-derived session
368
+ // is tagged so cli-guard and cli-watch know it requires a
369
+ // running watcher. sealcode@1.1.0 — also stash the policy
370
+ // bundle so subsequent commands enforce paths/ro/watermark/idle.
371
+ saveSession(projectRoot, K, {
372
+ source: 'grant',
373
+ grantId: g.id,
374
+ grantCodeHash: require('crypto').createHash('sha256').update(trimmed).digest('hex'),
375
+ grantExpiresAt: g.expiresAt,
376
+ deviceFingerprint: getDeviceFingerprint(),
377
+ strictWatch: !!g.strictMode,
378
+ policy: g.policy || {
379
+ allowedPaths: g.allowedPaths || null,
380
+ mode: g.mode || 'rw',
381
+ watermark: g.watermark || null,
382
+ idleAutoLockMinutes: g.idleAutoLockMinutes || 0,
383
+ },
384
+ recipientEmail: g.wrappedKeyRecipient || readMyEmail(),
385
+ projectName: g.projectName || null,
386
+ });
387
+ cachedK = true;
388
+ } else {
389
+ unwrapNote = ui.c.dim(
390
+ '(unwrapped K, but you are not inside a project root — '
391
+ + 'cd into the project and run `sealcode unlock` to materialize plaintext.)',
392
+ );
393
+ }
394
+ // Best-effort scrub of K from this process's memory.
395
+ K.fill(0);
396
+ } catch (err) {
397
+ unwrapNote = ui.c.red(`(could not unwrap delivered key: ${err.message || err})`);
398
+ }
399
+ }
400
+ }
401
+
166
402
  if (json) {
167
- process.stdout.write(JSON.stringify({ grant: g }, null, 2) + '\n');
403
+ process.stdout.write(
404
+ JSON.stringify({ grant: g, cachedSession: cachedK }, null, 2) + '\n',
405
+ );
168
406
  return;
169
407
  }
408
+
170
409
  process.stdout.write(`\n✓ access code accepted\n`);
171
410
  process.stdout.write(` project: ${g.projectName} (${g.projectId})\n`);
172
411
  process.stdout.write(` fingerprint: ${g.projectFingerprint}\n`);
@@ -174,12 +413,141 @@ async function runRedeem({ code, json = false }) {
174
413
  if (g.autoLockAt) process.stdout.write(` auto-lock: ${g.autoLockAt}\n`);
175
414
  process.stdout.write(` strict: ${g.strictMode ? 'yes' : 'no'}\n`);
176
415
  process.stdout.write(
177
- ` heartbeat: every ${g.heartbeatIntervalSeconds}s, ${g.offlineGraceSeconds}s offline grace\n\n`,
178
- );
179
- process.stdout.write(
180
- 'Next step (when the owner shares the unlocked vault with you):\n'
181
- + ' sealcode unlock\n\n',
416
+ ` heartbeat: every ${g.heartbeatIntervalSeconds}s, ${g.offlineGraceSeconds}s offline grace\n`,
182
417
  );
418
+ if (g.deviceFingerprintWarning) {
419
+ ui.warn(g.deviceFingerprintWarning);
420
+ }
421
+
422
+ if (cachedK) {
423
+ process.stdout.write('\n');
424
+ ui.ok('Key delivered via team-share envelope — no passphrase needed.');
425
+ // Auto-unlock + auto-spawn watcher. This is the user-asked-for
426
+ // "magic" flow: one command (`sealcode redeem`) handles redeem +
427
+ // unwrap + decrypt + supervise. Strict mode is implied by the
428
+ // server's `strictMode` flag on the grant.
429
+ try {
430
+ const { runUnlock } = require('./open');
431
+ const config = getActiveConfig(projectRoot);
432
+ ui.step(`auto-unlocking ${ui.c.dim(projectRoot)}`);
433
+ const sp = new ui.Spinner('decrypting').start();
434
+ let n = 0;
435
+ const K = loadSession(projectRoot); // freshly cached above
436
+ const ures = await runUnlock({
437
+ projectRoot,
438
+ config,
439
+ K,
440
+ removeStubs: true,
441
+ log: (msg) => {
442
+ n += 1;
443
+ const file = String(msg).replace(/^\s*unlocked\s+/, '');
444
+ sp.update(`decrypting ${ui.c.dim(`(${n})`)} ${file}`);
445
+ },
446
+ // sealcode@1.1.0 — apply the server-issued policy
447
+ policy: g.policy || {
448
+ allowedPaths: g.allowedPaths || null,
449
+ mode: g.mode || 'rw',
450
+ watermark: g.watermark || null,
451
+ idleAutoLockMinutes: g.idleAutoLockMinutes || 0,
452
+ },
453
+ watermarkCtx: {
454
+ email: g.wrappedKeyRecipient || readMyEmail(),
455
+ grantId: g.id,
456
+ projectName: g.projectName || '',
457
+ date: new Date().toISOString().slice(0, 10),
458
+ fingerprint: getDeviceFingerprint(),
459
+ },
460
+ });
461
+ const sk = ures.skipped > 0 ? ` ${ui.c.dim(`(${ures.skipped} file(s) outside grant scope skipped)`)}` : '';
462
+ sp.succeed(
463
+ `unlocked ${ui.c.bold(ures.count)} files${sk} ${ui.c.dim(`(locked at ${ures.sealedAt})`)}`,
464
+ );
465
+ if (ures.policy.mode === 'ro') {
466
+ ui.hint(' Read-only grant: files are 0444. Any modification will trigger an immediate re-lock.');
467
+ }
468
+ if (ures.policy.watermark) {
469
+ ui.hint(' Files are watermarked with your grant ID — leaks are traceable.');
470
+ }
471
+ } catch (err) {
472
+ ui.warn(`auto-unlock failed: ${err.message || err}`);
473
+ ui.hint(` Run \`sealcode unlock\` manually, then \`sealcode watch ${trimmed} --daemon\`.`);
474
+ }
475
+ try {
476
+ const { spawnDaemonWatcher } = require('./cli-watch');
477
+ const r = spawnDaemonWatcher({ projectRoot, code: trimmed });
478
+ if (r.error) {
479
+ ui.warn(`auto-spawn watcher failed: ${r.error}`);
480
+ ui.hint(` Run \`sealcode watch ${trimmed} --daemon\` manually.`);
481
+ } else {
482
+ ui.ok(
483
+ `watcher running ${ui.c.dim(`(pid ${r.pid}; logs ~/.sealcode/logs/)`)}`,
484
+ );
485
+ ui.hint(
486
+ g.strictMode
487
+ ? ' Strict mode: if you kill the watcher, your local plaintext re-locks immediately.'
488
+ : ' Lenient mode: watcher auto-locks on revoke / expiry only.',
489
+ );
490
+ }
491
+ } catch (err) {
492
+ ui.warn(`could not start watcher: ${err.message || err}`);
493
+ }
494
+ process.stdout.write('\n');
495
+ } else {
496
+ if (unwrapNote) {
497
+ process.stdout.write(` ${unwrapNote}\n`);
498
+ }
499
+ process.stdout.write(
500
+ '\nNext step (when the owner shares the unlocked vault with you):\n'
501
+ + ` ${ui.c.cyan('sealcode unlock')}\n`
502
+ + ` ${ui.c.cyan(`sealcode watch ${trimmed} &`)} ${ui.c.dim('# keep this running so revoke is instant')}\n\n`,
503
+ );
504
+ }
505
+ }
506
+
507
+ function readMyEmail() {
508
+ try {
509
+ const { readCreds } = require('./api');
510
+ const c = readCreds();
511
+ return (c && c.email) || '';
512
+ } catch (_) {
513
+ return '';
514
+ }
515
+ }
516
+
517
+ /**
518
+ * sealcode@1.1.0 — Project lockdown. Bulk-revokes every active grant
519
+ * on the linked project in a single server-side transaction, then
520
+ * pushes instant lock signals to every connected watcher via the
521
+ * long-poll event bus.
522
+ *
523
+ * Requires the project to be linked (cli-link.js).
524
+ */
525
+ async function runLockdown({ projectRoot, confirm = true } = {}) {
526
+ const { resolveLinkedProject } = require('./cli-link');
527
+ const linked = resolveLinkedProject(projectRoot);
528
+ if (!linked) {
529
+ ui.fail('This folder is not linked to a sealcode.dev project. Run `sealcode link` first.');
530
+ process.exitCode = 1;
531
+ return;
532
+ }
533
+ if (confirm) {
534
+ const { question } = require('./prompt');
535
+ ui.warn(`This will revoke EVERY active access code for ${ui.c.bold(linked.projectName || linked.projectId)}.`);
536
+ const reply = await question('Type "lockdown" to confirm: ');
537
+ if (String(reply).trim().toLowerCase() !== 'lockdown') {
538
+ ui.fail('Aborted.');
539
+ process.exitCode = 1;
540
+ return;
541
+ }
542
+ }
543
+ const res = await request('POST', `/api/v1/projects/${linked.projectId}/lockdown`, {
544
+ auth: true,
545
+ body: {},
546
+ });
547
+ ui.ok(`Lockdown complete — revoked ${ui.c.bold(res.revokedCount)} grant(s).`);
548
+ if (res.revokedCount > 0) {
549
+ ui.hint(' All connected watchers will re-lock within ~1 second.');
550
+ }
183
551
  }
184
552
 
185
- module.exports = { runShare, runGrants, runRevoke, runRedeem };
553
+ module.exports = { runShare, runGrants, runRevoke, runRedeem, runLockdown };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cli-guard — runs at the top of every sealcode command (see cli.js).
5
+ *
6
+ * Its job is to enforce the "strict watch" contract:
7
+ *
8
+ * - If the current project has a cached session whose `meta.source`
9
+ * is "grant" (i.e. the K was delivered via a temp-access code rather
10
+ * than typed as a passphrase), AND the watcher daemon is no longer
11
+ * supervising the project (process died, host rebooted, contractor
12
+ * ran kill -9 to "keep" the plaintext), AND the grant is flagged as
13
+ * `strictWatch`, then we lock the project IMMEDIATELY before running
14
+ * the user's actual command.
15
+ *
16
+ * This is the answer to "what stops a contractor from killing the
17
+ * watcher to keep my source": every other invocation of the sealcode
18
+ * binary on that machine will re-lock the moment the watcher is gone.
19
+ * Combined with the system service installer (cli-service.js), the
20
+ * watcher also auto-restarts at user login.
21
+ *
22
+ * The guard is purely best-effort + zero-config. It never throws. It
23
+ * never prompts. The worst it can do on a bug is print a warning.
24
+ *
25
+ * Lenient (non-strict) grants keep their files plaintext after a
26
+ * watcher death — but the watcher death is still recorded in the
27
+ * session meta and any subsequent `sealcode status` shows ⚠ next to
28
+ * the lock state so the user sees the gap.
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const { loadSessionMeta, clearSession, loadSession } = require('./keystore');
33
+ const { loadConfig } = require('./config');
34
+ const { detectPreset } = require('./presets');
35
+ const { isInitialized } = require('./keystore');
36
+ const { runLock } = require('./seal');
37
+ const { readWatcherStatus, watchStateFile } = require('./cli-watch');
38
+ const ui = require('./ui');
39
+
40
+ function getActiveConfig(projectRoot) {
41
+ const fromFile = loadConfig(projectRoot);
42
+ if (fromFile) return fromFile;
43
+ const preset = detectPreset(projectRoot);
44
+ return {
45
+ version: 1,
46
+ preset: preset.id,
47
+ lockedDir: preset.lockedDir,
48
+ include: preset.include,
49
+ exclude: preset.exclude,
50
+ stubs: preset.stubs || {},
51
+ _implicit: true,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Decide whether the current invocation should be blocked / re-locked
57
+ * before running. Called from cli.js before the user's command runs.
58
+ *
59
+ * @param {string} commandName 'unlock' | 'lock' | 'status' | ...
60
+ * The command about to run. We use this
61
+ * to avoid recursing — if user is already
62
+ * running `sealcode lock` we don't lock
63
+ * again from inside the guard.
64
+ * @param {string} projectRoot
65
+ * @returns {Promise<void>}
66
+ */
67
+ async function runGuard({ commandName, projectRoot }) {
68
+ // Commands that legitimately need to operate on a grant-derived
69
+ // unlocked project even without a watcher running: lock, panic, watch,
70
+ // status, where, logout, signout, presets, pro, install-hook,
71
+ // uninstall-hook, install-service, uninstall-service, redeem, login.
72
+ // The dangerous ones are unlock + commands that hand back plaintext.
73
+ // Right now we only lock on a small set of "you're about to use this
74
+ // for real" commands, to avoid annoying the user.
75
+ const ENFORCING_COMMANDS = new Set([
76
+ 'unlock', 'verify', 'rotate', 'backup', 'restore', 'install-hook',
77
+ ]);
78
+ if (!ENFORCING_COMMANDS.has(commandName)) return;
79
+
80
+ if (!projectRoot) return;
81
+ const config = getActiveConfig(projectRoot);
82
+ if (!isInitialized(projectRoot, config.lockedDir)) return;
83
+
84
+ const sm = loadSessionMeta(projectRoot);
85
+ if (!sm) return; // No live session → guard has nothing to do.
86
+ if (sm.meta.source !== 'grant') return; // Owner-passphrase sessions are out of scope.
87
+ if (!sm.meta.strictWatch) return; // Lenient grants — caller chose lenient.
88
+
89
+ const status = readWatcherStatus(projectRoot);
90
+ if (status.state === 'alive') return; // Healthy supervisor — proceed.
91
+
92
+ // Watcher is dead, stale, or missing. Lock + clear session + warn.
93
+ try {
94
+ const K = loadSession(projectRoot);
95
+ if (K) {
96
+ try {
97
+ await runLock({ projectRoot, config, K });
98
+ } catch (_) {
99
+ // We tried; even if lock failed, still clear the session.
100
+ }
101
+ }
102
+ } finally {
103
+ clearSession(projectRoot);
104
+ try { fs.unlinkSync(watchStateFile(projectRoot)); } catch (_) { /* ignore */ }
105
+ }
106
+
107
+ ui.warn(
108
+ `Watcher was not running (${status.state}) — auto-locked this project. `
109
+ + `Run \`sealcode redeem <code>\` again to resume.`,
110
+ );
111
+ // We don't throw — we let the user's command continue against the now-
112
+ // locked project. If they were running `unlock`, it will prompt them
113
+ // for a passphrase (which they don't have for a grant flow), giving
114
+ // them a clear "this grant has expired on this machine" experience.
115
+ }
116
+
117
+ module.exports = { runGuard };
package/src/cli-link.js CHANGED
@@ -87,4 +87,13 @@ function runLinkInfo({ projectRoot, json = false }) {
87
87
  );
88
88
  }
89
89
 
90
- module.exports = { runLink, runUnlink, runLinkInfo };
90
+ /**
91
+ * sealcode@1.1.0 helper used by `runLockdown` and other multi-target
92
+ * project commands. Returns the persisted link object (or null) for the
93
+ * given project root.
94
+ */
95
+ function resolveLinkedProject(projectRoot) {
96
+ return getLink(projectRoot) || null;
97
+ }
98
+
99
+ module.exports = { runLink, runUnlink, runLinkInfo, resolveLinkedProject };