sealcode 0.3.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/README.md CHANGED
@@ -85,9 +85,21 @@ sealcode uninstall-hook # remove hook block
85
85
  sealcode panic # immediate re-lock + session wipe
86
86
  sealcode logout # clear cached session, force passphrase next time
87
87
  sealcode presets # list supported ecosystems
88
- sealcode share <opts> # mint a temporary access code (Pro; needs link + login)
89
- sealcode redeem <code> # accept an access code shared with you
90
- sealcode watch <code> # poll the server; auto-lock if the owner revokes (Pro)
88
+ sealcode share <opts> # mint a temporary access code (--email <addr> wraps the key for them)
89
+ # 1.1 controls (all optional):
90
+ # --paths src/api/,tests/ only decrypt these prefixes
91
+ # --mode ro read-only; watcher re-locks on edit
92
+ # --watermark "Licensed to {email} · grant {grantId}"
93
+ # --idle 30 auto-lock after N idle minutes
94
+ # --allow-ip 10.0.0.0/8 restrict redeem + heartbeat IPs
95
+ # --allow-country US GB ISO-2 country allowlist (Cloudflare-fed)
96
+ # --single-device hard-reject 2nd device fingerprint
97
+ # --nda "text" require typed "I agree" on redeem
98
+ sealcode redeem <code> # accept a code; auto-unlocks + spawns watcher when team-shared
99
+ sealcode watch <code> # poll the server with long-poll (~1s revoke); --daemon for background
100
+ sealcode lockdown # 1.1: panic — revoke ALL active grants for the linked project
101
+ sealcode install-service # macOS launchd / Linux systemd unit so the watcher survives reboots
102
+ sealcode uninstall-service
91
103
  sealcode pro # Pro tier info
92
104
  sealcode where # debug: print paths and state (no secrets)
93
105
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "0.3.0",
3
+ "version": "1.1.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/api.js CHANGED
@@ -86,7 +86,7 @@ class ApiError extends Error {
86
86
  }
87
87
  }
88
88
 
89
- async function request(method, urlPath, { body, auth = false, apiUrl } = {}) {
89
+ async function request(method, urlPath, { body, auth = false, apiUrl, timeoutMs } = {}) {
90
90
  const base = apiUrl || getApiUrl();
91
91
  const url = new URL(urlPath, base).toString();
92
92
  const headers = {
@@ -106,12 +106,23 @@ async function request(method, urlPath, { body, auth = false, apiUrl } = {}) {
106
106
  headers.authorization = `Bearer ${token}`;
107
107
  }
108
108
 
109
+ // sealcode@1.1.0 — optional per-call timeout. We use an AbortController
110
+ // and let the caller pass `timeoutMs` for long-polled heartbeats
111
+ // (where we want a generous budget) without changing the default
112
+ // behavior of every other call.
113
+ const ac = typeof AbortController === 'function' ? new AbortController() : null;
114
+ let timer = null;
115
+ if (timeoutMs && ac) {
116
+ timer = setTimeout(() => ac.abort(), timeoutMs);
117
+ }
118
+
109
119
  let res;
110
120
  try {
111
121
  res = await fetch(url, {
112
122
  method,
113
123
  headers,
114
124
  body: body == null ? undefined : JSON.stringify(body),
125
+ signal: ac ? ac.signal : undefined,
115
126
  });
116
127
  } catch (err) {
117
128
  throw new ApiError(
@@ -119,6 +130,8 @@ async function request(method, urlPath, { body, auth = false, apiUrl } = {}) {
119
130
  'network',
120
131
  `Could not reach ${base}: ${err.message || err}.`,
121
132
  );
133
+ } finally {
134
+ if (timer) clearTimeout(timer);
122
135
  }
123
136
 
124
137
  const ct = res.headers.get('content-type') || '';
package/src/cli-auth.js CHANGED
@@ -24,6 +24,7 @@ const {
24
24
  request,
25
25
  writeCreds,
26
26
  } = require('./api');
27
+ const keypair = require('./keypair');
27
28
  const ui = require('./ui');
28
29
 
29
30
  function sleep(ms) {
@@ -163,6 +164,31 @@ async function runLogin({ apiUrl } = {}) {
163
164
  createdAt: new Date().toISOString(),
164
165
  });
165
166
 
167
+ // sealcode@1.0.0: publish the user's X25519 pubkey. This enables team
168
+ // key sharing (other users can wrap K for us via `sealcode share --to`).
169
+ // Best-effort: if the server doesn't support pubkeys yet (older deploy)
170
+ // we still consider login successful. The keypair is generated locally
171
+ // either way and persisted to ~/.sealcode/keypair.json.
172
+ try {
173
+ const pub = keypair.publicKey();
174
+ await request('POST', '/api/v1/users/pubkey', {
175
+ body: pub,
176
+ auth: true,
177
+ apiUrl: baseUrl,
178
+ });
179
+ } catch (err) {
180
+ if (err instanceof ApiError && err.status === 404) {
181
+ // Server predates 1.0; not a real error, just log it in verbose paths.
182
+ if (process.env.SEALCODE_DEBUG) {
183
+ process.stderr.write(
184
+ `(note: server hasn't enabled team key sharing yet — your pubkey was generated locally but not published)\n`,
185
+ );
186
+ }
187
+ } else if (process.env.SEALCODE_DEBUG) {
188
+ process.stderr.write(`(warning: pubkey publish failed: ${err.message || err})\n`);
189
+ }
190
+ }
191
+
166
192
  ui.ok(`Logged in as ${ui.c.bold(user.email || user.id || 'unknown')} ${ui.c.dim(`at ${baseUrl}`)}`);
167
193
  }
168
194
 
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 };