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 +15 -3
- package/package.json +1 -1
- package/src/api.js +14 -1
- package/src/cli-auth.js +26 -0
- package/src/cli-grants.js +409 -17
- package/src/cli-guard.js +117 -0
- package/src/cli-link.js +10 -1
- package/src/cli-service.js +230 -0
- package/src/cli-watch.js +659 -85
- package/src/cli.js +154 -3
- package/src/device.js +163 -0
- package/src/grant-policy.js +119 -0
- package/src/keypair.js +159 -0
- package/src/keystore.js +76 -6
- package/src/open.js +69 -6
- package/src/seal.js +68 -3
- package/src/share-crypto.js +155 -0
- package/src/watermark.js +212 -0
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`
|
|
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
|
|
12
|
-
*
|
|
13
|
-
* `sealcode
|
|
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
|
|
16
|
-
* - on `action: "lock"` (revoked / expired / auto-lock
|
|
17
|
-
* immediately runs the same lock pipeline as
|
|
18
|
-
* the cached session, and exits
|
|
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
|
|
21
|
-
*
|
|
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
|
|
24
|
-
* the authentication for these endpoints. That matches the contractor
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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: {
|
|
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
|
-
* @
|
|
109
|
-
*
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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({
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
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 ')} ${
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
604
|
+
const cleanup = async (reason) => {
|
|
174
605
|
if (stopped) return;
|
|
175
606
|
stopped = true;
|
|
176
|
-
if (
|
|
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',
|
|
180
|
-
process.on('SIGTERM',
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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 (
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 (
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
+
};
|