sealkeep 0.11.5 → 0.11.7

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,59 @@
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.11.7 — 2026-09-26 — secret scanning and faster hooks
7
+
8
+ - **Session hooks return after saving the archive request.** Queue-wide
9
+ reconciliation, sealing, indexing, and cloud upload continue in the
10
+ background, keeping large queues from exceeding the agent's hook timeout.
11
+
12
+ - **Local dashboard bootstrap tickets require owner proof.** Browser-supplied
13
+ Fetch Metadata headers no longer mint a bearer ticket; the approved UI
14
+ bootstrap flow remains available.
15
+
16
+ - **New seals can be checked for pasted credentials before they are encrypted
17
+ or uploaded.** The check is off until you turn it on. `sealkeep scan policy
18
+ record` stores findings with the archive. `block-high` refuses the seal
19
+ when a high-confidence finding exists, and it does that for automatic,
20
+ manual, MCP, and streamed seals. Medium findings, including the entropy
21
+ detector, never block. A transcript that changes while it is being scanned
22
+ is not sealed. Other scanner failures stop the seal when the policy is
23
+ `block-high`, or when `sealkeep scan policy fail-closed on` is set.
24
+ Otherwise the seal continues and does not claim it was scanned.
25
+
26
+ - **What is stored, and what can leave the machine.** The archive record keeps
27
+ the detector kind, severity, count, and line numbers. It does not keep the
28
+ matched text or the masked preview. `sealkeep scan` on this machine still
29
+ prints the masked preview. An organization rollup is sent only after
30
+ `sealkeep scan policy rollup on` and `sealkeep scan policy plane <url>`.
31
+ That request contains kind, severity, and count. It does not contain
32
+ transcript text, matched values, paths, or line numbers. The control plane
33
+ keeps those rollups for 90 days. Consent is per machine and is not copied
34
+ when a policy file is imported.
35
+
36
+ - **Patterns and policy can be shared without sharing consent.** Built-in
37
+ detectors stay fixed. `sealkeep scan pattern add <kind> <high|medium> <regex>`
38
+ adds a local detector. High custom findings block only under `block-high`.
39
+ A pattern must compile, must not match the empty string, must include four
40
+ consecutive letters or digits, and must not nest unbounded quantifiers.
41
+ `sealkeep scan policy export` and `import` move the mode, the fail-closed
42
+ flag, and the patterns. Each machine still chooses its own rollup consent
43
+ and control-plane URL. `sealkeep scan policy pull` copies the organization
44
+ policy from that URL and leaves consent unchanged.
45
+
46
+ The detectors are fixed patterns plus an entropy check. They do not prove
47
+ that a transcript contains no secret. A streamed or chunked seal uploads
48
+ the scanned copy, then deletes that copy even if later setup fails. A copy
49
+ left behind by a crash is removed on the next daemon start once its
50
+ process is gone.
51
+
52
+ ## 0.11.6 — 2026-09-25 — reconnect and Recovery Kit limits are separate
53
+
54
+ - After a machine reconnects successfully, the Recovery Kit page says so even
55
+ if a later cloud check is paused by a service limit. It shows when that
56
+ check can be retried and no longer asks for another reconnect that cannot
57
+ lift the limit. The local recovery phrase and existing vault are unchanged.
58
+
6
59
  ## 0.11.5 — 2026-09-23 — compacting is safe, and a lost destination says so
7
60
 
8
61
  - **An agent that has just been compacted is told where the work stands.**
package/CONTROL_PLANE.md CHANGED
@@ -20,6 +20,8 @@ Endpoints:
20
20
  - `POST /v1/archives/leases`, `POST /v1/archives/:id/complete`
21
21
  - `PUT /v1/archives/:id/manifest`, `GET /v1/archives`
22
22
  - `GET /v1/audit`
23
+ - `GET` and `PUT /v1/secret-scan/policy` — mode, fail-closed, and custom patterns. No transcript content.
24
+ - `POST` and `GET /v1/secret-scan/rollups` — detector kind, severity, and count only. Kept for 90 days. Line numbers, paths, and matched text are rejected.
23
25
 
24
26
  ## Authentication
25
27
 
@@ -1,4 +1,4 @@
1
- export type AuditAction = "archive.create" | "index.tokens" | "index.merge" | "archive.restore" | "archive.restore_local" | "archive.sealed_copy_download" | "archive.migrate" | "archive.rewrap" | "archive.prune" | "archive.offload" | "archive.delete" | "archive.copy_delete" | "phrase.rotate" | "password.change" | "archive.share" | "source.reclaim" | "recipient.add" | "recipient.remove" | "upload.verify" | "remote.check" | "retention.approve" | "storage.gdrive_connect" | "storage.target_connect" | "storage.targets_update" | "phrase.reveal" | "recovery.codes_created" | "bridge.share" | "project.sharing" | "project.draft";
1
+ export type AuditAction = "archive.create" | "index.tokens" | "index.merge" | "archive.restore" | "archive.restore_local" | "archive.sealed_copy_download" | "archive.migrate" | "archive.rewrap" | "archive.prune" | "archive.offload" | "archive.delete" | "archive.copy_delete" | "phrase.rotate" | "password.change" | "archive.share" | "source.reclaim" | "recipient.add" | "recipient.remove" | "upload.verify" | "remote.check" | "retention.approve" | "storage.gdrive_connect" | "storage.target_connect" | "storage.targets_update" | "phrase.reveal" | "recovery.codes_created" | "bridge.share" | "project.sharing" | "project.draft" | "secret_scan.rollup";
2
2
  export type LocalAuditEvent = {
3
3
  id: string;
4
4
  at: string;
@@ -13,6 +13,7 @@ import { DEFAULT_CHUNK_BYTES, KEY_BYTES, StreamingSha256, sealChunksToSink, wrap
13
13
  import { frameObject } from "./cloud.js";
14
14
  import { aeadCipher, aeadDecipher } from "../packages/sealkeep-crypto/src/aead.js";
15
15
  import { STORAGE_PREFIX_DEFAULT } from "./branding.js";
16
+ import { assertScanCoversSeal, deliverOrgRollup, discardScanSnapshot, scanBeforeSeal } from "./leakscan.js";
16
17
  import { oneChunk } from "./byte-stream.js";
17
18
  import { paceBackgroundByteStream } from "./background-bandwidth.js";
18
19
  const SEAL_SUITE = "chacha20-poly1305";
@@ -165,93 +166,101 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
165
166
  const source = await stat(absolute).catch(() => null);
166
167
  if (!source || !source.isFile())
167
168
  fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
168
- const phrase = canonicalPhrase(rawPhrase);
169
- const totalBytes = source.size;
170
- // Same override the local seal path honours, for the same reason: proving the
171
- // streamed layout works should not cost tens of megabytes of disk per run.
172
- const chunkOverride = options.chunkBytes;
173
- const chunkBytes = typeof chunkOverride === "number" && Number.isInteger(chunkOverride) && chunkOverride > 0
174
- ? chunkOverride
175
- : DEFAULT_CHUNK_BYTES;
176
- const totalChunks = Math.max(1, Math.ceil(totalBytes / chunkBytes));
177
- // Journal triage, chunk-layout edition. Same drain-the-stack shape as the
178
- // streaming path: resume the newest matching journal or abandon it with the
179
- // reason and look again.
180
- for (;;) {
181
- const journal = await findSpoolForSource(dataDir, absolute);
182
- if (!journal)
183
- break;
184
- if (journal.providerState !== null && !isChunkFolderState(journal.providerState)) {
185
- // A single-object streaming journal for this source belongs to the other
186
- // pipeline; leave it alone and let that pipeline (or --fresh) decide.
187
- break;
188
- }
189
- const abandon = async (reason) => {
190
- await abandonFolderJournal(dataDir, client, journal, reason);
191
- };
192
- if (options.resume === false) {
193
- await abandon("a fresh start was requested (--fresh)");
194
- continue;
195
- }
196
- if (journal.provider !== remoteStorage.provider || journal.bucket !== remoteStorage.bucket || journal.source.totalBytes !== totalBytes) {
197
- await abandon("the source file or the storage target is no longer the one the journal describes");
198
- continue;
169
+ const sealScan = await scanBeforeSeal(dataDir, absolute, options.signal, undefined, source.size, { snapshot: true });
170
+ try {
171
+ const secretScan = sealScan?.metadata;
172
+ const plaintextPath = sealScan?.snapshotPath ?? absolute;
173
+ const phrase = canonicalPhrase(rawPhrase);
174
+ const totalBytes = source.size;
175
+ // Same override the local seal path honours, for the same reason: proving the
176
+ // streamed layout works should not cost tens of megabytes of disk per run.
177
+ const chunkOverride = options.chunkBytes;
178
+ const chunkBytes = typeof chunkOverride === "number" && Number.isInteger(chunkOverride) && chunkOverride > 0
179
+ ? chunkOverride
180
+ : DEFAULT_CHUNK_BYTES;
181
+ const totalChunks = Math.max(1, Math.ceil(totalBytes / chunkBytes));
182
+ // Journal triage, chunk-layout edition. Same drain-the-stack shape as the
183
+ // streaming path: resume the newest matching journal or abandon it with the
184
+ // reason and look again.
185
+ for (;;) {
186
+ const journal = await findSpoolForSource(dataDir, absolute);
187
+ if (!journal)
188
+ break;
189
+ if (journal.providerState !== null && !isChunkFolderState(journal.providerState)) {
190
+ // A single-object streaming journal for this source belongs to the other
191
+ // pipeline; leave it alone and let that pipeline (or --fresh) decide.
192
+ break;
193
+ }
194
+ const abandon = async (reason) => {
195
+ await abandonFolderJournal(dataDir, client, journal, reason);
196
+ };
197
+ if (options.resume === false) {
198
+ await abandon("a fresh start was requested (--fresh)");
199
+ continue;
200
+ }
201
+ if (journal.provider !== remoteStorage.provider || journal.bucket !== remoteStorage.bucket || journal.source.totalBytes !== totalBytes) {
202
+ await abandon("the source file or the storage target is no longer the one the journal describes");
203
+ continue;
204
+ }
205
+ const lock = await acquireSpoolLock(dataDir, journal.archiveId, { now: options.now });
206
+ try {
207
+ return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, plaintextPath, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield, secretScan, sealScan }, journal);
208
+ }
209
+ catch (error) {
210
+ if (!(error instanceof Error) || !/cannot resume/i.test(error.message))
211
+ throw error;
212
+ await abandon(error.message);
213
+ continue;
214
+ }
215
+ finally {
216
+ await lock.release();
217
+ }
199
218
  }
200
- const lock = await acquireSpoolLock(dataDir, journal.archiveId, { now: options.now });
219
+ // Fresh seal.
220
+ const archiveId = randomUUID();
221
+ const createdAt = new Date(options.now ?? Date.now()).toISOString();
222
+ // Sealed vault ⇒ unreadable bucket, everywhere. Sealing the CONTENT while
223
+ // labelling it project/date/session.jsonl would hand every storage provider
224
+ // the story of the work; the readable hierarchy is the dashboard's job,
225
+ // built from local records — and from the encrypted identity in each
226
+ // folder's sidecar when only the bucket survives. `remoteNaming:
227
+ // "readable"` in config.json is the explicit opt-out for people who want
228
+ // their own bucket browsable and accept what that says.
229
+ // Managed vaults skip the naming machinery entirely: the folder is the bare
230
+ // archive id, so the plane's references (`<id>.chunk-000000`) carry nothing
231
+ // but the grouping it could always see. Own buckets keep the hashed (or
232
+ // opted-in readable) project/date/session hierarchy.
233
+ const naming = config.remoteNaming ?? "hashed";
234
+ const folder = remoteStorage.provider === "vaultline" ? archiveId : chunkFolderFor({
235
+ configuredPrefix: remoteStorage.prefix, project: options.project ?? null, createdAt, sourcePath: absolute, archiveId,
236
+ naming, ...(naming === "hashed" ? { namingKey: folderNamingKey(phrase, storageScopeOf(remoteStorage)) } : {})
237
+ });
238
+ // Members of this project are wrapped in here, and nowhere else.
239
+ const recipients = [...configuredRecipients(config, phrase, { project: options.project ?? undefined, projectKey: options.projectKey ?? undefined }), ...(options.teamRecipients ?? [])];
240
+ const archiveKey = randomBytes(KEY_BYTES);
241
+ const noncePrefix = randomBytes(4);
242
+ const lock = await acquireSpoolLock(dataDir, archiveId, { now: options.now });
201
243
  try {
202
- return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield }, journal);
203
- }
204
- catch (error) {
205
- if (!(error instanceof Error) || !/cannot resume/i.test(error.message))
206
- throw error;
207
- await abandon(error.message);
208
- continue;
244
+ await createSpool(dataDir, {
245
+ archiveId, suite: SEAL_SUITE, provider: remoteStorage.provider, bucket: remoteStorage.bucket, objectKey: folder,
246
+ source: { path: absolute, totalBytes, chunkBytes },
247
+ wrappedKeys: wrapAll(archiveKey, recipients, SEAL_SUITE, archiveId),
248
+ noncePrefixB64: noncePrefix.toString("base64"),
249
+ nextChunkIndex: 0,
250
+ consumedPrefixSha256: createHash("sha256").digest("hex"),
251
+ chunkHeaders: [],
252
+ hashState: { plaintext: "", stored: "" },
253
+ providerState: { kind: "chunk-folder", folder, uploaded: 0 }
254
+ }, { now: options.now });
255
+ return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, plaintextPath, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield, secretScan, sealScan }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
209
256
  }
210
257
  finally {
258
+ zeroize(archiveKey);
211
259
  await lock.release();
212
260
  }
213
261
  }
214
- // Fresh seal.
215
- const archiveId = randomUUID();
216
- const createdAt = new Date(options.now ?? Date.now()).toISOString();
217
- // Sealed vault ⇒ unreadable bucket, everywhere. Sealing the CONTENT while
218
- // labelling it project/date/session.jsonl would hand every storage provider
219
- // the story of the work; the readable hierarchy is the dashboard's job,
220
- // built from local records — and from the encrypted identity in each
221
- // folder's sidecar when only the bucket survives. `remoteNaming:
222
- // "readable"` in config.json is the explicit opt-out for people who want
223
- // their own bucket browsable and accept what that says.
224
- // Managed vaults skip the naming machinery entirely: the folder is the bare
225
- // archive id, so the plane's references (`<id>.chunk-000000`) carry nothing
226
- // but the grouping it could always see. Own buckets keep the hashed (or
227
- // opted-in readable) project/date/session hierarchy.
228
- const naming = config.remoteNaming ?? "hashed";
229
- const folder = remoteStorage.provider === "vaultline" ? archiveId : chunkFolderFor({
230
- configuredPrefix: remoteStorage.prefix, project: options.project ?? null, createdAt, sourcePath: absolute, archiveId,
231
- naming, ...(naming === "hashed" ? { namingKey: folderNamingKey(phrase, storageScopeOf(remoteStorage)) } : {})
232
- });
233
- // Members of this project are wrapped in here, and nowhere else.
234
- const recipients = [...configuredRecipients(config, phrase, { project: options.project ?? undefined, projectKey: options.projectKey ?? undefined }), ...(options.teamRecipients ?? [])];
235
- const archiveKey = randomBytes(KEY_BYTES);
236
- const noncePrefix = randomBytes(4);
237
- const lock = await acquireSpoolLock(dataDir, archiveId, { now: options.now });
238
- try {
239
- await createSpool(dataDir, {
240
- archiveId, suite: SEAL_SUITE, provider: remoteStorage.provider, bucket: remoteStorage.bucket, objectKey: folder,
241
- source: { path: absolute, totalBytes, chunkBytes },
242
- wrappedKeys: wrapAll(archiveKey, recipients, SEAL_SUITE, archiveId),
243
- noncePrefixB64: noncePrefix.toString("base64"),
244
- nextChunkIndex: 0,
245
- consumedPrefixSha256: createHash("sha256").digest("hex"),
246
- chunkHeaders: [],
247
- hashState: { plaintext: "", stored: "" },
248
- providerState: { kind: "chunk-folder", folder, uploaded: 0 }
249
- }, { now: options.now });
250
- return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
251
- }
252
262
  finally {
253
- zeroize(archiveKey);
254
- await lock.release();
263
+ await discardScanSnapshot(sealScan);
255
264
  }
256
265
  }
257
266
  function isChunkFolderState(state) {
@@ -369,7 +378,7 @@ async function runChunkSeal(ctx, args) {
369
378
  pending.set(index, task);
370
379
  }
371
380
  };
372
- const sealed = await sealChunksToSink({ path: ctx.absolute, end: ctx.totalBytes }, sink, {
381
+ const sealed = await sealChunksToSink({ path: ctx.plaintextPath, end: ctx.totalBytes }, sink, {
373
382
  recipients, archiveId: args.archiveId, adapter: { agent: ctx.agent, version: ADAPTER_VERSION },
374
383
  suite: SEAL_SUITE, chunkBytes: ctx.chunkBytes, archiveKey: args.archiveKey, noncePrefix: args.noncePrefix,
375
384
  hashers, createdAt: args.createdAt,
@@ -419,6 +428,17 @@ async function runChunkSeal(ctx, args) {
419
428
  await recordAudit(ctx.dataDir, "upload.verify", "denied", { archiveId: args.archiveId, objectKey: args.folder, problems: problems.length });
420
429
  fail("ciphertext_integrity_failed", `Refusing to record the chunked archive as durable: ${problems.join("; ")}. The journal was kept; re-running resumes what is already there.`, { archiveId: args.archiveId, objectKey: args.folder });
421
430
  }
431
+ try {
432
+ assertScanCoversSeal(ctx.sealScan, sealed.originalSha256, ctx.absolute);
433
+ }
434
+ catch (error) {
435
+ for (let index = 0; index < envelope.chunks.length; index += 1) {
436
+ await ctx.client.deleteObject(`${args.folder}/${chunkObjectName(index)}`).catch(() => undefined);
437
+ }
438
+ await ctx.client.deleteObject(`${args.folder}/${ENVELOPE_OBJECT}`).catch(() => undefined);
439
+ await shredSpool(ctx.dataDir, args.archiveId);
440
+ throw error;
441
+ }
422
442
  const now = new Date(ctx.now ?? Date.now()).toISOString();
423
443
  const remote = {
424
444
  provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder,
@@ -436,7 +456,8 @@ async function runChunkSeal(ctx, args) {
436
456
  envelope,
437
457
  objectPath: join(config.storage.root, `${args.archiveId}.skarchive`),
438
458
  remote,
439
- offloaded: { at: now, provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder }
459
+ offloaded: { at: now, provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder },
460
+ ...(ctx.secretScan ? { secretScan: ctx.secretScan } : {})
440
461
  };
441
462
  const record = await mutateArchiveRecord(config.storage.root, args.archiveId, (current) => {
442
463
  if (!isV2(current))
@@ -448,7 +469,8 @@ async function runChunkSeal(ctx, args) {
448
469
  const { remote: _remote, copies: _copies, ...completedFacts } = completed;
449
470
  return appendArchiveCopy({ ...current, ...completedFacts }, remote, { makePrimary: true });
450
471
  }, { initial: completed });
451
- await recordAudit(ctx.dataDir, "archive.create", "allowed", { archiveId: args.archiveId, agent: ctx.agent, bytes: ctx.totalBytes, recipients: envelope.wrappedKeys.length, chunked: true, reused: args.reusedChunks });
472
+ await recordAudit(ctx.dataDir, "archive.create", "allowed", { archiveId: args.archiveId, agent: ctx.agent, bytes: ctx.totalBytes, recipients: envelope.wrappedKeys.length, chunked: true, reused: args.reusedChunks, secretFindings: ctx.secretScan?.findings.reduce((total, item) => total + item.count, 0) ?? null });
473
+ await deliverOrgRollup(ctx.dataDir, args.archiveId, ctx.secretScan);
452
474
  await recordAudit(ctx.dataDir, "upload.verify", "allowed", { archiveId: args.archiveId, objectKey: args.folder, bytes: sealed.storedBytes, provider: ctx.remoteStorage.provider, chunked: true });
453
475
  await shredSpool(ctx.dataDir, args.archiveId);
454
476
  // The tokens collected during the seal become the archive's index entry —
package/dist/src/cli.js CHANGED
@@ -39,7 +39,7 @@ import { connectGdrive } from "./providers/gdrive.js";
39
39
  import { resolveRecoveryPhrase } from "./secrets.js";
40
40
  import { amber, bold, blue, bytes, callout, command as cmd, dim, divider, green, heading, hint, keyValue, mark, relativeTime, shortPath, steps, table } from "./ui.js";
41
41
  import { envVar, PREFIX as ENV_PREFIX } from "./env.js";
42
- import { automaticTranscriptIsEnabled, readLocalSettings } from "./machine-settings.js";
42
+ import { automaticTranscriptIsEnabled, readLocalSettings, writeLocalSettings } from "./machine-settings.js";
43
43
  import { normalizeDarwinServicePolicy, prepareLegacyDarwinManagerHandoff } from "./darwin-service-policy.js";
44
44
  const VALUE_FLAGS = new Set(["--data-dir", "--recovery-phrase", "--agent", "--home", "--limit", "--executable", "--provider", "--bucket", "--prefix", "--region", "--older-than-days", "--status", "--max", "--port", "--ui-port", "--overwrite", "--label", "--public-key", "--config-id", "--backend", "--endpoint", "--policy", "--grace-days", "--interval", "--manifest", "--artifact", "--key", "--current", "--api", "--token", "--group", "--cli-path", "--account-id", "--project", "--out", "--password", "--kind", "--text", "--channel", "--reword", "--mode", "--session", "--mirror", "--interval-ms", "--from-seq", "--from-byte", "--max-gb", "--priority", "--projects", "--id", "--email", "--code", "--passcode"]);
45
45
  function take(args, flag, fallback) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : fallback; }
@@ -205,7 +205,7 @@ function usage() {
205
205
  ["archive <file>", "seal one file into the vault by hand"],
206
206
  ["archive <file> --stream", "seal straight into your bucket, no local ciphertext"],
207
207
  ["archive <file> --stream --fresh", "abandon an interrupted streamed upload and start over"],
208
- ["scan <path>", "find pasted secrets in transcripts before they are sealed in"],
208
+ ["scan <path> · scan policy off|record|block-high", "find pasted secrets; record or block them when a session is sealed"],
209
209
  ["list", "browse archives"],
210
210
  ["search <query>", "search metadata, or contents with --content"],
211
211
  ["why <commit>", "the session — and the reasoning — behind a git commit"],
@@ -1462,9 +1462,150 @@ async function main() {
1462
1462
  * machine — see leakscan.ts for why it cannot.
1463
1463
  */
1464
1464
  if (command === "scan") {
1465
+ const scanArgs = positionals(args);
1466
+ if (scanArgs[0] === "policy") {
1467
+ const { sharedScanPolicy, validateCustomPattern } = await import("./leakscan.js");
1468
+ const settings = await readLocalSettings(dataDir);
1469
+ const requested = scanArgs[1];
1470
+ if (!requested) {
1471
+ print(`\n Seal-time secret scan: ${bold(settings.secretScanPolicy)} ${dim("· machine-local")}`);
1472
+ print(` Fail closed on scanner errors: ${bold(settings.secretScanFailClosed ? "on" : "off")} ${dim("· block-high always fails closed")}`);
1473
+ print(` Organization rollup: ${bold(settings.secretScanOrgRollup ? "on" : "off")} ${dim(settings.secretScanControlPlaneUrl ? `· ${settings.secretScanControlPlaneUrl}` : "· no control plane configured")}`);
1474
+ print(` Custom patterns: ${bold(String(settings.secretScanPatterns.length))}`);
1475
+ print(` ${dim("Modes: off · record · block-high. Shared files carry mode, fail-closed, and patterns. Consent stays on this machine.")}\n`);
1476
+ return;
1477
+ }
1478
+ if (requested === "off" || requested === "record" || requested === "block-high") {
1479
+ await writeLocalSettings(dataDir, { ...settings, secretScanPolicy: requested });
1480
+ print(`\n ${mark.ok()} Seal-time secret scan set to ${bold(requested)} ${dim("· every new local, MCP, and streamed seal")}\n`);
1481
+ return;
1482
+ }
1483
+ if (requested === "fail-closed") {
1484
+ const value = scanArgs[2];
1485
+ if (value !== "on" && value !== "off")
1486
+ fail("invalid_argument", "Usage: sealkeep scan policy fail-closed on|off");
1487
+ await writeLocalSettings(dataDir, { ...settings, secretScanFailClosed: value === "on" });
1488
+ print(`\n ${mark.ok()} Scanner errors ${bold(value === "on" ? "fail closed" : "do not block")} ${dim("· block-high still fails closed")}\n`);
1489
+ return;
1490
+ }
1491
+ if (requested === "rollup") {
1492
+ const value = scanArgs[2];
1493
+ if (value !== "on" && value !== "off")
1494
+ fail("invalid_argument", "Usage: sealkeep scan policy rollup on|off");
1495
+ await writeLocalSettings(dataDir, { ...settings, secretScanOrgRollup: value === "on" });
1496
+ print(`\n ${mark.ok()} Organization rollup ${bold(value)} ${dim("· kinds, severities, and counts only; line numbers stay here")}\n`);
1497
+ return;
1498
+ }
1499
+ if (requested === "plane") {
1500
+ const value = scanArgs[2];
1501
+ if (!value || value === "off") {
1502
+ await writeLocalSettings(dataDir, { ...settings, secretScanControlPlaneUrl: null });
1503
+ print(`\n ${mark.ok()} Control plane cleared. Rollups stay on this machine.\n`);
1504
+ return;
1505
+ }
1506
+ let origin;
1507
+ try {
1508
+ origin = new URL(value).origin;
1509
+ }
1510
+ catch {
1511
+ fail("invalid_argument", "Usage: sealkeep scan policy plane <https://control-plane> | off");
1512
+ }
1513
+ await writeLocalSettings(dataDir, { ...settings, secretScanControlPlaneUrl: origin });
1514
+ print(`\n ${mark.ok()} Control plane set to ${bold(origin)}\n`);
1515
+ return;
1516
+ }
1517
+ if (requested === "export") {
1518
+ print(JSON.stringify(sharedScanPolicy(settings), null, 2));
1519
+ return;
1520
+ }
1521
+ if (requested === "import") {
1522
+ const file = required(scanArgs[2], "Usage: sealkeep scan policy import <file>");
1523
+ const { readFile } = await import("node:fs/promises");
1524
+ const parsed = JSON.parse(await readFile(file, "utf8"));
1525
+ if (parsed.secretScanPolicy !== "off" && parsed.secretScanPolicy !== "record" && parsed.secretScanPolicy !== "block-high") {
1526
+ fail("invalid_argument", "Policy file needs secretScanPolicy of off, record, or block-high.");
1527
+ }
1528
+ const patterns = Array.isArray(parsed.secretScanPatterns) ? parsed.secretScanPatterns.map((pattern) => validateCustomPattern(pattern)) : [];
1529
+ await writeLocalSettings(dataDir, {
1530
+ ...settings,
1531
+ secretScanPolicy: parsed.secretScanPolicy,
1532
+ secretScanFailClosed: parsed.secretScanFailClosed === true,
1533
+ secretScanPatterns: patterns
1534
+ });
1535
+ print(`\n ${mark.ok()} Imported scan policy ${bold(parsed.secretScanPolicy)} with ${patterns.length} custom pattern${patterns.length === 1 ? "" : "s"}. ${dim("Rollup consent was left as it was.")}\n`);
1536
+ return;
1537
+ }
1538
+ if (requested === "pull") {
1539
+ if (!settings.secretScanControlPlaneUrl)
1540
+ fail("invalid_argument", "Set a control plane first: sealkeep scan policy plane <url>");
1541
+ const { readControlPlaneDevice } = await import("./leakscan.js");
1542
+ const { signedControlPlaneFetch } = await import("./control-plane/auth.js");
1543
+ const device = await readControlPlaneDevice(dataDir);
1544
+ if (!device)
1545
+ fail("unauthorized", "This machine has no control-plane device key. Import one before pulling a policy.");
1546
+ const response = await signedControlPlaneFetch({
1547
+ origin: settings.secretScanControlPlaneUrl,
1548
+ method: "GET",
1549
+ path: "/v1/secret-scan/policy",
1550
+ deviceId: device.deviceId,
1551
+ privateKey: device.privateKeyPem
1552
+ });
1553
+ if (!response.ok)
1554
+ fail("cloud_account_unavailable", `Control plane returned ${response.status}`);
1555
+ const parsed = await response.json();
1556
+ if (parsed.mode !== "off" && parsed.mode !== "record" && parsed.mode !== "block-high")
1557
+ fail("invalid_argument", "Control plane policy was not usable.");
1558
+ const patterns = Array.isArray(parsed.patterns) ? parsed.patterns.map((pattern) => validateCustomPattern(pattern)) : [];
1559
+ await writeLocalSettings(dataDir, { ...settings, secretScanPolicy: parsed.mode, secretScanFailClosed: parsed.failClosed === true, secretScanPatterns: patterns });
1560
+ print(`\n ${mark.ok()} Pulled scan policy ${bold(parsed.mode)}. ${dim("This machine's rollup consent did not change.")}\n`);
1561
+ return;
1562
+ }
1563
+ fail("invalid_argument", "Usage: sealkeep scan policy [off|record|block-high|fail-closed on|off|rollup on|off|plane <url>|export|import <file>|pull]");
1564
+ }
1565
+ if (scanArgs[0] === "pattern") {
1566
+ const { validateCustomPattern } = await import("./leakscan.js");
1567
+ const settings = await readLocalSettings(dataDir);
1568
+ const action = scanArgs[1];
1569
+ if (action === "list" || !action) {
1570
+ if (json) {
1571
+ print(JSON.stringify(settings.secretScanPatterns, null, 2));
1572
+ return;
1573
+ }
1574
+ print(`\n${heading("Custom secret patterns")}`);
1575
+ print(table(settings.secretScanPatterns, [
1576
+ { header: "kind", get: (pattern) => pattern.kind },
1577
+ { header: "severity", get: (pattern) => pattern.severity },
1578
+ { header: "pattern", get: (pattern) => dim(pattern.source) }
1579
+ ], "None. Built-in detectors still run when scanning is on."));
1580
+ print("");
1581
+ return;
1582
+ }
1583
+ if (action === "remove") {
1584
+ const kind = required(scanArgs[2], "Usage: sealkeep scan pattern remove <kind>");
1585
+ await writeLocalSettings(dataDir, { ...settings, secretScanPatterns: settings.secretScanPatterns.filter((pattern) => pattern.kind !== kind) });
1586
+ print(`\n ${mark.ok()} Removed ${bold(kind)}\n`);
1587
+ return;
1588
+ }
1589
+ if (action === "add") {
1590
+ const kind = required(scanArgs[2], "Usage: sealkeep scan pattern add <kind> <high|medium> <regex>");
1591
+ const severity = scanArgs[3];
1592
+ const source = scanArgs[4];
1593
+ if ((severity !== "high" && severity !== "medium") || !source)
1594
+ fail("invalid_argument", "Usage: sealkeep scan pattern add <kind> <high|medium> <regex>");
1595
+ const pattern = validateCustomPattern({ kind, severity, source });
1596
+ if (settings.secretScanPatterns.some((existing) => existing.kind === pattern.kind))
1597
+ fail("invalid_argument", `A pattern named ${pattern.kind} already exists.`);
1598
+ if (settings.secretScanPatterns.length >= 32)
1599
+ fail("invalid_argument", "This machine already has 32 custom patterns.");
1600
+ await writeLocalSettings(dataDir, { ...settings, secretScanPatterns: [...settings.secretScanPatterns, pattern] });
1601
+ print(`\n ${mark.ok()} Added ${bold(pattern.kind)} as ${pattern.severity}. ${dim("High findings block only when the policy is block-high.")}\n`);
1602
+ return;
1603
+ }
1604
+ fail("invalid_argument", "Usage: sealkeep scan pattern list|add|remove");
1605
+ }
1465
1606
  const { scanPath } = await import("./leakscan.js");
1466
- const target = required(positionals(args)[0], "Usage: sealkeep scan <file-or-directory> [--json]");
1467
- const report = await scanPath(target);
1607
+ const target = required(scanArgs[0], "Usage: sealkeep scan <file-or-directory> [--json]");
1608
+ const report = await scanPath(target, undefined, (await readLocalSettings(dataDir)).secretScanPatterns);
1468
1609
  if (json) {
1469
1610
  print(JSON.stringify(report, null, 2));
1470
1611
  }
@@ -2225,7 +2366,11 @@ async function main() {
2225
2366
  const event = await hookEventFromStdin(agent, raw);
2226
2367
  const local = await readLocalSettings(dataDir);
2227
2368
  if (automaticTranscriptIsEnabled(local, agent, event.sourcePath)) {
2228
- await new ArchiveQueue(dataDir).enqueue({ sourcePath: event.sourcePath, agent, event: event.event, sessionId: event.sessionId });
2369
+ // Persist the archive intent and return to the agent immediately.
2370
+ // Queue-wide supersession, sealing, indexing and cloud upload belong
2371
+ // to the daemon; doing the queue scan here made SessionEnd exceed the
2372
+ // agent's three-second hook budget on mature vaults.
2373
+ await new ArchiveQueue(dataDir).enqueue({ sourcePath: event.sourcePath, agent, event: event.event, sessionId: event.sessionId }, { deferSupersede: true });
2229
2374
  }
2230
2375
  }
2231
2376
  catch {
@@ -46,6 +46,15 @@ export declare function signRequest(input: {
46
46
  timestamp?: string;
47
47
  nonce: string;
48
48
  }): Record<string, string>;
49
+ /** One signed control-plane call. Unsigned fetches are not a client of this plane. */
50
+ export declare function signedControlPlaneFetch(input: {
51
+ origin: string;
52
+ method: "GET" | "POST" | "PUT";
53
+ path: string;
54
+ deviceId: string;
55
+ privateKey: KeyObject | string;
56
+ body?: string;
57
+ }): Promise<Response>;
49
58
  export type AuthFailure = {
50
59
  ok: false;
51
60
  status: number;
@@ -1,4 +1,4 @@
1
- import { createHash, createPublicKey, sign as edSign, verify as edVerify, timingSafeEqual } from "node:crypto";
1
+ import { createHash, createPrivateKey, createPublicKey, randomUUID, sign as edSign, verify as edVerify, timingSafeEqual } from "node:crypto";
2
2
  /**
3
3
  * Device authentication for the control plane.
4
4
  *
@@ -49,7 +49,7 @@ export function signingString(method, path, timestamp, nonce, body, prefix = SIG
49
49
  export function signRequest(input) {
50
50
  const timestamp = input.timestamp ?? new Date().toISOString();
51
51
  const message = signingString(input.method, input.path, timestamp, input.nonce, input.body ?? "");
52
- const key = typeof input.privateKey === "string" ? createPublicKey(input.privateKey) : input.privateKey;
52
+ const key = typeof input.privateKey === "string" ? createPrivateKey(input.privateKey) : input.privateKey;
53
53
  return {
54
54
  [SENDS.device]: input.deviceId,
55
55
  [SENDS.timestamp]: timestamp,
@@ -57,6 +57,23 @@ export function signRequest(input) {
57
57
  [SENDS.signature]: edSign(null, Buffer.from(message), key).toString("base64")
58
58
  };
59
59
  }
60
+ /** One signed control-plane call. Unsigned fetches are not a client of this plane. */
61
+ export async function signedControlPlaneFetch(input) {
62
+ const body = input.body ?? "";
63
+ const headers = {
64
+ ...signRequest({
65
+ method: input.method,
66
+ path: input.path,
67
+ deviceId: input.deviceId,
68
+ privateKey: input.privateKey,
69
+ body,
70
+ nonce: randomUUID()
71
+ })
72
+ };
73
+ if (body)
74
+ headers["content-type"] = "application/json";
75
+ return fetch(new URL(input.path, input.origin), { method: input.method, headers, body: body || undefined });
76
+ }
60
77
  export class NonceCache {
61
78
  windowMs;
62
79
  limit;
@@ -29,6 +29,25 @@ const completeSchema = z.object({
29
29
  // The manifest is opaque ciphertext. A strict schema is what stops a future caller
30
30
  // from "temporarily" posting plaintext transcript fields to this endpoint.
31
31
  const manifestSchema = z.object({ ciphertext: z.string().min(1).max(256 * 1024) }).strict();
32
+ const rollupSchema = z.object({
33
+ version: z.literal(1),
34
+ archiveId: z.string().uuid(),
35
+ scannedAt: z.string().datetime(),
36
+ counts: z.array(z.object({
37
+ kind: z.string().regex(/^[a-z][a-z0-9-]{1,40}$/),
38
+ severity: z.enum(["high", "medium"]),
39
+ count: z.number().int().positive().max(10_000)
40
+ })).max(64)
41
+ }).strict();
42
+ const scanPolicySchema = z.object({
43
+ mode: z.enum(["off", "record", "block-high"]),
44
+ failClosed: z.boolean(),
45
+ patterns: z.array(z.object({
46
+ kind: z.string().regex(/^[a-z][a-z0-9-]{1,40}$/),
47
+ severity: z.enum(["high", "medium"]),
48
+ source: z.string().min(4).max(200)
49
+ })).max(32)
50
+ }).strict();
32
51
  const enrollSchema = z.object({
33
52
  accountId: z.string().uuid(),
34
53
  enrollmentToken: z.string().min(8).max(200),
@@ -255,6 +274,34 @@ export function createControlPlaneServer(options = {}) {
255
274
  status = 200;
256
275
  return json(response, 200, store.archivesFor(device.accountId).map(({ archiveId, bytes, objectKey, durableAt }) => ({ archiveId, bytes, objectKey, durableAt: durableAt ?? null })));
257
276
  }
277
+ if (method === "GET" && url.pathname === "/v1/secret-scan/policy") {
278
+ status = 200;
279
+ const policy = store.secretScanPolicy(device.accountId);
280
+ return json(response, 200, policy
281
+ ? { version: 1, mode: policy.mode, failClosed: policy.failClosed, patterns: policy.patterns, updatedAt: policy.updatedAt }
282
+ : { version: 1, mode: "off", failClosed: false, patterns: [], updatedAt: null });
283
+ }
284
+ if (method === "PUT" && url.pathname === "/v1/secret-scan/policy") {
285
+ const input = parse(scanPolicySchema, body);
286
+ const saved = store.setSecretScanPolicy({ accountId: device.accountId, updatedAt: new Date(now()).toISOString(), mode: input.mode, failClosed: input.failClosed, patterns: input.patterns });
287
+ store.audit({ accountId: device.accountId, deviceId: device.id, action: "secret-scan.policy", outcome: "allowed", detail: { mode: saved.mode, patterns: saved.patterns.length } });
288
+ status = 200;
289
+ return json(response, 200, { version: 1, mode: saved.mode, failClosed: saved.failClosed, patterns: saved.patterns, updatedAt: saved.updatedAt });
290
+ }
291
+ if (method === "POST" && url.pathname === "/v1/secret-scan/rollups") {
292
+ const input = parse(rollupSchema, body);
293
+ const saved = store.addSecretScanRollup({
294
+ id: randomUUID(), accountId: device.accountId, deviceId: device.id, archiveId: input.archiveId,
295
+ scannedAt: input.scannedAt, receivedAt: new Date(now()).toISOString(), counts: input.counts
296
+ }, now());
297
+ store.audit({ accountId: device.accountId, deviceId: device.id, action: "secret-scan.rollup", outcome: "allowed", detail: { archiveId: input.archiveId, counts: input.counts.reduce((total, item) => total + item.count, 0) } });
298
+ status = 201;
299
+ return json(response, 201, { id: saved.id, archiveId: saved.archiveId, counts: saved.counts });
300
+ }
301
+ if (method === "GET" && url.pathname === "/v1/secret-scan/rollups") {
302
+ status = 200;
303
+ return json(response, 200, store.secretScanRollups(device.accountId).map(({ archiveId, scannedAt, receivedAt, counts }) => ({ archiveId, scannedAt, receivedAt, counts })));
304
+ }
258
305
  if (method === "GET" && url.pathname === "/v1/audit") {
259
306
  status = 200;
260
307
  return json(response, 200, store.auditEvents().filter((event) => !event.accountId || event.accountId === device.accountId).slice(-200));