@vellumai/vellum-gateway 0.7.0 → 0.7.2
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/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offsite snapshot writer with per-destination encryption.
|
|
3
|
+
*
|
|
4
|
+
* "Offsite" destinations are any location outside the local backup directory
|
|
5
|
+
* where the user wants a redundant copy. Canonical examples: iCloud Drive,
|
|
6
|
+
* an external SSD, a network share.
|
|
7
|
+
*
|
|
8
|
+
* Per-destination `encrypt` flag:
|
|
9
|
+
* - `encrypt: true` → AES-256-GCM stream-encrypt into `.vbundle.enc`.
|
|
10
|
+
* - `encrypt: false` → plaintext copy into `.vbundle`.
|
|
11
|
+
*
|
|
12
|
+
* Each destination is written independently and sequentially.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { copyFile, mkdir, rename, stat, unlink } from "node:fs/promises";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import type { SnapshotEntry } from "./list-snapshots.js";
|
|
19
|
+
import { pruneDir } from "./list-snapshots.js";
|
|
20
|
+
import { formatBackupFilename } from "./paths.js";
|
|
21
|
+
import { encryptFile } from "./stream-crypt.js";
|
|
22
|
+
|
|
23
|
+
export interface BackupDestination {
|
|
24
|
+
path: string;
|
|
25
|
+
encrypt: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OffsiteWriteResult {
|
|
29
|
+
destination: BackupDestination;
|
|
30
|
+
entry: SnapshotEntry | null;
|
|
31
|
+
skipped?: "parent-missing";
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Derive the "safe ancestor" for an offsite destination — a directory that
|
|
37
|
+
* must already exist on disk before we create intermediate directories
|
|
38
|
+
* under it.
|
|
39
|
+
*/
|
|
40
|
+
function deriveSafeAncestor(destinationPath: string): string {
|
|
41
|
+
// iCloud Drive subtrees anchor on the iCloud root
|
|
42
|
+
const home = process.env.HOME || "";
|
|
43
|
+
if (home) {
|
|
44
|
+
const iCloudRoot = join(home, "Library", "Mobile Documents", "com~apple~CloudDocs");
|
|
45
|
+
if (
|
|
46
|
+
destinationPath === iCloudRoot ||
|
|
47
|
+
destinationPath.startsWith(`${iCloudRoot}/`)
|
|
48
|
+
) {
|
|
49
|
+
return iCloudRoot;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// /Volumes/<name>/... paths anchor on the volume mount point
|
|
53
|
+
const volumesPrefix = "/Volumes/";
|
|
54
|
+
if (destinationPath.startsWith(volumesPrefix)) {
|
|
55
|
+
const rest = destinationPath.slice(volumesPrefix.length);
|
|
56
|
+
const slash = rest.indexOf("/");
|
|
57
|
+
const volumeName = slash === -1 ? rest : rest.slice(0, slash);
|
|
58
|
+
if (volumeName.length > 0) {
|
|
59
|
+
return `${volumesPrefix}${volumeName}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return dirname(destinationPath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write a local snapshot to a single offsite destination.
|
|
67
|
+
*/
|
|
68
|
+
export async function writeOffsiteSnapshotToOne(
|
|
69
|
+
localSnapshotPath: string,
|
|
70
|
+
destination: BackupDestination,
|
|
71
|
+
key: Buffer | null,
|
|
72
|
+
now: Date,
|
|
73
|
+
): Promise<OffsiteWriteResult> {
|
|
74
|
+
try {
|
|
75
|
+
try {
|
|
76
|
+
await stat(deriveSafeAncestor(destination.path));
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
79
|
+
return { destination, entry: null, skipped: "parent-missing" };
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await mkdir(destination.path, { recursive: true, mode: 0o700 });
|
|
85
|
+
|
|
86
|
+
const filename = formatBackupFilename(now, {
|
|
87
|
+
encrypted: destination.encrypt,
|
|
88
|
+
});
|
|
89
|
+
const outputPath = join(destination.path, filename);
|
|
90
|
+
|
|
91
|
+
if (destination.encrypt) {
|
|
92
|
+
if (key == null) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"Offsite destination requires encryption but no key was provided",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
await encryptFile(localSnapshotPath, outputPath, key);
|
|
98
|
+
} else {
|
|
99
|
+
const tempPath = `${outputPath}.tmp`;
|
|
100
|
+
await copyFile(localSnapshotPath, tempPath);
|
|
101
|
+
try {
|
|
102
|
+
await rename(tempPath, outputPath);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
|
105
|
+
await copyFile(tempPath, outputPath);
|
|
106
|
+
await unlink(tempPath);
|
|
107
|
+
} else {
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const stats = await stat(outputPath);
|
|
114
|
+
return {
|
|
115
|
+
destination,
|
|
116
|
+
entry: {
|
|
117
|
+
path: outputPath,
|
|
118
|
+
filename,
|
|
119
|
+
createdAt: now,
|
|
120
|
+
sizeBytes: stats.size,
|
|
121
|
+
encrypted: destination.encrypt,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
126
|
+
return { destination, entry: null, error: message };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Write a local snapshot to every configured offsite destination.
|
|
132
|
+
*/
|
|
133
|
+
export async function writeOffsiteSnapshotToAll(
|
|
134
|
+
localSnapshotPath: string,
|
|
135
|
+
destinations: BackupDestination[],
|
|
136
|
+
key: Buffer | null,
|
|
137
|
+
now: Date,
|
|
138
|
+
): Promise<OffsiteWriteResult[]> {
|
|
139
|
+
if (destinations.length === 0) return [];
|
|
140
|
+
|
|
141
|
+
const results: OffsiteWriteResult[] = [];
|
|
142
|
+
for (const destination of destinations) {
|
|
143
|
+
const result = await writeOffsiteSnapshotToOne(
|
|
144
|
+
localSnapshotPath,
|
|
145
|
+
destination,
|
|
146
|
+
key,
|
|
147
|
+
now,
|
|
148
|
+
);
|
|
149
|
+
results.push(result);
|
|
150
|
+
}
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Apply retention to every configured offsite destination.
|
|
156
|
+
*/
|
|
157
|
+
export async function pruneOffsiteSnapshotsInAll(
|
|
158
|
+
destinations: BackupDestination[],
|
|
159
|
+
retention: number,
|
|
160
|
+
): Promise<
|
|
161
|
+
Array<{
|
|
162
|
+
destination: BackupDestination;
|
|
163
|
+
kept: SnapshotEntry[];
|
|
164
|
+
deleted: SnapshotEntry[];
|
|
165
|
+
skipped?: boolean;
|
|
166
|
+
}>
|
|
167
|
+
> {
|
|
168
|
+
const results: Array<{
|
|
169
|
+
destination: BackupDestination;
|
|
170
|
+
kept: SnapshotEntry[];
|
|
171
|
+
deleted: SnapshotEntry[];
|
|
172
|
+
skipped?: boolean;
|
|
173
|
+
}> = [];
|
|
174
|
+
for (const destination of destinations) {
|
|
175
|
+
const { kept, deleted, skipped } = await pruneDir(
|
|
176
|
+
destination.path,
|
|
177
|
+
retention,
|
|
178
|
+
);
|
|
179
|
+
results.push({ destination, kept, deleted, skipped });
|
|
180
|
+
}
|
|
181
|
+
return results;
|
|
182
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path and filename helpers for the gateway backup module.
|
|
3
|
+
*
|
|
4
|
+
* The backup key lives in GATEWAY_SECURITY_DIR — outside the workspace
|
|
5
|
+
* and outside the assistant sandbox boundary. The assistant process never
|
|
6
|
+
* has access to this file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { getGatewaySecurityDir } from "../paths.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Key path
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Filename for the backup encryption key inside GATEWAY_SECURITY_DIR. */
|
|
19
|
+
const BACKUP_KEY_FILENAME = "backup.key";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns the path to the backup encryption key file.
|
|
23
|
+
*
|
|
24
|
+
* The key lives in the gateway security directory, which is:
|
|
25
|
+
* - In Docker: a dedicated volume at /gateway-security (GATEWAY_SECURITY_DIR)
|
|
26
|
+
* - Locally: ~/.vellum/protected/
|
|
27
|
+
*
|
|
28
|
+
* This keeps the key outside the workspace — the assistant's sandbox
|
|
29
|
+
* boundary — so model-driven tools (file_read, shell) cannot access it.
|
|
30
|
+
*/
|
|
31
|
+
export function getBackupKeyPath(): string {
|
|
32
|
+
return join(getGatewaySecurityDir(), BACKUP_KEY_FILENAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Backup root + local directory
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns the backup root directory. Respects the `VELLUM_BACKUP_DIR`
|
|
41
|
+
* environment variable override (used in containerized deployments where
|
|
42
|
+
* backups must be on a persistent volume); falls back to `~/.vellum/backups`.
|
|
43
|
+
*/
|
|
44
|
+
export function getBackupRootDir(): string {
|
|
45
|
+
const override = process.env.VELLUM_BACKUP_DIR?.trim();
|
|
46
|
+
return override || join(homedir(), ".vellum", "backups");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns the directory for local (on-device) backups. By default this lives
|
|
51
|
+
* under `~/.vellum/backups/local`; callers can pass an explicit override from
|
|
52
|
+
* config to place backups elsewhere on disk.
|
|
53
|
+
*/
|
|
54
|
+
export function getLocalBackupsDir(override?: string | null): string {
|
|
55
|
+
return override ?? join(getBackupRootDir(), "local");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Backup filenames
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Formats a backup filename from a date. Encrypted backups get a `.vbundle.enc`
|
|
64
|
+
* suffix; plaintext backups get `.vbundle`. Timestamp components are in UTC to
|
|
65
|
+
* avoid timezone-induced filename collisions across devices. Milliseconds are
|
|
66
|
+
* included so two backups started in the same UTC second produce distinct
|
|
67
|
+
* filenames rather than silently overwriting each other.
|
|
68
|
+
*
|
|
69
|
+
* Example: `backup-20260411-153045-123.vbundle`
|
|
70
|
+
*/
|
|
71
|
+
export function formatBackupFilename(
|
|
72
|
+
date: Date,
|
|
73
|
+
{ encrypted }: { encrypted: boolean },
|
|
74
|
+
): string {
|
|
75
|
+
const year = date.getUTCFullYear().toString().padStart(4, "0");
|
|
76
|
+
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
|
|
77
|
+
const day = date.getUTCDate().toString().padStart(2, "0");
|
|
78
|
+
const hour = date.getUTCHours().toString().padStart(2, "0");
|
|
79
|
+
const minute = date.getUTCMinutes().toString().padStart(2, "0");
|
|
80
|
+
const second = date.getUTCSeconds().toString().padStart(2, "0");
|
|
81
|
+
const millis = date.getUTCMilliseconds().toString().padStart(3, "0");
|
|
82
|
+
const ext = encrypted ? ".vbundle.enc" : ".vbundle";
|
|
83
|
+
return `backup-${year}${month}${day}-${hour}${minute}${second}-${millis}${ext}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Matches `backup-YYYYMMDD-HHMMSS` with an optional `-SSS` milliseconds
|
|
87
|
+
// segment (legacy backups written before ms precision was added omit it) and
|
|
88
|
+
// an optional `-<hex>` collision suffix that `writeLocalSnapshot` appends when
|
|
89
|
+
// the canonical name is already taken. Followed by `.vbundle` or
|
|
90
|
+
// `.vbundle.enc`. Kept as a module-level constant so repeated parsing doesn't
|
|
91
|
+
// rebuild the RegExp.
|
|
92
|
+
const BACKUP_FILENAME_RE =
|
|
93
|
+
/^backup-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})(?:-(\d{3})(?:-[0-9a-f]+)?)?\.vbundle(?:\.enc)?$/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inverse of `formatBackupFilename`. Parses a backup filename (with either
|
|
97
|
+
* `.vbundle` or `.vbundle.enc` suffix) and returns the encoded UTC timestamp.
|
|
98
|
+
* Accepts legacy filenames without the `-SSS` milliseconds segment (treated
|
|
99
|
+
* as `.000`). Returns `null` when the filename doesn't match the expected
|
|
100
|
+
* pattern, when a component is out of range, or when the parsed date is
|
|
101
|
+
* invalid.
|
|
102
|
+
*/
|
|
103
|
+
export function parseBackupTimestamp(filename: string): Date | null {
|
|
104
|
+
const match = BACKUP_FILENAME_RE.exec(filename);
|
|
105
|
+
if (!match) return null;
|
|
106
|
+
const [, year, month, day, hour, minute, second, millis] = match;
|
|
107
|
+
const ms = millis ?? "000";
|
|
108
|
+
const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}Z`;
|
|
109
|
+
const date = new Date(iso);
|
|
110
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
111
|
+
if (
|
|
112
|
+
date.getUTCFullYear() !== Number(year) ||
|
|
113
|
+
date.getUTCMonth() !== Number(month) - 1 ||
|
|
114
|
+
date.getUTCDate() !== Number(day) ||
|
|
115
|
+
date.getUTCHours() !== Number(hour) ||
|
|
116
|
+
date.getUTCMinutes() !== Number(minute) ||
|
|
117
|
+
date.getUTCSeconds() !== Number(second) ||
|
|
118
|
+
date.getUTCMilliseconds() !== Number(ms)
|
|
119
|
+
) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return date;
|
|
123
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming AES-256-GCM file encryption/decryption for backup bundles.
|
|
3
|
+
*
|
|
4
|
+
* The on-disk format is:
|
|
5
|
+
*
|
|
6
|
+
* [12-byte IV][ciphertext...][16-byte GCM auth tag]
|
|
7
|
+
*
|
|
8
|
+
* Both encrypt and decrypt use Node streams so peak memory stays bounded
|
|
9
|
+
* regardless of input size. This is important for backup archives which may
|
|
10
|
+
* run to many gigabytes on larger workspaces.
|
|
11
|
+
*
|
|
12
|
+
* The key must be exactly 32 bytes (AES-256). The IV is randomly generated
|
|
13
|
+
* per call, which is required for GCM semantic security — never reuse an
|
|
14
|
+
* IV with the same key.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createCipheriv,
|
|
19
|
+
createDecipheriv,
|
|
20
|
+
randomBytes,
|
|
21
|
+
} from "node:crypto";
|
|
22
|
+
import {
|
|
23
|
+
createReadStream,
|
|
24
|
+
createWriteStream,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { open, rename, stat, unlink } from "node:fs/promises";
|
|
27
|
+
import { Readable, Writable } from "node:stream";
|
|
28
|
+
import { pipeline } from "node:stream/promises";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Size of the AES-GCM initialization vector prefix, in bytes. */
|
|
35
|
+
export const ENCRYPTED_HEADER_SIZE = 12;
|
|
36
|
+
|
|
37
|
+
/** Size of the AES-GCM authentication tag suffix, in bytes. */
|
|
38
|
+
export const GCM_TAG_SIZE = 16;
|
|
39
|
+
|
|
40
|
+
const ALGORITHM = "aes-256-gcm";
|
|
41
|
+
const KEY_LENGTH = 32;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function assertKey(key: Buffer): void {
|
|
48
|
+
if (key.length !== KEY_LENGTH) {
|
|
49
|
+
throw new Error("Backup encryption key must be 32 bytes");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function safeUnlink(path: string): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
await unlink(path);
|
|
56
|
+
} catch {
|
|
57
|
+
// best-effort cleanup — swallow ENOENT and other errors
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tempPath(outputPath: string): string {
|
|
62
|
+
return `${outputPath}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Encrypt
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Stream-encrypt `inputPath` to `outputPath` using AES-256-GCM.
|
|
71
|
+
*
|
|
72
|
+
* Produces `[IV (12 bytes)][ciphertext][auth tag (16 bytes)]` in the output.
|
|
73
|
+
* Writes to a temp file and atomically renames on success; unlinks the temp
|
|
74
|
+
* file on any error so failed writes don't leave partial bundles behind.
|
|
75
|
+
*/
|
|
76
|
+
export async function encryptFile(
|
|
77
|
+
inputPath: string,
|
|
78
|
+
outputPath: string,
|
|
79
|
+
key: Buffer,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
assertKey(key);
|
|
82
|
+
|
|
83
|
+
const iv = randomBytes(ENCRYPTED_HEADER_SIZE);
|
|
84
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
85
|
+
|
|
86
|
+
const tmp = tempPath(outputPath);
|
|
87
|
+
const writeStream = createWriteStream(tmp);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Write IV first so decrypt can read it without knowing the ciphertext size.
|
|
91
|
+
await new Promise<void>((resolve, reject) => {
|
|
92
|
+
writeStream.write(iv, (err) => (err ? reject(err) : resolve()));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Stream plaintext through the cipher into the output.
|
|
96
|
+
const readStream = createReadStream(inputPath);
|
|
97
|
+
await pipeline(readStream, cipher, writeStream, { end: false });
|
|
98
|
+
|
|
99
|
+
// Append the auth tag after the ciphertext body.
|
|
100
|
+
const tag = cipher.getAuthTag();
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
writeStream.write(tag, (err) => (err ? reject(err) : resolve()));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await new Promise<void>((resolve, reject) => {
|
|
106
|
+
writeStream.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await rename(tmp, outputPath);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// Make sure the write stream is closed before we try to unlink the temp file.
|
|
112
|
+
writeStream.destroy();
|
|
113
|
+
await safeUnlink(tmp);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Decrypt
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Stream-decrypt `inputPath` to `outputPath`. Expects the on-disk format
|
|
124
|
+
* produced by `encryptFile`: `[IV][ciphertext][auth tag]`.
|
|
125
|
+
*
|
|
126
|
+
* Reads the IV and auth tag via positional reads, then streams only the
|
|
127
|
+
* ciphertext body through the decipher. Atomic tmp + rename semantics.
|
|
128
|
+
*/
|
|
129
|
+
export async function decryptFile(
|
|
130
|
+
inputPath: string,
|
|
131
|
+
outputPath: string,
|
|
132
|
+
key: Buffer,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
assertKey(key);
|
|
135
|
+
|
|
136
|
+
const info = await stat(inputPath);
|
|
137
|
+
const totalSize = info.size;
|
|
138
|
+
const minSize = ENCRYPTED_HEADER_SIZE + GCM_TAG_SIZE;
|
|
139
|
+
if (totalSize < minSize) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Encrypted file is too small: ${totalSize} bytes (need at least ${minSize})`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Read IV (first 12 bytes) and auth tag (last 16 bytes) via positional reads.
|
|
146
|
+
const iv = Buffer.alloc(ENCRYPTED_HEADER_SIZE);
|
|
147
|
+
const tag = Buffer.alloc(GCM_TAG_SIZE);
|
|
148
|
+
const fh = await open(inputPath, "r");
|
|
149
|
+
try {
|
|
150
|
+
await fh.read(iv, 0, ENCRYPTED_HEADER_SIZE, 0);
|
|
151
|
+
await fh.read(tag, 0, GCM_TAG_SIZE, totalSize - GCM_TAG_SIZE);
|
|
152
|
+
} finally {
|
|
153
|
+
await fh.close();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
157
|
+
decipher.setAuthTag(tag);
|
|
158
|
+
|
|
159
|
+
const ciphertextStart = ENCRYPTED_HEADER_SIZE;
|
|
160
|
+
const ciphertextEnd = totalSize - GCM_TAG_SIZE - 1; // createReadStream end is inclusive
|
|
161
|
+
const hasCiphertext = ciphertextEnd >= ciphertextStart;
|
|
162
|
+
|
|
163
|
+
const tmp = tempPath(outputPath);
|
|
164
|
+
const writeStream = createWriteStream(tmp);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const ciphertextStream = hasCiphertext
|
|
168
|
+
? createReadStream(inputPath, {
|
|
169
|
+
start: ciphertextStart,
|
|
170
|
+
end: ciphertextEnd,
|
|
171
|
+
})
|
|
172
|
+
: Readable.from([]);
|
|
173
|
+
|
|
174
|
+
// pipeline consumes the ciphertext, pushes it through the decipher, and
|
|
175
|
+
// calls decipher.final() at the end — which is where auth tag verification
|
|
176
|
+
// happens. A bad tag surfaces here as a thrown error.
|
|
177
|
+
await pipeline(ciphertextStream, decipher, writeStream);
|
|
178
|
+
|
|
179
|
+
await rename(tmp, outputPath);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
writeStream.destroy();
|
|
182
|
+
await safeUnlink(tmp);
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Verify
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Verify that `path` is a valid AES-256-GCM encrypted bundle for `key`.
|
|
193
|
+
*
|
|
194
|
+
* Streams the ciphertext through the decipher into a null sink and relies on
|
|
195
|
+
* `decipher.final()` to either succeed (tag matches) or throw (tamper / wrong
|
|
196
|
+
* key). No scratch file is written, so a full or read-only tmpdir cannot
|
|
197
|
+
* cause a healthy backup to be reported as invalid.
|
|
198
|
+
*
|
|
199
|
+
* Returns `true` if the bundle authenticates, `false` on a cryptographic
|
|
200
|
+
* failure (bad auth tag, wrong key, truncated/short input). Filesystem errors
|
|
201
|
+
* on the *source* file (ENOENT, EACCES, EIO, …) are rethrown so callers can
|
|
202
|
+
* distinguish tamper from transient I/O.
|
|
203
|
+
*/
|
|
204
|
+
export async function verifyEncryptedFile(
|
|
205
|
+
path: string,
|
|
206
|
+
key: Buffer,
|
|
207
|
+
): Promise<boolean> {
|
|
208
|
+
assertKey(key);
|
|
209
|
+
|
|
210
|
+
const info = await stat(path);
|
|
211
|
+
const totalSize = info.size;
|
|
212
|
+
const minSize = ENCRYPTED_HEADER_SIZE + GCM_TAG_SIZE;
|
|
213
|
+
if (totalSize < minSize) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const iv = Buffer.alloc(ENCRYPTED_HEADER_SIZE);
|
|
218
|
+
const tag = Buffer.alloc(GCM_TAG_SIZE);
|
|
219
|
+
const fh = await open(path, "r");
|
|
220
|
+
try {
|
|
221
|
+
await fh.read(iv, 0, ENCRYPTED_HEADER_SIZE, 0);
|
|
222
|
+
await fh.read(tag, 0, GCM_TAG_SIZE, totalSize - GCM_TAG_SIZE);
|
|
223
|
+
} finally {
|
|
224
|
+
await fh.close();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
228
|
+
decipher.setAuthTag(tag);
|
|
229
|
+
|
|
230
|
+
const ciphertextStart = ENCRYPTED_HEADER_SIZE;
|
|
231
|
+
const ciphertextEnd = totalSize - GCM_TAG_SIZE - 1;
|
|
232
|
+
const hasCiphertext = ciphertextEnd >= ciphertextStart;
|
|
233
|
+
const ciphertextStream = hasCiphertext
|
|
234
|
+
? createReadStream(path, { start: ciphertextStart, end: ciphertextEnd })
|
|
235
|
+
: Readable.from([]);
|
|
236
|
+
|
|
237
|
+
const nullSink = new Writable({
|
|
238
|
+
write(_chunk, _encoding, cb) {
|
|
239
|
+
cb();
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
await pipeline(ciphertextStream, decipher, nullSink);
|
|
245
|
+
return true;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (isFilesystemError(err)) {
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isFilesystemError(err: unknown): boolean {
|
|
255
|
+
if (!err || typeof err !== "object") return false;
|
|
256
|
+
const code = (err as { code?: unknown }).code;
|
|
257
|
+
return typeof code === "string" && /^E[A-Z]+$/.test(code);
|
|
258
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist of known Vellum Chrome extension origins.
|
|
3
|
+
*
|
|
4
|
+
* Chrome extension IDs are deterministic per environment: non-production
|
|
5
|
+
* builds embed a fixed public key in the manifest (injected at build time by
|
|
6
|
+
* `clients/chrome-extension/build.sh`), and the production build uses the
|
|
7
|
+
* stable Chrome Web Store signing key. All four IDs are therefore stable and
|
|
8
|
+
* enumerable — no need for a catch-all regex.
|
|
9
|
+
*
|
|
10
|
+
* The canonical mapping of env → extensionId lives in:
|
|
11
|
+
* clients/chrome-extension/extension-environments.json
|
|
12
|
+
*
|
|
13
|
+
* If a new environment is added or an ID changes, update both files.
|
|
14
|
+
*
|
|
15
|
+
* Format: `chrome-extension://<id>` (the value of the HTTP Origin header sent
|
|
16
|
+
* by the extension's service worker when it makes cross-origin requests to the
|
|
17
|
+
* local gateway).
|
|
18
|
+
*/
|
|
19
|
+
export const KNOWN_EXTENSION_ORIGINS: ReadonlySet<string> = new Set([
|
|
20
|
+
// production (Chrome Web Store)
|
|
21
|
+
"chrome-extension://hphbdmpffeigpcdjkckleobjmhhokpne",
|
|
22
|
+
// dev
|
|
23
|
+
"chrome-extension://kajfcoaefacmjgdaloeafnpcfaeahcio",
|
|
24
|
+
// staging
|
|
25
|
+
"chrome-extension://idpcnibfinmkdhlpenkglianflkbhfim",
|
|
26
|
+
// local
|
|
27
|
+
"chrome-extension://gfcldmjjhcginboeldmknclbjilohcbn",
|
|
28
|
+
]);
|
package/src/cli/enable-proxy.ts
CHANGED
package/src/config-file-cache.ts
CHANGED
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
* is passed to a getter, or when `refreshNow()` is called explicitly.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { getWorkspaceDir } from "./credential-reader.js";
|
|
9
|
+
import { readConfigFileOrEmpty } from "./config-file-utils.js";
|
|
12
10
|
|
|
13
11
|
const DEFAULT_TTL_MS = 1000;
|
|
14
12
|
|
|
@@ -16,18 +14,6 @@ type ReadOptions = {
|
|
|
16
14
|
force?: boolean;
|
|
17
15
|
};
|
|
18
16
|
|
|
19
|
-
function readConfigFile(path: string): Record<string, unknown> {
|
|
20
|
-
try {
|
|
21
|
-
if (!existsSync(path)) return {};
|
|
22
|
-
const raw = readFileSync(path, "utf-8");
|
|
23
|
-
const data = JSON.parse(raw);
|
|
24
|
-
if (!data || typeof data !== "object" || Array.isArray(data)) return {};
|
|
25
|
-
return data as Record<string, unknown>;
|
|
26
|
-
} catch {
|
|
27
|
-
return {};
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
17
|
/**
|
|
32
18
|
* Iterate entries and keep only those whose value is a non-empty,
|
|
33
19
|
* non-whitespace string. Returns undefined when the input is not
|
|
@@ -48,21 +34,19 @@ function normalizeRecord(raw: unknown): Record<string, string> | undefined {
|
|
|
48
34
|
|
|
49
35
|
export class ConfigFileCache {
|
|
50
36
|
private ttlMs: number;
|
|
51
|
-
private configPath: string;
|
|
52
37
|
private snapshot: Record<string, unknown> = {};
|
|
53
38
|
private lastReadAt = 0;
|
|
54
39
|
private invalidateCallbacks: Set<() => void> = new Set();
|
|
55
40
|
|
|
56
41
|
constructor(opts?: { ttlMs?: number }) {
|
|
57
42
|
this.ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
|
|
58
|
-
this.configPath = join(getWorkspaceDir(), "config.json");
|
|
59
43
|
}
|
|
60
44
|
|
|
61
45
|
/** Read the config file if the cached snapshot is stale or force is set. */
|
|
62
46
|
private ensureFresh(opts?: ReadOptions): void {
|
|
63
47
|
const now = Date.now();
|
|
64
48
|
if (opts?.force || now - this.lastReadAt >= this.ttlMs) {
|
|
65
|
-
this.snapshot =
|
|
49
|
+
this.snapshot = readConfigFileOrEmpty();
|
|
66
50
|
this.lastReadAt = Date.now();
|
|
67
51
|
}
|
|
68
52
|
}
|
|
@@ -121,7 +105,7 @@ export class ConfigFileCache {
|
|
|
121
105
|
|
|
122
106
|
/** Immediately re-read config.json, updating the cached snapshot. */
|
|
123
107
|
refreshNow(): void {
|
|
124
|
-
this.snapshot =
|
|
108
|
+
this.snapshot = readConfigFileOrEmpty();
|
|
125
109
|
this.lastReadAt = Date.now();
|
|
126
110
|
}
|
|
127
111
|
|