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 +21 -0
- package/dist/src/cli.js +51 -6
- package/dist/src/migrate.d.ts +1 -0
- package/dist/src/migrate.js +23 -1
- package/dist/src/vault.d.ts +11 -1
- package/dist/src/vault.js +14 -5
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
1691
|
-
|
|
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") {
|
package/dist/src/migrate.d.ts
CHANGED
|
@@ -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: {
|
package/dist/src/migrate.js
CHANGED
|
@@ -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);
|
package/dist/src/vault.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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