sealcode 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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');
@@ -313,6 +314,7 @@ function startExfilWatchers({
313
314
  encoding: 'utf8',
314
315
  timeout: 1500,
315
316
  stdio: ['ignore', 'pipe', 'ignore'],
317
+ windowsHide: true,
316
318
  })
317
319
  .trim();
318
320
  } catch (_) { /* repo may not have git */ }
@@ -416,6 +418,7 @@ function startExfilWatchers({
416
418
  const cur = cp
417
419
  .execFileSync('git', ['-C', projectAbs, 'remote', '-v'], {
418
420
  encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'],
421
+ windowsHide: true,
419
422
  })
420
423
  .trim();
421
424
  if (initialRemotes && cur !== initialRemotes) {
@@ -529,10 +532,22 @@ async function runWatch({
529
532
  strictWatch: strict,
530
533
  });
531
534
 
535
+ // sealcode@1.3.0 — track local pause state. When true, files are
536
+ // locked-on-disk but K is still in our session cache. We keep
537
+ // heartbeating so a server-side "unlock" event (owner resumed with
538
+ // auto-unlock) can re-materialize plaintext without a redeem.
539
+ let pausedLocally = false;
540
+
532
541
  // If we somehow started watching an already-locked grant, lock first
533
- // and exit. Don't pretend everything's fine.
542
+ // and exit (revoke/expire/etc), OR soft-lock and stay running (paused).
534
543
  if (first.response.action === 'lock') {
535
- return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
544
+ if (first.response.keepSession || first.response.reason === 'paused') {
545
+ await softLock(projectRoot, config, trimmedCode, first.response.reason || 'paused', json, daemon);
546
+ pausedLocally = true;
547
+ writeWatchState(projectRoot, { pausedLocally: true, pausedReason: first.response.reason || 'paused' });
548
+ } else {
549
+ return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
550
+ }
536
551
  }
537
552
 
538
553
  if (daemon) {
@@ -695,9 +710,76 @@ async function runWatch({
695
710
  continue;
696
711
  }
697
712
  consecutiveTransient = 0;
713
+
714
+ // sealcode@1.3.0 — branch on action.
698
715
  if (r.response.action === 'lock') {
716
+ // Soft lock (paused): re-encrypt, KEEP K, KEEP watcher alive.
717
+ // The 1.3 server marks this with `keepSession: true`; we also
718
+ // honor the legacy `reason: "paused"` from 1.2.0 servers in case
719
+ // someone is on a mixed deploy.
720
+ const isSoft = !!r.response.keepSession || r.response.reason === 'paused';
721
+ if (isSoft) {
722
+ if (!pausedLocally) {
723
+ await softLock(projectRoot, config, trimmedCode, r.response.reason || 'paused', json, daemon);
724
+ pausedLocally = true;
725
+ writeWatchState(projectRoot, { pausedLocally: true, pausedReason: r.response.reason || 'paused' });
726
+ appendLog(projectRoot, { type: 'soft_locked', reason: r.response.reason || 'paused' });
727
+ }
728
+ // Stay in the loop — wait for the next event (unlock or hard lock).
729
+ continue;
730
+ }
731
+ // Hard lock — revoke / expire / device mismatch / idle / etc.
699
732
  return finalLock(projectRoot, config, trimmedCode, r.response.reason, json, daemon);
700
733
  }
734
+
735
+ if (r.response.action === 'unlock') {
736
+ // Owner resumed with auto-unlock. Re-materialize plaintext using
737
+ // the K we kept cached through the pause.
738
+ try {
739
+ const K = loadSession(projectRoot);
740
+ if (!K) {
741
+ // We somehow lost K (e.g. another process cleared it). Best
742
+ // we can do: stay locked and nudge the user.
743
+ appendLog(projectRoot, { type: 'unlock_skipped_no_key' });
744
+ if (!daemon && !json) ui.warn(`[${ts()}] resumed, but no cached key on disk — run: sealcode redeem ${trimmedCode}`);
745
+ } else {
746
+ const sm = loadSessionMeta(projectRoot);
747
+ const pol = (sm && sm.meta && sm.meta.policy) || {};
748
+ const wctx = pol.watermark ? {
749
+ email: (sm && sm.meta && sm.meta.recipientEmail) || '',
750
+ grantId: (sm && sm.meta && sm.meta.grantId) || '',
751
+ projectName: (sm && sm.meta && sm.meta.projectName) || '',
752
+ date: new Date().toISOString().slice(0, 10),
753
+ fingerprint: getDeviceFingerprint(),
754
+ } : null;
755
+ const ures = await runUnlock({
756
+ projectRoot, config, K, removeStubs: true, policy: pol, watermarkCtx: wctx,
757
+ log: () => {},
758
+ });
759
+ K.fill(0);
760
+ appendLog(projectRoot, { type: 'auto_unlocked', count: ures.count, reason: r.response.reason || 'resumed' });
761
+ if (!daemon && !json) ui.ok(`[${ts()}] owner resumed — auto-unlocked ${ui.c.bold(ures.count)} files`);
762
+ }
763
+ } catch (err) {
764
+ appendLog(projectRoot, { type: 'auto_unlock_error', error: String(err.message || err) });
765
+ if (!daemon && !json) ui.warn(`[${ts()}] auto-unlock failed: ${err.message || err}`);
766
+ }
767
+ pausedLocally = false;
768
+ writeWatchState(projectRoot, { pausedLocally: false, pausedReason: null });
769
+ continue;
770
+ }
771
+
772
+ // Plain "ok" or "warn". If we WERE paused and the server now says
773
+ // "ok" without sending an unlock event, that means the owner
774
+ // resumed without auto-unlock. Clear our paused flag so the user's
775
+ // `sealcode unlock` invocation will be allowed by the local guard;
776
+ // we leave files locked-on-disk and let them choose when to unlock.
777
+ if (pausedLocally && (r.response.action === 'ok' || r.response.action === 'warn')) {
778
+ pausedLocally = false;
779
+ writeWatchState(projectRoot, { pausedLocally: false, pausedReason: null });
780
+ appendLog(projectRoot, { type: 'resumed_manual' });
781
+ if (!daemon && !json) ui.ok(`[${ts()}] owner resumed — files stay locked until you run: ${ui.c.cyan('sealcode unlock')}`);
782
+ }
701
783
  printTick(r.response, json, verbose, daemon, projectRoot);
702
784
  // After a successful (possibly long-polled) heartbeat we do NOT
703
785
  // sleep here — long-poll already consumed our budget. If the server
@@ -731,6 +813,61 @@ function printTick(resp, json, verbose, daemon, projectRoot) {
731
813
  }
732
814
  }
733
815
 
816
+ /**
817
+ * sealcode@1.3.0 — Soft lock: re-encrypt plaintext but KEEP the cached
818
+ * K and KEEP the watcher process alive. Used for pause, which is
819
+ * meant to be reversible — the owner can resume and (optionally)
820
+ * push an `action: "unlock"` event that re-materializes the plaintext
821
+ * without requiring the recipient to redeem again.
822
+ *
823
+ * Idempotent: if there's no K cached (e.g. we restarted into an
824
+ * already-locked project), this is a no-op.
825
+ */
826
+ async function softLock(projectRoot, config, code, reason, json, daemon) {
827
+ if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
828
+ const label = reason || 'paused';
829
+ if (daemon) {
830
+ appendLog(projectRoot, { type: 'soft_lock', reason: label });
831
+ } else if (json) {
832
+ process.stdout.write(JSON.stringify({ type: 'soft_lock', reason: label }) + '\n');
833
+ } else {
834
+ ui.warn(`[${ts()}] paused by owner — re-locking, keeping session for resume`);
835
+ }
836
+
837
+ const K = loadSession(projectRoot);
838
+ if (!K) {
839
+ // Nothing to re-encrypt. Files are already in their locked state.
840
+ return;
841
+ }
842
+
843
+ const _sessionMeta = loadSessionMeta(projectRoot);
844
+ const _preserveUnseen =
845
+ !!_sessionMeta &&
846
+ _sessionMeta.meta &&
847
+ _sessionMeta.meta.source === 'grant' &&
848
+ _sessionMeta.meta.policy &&
849
+ Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
850
+ _sessionMeta.meta.policy.allowedPaths.length > 0;
851
+
852
+ try {
853
+ const res = await runLock({ projectRoot, config, K, preserveUnseen: _preserveUnseen });
854
+ K.fill(0);
855
+ if (daemon) {
856
+ appendLog(projectRoot, { type: 'soft_locked', count: res.count, reason: label });
857
+ } else if (!json) {
858
+ ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files — waiting for owner to resume`);
859
+ ui.hint(` Files will auto-unlock if the owner picks "Resume + unlock now",`);
860
+ ui.hint(` or run ${ui.c.cyan('sealcode unlock')} yourself once they resume (no passphrase).`);
861
+ }
862
+ } catch (err) {
863
+ // If soft-lock fails we DO NOT escalate to hard-lock — that would
864
+ // wipe the session and force a redeem, which is exactly the UX we
865
+ // built this feature to avoid. Surface the error and stay locked.
866
+ if (daemon) appendLog(projectRoot, { type: 'soft_lock_error', error: String(err.message || err) });
867
+ else if (!json) ui.fail(`soft-lock failed: ${err.message}. Files may still contain plaintext — investigate.`);
868
+ }
869
+ }
870
+
734
871
  async function finalLock(projectRoot, config, code, reason, json, daemon) {
735
872
  if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
736
873
  const label = reason || 'lock';
@@ -781,7 +918,8 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
781
918
  } else {
782
919
  ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')} — session cleared`);
783
920
  // sealcode@1.2.0 — be specific about the "paused" case so the
784
- // recipient knows it's reversible and what their next step is.
921
+ // recipient knows it's reversible. (sealcode@1.3.0+ goes through
922
+ // softLock instead and won't reach this branch.)
785
923
  if (label === 'paused') {
786
924
  ui.hint(' This access code was PAUSED by the owner — not revoked.');
787
925
  ui.hint(` Wait for the owner to resume, then run: ${ui.c.cyan(`sealcode redeem ${code}`)}`);
@@ -796,9 +934,33 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
796
934
  * Helper: spawn a detached `sealcode watch <code> --daemon` child. Used
797
935
  * by runUnlock when the unlock came from a grant-derived session.
798
936
  * Returns the child pid (already detached). Best-effort.
937
+ *
938
+ * Windows specifics (sealcode@1.3.1):
939
+ * - `windowsHide: true` keeps the spawned process from popping a
940
+ * console window. Without this, every daemon spawn flashes a black
941
+ * cmd window which users (rightly) find alarming.
942
+ * - We also guard against re-spawn loops: if a daemon is already
943
+ * alive for this project we just return its pid instead of
944
+ * spawning a new one. Without this guard, repeated `redeem` calls
945
+ * (or any startup hook that re-runs redeem) accumulate orphan
946
+ * daemons that each show up as a pop-and-vanish window when they
947
+ * periodically run their child-process sweeps.
799
948
  */
800
949
  function spawnDaemonWatcher({ projectRoot, code }) {
801
950
  try {
951
+ // If a healthy daemon is already running for this project, don't
952
+ // start another one. This is the single most important defense
953
+ // against the "windows pop in and out" loop on Windows.
954
+ const existing = readWatcherStatus(projectRoot);
955
+ if (existing.state === 'alive') {
956
+ return { pid: existing.pid, reused: true };
957
+ }
958
+ // Stale / dead state file should be cleared before respawn, otherwise
959
+ // the new daemon may collide with leftover bookkeeping.
960
+ if (existing.state === 'stale' || existing.state === 'dead') {
961
+ try { fs.unlinkSync(watchStateFile(projectRoot)); } catch (_) { /* ignore */ }
962
+ }
963
+
802
964
  ensureDir(WATCH_LOG_DIR);
803
965
  const bin = process.execPath;
804
966
  const entry = require.resolve('../bin/sealcode.js');
@@ -809,6 +971,9 @@ function spawnDaemonWatcher({ projectRoot, code }) {
809
971
  cwd: projectRoot,
810
972
  detached: true,
811
973
  stdio: 'ignore',
974
+ // Critical on Windows — without this, the spawned daemon
975
+ // opens a visible cmd window. macOS / Linux ignore the flag.
976
+ windowsHide: true,
812
977
  env: { ...process.env, SEALCODE_AUTO_DAEMON: '1' },
813
978
  },
814
979
  );
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
  }
package/src/device.js CHANGED
@@ -72,6 +72,9 @@ function readMachineId() {
72
72
  encoding: 'utf8',
73
73
  timeout: 1500,
74
74
  stdio: ['ignore', 'pipe', 'ignore'],
75
+ // Stop reg.exe from flashing a console window — happens on every
76
+ // command run because device.js is loaded by `clientInfo()`.
77
+ windowsHide: true,
75
78
  });
76
79
  const m = /MachineGuid\s+REG_SZ\s+([0-9a-f-]+)/i.exec(out);
77
80
  return m ? m[1] : null;