sealcode 1.3.4 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/cli-grants.js +5 -1
- package/src/cli-registry.js +256 -0
- package/src/cli-service.js +150 -37
- package/src/cli-watch.js +71 -17
- package/src/cli.js +124 -1
- package/src/seal.js +46 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sealcode",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6",
|
|
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",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
],
|
|
42
42
|
"scripts": {
|
|
43
43
|
"test": "node test/roundtrip.js",
|
|
44
|
-
"selftest": "node test/roundtrip.js"
|
|
44
|
+
"selftest": "node test/roundtrip.js",
|
|
45
|
+
"preuninstall": "node ./bin/sealcode.js preuninstall-check || true"
|
|
45
46
|
},
|
|
46
47
|
"engines": {
|
|
47
48
|
"node": ">=18"
|
package/src/cli-grants.js
CHANGED
|
@@ -628,6 +628,7 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
|
|
|
628
628
|
sp.succeed(
|
|
629
629
|
`unlocked ${ui.c.bold(ures.count)} files${sk} ${ui.c.dim(`(locked at ${ures.sealedAt})`)}`,
|
|
630
630
|
);
|
|
631
|
+
try { require('./cli-registry').markUnlocked(projectRoot); } catch (_) { /* ignore */ }
|
|
631
632
|
if (ures.policy.mode === 'ro') {
|
|
632
633
|
ui.hint(' Read-only grant: files are 0444. Any modification will trigger an immediate re-lock.');
|
|
633
634
|
}
|
|
@@ -735,7 +736,10 @@ async function runLockdown({ projectRoot, confirm = true } = {}) {
|
|
|
735
736
|
{ auth: true },
|
|
736
737
|
);
|
|
737
738
|
const grants = Array.isArray(list.grants) ? list.grants : [];
|
|
738
|
-
|
|
739
|
+
// The serialized payload uses `state` (not `status`) — values are
|
|
740
|
+
// computed by describeGrant() and include "active", "paused",
|
|
741
|
+
// "expired", "revoked", "pending".
|
|
742
|
+
const live = grants.filter((g) => g.state === 'active' || g.state === 'paused');
|
|
739
743
|
if (live.length === 0) {
|
|
740
744
|
ui.ok('Lockdown complete — no active grants to revoke.');
|
|
741
745
|
return;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sealcode@1.3.6 — Known-projects registry + self-check.
|
|
5
|
+
*
|
|
6
|
+
* Problem this solves
|
|
7
|
+
* -------------------
|
|
8
|
+
* Today the watcher is the only thing that re-locks a recipient's
|
|
9
|
+
* working copy on revoke/pause/expiry. If the watcher dies and the
|
|
10
|
+
* recipient never runs another `sealcode` command in that project,
|
|
11
|
+
* the plaintext sits there indefinitely.
|
|
12
|
+
*
|
|
13
|
+
* We can't stop a determined user from `kill -9`-ing the watcher and
|
|
14
|
+
* never running sealcode again on that project — that's a fundamental
|
|
15
|
+
* limit of any client-side enforcement. But we can close the much
|
|
16
|
+
* more common path: the recipient uses sealcode in OTHER projects (or
|
|
17
|
+
* for other commands) on the same machine. Every one of those
|
|
18
|
+
* invocations is an opportunity to notice "hey, project X is sitting
|
|
19
|
+
* unlocked with no watcher" and re-lock it.
|
|
20
|
+
*
|
|
21
|
+
* The registry
|
|
22
|
+
* ------------
|
|
23
|
+
* One JSON file at ~/.sealcode/projects.json. Maintained by the CLI
|
|
24
|
+
* on every unlock (add) and every lock (mark locked). Format:
|
|
25
|
+
*
|
|
26
|
+
* {
|
|
27
|
+
* "/Users/alice/work/proj-a": {
|
|
28
|
+
* "addedAt": "2026-05-21T12:00:00.000Z",
|
|
29
|
+
* "lastSeenAt": "2026-05-21T12:34:56.000Z",
|
|
30
|
+
* "lastState": "unlocked" | "locked"
|
|
31
|
+
* },
|
|
32
|
+
* ...
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* Self-check (called from cli.js on EVERY invocation, including
|
|
36
|
+
* commands like `sealcode --help` and `sealcode whoami`):
|
|
37
|
+
* - Walk the registry.
|
|
38
|
+
* - For each entry whose lastState is "unlocked":
|
|
39
|
+
* - If the project no longer exists on disk, drop it.
|
|
40
|
+
* - If the watcher state file is missing or stale AND a session
|
|
41
|
+
* is cached, attempt a panic-lock in the background.
|
|
42
|
+
* - Print a single-line warning so the user sees it.
|
|
43
|
+
*
|
|
44
|
+
* The self-check is purely best-effort and never throws. It runs
|
|
45
|
+
* synchronously (cheap — just file stat-ing) and any async lock
|
|
46
|
+
* happens out-of-band so the user's command isn't delayed.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const fs = require('fs');
|
|
50
|
+
const os = require('os');
|
|
51
|
+
const path = require('path');
|
|
52
|
+
|
|
53
|
+
const REGISTRY_DIR = path.join(os.homedir(), '.sealcode');
|
|
54
|
+
const REGISTRY_FILE = path.join(REGISTRY_DIR, 'projects.json');
|
|
55
|
+
|
|
56
|
+
function ensureDir() {
|
|
57
|
+
try { fs.mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 }); } catch (_) { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readRegistry() {
|
|
61
|
+
try {
|
|
62
|
+
const raw = fs.readFileSync(REGISTRY_FILE, 'utf8');
|
|
63
|
+
const obj = JSON.parse(raw);
|
|
64
|
+
if (obj && typeof obj === 'object') return obj;
|
|
65
|
+
} catch (_) { /* missing or corrupt — start fresh */ }
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeRegistry(obj) {
|
|
70
|
+
ensureDir();
|
|
71
|
+
// Atomic-ish write: write to .tmp, rename. Avoids torn writes if
|
|
72
|
+
// two CLI invocations race.
|
|
73
|
+
const tmp = REGISTRY_FILE + '.' + process.pid + '.tmp';
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
76
|
+
fs.renameSync(tmp, REGISTRY_FILE);
|
|
77
|
+
} catch (_) {
|
|
78
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* ignore */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark a project as currently unlocked. Called from runUnlock.
|
|
84
|
+
*/
|
|
85
|
+
function markUnlocked(projectRoot) {
|
|
86
|
+
if (!projectRoot) return;
|
|
87
|
+
const reg = readRegistry();
|
|
88
|
+
const abs = path.resolve(projectRoot);
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
reg[abs] = {
|
|
91
|
+
addedAt: reg[abs]?.addedAt || now,
|
|
92
|
+
lastSeenAt: now,
|
|
93
|
+
lastState: 'unlocked',
|
|
94
|
+
};
|
|
95
|
+
writeRegistry(reg);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mark a project as currently locked. Called from runLock + finalLock
|
|
100
|
+
* + softLock paths.
|
|
101
|
+
*/
|
|
102
|
+
function markLocked(projectRoot) {
|
|
103
|
+
if (!projectRoot) return;
|
|
104
|
+
const reg = readRegistry();
|
|
105
|
+
const abs = path.resolve(projectRoot);
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
reg[abs] = {
|
|
108
|
+
addedAt: reg[abs]?.addedAt || now,
|
|
109
|
+
lastSeenAt: now,
|
|
110
|
+
lastState: 'locked',
|
|
111
|
+
};
|
|
112
|
+
writeRegistry(reg);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Remove a project from the registry (e.g. when it's deleted or the
|
|
117
|
+
* user explicitly unlinks).
|
|
118
|
+
*/
|
|
119
|
+
function forget(projectRoot) {
|
|
120
|
+
if (!projectRoot) return;
|
|
121
|
+
const reg = readRegistry();
|
|
122
|
+
const abs = path.resolve(projectRoot);
|
|
123
|
+
if (reg[abs]) {
|
|
124
|
+
delete reg[abs];
|
|
125
|
+
writeRegistry(reg);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Return a list of currently-known projects.
|
|
131
|
+
*/
|
|
132
|
+
function list() {
|
|
133
|
+
const reg = readRegistry();
|
|
134
|
+
return Object.entries(reg).map(([projectRoot, meta]) => ({
|
|
135
|
+
projectRoot,
|
|
136
|
+
...meta,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Return projects whose lastState is "unlocked" but:
|
|
142
|
+
* - the directory still exists, AND
|
|
143
|
+
* - either no watcher state file exists OR the watcher's pid is
|
|
144
|
+
* not alive OR the watch state hasn't been touched in > maxStaleMin.
|
|
145
|
+
*
|
|
146
|
+
* Synchronous, cheap. Used by selfCheck and by the supervisor.
|
|
147
|
+
*/
|
|
148
|
+
function findOrphanedUnlocks({ maxStaleMin = 5 } = {}) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const reg = readRegistry();
|
|
151
|
+
const cutoff = Date.now() - maxStaleMin * 60_000;
|
|
152
|
+
|
|
153
|
+
for (const [projectRoot, meta] of Object.entries(reg)) {
|
|
154
|
+
if (!meta || meta.lastState !== 'unlocked') continue;
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(projectRoot)) continue;
|
|
157
|
+
} catch (_) { continue; }
|
|
158
|
+
|
|
159
|
+
// Look up the watch state. We can't import cli-watch here (circular),
|
|
160
|
+
// so we replicate the path math.
|
|
161
|
+
const id = projectIdHash(projectRoot);
|
|
162
|
+
const watchFile = path.join(os.homedir(), '.sealcode', 'sessions', `${id}.watch.json`);
|
|
163
|
+
let watcherAlive = false;
|
|
164
|
+
try {
|
|
165
|
+
const st = JSON.parse(fs.readFileSync(watchFile, 'utf8'));
|
|
166
|
+
const mtimeMs = fs.statSync(watchFile).mtimeMs;
|
|
167
|
+
const pid = st && st.pid;
|
|
168
|
+
if (pid && mtimeMs > cutoff) {
|
|
169
|
+
try { process.kill(pid, 0); watcherAlive = true; } catch (_) { watcherAlive = false; }
|
|
170
|
+
}
|
|
171
|
+
} catch (_) { watcherAlive = false; }
|
|
172
|
+
|
|
173
|
+
if (!watcherAlive) {
|
|
174
|
+
out.push({ projectRoot, lastSeenAt: meta.lastSeenAt });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Identical hash that keystore.projectId uses. Inlined here to avoid
|
|
182
|
+
* a circular dep between cli-registry and keystore (keystore is loaded
|
|
183
|
+
* very early on every CLI invocation).
|
|
184
|
+
*/
|
|
185
|
+
function projectIdHash(projectRoot) {
|
|
186
|
+
const crypto = require('crypto');
|
|
187
|
+
return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Synchronous self-check called at the top of every CLI invocation
|
|
192
|
+
* (from cli.js). NEVER throws. Returns the number of orphaned
|
|
193
|
+
* unlocked projects detected. Prints a one-line warning per project
|
|
194
|
+
* unless `quiet` is true.
|
|
195
|
+
*
|
|
196
|
+
* The actual re-lock is deferred to a background detached process so
|
|
197
|
+
* the user's command isn't blocked. We invoke `sealcode panic` in a
|
|
198
|
+
* detached child for each orphan.
|
|
199
|
+
*
|
|
200
|
+
* `quiet` is set for short-running commands (where, version, help)
|
|
201
|
+
* and for the panic command itself to avoid recursion.
|
|
202
|
+
*/
|
|
203
|
+
function selfCheck({ quiet = false, skipBackgroundLock = false } = {}) {
|
|
204
|
+
let orphans;
|
|
205
|
+
try {
|
|
206
|
+
orphans = findOrphanedUnlocks();
|
|
207
|
+
} catch (_) { return 0; }
|
|
208
|
+
|
|
209
|
+
if (orphans.length === 0) return 0;
|
|
210
|
+
|
|
211
|
+
if (!quiet) {
|
|
212
|
+
try {
|
|
213
|
+
const ui = require('./ui');
|
|
214
|
+
for (const o of orphans) {
|
|
215
|
+
ui.warn(
|
|
216
|
+
`\u26a0 ${o.projectRoot} appears unlocked but its watcher is not running. `
|
|
217
|
+
+ `Auto-locking in the background.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} catch (_) { /* ui module may not be loadable in some edge cases */ }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (skipBackgroundLock) return orphans.length;
|
|
224
|
+
|
|
225
|
+
// Detached background panic-lock per orphan. We CANNOT just call
|
|
226
|
+
// runLock here — we don't have the session key. The `panic` command
|
|
227
|
+
// (and its lock pathway) is what knows how to find the cached
|
|
228
|
+
// session and re-encrypt. If there's no session cached, the panic
|
|
229
|
+
// command is a no-op + the registry gets corrected to "locked".
|
|
230
|
+
const { spawn } = require('child_process');
|
|
231
|
+
const node = process.execPath;
|
|
232
|
+
const cli = path.resolve(__dirname, '..', 'bin', 'sealcode.js');
|
|
233
|
+
for (const o of orphans) {
|
|
234
|
+
try {
|
|
235
|
+
const child = spawn(node, [cli, 'panic', '--project', o.projectRoot, '--from-selfcheck'], {
|
|
236
|
+
detached: true,
|
|
237
|
+
stdio: 'ignore',
|
|
238
|
+
windowsHide: true,
|
|
239
|
+
cwd: o.projectRoot,
|
|
240
|
+
});
|
|
241
|
+
child.unref();
|
|
242
|
+
} catch (_) { /* best-effort */ }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return orphans.length;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
markUnlocked,
|
|
250
|
+
markLocked,
|
|
251
|
+
forget,
|
|
252
|
+
list,
|
|
253
|
+
findOrphanedUnlocks,
|
|
254
|
+
selfCheck,
|
|
255
|
+
REGISTRY_FILE,
|
|
256
|
+
};
|
package/src/cli-service.js
CHANGED
|
@@ -34,6 +34,10 @@ const path = require('path');
|
|
|
34
34
|
const { execFileSync } = require('child_process');
|
|
35
35
|
|
|
36
36
|
const SERVICE_NAME = 'dev.sealcode.watcher';
|
|
37
|
+
// sealcode@1.3.6 — Windows Task Scheduler name. No dots allowed in path
|
|
38
|
+
// components, so we use a hyphen-cased name. Stored at the user level
|
|
39
|
+
// (no Administrator prompt required).
|
|
40
|
+
const WIN_TASK_NAME = 'SealCodeWatcherSupervisor';
|
|
37
41
|
const ui = require('./ui');
|
|
38
42
|
|
|
39
43
|
function launchdPath() {
|
|
@@ -129,6 +133,77 @@ WantedBy=timers.target
|
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
/**
|
|
137
|
+
* sealcode@1.3.6 — Windows scheduled task. Runs at user logon AND every
|
|
138
|
+
* 1 minute thereafter. We use `schtasks` (built-in, no admin required
|
|
139
|
+
* for user-level tasks) instead of an actual Windows Service (which
|
|
140
|
+
* requires Administrator and a service wrapper).
|
|
141
|
+
*
|
|
142
|
+
* /SC ONLOGON → fires when the user logs in (so the watcher
|
|
143
|
+
* supervisor is running before they start work)
|
|
144
|
+
* /RI 1 → repeat interval of 1 minute (combined with /DU)
|
|
145
|
+
* /DU 9999:59 → for ~10000 hours = effectively forever
|
|
146
|
+
* /F → force overwrite if a task already exists (idempotent)
|
|
147
|
+
*
|
|
148
|
+
* Two separate tasks are needed: ONLOGON does NOT support /RI on its
|
|
149
|
+
* own, so we create:
|
|
150
|
+
* - "SealCodeWatcherSupervisor-Logon" (one-shot at logon)
|
|
151
|
+
* - "SealCodeWatcherSupervisor-Minute" (every minute)
|
|
152
|
+
*
|
|
153
|
+
* The supervise command itself is idempotent so dual-invocation is safe.
|
|
154
|
+
*/
|
|
155
|
+
function writeWindowsTasks() {
|
|
156
|
+
const node = nodeBin();
|
|
157
|
+
const cli = cliEntry();
|
|
158
|
+
const logDir = path.join(os.homedir(), '.sealcode', 'logs');
|
|
159
|
+
mkdirP(logDir);
|
|
160
|
+
|
|
161
|
+
// Wrap in a tiny .cmd shim so schtasks doesn't have to deal with
|
|
162
|
+
// node + cli paths containing spaces (Windows path quoting via
|
|
163
|
+
// schtasks /TR is famously fragile). The shim writes output to a
|
|
164
|
+
// log file so we have evidence the supervisor ran.
|
|
165
|
+
const shimPath = path.join(os.homedir(), '.sealcode', 'supervise.cmd');
|
|
166
|
+
const shim = `@echo off\r\n`
|
|
167
|
+
+ `"${node}" "${cli}" supervise >> "${path.join(logDir, 'supervise.out.log')}" 2>> "${path.join(logDir, 'supervise.err.log')}"\r\n`;
|
|
168
|
+
mkdirP(path.dirname(shimPath));
|
|
169
|
+
fs.writeFileSync(shimPath, shim, { mode: 0o644 });
|
|
170
|
+
|
|
171
|
+
// Task 1 — run once at every user logon.
|
|
172
|
+
try {
|
|
173
|
+
execFileSync('schtasks', [
|
|
174
|
+
'/Create', '/F',
|
|
175
|
+
'/TN', `${WIN_TASK_NAME}-Logon`,
|
|
176
|
+
'/TR', shimPath,
|
|
177
|
+
'/SC', 'ONLOGON',
|
|
178
|
+
'/RL', 'LIMITED',
|
|
179
|
+
], { stdio: 'inherit' });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
ui.warn(`schtasks (logon) failed: ${err.message || err}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Task 2 — repeat every 1 minute forever.
|
|
185
|
+
// schtasks doesn't have an "every minute" trigger directly — we use
|
|
186
|
+
// /SC MINUTE /MO 1 which runs every minute.
|
|
187
|
+
try {
|
|
188
|
+
execFileSync('schtasks', [
|
|
189
|
+
'/Create', '/F',
|
|
190
|
+
'/TN', `${WIN_TASK_NAME}-Minute`,
|
|
191
|
+
'/TR', shimPath,
|
|
192
|
+
'/SC', 'MINUTE',
|
|
193
|
+
'/MO', '1',
|
|
194
|
+
'/RL', 'LIMITED',
|
|
195
|
+
], { stdio: 'inherit' });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
ui.warn(`schtasks (minute) failed: ${err.message || err}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function removeWindowsTasks() {
|
|
202
|
+
try { execFileSync('schtasks', ['/Delete', '/F', '/TN', `${WIN_TASK_NAME}-Logon`], { stdio: 'ignore' }); } catch (_) { /* ok */ }
|
|
203
|
+
try { execFileSync('schtasks', ['/Delete', '/F', '/TN', `${WIN_TASK_NAME}-Minute`], { stdio: 'ignore' }); } catch (_) { /* ok */ }
|
|
204
|
+
try { fs.unlinkSync(path.join(os.homedir(), '.sealcode', 'supervise.cmd')); } catch (_) { /* ok */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
132
207
|
function runInstallService() {
|
|
133
208
|
if (process.platform === 'darwin') {
|
|
134
209
|
writePlist();
|
|
@@ -141,11 +216,14 @@ function runInstallService() {
|
|
|
141
216
|
ui.ok(`Installed systemd user units: sealcode-watcher.{service,timer}`);
|
|
142
217
|
return;
|
|
143
218
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
);
|
|
219
|
+
if (process.platform === 'win32') {
|
|
220
|
+
writeWindowsTasks();
|
|
221
|
+
ui.ok(`Installed Windows scheduled tasks: ${WIN_TASK_NAME}-{Logon,Minute}`);
|
|
222
|
+
ui.hint(' They run as your user, with no Administrator prompt, every minute.');
|
|
223
|
+
ui.hint(` Logs: ${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log')}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
ui.warn(`'sealcode install-service' is not supported on ${process.platform}.`);
|
|
149
227
|
process.exitCode = 1;
|
|
150
228
|
}
|
|
151
229
|
|
|
@@ -163,60 +241,95 @@ function runUninstallService() {
|
|
|
163
241
|
ui.ok('Removed systemd user units.');
|
|
164
242
|
return;
|
|
165
243
|
}
|
|
244
|
+
if (process.platform === 'win32') {
|
|
245
|
+
removeWindowsTasks();
|
|
246
|
+
ui.ok('Removed Windows scheduled tasks.');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
166
249
|
ui.hint('Nothing to uninstall on this platform.');
|
|
167
250
|
}
|
|
168
251
|
|
|
169
252
|
/**
|
|
170
|
-
* Called by the supervisor every minute (launchd / systemd timer
|
|
171
|
-
*
|
|
172
|
-
*
|
|
253
|
+
* Called by the supervisor every minute (launchd / systemd timer /
|
|
254
|
+
* Windows schtasks). Two responsibilities:
|
|
255
|
+
*
|
|
256
|
+
* 1. Stale-watch-state sweep — pre-1.3.6 behavior. Walk
|
|
257
|
+
* ~/.sealcode/sessions/*.watch.json, find any whose pid is dead,
|
|
258
|
+
* and remove the stale watch state file. This makes cli-guard
|
|
259
|
+
* lock the next time the user runs sealcode in that project.
|
|
260
|
+
*
|
|
261
|
+
* 2. Registry-based orphan lock (sealcode@1.3.6+). The session-file
|
|
262
|
+
* sweep above is good but only catches projects whose watch.json
|
|
263
|
+
* file is still present on disk. If the user killed the watcher
|
|
264
|
+
* AND wiped the session dir (the Saad scenario), there's nothing
|
|
265
|
+
* in ~/.sealcode/sessions/ to walk — but the project is still
|
|
266
|
+
* unlocked. The known-projects registry at ~/.sealcode/projects.json
|
|
267
|
+
* lets us catch this: anything marked `unlocked` with no live
|
|
268
|
+
* watcher gets a detached panic-lock dispatched.
|
|
269
|
+
*
|
|
270
|
+
* Cheap, idempotent, silent. Runs as the logged-in user.
|
|
173
271
|
*/
|
|
174
272
|
function runSupervise() {
|
|
273
|
+
// -----------------------------------------------------------------
|
|
274
|
+
// Step 1 — old session-file sweep
|
|
275
|
+
// -----------------------------------------------------------------
|
|
175
276
|
const dir = path.join(os.homedir(), '.sealcode', 'sessions');
|
|
176
277
|
let files;
|
|
177
278
|
try {
|
|
178
279
|
files = fs.readdirSync(dir).filter((f) => f.endsWith('.watch.json'));
|
|
179
280
|
} catch (_) {
|
|
180
|
-
|
|
281
|
+
files = [];
|
|
181
282
|
}
|
|
182
|
-
|
|
183
|
-
let respawned = 0;
|
|
283
|
+
let cleared = 0;
|
|
184
284
|
for (const f of files) {
|
|
185
285
|
let state;
|
|
186
286
|
try {
|
|
187
287
|
state = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
188
|
-
} catch (_) {
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
if (!state || !state.code || !state.pid) continue;
|
|
288
|
+
} catch (_) { continue; }
|
|
289
|
+
if (!state || !state.pid) continue;
|
|
192
290
|
let alive = false;
|
|
193
291
|
try { process.kill(state.pid, 0); alive = true; } catch (_) { alive = false; }
|
|
194
292
|
if (alive) continue;
|
|
195
|
-
|
|
196
|
-
// but the file name encodes the sha256(projectAbs).slice(0,16) which
|
|
197
|
-
// we can't reverse. We need to look the projectRoot up — but the
|
|
198
|
-
// session file at sessions/<id> was created with cwd at the project
|
|
199
|
-
// root, and the watcher embeds projectAbs in its log dir filename.
|
|
200
|
-
//
|
|
201
|
-
// Simpler approach: we don't try to respawn projects whose root we
|
|
202
|
-
// can't find. Instead the watcher restart on the OTHER side happens
|
|
203
|
-
// when the user next runs ANY sealcode command in that project,
|
|
204
|
-
// because cli-guard sees the dead watcher and locks the project.
|
|
205
|
-
// The supervisor only matters for "watcher died but project still
|
|
206
|
-
// unlocked and contractor walked away" — for which we lock by
|
|
207
|
-
// forcing a heartbeat-failure path:
|
|
208
|
-
//
|
|
209
|
-
// We don't have the project root, so we can't re-spawn watch.
|
|
210
|
-
// But we CAN remove the stale watch.json so cli-guard fires next
|
|
211
|
-
// time the user runs anything. That's enough.
|
|
212
|
-
try { fs.unlinkSync(path.join(dir, f)); respawned += 1; } catch (_) { /* ignore */ }
|
|
293
|
+
try { fs.unlinkSync(path.join(dir, f)); cleared += 1; } catch (_) { /* ignore */ }
|
|
213
294
|
}
|
|
214
|
-
|
|
215
|
-
|
|
295
|
+
|
|
296
|
+
// -----------------------------------------------------------------
|
|
297
|
+
// Step 2 — registry-based orphan lock (1.3.6+)
|
|
298
|
+
// -----------------------------------------------------------------
|
|
299
|
+
let orphansHandled = 0;
|
|
300
|
+
try {
|
|
301
|
+
const registry = require('./cli-registry');
|
|
302
|
+
const orphans = registry.findOrphanedUnlocks({ maxStaleMin: 5 });
|
|
303
|
+
if (orphans.length > 0) {
|
|
304
|
+
const { spawn } = require('child_process');
|
|
305
|
+
const node = process.execPath;
|
|
306
|
+
const cli = cliEntry();
|
|
307
|
+
for (const o of orphans) {
|
|
308
|
+
try {
|
|
309
|
+
const child = spawn(
|
|
310
|
+
node,
|
|
311
|
+
[cli, 'panic', '--project', o.projectRoot, '--from-selfcheck'],
|
|
312
|
+
{
|
|
313
|
+
detached: true,
|
|
314
|
+
stdio: 'ignore',
|
|
315
|
+
windowsHide: true,
|
|
316
|
+
cwd: o.projectRoot,
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
child.unref();
|
|
320
|
+
orphansHandled += 1;
|
|
321
|
+
} catch (_) { /* best-effort */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch (_) { /* registry module may not load on partial installs */ }
|
|
325
|
+
|
|
326
|
+
if (cleared > 0 || orphansHandled > 0) {
|
|
216
327
|
try {
|
|
328
|
+
const logDir = path.join(os.homedir(), '.sealcode', 'logs');
|
|
329
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
217
330
|
fs.appendFileSync(
|
|
218
|
-
path.join(
|
|
219
|
-
`${new Date().toISOString()} cleared
|
|
331
|
+
path.join(logDir, 'supervise.out.log'),
|
|
332
|
+
`${new Date().toISOString()} cleared=${cleared} orphan_locks=${orphansHandled}\n`,
|
|
220
333
|
);
|
|
221
334
|
} catch (_) { /* ignore */ }
|
|
222
335
|
}
|
package/src/cli-watch.js
CHANGED
|
@@ -612,20 +612,72 @@ async function runWatch({
|
|
|
612
612
|
});
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
// Signal handling.
|
|
616
|
-
//
|
|
617
|
-
//
|
|
615
|
+
// Signal handling. We hit this path for clean exits (Ctrl-C, terminal
|
|
616
|
+
// close, OS shutdown, `pm2 stop`). The signals we DON'T hit on are
|
|
617
|
+
// SIGKILL / kill -9 / Stop-Process -Force — by design that's the
|
|
618
|
+
// signal the server-side dead-watcher sweeper is built to detect.
|
|
619
|
+
//
|
|
620
|
+
// Behavior:
|
|
621
|
+
// - strict + lenient both re-lock on clean exit (sealcode@1.3.6 —
|
|
622
|
+
// previously only strict locked; lenient left plaintext for
|
|
623
|
+
// "convenience" but that was a foot-gun. A clean shutdown is a
|
|
624
|
+
// signal that the recipient is walking away, and the right
|
|
625
|
+
// default is to leave their disk safe.)
|
|
626
|
+
// - both POST /api/v1/access/exit so the server records a
|
|
627
|
+
// `gracefullyExitedAt` timestamp and the sweeper skips them. The
|
|
628
|
+
// POST is best-effort; if the network is down (laptop closing
|
|
629
|
+
// mid-VPN drop), we still tear down locally and the sweeper will
|
|
630
|
+
// eventually email — fail-safe in the alerting direction.
|
|
618
631
|
let stopped = false;
|
|
619
632
|
const cleanup = async (reason) => {
|
|
620
633
|
if (stopped) return;
|
|
621
634
|
stopped = true;
|
|
622
635
|
if (exfilCtrl) exfilCtrl.stop();
|
|
623
636
|
deleteWatchState(projectRoot);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
637
|
+
|
|
638
|
+
appendLog(projectRoot, { type: 'graceful_exit_lock', reason, strict });
|
|
639
|
+
|
|
640
|
+
// Best-effort: re-lock the recipient's plaintext before we die.
|
|
641
|
+
// Errors are swallowed — we'd rather exit and let the sweeper alert
|
|
642
|
+
// than block the OS shutdown waiting on a runLock that hung on a
|
|
643
|
+
// disk write.
|
|
644
|
+
let relocked = false;
|
|
645
|
+
try {
|
|
646
|
+
const K = loadSession(projectRoot);
|
|
647
|
+
if (K) {
|
|
648
|
+
const sm = loadSessionMeta(projectRoot);
|
|
649
|
+
const preserveUnseen = !!sm && sm.meta && sm.meta.source === 'grant';
|
|
650
|
+
await runLock({ projectRoot, config, K, preserveUnseen }).catch(() => {});
|
|
651
|
+
try { K.fill(0); } catch (_) { /* ignore */ }
|
|
652
|
+
relocked = true;
|
|
653
|
+
}
|
|
654
|
+
} catch (_) { /* best-effort */ }
|
|
655
|
+
try { clearSession(projectRoot); } catch (_) { /* ignore */ }
|
|
656
|
+
try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
|
|
657
|
+
|
|
658
|
+
// Best-effort: tell the server we exited cleanly so the sweeper
|
|
659
|
+
// doesn't dead-watcher-alert. Hard-bounded timeout: if the server
|
|
660
|
+
// is slow/unreachable we'd rather die quickly than block shutdown.
|
|
661
|
+
try {
|
|
662
|
+
const timeoutMs = 1500;
|
|
663
|
+
const exitPromise = request('POST', `/api/v1/access/exit`, {
|
|
664
|
+
body: {
|
|
665
|
+
code: trimmedCode,
|
|
666
|
+
reason: reason === 'sigint' || reason === 'sigterm' || reason === 'sighup' ? reason : 'manual',
|
|
667
|
+
watcherPid: process.pid,
|
|
668
|
+
watcherVersion: pkg.version,
|
|
669
|
+
relockedFiles: relocked,
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
await Promise.race([
|
|
673
|
+
exitPromise,
|
|
674
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
675
|
+
]).catch(() => {});
|
|
676
|
+
} catch (_) { /* best-effort */ }
|
|
677
|
+
|
|
678
|
+
if (!json && !daemon) {
|
|
679
|
+
if (relocked) ui.say('\n' + ui.c.dim('stopped watching — files re-locked'));
|
|
680
|
+
else ui.say('\n' + ui.c.dim('stopped watching'));
|
|
629
681
|
}
|
|
630
682
|
process.exit(0);
|
|
631
683
|
};
|
|
@@ -959,17 +1011,18 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
|
|
|
959
1011
|
process.exit(2);
|
|
960
1012
|
}
|
|
961
1013
|
|
|
962
|
-
// sealcode@1.
|
|
963
|
-
//
|
|
964
|
-
//
|
|
1014
|
+
// sealcode@1.3.6 — ALWAYS preserve unseen blobs for grant-derived
|
|
1015
|
+
// sessions, not just path-scoped ones. The recipient-side re-lock
|
|
1016
|
+
// (revoke / pause / device-mismatch / expiry) is supposed to wipe
|
|
1017
|
+
// THEIR plaintext copy; it must not wipe the encrypted blobs that
|
|
1018
|
+
// were sealed by the OWNER. Without preserveUnseen, any race that
|
|
1019
|
+
// makes collectFiles return fewer paths than the manifest expected
|
|
1020
|
+
// permanently destroys those blobs (the "revoked → 1 file restored"
|
|
1021
|
+
// bug). Owner-side passphrase re-locks still use preserveUnseen=false
|
|
1022
|
+
// since the owner has all plaintext on hand by definition.
|
|
965
1023
|
const _sessionMeta = loadSessionMeta(projectRoot);
|
|
966
1024
|
const _preserveUnseen =
|
|
967
|
-
!!_sessionMeta &&
|
|
968
|
-
_sessionMeta.meta &&
|
|
969
|
-
_sessionMeta.meta.source === 'grant' &&
|
|
970
|
-
_sessionMeta.meta.policy &&
|
|
971
|
-
Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
|
|
972
|
-
_sessionMeta.meta.policy.allowedPaths.length > 0;
|
|
1025
|
+
!!_sessionMeta && _sessionMeta.meta && _sessionMeta.meta.source === 'grant';
|
|
973
1026
|
|
|
974
1027
|
let res;
|
|
975
1028
|
try {
|
|
@@ -981,6 +1034,7 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
|
|
|
981
1034
|
}
|
|
982
1035
|
clearSession(projectRoot);
|
|
983
1036
|
deleteWatchState(projectRoot);
|
|
1037
|
+
try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
|
|
984
1038
|
if (daemon) {
|
|
985
1039
|
appendLog(projectRoot, { type: 'locked', count: res.count, reason: label });
|
|
986
1040
|
} else if (json) {
|
package/src/cli.js
CHANGED
|
@@ -269,6 +269,7 @@ function build() {
|
|
|
269
269
|
sp.succeed(`locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')}`);
|
|
270
270
|
const stubs = Object.keys(res.stubs);
|
|
271
271
|
if (stubs.length) ui.hint(` stubs placed: ${stubs.join(', ')}`);
|
|
272
|
+
try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
|
|
272
273
|
} catch (err) {
|
|
273
274
|
process.exitCode = reportError(err);
|
|
274
275
|
}
|
|
@@ -380,6 +381,7 @@ function build() {
|
|
|
380
381
|
});
|
|
381
382
|
const _skipped = res.skipped > 0 ? ` ${ui.c.dim(`(${res.skipped} outside grant scope)`)}` : '';
|
|
382
383
|
sp.succeed(`unlocked ${ui.c.bold(res.count)} files${_skipped} ${ui.c.dim(`(locked at ${res.sealedAt})`)}`);
|
|
384
|
+
try { require('./cli-registry').markUnlocked(projectRoot); } catch (_) { /* ignore */ }
|
|
383
385
|
if (res.policy && res.policy.mode === 'ro') {
|
|
384
386
|
ui.hint(' Read-only grant: files set to 0444. Any edit triggers an immediate re-lock.');
|
|
385
387
|
}
|
|
@@ -519,19 +521,112 @@ function build() {
|
|
|
519
521
|
program
|
|
520
522
|
.command('panic')
|
|
521
523
|
.description('Re-lock immediately and wipe plaintext (for "shut my laptop NOW" moments).')
|
|
522
|
-
.
|
|
524
|
+
.option('--from-selfcheck', '[internal] called by the self-check background job — never prompts, silent on no-op')
|
|
525
|
+
.action(async (opts) => {
|
|
523
526
|
try {
|
|
524
527
|
const projectRoot = resolveProject(program.opts());
|
|
525
528
|
const config = getActiveConfig(projectRoot);
|
|
529
|
+
|
|
530
|
+
// sealcode@1.3.6 — when invoked by the registry self-check we
|
|
531
|
+
// MUST NOT prompt for a passphrase (would hang the detached
|
|
532
|
+
// background process forever). If there's no cached session,
|
|
533
|
+
// we silently correct the registry and exit — the user can
|
|
534
|
+
// run `sealcode lock` manually if they want a real lock.
|
|
535
|
+
if (opts.fromSelfcheck) {
|
|
536
|
+
const cached = loadSession(projectRoot);
|
|
537
|
+
if (!cached) {
|
|
538
|
+
try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const sm = require('./keystore').loadSessionMeta(projectRoot);
|
|
543
|
+
const preserveUnseen = !!sm && sm.meta && sm.meta.source === 'grant';
|
|
544
|
+
await runLock({ projectRoot, config, K: cached, preserveUnseen });
|
|
545
|
+
} catch (_) { /* swallow — background job */ }
|
|
546
|
+
try { cached.fill(0); } catch (_) { /* ignore */ }
|
|
547
|
+
try { clearSession(projectRoot); } catch (_) { /* ignore */ }
|
|
548
|
+
try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
526
552
|
const K = await resolveKey(projectRoot, config);
|
|
527
553
|
const res = await runLock({ projectRoot, config, K });
|
|
528
554
|
clearSession(projectRoot);
|
|
555
|
+
try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
|
|
529
556
|
process.stdout.write(`✓ panic-locked ${res.count} files. Session cleared.\n`);
|
|
530
557
|
} catch (err) {
|
|
531
558
|
process.exitCode = reportError(err);
|
|
532
559
|
}
|
|
533
560
|
});
|
|
534
561
|
|
|
562
|
+
// -------- preuninstall-check (internal, called by npm preuninstall) --------
|
|
563
|
+
//
|
|
564
|
+
// sealcode@1.3.6 — when the user runs `npm uninstall -g sealcode`, npm
|
|
565
|
+
// executes this command FIRST (before deleting files). We use the small
|
|
566
|
+
// window to:
|
|
567
|
+
// 1. Walk the known-projects registry and re-lock any unlocked ones.
|
|
568
|
+
// 2. Print a clear warning so the user understands the implication.
|
|
569
|
+
//
|
|
570
|
+
// Bypassable with `npm uninstall --ignore-scripts`. We accept that —
|
|
571
|
+
// the goal is to raise the floor against accidental "I'll just remove
|
|
572
|
+
// sealcode" actions, not to defeat a determined attacker who reads
|
|
573
|
+
// our docs.
|
|
574
|
+
//
|
|
575
|
+
// ALWAYS exit 0 (via `|| true` in package.json) so npm uninstall
|
|
576
|
+
// succeeds even if our hook fails. We do NOT want to leave the user
|
|
577
|
+
// stuck with a partial uninstall.
|
|
578
|
+
program
|
|
579
|
+
.command('preuninstall-check', { hidden: true })
|
|
580
|
+
.description('[internal] npm preuninstall hook — re-locks any unlocked projects before uninstall')
|
|
581
|
+
.action(async () => {
|
|
582
|
+
try {
|
|
583
|
+
const registry = require('./cli-registry');
|
|
584
|
+
const orphans = registry.findOrphanedUnlocks({ maxStaleMin: 0 });
|
|
585
|
+
const allUnlocked = registry.list().filter((p) => p.lastState === 'unlocked');
|
|
586
|
+
|
|
587
|
+
const total = allUnlocked.length;
|
|
588
|
+
if (total === 0) {
|
|
589
|
+
// Nothing to do. Silent exit so npm uninstall stays quiet.
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
process.stderr.write(
|
|
594
|
+
`\n \x1b[33m\u26a0 sealcode preuninstall: ${total} project(s) are still unlocked.\x1b[0m\n`
|
|
595
|
+
+ `\n Re-locking before uninstall so you don't leave plaintext lying around:\n`,
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
// Re-lock each one. We use the same panic-from-selfcheck flow:
|
|
599
|
+
// it uses ONLY the cached session (no passphrase prompt — npm
|
|
600
|
+
// hooks have no TTY anyway) and is a no-op if the session was
|
|
601
|
+
// already cleared. We run them SYNCHRONOUSLY here (not detached)
|
|
602
|
+
// because npm will delete our files the moment we return.
|
|
603
|
+
const { spawnSync } = require('child_process');
|
|
604
|
+
for (const p of allUnlocked) {
|
|
605
|
+
process.stderr.write(` - ${p.projectRoot} ... `);
|
|
606
|
+
const r = spawnSync(
|
|
607
|
+
process.execPath,
|
|
608
|
+
[path.resolve(__dirname, '..', 'bin', 'sealcode.js'),
|
|
609
|
+
'panic', '--project', p.projectRoot, '--from-selfcheck'],
|
|
610
|
+
{ stdio: 'ignore', timeout: 30_000 },
|
|
611
|
+
);
|
|
612
|
+
process.stderr.write((r.status === 0 ? '\x1b[32mlocked\x1b[0m' : '\x1b[31mfailed\x1b[0m') + '\n');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (orphans.length > 0) {
|
|
616
|
+
process.stderr.write(
|
|
617
|
+
`\n \x1b[33mNote:\x1b[0m ${orphans.length} of these had no running watcher already. `
|
|
618
|
+
+ `Any active access grants on those projects can no longer be revoked from the dashboard.\n`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
process.stderr.write(`\n`);
|
|
623
|
+
} catch (_) {
|
|
624
|
+
// Never fail the npm uninstall. Worst case: user uninstalls
|
|
625
|
+
// sealcode and we couldn't lock — the registry self-check on
|
|
626
|
+
// some future reinstall would still flag it.
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
535
630
|
// -------- install-hook --------
|
|
536
631
|
program
|
|
537
632
|
.command('install-hook')
|
|
@@ -1061,6 +1156,34 @@ async function run(argv) {
|
|
|
1061
1156
|
if (bare || helpish) {
|
|
1062
1157
|
ui.maybeShowWelcome(pkg.version);
|
|
1063
1158
|
}
|
|
1159
|
+
|
|
1160
|
+
// sealcode@1.3.6 — registry self-check on EVERY invocation. Scans
|
|
1161
|
+
// ~/.sealcode/projects.json for projects marked unlocked whose
|
|
1162
|
+
// watcher is no longer alive, and spawns a detached panic-lock for
|
|
1163
|
+
// each. Closes the "recipient killed the watcher and walked away"
|
|
1164
|
+
// hole for anyone who uses sealcode at all on the same machine
|
|
1165
|
+
// afterwards.
|
|
1166
|
+
//
|
|
1167
|
+
// Quiet for short/info commands so we don't spam warnings during
|
|
1168
|
+
// `sealcode --version` etc. NEVER throws — wrapped in try.
|
|
1169
|
+
//
|
|
1170
|
+
// We skip the self-check when the invocation IS the self-check (the
|
|
1171
|
+
// detached `panic --from-selfcheck` child) to prevent infinite spawn
|
|
1172
|
+
// recursion.
|
|
1173
|
+
try {
|
|
1174
|
+
const first = userArgs[0];
|
|
1175
|
+
const isPanicFromSelfcheck =
|
|
1176
|
+
first === 'panic' && userArgs.includes('--from-selfcheck');
|
|
1177
|
+
const isSupervise = first === 'supervise';
|
|
1178
|
+
const quiet =
|
|
1179
|
+
bare || helpish || isSupervise || isPanicFromSelfcheck ||
|
|
1180
|
+
first === 'version' || first === '-V' || first === '--version' ||
|
|
1181
|
+
first === 'where' || first === 'whoami';
|
|
1182
|
+
if (!isPanicFromSelfcheck && !isSupervise) {
|
|
1183
|
+
require('./cli-registry').selfCheck({ quiet });
|
|
1184
|
+
}
|
|
1185
|
+
} catch (_) { /* never block the user's command */ }
|
|
1186
|
+
|
|
1064
1187
|
await program.parseAsync(argv);
|
|
1065
1188
|
}
|
|
1066
1189
|
|
package/src/seal.js
CHANGED
|
@@ -97,16 +97,33 @@ async function runLock({
|
|
|
97
97
|
const lockedDir = config.lockedDir;
|
|
98
98
|
const lockedRoot = path.join(projectRoot, lockedDir);
|
|
99
99
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
100
|
+
// sealcode@1.3.6 — ALWAYS read the previous manifest, not just when
|
|
101
|
+
// preserveUnseen was requested. We use it for two purposes:
|
|
102
|
+
//
|
|
103
|
+
// 1. The original preserveUnseen feature (carry path-scoped blobs
|
|
104
|
+
// forward) — unchanged.
|
|
105
|
+
// 2. A data-loss SAFETY GUARD against the "revoke race" bug.
|
|
106
|
+
//
|
|
107
|
+
// The bug: recipient has 553 plaintext files. Owner revokes. Watcher
|
|
108
|
+
// wakes, calls runLock, but collectFiles momentarily returns just a
|
|
109
|
+
// handful of files (filesystem race on Windows, antivirus scan in
|
|
110
|
+
// progress, watcher woke mid-softLock-followed-by-finalLock, etc.).
|
|
111
|
+
// Without a guard, rmIfExists(lockedRoot) would wipe the 553 existing
|
|
112
|
+
// blobs and we'd write 3 new ones — permanent data loss for the owner
|
|
113
|
+
// who shared the project.
|
|
114
|
+
//
|
|
115
|
+
// The guard: if we had a real manifest with N entries and we're about
|
|
116
|
+
// to write < ceil(N/2) plaintext blobs (and preserveUnseen wasn't
|
|
117
|
+
// already requested), we auto-promote to preserveUnseen=true and log
|
|
118
|
+
// a warning. This means in the worst case we keep slightly-stale
|
|
119
|
+
// ciphertext (recoverable) instead of nuking the locked repo
|
|
120
|
+
// (unrecoverable for whoever doesn't have a separate copy).
|
|
102
121
|
let prevManifest = null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
prevManifest = null;
|
|
109
|
-
}
|
|
122
|
+
try {
|
|
123
|
+
prevManifest = readManifest(projectRoot, lockedDir, K);
|
|
124
|
+
} catch (_) {
|
|
125
|
+
// No manifest yet (first init) or unreadable — fall through.
|
|
126
|
+
prevManifest = null;
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
const files = await collectFiles(projectRoot, config);
|
|
@@ -114,6 +131,26 @@ async function runLock({
|
|
|
114
131
|
throw new Error('SEALCODE_NOTHING_TO_LOCK');
|
|
115
132
|
}
|
|
116
133
|
|
|
134
|
+
// Auto-promote preserveUnseen on suspicious shrink. Threshold: only
|
|
135
|
+
// engage when the previous manifest had at least 5 entries (avoids
|
|
136
|
+
// false positives on tiny projects) AND the new plaintext set is
|
|
137
|
+
// less than half the previous. We deliberately don't refuse the
|
|
138
|
+
// operation — that would leave the watcher in a bad state. We just
|
|
139
|
+
// make it non-destructive.
|
|
140
|
+
if (
|
|
141
|
+
!preserveUnseen
|
|
142
|
+
&& prevManifest
|
|
143
|
+
&& Array.isArray(prevManifest.files)
|
|
144
|
+
&& prevManifest.files.length >= 5
|
|
145
|
+
&& files.length < Math.ceil(prevManifest.files.length / 2)
|
|
146
|
+
) {
|
|
147
|
+
log(
|
|
148
|
+
` [safety] previous manifest had ${prevManifest.files.length} files but only ${files.length} `
|
|
149
|
+
+ `plaintext on disk — auto-preserving unseen blobs to avoid data loss.`
|
|
150
|
+
);
|
|
151
|
+
preserveUnseen = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
117
154
|
// Preserve any existing keystore files when re-locking. On first init we
|
|
118
155
|
// wipe and persist the bootstrap output fresh.
|
|
119
156
|
const existingSalt = !bootstrapOutput && fs.existsSync(path.join(lockedRoot, SALT_NAME));
|