sealkeep 0.8.1 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,27 @@
3
3
  Notable changes, by published version. Sealkeep is pre-1.0: minor versions
4
4
  may change behavior, and say so here when they do.
5
5
 
6
+ ## 0.9.0 — 2026-08-20
7
+
8
+ **Share a project, not the vault.** Until now a registered key opened everything
9
+ the vault ever sealed, so sharing with a colleague meant handing over every
10
+ session on the machine. A recipient can be tied to one project now:
11
+
12
+ ```sh
13
+ sealkeep projects # the names this vault knows
14
+ sealkeep recipients add --label "Dana" --public-key <key> --project checkout
15
+ sealkeep rewrap --project checkout # let them read what came before
16
+ sealkeep space members checkout # who is in, and who sees everything
17
+ ```
18
+
19
+ Dana opens that project's sessions with `sealkeep recover <id> --key dana.key`
20
+ and is refused everything else. The company key added without `--project` still
21
+ opens every project, and the phrase always does.
22
+
23
+ Two fixes underneath: the ordinary (non-streamed) seal path had never learned
24
+ about projects, so `archive --project` wrapped nobody; and `rewrap --project`
25
+ now skips other projects instead of quietly adding or dropping people there.
26
+
6
27
  ## 0.8.1 — 2026-08-20
7
28
 
8
29
  - **`sealkeep recover --key`** — open an archive with a registered recipient's
package/dist/src/cli.js CHANGED
@@ -203,6 +203,7 @@ function usage() {
203
203
  ["list", "browse archives"],
204
204
  ["search <query>", "search metadata, or contents with --content"],
205
205
  ["projects", "the project names this vault knows — the ones routing rules pin"],
206
+ ["space members [project]", "who can open a project, and who can open everything"],
206
207
  ["index build", "build the local content index that --content searches"],
207
208
  ["index status", "what is searchable and what is not yet indexed"],
208
209
  ["storage targets", "several storages at once, with limits, pins, and priority"],
@@ -866,7 +867,7 @@ async function main() {
866
867
  if (routed.provider === "gdrive") {
867
868
  const { archiveFile } = await import("./vault.js");
868
869
  const { uploadArchive } = await import("./upload.js");
869
- const record = await archiveFile(dataDir, source, phraseForSeal, agentName);
870
+ const record = await archiveFile(dataDir, source, phraseForSeal, agentName, { project });
870
871
  const uploaded = await uploadArchive(dataDir, record.id, {
871
872
  remoteStorage: { provider: "gdrive", bucket: routed.bucket ?? "gdrive", prefix: routed.prefix ?? "vaultline", region: routed.region },
872
873
  targetId: routed.id
@@ -898,7 +899,10 @@ async function main() {
898
899
  print(` ${dim(`${bytes(outcome.storedBytes)} sealed and verified in the bucket; this disk held at most ${bytes(outcome.heldAtMostBytes)} of it at any moment`)}`);
899
900
  return;
900
901
  }
901
- const record = await archiveFile(dataDir, source, phraseForSeal, take(args, "--agent", "custom"));
902
+ // A project named here decides which members are wrapped in, so an
903
+ // archive shared with a colleague is the one they were actually given.
904
+ const localProject = take(args, "--project") ?? null;
905
+ const record = await archiveFile(dataDir, source, phraseForSeal, take(args, "--agent", "custom"), { project: localProject });
902
906
  if (json) {
903
907
  print(JSON.stringify({ id: record.id, bytes: record.source.bytes, objectPath: record.objectPath, deduplicated: record.deduplicated }, null, 2));
904
908
  return;
@@ -1004,6 +1008,37 @@ async function main() {
1004
1008
  print(dim("\n On a package we run the storage and choose the provider. Your own bucket means\n your provider, your region, your bill. Either way we hold no key that opens an archive.\n"));
1005
1009
  return;
1006
1010
  }
1011
+ if (command === "space") {
1012
+ // The everyday question a shared folder answers: who is in this, and what
1013
+ // can they reach. Membership here is a set of key wraps, so "who is in" is
1014
+ // answerable exactly — a member either is in the envelopes or is not.
1015
+ const [action, name] = positionals(args);
1016
+ const config = await readConfig(dataDir);
1017
+ const members = (config.recipients ?? []);
1018
+ if (action === "members" || action === undefined) {
1019
+ const scoped = name ? members.filter((m) => m.project === name) : members;
1020
+ const vaultWide = members.filter((m) => !m.project);
1021
+ if (json) {
1022
+ print(JSON.stringify({ project: name ?? null, members: scoped, vaultWide: name ? vaultWide : undefined }, null, 2));
1023
+ return;
1024
+ }
1025
+ print(`\n${heading(name ? `Members of ${name}` : "Everyone who can open this vault")}`);
1026
+ print(` ${green("always")} ${dim("your recovery phrase")}`);
1027
+ print(table(scoped, [
1028
+ { header: "member", get: (m) => m.label },
1029
+ { header: "scope", get: (m) => (m.project ? m.project : dim("whole vault")) },
1030
+ { header: "added", get: (m) => relativeTime(m.addedAt) },
1031
+ { header: "id", get: (m) => dim(m.id.slice(0, 8)) }
1032
+ ], name ? `nobody has been added to ${name} yet` : "no keys besides the phrase"));
1033
+ if (name && vaultWide.length > 0) {
1034
+ print(`\n ${dim(`${vaultWide.length} key${vaultWide.length === 1 ? "" : "s"} can open every project, including this one: ${vaultWide.map((m) => m.label).join(", ")}`)}`);
1035
+ }
1036
+ print(`\n ${hint(`${cmd("sealkeep recipients add --label \"name\" --public-key <key> --project <project>")} adds a member`)}`);
1037
+ print(` ${hint(`${cmd("sealkeep recipients remove <id>")} then ${cmd("sealkeep rewrap --project <project>")} removes one`)}\n`);
1038
+ return;
1039
+ }
1040
+ fail("invalid_argument", "Usage: sealkeep space members [project]");
1041
+ }
1007
1042
  if (command === "projects") {
1008
1043
  // Routing rules can pin a project to a destination, and the panel cannot
1009
1044
  // offer a list: project names never reach the cloud — that is the point.
@@ -1652,7 +1687,7 @@ async function main() {
1652
1687
  }
1653
1688
  if (command === "rewrap") {
1654
1689
  const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or set SEALKEEP_RECOVERY_PHRASE)");
1655
- const result = await rewrapVault(dataDir, phrase, { group: take(args, "--group") });
1690
+ const result = await rewrapVault(dataDir, phrase, { group: take(args, "--group"), project: take(args, "--project") });
1656
1691
  if (json) {
1657
1692
  print(JSON.stringify(result, null, 2));
1658
1693
  return;
@@ -1682,13 +1717,23 @@ async function main() {
1682
1717
  return;
1683
1718
  }
1684
1719
  if (action === "add") {
1685
- const config = await addRecipient(dataDir, required(take(args, "--label"), "--label is required"), required(take(args, "--public-key"), "--public-key is required (base64 X25519)"), take(args, "--group"));
1720
+ const project = take(args, "--project");
1721
+ const config = await addRecipient(dataDir, required(take(args, "--label"), "--label is required"), required(take(args, "--public-key"), "--public-key is required (base64 X25519)"), take(args, "--group"), project);
1686
1722
  if (json) {
1687
1723
  print(JSON.stringify(config.recipients, null, 2));
1688
1724
  return;
1689
1725
  }
1690
- print(` ${mark.ok()} Added. New archives include it.`);
1691
- print(` ${hint(`${cmd("sealkeep rewrap")} extends access to archives you already have`)}`);
1726
+ if (project) {
1727
+ // Say what they can reach, because the whole point of the flag is that
1728
+ // it is less than everything.
1729
+ print(` ${mark.ok()} Added to ${bold(project)}. New archives of that project include it; nothing else this vault seals does.`);
1730
+ print(` ${hint(`${cmd(`sealkeep rewrap --project ${project}`)} extends it to that project's existing archives`)}`);
1731
+ print(` ${hint(`${cmd("sealkeep projects")} lists the names this vault actually knows`)}`);
1732
+ }
1733
+ else {
1734
+ print(` ${mark.ok()} Added. New archives include it — every project.`);
1735
+ print(` ${hint(`${cmd("sealkeep rewrap")} extends access to archives you already have`)}`);
1736
+ }
1692
1737
  return;
1693
1738
  }
1694
1739
  if (action === "remove") {
@@ -25,6 +25,7 @@ export declare function migrateVault(dataDir: string, phrase: string): Promise<{
25
25
  */
26
26
  export declare function rewrapVault(dataDir: string, rawPhrase: string, options?: {
27
27
  group?: string;
28
+ project?: string;
28
29
  }): Promise<{
29
30
  rewrapped: number;
30
31
  skipped: {
@@ -70,15 +70,37 @@ export async function rewrapVault(dataDir, rawPhrase, options = {}) {
70
70
  }
71
71
  if (!matchesPhraseCheck(config.recovery.phraseCheck, phrase))
72
72
  fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault");
73
- const recipients = configuredRecipients(config, phrase, options.group);
73
+ const recipients = configuredRecipients(config, phrase, { group: options.group, project: options.project });
74
74
  const records = await listArchives(dataDir);
75
75
  const rewrapped = [];
76
76
  const skipped = [];
77
+ // Extending a project's membership must not touch archives of other
78
+ // projects: rewrapping those would either add the member (leaking work they
79
+ // were never given) or drop them (silently revoking elsewhere).
80
+ const projectOf = options.project
81
+ ? await (async () => {
82
+ const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
83
+ return async (record) => {
84
+ try {
85
+ if (record.source.agent === "claude")
86
+ return claudeProjectFromPath(record.source.path).project;
87
+ if (record.source.agent === "codex")
88
+ return (await codexProjectFromRollout(record.source.path, record.createdAt)).project;
89
+ }
90
+ catch { /* unreadable project is not a match */ }
91
+ return null;
92
+ };
93
+ })()
94
+ : null;
77
95
  for (const record of records) {
78
96
  if (!isV2(record)) {
79
97
  skipped.push({ id: record.id, reason: "v1 archive; run `sealkeep migrate` first" });
80
98
  continue;
81
99
  }
100
+ if (projectOf && await projectOf(record) !== options.project) {
101
+ skipped.push({ id: record.id, reason: `belongs to another project` });
102
+ continue;
103
+ }
82
104
  const envelope = rewrapArchive(record.envelope, { phrase }, recipients);
83
105
  await writeRecord(config.storage.root, { ...record, envelope });
84
106
  rewrapped.push(record.id);
@@ -104,6 +104,8 @@ export declare function archiveFile(dataDir: string, sourcePath: string, rawPhra
104
104
  onProgress?: (bytesRead: number) => void;
105
105
  delta?: boolean;
106
106
  chunkBytes?: number;
107
+ /** Which project this session belongs to — decides which members are wrapped in. */
108
+ project?: string | null;
107
109
  }): Promise<ArchiveResult>;
108
110
  /**
109
111
  * The recovery phrase always gets a recipient, so a recovery kit alone can restore.
@@ -118,7 +120,15 @@ export declare function listArchives(dataDir: string): Promise<ArchiveRecord[]>;
118
120
  * Registers an X25519 public key that may open future archives. Only the public
119
121
  * key is stored; the matching private key stays on its own device.
120
122
  */
121
- export declare function addRecipient(dataDir: string, label: string, publicKeyBase64: string, group?: string): Promise<VaultConfig>;
123
+ export declare function addRecipient(dataDir: string, label: string, publicKeyBase64: string, group?: string,
124
+ /**
125
+ * Tie this key to one project and it becomes a member of that project only:
126
+ * wrapped into that project's archives, and into nothing else this vault
127
+ * seals. A colleague on `checkout-rewrite` never receives the rest of your
128
+ * work, which is the difference between sharing a folder and handing over a
129
+ * drive.
130
+ */
131
+ project?: string): Promise<VaultConfig>;
122
132
  /** Removes a recipient from future archives. Run `sealkeep rewrap` to revoke it on existing ones. */
123
133
  export declare function removeRecipient(dataDir: string, id: string): Promise<VaultConfig>;
124
134
  /** Persists a full config document after validation by the caller. */
package/dist/src/vault.js CHANGED
@@ -321,7 +321,8 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
321
321
  // pass with no staged body — the ×2 disk window is gone — and every chunk
322
322
  // can later be fetched and opened alone. Archives sealed under the legacy
323
323
  // whole-body layout stay readable forever; this changes what is WRITTEN.
324
- recipients: configuredRecipients(config, phrase), archiveId: id, compression: "gzip-chunk",
324
+ // Members of this project are wrapped in; members of other projects are not.
325
+ recipients: configuredRecipients(config, phrase, { project: hooks.project ?? undefined }), archiveId: id, compression: "gzip-chunk",
325
326
  adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
326
327
  ...(chunkBytes ? { chunkBytes } : {}),
327
328
  ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
@@ -374,7 +375,7 @@ async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size,
374
375
  const objectPath = join(config.storage.root, `${id}.vlarchive`);
375
376
  const staged = `${objectPath}.${randomUUID()}.partial`;
376
377
  const sealed = await sealArchiveToFile({ path: absolute, start: base.bytes, end: size }, staged, {
377
- recipients: configuredRecipients(config, phrase), archiveId: id, compression: "gzip-chunk",
378
+ recipients: configuredRecipients(config, phrase, { project: hooks.project ?? undefined }), archiveId: id, compression: "gzip-chunk",
378
379
  adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
379
380
  ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
380
381
  }).catch((error) => {
@@ -445,7 +446,15 @@ export async function listArchives(dataDir) {
445
446
  * Registers an X25519 public key that may open future archives. Only the public
446
447
  * key is stored; the matching private key stays on its own device.
447
448
  */
448
- export async function addRecipient(dataDir, label, publicKeyBase64, group) {
449
+ export async function addRecipient(dataDir, label, publicKeyBase64, group,
450
+ /**
451
+ * Tie this key to one project and it becomes a member of that project only:
452
+ * wrapped into that project's archives, and into nothing else this vault
453
+ * seals. A colleague on `checkout-rewrite` never receives the rest of your
454
+ * work, which is the difference between sharing a folder and handing over a
455
+ * drive.
456
+ */
457
+ project) {
449
458
  if (!label.trim())
450
459
  fail("invalid_argument", "A recipient label is required");
451
460
  const raw = Buffer.from(publicKeyBase64, "base64");
@@ -454,10 +463,10 @@ export async function addRecipient(dataDir, label, publicKeyBase64, group) {
454
463
  const config = await readConfig(dataDir);
455
464
  const id = keyRecipientId(raw);
456
465
  const recipients = (config.recipients ?? []).filter((recipient) => recipient.id !== id);
457
- recipients.push({ id, label: label.trim(), publicKey: raw.toString("base64"), addedAt: new Date().toISOString(), ...(group ? { group } : {}) });
466
+ recipients.push({ id, label: label.trim(), publicKey: raw.toString("base64"), addedAt: new Date().toISOString(), ...(group ? { group } : {}), ...(project ? { project } : {}) });
458
467
  const next = { ...config, recipients };
459
468
  await writeJson(configPath(dataDir), next);
460
- await recordAudit(dataDir, "recipient.add", "allowed", { recipientId: id, label: label.trim(), group: group ?? null });
469
+ await recordAudit(dataDir, "recipient.add", "allowed", { recipientId: id, label: label.trim(), group: group ?? null, project: project ?? null });
461
470
  return next;
462
471
  }
463
472
  /** Removes a recipient from future archives. Run `sealkeep rewrap` to revoke it on existing ones. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
6
  "license": "SEE LICENSE IN LICENSE",