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.
@@ -66,6 +66,31 @@ export type AuditEvent = {
66
66
  outcome: "allowed" | "denied";
67
67
  detail?: Record<string, string | number>;
68
68
  };
69
+ export type SecretScanCount = {
70
+ kind: string;
71
+ severity: "high" | "medium";
72
+ count: number;
73
+ };
74
+ export type SecretScanRollup = {
75
+ id: string;
76
+ accountId: string;
77
+ deviceId: string;
78
+ archiveId: string;
79
+ scannedAt: string;
80
+ receivedAt: string;
81
+ counts: SecretScanCount[];
82
+ };
83
+ export type SecretScanPolicy = {
84
+ accountId: string;
85
+ updatedAt: string;
86
+ mode: "off" | "record" | "block-high";
87
+ failClosed: boolean;
88
+ patterns: Array<{
89
+ kind: string;
90
+ severity: "high" | "medium";
91
+ source: string;
92
+ }>;
93
+ };
69
94
  export declare class ControlPlaneStore {
70
95
  private readonly path?;
71
96
  private document;
@@ -98,4 +123,11 @@ export declare class ControlPlaneStore {
98
123
  at?: string;
99
124
  }): AuditEvent;
100
125
  usedBytes(accountId: string): number;
126
+ /** 90 days. Rollups are counts only. Reads and writes both drop expired rows. */
127
+ static readonly ROLLUP_RETENTION_MS: number;
128
+ private freshRollups;
129
+ secretScanPolicy(accountId: string): SecretScanPolicy | undefined;
130
+ setSecretScanPolicy(policy: SecretScanPolicy): SecretScanPolicy;
131
+ addSecretScanRollup(rollup: SecretScanRollup, now?: number): SecretScanRollup;
132
+ secretScanRollups(accountId: string, now?: number): SecretScanRollup[];
101
133
  }
@@ -79,4 +79,37 @@ export class ControlPlaneStore {
79
79
  return entry;
80
80
  }
81
81
  usedBytes(accountId) { return this.archivesFor(accountId).filter((item) => item.durableAt).reduce((total, item) => total + item.bytes, 0); }
82
+ /** 90 days. Rollups are counts only. Reads and writes both drop expired rows. */
83
+ static ROLLUP_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
84
+ freshRollups(now = Date.now()) {
85
+ const all = this.document.secretScanRollups ?? [];
86
+ const fresh = all.filter((item) => now - Date.parse(item.receivedAt) <= ControlPlaneStore.ROLLUP_RETENTION_MS);
87
+ if (fresh.length !== all.length) {
88
+ this.document.secretScanRollups = fresh;
89
+ this.persist();
90
+ }
91
+ return fresh;
92
+ }
93
+ secretScanPolicy(accountId) { return (this.document.secretScanPolicies ?? []).find((item) => item.accountId === accountId); }
94
+ setSecretScanPolicy(policy) {
95
+ const policies = this.document.secretScanPolicies ?? [];
96
+ const index = policies.findIndex((item) => item.accountId === policy.accountId);
97
+ if (index >= 0)
98
+ policies[index] = policy;
99
+ else
100
+ policies.push(policy);
101
+ this.document.secretScanPolicies = policies;
102
+ this.persist();
103
+ return policy;
104
+ }
105
+ addSecretScanRollup(rollup, now = Date.now()) {
106
+ const kept = this.freshRollups(now);
107
+ kept.push(rollup);
108
+ this.document.secretScanRollups = kept;
109
+ this.persist();
110
+ return rollup;
111
+ }
112
+ secretScanRollups(accountId, now = Date.now()) {
113
+ return this.freshRollups(now).filter((item) => item.accountId === accountId);
114
+ }
82
115
  }
@@ -16,6 +16,7 @@ import { automaticTranscriptIsEnabled, backgroundUploadBytesPerSecond, readLocal
16
16
  import { hasDueAutomaticAgentContextRequest, recoverAutomaticAgentContextClaims, } from "./agent-context.js";
17
17
  import { reconcileTeamHistoryBackfill } from "./team-backfill.js";
18
18
  import { acquireDaemonWorkerLease } from "./daemon-lease.js";
19
+ import { sweepStaleScanSnapshots } from "./leakscan.js";
19
20
  import { createProgressDeadline } from "./progress-deadline.js";
20
21
  import { recoverBackgroundBandwidthLockAtDaemonStartup } from "./background-bandwidth.js";
21
22
  // One definition, shared with the worker's per-job check in src/disk.ts. Two
@@ -29,6 +30,7 @@ import { recoverBackgroundBandwidthLockAtDaemonStartup } from "./background-band
29
30
  * exposed directly so behaviour can be tested without waiting on timers.
30
31
  */
31
32
  export async function startDaemon(dataDir, options) {
33
+ await sweepStaleScanSnapshots(dataDir);
32
34
  // The service manager is not a lock: an in-place upgrade or manager crash
33
35
  // can orphan its child, then launch a replacement. Elect one complete vault
34
36
  // worker before any heartbeat, watcher, queue, index, upload, retention or
@@ -3,7 +3,7 @@
3
3
  * between releases; these codes are a contract for the CLI, local API, and MCP.
4
4
  * Details are operational only: never place plaintext, phrases, or keys here.
5
5
  */
6
- export type SealkeepErrorCode = "invalid_argument" | "vault_not_initialized" | "vault_already_initialized" | "config_unsupported_version" | "recovery_phrase_missing" | "recovery_phrase_mismatch" | "recipient_key_mismatch" | "payment_required" | "archive_not_found" | "source_unreadable" | "destination_exists" | "destination_unwritable" | "insufficient_disk_space" | "scratch_space_busy" | "path_not_permitted" | "native_restore_unavailable" | "ciphertext_integrity_failed" | "plaintext_integrity_failed" | "hook_payload_invalid" | "queue_job_not_found" | "queue_lease_invalid" | "queue_lease_expired" | "queue_job_exhausted" | "daemon_already_running" | "storage_not_configured" | "storage_read_timeout" | "operation_timeout" | "cloud_account_unavailable" | "provider_unsupported" | "signer_not_configured" | "lease_expired" | "upload_incomplete" | "team_state_changed" | "team_membership_revoked" | "rate_limited" | "unauthorized" | "forbidden" | "internal";
6
+ export type SealkeepErrorCode = "invalid_argument" | "vault_not_initialized" | "vault_already_initialized" | "config_unsupported_version" | "recovery_phrase_missing" | "recovery_phrase_mismatch" | "recipient_key_mismatch" | "payment_required" | "archive_not_found" | "source_unreadable" | "secret_scan_blocked" | "secret_scan_failed" | "destination_exists" | "destination_unwritable" | "insufficient_disk_space" | "scratch_space_busy" | "path_not_permitted" | "native_restore_unavailable" | "ciphertext_integrity_failed" | "plaintext_integrity_failed" | "hook_payload_invalid" | "queue_job_not_found" | "queue_lease_invalid" | "queue_lease_expired" | "queue_job_exhausted" | "daemon_already_running" | "storage_not_configured" | "storage_read_timeout" | "operation_timeout" | "cloud_account_unavailable" | "provider_unsupported" | "signer_not_configured" | "lease_expired" | "upload_incomplete" | "team_state_changed" | "team_membership_revoked" | "rate_limited" | "unauthorized" | "forbidden" | "internal";
7
7
  export declare class SealkeepError extends Error {
8
8
  readonly code: SealkeepErrorCode;
9
9
  readonly details: Record<string, unknown>;
@@ -1,3 +1,90 @@
1
+ import { type LocalSettings } from "./machine-settings.js";
2
+ import type { ArchiveSecretScan } from "./types.js";
3
+ export type CustomScanPattern = {
4
+ kind: string;
5
+ severity: "high" | "medium";
6
+ source: string;
7
+ };
8
+ /** Aggregate sent to the control plane. No lines, paths, or matched text. */
9
+ export type OrgSecretRollup = {
10
+ version: 1;
11
+ archiveId: string;
12
+ scannedAt: string;
13
+ counts: Array<{
14
+ kind: string;
15
+ severity: "high" | "medium";
16
+ count: number;
17
+ }>;
18
+ };
19
+ /** Reject patterns that match everything, fail to compile, or can backtrack without bound. */
20
+ export declare function validateCustomPattern(input: CustomScanPattern): CustomScanPattern;
21
+ type CustomHit = {
22
+ kind: string;
23
+ severity: "high" | "medium";
24
+ start: number;
25
+ end: number;
26
+ };
27
+ /** Runs custom detectors off the seal thread and stops them if one line runs long. */
28
+ export declare function matchCustomLine(line: string, patterns: readonly CustomScanPattern[], timeoutMs?: number): Promise<CustomHit[]>;
29
+ export declare function findingsMetadata(findings: readonly Finding[], policy: "record" | "block-high"): ArchiveSecretScan;
30
+ export declare function orgRollupBody(archiveId: string, scan: ArchiveSecretScan): OrgSecretRollup;
31
+ /**
32
+ * The scan result is only valid for the bytes whose SHA-256 it names.
33
+ * `snapshotPath`, when present, is an immutable copy of those bytes. Streamed
34
+ * seals read it instead of the live transcript, so a later edit cannot be
35
+ * uploaded. The caller deletes it when the seal finishes.
36
+ */
37
+ export type SealScan = {
38
+ metadata: ArchiveSecretScan;
39
+ sha256: string;
40
+ snapshotPath?: string;
41
+ };
42
+ /** Removes a scanned snapshot. Safe to call when the scan did not take one. */
43
+ export declare function discardScanSnapshot(scan: SealScan | undefined): Promise<void>;
44
+ /**
45
+ * A crash after the snapshot is written leaves plaintext in scan-snapshots.
46
+ * The name records the creating pid. Startup and the next scanned seal remove
47
+ * a file only when that process is gone, so a live seal keeps its copy.
48
+ */
49
+ export declare function sweepStaleScanSnapshots(dataDir: string): Promise<void>;
50
+ /**
51
+ * Set by tests to change the file after the scan has accepted it and before
52
+ * the seal reads it again. Production leaves this unset.
53
+ */
54
+ export declare const sealScanTestHooks: {
55
+ afterAccepted?: (path: string) => Promise<void>;
56
+ };
57
+ /**
58
+ * Scan before encryption or upload. Returns metadata and the digest of the
59
+ * exact byte range the seal will read, or undefined when the policy is off
60
+ * or a fail-open scanner error skipped it. Throws before any archive exists
61
+ * when the seal must not proceed.
62
+ */
63
+ export declare function scanBeforeSeal(dataDir: string, path: string, signal?: AbortSignal, hooks?: {
64
+ afterRead?: () => Promise<void>;
65
+ }, byteLength?: number, options?: {
66
+ snapshot?: boolean;
67
+ }): Promise<SealScan | undefined>;
68
+ /** Refuse to publish a seal whose plaintext digest is not the digest that was scanned. */
69
+ export declare function assertScanCoversSeal(scan: SealScan | undefined, sealedSha256: string, sourcePath: string): void;
70
+ /** @deprecated Use scanBeforeSeal. Kept so older call sites keep blocking. */
71
+ export declare function assertSecretScanPasses(dataDir: string, path: string, signal?: AbortSignal): Promise<void>;
72
+ export type ControlPlaneDevice = {
73
+ deviceId: string;
74
+ privateKeyPem: string;
75
+ };
76
+ export declare function readControlPlaneDevice(dataDir: string): Promise<ControlPlaneDevice | null>;
77
+ export declare function writeControlPlaneDevice(dataDir: string, device: ControlPlaneDevice): Promise<void>;
78
+ /** Best-effort delivery after a durable seal. A network failure never rolls the archive back. */
79
+ export declare function deliverOrgRollup(dataDir: string, archiveId: string, scan: ArchiveSecretScan | undefined): Promise<void>;
80
+ export type SharedScanPolicy = {
81
+ version: 1;
82
+ secretScanPolicy: LocalSettings["secretScanPolicy"];
83
+ secretScanFailClosed: boolean;
84
+ secretScanPatterns: CustomScanPattern[];
85
+ };
86
+ /** What may move between machines. Consent and the control-plane URL stay local. */
87
+ export declare function sharedScanPolicy(settings: LocalSettings): SharedScanPolicy;
1
88
  export type Finding = {
2
89
  kind: string;
3
90
  /** 1-based, matching what editors and `sed -n` call a line. */
@@ -28,21 +115,26 @@ export type ScanReport = {
28
115
  export declare function maskSecret(secret: string): string;
29
116
  /** Character-frequency Shannon entropy, in bits per character. */
30
117
  export declare function shannonEntropy(value: string): number;
118
+ type Detector = {
119
+ kind: string;
120
+ /** `dg` flags, so matchAll yields index pairs for the secret group. */
121
+ pattern: RegExp;
122
+ /** Capture group holding the secret itself; whole match when omitted. */
123
+ group?: number;
124
+ /** Post-regex gate for shapes a regex alone over-matches. */
125
+ validate?: (candidate: string) => boolean;
126
+ severity?: "high" | "medium";
127
+ };
31
128
  /** Tests derive their vectors from these rather than restating the numbers. */
32
129
  export declare const GENERIC_MIN_LENGTH = 24;
33
130
  export declare const GENERIC_ENTROPY_THRESHOLD = 4.2;
34
- export declare function scanText(text: string): Finding[];
35
- /**
36
- * Line-by-line over a read stream: memory is bounded by the longest single
37
- * line, never by the file, so a multi-GB jsonl scans in the same footprint as
38
- * a small one. An unreadable input rejects — a scanner that swallows a read
39
- * error would be reporting "clean" about bytes it never saw.
40
- */
41
- export declare function scanFileStreaming(path: string, onFinding?: (finding: Finding) => void): Promise<Finding[]>;
131
+ export declare function scanText(text: string, patterns?: readonly CustomScanPattern[]): Finding[];
132
+ export declare function scanFileStreaming(path: string, onFinding?: (finding: Finding) => void, extra?: readonly Detector[]): Promise<Finding[]>;
42
133
  /**
43
134
  * A file is scanned as pointed at, whatever its name; a directory means every
44
135
  * *.jsonl beneath it. Only high findings set the exit code — failing a script
45
136
  * over a "medium" suspicion is exactly the cry-wolf behaviour the severity
46
137
  * split exists to avoid.
47
138
  */
48
- export declare function scanPath(target: string, onFinding?: (finding: FileFinding) => void): Promise<ScanReport>;
139
+ export declare function scanPath(target: string, onFinding?: (finding: FileFinding) => void, patterns?: readonly CustomScanPattern[]): Promise<ScanReport>;
140
+ export {};