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,132 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { matchesPhraseCheck } from "./crypto.js";
3
+ import { canonicalPhrase, inspectPhrase, isMnemonic, PHRASE_WORDS } from "./mnemonic.js";
4
+ import { readConfig } from "./vault.js";
5
+ /**
6
+ * The recovery kit.
7
+ *
8
+ * Everything a person needs to get their archives back, except the one thing that
9
+ * must never be stored: the phrase itself. The sheet leaves numbered blanks for it,
10
+ * because the point of a written kit is that it lives on paper, in a drawer, far
11
+ * from the machine it unlocks.
12
+ */
13
+ /**
14
+ * The sheet someone reads during an actual disaster.
15
+ *
16
+ * `managed` says this vault pushes to Sealkeep Cloud. Without it the kit told
17
+ * a paying customer "Storage: this machine only" and left the cloud steps out
18
+ * of the restore instructions — so the one document they reach for after
19
+ * losing the laptop would tell them their archives went with it.
20
+ */
21
+ export function recoveryKit(config, options = {}) {
22
+ const when = (options.now ?? new Date()).toISOString().slice(0, 10);
23
+ const blanks = Array.from({ length: PHRASE_WORDS }, (_, index) => ` ${String(index + 1).padStart(2, " ")}. ______________`);
24
+ // Column-major, `rows` apart — the same layout the on-screen grid uses. The
25
+ // offsets were once +6/+12, hard-wired for a 12-word phrase; at 24 words that
26
+ // printed words 7, 8, 13 and 14 twice and left 21-24 with no blank at all, so
27
+ // anyone writing their phrase onto this sheet recorded it wrong. Deriving the
28
+ // stride from PHRASE_WORDS keeps the sheet correct if the length ever changes.
29
+ const rows = Math.ceil(PHRASE_WORDS / 3);
30
+ const columns = [];
31
+ for (let row = 0; row < rows; row += 1) {
32
+ columns.push([blanks[row], blanks[row + rows], blanks[row + rows * 2]].filter(Boolean).join(" "));
33
+ }
34
+ return `VAULTLINE RECOVERY KIT
35
+ ${"=".repeat(58)}
36
+
37
+ Vault ${config.vaultId}
38
+ Created ${when}
39
+ Storage ${config.remoteStorage
40
+ ? `${config.remoteStorage.provider}://${config.remoteStorage.bucket}/${config.remoteStorage.prefix}`
41
+ : options.managed
42
+ ? `Sealkeep Cloud, account ${options.managed.email}`
43
+ : "this machine only"}
44
+ Devices ${(config.recipients ?? []).length} registered key(s) besides the phrase
45
+ ${config.storageMode === "plain" ? `
46
+ THIS VAULT IS NOT ENCRYPTED
47
+ ${"-".repeat(58)}
48
+ This vault stores archives UNENCRYPTED — the phrase identifies it
49
+ but protects nothing. Anyone who can read the storage can read the
50
+ sessions. You chose this at setup; the phrase below still matters,
51
+ because it is how a new machine proves it is restoring YOUR vault.
52
+ ` : ""}
53
+ YOUR RECOVERY PHRASE
54
+ ${"-".repeat(58)}
55
+ Write the ${PHRASE_WORDS} words here, in order. Do not photograph this sheet, and do
56
+ not store it on the computer it unlocks.
57
+
58
+ ${columns.join("\n")}
59
+
60
+ TO RESTORE ON A NEW MACHINE
61
+ ${"-".repeat(58)}
62
+ 1. Install Sealkeep.
63
+ 2. Run: sealkeep setup --recovery-phrase "<the words above>"
64
+ ${config.remoteStorage ? ` 3. Point it at your storage:
65
+ sealkeep storage configure --provider ${config.remoteStorage.provider} \\
66
+ --bucket ${config.remoteStorage.bucket} --prefix ${config.remoteStorage.prefix}
67
+ 4. Restore a session: sealkeep recover <archive-id> ./restored.jsonl`
68
+ : options.managed ? ` 3. Sign in: sealkeep cloud login --email ${options.managed.email}
69
+ 4. See what is stored: sealkeep cloud archives
70
+ 5. Read one back: sealkeep open <ref> ./restored.jsonl --recovery-phrase "<the words above>"
71
+
72
+ Every archive carries what is needed to open it, so this works on a
73
+ machine that has never seen your vault. The phrase is the only thing
74
+ you must bring.`
75
+ : " 3. Restore a session: sealkeep recover <archive-id> ./restored.jsonl"}
76
+
77
+ IF YOU LOSE THIS
78
+ ${"-".repeat(58)}
79
+ Any device key you registered can also open your archives. If you lose the phrase
80
+ and every registered device, the archives cannot be opened — not by you, not by
81
+ support, not by whoever is holding the storage bucket. That is the design.
82
+
83
+ Verify your copy any time with: sealkeep recovery verify
84
+ `;
85
+ }
86
+ /** Checks a typed phrase for shape, checksum, and whether it opens this vault. */
87
+ export function verifyPhrase(config, typed) {
88
+ const inspection = inspectPhrase(typed);
89
+ // matchesPhraseCheck reads the format of the stored value itself (defect
90
+ // #49), so this is correct whether or not `config` already went through
91
+ // readConfig's legacy-upgrade path.
92
+ const matchesVault = matchesPhraseCheck(config.recovery.phraseCheck, canonicalPhrase(typed));
93
+ const problems = [...inspection.problems];
94
+ // A pre-word-list vault holds a free-text phrase, so only the vault check applies.
95
+ if (!inspection.valid && matchesVault) {
96
+ return { valid: true, matchesVault: true, problems: ["This vault predates word phrases, so only the vault check applies."] };
97
+ }
98
+ if (inspection.valid && !matchesVault) {
99
+ problems.push("These are valid recovery words, but they do not open this vault. Check you are reading the right kit.");
100
+ }
101
+ return { valid: inspection.valid && matchesVault, matchesVault, problems };
102
+ }
103
+ /**
104
+ * Asks the person to type their phrase back, the way a wallet does.
105
+ *
106
+ * Input is read without echo so it does not end up in a screenshot or a scrollback
107
+ * buffer, and it is never written anywhere.
108
+ */
109
+ export async function promptForPhrase(prompt = "Type your recovery phrase: ") {
110
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
111
+ const output = process.stdout;
112
+ let muted = false;
113
+ const write = output.write.bind(output);
114
+ output.write = (chunk) => (muted ? true : write(chunk));
115
+ try {
116
+ const answer = rl.question(prompt);
117
+ muted = true;
118
+ const typed = await answer;
119
+ muted = false;
120
+ write("\n");
121
+ return typed;
122
+ }
123
+ finally {
124
+ muted = false;
125
+ output.write = write;
126
+ rl.close();
127
+ }
128
+ }
129
+ export async function verifyAgainstVault(dataDir, typed) {
130
+ return verifyPhrase(await readConfig(dataDir), typed);
131
+ }
132
+ export { isMnemonic, PHRASE_WORDS };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Rehydration: the transparent answer to "resume is bound to a file we moved".
3
+ *
4
+ * The failure class this exists for is structural. Agents keep a catalog that
5
+ * binds a session id to a transcript path; Sealkeep's whole point is that the
6
+ * file can leave the disk once a sealed copy is safe. Those two facts collide
7
+ * the moment someone resumes an old session — unless the file comes BACK
8
+ * before the agent reads it. So: the agent's own SessionStart hook calls this,
9
+ * and when the path is missing but the vault holds it, the newest archive of
10
+ * that session is restored natively — exact path, exact bytes, delta chains
11
+ * resolved, chunks fetched from the bucket if the local blob was offloaded.
12
+ * The resume proceeds as if the file had never been away — paging, not loss.
13
+ *
14
+ * Contract with the agent: NEVER break the session. Every outcome — including
15
+ * "this vault has no phrase available" and "that session was never archived" —
16
+ * is a calm JSON line and exit 0. A hook that can fail a session start would
17
+ * be worse than the problem it solves.
18
+ */
19
+ export type RehydrateOutcome = {
20
+ rehydrated: true;
21
+ archiveId: string;
22
+ output: string;
23
+ bytes: number;
24
+ } | {
25
+ rehydrated: false;
26
+ reason: "already-present" | "no-target" | "not-archived" | "no-phrase" | "restore-failed";
27
+ note?: string;
28
+ };
29
+ export declare function rehydrateSession(dataDir: string, target: {
30
+ path?: string;
31
+ sessionId?: string;
32
+ }, phrase: string | undefined, options?: {
33
+ home?: string;
34
+ }): Promise<RehydrateOutcome>;
35
+ /**
36
+ * Lenient hook-payload reading, distinct from `hookEventFromStdin` on purpose:
37
+ * that parser refuses when the transcript is unreadable, and an ABSENT
38
+ * transcript is this feature's entire subject.
39
+ */
40
+ export declare function rehydrateTargetFromPayload(stdin: string): {
41
+ path?: string;
42
+ sessionId?: string;
43
+ };
@@ -0,0 +1,66 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import { listArchives } from "./vault.js";
4
+ import { restoreArchive } from "./restore.js";
5
+ import { recordAudit } from "./audit.js";
6
+ export async function rehydrateSession(dataDir, target, phrase, options = {}) {
7
+ const wanted = target.path ? resolve(target.path) : null;
8
+ if (!wanted && !target.sessionId)
9
+ return { rehydrated: false, reason: "no-target" };
10
+ if (wanted) {
11
+ const present = await access(wanted, constants.R_OK).then(() => true, () => false);
12
+ if (present)
13
+ return { rehydrated: false, reason: "already-present" };
14
+ }
15
+ const records = (await listArchives(dataDir)).filter((record) => record.version === 2);
16
+ const byPath = wanted ? records.filter((record) => record.source.path === wanted) : [];
17
+ const byName = byPath.length > 0 ? byPath
18
+ : wanted ? records.filter((record) => basename(record.source.path) === basename(wanted)) : [];
19
+ const bySession = byName.length > 0 ? byName
20
+ : target.sessionId ? records.filter((record) => basename(record.source.path).includes(target.sessionId)) : [];
21
+ if (bySession.length === 0)
22
+ return { rehydrated: false, reason: "not-archived" };
23
+ const newest = bySession.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
24
+ // If the match came by name and the file exists at ITS recorded path, the
25
+ // catalog and the disk already agree — nothing to do.
26
+ if (!wanted || newest.source.path !== wanted) {
27
+ const there = await access(newest.source.path, constants.R_OK).then(() => true, () => false);
28
+ if (there)
29
+ return { rehydrated: false, reason: "already-present" };
30
+ }
31
+ if (!phrase)
32
+ return { rehydrated: false, reason: "no-phrase", note: "No recovery phrase is available to this machine (keystore or VAULTLINE_RECOVERY_PHRASE), so the sealed copy stays sealed." };
33
+ try {
34
+ const outcome = await restoreArchive(dataDir, newest.id, phrase, { native: true, overwrite: "refuse", home: options.home });
35
+ await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: newest.id, path: outcome.output, rehydrated: true });
36
+ return { rehydrated: true, archiveId: newest.id, output: outcome.output, bytes: outcome.bytes };
37
+ }
38
+ catch (error) {
39
+ return { rehydrated: false, reason: "restore-failed", note: error instanceof Error ? error.message.split("\n")[0] : "restore failed" };
40
+ }
41
+ }
42
+ /**
43
+ * Lenient hook-payload reading, distinct from `hookEventFromStdin` on purpose:
44
+ * that parser refuses when the transcript is unreadable, and an ABSENT
45
+ * transcript is this feature's entire subject.
46
+ */
47
+ export function rehydrateTargetFromPayload(stdin) {
48
+ try {
49
+ const payload = JSON.parse(stdin);
50
+ const pick = (keys) => {
51
+ for (const key of keys) {
52
+ const value = payload[key];
53
+ if (typeof value === "string" && value.trim())
54
+ return value.trim();
55
+ }
56
+ return undefined;
57
+ };
58
+ return {
59
+ path: pick(["transcript_path", "transcriptPath", "rollout_path", "rolloutPath"]),
60
+ sessionId: pick(["session_id", "sessionId", "conversation_id", "thread_id"])
61
+ };
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ }
@@ -0,0 +1,34 @@
1
+ import type { AgentId } from "./adapters.js";
2
+ import type { ArchiveRecord } from "./types.js";
3
+ export type OverwritePolicy = "refuse" | "backup" | "replace";
4
+ export type RestoreOptions = {
5
+ destination?: string;
6
+ native?: boolean;
7
+ overwrite?: OverwritePolicy;
8
+ home?: string;
9
+ };
10
+ export type RestoreOutcome = {
11
+ record: ArchiveRecord;
12
+ output: string;
13
+ bytes: number;
14
+ native: boolean;
15
+ backupPath?: string;
16
+ };
17
+ /**
18
+ * Where each adapter's transcripts legitimately live. Restoring "natively" means
19
+ * putting bytes back at the recorded original path, and only if that path is still
20
+ * inside the agent's own root — never anywhere else on the filesystem.
21
+ */
22
+ export declare const NATIVE_ROOTS: Record<AgentId, (home: string) => string[]>;
23
+ /** Resolves the adapter-native destination, refusing anything outside the agent's roots. */
24
+ export declare function nativeRestoreTarget(record: ArchiveRecord, home?: string): string;
25
+ /**
26
+ * Restores one archive atomically.
27
+ *
28
+ * The plaintext is streamed to a temporary file beside the destination and renamed
29
+ * into place, so a crash never leaves a half-written transcript — and a transcript
30
+ * of any size is restored without ever holding it in memory. An existing file
31
+ * is refused by default; `backup` preserves it beside the restored copy and
32
+ * `replace` is the only policy that discards it.
33
+ */
34
+ export declare function restoreArchive(dataDir: string, id: string, phrase: string, options?: RestoreOptions): Promise<RestoreOutcome>;
@@ -0,0 +1,80 @@
1
+ import { mkdir, rename, rm, stat } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve, sep } from "node:path";
5
+ import { fail } from "./errors.js";
6
+ import { recordAudit } from "./audit.js";
7
+ import { assertPhraseOpens, findArchive, readConfig, restoreRecordToFile } from "./vault.js";
8
+ /**
9
+ * Where each adapter's transcripts legitimately live. Restoring "natively" means
10
+ * putting bytes back at the recorded original path, and only if that path is still
11
+ * inside the agent's own root — never anywhere else on the filesystem.
12
+ */
13
+ export const NATIVE_ROOTS = {
14
+ codex: (home) => [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")],
15
+ claude: (home) => [join(home, ".claude", "projects")]
16
+ };
17
+ function within(candidate, root) {
18
+ const target = resolve(candidate);
19
+ const base = resolve(root);
20
+ return target === base || target.startsWith(base.endsWith(sep) ? base : base + sep);
21
+ }
22
+ /** Resolves the adapter-native destination, refusing anything outside the agent's roots. */
23
+ export function nativeRestoreTarget(record, home = homedir()) {
24
+ const agent = record.source.agent;
25
+ if (agent !== "codex" && agent !== "claude") {
26
+ fail("native_restore_unavailable", `No native restore path is defined for agent "${agent}"; pass an explicit destination`, { agent });
27
+ }
28
+ const roots = NATIVE_ROOTS[agent](home);
29
+ const original = resolve(record.source.path);
30
+ if (!roots.some((root) => within(original, root))) {
31
+ fail("path_not_permitted", `Recorded source ${original} is not inside a ${agent} transcript root; pass an explicit destination`, { agent, roots });
32
+ }
33
+ return original;
34
+ }
35
+ /**
36
+ * Restores one archive atomically.
37
+ *
38
+ * The plaintext is streamed to a temporary file beside the destination and renamed
39
+ * into place, so a crash never leaves a half-written transcript — and a transcript
40
+ * of any size is restored without ever holding it in memory. An existing file
41
+ * is refused by default; `backup` preserves it beside the restored copy and
42
+ * `replace` is the only policy that discards it.
43
+ */
44
+ export async function restoreArchive(dataDir, id, phrase, options = {}) {
45
+ const record = await findArchive(dataDir, id);
46
+ const native = options.native === true;
47
+ if (native && options.destination)
48
+ fail("invalid_argument", "Choose either --native or an explicit destination, not both");
49
+ if (!native && !options.destination)
50
+ fail("invalid_argument", "A destination is required unless --native is used");
51
+ const output = native ? nativeRestoreTarget(record, options.home ?? homedir()) : resolve(options.destination);
52
+ const policy = options.overwrite ?? "refuse";
53
+ const existing = await stat(output).then(() => true).catch(() => false);
54
+ if (existing && policy === "refuse") {
55
+ fail("destination_exists", `${output} already exists. Re-run with --overwrite backup to keep it, or --overwrite replace to discard it.`, { destination: output });
56
+ }
57
+ // Proved before anything is created, because the restore itself writes as it
58
+ // decrypts: a wrong phrase must not leave a directory or a partial file
59
+ // behind. The config decides what "proved" means — unwrapping for a sealed
60
+ // vault, the identity check for a plain one.
61
+ assertPhraseOpens(await readConfig(dataDir), record, phrase);
62
+ await mkdir(dirname(output), { recursive: true });
63
+ const temp = join(dirname(output), `.${basename(output)}.${randomUUID()}.partial`);
64
+ let backupPath;
65
+ let bytes;
66
+ try {
67
+ ({ bytes } = await restoreRecordToFile(dataDir, record, phrase, temp));
68
+ if (existing && policy === "backup") {
69
+ backupPath = `${output}.vaultline-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
70
+ await rename(output, backupPath);
71
+ }
72
+ await rename(temp, output);
73
+ }
74
+ catch (error) {
75
+ await rm(temp, { force: true });
76
+ throw error;
77
+ }
78
+ await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: id, output, bytes, native, backedUp: Boolean(backupPath) });
79
+ return { record, output, bytes, native, backupPath };
80
+ }
@@ -0,0 +1,251 @@
1
+ import { type ActivityOptions } from "./activity.js";
2
+ import { type TrashResult } from "./trash.js";
3
+ import { type VaultConfig } from "./types.js";
4
+ export type RetentionPolicy = "local-only" | "sync-only" | "archive-and-reclaim" | "manual-approval";
5
+ export type RetentionSettings = {
6
+ policy: RetentionPolicy;
7
+ olderThanDays: number;
8
+ graceDays: number;
9
+ /**
10
+ * How long an *archive* is kept before it may be deleted from storage.
11
+ * `null` means never — and that is the default, because the surprising
12
+ * outcome for an archive tool is losing an archive, not keeping one.
13
+ */
14
+ deleteAfterDays: number | null;
15
+ /**
16
+ * How long a *session* must have gone untouched before its bytes are taken.
17
+ *
18
+ * `olderThanDays` above measures the archive, which answers "how long has
19
+ * this been safely backed up" — a question about us. This one measures the
20
+ * transcript's own last-modified time, which answers "when did I last care
21
+ * about this", the question people actually ask when deciding what to throw
22
+ * away. They are different: a session archived two months ago that you
23
+ * reopened this morning is old by the first measure and current by this one.
24
+ *
25
+ * `null` means the source's own age is not considered.
26
+ */
27
+ sourceIdleDays: number | null;
28
+ /**
29
+ * Don't bother reclaiming sources smaller than this.
30
+ *
31
+ * Disk problems are not evenly distributed. A real machine here held 78 GB
32
+ * across 303 sessions, of which forty were over a gigabyte and 359 were under
33
+ * ten megabytes — so almost all the pain sat in a handful of files, and
34
+ * churning through hundreds of tiny ones buys nothing but risk and noise.
35
+ *
36
+ * `null` means every size is a candidate.
37
+ */
38
+ minSourceBytes: number | null;
39
+ };
40
+ export declare const DEFAULT_RETENTION: RetentionSettings;
41
+ export declare const POLICIES: readonly RetentionPolicy[];
42
+ export type Candidate = {
43
+ id: string;
44
+ agent: string;
45
+ path: string;
46
+ bytes: number;
47
+ createdAt: string;
48
+ eligible: boolean;
49
+ /** Every reason this source is still protected. Empty exactly when eligible. */
50
+ blockers: string[];
51
+ reason: string;
52
+ /** The one fact the table leads with: a verified cloud copy, or nothing. */
53
+ remote: {
54
+ provider: string;
55
+ verifiedAt: string;
56
+ } | null;
57
+ };
58
+ export type RetentionOptions = {
59
+ olderThanDays?: number;
60
+ now?: number;
61
+ activity?: ActivityOptions;
62
+ settings?: Partial<RetentionSettings>;
63
+ };
64
+ export declare function retentionSettings(config: VaultConfig, overrides?: Partial<RetentionSettings>): RetentionSettings;
65
+ export declare function readApprovals(dataDir: string): Promise<string[]>;
66
+ /** Manual-approval policy: an operator names the archives whose sources may be reclaimed. */
67
+ export declare function approveReclamation(dataDir: string, archiveIds: string[]): Promise<string[]>;
68
+ /**
69
+ * Decides, per archive, whether its local source may be reclaimed.
70
+ *
71
+ * Every gate is evaluated and all failures are reported, so an operator sees the
72
+ * complete reason a file is protected rather than the first one that matched.
73
+ */
74
+ export declare function evaluateRetention(dataDir: string, options?: RetentionOptions): Promise<{
75
+ mode: "preview";
76
+ policy: RetentionPolicy;
77
+ olderThanDays: number;
78
+ graceDays: number;
79
+ sourceIdleDays: number | null;
80
+ minSourceBytes: number | null;
81
+ deleteAfterDays: number | null;
82
+ reclaimableBytes: number;
83
+ candidates: Candidate[];
84
+ }>;
85
+ export type ReclaimOutcome = {
86
+ id: string;
87
+ path: string;
88
+ bytes: number;
89
+ movedTo: string;
90
+ strategy: TrashResult["strategy"];
91
+ };
92
+ /**
93
+ * Reclaims eligible sources. Dry-run is the default: without `confirm`, nothing on
94
+ * disk changes. Reclaimed files go to the OS trash, never to `unlink`.
95
+ */
96
+ export declare function applyRetention(dataDir: string, options?: RetentionOptions & {
97
+ confirm?: boolean;
98
+ home?: string;
99
+ strategy?: TrashResult["strategy"];
100
+ }): Promise<{
101
+ mode: "dry-run";
102
+ policy: RetentionPolicy;
103
+ wouldReclaim: number;
104
+ wouldFreeBytes: number;
105
+ candidates: Candidate[];
106
+ reclaimed?: undefined;
107
+ failed?: undefined;
108
+ freedBytes?: undefined;
109
+ protected?: undefined;
110
+ } | {
111
+ mode: "apply";
112
+ policy: RetentionPolicy;
113
+ reclaimed: ReclaimOutcome[];
114
+ failed: {
115
+ id: string;
116
+ error: string;
117
+ }[];
118
+ freedBytes: number;
119
+ protected: number;
120
+ wouldReclaim?: undefined;
121
+ wouldFreeBytes?: undefined;
122
+ candidates?: undefined;
123
+ }>;
124
+ /**
125
+ * Reclaims ONE source, because a person pointed at it.
126
+ *
127
+ * The automatic pass (`applyRetention`) obeys every rule: policy, age, grace,
128
+ * idle, size floor, manual approval. Those rules exist so the machine acting
129
+ * on its own is conservative. A person clicking one row after checking its
130
+ * cloud copy IS the approval — so this path keeps only the gates that protect
131
+ * against actual data loss, and says which one refused when it refuses:
132
+ *
133
+ * - the archive must have a verified remote copy (and a delta head's whole
134
+ * chain must be verified — one unverified base and the cloud cannot
135
+ * reproduce the file this is about to trash);
136
+ * - the source must be byte-for-byte the file that was archived — a session
137
+ * that grew since has content in no archive at all;
138
+ * - nothing may have the file open right now.
139
+ *
140
+ * Policy, age and grace are deliberately not consulted. Trash, never unlink,
141
+ * same as everywhere.
142
+ */
143
+ export declare function reclaimOne(dataDir: string, archiveId: string, options?: {
144
+ confirm?: boolean;
145
+ home?: string;
146
+ strategy?: TrashResult["strategy"];
147
+ now?: number;
148
+ activity?: ActivityOptions;
149
+ }): Promise<ReclaimOutcome>;
150
+ /**
151
+ * `reclaimOne` over a selection. Each row passes or is kept with its reason —
152
+ * one refusal never blocks the rest, and the reasons come back in the same
153
+ * sentences the single path speaks.
154
+ */
155
+ export declare function reclaimBatch(dataDir: string, ids: readonly string[], options?: {
156
+ confirm?: boolean;
157
+ home?: string;
158
+ strategy?: TrashResult["strategy"];
159
+ now?: number;
160
+ activity?: ActivityOptions;
161
+ }): Promise<{
162
+ reclaimed: ReclaimOutcome[];
163
+ kept: {
164
+ id: string;
165
+ reason: string;
166
+ }[];
167
+ freedBytes: number;
168
+ }>;
169
+ export declare function setRetentionPolicy(dataDir: string, settings: Partial<RetentionSettings>): Promise<VaultConfig>;
170
+ export type RedundantArchive = {
171
+ id: string;
172
+ agent: string;
173
+ path: string;
174
+ /** Bytes this snapshot occupies in storage — what removing it would give back. */
175
+ storedBytes: number;
176
+ createdAt: string;
177
+ /** The newer archive that already contains every byte this one holds. */
178
+ supersededBy: string;
179
+ objectPath: string;
180
+ };
181
+ /**
182
+ * Snapshots whose every byte is already inside a newer archive of the same file.
183
+ *
184
+ * Agent transcripts are append-only, so a session archived while it was still
185
+ * growing leaves a trail: a 1 GB snapshot, then a 2 GB one, then the finished
186
+ * 3.8 GB. Each is complete and standalone — that is what makes them safe — but
187
+ * it also makes the earlier ones pure duplication, because the newest archive
188
+ * already reproduces every byte they hold. `supersedes` recorded this from the
189
+ * beginning and nothing ever read it, so the trail was kept forever and, on
190
+ * metered storage, paid for forever.
191
+ *
192
+ * Nothing is deleted here. Deleting an archive is the one action that cannot be
193
+ * undone from somewhere else, and the product's promise is that it reports what
194
+ * could go and never acts alone. Two conditions are required before a snapshot
195
+ * is even *offered*: the superseding archive's object must still exist, and,
196
+ * where remote storage is configured, it must be verified there. A redundant
197
+ * copy is only redundant while the copy that replaced it is real.
198
+ */
199
+ export declare function redundantArchives(dataDir: string): Promise<{
200
+ redundant: RedundantArchive[];
201
+ reclaimableBytes: number;
202
+ kept: number;
203
+ }>;
204
+ /**
205
+ * Removes snapshots that `redundantArchives` has already proved duplicate.
206
+ *
207
+ * A dry run unless confirmed, like every other destructive path here. The list
208
+ * is recomputed rather than accepted from a caller, so a stale page cannot ask
209
+ * for the deletion of something that has since become the last copy.
210
+ */
211
+ export declare function pruneRedundantArchives(dataDir: string, options?: {
212
+ confirm?: boolean;
213
+ }): Promise<{
214
+ mode: "preview";
215
+ removed: never[];
216
+ wouldRemove: RedundantArchive[];
217
+ freedBytes: number;
218
+ failed?: undefined;
219
+ } | {
220
+ mode: "apply";
221
+ removed: RedundantArchive[];
222
+ failed: {
223
+ id: string;
224
+ error: string;
225
+ }[];
226
+ freedBytes: number;
227
+ wouldRemove?: undefined;
228
+ }>;
229
+ export type ExpiredArchive = {
230
+ id: string;
231
+ agent: string;
232
+ path: string;
233
+ bytes: number;
234
+ createdAt: string;
235
+ ageDays: number;
236
+ objectPath: string;
237
+ };
238
+ /**
239
+ * Archives whose retention period has run out.
240
+ *
241
+ * This only ever *reports*. Deleting an archive is the one action in Sealkeep
242
+ * that cannot be undone by restoring from somewhere else — the archive is the
243
+ * last copy once the source has been reclaimed — so the decision to act on this
244
+ * list stays with an explicit command, and `deleteAfterDays: null` (never) is
245
+ * the default.
246
+ */
247
+ export declare function expiredArchives(dataDir: string, options?: RetentionOptions): Promise<{
248
+ settings: RetentionSettings;
249
+ expired: ExpiredArchive[];
250
+ keptForever: number;
251
+ }>;