sealcode 0.3.0 → 1.1.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/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.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/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,108 @@ 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({
78
+ projectRoot,
79
+ recipientEmail,
80
+ verbose = false,
81
+ resolveKey = null,
82
+ }) {
83
+ if (!recipientEmail) return null;
84
+
85
+ // Need the master key K to wrap. Prefer a cached session; if none, ask
86
+ // the caller (cli.js) to prompt for the passphrase. We deliberately do
87
+ // NOT silently downgrade to "passphrase fallback" here — the whole point
88
+ // of `--email` is to spare the recipient the passphrase exchange.
89
+ const config = getActiveConfig(projectRoot);
90
+ if (!isInitialized(projectRoot, config.lockedDir)) return null;
91
+ let K = loadSession(projectRoot);
92
+ if (!K && typeof resolveKey === 'function') {
93
+ if (verbose) {
94
+ ui.step('No cached master key — prompting for passphrase so we can wrap for the recipient.');
95
+ }
96
+ try {
97
+ K = await resolveKey(projectRoot, config);
98
+ } catch (err) {
99
+ if (verbose) {
100
+ ui.warn(`Could not derive master key (${err.message || err}). Will mint without wrapping.`);
101
+ }
102
+ return null;
103
+ }
104
+ }
105
+ if (!K) {
106
+ if (verbose) {
107
+ ui.warn('No cached master key — minting code WITHOUT wrapped key. Recipient will need the passphrase.');
108
+ ui.hint('Run `sealcode unlock` first, or supply a passphrase prompt, to enable passphrase-free sharing.');
109
+ }
110
+ return null;
111
+ }
112
+
113
+ // Fetch their pubkey.
114
+ let pub;
115
+ try {
116
+ pub = await request(
117
+ 'GET',
118
+ `/api/v1/users/pubkey?email=${encodeURIComponent(recipientEmail)}`,
119
+ { auth: true },
120
+ );
121
+ } catch (err) {
122
+ if (err instanceof ApiError) {
123
+ if (err.status === 404) {
124
+ if (verbose) {
125
+ ui.warn(
126
+ `Recipient ${recipientEmail} hasn't signed in to sealcode.dev yet, so no pubkey is on file.`,
127
+ );
128
+ ui.hint(
129
+ 'Ask them to visit https://sealcode.dev/login, then re-run `sealcode share --email`.',
130
+ );
131
+ ui.hint('Continuing with passphrase-fallback for this code…');
132
+ }
133
+ return null;
134
+ }
135
+ if (err.status === 0) {
136
+ // Network blip — let the caller surface this; we don't want to
137
+ // silently strip security.
138
+ throw err;
139
+ }
140
+ }
141
+ throw err;
142
+ }
143
+ if (!pub || !pub.publicKey || pub.algo !== keypair.ALGO) {
144
+ return null;
145
+ }
146
+
147
+ const wrapped = shareCrypto.wrapForRecipient(K, pub.publicKey);
148
+ return { wrappedKey: wrapped, wrappedKeyRecipient: recipientEmail };
149
+ }
150
+
41
151
  async function runShare({
42
152
  projectRoot,
43
153
  hours,
@@ -48,9 +158,40 @@ async function runShare({
48
158
  strict = false,
49
159
  heartbeatSec = 300,
50
160
  offlineGraceSec = 1800,
161
+ // sealcode@1.1.0 — admin precision controls. All optional.
162
+ paths = null, // string[] | null → allowedPaths
163
+ mode = 'rw', // 'rw' | 'ro'
164
+ watermark = null, // string | null → template
165
+ idleAutoLockMinutes = 0, // 0 = disabled
166
+ allowedIpCidrs = null, // string[] | null → CIDRs
167
+ allowedCountries = null, // string[] | null → ISO-2 codes
168
+ singleDeviceEnforce = false,
169
+ ndaText = null,
51
170
  json = false,
171
+ resolveKey = null,
52
172
  }) {
53
173
  const link = requireLink(projectRoot);
174
+
175
+ // sealcode@1.0.0: if `--email <addr>` is supplied AND we can reach the
176
+ // recipient's pubkey AND we have K cached, wrap K to them. This makes
177
+ // `sealcode redeem` self-sufficient on the recipient side (no
178
+ // out-of-band passphrase exchange).
179
+ let wrap = null;
180
+ try {
181
+ wrap = await maybeWrapKeyForRecipient({
182
+ projectRoot,
183
+ recipientEmail: email,
184
+ verbose: !json,
185
+ resolveKey,
186
+ });
187
+ } catch (err) {
188
+ // Network errors during pubkey lookup shouldn't be silent — degrade
189
+ // loudly so the owner knows the recipient will need the passphrase.
190
+ if (!json) {
191
+ ui.warn(`Pubkey lookup failed (${err.message || err}) — minting code without wrapped key.`);
192
+ }
193
+ }
194
+
54
195
  const body = {
55
196
  developerEmail: email || '',
56
197
  developerLabel: label || '',
@@ -60,6 +201,18 @@ async function runShare({
60
201
  strictMode: !!strict,
61
202
  heartbeatIntervalSeconds: Math.max(30, Math.floor(heartbeatSec)),
62
203
  offlineGraceSeconds: Math.max(60, Math.floor(offlineGraceSec)),
204
+ ...(wrap ? wrap : {}),
205
+ // sealcode@1.1.0 — admin precision controls (server validates again)
206
+ ...(paths && paths.length ? { allowedPaths: paths } : {}),
207
+ mode: mode === 'ro' ? 'ro' : 'rw',
208
+ ...(watermark ? { watermark } : {}),
209
+ idleAutoLockMinutes: Math.max(0, Math.floor(idleAutoLockMinutes || 0)),
210
+ ...(allowedIpCidrs && allowedIpCidrs.length ? { allowedIpCidrs } : {}),
211
+ ...(allowedCountries && allowedCountries.length
212
+ ? { allowedCountries: allowedCountries.map((c) => c.toUpperCase()) }
213
+ : {}),
214
+ singleDeviceEnforce: !!singleDeviceEnforce,
215
+ ...(ndaText ? { ndaText } : {}),
63
216
  };
64
217
 
65
218
  let res;
@@ -88,6 +241,8 @@ async function runShare({
88
241
  codePrefix: g.codePrefix,
89
242
  projectId: g.projectId,
90
243
  expiresAt: g.expiresAt,
244
+ wrappedFor: g.wrappedFor ?? null,
245
+ hasWrappedKey: !!g.hasWrappedKey,
91
246
  },
92
247
  null,
93
248
  2,
@@ -97,11 +252,17 @@ async function runShare({
97
252
  }
98
253
 
99
254
  const meta = [
100
- `${ui.c.dim('project ')} ${ui.c.bold(link.projectName || link.projectId)}`,
101
- `${ui.c.dim('expires ')} ${g.expiresAt}`,
255
+ `${ui.c.dim('project ')} ${ui.c.bold(link.projectName || link.projectId)}`,
256
+ `${ui.c.dim('expires ')} ${g.expiresAt}`,
102
257
  ];
103
- if (g.autoLockAt) meta.push(`${ui.c.dim('auto-lock ')} ${g.autoLockAt}`);
104
- if (email) meta.push(`${ui.c.dim('email ')} ${email}`);
258
+ if (g.autoLockAt) meta.push(`${ui.c.dim('auto-lock ')} ${g.autoLockAt}`);
259
+ if (email) meta.push(`${ui.c.dim('email ')} ${email}`);
260
+ if (strict) meta.push(`${ui.c.dim('strict ')} ${ui.c.yellow('yes — watcher required')}`);
261
+ if (g.hasWrappedKey) {
262
+ meta.push(`${ui.c.dim('wrapped to ')} ${ui.c.green(g.wrappedFor)} ${ui.c.dim('(no passphrase exchange)')}`);
263
+ } else if (email) {
264
+ meta.push(`${ui.c.dim('wrapped to ')} ${ui.c.yellow('— (recipient hasn\'t published a pubkey; passphrase still needed)')}`);
265
+ }
105
266
 
106
267
  ui.box('Access code — copy now, shown only once', [
107
268
  ` ${ui.c.bold(ui.c.green(g.code))}`,
@@ -109,8 +270,14 @@ async function runShare({
109
270
  ...meta,
110
271
  ], { tone: 'green' });
111
272
 
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`);
273
+ if (g.hasWrappedKey) {
274
+ ui.hint('Recipient runs:');
275
+ process.stdout.write(` ${ui.c.cyan(`sealcode redeem ${g.code}`)}\n`);
276
+ process.stdout.write(` ${ui.c.cyan(`sealcode unlock`)} ${ui.c.dim('# no passphrase prompt — K is delivered by the redeem call')}\n\n`);
277
+ } else {
278
+ ui.hint('Share it with the developer over a secure channel. They redeem with:');
279
+ process.stdout.write(` ${ui.c.cyan(`sealcode redeem ${g.code}`)}\n\n`);
280
+ }
114
281
  }
115
282
 
116
283
  async function runGrants({ projectRoot, json = false }) {
@@ -156,17 +323,113 @@ async function runRevoke({ grantId }) {
156
323
  process.stdout.write(`✓ revoked grant ${grantId}\n`);
157
324
  }
158
325
 
159
- async function runRedeem({ code, json = false }) {
326
+ async function runRedeem({ projectRoot, code, json = false, agreeNda = false }) {
160
327
  if (!code) throw new Error('Usage: sealcode redeem <CODE>');
161
328
  const trimmed = code.trim();
162
- const res = await request('POST', '/api/v1/access/redeem', {
163
- body: { code: trimmed, client: clientInfo() },
164
- });
329
+
330
+ // Include the device fingerprint so the server pins it to the grant
331
+ // on first redeem (and warns on mismatch on subsequent redeems).
332
+ const client = { ...clientInfo(), deviceFingerprint: getDeviceFingerprint() };
333
+
334
+ // First call without the NDA flag — server tells us whether one is
335
+ // required by including `nda_required: true` and `ndaText` in the
336
+ // 409-NDA response (or in the success payload's policy block). We
337
+ // use a separate pre-flight to a metadata endpoint to avoid the
338
+ // "race the server pins the device fingerprint before user has
339
+ // even agreed" problem.
340
+ let res;
341
+ try {
342
+ res = await request('POST', '/api/v1/access/redeem', {
343
+ body: { code: trimmed, client, ndaAccepted: !!agreeNda },
344
+ });
345
+ } catch (err) {
346
+ if (err instanceof ApiError && err.status === 409 && err.apiCode === 'nda_required') {
347
+ const ndaText = (err.raw && (err.raw.ndaText || err.raw.error?.ndaText)) || '(no text provided by owner)';
348
+ ui.box('Acceptance required', [
349
+ ui.c.dim('The owner of this project requires you to accept the following before unlocking:'),
350
+ '',
351
+ ...String(ndaText).split('\n'),
352
+ ], { tone: 'yellow' });
353
+ const { question } = require('./prompt');
354
+ const reply = await question('Type "I agree" to continue (or Ctrl-C to abort): ');
355
+ if (String(reply).trim().toLowerCase() !== 'i agree') {
356
+ ui.fail('Did not accept — aborting redeem.');
357
+ process.exitCode = 1;
358
+ return;
359
+ }
360
+ res = await request('POST', '/api/v1/access/redeem', {
361
+ body: { code: trimmed, client, ndaAccepted: true },
362
+ });
363
+ } else {
364
+ throw err;
365
+ }
366
+ }
165
367
  const g = res.grant;
368
+
369
+ // sealcode@1.0.0: if the server returned a wrapped K AND we're running
370
+ // inside a project root that has a manifest, unwrap it and cache the
371
+ // session so subsequent `sealcode unlock` skips the passphrase prompt.
372
+ // This is the magic "no passphrase exchange needed" path.
373
+ let cachedK = false;
374
+ let unwrapNote = null;
375
+ if (g.wrappedKey) {
376
+ const kp = keypair.read();
377
+ if (!kp) {
378
+ unwrapNote = ui.c.yellow(
379
+ '(received a wrapped key, but no local keypair was found. '
380
+ + 'Run `sealcode login` to generate one.)',
381
+ );
382
+ } else if (g.wrappedKeyRecipient && g.wrappedKeyRecipient.toLowerCase() !== readMyEmail().toLowerCase()) {
383
+ unwrapNote = ui.c.yellow(
384
+ `(received a wrapped key, but it was wrapped for ${g.wrappedKeyRecipient}; `
385
+ + `you are logged in as ${readMyEmail()}. Ask the owner to re-share.)`,
386
+ );
387
+ } else {
388
+ try {
389
+ const K = shareCrypto.unwrapWithRecipient(g.wrappedKey, kp.privateKey, kp.publicKey);
390
+ if (projectRoot) {
391
+ // Cache K against THIS project root. The grant-derived session
392
+ // is tagged so cli-guard and cli-watch know it requires a
393
+ // running watcher. sealcode@1.1.0 — also stash the policy
394
+ // bundle so subsequent commands enforce paths/ro/watermark/idle.
395
+ saveSession(projectRoot, K, {
396
+ source: 'grant',
397
+ grantId: g.id,
398
+ grantCodeHash: require('crypto').createHash('sha256').update(trimmed).digest('hex'),
399
+ grantExpiresAt: g.expiresAt,
400
+ deviceFingerprint: getDeviceFingerprint(),
401
+ strictWatch: !!g.strictMode,
402
+ policy: g.policy || {
403
+ allowedPaths: g.allowedPaths || null,
404
+ mode: g.mode || 'rw',
405
+ watermark: g.watermark || null,
406
+ idleAutoLockMinutes: g.idleAutoLockMinutes || 0,
407
+ },
408
+ recipientEmail: g.wrappedKeyRecipient || readMyEmail(),
409
+ projectName: g.projectName || null,
410
+ });
411
+ cachedK = true;
412
+ } else {
413
+ unwrapNote = ui.c.dim(
414
+ '(unwrapped K, but you are not inside a project root — '
415
+ + 'cd into the project and run `sealcode unlock` to materialize plaintext.)',
416
+ );
417
+ }
418
+ // Best-effort scrub of K from this process's memory.
419
+ K.fill(0);
420
+ } catch (err) {
421
+ unwrapNote = ui.c.red(`(could not unwrap delivered key: ${err.message || err})`);
422
+ }
423
+ }
424
+ }
425
+
166
426
  if (json) {
167
- process.stdout.write(JSON.stringify({ grant: g }, null, 2) + '\n');
427
+ process.stdout.write(
428
+ JSON.stringify({ grant: g, cachedSession: cachedK }, null, 2) + '\n',
429
+ );
168
430
  return;
169
431
  }
432
+
170
433
  process.stdout.write(`\n✓ access code accepted\n`);
171
434
  process.stdout.write(` project: ${g.projectName} (${g.projectId})\n`);
172
435
  process.stdout.write(` fingerprint: ${g.projectFingerprint}\n`);
@@ -174,12 +437,141 @@ async function runRedeem({ code, json = false }) {
174
437
  if (g.autoLockAt) process.stdout.write(` auto-lock: ${g.autoLockAt}\n`);
175
438
  process.stdout.write(` strict: ${g.strictMode ? 'yes' : 'no'}\n`);
176
439
  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',
440
+ ` heartbeat: every ${g.heartbeatIntervalSeconds}s, ${g.offlineGraceSeconds}s offline grace\n`,
182
441
  );
442
+ if (g.deviceFingerprintWarning) {
443
+ ui.warn(g.deviceFingerprintWarning);
444
+ }
445
+
446
+ if (cachedK) {
447
+ process.stdout.write('\n');
448
+ ui.ok('Key delivered via team-share envelope — no passphrase needed.');
449
+ // Auto-unlock + auto-spawn watcher. This is the user-asked-for
450
+ // "magic" flow: one command (`sealcode redeem`) handles redeem +
451
+ // unwrap + decrypt + supervise. Strict mode is implied by the
452
+ // server's `strictMode` flag on the grant.
453
+ try {
454
+ const { runUnlock } = require('./open');
455
+ const config = getActiveConfig(projectRoot);
456
+ ui.step(`auto-unlocking ${ui.c.dim(projectRoot)}`);
457
+ const sp = new ui.Spinner('decrypting').start();
458
+ let n = 0;
459
+ const K = loadSession(projectRoot); // freshly cached above
460
+ const ures = await runUnlock({
461
+ projectRoot,
462
+ config,
463
+ K,
464
+ removeStubs: true,
465
+ log: (msg) => {
466
+ n += 1;
467
+ const file = String(msg).replace(/^\s*unlocked\s+/, '');
468
+ sp.update(`decrypting ${ui.c.dim(`(${n})`)} ${file}`);
469
+ },
470
+ // sealcode@1.1.0 — apply the server-issued policy
471
+ policy: g.policy || {
472
+ allowedPaths: g.allowedPaths || null,
473
+ mode: g.mode || 'rw',
474
+ watermark: g.watermark || null,
475
+ idleAutoLockMinutes: g.idleAutoLockMinutes || 0,
476
+ },
477
+ watermarkCtx: {
478
+ email: g.wrappedKeyRecipient || readMyEmail(),
479
+ grantId: g.id,
480
+ projectName: g.projectName || '',
481
+ date: new Date().toISOString().slice(0, 10),
482
+ fingerprint: getDeviceFingerprint(),
483
+ },
484
+ });
485
+ const sk = ures.skipped > 0 ? ` ${ui.c.dim(`(${ures.skipped} file(s) outside grant scope skipped)`)}` : '';
486
+ sp.succeed(
487
+ `unlocked ${ui.c.bold(ures.count)} files${sk} ${ui.c.dim(`(locked at ${ures.sealedAt})`)}`,
488
+ );
489
+ if (ures.policy.mode === 'ro') {
490
+ ui.hint(' Read-only grant: files are 0444. Any modification will trigger an immediate re-lock.');
491
+ }
492
+ if (ures.policy.watermark) {
493
+ ui.hint(' Files are watermarked with your grant ID — leaks are traceable.');
494
+ }
495
+ } catch (err) {
496
+ ui.warn(`auto-unlock failed: ${err.message || err}`);
497
+ ui.hint(` Run \`sealcode unlock\` manually, then \`sealcode watch ${trimmed} --daemon\`.`);
498
+ }
499
+ try {
500
+ const { spawnDaemonWatcher } = require('./cli-watch');
501
+ const r = spawnDaemonWatcher({ projectRoot, code: trimmed });
502
+ if (r.error) {
503
+ ui.warn(`auto-spawn watcher failed: ${r.error}`);
504
+ ui.hint(` Run \`sealcode watch ${trimmed} --daemon\` manually.`);
505
+ } else {
506
+ ui.ok(
507
+ `watcher running ${ui.c.dim(`(pid ${r.pid}; logs ~/.sealcode/logs/)`)}`,
508
+ );
509
+ ui.hint(
510
+ g.strictMode
511
+ ? ' Strict mode: if you kill the watcher, your local plaintext re-locks immediately.'
512
+ : ' Lenient mode: watcher auto-locks on revoke / expiry only.',
513
+ );
514
+ }
515
+ } catch (err) {
516
+ ui.warn(`could not start watcher: ${err.message || err}`);
517
+ }
518
+ process.stdout.write('\n');
519
+ } else {
520
+ if (unwrapNote) {
521
+ process.stdout.write(` ${unwrapNote}\n`);
522
+ }
523
+ process.stdout.write(
524
+ '\nNext step (when the owner shares the unlocked vault with you):\n'
525
+ + ` ${ui.c.cyan('sealcode unlock')}\n`
526
+ + ` ${ui.c.cyan(`sealcode watch ${trimmed} &`)} ${ui.c.dim('# keep this running so revoke is instant')}\n\n`,
527
+ );
528
+ }
529
+ }
530
+
531
+ function readMyEmail() {
532
+ try {
533
+ const { readCreds } = require('./api');
534
+ const c = readCreds();
535
+ return (c && c.email) || '';
536
+ } catch (_) {
537
+ return '';
538
+ }
539
+ }
540
+
541
+ /**
542
+ * sealcode@1.1.0 — Project lockdown. Bulk-revokes every active grant
543
+ * on the linked project in a single server-side transaction, then
544
+ * pushes instant lock signals to every connected watcher via the
545
+ * long-poll event bus.
546
+ *
547
+ * Requires the project to be linked (cli-link.js).
548
+ */
549
+ async function runLockdown({ projectRoot, confirm = true } = {}) {
550
+ const { resolveLinkedProject } = require('./cli-link');
551
+ const linked = resolveLinkedProject(projectRoot);
552
+ if (!linked) {
553
+ ui.fail('This folder is not linked to a sealcode.dev project. Run `sealcode link` first.');
554
+ process.exitCode = 1;
555
+ return;
556
+ }
557
+ if (confirm) {
558
+ const { question } = require('./prompt');
559
+ ui.warn(`This will revoke EVERY active access code for ${ui.c.bold(linked.projectName || linked.projectId)}.`);
560
+ const reply = await question('Type "lockdown" to confirm: ');
561
+ if (String(reply).trim().toLowerCase() !== 'lockdown') {
562
+ ui.fail('Aborted.');
563
+ process.exitCode = 1;
564
+ return;
565
+ }
566
+ }
567
+ const res = await request('POST', `/api/v1/projects/${linked.projectId}/lockdown`, {
568
+ auth: true,
569
+ body: {},
570
+ });
571
+ ui.ok(`Lockdown complete — revoked ${ui.c.bold(res.revokedCount)} grant(s).`);
572
+ if (res.revokedCount > 0) {
573
+ ui.hint(' All connected watchers will re-lock within ~1 second.');
574
+ }
183
575
  }
184
576
 
185
- module.exports = { runShare, runGrants, runRevoke, runRedeem };
577
+ module.exports = { runShare, runGrants, runRevoke, runRedeem, runLockdown };