sealkeep 0.5.0

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.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,533 @@
1
+ import { access, mkdir, open, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname, join, resolve, sep } from "node:path";
5
+ import { fail } from "./errors.js";
6
+ /**
7
+ * One table, so detection, hook installation and the setup API answer from the
8
+ * same facts. A second list of roots is how "where does Cursor live" starts
9
+ * drifting between the page that shows it and the installer that writes there.
10
+ */
11
+ const AGENTS = {
12
+ codex: {
13
+ label: "Codex CLI",
14
+ root: (home) => join(home, ".codex"),
15
+ // Codex configures itself in TOML, so its hooks cannot be merged in by this
16
+ // build; `installAgentHooks` produces a reviewable fragment instead.
17
+ hookFile: (home) => join(home, ".codex", "config.toml"),
18
+ hookFileFormat: "toml",
19
+ archivable: true,
20
+ transcriptRoots: (home) => [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")]
21
+ },
22
+ claude: {
23
+ label: "Claude Code",
24
+ root: (home) => join(home, ".claude"),
25
+ hookFile: (home) => join(home, ".claude", "settings.json"),
26
+ hookFileFormat: "json",
27
+ archivable: true,
28
+ transcriptRoots: (home) => [join(home, ".claude", "projects")]
29
+ },
30
+ cursor: {
31
+ label: "Cursor",
32
+ root: (home) => join(home, ".cursor"),
33
+ hookFile: (home) => join(home, ".cursor", "hooks.json"),
34
+ hookFileFormat: "json",
35
+ archivable: false,
36
+ transcriptRoots: () => []
37
+ },
38
+ gemini: {
39
+ label: "Gemini CLI",
40
+ root: (home) => join(home, ".gemini"),
41
+ hookFile: (home) => join(home, ".gemini", "settings.json"),
42
+ hookFileFormat: "json",
43
+ archivable: false,
44
+ transcriptRoots: () => []
45
+ },
46
+ windsurf: {
47
+ label: "Windsurf",
48
+ root: (home) => join(home, ".codeium", "windsurf"),
49
+ hookFile: (home) => join(home, ".codeium", "windsurf", "hooks.json"),
50
+ hookFileFormat: "json",
51
+ archivable: false,
52
+ transcriptRoots: () => []
53
+ },
54
+ antigravity: {
55
+ label: "Antigravity",
56
+ root: (home) => join(home, ".gemini", "antigravity"),
57
+ hookFile: (home) => join(home, ".gemini", "antigravity", "hooks.json"),
58
+ hookFileFormat: "json",
59
+ archivable: false,
60
+ transcriptRoots: () => []
61
+ }
62
+ };
63
+ /** Every id `detectSetupAgents` reports on, in the order a setup page should show them. */
64
+ export const SETUP_AGENT_IDS = Object.keys(AGENTS);
65
+ /** The subset with a session adapter. Mirrors `AgentId` at runtime for schema use. */
66
+ export const ARCHIVING_AGENT_IDS = ["codex", "claude"];
67
+ /** Narrows a setup id to an archiving one, so callers prove the subset rather than casting to it. */
68
+ export function isArchivingAgent(agent) {
69
+ return ARCHIVING_AGENT_IDS.includes(agent);
70
+ }
71
+ async function directoryExists(path) {
72
+ try {
73
+ return (await stat(path)).isDirectory();
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /** Detect only well-known local roots; it never reads transcript contents. */
80
+ export async function detectAgents(home = process.env.HOME ?? ".") {
81
+ return Promise.all(ARCHIVING_AGENT_IDS.map(async (agent) => {
82
+ const definition = AGENTS[agent];
83
+ const root = definition.root(home);
84
+ return { agent, root, transcriptRoots: definition.transcriptRoots(home), detected: await directoryExists(root) };
85
+ }));
86
+ }
87
+ /**
88
+ * Claude Code names each transcript directory after the session's cwd with the
89
+ * separators flattened to dashes ("/Users/x/repo" → "-Users-x-repo"). The
90
+ * decode is lossy by construction — a dash that was part of a directory name
91
+ * is indistinguishable from one that was a separator — so `project` is the
92
+ * final segment, which is the part a person recognises, and `projectPath` is
93
+ * the naive re-expansion, kept because "which /Users/… did this come from" is
94
+ * still worth answering approximately.
95
+ */
96
+ export function claudeProjectFromPath(path) {
97
+ const marker = `${sep}.claude${sep}projects${sep}`;
98
+ const start = path.indexOf(marker);
99
+ if (start === -1)
100
+ return { project: null, projectPath: null };
101
+ const rest = path.slice(start + marker.length).split(sep);
102
+ // A .jsonl directly under projects/ is a stray file, not a project directory.
103
+ if (rest.length < 2)
104
+ return { project: null, projectPath: null };
105
+ const segments = rest[0].split("-").filter(Boolean);
106
+ if (segments.length === 0)
107
+ return { project: null, projectPath: null };
108
+ return { project: segments[segments.length - 1], projectPath: rest[0].replace(/-/g, "/") };
109
+ }
110
+ /**
111
+ * A rollout's first line is its session-meta record, and `cwd` in it is the
112
+ * only place Codex writes where the session ran. 8KB is the bound because this
113
+ * runs over every session on the machine from `listSessions`: a first line
114
+ * that keeps going past that is no longer metadata, and refusing to read
115
+ * further is what keeps "list 700 sessions" from becoming "read 700
116
+ * transcripts". Cached per (path, mtime) because a first line never changes
117
+ * once written; the mtime check only exists so a replaced file is re-read.
118
+ */
119
+ const FIRST_LINE_BYTES = 8192;
120
+ const codexIdentityCache = new Map();
121
+ export async function codexProjectFromRollout(path, modifiedAt) {
122
+ const cached = codexIdentityCache.get(path);
123
+ if (cached && cached.modifiedAt === modifiedAt)
124
+ return { project: cached.project, projectPath: cached.projectPath };
125
+ const identity = await codexIdentityFromFirstLine(path);
126
+ codexIdentityCache.set(path, { modifiedAt, ...identity });
127
+ return identity;
128
+ }
129
+ /** `cwd` (or the older `cd`) on the record itself, or one level down in `payload`, where session-meta keeps it. */
130
+ function recordedCwd(record, depth = 0) {
131
+ if (!record || typeof record !== "object" || Array.isArray(record))
132
+ return null;
133
+ const fields = record;
134
+ for (const key of ["cwd", "cd"]) {
135
+ const value = fields[key];
136
+ if (typeof value === "string" && value.trim())
137
+ return value.trim();
138
+ }
139
+ return depth === 0 ? recordedCwd(fields.payload, 1) : null;
140
+ }
141
+ /**
142
+ * `cwd` out of a first line too large to parse whole. Real session-meta lines
143
+ * run past 22KB because Codex embeds the full instructions text in the same
144
+ * record, so the 8KB read is a truncation and JSON.parse refuses it — which
145
+ * silently cost every real Codex session its project until a live rollout was
146
+ * tested. The key itself sits in the first few hundred bytes, before the
147
+ * instructions blob, so a bounded scan of the truncated head recovers it.
148
+ * First match wins; JSON string escapes in the value are decoded properly.
149
+ */
150
+ function scannedCwd(text) {
151
+ const match = /"(?:cwd|cd)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text);
152
+ if (!match)
153
+ return null;
154
+ try {
155
+ const value = JSON.parse(`"${match[1]}"`);
156
+ return typeof value === "string" && value.trim() ? value.trim() : null;
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ async function codexIdentityFromFirstLine(path) {
163
+ const none = { project: null, projectPath: null };
164
+ let handle;
165
+ try {
166
+ handle = await open(path, "r");
167
+ const buffer = Buffer.alloc(FIRST_LINE_BYTES);
168
+ const { bytesRead } = await handle.read(buffer, 0, FIRST_LINE_BYTES, 0);
169
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
170
+ const newline = text.indexOf("\n");
171
+ let cwd = null;
172
+ try {
173
+ cwd = recordedCwd(JSON.parse(newline === -1 ? text : text.slice(0, newline)));
174
+ }
175
+ catch {
176
+ // The line outgrew the read — the shape every real rollout has. The
177
+ // bounded scan reads the head that was fetched and nothing more.
178
+ cwd = scannedCwd(text);
179
+ }
180
+ if (!cwd)
181
+ return none;
182
+ const segments = cwd.split(/[\\/]/).filter(Boolean);
183
+ return segments.length === 0 ? none : { project: segments[segments.length - 1], projectPath: cwd };
184
+ }
185
+ catch {
186
+ // An unreadable first line is a session without a project, not an error:
187
+ // the list must survive whatever a vendor format update does.
188
+ return none;
189
+ }
190
+ finally {
191
+ await handle?.close().catch(() => { });
192
+ }
193
+ }
194
+ /**
195
+ * Fills project identity in place, after the limit cut — resolving only what
196
+ * will be returned keeps the codex reads proportional to the page, not the
197
+ * disk. Claude's identity is pure path arithmetic; codex needs the bounded
198
+ * first-line read, capped at 16 in flight so hundreds of cold files neither
199
+ * run serially nor open hundreds of descriptors at once.
200
+ */
201
+ async function attachProjects(candidates) {
202
+ const rollouts = [];
203
+ for (const candidate of candidates) {
204
+ if (candidate.agent === "claude")
205
+ Object.assign(candidate, claudeProjectFromPath(candidate.path));
206
+ else
207
+ rollouts.push(candidate);
208
+ }
209
+ let next = 0;
210
+ await Promise.all(Array.from({ length: Math.min(16, rollouts.length) }, async () => {
211
+ while (next < rollouts.length) {
212
+ const candidate = rollouts[next++];
213
+ Object.assign(candidate, await codexProjectFromRollout(candidate.path, candidate.modifiedAt));
214
+ }
215
+ }));
216
+ }
217
+ /**
218
+ * Collects every transcript under `root`.
219
+ *
220
+ * Deliberately unbounded: truncating here would truncate in readdir order,
221
+ * which has nothing to do with time, so "the most recent N" would be N
222
+ * arbitrary sessions that happened to be walked first. Only metadata is read,
223
+ * so the cost is one stat per file.
224
+ */
225
+ async function walkJsonl(root, agent, output) {
226
+ if (!(await directoryExists(root)))
227
+ return;
228
+ for (const entry of await readdir(root, { withFileTypes: true })) {
229
+ const path = join(root, entry.name);
230
+ if (entry.isDirectory())
231
+ await walkJsonl(path, agent, output);
232
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
233
+ const file = await stat(path);
234
+ output.push({ agent, path, bytes: file.size, modifiedAt: file.mtime.toISOString() });
235
+ }
236
+ }
237
+ }
238
+ /**
239
+ * Lists metadata for recent transcripts, never their conversations. The one
240
+ * read into a file is the codex rollout's first line, which is the session's
241
+ * own metadata record — what was said in the session stays unread.
242
+ */
243
+ export async function findTranscripts(agent, home = process.env.HOME ?? ".", limit = 50) {
244
+ const install = (await detectAgents(home)).find((item) => item.agent === agent);
245
+ const files = [];
246
+ for (const root of install.transcriptRoots)
247
+ await walkJsonl(root, agent, files);
248
+ const recent = files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)).slice(0, limit);
249
+ await attachProjects(recent);
250
+ return recent;
251
+ }
252
+ /**
253
+ * The one command a Sealkeep hook ever runs, built in one place.
254
+ *
255
+ * Detection matches installed hooks on the `hook enqueue --agent <id>` part of
256
+ * it, so the command and the thing that recognises it can never disagree about
257
+ * what a Sealkeep hook looks like.
258
+ */
259
+ export function hookCommand(agent, executable = "vaultline", dataDir = "~/.vaultline") {
260
+ return `${executable} ${hookMarker(agent)} --data-dir ${dataDir}`;
261
+ }
262
+ /** The substring that identifies a Sealkeep hook for one agent, wherever it is written. */
263
+ export function hookMarker(agent) {
264
+ return `hook enqueue --agent ${agent}`;
265
+ }
266
+ /**
267
+ * Returns a reviewable config fragment. It is intentionally not installed into
268
+ * an agent's settings: users must review and merge it themselves. The hook only
269
+ * enqueues local work, so it needs no recovery phrase and no network access.
270
+ */
271
+ export function hookConfig(agent, executable = "vaultline", dataDir = "~/.vaultline") {
272
+ const command = hookCommand(agent, executable, dataDir);
273
+ const entry = (timeout) => [{ hooks: [{ type: "command", command, timeout }] }];
274
+ // The resume side of the pair: SessionStart checks whether the transcript
275
+ // the agent is about to read left this disk for the vault, and puts it back
276
+ // first — resume never learns the file was away. Generous timeout, because
277
+ // an offloaded multi-GB session fetches from the bucket; the command itself
278
+ // never exits nonzero, so a slow restore is the worst case, not a broken one.
279
+ const rehydrate = `${executable} hook rehydrate --agent ${agent} --data-dir ${dataDir}`;
280
+ const rehydrateEntry = [{ matcher: "resume", hooks: [{ type: "command", command: rehydrate, timeout: 300 }] }];
281
+ if (agent === "codex")
282
+ return {
283
+ description: "Sealkeep records an archive intent locally, and puts archived transcripts back before a resume reads them. It holds no secret; rehydration uses this machine's keystore.",
284
+ hooks: {
285
+ SessionEnd: [{ hooks: [{ type: "command", command, timeout: 3, statusMessage: "Queuing encrypted archive" }] }],
286
+ PostCompact: entry(3),
287
+ SessionStart: rehydrateEntry
288
+ },
289
+ _vaultlineNote: "Run `sealkeep queue run` (with VAULTLINE_RECOVERY_PHRASE set) to encrypt queued sessions. If this Codex version has no SessionStart hook, the entry is ignored; `sealkeep recover <session-file>` is the manual door."
290
+ };
291
+ return {
292
+ hooks: { SessionEnd: entry(5), SessionStart: rehydrateEntry },
293
+ _vaultlineNote: "Merge the hooks object into ~/.claude/settings.json. The hook stores no secret; run `sealkeep queue run` with VAULTLINE_RECOVERY_PHRASE to encrypt queued sessions. SessionStart(resume) puts archived transcripts back before the resume reads them."
294
+ };
295
+ }
296
+ /**
297
+ * Whether Sealkeep could put its hook in this file.
298
+ *
299
+ * A missing file is not a refusal — most agents create theirs on first use —
300
+ * so the question becomes whether its directory will accept a new file. Any
301
+ * other error is answered "no": claiming an agent is hookable and then failing
302
+ * to write is the outcome this whole endpoint exists to avoid.
303
+ */
304
+ async function hookFileWritable(path) {
305
+ try {
306
+ await access(path, constants.W_OK);
307
+ return true;
308
+ }
309
+ catch (error) {
310
+ if (error.code !== "ENOENT")
311
+ return false;
312
+ try {
313
+ await access(dirname(path), constants.W_OK);
314
+ return true;
315
+ }
316
+ catch {
317
+ return false;
318
+ }
319
+ }
320
+ }
321
+ /**
322
+ * Reads an agent's hook file as text, whatever its format.
323
+ *
324
+ * Detection only has to answer "is our command in here", and the command is a
325
+ * literal string in JSON and TOML alike, so this needs no parser and cannot be
326
+ * defeated by a config shape this build has not seen.
327
+ */
328
+ async function hookFileText(path) {
329
+ try {
330
+ return await readFile(path, "utf8");
331
+ }
332
+ catch {
333
+ return null;
334
+ }
335
+ }
336
+ function statusFor(detection) {
337
+ if (!detection.detected)
338
+ return { status: "not-installed", note: `No ${detection.root} on this machine.` };
339
+ if (detection.hooksInstalled)
340
+ return { status: "hooked", note: null };
341
+ if (!detection.hookFileWritable)
342
+ return { status: "found-but-unwritable", note: `${detection.hookFile} cannot be written or created, so Sealkeep cannot hook ${detection.label} here.` };
343
+ if (!detection.archivable) {
344
+ return {
345
+ status: "found-but-unsupported",
346
+ // A reason, not a sentence. Callers introduce the agent themselves — the
347
+ // wizard row already opens with "Sealkeep can see Cursor here but
348
+ // cannot archive its sessions yet" — so repeating that here produced the
349
+ // same statement twice in one paragraph, followed by a stray full stop.
350
+ note: `\`sealkeep hook enqueue\` accepts only codex and claude, and this agent's hook payload carries no transcript path, so a hook here would fail on every session`
351
+ };
352
+ }
353
+ if (detection.hookFileFormat !== "json") {
354
+ return { status: "manual-merge-required", note: `${detection.hookFile} is TOML and this build has no TOML writer, so Sealkeep writes a reviewable fragment instead of editing it.` };
355
+ }
356
+ return { status: "ready", note: null };
357
+ }
358
+ /**
359
+ * Everything first-run setup needs about the agents on this machine.
360
+ *
361
+ * Unlike `detectAgents`, which answers the archiving question and so lists only
362
+ * the agents with a session adapter, this answers the setup question: what is
363
+ * here, where its hooks live, whether ours are already among them, and whether
364
+ * we could write there at all. It reads no transcript and no config value —
365
+ * only whether one string appears in a config file.
366
+ */
367
+ /** Total size and count of one agent's transcripts. Reads sizes, never contents. */
368
+ async function measureSessions(roots) {
369
+ const found = [];
370
+ for (const root of roots)
371
+ await walkJsonl(root, "codex", found);
372
+ return { sessionBytes: found.reduce((total, item) => total + item.bytes, 0), sessionCount: found.length };
373
+ }
374
+ export async function detectSetupAgents(home = process.env.HOME ?? ".") {
375
+ return Promise.all(SETUP_AGENT_IDS.map(async (agent) => {
376
+ const definition = AGENTS[agent];
377
+ const root = definition.root(home);
378
+ const hookFile = definition.hookFile(home);
379
+ const [detected, contents, writable] = await Promise.all([directoryExists(root), hookFileText(hookFile), hookFileWritable(hookFile)]);
380
+ // What this agent is costing in disk right now. It is the reason anyone
381
+ // installs Sealkeep, and until it is on screen the problem is invisible —
382
+ // sessions accumulate in a directory nobody opens. Metadata only, one stat
383
+ // per file, and skipped entirely for an agent with no adapter, where the
384
+ // number would be a figure we cannot act on.
385
+ const sessions = detected && definition.archivable
386
+ ? await measureSessions(definition.transcriptRoots(home))
387
+ : { sessionBytes: 0, sessionCount: 0 };
388
+ const base = {
389
+ agent, label: definition.label, detected, root, hookFile,
390
+ hookFileFormat: definition.hookFileFormat,
391
+ hookFileExists: contents !== null,
392
+ hookFileWritable: writable,
393
+ hooksInstalled: contents !== null && contents.includes(hookMarker(agent)),
394
+ archivable: definition.archivable,
395
+ ...sessions
396
+ };
397
+ return { ...base, ...statusFor(base) };
398
+ }));
399
+ }
400
+ /** Written beside the original before Sealkeep edits another program's config, so a bad merge is always undoable. */
401
+ async function backupHookFile(path, contents) {
402
+ const backup = `${path}.vaultline-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
403
+ await writeFile(backup, contents, { mode: 0o600 });
404
+ return backup;
405
+ }
406
+ /**
407
+ * Merges Sealkeep's hook entries into whatever the agent already declares.
408
+ *
409
+ * Every key the agent owns is carried through untouched and our entry is
410
+ * appended rather than substituted, because this file belongs to another
411
+ * program: a person's own hooks surviving a Sealkeep install matters more
412
+ * than a tidy document.
413
+ */
414
+ function mergeHooks(existing, ours) {
415
+ const base = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
416
+ for (const [event, entries] of Object.entries(ours)) {
417
+ // One agent's config declares events another does not; an absent event is
418
+ // nothing to merge rather than an empty list to write.
419
+ if (!entries)
420
+ continue;
421
+ const current = Array.isArray(base[event]) ? [...base[event]] : [];
422
+ base[event] = [...current, ...entries];
423
+ }
424
+ return base;
425
+ }
426
+ /** Atomic, owner-only, and never a partial file: a half-written settings.json breaks the agent it belongs to. */
427
+ async function writeJsonAtomic(path, value) {
428
+ await mkdir(dirname(path), { recursive: true });
429
+ const temp = `${path}.${randomUUID()}.tmp`;
430
+ await writeFile(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
431
+ await rename(temp, path);
432
+ }
433
+ /**
434
+ * Installs Sealkeep's hook into the agents it can, and says why for the rest.
435
+ *
436
+ * Idempotent by construction: a second call finds its own marker and writes
437
+ * nothing. Nothing here reports `installed: true` unless the agent's own config
438
+ * now contains the command — a fragment written for manual review is reported
439
+ * as what it is.
440
+ */
441
+ export async function installAgentHooks(agents, options) {
442
+ const home = options.home ?? process.env.HOME ?? ".";
443
+ const executable = options.executable ?? "vaultline";
444
+ const detections = new Map((await detectSetupAgents(home)).map((detection) => [detection.agent, detection]));
445
+ const outcomes = [];
446
+ for (const agent of agents) {
447
+ const detection = detections.get(agent);
448
+ const base = { agent, hookFile: detection.hookFile, installed: false, alreadyInstalled: false, backupPath: null, fragmentPath: null, reason: null };
449
+ if (detection.status === "hooked") {
450
+ outcomes.push({ ...base, installed: true, alreadyInstalled: true });
451
+ continue;
452
+ }
453
+ if (detection.status === "not-installed" || detection.status === "found-but-unwritable" || detection.status === "found-but-unsupported") {
454
+ outcomes.push({ ...base, reason: detection.note });
455
+ continue;
456
+ }
457
+ // Only an agent with a session adapter can still be here: `statusFor` sent
458
+ // every other id to "found-but-unsupported" above. The guard is what makes that
459
+ // provable rather than merely true.
460
+ if (!isArchivingAgent(agent)) {
461
+ outcomes.push({ ...base, reason: detection.note });
462
+ continue;
463
+ }
464
+ if (detection.status === "manual-merge-required") {
465
+ // The fragment is a Sealkeep-owned artifact, exactly as `sealkeep setup`
466
+ // has always written it. It is not an install and is not reported as one.
467
+ const fragmentPath = join(options.dataDir, "integrations", `${agent}-hooks.json`);
468
+ await writeJsonAtomic(fragmentPath, hookConfig(agent, executable, options.dataDir));
469
+ outcomes.push({ ...base, fragmentPath, reason: detection.note });
470
+ continue;
471
+ }
472
+ const raw = detection.hookFileExists ? await hookFileText(detection.hookFile) : "";
473
+ let parsed = {};
474
+ if (raw && raw.trim()) {
475
+ try {
476
+ parsed = JSON.parse(raw);
477
+ }
478
+ catch {
479
+ // Refusing beats guessing: rewriting a file we could not read would
480
+ // discard settings that belong to somebody else.
481
+ outcomes.push({ ...base, reason: `${detection.hookFile} is not valid JSON, so Sealkeep will not rewrite it. Fix or move it and try again.` });
482
+ continue;
483
+ }
484
+ }
485
+ const document = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
486
+ document.hooks = mergeHooks(document.hooks, hookConfig(agent, executable, options.dataDir).hooks);
487
+ const backupPath = raw && raw.trim() ? await backupHookFile(detection.hookFile, raw) : null;
488
+ await writeJsonAtomic(detection.hookFile, document);
489
+ outcomes.push({ ...base, installed: true, backupPath });
490
+ }
491
+ return outcomes;
492
+ }
493
+ const PATH_KEYS = ["transcript_path", "transcriptPath", "rollout_path", "rolloutPath"];
494
+ const EVENT_KEYS = ["hook_event_name", "hookEventName", "event", "type"];
495
+ const SESSION_KEYS = ["session_id", "sessionId", "conversation_id"];
496
+ function firstString(payload, keys) {
497
+ for (const key of keys) {
498
+ const value = payload[key];
499
+ if (typeof value === "string" && value.trim())
500
+ return value.trim();
501
+ }
502
+ return undefined;
503
+ }
504
+ /**
505
+ * Accepts Codex `SessionEnd`/`PostCompact` and Claude `SessionEnd` payloads. Unknown
506
+ * vendor fields are ignored rather than parsed, so a schema change cannot break preservation.
507
+ */
508
+ export async function hookEventFromStdin(agent, stdin) {
509
+ let payload;
510
+ try {
511
+ payload = JSON.parse(stdin);
512
+ }
513
+ catch {
514
+ return fail("hook_payload_invalid", "Hook payload must be a JSON object on stdin");
515
+ }
516
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
517
+ return fail("hook_payload_invalid", "Hook payload must be a JSON object on stdin");
518
+ const record = payload;
519
+ const raw = firstString(record, PATH_KEYS);
520
+ if (!raw)
521
+ return fail("hook_payload_invalid", `Hook payload has no transcript path (looked for ${PATH_KEYS.join(", ")})`);
522
+ const path = resolve(raw);
523
+ try {
524
+ await access(path, constants.R_OK);
525
+ }
526
+ catch {
527
+ return fail("source_unreadable", `Transcript is not readable: ${path}`, { sourcePath: path });
528
+ }
529
+ return { agent, event: firstString(record, EVENT_KEYS) ?? "unknown", sourcePath: path, sessionId: firstString(record, SESSION_KEYS) };
530
+ }
531
+ export async function transcriptPathFromHook(stdin, agent = "codex") {
532
+ return (await hookEventFromStdin(agent, stdin)).sourcePath;
533
+ }
@@ -0,0 +1,24 @@
1
+ export type AuditAction = "archive.create" | "archive.restore" | "archive.migrate" | "archive.rewrap" | "archive.prune" | "archive.offload" | "archive.delete" | "phrase.rotate" | "password.change" | "archive.share" | "source.reclaim" | "recipient.add" | "recipient.remove" | "upload.verify" | "remote.check" | "retention.approve";
2
+ export type LocalAuditEvent = {
3
+ id: string;
4
+ at: string;
5
+ action: AuditAction;
6
+ outcome: "allowed" | "denied";
7
+ /** Identifiers, counts, and paths only. Never transcript content or secrets. */
8
+ detail?: Record<string, string | number | boolean | null>;
9
+ };
10
+ export declare const auditPath: (dataDir: string) => string;
11
+ /**
12
+ * Append-only local audit log.
13
+ *
14
+ * It exists so a user can answer "what did Sealkeep do to my files?" without
15
+ * trusting a server. Entries are one JSON object per line, rotated at 5 MB, and
16
+ * carry identifiers and counts only — never plaintext, phrases, or key material.
17
+ */
18
+ export declare function recordAudit(dataDir: string, action: AuditAction, outcome: "allowed" | "denied", detail?: LocalAuditEvent["detail"]): Promise<LocalAuditEvent>;
19
+ export declare function readAudit(dataDir: string, options?: {
20
+ limit?: number;
21
+ action?: AuditAction;
22
+ }): Promise<LocalAuditEvent[]>;
23
+ /** CSV export for teams that keep audit evidence outside the machine. */
24
+ export declare function toCsv(events: readonly LocalAuditEvent[]): string;
@@ -0,0 +1,41 @@
1
+ import { appendFile, mkdir, readFile, rename, stat } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ const MAX_BYTES = 5 * 1024 * 1024;
5
+ export const auditPath = (dataDir) => join(dataDir, "audit.jsonl");
6
+ /**
7
+ * Append-only local audit log.
8
+ *
9
+ * It exists so a user can answer "what did Sealkeep do to my files?" without
10
+ * trusting a server. Entries are one JSON object per line, rotated at 5 MB, and
11
+ * carry identifiers and counts only — never plaintext, phrases, or key material.
12
+ */
13
+ export async function recordAudit(dataDir, action, outcome, detail) {
14
+ const event = { id: randomUUID(), at: new Date().toISOString(), action, outcome, detail };
15
+ const path = auditPath(dataDir);
16
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
17
+ const size = await stat(path).then((info) => info.size).catch(() => 0);
18
+ if (size > MAX_BYTES)
19
+ await rename(path, `${path}.1`).catch(() => undefined);
20
+ await appendFile(path, `${JSON.stringify(event)}\n`, { mode: 0o600 });
21
+ return event;
22
+ }
23
+ export async function readAudit(dataDir, options = {}) {
24
+ const raw = await readFile(auditPath(dataDir), "utf8").catch(() => "");
25
+ const events = raw.split("\n").filter(Boolean).flatMap((line) => {
26
+ try {
27
+ return [JSON.parse(line)];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ });
33
+ const filtered = options.action ? events.filter((event) => event.action === options.action) : events;
34
+ return filtered.slice(-(options.limit ?? 200));
35
+ }
36
+ /** CSV export for teams that keep audit evidence outside the machine. */
37
+ export function toCsv(events) {
38
+ const escape = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
39
+ const header = "id,at,action,outcome,detail";
40
+ return [header, ...events.map((event) => [event.id, event.at, event.action, event.outcome, JSON.stringify(event.detail ?? {})].map(escape).join(","))].join("\n");
41
+ }
@@ -0,0 +1,77 @@
1
+ import { ArchiveQueue } from "./queue.js";
2
+ import { type RetentionSettings } from "./retention.js";
3
+ import { type BackendName } from "./secrets.js";
4
+ import { serviceStatus, type ServiceOptions, type ServiceOutcome } from "./service.js";
5
+ import { type Liveness } from "./heartbeat.js";
6
+ export type AutopilotOptions = {
7
+ phrase?: string;
8
+ /** Keep the phrase in this machine's keystore so the service can run unattended. */
9
+ remember?: boolean;
10
+ /** Let the service reclaim disk once a remote copy is verified. */
11
+ reclaim?: boolean;
12
+ olderThanDays?: number;
13
+ graceDays?: number;
14
+ home?: string;
15
+ platform?: NodeJS.Platform;
16
+ exec?: ServiceOptions["exec"];
17
+ cliPath?: string;
18
+ /** Pin the keystore. Omitted, the strongest backend this machine offers is used. */
19
+ backend?: BackendName;
20
+ /** Show what would happen and change nothing. */
21
+ dryRun?: boolean;
22
+ };
23
+ export type AutopilotResult = {
24
+ vaultId: string;
25
+ /** Present only on first setup. Shown once, then never retrievable. */
26
+ recoveryPhrase?: string;
27
+ remembered: {
28
+ backend: string;
29
+ } | null;
30
+ retention: RetentionSettings;
31
+ service: ServiceOutcome;
32
+ agents: string[];
33
+ found: {
34
+ sessions: number;
35
+ bytes: number;
36
+ };
37
+ queued: number;
38
+ reclaimEnabled: boolean;
39
+ hasRemoteTarget: boolean;
40
+ notes: string[];
41
+ };
42
+ /**
43
+ * Turns Sealkeep into something that runs itself.
44
+ *
45
+ * One call sets up the vault, teaches this machine to unlock it, picks a retention
46
+ * policy, sweeps up the sessions already on disk, and installs a login service. The
47
+ * safety gates are unchanged: reclamation still requires a verified remote copy, an
48
+ * idle file, and an elapsed grace period, so enabling it here cannot delete anything
49
+ * until a bucket is configured and an upload has been checked.
50
+ */
51
+ export declare function enableAutopilot(dataDir: string, options?: AutopilotOptions): Promise<AutopilotResult>;
52
+ export type AutopilotStatus = {
53
+ running: boolean;
54
+ /** What the service is actually doing, read from its own heartbeat. */
55
+ live: Liveness;
56
+ service: Awaited<ReturnType<typeof serviceStatus>>;
57
+ phraseAvailable: boolean;
58
+ retention: RetentionSettings;
59
+ hasRemoteTarget: boolean;
60
+ queue: Awaited<ReturnType<ArchiveQueue["stats"]>>;
61
+ };
62
+ export declare function autopilotStatus(dataDir: string, options?: AutopilotOptions): Promise<AutopilotStatus>;
63
+ /**
64
+ * Stops the service and forgets the stored phrase. Archives are untouched: turning
65
+ * automation off is not a way to lose data.
66
+ */
67
+ export declare function disableAutopilot(dataDir: string, options?: AutopilotOptions): Promise<{
68
+ service: ServiceOutcome;
69
+ forgotPhrase: boolean;
70
+ retention: {
71
+ policy: "local-only" | "sync-only" | "archive-and-reclaim" | "manual-approval";
72
+ olderThanDays: number;
73
+ graceDays: number;
74
+ deleteAfterDays?: number | null;
75
+ };
76
+ archivesKept: boolean;
77
+ }>;