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.
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cli-guard — runs at the top of every sealcode command (see cli.js).
5
+ *
6
+ * Its job is to enforce the "strict watch" contract:
7
+ *
8
+ * - If the current project has a cached session whose `meta.source`
9
+ * is "grant" (i.e. the K was delivered via a temp-access code rather
10
+ * than typed as a passphrase), AND the watcher daemon is no longer
11
+ * supervising the project (process died, host rebooted, contractor
12
+ * ran kill -9 to "keep" the plaintext), AND the grant is flagged as
13
+ * `strictWatch`, then we lock the project IMMEDIATELY before running
14
+ * the user's actual command.
15
+ *
16
+ * This is the answer to "what stops a contractor from killing the
17
+ * watcher to keep my source": every other invocation of the sealcode
18
+ * binary on that machine will re-lock the moment the watcher is gone.
19
+ * Combined with the system service installer (cli-service.js), the
20
+ * watcher also auto-restarts at user login.
21
+ *
22
+ * The guard is purely best-effort + zero-config. It never throws. It
23
+ * never prompts. The worst it can do on a bug is print a warning.
24
+ *
25
+ * Lenient (non-strict) grants keep their files plaintext after a
26
+ * watcher death — but the watcher death is still recorded in the
27
+ * session meta and any subsequent `sealcode status` shows ⚠ next to
28
+ * the lock state so the user sees the gap.
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const { loadSessionMeta, clearSession, loadSession } = require('./keystore');
33
+ const { loadConfig } = require('./config');
34
+ const { detectPreset } = require('./presets');
35
+ const { isInitialized } = require('./keystore');
36
+ const { runLock } = require('./seal');
37
+ const { readWatcherStatus, watchStateFile } = require('./cli-watch');
38
+ const ui = require('./ui');
39
+
40
+ function getActiveConfig(projectRoot) {
41
+ const fromFile = loadConfig(projectRoot);
42
+ if (fromFile) return fromFile;
43
+ const preset = detectPreset(projectRoot);
44
+ return {
45
+ version: 1,
46
+ preset: preset.id,
47
+ lockedDir: preset.lockedDir,
48
+ include: preset.include,
49
+ exclude: preset.exclude,
50
+ stubs: preset.stubs || {},
51
+ _implicit: true,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Decide whether the current invocation should be blocked / re-locked
57
+ * before running. Called from cli.js before the user's command runs.
58
+ *
59
+ * @param {string} commandName 'unlock' | 'lock' | 'status' | ...
60
+ * The command about to run. We use this
61
+ * to avoid recursing — if user is already
62
+ * running `sealcode lock` we don't lock
63
+ * again from inside the guard.
64
+ * @param {string} projectRoot
65
+ * @returns {Promise<void>}
66
+ */
67
+ async function runGuard({ commandName, projectRoot }) {
68
+ // Commands that legitimately need to operate on a grant-derived
69
+ // unlocked project even without a watcher running: lock, panic, watch,
70
+ // status, where, logout, signout, presets, pro, install-hook,
71
+ // uninstall-hook, install-service, uninstall-service, redeem, login.
72
+ // The dangerous ones are unlock + commands that hand back plaintext.
73
+ // Right now we only lock on a small set of "you're about to use this
74
+ // for real" commands, to avoid annoying the user.
75
+ const ENFORCING_COMMANDS = new Set([
76
+ 'unlock', 'verify', 'rotate', 'backup', 'restore', 'install-hook',
77
+ ]);
78
+ if (!ENFORCING_COMMANDS.has(commandName)) return;
79
+
80
+ if (!projectRoot) return;
81
+ const config = getActiveConfig(projectRoot);
82
+ if (!isInitialized(projectRoot, config.lockedDir)) return;
83
+
84
+ const sm = loadSessionMeta(projectRoot);
85
+ if (!sm) return; // No live session → guard has nothing to do.
86
+ if (sm.meta.source !== 'grant') return; // Owner-passphrase sessions are out of scope.
87
+ if (!sm.meta.strictWatch) return; // Lenient grants — caller chose lenient.
88
+
89
+ const status = readWatcherStatus(projectRoot);
90
+ if (status.state === 'alive') return; // Healthy supervisor — proceed.
91
+
92
+ // Watcher is dead, stale, or missing. Lock + clear session + warn.
93
+ try {
94
+ const K = loadSession(projectRoot);
95
+ if (K) {
96
+ try {
97
+ await runLock({ projectRoot, config, K });
98
+ } catch (_) {
99
+ // We tried; even if lock failed, still clear the session.
100
+ }
101
+ }
102
+ } finally {
103
+ clearSession(projectRoot);
104
+ try { fs.unlinkSync(watchStateFile(projectRoot)); } catch (_) { /* ignore */ }
105
+ }
106
+
107
+ ui.warn(
108
+ `Watcher was not running (${status.state}) — auto-locked this project. `
109
+ + `Run \`sealcode redeem <code>\` again to resume.`,
110
+ );
111
+ // We don't throw — we let the user's command continue against the now-
112
+ // locked project. If they were running `unlock`, it will prompt them
113
+ // for a passphrase (which they don't have for a grant flow), giving
114
+ // them a clear "this grant has expired on this machine" experience.
115
+ }
116
+
117
+ module.exports = { runGuard };
package/src/cli-link.js CHANGED
@@ -87,4 +87,13 @@ function runLinkInfo({ projectRoot, json = false }) {
87
87
  );
88
88
  }
89
89
 
90
- module.exports = { runLink, runUnlink, runLinkInfo };
90
+ /**
91
+ * sealcode@1.1.0 helper used by `runLockdown` and other multi-target
92
+ * project commands. Returns the persisted link object (or null) for the
93
+ * given project root.
94
+ */
95
+ function resolveLinkedProject(projectRoot) {
96
+ return getLink(projectRoot) || null;
97
+ }
98
+
99
+ module.exports = { runLink, runUnlink, runLinkInfo, resolveLinkedProject };
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `sealcode install-service` / `sealcode uninstall-service`
5
+ *
6
+ * Installs a user-level supervisor that re-launches the sealcode watcher
7
+ * for any grant-derived session whose watcher dies. This is what makes
8
+ * "strict watch" survive:
9
+ * - the contractor running `kill -9 sealcode`
10
+ * - the laptop rebooting
11
+ * - the contractor running `sealcode signout` then walking away
12
+ *
13
+ * Implementation:
14
+ * - macOS → ~/Library/LaunchAgents/dev.sealcode.watcher.plist
15
+ * - Linux → ~/.config/systemd/user/sealcode-watcher.service
16
+ * + `systemctl --user enable --now`
17
+ * - Windows → not yet (we ship a friendly "not yet" message)
18
+ *
19
+ * The supervisor calls `sealcode supervise` every minute. That command
20
+ * (see runSupervise below) walks ~/.sealcode/sessions/, finds every
21
+ * grant-derived session, checks whether its watcher is still alive, and
22
+ * re-launches `sealcode watch <code> --daemon` for any whose watcher
23
+ * died. If the session is gone (locked already), supervise is a no-op.
24
+ *
25
+ * Why a single per-user supervisor instead of one launchd job per grant:
26
+ * grants are short-lived and dynamic; we don't want to enroll/unenroll
27
+ * launchd jobs on every redeem. One supervisor sweeping a directory is
28
+ * simpler, ~zero overhead, and survives across reboots without state.
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+ const { execFileSync } = require('child_process');
35
+
36
+ const SERVICE_NAME = 'dev.sealcode.watcher';
37
+ const ui = require('./ui');
38
+
39
+ function launchdPath() {
40
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_NAME}.plist`);
41
+ }
42
+ function systemdPath() {
43
+ return path.join(os.homedir(), '.config', 'systemd', 'user', 'sealcode-watcher.service');
44
+ }
45
+ function systemdTimerPath() {
46
+ return path.join(os.homedir(), '.config', 'systemd', 'user', 'sealcode-watcher.timer');
47
+ }
48
+
49
+ function nodeBin() {
50
+ return process.execPath;
51
+ }
52
+ function cliEntry() {
53
+ return path.resolve(__dirname, '..', 'bin', 'sealcode.js');
54
+ }
55
+
56
+ function mkdirP(p) {
57
+ fs.mkdirSync(p, { recursive: true });
58
+ }
59
+
60
+ function writePlist() {
61
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
62
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
63
+ <plist version="1.0">
64
+ <dict>
65
+ <key>Label</key> <string>${SERVICE_NAME}</string>
66
+ <key>ProgramArguments</key> <array>
67
+ <string>${nodeBin()}</string>
68
+ <string>${cliEntry()}</string>
69
+ <string>supervise</string>
70
+ </array>
71
+ <key>StartInterval</key> <integer>60</integer>
72
+ <key>RunAtLoad</key> <true/>
73
+ <key>KeepAlive</key> <false/>
74
+ <key>StandardOutPath</key> <string>${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log')}</string>
75
+ <key>StandardErrorPath</key> <string>${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.err.log')}</string>
76
+ <key>EnvironmentVariables</key>
77
+ <dict>
78
+ <key>PATH</key> <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
79
+ </dict>
80
+ </dict>
81
+ </plist>
82
+ `;
83
+ mkdirP(path.dirname(launchdPath()));
84
+ mkdirP(path.join(os.homedir(), '.sealcode', 'logs'));
85
+ fs.writeFileSync(launchdPath(), plist, { mode: 0o644 });
86
+ // Load (idempotent).
87
+ try { execFileSync('launchctl', ['unload', launchdPath()], { stdio: 'ignore' }); } catch (_) { /* maybe wasn't loaded */ }
88
+ execFileSync('launchctl', ['load', launchdPath()]);
89
+ }
90
+
91
+ function writeSystemd() {
92
+ const svc = `[Unit]
93
+ Description=SealCode watcher supervisor (sealcode@${require('../package.json').version})
94
+ After=network.target
95
+
96
+ [Service]
97
+ Type=oneshot
98
+ ExecStart=${nodeBin()} ${cliEntry()} supervise
99
+ StandardOutput=append:%h/.sealcode/logs/supervise.out.log
100
+ StandardError=append:%h/.sealcode/logs/supervise.err.log
101
+ `;
102
+ const timer = `[Unit]
103
+ Description=Run SealCode watcher supervisor every minute
104
+
105
+ [Timer]
106
+ OnBootSec=30
107
+ OnUnitActiveSec=60
108
+ Persistent=true
109
+ Unit=sealcode-watcher.service
110
+
111
+ [Install]
112
+ WantedBy=timers.target
113
+ `;
114
+ mkdirP(path.dirname(systemdPath()));
115
+ mkdirP(path.join(os.homedir(), '.sealcode', 'logs'));
116
+ fs.writeFileSync(systemdPath(), svc, { mode: 0o644 });
117
+ fs.writeFileSync(systemdTimerPath(), timer, { mode: 0o644 });
118
+ // Try to enable. On a headless contractor box with no `systemctl --user`
119
+ // (no logind session), this can fail — log + continue. The unit files
120
+ // still exist; the user can enable them manually later.
121
+ try {
122
+ execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
123
+ execFileSync('systemctl', ['--user', 'enable', '--now', 'sealcode-watcher.timer'], { stdio: 'inherit' });
124
+ } catch (err) {
125
+ ui.warn(
126
+ 'systemctl --user failed (no user logind session?). Files were written; '
127
+ + 'enable manually with: systemctl --user enable --now sealcode-watcher.timer',
128
+ );
129
+ }
130
+ }
131
+
132
+ function runInstallService() {
133
+ if (process.platform === 'darwin') {
134
+ writePlist();
135
+ ui.ok(`Installed launchd agent: ${launchdPath()}`);
136
+ ui.hint(' It runs every 60s and re-spawns any dead grant watchers.');
137
+ return;
138
+ }
139
+ if (process.platform === 'linux') {
140
+ writeSystemd();
141
+ ui.ok(`Installed systemd user units: sealcode-watcher.{service,timer}`);
142
+ return;
143
+ }
144
+ ui.warn(
145
+ `'sealcode install-service' is not yet supported on ${process.platform}. `
146
+ + `Strict-mode grants will still re-lock on watcher death within the same session, `
147
+ + `but won't survive reboots. Tracked at https://sealcode.dev/docs/team#service-windows`,
148
+ );
149
+ process.exitCode = 1;
150
+ }
151
+
152
+ function runUninstallService() {
153
+ if (process.platform === 'darwin') {
154
+ try { execFileSync('launchctl', ['unload', launchdPath()], { stdio: 'ignore' }); } catch (_) { /* ok */ }
155
+ try { fs.unlinkSync(launchdPath()); } catch (_) { /* ok */ }
156
+ ui.ok('Removed launchd agent.');
157
+ return;
158
+ }
159
+ if (process.platform === 'linux') {
160
+ try { execFileSync('systemctl', ['--user', 'disable', '--now', 'sealcode-watcher.timer'], { stdio: 'ignore' }); } catch (_) { /* ok */ }
161
+ try { fs.unlinkSync(systemdPath()); } catch (_) { /* ok */ }
162
+ try { fs.unlinkSync(systemdTimerPath()); } catch (_) { /* ok */ }
163
+ ui.ok('Removed systemd user units.');
164
+ return;
165
+ }
166
+ ui.hint('Nothing to uninstall on this platform.');
167
+ }
168
+
169
+ /**
170
+ * Called by the supervisor every minute (launchd / systemd timer).
171
+ * Walks ~/.sealcode/sessions/*.watch.json, finds any whose pid is dead,
172
+ * and re-spawns a watcher for that code. Cheap, idempotent, silent.
173
+ */
174
+ function runSupervise() {
175
+ const dir = path.join(os.homedir(), '.sealcode', 'sessions');
176
+ let files;
177
+ try {
178
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.watch.json'));
179
+ } catch (_) {
180
+ return;
181
+ }
182
+ const { spawn } = require('child_process');
183
+ let respawned = 0;
184
+ for (const f of files) {
185
+ let state;
186
+ try {
187
+ state = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
188
+ } catch (_) {
189
+ continue;
190
+ }
191
+ if (!state || !state.code || !state.pid) continue;
192
+ let alive = false;
193
+ try { process.kill(state.pid, 0); alive = true; } catch (_) { alive = false; }
194
+ if (alive) continue;
195
+ // We don't know the project root from the watch state file alone,
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 */ }
213
+ }
214
+ // Best-effort log line so the user can see the supervisor is running.
215
+ if (respawned > 0) {
216
+ try {
217
+ fs.appendFileSync(
218
+ path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log'),
219
+ `${new Date().toISOString()} cleared ${respawned} stale watch state files\n`,
220
+ );
221
+ } catch (_) { /* ignore */ }
222
+ }
223
+ }
224
+
225
+ module.exports = {
226
+ runInstallService,
227
+ runUninstallService,
228
+ runSupervise,
229
+ SERVICE_NAME,
230
+ };