sealcode 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
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",
package/src/cli-grants.js CHANGED
@@ -354,20 +354,24 @@ async function runPause({ grantId, reason, extendOnResume = false }) {
354
354
  /**
355
355
  * sealcode@1.2.0 — Resume a paused access code from the CLI.
356
356
  *
357
- * sealcode resume <grantId>
357
+ * sealcode resume <grantId> [--unlock]
358
358
  *
359
- * If the grant was paused with --extend-on-resume, the expiresAt shifts
360
- * forward by the just-elapsed pause duration. The recipient must run
361
- * `sealcode redeem <code>` again to re-open their working copy.
359
+ * sealcode@1.3.0:
360
+ * --unlock pushes an unlock event to the recipient's live watcher,
361
+ * which re-materializes plaintext using the K it kept cached through
362
+ * the pause. Without --unlock, status flips back to active but the
363
+ * recipient stays locked until they manually `sealcode unlock`
364
+ * (which now works without a passphrase since the session was
365
+ * preserved through the pause).
362
366
  */
363
- async function runResume({ grantId }) {
364
- if (!grantId) throw new Error('Usage: sealcode resume <grantId>');
367
+ async function runResume({ grantId, autoUnlock = false }) {
368
+ if (!grantId) throw new Error('Usage: sealcode resume <grantId> [--unlock]');
365
369
  let res;
366
370
  try {
367
371
  res = await request(
368
372
  'POST',
369
373
  `/api/v1/grants/${encodeURIComponent(grantId)}/resume`,
370
- { auth: true, body: {} },
374
+ { auth: true, body: { autoUnlock: !!autoUnlock } },
371
375
  );
372
376
  } catch (err) {
373
377
  if (err instanceof ApiError) {
@@ -391,7 +395,53 @@ async function runResume({ grantId }) {
391
395
  const mins = Math.round((res.pausedDurationMs || 0) / 60000);
392
396
  process.stdout.write(` expiry shifted forward by ~${mins}m (paused duration)\n`);
393
397
  }
394
- process.stdout.write(' Recipient must re-redeem the code to re-open the vault.\n');
398
+ if (autoUnlock) {
399
+ process.stdout.write(' Recipient\'s 1.3+ watcher will auto-unlock within ~1 second.\n');
400
+ process.stdout.write(' (No redeem needed — K was kept cached through the pause.)\n');
401
+ } else {
402
+ process.stdout.write(' Recipient can run `sealcode unlock` to re-open (no passphrase needed).\n');
403
+ }
404
+ }
405
+
406
+ /**
407
+ * sealcode@1.3.0 — Ask the server whether a grant-derived session is
408
+ * currently allowed to unlock. Used by the `unlock` command to refuse
409
+ * unlocking while the grant is paused (or revoked / expired) WITHOUT
410
+ * requiring the user to redeem first.
411
+ *
412
+ * Returns one of:
413
+ * { allow: true, reason: 'active', ... }
414
+ * { allow: false, reason: 'paused' | 'revoked' | 'expired' | 'not_found', ... }
415
+ * { allow: true, reason: 'network_unreachable' } (best-effort fallback)
416
+ *
417
+ * The last case lets a recipient who's offline still unlock — by
418
+ * design. If you want strict "must be online to unlock", pair this
419
+ * with the `strictMode` grant flag and the watcher's offline-grace
420
+ * (which already covers that case).
421
+ */
422
+ async function checkUnlockAllowed(code, { strict = false } = {}) {
423
+ if (!code) return { allow: true, reason: 'no_code_local_only' };
424
+ try {
425
+ const res = await request('POST', '/api/v1/access/unlock-check', {
426
+ body: {
427
+ code: String(code).trim(),
428
+ deviceFingerprint: getDeviceFingerprint(),
429
+ },
430
+ timeoutMs: 8000,
431
+ });
432
+ return res || { allow: false, reason: 'unknown' };
433
+ } catch (err) {
434
+ if (err instanceof ApiError && err.status === 404) {
435
+ // Older deploys (pre-1.3) without the endpoint — degrade open
436
+ // so existing CLIs against an older server don't break.
437
+ return { allow: true, reason: 'endpoint_missing' };
438
+ }
439
+ if (err instanceof ApiError && (err.status === 0 || err.status >= 500)) {
440
+ if (strict) return { allow: false, reason: 'network_unreachable_strict' };
441
+ return { allow: true, reason: 'network_unreachable' };
442
+ }
443
+ throw err;
444
+ }
395
445
  }
396
446
 
397
447
  async function runRevoke({ grantId }) {
@@ -481,6 +531,13 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
481
531
  source: 'grant',
482
532
  grantId: g.id,
483
533
  grantCodeHash: require('crypto').createHash('sha256').update(trimmed).digest('hex'),
534
+ // sealcode@1.3.0 — stash the code itself so the local
535
+ // unlock command can ask the server "may I unlock?" after
536
+ // a pause without requiring the user to re-type it. Lives
537
+ // under ~/.sealcode/sessions/ at mode 0600 alongside the
538
+ // already-cached K, so this doesn't materially expand the
539
+ // local attack surface.
540
+ grantCode: trimmed,
484
541
  grantExpiresAt: g.expiresAt,
485
542
  deviceFingerprint: getDeviceFingerprint(),
486
543
  strictWatch: !!g.strictMode,
@@ -667,4 +724,5 @@ module.exports = {
667
724
  runLockdown,
668
725
  runPause,
669
726
  runResume,
727
+ checkUnlockAllowed,
670
728
  };
package/src/cli-watch.js CHANGED
@@ -55,6 +55,7 @@ const {
55
55
  projectId,
56
56
  } = require('./keystore');
57
57
  const { runLock } = require('./seal');
58
+ const { runUnlock } = require('./open');
58
59
  const { getDeviceFingerprint } = require('./device');
59
60
  const { SealcodeError } = require('./errors');
60
61
  const grantPolicy = require('./grant-policy');
@@ -529,10 +530,22 @@ async function runWatch({
529
530
  strictWatch: strict,
530
531
  });
531
532
 
533
+ // sealcode@1.3.0 — track local pause state. When true, files are
534
+ // locked-on-disk but K is still in our session cache. We keep
535
+ // heartbeating so a server-side "unlock" event (owner resumed with
536
+ // auto-unlock) can re-materialize plaintext without a redeem.
537
+ let pausedLocally = false;
538
+
532
539
  // If we somehow started watching an already-locked grant, lock first
533
- // and exit. Don't pretend everything's fine.
540
+ // and exit (revoke/expire/etc), OR soft-lock and stay running (paused).
534
541
  if (first.response.action === 'lock') {
535
- return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
542
+ if (first.response.keepSession || first.response.reason === 'paused') {
543
+ await softLock(projectRoot, config, trimmedCode, first.response.reason || 'paused', json, daemon);
544
+ pausedLocally = true;
545
+ writeWatchState(projectRoot, { pausedLocally: true, pausedReason: first.response.reason || 'paused' });
546
+ } else {
547
+ return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
548
+ }
536
549
  }
537
550
 
538
551
  if (daemon) {
@@ -695,9 +708,76 @@ async function runWatch({
695
708
  continue;
696
709
  }
697
710
  consecutiveTransient = 0;
711
+
712
+ // sealcode@1.3.0 — branch on action.
698
713
  if (r.response.action === 'lock') {
714
+ // Soft lock (paused): re-encrypt, KEEP K, KEEP watcher alive.
715
+ // The 1.3 server marks this with `keepSession: true`; we also
716
+ // honor the legacy `reason: "paused"` from 1.2.0 servers in case
717
+ // someone is on a mixed deploy.
718
+ const isSoft = !!r.response.keepSession || r.response.reason === 'paused';
719
+ if (isSoft) {
720
+ if (!pausedLocally) {
721
+ await softLock(projectRoot, config, trimmedCode, r.response.reason || 'paused', json, daemon);
722
+ pausedLocally = true;
723
+ writeWatchState(projectRoot, { pausedLocally: true, pausedReason: r.response.reason || 'paused' });
724
+ appendLog(projectRoot, { type: 'soft_locked', reason: r.response.reason || 'paused' });
725
+ }
726
+ // Stay in the loop — wait for the next event (unlock or hard lock).
727
+ continue;
728
+ }
729
+ // Hard lock — revoke / expire / device mismatch / idle / etc.
699
730
  return finalLock(projectRoot, config, trimmedCode, r.response.reason, json, daemon);
700
731
  }
732
+
733
+ if (r.response.action === 'unlock') {
734
+ // Owner resumed with auto-unlock. Re-materialize plaintext using
735
+ // the K we kept cached through the pause.
736
+ try {
737
+ const K = loadSession(projectRoot);
738
+ if (!K) {
739
+ // We somehow lost K (e.g. another process cleared it). Best
740
+ // we can do: stay locked and nudge the user.
741
+ appendLog(projectRoot, { type: 'unlock_skipped_no_key' });
742
+ if (!daemon && !json) ui.warn(`[${ts()}] resumed, but no cached key on disk — run: sealcode redeem ${trimmedCode}`);
743
+ } else {
744
+ const sm = loadSessionMeta(projectRoot);
745
+ const pol = (sm && sm.meta && sm.meta.policy) || {};
746
+ const wctx = pol.watermark ? {
747
+ email: (sm && sm.meta && sm.meta.recipientEmail) || '',
748
+ grantId: (sm && sm.meta && sm.meta.grantId) || '',
749
+ projectName: (sm && sm.meta && sm.meta.projectName) || '',
750
+ date: new Date().toISOString().slice(0, 10),
751
+ fingerprint: getDeviceFingerprint(),
752
+ } : null;
753
+ const ures = await runUnlock({
754
+ projectRoot, config, K, removeStubs: true, policy: pol, watermarkCtx: wctx,
755
+ log: () => {},
756
+ });
757
+ K.fill(0);
758
+ appendLog(projectRoot, { type: 'auto_unlocked', count: ures.count, reason: r.response.reason || 'resumed' });
759
+ if (!daemon && !json) ui.ok(`[${ts()}] owner resumed — auto-unlocked ${ui.c.bold(ures.count)} files`);
760
+ }
761
+ } catch (err) {
762
+ appendLog(projectRoot, { type: 'auto_unlock_error', error: String(err.message || err) });
763
+ if (!daemon && !json) ui.warn(`[${ts()}] auto-unlock failed: ${err.message || err}`);
764
+ }
765
+ pausedLocally = false;
766
+ writeWatchState(projectRoot, { pausedLocally: false, pausedReason: null });
767
+ continue;
768
+ }
769
+
770
+ // Plain "ok" or "warn". If we WERE paused and the server now says
771
+ // "ok" without sending an unlock event, that means the owner
772
+ // resumed without auto-unlock. Clear our paused flag so the user's
773
+ // `sealcode unlock` invocation will be allowed by the local guard;
774
+ // we leave files locked-on-disk and let them choose when to unlock.
775
+ if (pausedLocally && (r.response.action === 'ok' || r.response.action === 'warn')) {
776
+ pausedLocally = false;
777
+ writeWatchState(projectRoot, { pausedLocally: false, pausedReason: null });
778
+ appendLog(projectRoot, { type: 'resumed_manual' });
779
+ if (!daemon && !json) ui.ok(`[${ts()}] owner resumed — files stay locked until you run: ${ui.c.cyan('sealcode unlock')}`);
780
+ }
701
781
  printTick(r.response, json, verbose, daemon, projectRoot);
702
782
  // After a successful (possibly long-polled) heartbeat we do NOT
703
783
  // sleep here — long-poll already consumed our budget. If the server
@@ -731,6 +811,61 @@ function printTick(resp, json, verbose, daemon, projectRoot) {
731
811
  }
732
812
  }
733
813
 
814
+ /**
815
+ * sealcode@1.3.0 — Soft lock: re-encrypt plaintext but KEEP the cached
816
+ * K and KEEP the watcher process alive. Used for pause, which is
817
+ * meant to be reversible — the owner can resume and (optionally)
818
+ * push an `action: "unlock"` event that re-materializes the plaintext
819
+ * without requiring the recipient to redeem again.
820
+ *
821
+ * Idempotent: if there's no K cached (e.g. we restarted into an
822
+ * already-locked project), this is a no-op.
823
+ */
824
+ async function softLock(projectRoot, config, code, reason, json, daemon) {
825
+ if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
826
+ const label = reason || 'paused';
827
+ if (daemon) {
828
+ appendLog(projectRoot, { type: 'soft_lock', reason: label });
829
+ } else if (json) {
830
+ process.stdout.write(JSON.stringify({ type: 'soft_lock', reason: label }) + '\n');
831
+ } else {
832
+ ui.warn(`[${ts()}] paused by owner — re-locking, keeping session for resume`);
833
+ }
834
+
835
+ const K = loadSession(projectRoot);
836
+ if (!K) {
837
+ // Nothing to re-encrypt. Files are already in their locked state.
838
+ return;
839
+ }
840
+
841
+ const _sessionMeta = loadSessionMeta(projectRoot);
842
+ const _preserveUnseen =
843
+ !!_sessionMeta &&
844
+ _sessionMeta.meta &&
845
+ _sessionMeta.meta.source === 'grant' &&
846
+ _sessionMeta.meta.policy &&
847
+ Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
848
+ _sessionMeta.meta.policy.allowedPaths.length > 0;
849
+
850
+ try {
851
+ const res = await runLock({ projectRoot, config, K, preserveUnseen: _preserveUnseen });
852
+ K.fill(0);
853
+ if (daemon) {
854
+ appendLog(projectRoot, { type: 'soft_locked', count: res.count, reason: label });
855
+ } else if (!json) {
856
+ ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files — waiting for owner to resume`);
857
+ ui.hint(` Files will auto-unlock if the owner picks "Resume + unlock now",`);
858
+ ui.hint(` or run ${ui.c.cyan('sealcode unlock')} yourself once they resume (no passphrase).`);
859
+ }
860
+ } catch (err) {
861
+ // If soft-lock fails we DO NOT escalate to hard-lock — that would
862
+ // wipe the session and force a redeem, which is exactly the UX we
863
+ // built this feature to avoid. Surface the error and stay locked.
864
+ if (daemon) appendLog(projectRoot, { type: 'soft_lock_error', error: String(err.message || err) });
865
+ else if (!json) ui.fail(`soft-lock failed: ${err.message}. Files may still contain plaintext — investigate.`);
866
+ }
867
+ }
868
+
734
869
  async function finalLock(projectRoot, config, code, reason, json, daemon) {
735
870
  if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
736
871
  const label = reason || 'lock';
@@ -781,7 +916,8 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
781
916
  } else {
782
917
  ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')} — session cleared`);
783
918
  // sealcode@1.2.0 — be specific about the "paused" case so the
784
- // recipient knows it's reversible and what their next step is.
919
+ // recipient knows it's reversible. (sealcode@1.3.0+ goes through
920
+ // softLock instead and won't reach this branch.)
785
921
  if (label === 'paused') {
786
922
  ui.hint(' This access code was PAUSED by the owner — not revoked.');
787
923
  ui.hint(` Wait for the owner to resume, then run: ${ui.c.cyan(`sealcode redeem ${code}`)}`);
package/src/cli.js CHANGED
@@ -40,6 +40,7 @@ const {
40
40
  runRedeem,
41
41
  runPause,
42
42
  runResume,
43
+ checkUnlockAllowed,
43
44
  } = require('./cli-grants');
44
45
  const {
45
46
  runCiCreate,
@@ -286,6 +287,63 @@ function build() {
286
287
  try {
287
288
  const projectRoot = resolveProject(program.opts());
288
289
  const config = getActiveConfig(projectRoot);
290
+
291
+ // sealcode@1.3.0 — grant-derived sessions must clear a server
292
+ // status check before unlocking. This is what stops a recipient
293
+ // from running `sealcode unlock` to bypass a pause.
294
+ //
295
+ // We do this BEFORE resolveKey so a paused/revoked grant
296
+ // doesn't even decrypt the cached K from disk. If the local
297
+ // session isn't grant-derived (owner-passphrase flow), this
298
+ // whole block is a no-op.
299
+ {
300
+ const { loadSessionMeta: _lsm } = require('./keystore');
301
+ const _sm = _lsm(projectRoot);
302
+ if (_sm && _sm.meta && _sm.meta.source === 'grant' && _sm.meta.grantCode) {
303
+ const _strict = !!_sm.meta.strictWatch;
304
+ const _gate = await checkUnlockAllowed(_sm.meta.grantCode, { strict: _strict });
305
+ if (!_gate.allow) {
306
+ if (_gate.reason === 'paused') {
307
+ ui.fail(`This access has been PAUSED by the project owner.`);
308
+ if (_gate.pauseReason) ui.hint(` Reason: ${_gate.pauseReason}`);
309
+ ui.hint(` Files stay locked until the owner resumes. You'll get an automatic`);
310
+ ui.hint(` notification (and auto-unlock) when they do — no redeem needed.`);
311
+ process.exitCode = 1;
312
+ return;
313
+ }
314
+ if (_gate.reason === 'revoked') {
315
+ ui.fail('This access code has been revoked. You can no longer unlock.');
316
+ ui.hint(' Ask the owner for a fresh code: `sealcode share` on their side.');
317
+ process.exitCode = 1;
318
+ return;
319
+ }
320
+ if (_gate.reason === 'expired') {
321
+ ui.fail('This access code has expired. You can no longer unlock.');
322
+ ui.hint(' Ask the owner for a fresh code.');
323
+ process.exitCode = 1;
324
+ return;
325
+ }
326
+ if (_gate.reason === 'not_found') {
327
+ ui.fail('Access code not found on the server (it may have been deleted).');
328
+ process.exitCode = 1;
329
+ return;
330
+ }
331
+ if (_gate.reason === 'network_unreachable_strict') {
332
+ ui.fail('Cannot reach sealcode.dev to verify access status, and this grant is strict.');
333
+ ui.hint(' Reconnect to the network, then re-run `sealcode unlock`.');
334
+ process.exitCode = 1;
335
+ return;
336
+ }
337
+ ui.fail(`Unlock denied (${_gate.reason}).`);
338
+ process.exitCode = 1;
339
+ return;
340
+ }
341
+ if (_gate.reason === 'network_unreachable') {
342
+ ui.warn(`Could not reach sealcode.dev — proceeding with cached key (lenient grant).`);
343
+ }
344
+ }
345
+ }
346
+
289
347
  const K = await resolveKey(projectRoot, config, { useRecovery: opts.recovery });
290
348
  ui.step(`unlocking ${ui.c.dim(projectRoot)}`);
291
349
  const sp = new ui.Spinner('decrypting').start();
@@ -774,14 +832,15 @@ function build() {
774
832
  }
775
833
  });
776
834
 
777
- // -------- resume (sealcode@1.2.0) --------
835
+ // -------- resume (sealcode@1.2.0; --unlock added 1.3.0) --------
778
836
  program
779
837
  .command('resume')
780
838
  .argument('<grantId>', 'grant ID to resume (see `sealcode grants`)')
781
- .description('Resume a paused access code. The recipient must `sealcode redeem <code>` again to re-open their working copy.')
782
- .action(async (grantId) => {
839
+ .description('Resume a paused access code. The recipient\'s 1.3+ watcher can re-open the working copy automatically — no redeem needed.')
840
+ .option('--unlock', 'also push an unlock event so the recipient\'s files re-materialize automatically (1.3+ watcher required)', false)
841
+ .action(async (grantId, opts) => {
783
842
  try {
784
- await runResume({ grantId });
843
+ await runResume({ grantId, autoUnlock: !!opts.unlock });
785
844
  } catch (err) {
786
845
  process.exitCode = reportError(err);
787
846
  }