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/src/cli-watch.js CHANGED
@@ -5,34 +5,60 @@
5
5
  *
6
6
  * This is the missing piece of the "real-time revoke" story:
7
7
  *
8
- * On the OWNER side: `sealcode share` mints a code, `sealcode revoke` (or
9
- * the dashboard) kills it.
8
+ * On the OWNER side: `sealcode share` mints a code, `sealcode revoke`
9
+ * (or the dashboard) kills it.
10
10
  *
11
- * On the RECIPIENT side: `sealcode redeem` validates the code once, then
12
- * the user runs `sealcode unlock` to start working. While they're working,
13
- * `sealcode watch <code>` polls the heartbeat endpoint and:
11
+ * On the RECIPIENT side: `sealcode redeem` validates the code once and
12
+ * (since 1.0.0) unwraps a team-shared K straight into the session
13
+ * cache; then `sealcode unlock` materializes plaintext. While they're
14
+ * working, `sealcode watch <code>` polls the heartbeat endpoint and:
14
15
  *
15
- * - prints a heartbeat line every interval so the demo is visible
16
- * - on `action: "lock"` (revoked / expired / auto-lock reached), it
17
- * immediately runs the same lock pipeline as `sealcode lock`, wipes
18
- * the cached session, and exits with status 0.
16
+ * - prints a live status line so the demo is visible
17
+ * - on `action: "lock"` (revoked / expired / auto-lock / strict-mode
18
+ * device mismatch), it immediately runs the same lock pipeline as
19
+ * `sealcode lock`, wipes the cached session, and exits status 0.
19
20
  * - on `action: "warn"` (close to expiry), warns the user.
20
- * - on transient network failure, retries; only an explicit terminal
21
- * response from the server triggers a lock.
21
+ * - on transient network failure, retries with backoff; after the
22
+ * grant's `offlineGraceSeconds` it self-locks (strict mode) or
23
+ * warns (lenient mode).
24
+ * - watches the project root with fs.watch and raises EXFIL ALERTS to
25
+ * the server when heuristics trip (mass reads, mass bytes, a tar/zip
26
+ * process touching the dir, a new git remote, dir moved/renamed).
22
27
  *
23
- * The watcher does NOT need an account / login — the access code itself is
24
- * the authentication for these endpoints. That matches the contractor flow:
25
- * they may not have a sealcode.dev account at all.
28
+ * The watcher does NOT need an account / login — the access code itself
29
+ * is the authentication for these endpoints. That matches the contractor
30
+ * flow: they may not have a sealcode.dev account at all.
31
+ *
32
+ * --daemon mode (sealcode@1.0.0+):
33
+ * When invoked with --daemon, the watcher detaches from the parent's
34
+ * stdio and writes structured JSONL events to ~/.sealcode/logs/<id>.jsonl.
35
+ * This is the form auto-spawned by `runUnlock` when the session was
36
+ * derived from a grant (so the contractor doesn't have to remember to
37
+ * run watch by hand).
26
38
  */
27
39
 
40
+ const fs = require('fs');
41
+ const os = require('os');
28
42
  const path = require('path');
43
+ const crypto = require('crypto');
44
+ const { spawn } = require('child_process');
29
45
 
30
- const { request, ApiError } = require('./api');
46
+ const { request, ApiError, clientInfo } = require('./api');
31
47
  const { loadConfig } = require('./config');
32
48
  const { detectPreset } = require('./presets');
33
- const { isInitialized, loadSession, clearSession } = require('./keystore');
49
+ const {
50
+ isInitialized,
51
+ loadSession,
52
+ loadSessionMeta,
53
+ updateSessionMeta,
54
+ clearSession,
55
+ projectId,
56
+ } = require('./keystore');
34
57
  const { runLock } = require('./seal');
58
+ const { getDeviceFingerprint } = require('./device');
35
59
  const { SealcodeError } = require('./errors');
60
+ const grantPolicy = require('./grant-policy');
61
+ const pkg = require('../package.json');
36
62
  const ui = require('./ui');
37
63
 
38
64
  const DEFAULT_INTERVAL_SEC = 30;
@@ -40,6 +66,18 @@ const MIN_INTERVAL_SEC = 5;
40
66
  const MAX_INTERVAL_SEC = 600;
41
67
  const TRANSIENT_BACKOFF_SEC = 5;
42
68
 
69
+ // Where the watcher writes its heartbeat sidecar — cli-guard reads this to
70
+ // decide whether a grant-derived session is still being supervised.
71
+ const WATCH_STATE_DIR = path.join(os.homedir(), '.sealcode', 'sessions');
72
+ const WATCH_LOG_DIR = path.join(os.homedir(), '.sealcode', 'logs');
73
+
74
+ function watchStateFile(projectRoot) {
75
+ return path.join(WATCH_STATE_DIR, `${projectId(projectRoot)}.watch.json`);
76
+ }
77
+ function watchLogFile(projectRoot) {
78
+ return path.join(WATCH_LOG_DIR, `${projectId(projectRoot)}.jsonl`);
79
+ }
80
+
43
81
  function getActiveConfig(projectRoot) {
44
82
  const fromFile = loadConfig(projectRoot);
45
83
  if (fromFile) return fromFile;
@@ -69,24 +107,62 @@ function fmtRemaining(ms) {
69
107
  }
70
108
 
71
109
  function ts() {
72
- const d = new Date();
73
- return d.toTimeString().slice(0, 8);
110
+ return new Date().toTimeString().slice(0, 8);
74
111
  }
75
112
 
76
113
  function sleep(ms) {
77
114
  return new Promise((r) => setTimeout(r, ms));
78
115
  }
79
116
 
117
+ function ensureDir(p) {
118
+ fs.mkdirSync(p, { recursive: true, mode: 0o700 });
119
+ }
120
+
121
+ function writeWatchState(projectRoot, patch) {
122
+ ensureDir(WATCH_STATE_DIR);
123
+ const p = watchStateFile(projectRoot);
124
+ let existing = {};
125
+ try {
126
+ existing = JSON.parse(fs.readFileSync(p, 'utf8'));
127
+ } catch (_) { /* fresh file */ }
128
+ const merged = { ...existing, ...patch, updatedAt: new Date().toISOString() };
129
+ fs.writeFileSync(p, JSON.stringify(merged) + '\n', { mode: 0o600 });
130
+ return merged;
131
+ }
132
+
133
+ function deleteWatchState(projectRoot) {
134
+ try {
135
+ fs.unlinkSync(watchStateFile(projectRoot));
136
+ } catch (_) { /* ignore */ }
137
+ }
138
+
139
+ function appendLog(projectRoot, event) {
140
+ ensureDir(WATCH_LOG_DIR);
141
+ try {
142
+ fs.appendFileSync(
143
+ watchLogFile(projectRoot),
144
+ JSON.stringify({ at: new Date().toISOString(), ...event }) + '\n',
145
+ );
146
+ } catch (_) { /* best-effort */ }
147
+ }
148
+
80
149
  /**
81
- * Single heartbeat round-trip. Returns `{ ok, response, transient }`:
82
- * - ok=true with response on a real 2xx server reply
83
- * - ok=false with transient=true on a network / 5xx blip — caller retries
84
- * - throws SealcodeError on a malformed code / 404 etc. — caller exits
150
+ * Single heartbeat round-trip.
85
151
  */
86
- async function heartbeatOnce(code) {
152
+ async function heartbeatOnce(code, extras) {
87
153
  try {
88
154
  const res = await request('POST', '/api/v1/access/heartbeat', {
89
- body: { code },
155
+ body: {
156
+ code,
157
+ watcherPid: process.pid,
158
+ watcherVersion: pkg.version,
159
+ deviceFingerprint: getDeviceFingerprint(),
160
+ ...(extras || {}),
161
+ },
162
+ // sealcode@1.1.0 — long-poll allowed up to ~30s; we leave ~5s of
163
+ // headroom over the server's `waitMs` for the TCP round trip
164
+ // before our local request timeout fires.
165
+ timeoutMs: extras && extras.waitMs ? extras.waitMs + 5000 : undefined,
90
166
  });
91
167
  return { ok: true, response: res };
92
168
  } catch (err) {
@@ -94,7 +170,6 @@ async function heartbeatOnce(code) {
94
170
  if (err.status === 0 || err.status >= 500) {
95
171
  return { ok: false, transient: true, err };
96
172
  }
97
- // 4xx (code not found, bad request) is terminal — don't keep guessing.
98
173
  throw new SealcodeError('SEALCODE_WATCH_BAD_CODE', {
99
174
  detail: `Heartbeat rejected (${err.status} ${err.apiCode}): ${err.message}`,
100
175
  hint: 'Try: sealcode redeem <code> (re-validate the access code)',
@@ -105,14 +180,294 @@ async function heartbeatOnce(code) {
105
180
  }
106
181
 
107
182
  /**
108
- * @param {Object} opts
109
- * @param {string} opts.projectRoot
110
- * @param {string} opts.code — access code (SC-XXXX-…)
111
- * @param {number} [opts.intervalSec] — override poll interval
112
- * @param {boolean} [opts.verbose]
113
- * @param {boolean} [opts.json] — emit JSONL events instead of pretty
183
+ * Post a batch of per-file activity events. sealcode@1.1.0 these feed
184
+ * the dashboard's per-grant "what did they touch" timeline. Best-effort.
185
+ */
186
+ async function postFileEvents(code, events) {
187
+ if (!events || events.length === 0) return;
188
+ try {
189
+ await request('POST', '/api/v1/access/file-events', {
190
+ body: {
191
+ code,
192
+ events,
193
+ deviceFingerprint: getDeviceFingerprint(),
194
+ },
195
+ });
196
+ } catch (_) { /* never tear down the watcher over telemetry */ }
197
+ }
198
+
199
+ /**
200
+ * Post a structured exfil alert to the server. Best-effort — failure is
201
+ * logged locally but doesn't tear down the watcher.
202
+ */
203
+ async function postAlert(code, alert) {
204
+ try {
205
+ await request('POST', '/api/v1/access/alerts', {
206
+ body: {
207
+ code,
208
+ kind: alert.kind,
209
+ severity: alert.severity || 'warn',
210
+ summary: alert.summary,
211
+ detail: alert.detail || {},
212
+ deviceFingerprint: getDeviceFingerprint(),
213
+ hostname: os.hostname(),
214
+ ...clientInfo(),
215
+ },
216
+ });
217
+ } catch (_) {
218
+ // We never let a failed alert bring down the watcher.
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Lightweight exfiltration heuristics. Runs inside the watcher process,
224
+ * polls cheap signals every few seconds, and posts alerts when thresholds
225
+ * trip. Deliberately heuristic — false positives are fine (owner can
226
+ * dismiss), false negatives are the failure mode.
227
+ *
228
+ * Tracked signals:
229
+ * - file count opened in last 60s (uses fs.watch counts)
230
+ * - bytes read in last 60s (uses fs.watch + stat sizes, capped)
231
+ * - presence of suspicious processes (tar/zip/cp/rsync/scp) on Unix
232
+ * - new git remotes added (diff vs initial remote set)
233
+ * - project root moved or renamed (we hold the inode; if it disappears
234
+ * the watcher just locks because the manifest is gone)
235
+ *
236
+ * @returns {{stop: () => void}}
237
+ */
238
+ function startExfilWatchers({
239
+ projectRoot,
240
+ code,
241
+ config,
242
+ onAlert,
243
+ // sealcode@1.1.0 — per-event hook for the per-file activity log + the
244
+ // idle-lock + RO-violation detectors. All optional; legacy callers
245
+ // (no per-file reporting) can omit.
246
+ onFileEvent = null,
247
+ }) {
248
+ const lockedDir = config.lockedDir || 'vendor';
249
+ const projectAbs = path.resolve(projectRoot);
250
+ const lockedAbs = path.join(projectAbs, lockedDir);
251
+
252
+ let stopped = false;
253
+
254
+ // Rolling 60s window of file events.
255
+ const windowMs = 60_000;
256
+ const events = []; // { at, kind, path, size? }
257
+ function prune(now) {
258
+ const cutoff = now - windowMs;
259
+ while (events.length && events[0].at < cutoff) events.shift();
260
+ }
261
+ function bytesInWindow() {
262
+ let total = 0;
263
+ for (const e of events) total += e.size || 0;
264
+ return total;
265
+ }
266
+ function countInWindow() { return events.length; }
267
+
268
+ // Watch the project root recursively for change/rename events. macOS
269
+ // and Linux both support recursive watch on the root; Linux gives
270
+ // per-file events via inotify.
271
+ let watcher;
272
+ try {
273
+ watcher = fs.watch(projectAbs, { recursive: true, persistent: false }, (event, filename) => {
274
+ if (stopped || !filename) return;
275
+ // Ignore everything under lockedDir (those are the encrypted blobs
276
+ // we ourselves write during lock — would self-trigger constantly).
277
+ const rel = String(filename);
278
+ if (rel.startsWith(lockedDir + path.sep) || rel === lockedDir) return;
279
+ // Don't try to stat — fs.watch can fire AFTER the file was deleted.
280
+ let size = 0;
281
+ try {
282
+ const st = fs.statSync(path.join(projectAbs, rel));
283
+ if (st.isFile()) size = st.size;
284
+ } catch (_) { /* fine */ }
285
+ const now = Date.now();
286
+ events.push({ at: now, kind: event, path: rel, size });
287
+ prune(now);
288
+ // sealcode@1.1.0 — surface per-event signal upward. We translate
289
+ // fs.watch's "rename"/"change" into the canonical kinds the
290
+ // server's file-events table expects.
291
+ if (onFileEvent) {
292
+ let kind = 'modified';
293
+ if (event === 'rename') {
294
+ kind = fs.existsSync(path.join(projectAbs, rel)) ? 'renamed' : 'deleted';
295
+ }
296
+ try {
297
+ onFileEvent({ path: rel, kind, sizeBytes: size, occurredAt: new Date(now).toISOString() });
298
+ } catch (_) { /* observer must not crash the watcher */ }
299
+ }
300
+ });
301
+ watcher.on('error', () => { /* fs.watch can race; ignore */ });
302
+ } catch (_) {
303
+ // Some platforms (notably some container images) can't do recursive
304
+ // watch. Heuristic just won't fire — not fatal.
305
+ }
306
+
307
+ // Pin initial git remote set so we can detect "new remote added".
308
+ let initialRemotes = '';
309
+ try {
310
+ const cp = require('child_process');
311
+ initialRemotes = cp
312
+ .execFileSync('git', ['-C', projectAbs, 'remote', '-v'], {
313
+ encoding: 'utf8',
314
+ timeout: 1500,
315
+ stdio: ['ignore', 'pipe', 'ignore'],
316
+ })
317
+ .trim();
318
+ } catch (_) { /* repo may not have git */ }
319
+
320
+ // Pin initial project-root inode/mtime so we can detect "dir moved".
321
+ let initialStat = null;
322
+ try {
323
+ initialStat = fs.statSync(projectAbs);
324
+ } catch (_) { /* nothing we can do */ }
325
+
326
+ // Thresholds. Configurable via env so power-users can tune; defaults
327
+ // chosen so a normal coding session (saving a few files / running
328
+ // tests) never trips them, but `tar -czf` of the project does.
329
+ const FILES_THRESHOLD = parseInt(process.env.SEALCODE_EXFIL_FILES || '120', 10);
330
+ const BYTES_THRESHOLD = parseInt(process.env.SEALCODE_EXFIL_BYTES || String(50 * 1024 * 1024), 10);
331
+ const SUSPICIOUS_BINARIES = new Set(['tar', 'gtar', 'bsdtar', 'zip', 'gzip', '7z', 'rsync', 'scp']);
332
+
333
+ // Process scanner — only on Unix; uses /proc on Linux, ps on macOS.
334
+ function suspiciousProcesses() {
335
+ try {
336
+ if (process.platform === 'linux') {
337
+ const dir = '/proc';
338
+ const out = [];
339
+ for (const pid of fs.readdirSync(dir)) {
340
+ if (!/^\d+$/.test(pid)) continue;
341
+ let cmdline = '';
342
+ try {
343
+ cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
344
+ } catch (_) { continue; }
345
+ if (!cmdline) continue;
346
+ const argv = cmdline.split('\0').filter(Boolean);
347
+ const exe = path.basename(argv[0] || '').toLowerCase();
348
+ if (SUSPICIOUS_BINARIES.has(exe) && argv.slice(1).some((a) => a.includes(projectAbs))) {
349
+ out.push({ pid, exe, cmd: argv.join(' ').slice(0, 200) });
350
+ }
351
+ }
352
+ return out;
353
+ }
354
+ if (process.platform === 'darwin') {
355
+ const cp = require('child_process');
356
+ const out = cp
357
+ .execFileSync('ps', ['-axo', 'pid=,comm=,args='], {
358
+ encoding: 'utf8',
359
+ timeout: 1500,
360
+ stdio: ['ignore', 'pipe', 'ignore'],
361
+ })
362
+ .split('\n');
363
+ const matches = [];
364
+ for (const line of out) {
365
+ const m = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
366
+ if (!m) continue;
367
+ const [, pid, comm, args] = m;
368
+ const exe = path.basename(comm).toLowerCase();
369
+ if (SUSPICIOUS_BINARIES.has(exe) && args.includes(projectAbs)) {
370
+ matches.push({ pid, exe, cmd: args.slice(0, 200) });
371
+ }
372
+ }
373
+ return matches;
374
+ }
375
+ } catch (_) { /* ignore */ }
376
+ return [];
377
+ }
378
+
379
+ // Sweep loop. We dedupe alerts so a single tar invocation doesn't
380
+ // spam the server.
381
+ const recentlyFired = new Map(); // kind -> lastFiredAt
382
+ function maybeFire(kind, severity, summary, detail) {
383
+ const last = recentlyFired.get(kind) || 0;
384
+ if (Date.now() - last < 60_000) return;
385
+ recentlyFired.set(kind, Date.now());
386
+ onAlert({ kind, severity, summary, detail });
387
+ }
388
+
389
+ const sweepTimer = setInterval(() => {
390
+ if (stopped) return;
391
+ prune(Date.now());
392
+
393
+ const files = countInWindow();
394
+ const bytes = bytesInWindow();
395
+ if (files > FILES_THRESHOLD) {
396
+ maybeFire('mass_read', 'alert',
397
+ `Read or wrote ${files} files in the last 60s under ${projectAbs}.`,
398
+ { files, projectAbs });
399
+ }
400
+ if (bytes > BYTES_THRESHOLD) {
401
+ maybeFire('mass_bytes', 'alert',
402
+ `Touched ${(bytes / 1024 / 1024).toFixed(1)} MB of project files in the last 60s.`,
403
+ { bytes, projectAbs });
404
+ }
405
+
406
+ const susp = suspiciousProcesses();
407
+ if (susp.length > 0) {
408
+ maybeFire('archiver_process', 'alert',
409
+ `Detected ${susp[0].exe} touching the project root (${susp[0].cmd}).`,
410
+ { processes: susp });
411
+ }
412
+
413
+ // Git remote drift
414
+ try {
415
+ const cp = require('child_process');
416
+ const cur = cp
417
+ .execFileSync('git', ['-C', projectAbs, 'remote', '-v'], {
418
+ encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'],
419
+ })
420
+ .trim();
421
+ if (initialRemotes && cur !== initialRemotes) {
422
+ maybeFire('git_remote_changed', 'alert',
423
+ `git remotes changed since the watcher started.`,
424
+ { before: initialRemotes, after: cur });
425
+ initialRemotes = cur;
426
+ }
427
+ } catch (_) { /* repo may not have git */ }
428
+
429
+ // Project moved/renamed
430
+ if (initialStat) {
431
+ try {
432
+ const s = fs.statSync(projectAbs);
433
+ if (s.ino !== initialStat.ino) {
434
+ maybeFire('project_moved', 'alert',
435
+ `Project root inode changed — directory may have been moved or replaced.`,
436
+ { wasIno: initialStat.ino, nowIno: s.ino });
437
+ }
438
+ } catch (_) {
439
+ maybeFire('project_disappeared', 'alert',
440
+ `Project root no longer exists at ${projectAbs}.`, {});
441
+ }
442
+ }
443
+ }, 5000);
444
+
445
+ return {
446
+ stop() {
447
+ stopped = true;
448
+ clearInterval(sweepTimer);
449
+ try { watcher && watcher.close(); } catch (_) { /* ignore */ }
450
+ },
451
+ };
452
+ }
453
+
454
+ /**
455
+ * The CLI entry. Options:
456
+ * - intervalSec: override server-suggested poll interval
457
+ * - verbose: print every tick instead of one live line
458
+ * - json: emit JSONL to stdout (used by --daemon)
459
+ * - daemon: detach from TTY, write JSONL to log file, no stdout
460
+ * - exfil: enable file-system exfiltration heuristics (default true)
114
461
  */
115
- async function runWatch({ projectRoot, code, intervalSec, verbose = false, json = false }) {
462
+ async function runWatch({
463
+ projectRoot,
464
+ code,
465
+ intervalSec,
466
+ verbose = false,
467
+ json = false,
468
+ daemon = false,
469
+ exfil = true,
470
+ }) {
116
471
  if (!code || typeof code !== 'string') {
117
472
  throw new SealcodeError('SEALCODE_WATCH_NO_CODE', {
118
473
  detail: 'Pass the access code as the first argument.',
@@ -127,7 +482,8 @@ async function runWatch({ projectRoot, code, intervalSec, verbose = false, json
127
482
  const trimmedCode = code.trim();
128
483
 
129
484
  // First call doubles as a fast-fail validator and as a way to learn the
130
- // server-side heartbeat interval, which we honor unless --interval is set.
485
+ // server-side heartbeat interval, which we honor unless --interval is
486
+ // explicitly set.
131
487
  const first = await heartbeatOnce(trimmedCode);
132
488
  if (!first.ok) {
133
489
  throw new SealcodeError('SEALCODE_WATCH_OFFLINE', {
@@ -140,13 +496,48 @@ async function runWatch({ projectRoot, code, intervalSec, verbose = false, json
140
496
  MIN_INTERVAL_SEC,
141
497
  Math.min(MAX_INTERVAL_SEC, intervalSec || serverInterval),
142
498
  );
499
+ const offlineGraceSec = Number(first.response?.offlineGraceSeconds) || 1800;
500
+ const strict = !!first.response?.strictMode;
501
+ // sealcode@1.1.0 — pull policy from the session meta (set by redeem)
502
+ // and from the heartbeat echo (in case the owner edited the grant
503
+ // after redeem). Local session is authoritative for mode/idle —
504
+ // server's heartbeat policy is just a refresh hint.
505
+ const sessionMeta = loadSessionMeta(projectRoot) || {};
506
+ const policy = grantPolicy.normalize(
507
+ (sessionMeta.meta && sessionMeta.meta.policy) || first.response?.policy || {},
508
+ );
509
+ // Long-poll budget. We pick min(server_interval * 1000, 25_000).
510
+ // ~25s wait keeps the open connection well under typical load-balancer
511
+ // / proxy idle timeouts (30s on most). When wait elapses we just go
512
+ // around again immediately.
513
+ const waitMs = Math.min(25_000, effectiveInterval * 1000);
143
514
 
144
- // Handle the "already locked at startup" case before announcing.
515
+ // Heartbeat sidecar read by cli-guard.js to decide whether the
516
+ // grant-derived session is still being supervised. We touch it every
517
+ // tick; stale mtime = dead watcher.
518
+ writeWatchState(projectRoot, {
519
+ pid: process.pid,
520
+ code: trimmedCode,
521
+ startedAt: new Date().toISOString(),
522
+ intervalSec: effectiveInterval,
523
+ strict,
524
+ daemon: !!daemon,
525
+ });
526
+ // Record the watcher pid in the session meta too — guard checks both.
527
+ updateSessionMeta(projectRoot, {
528
+ watchPidFile: watchStateFile(projectRoot),
529
+ strictWatch: strict,
530
+ });
531
+
532
+ // If we somehow started watching an already-locked grant, lock first
533
+ // and exit. Don't pretend everything's fine.
145
534
  if (first.response.action === 'lock') {
146
- return finalLock(projectRoot, config, trimmedCode, first.response.reason, json);
535
+ return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
147
536
  }
148
537
 
149
- if (json) {
538
+ if (daemon) {
539
+ appendLog(projectRoot, { type: 'start', intervalSec: effectiveInterval, ...first.response });
540
+ } else if (json) {
150
541
  process.stdout.write(
151
542
  JSON.stringify({ type: 'start', intervalSec: effectiveInterval, ...first.response }) + '\n',
152
543
  );
@@ -158,68 +549,170 @@ async function runWatch({ projectRoot, code, intervalSec, verbose = false, json
158
549
  first.response.autoLockAt
159
550
  ? `${ui.c.dim('auto-lock ')} ${first.response.autoLockAt}`
160
551
  : `${ui.c.dim('auto-lock ')} ${ui.c.dim('(none)')}`,
161
- `${ui.c.dim('strict ')} ${first.response.strictMode ? 'yes' : 'no'}`,
552
+ `${ui.c.dim('strict ')} ${strict ? ui.c.yellow('yes — files will lock if I am killed') : 'no'}`,
162
553
  '',
163
554
  ui.c.dim('Press Ctrl-C to stop. If the owner revokes this code, your local'),
164
555
  ui.c.dim('files will be re-locked automatically.'),
165
556
  ]);
166
557
  }
167
558
 
168
- // Print one immediate "ok" line so the demo viewer sees activity right away.
169
- printTick(first.response, json, verbose);
559
+ // First tick line right after the box so demos look alive immediately.
560
+ printTick(first.response, json, verbose, daemon, projectRoot);
561
+
562
+ // sealcode@1.1.0 — track file activity (for the activity timeline +
563
+ // idle-lock + RO-violation detection). We buffer per-file events
564
+ // between heartbeats and flush them with each tick.
565
+ const fileEventBuffer = [];
566
+ let lastFsActivityAt = Date.now();
567
+ let roViolationPending = null; // { path, at } or null
568
+ const FILE_EVENT_BUFFER_MAX = 500;
569
+
570
+ // Exfil heuristics
571
+ let exfilCtrl = null;
572
+ if (exfil) {
573
+ exfilCtrl = startExfilWatchers({
574
+ projectRoot,
575
+ code: trimmedCode,
576
+ config,
577
+ onAlert: (a) => {
578
+ appendLog(projectRoot, { type: 'exfil_alert', ...a });
579
+ if (!daemon && !json) ui.warn(`[${ts()}] ⚠ ${a.summary}`);
580
+ void postAlert(trimmedCode, a);
581
+ },
582
+ onFileEvent: (ev) => {
583
+ lastFsActivityAt = Date.now();
584
+ // RO violation: any modification of an unlocked file means the
585
+ // recipient bypassed the chmod 0444 (chmod is advisory on
586
+ // Unix and not enforced for root / chattr). Trigger an immediate
587
+ // re-lock + alert.
588
+ if (policy.mode === 'ro' && (ev.kind === 'modified' || ev.kind === 'created' || ev.kind === 'deleted' || ev.kind === 'renamed')) {
589
+ if (!roViolationPending) {
590
+ roViolationPending = { path: ev.path, at: ev.occurredAt };
591
+ }
592
+ }
593
+ if (fileEventBuffer.length < FILE_EVENT_BUFFER_MAX) {
594
+ fileEventBuffer.push(ev);
595
+ }
596
+ },
597
+ });
598
+ }
170
599
 
171
- // Make Ctrl-C clean.
600
+ // Signal handling. In strict mode, ANY exit path (SIGINT, SIGTERM)
601
+ // must re-lock the project before we die — otherwise the contractor
602
+ // could just Ctrl-C the watcher to keep plaintext.
172
603
  let stopped = false;
173
- const onSig = () => {
604
+ const cleanup = async (reason) => {
174
605
  if (stopped) return;
175
606
  stopped = true;
176
- if (!json) ui.say('\n' + ui.c.dim('stopped watching (files left as-is)'));
607
+ if (exfilCtrl) exfilCtrl.stop();
608
+ deleteWatchState(projectRoot);
609
+ if (strict) {
610
+ appendLog(projectRoot, { type: 'strict_exit_lock', reason });
611
+ await finalLock(projectRoot, config, trimmedCode, `strict:${reason}`, json, daemon).catch(() => {});
612
+ } else {
613
+ if (!json && !daemon) ui.say('\n' + ui.c.dim('stopped watching (lenient mode — files left as-is)'));
614
+ }
177
615
  process.exit(0);
178
616
  };
179
- process.on('SIGINT', onSig);
180
- process.on('SIGTERM', onSig);
617
+ process.on('SIGINT', () => { void cleanup('sigint'); });
618
+ process.on('SIGTERM', () => { void cleanup('sigterm'); });
619
+ process.on('SIGHUP', () => { void cleanup('sighup'); });
181
620
 
621
+ // Poll loop with offline-grace.
622
+ //
623
+ // sealcode@1.1.0 — we use HTTP long-poll (`waitMs`) for near-instant
624
+ // revoke. The server holds the heartbeat open for up to ~25s and
625
+ // returns immediately when a lock event fires for our grant. If it
626
+ // returns "ok"/"warn" naturally, we go around the loop again with
627
+ // no additional sleep — long-poll already burned the interval.
182
628
  let consecutiveTransient = 0;
629
+ let firstTransientAt = 0;
630
+ let lastFlushAt = 0;
183
631
  while (!stopped) {
184
- await sleep(effectiveInterval * 1000);
185
632
  if (stopped) break;
633
+ writeWatchState(projectRoot, { lastTickAt: new Date().toISOString() });
634
+
635
+ // Self-reports (idle / ro-violation) MUST flush before we hit the
636
+ // long-poll heartbeat — the server uses the self-report kind to
637
+ // audit the lock with the right reason.
638
+ if (policy.mode === 'ro' && roViolationPending) {
639
+ const violation = roViolationPending;
640
+ roViolationPending = null;
641
+ try {
642
+ await heartbeatOnce(trimmedCode, {
643
+ selfReport: { reason: 'ro_violation', detail: violation.path.slice(0, 200) },
644
+ });
645
+ } catch (_) { /* fall through; we lock either way */ }
646
+ appendLog(projectRoot, { type: 'ro_violation', path: violation.path });
647
+ if (!daemon && !json) ui.warn(`[${ts()}] ⚠ read-only violation: ${violation.path} — re-locking`);
648
+ return finalLock(projectRoot, config, trimmedCode, 'ro_violation', json, daemon);
649
+ }
650
+ if (policy.idleAutoLockMinutes > 0) {
651
+ const idleMs = Date.now() - lastFsActivityAt;
652
+ if (idleMs >= policy.idleAutoLockMinutes * 60_000) {
653
+ try {
654
+ await heartbeatOnce(trimmedCode, {
655
+ selfReport: { reason: 'idle_lock', detail: `idle ${Math.floor(idleMs / 60_000)}m` },
656
+ });
657
+ } catch (_) { /* lock anyway */ }
658
+ appendLog(projectRoot, { type: 'idle_lock', idleMs });
659
+ if (!daemon && !json) ui.warn(`[${ts()}] ⏰ idle ${Math.floor(idleMs / 60_000)}m — auto-locking`);
660
+ return finalLock(projectRoot, config, trimmedCode, 'idle_lock', json, daemon);
661
+ }
662
+ }
663
+
664
+ // Flush per-file activity batch (at most once every 10s; otherwise
665
+ // we'd hit the rate limiter on `access.file-events`).
666
+ if (fileEventBuffer.length > 0 && Date.now() - lastFlushAt > 10_000) {
667
+ const batch = fileEventBuffer.splice(0, fileEventBuffer.length);
668
+ lastFlushAt = Date.now();
669
+ void postFileEvents(trimmedCode, batch);
670
+ }
671
+
186
672
  let r;
187
673
  try {
188
- r = await heartbeatOnce(trimmedCode);
674
+ r = await heartbeatOnce(trimmedCode, { waitMs });
189
675
  } catch (err) {
190
- // Terminal error (malformed code etc.) — propagate.
191
676
  throw err;
192
677
  }
193
678
  if (!r.ok) {
679
+ const now = Date.now();
680
+ if (consecutiveTransient === 0) firstTransientAt = now;
194
681
  consecutiveTransient += 1;
195
- if (!json) {
196
- ui.warn(`[${ts()}] transient: ${r.err?.message || 'network'} (retry in ${TRANSIENT_BACKOFF_SEC}s)`);
682
+ const offlineSec = Math.floor((now - firstTransientAt) / 1000);
683
+ const msg = `transient: ${r.err?.message || 'network'} (offline ${offlineSec}s / ${offlineGraceSec}s grace, retry in ${TRANSIENT_BACKOFF_SEC}s)`;
684
+ if (daemon) {
685
+ appendLog(projectRoot, { type: 'transient', error: String(r.err?.message || r.err), offlineSec });
686
+ } else if (json) {
687
+ process.stdout.write(JSON.stringify({ type: 'transient', error: String(r.err?.message || r.err), offlineSec }) + '\n');
197
688
  } else {
198
- process.stdout.write(
199
- JSON.stringify({ type: 'transient', at: new Date().toISOString(), error: String(r.err?.message || r.err) }) + '\n',
200
- );
689
+ ui.warn(`[${ts()}] ${msg}`);
201
690
  }
202
- // After a long offline streak the recipient should know — but we don't
203
- // auto-lock on a server outage. Owner intent (revoke) must be observable.
204
- if (consecutiveTransient > 6 && !json) {
205
- ui.warn(` still offline after ${consecutiveTransient} attempts — owner revokes are not visible while offline.`);
691
+ if (offlineSec >= offlineGraceSec) {
692
+ return finalLock(projectRoot, config, trimmedCode, 'offline_grace_exceeded', json, daemon);
206
693
  }
207
694
  await sleep(TRANSIENT_BACKOFF_SEC * 1000);
208
695
  continue;
209
696
  }
210
697
  consecutiveTransient = 0;
211
698
  if (r.response.action === 'lock') {
212
- return finalLock(projectRoot, config, trimmedCode, r.response.reason, json);
699
+ return finalLock(projectRoot, config, trimmedCode, r.response.reason, json, daemon);
213
700
  }
214
- printTick(r.response, json, verbose);
701
+ printTick(r.response, json, verbose, daemon, projectRoot);
702
+ // After a successful (possibly long-polled) heartbeat we do NOT
703
+ // sleep here — long-poll already consumed our budget. If the server
704
+ // returned quickly (no wait) we still loop immediately; that's
705
+ // fine, the inner heartbeat handles its own pacing.
215
706
  }
216
707
  }
217
708
 
218
- function printTick(resp, json, verbose) {
709
+ function printTick(resp, json, verbose, daemon, projectRoot) {
710
+ if (daemon) {
711
+ appendLog(projectRoot, { type: 'tick', ...resp });
712
+ return;
713
+ }
219
714
  if (json) {
220
- process.stdout.write(
221
- JSON.stringify({ type: 'tick', at: new Date().toISOString(), ...resp }) + '\n',
222
- );
715
+ process.stdout.write(JSON.stringify({ type: 'tick', ...resp }) + '\n');
223
716
  return;
224
717
  }
225
718
  const remaining = fmtRemaining(resp.remainingMs);
@@ -228,7 +721,6 @@ function printTick(resp, json, verbose) {
228
721
  } else if (verbose) {
229
722
  ui.say(`[${ts()}] ${ui.c.green('ok')} — ${remaining} left`);
230
723
  } else {
231
- // Single quiet line; overwrite-in-place if TTY for a clean demo.
232
724
  if (ui.STDERR_TTY) {
233
725
  process.stderr.write(
234
726
  `\r${ui.c.green('●')} watching · ${ui.c.dim(ts())} · ${ui.c.dim(remaining + ' left')}\x1b[K`,
@@ -239,45 +731,53 @@ function printTick(resp, json, verbose) {
239
731
  }
240
732
  }
241
733
 
242
- async function finalLock(projectRoot, config, code, reason, json) {
243
- // Erase the in-place tick line if we were drawing one.
244
- if (!json && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
245
-
734
+ async function finalLock(projectRoot, config, code, reason, json, daemon) {
735
+ if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
246
736
  const label = reason || 'lock';
247
- if (json) {
248
- process.stdout.write(
249
- JSON.stringify({ type: 'lock', at: new Date().toISOString(), reason: label, code }) + '\n',
250
- );
737
+ if (daemon) {
738
+ appendLog(projectRoot, { type: 'lock', reason: label });
739
+ } else if (json) {
740
+ process.stdout.write(JSON.stringify({ type: 'lock', reason: label }) + '\n');
251
741
  } else {
252
742
  ui.warn(`[${ts()}] server says LOCK — reason: ${ui.c.bold(label)}`);
253
743
  }
254
744
 
255
745
  const K = loadSession(projectRoot);
256
746
  if (!K) {
257
- // The recipient never had a key cached (didn't run `sealcode unlock`),
258
- // so there's no plaintext to seal. We still exit non-zero so a wrapping
259
- // shell script knows the grant is dead.
260
- if (!json) {
261
- ui.fail(
262
- 'access revoked — but no cached session was found, so there is nothing to re-lock here.',
263
- );
747
+ deleteWatchState(projectRoot);
748
+ if (!json && !daemon) {
749
+ ui.fail('access revoked but no cached session was found, so there is nothing to re-lock here.');
264
750
  ui.hint(' (this is normal if you never ran `sealcode unlock` on this machine.)');
265
751
  }
266
752
  process.exit(2);
267
753
  }
268
754
 
755
+ // sealcode@1.1.0 — for grant-derived sessions that had a path scope
756
+ // (or any future "subset-only" policy), preserve out-of-scope blobs
757
+ // so the contractor's re-lock doesn't wipe the project for everyone.
758
+ const _sessionMeta = loadSessionMeta(projectRoot);
759
+ const _preserveUnseen =
760
+ !!_sessionMeta &&
761
+ _sessionMeta.meta &&
762
+ _sessionMeta.meta.source === 'grant' &&
763
+ _sessionMeta.meta.policy &&
764
+ Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
765
+ _sessionMeta.meta.policy.allowedPaths.length > 0;
766
+
269
767
  let res;
270
768
  try {
271
- res = await runLock({ projectRoot, config, K });
769
+ res = await runLock({ projectRoot, config, K, preserveUnseen: _preserveUnseen });
272
770
  } catch (err) {
273
- if (!json) ui.fail(`re-lock failed: ${err.message}`);
771
+ if (daemon) appendLog(projectRoot, { type: 'lock_error', error: String(err.message || err) });
772
+ else if (!json) ui.fail(`re-lock failed: ${err.message}`);
274
773
  process.exit(3);
275
774
  }
276
775
  clearSession(projectRoot);
277
- if (json) {
278
- process.stdout.write(
279
- JSON.stringify({ type: 'locked', at: new Date().toISOString(), count: res.count, reason: label }) + '\n',
280
- );
776
+ deleteWatchState(projectRoot);
777
+ if (daemon) {
778
+ appendLog(projectRoot, { type: 'locked', count: res.count, reason: label });
779
+ } else if (json) {
780
+ process.stdout.write(JSON.stringify({ type: 'locked', count: res.count, reason: label }) + '\n');
281
781
  } else {
282
782
  ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')} — session cleared`);
283
783
  ui.hint(' Your local plaintext has been wiped. Ask the owner for a fresh code if you still need access.');
@@ -285,4 +785,78 @@ async function finalLock(projectRoot, config, code, reason, json) {
285
785
  process.exit(0);
286
786
  }
287
787
 
288
- module.exports = { runWatch };
788
+ /**
789
+ * Helper: spawn a detached `sealcode watch <code> --daemon` child. Used
790
+ * by runUnlock when the unlock came from a grant-derived session.
791
+ * Returns the child pid (already detached). Best-effort.
792
+ */
793
+ function spawnDaemonWatcher({ projectRoot, code }) {
794
+ try {
795
+ ensureDir(WATCH_LOG_DIR);
796
+ const bin = process.execPath;
797
+ const entry = require.resolve('../bin/sealcode.js');
798
+ const child = spawn(
799
+ bin,
800
+ [entry, 'watch', code, '--daemon'],
801
+ {
802
+ cwd: projectRoot,
803
+ detached: true,
804
+ stdio: 'ignore',
805
+ env: { ...process.env, SEALCODE_AUTO_DAEMON: '1' },
806
+ },
807
+ );
808
+ child.unref();
809
+ return { pid: child.pid };
810
+ } catch (err) {
811
+ return { error: String(err.message || err) };
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Check whether a daemon watcher is currently running for this project.
817
+ * Used by cli-guard. Returns one of:
818
+ * { state: 'none' } no watch state file
819
+ * { state: 'alive', pid } pid exists + heartbeat fresh
820
+ * { state: 'stale', pid } heartbeat older than 3 * interval
821
+ * { state: 'dead', pid } process not found
822
+ */
823
+ function readWatcherStatus(projectRoot) {
824
+ const p = watchStateFile(projectRoot);
825
+ if (!fs.existsSync(p)) return { state: 'none' };
826
+ let state;
827
+ try {
828
+ state = JSON.parse(fs.readFileSync(p, 'utf8'));
829
+ } catch (_) {
830
+ return { state: 'none' };
831
+ }
832
+ const pid = state.pid;
833
+ if (typeof pid !== 'number') return { state: 'none' };
834
+ // Process exists?
835
+ let alive = false;
836
+ try {
837
+ process.kill(pid, 0);
838
+ alive = true;
839
+ } catch (_) {
840
+ alive = false;
841
+ }
842
+ if (!alive) return { state: 'dead', pid };
843
+ // Heartbeat fresh?
844
+ const interval = Number(state.intervalSec) || DEFAULT_INTERVAL_SEC;
845
+ const last = state.lastTickAt
846
+ ? Date.parse(state.lastTickAt)
847
+ : state.startedAt
848
+ ? Date.parse(state.startedAt)
849
+ : 0;
850
+ if (!last || Date.now() - last > 3 * interval * 1000) {
851
+ return { state: 'stale', pid, lastTickAt: state.lastTickAt };
852
+ }
853
+ return { state: 'alive', pid };
854
+ }
855
+
856
+ module.exports = {
857
+ runWatch,
858
+ spawnDaemonWatcher,
859
+ readWatcherStatus,
860
+ watchStateFile,
861
+ watchLogFile,
862
+ };