@zeph-to/cli 1.27.0 → 2.0.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/README.md +59 -29
- package/dist/cli.js +6 -1
- package/dist/crypto.d.ts +74 -53
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +161 -179
- package/dist/gate.d.ts +47 -2
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +50 -4
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +7 -3
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +15 -5
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/zeph-hook.d.ts +22 -0
- package/dist/zeph-hook.d.ts.map +1 -1
- package/dist/zeph-hook.js +110 -47
- package/package.json +1 -1
package/dist/crypto.js
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Per-device encryption for the Hook SDK — self-contained ECDH P-256 +
|
|
4
4
|
* AES-256-GCM. Mirrors @zeph/crypto API but bundled inline (no external
|
|
5
5
|
* dependency). Uses Web Crypto API via node:crypto webcrypto — Node.js 18+
|
|
6
6
|
* (the `crypto` global only exists unflagged from Node 19, so we import it).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* How it works (ADR-0007):
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* • Cross-device readability (all your devices share one keypair)
|
|
17
|
-
* What it does NOT give you:
|
|
18
|
-
* • Protection against the Zeph backend itself
|
|
19
|
-
* • Forward secrecy — encryptPushBodyForSelf / encryptFileForSelf do
|
|
20
|
-
* ECDH(self, self), which collapses to a static derived key. A single
|
|
21
|
-
* device compromise (since all your devices share the same keypair)
|
|
22
|
-
* lets the attacker decrypt every past push for which they have the
|
|
23
|
-
* ciphertext. The per-message AES key is random, but its wrap key is
|
|
24
|
-
* static, so wrapped keys are decryptable forever.
|
|
10
|
+
* This host holds its own ECDH keypair in ~/.zeph/device-keys.json. The
|
|
11
|
+
* private half is generated here and never leaves — the server only ever
|
|
12
|
+
* sees public keys. A push is encrypted once with a random AES key, and
|
|
13
|
+
* that key is wrapped separately for each of the user's registered devices
|
|
14
|
+
* using ECDH(this host, that device). Same keypair the stream frames below
|
|
15
|
+
* already used; push and file bodies now share it.
|
|
25
16
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
17
|
+
* What it does not give you:
|
|
18
|
+
* • Forward secrecy — the ECDH secret for a given (sender, device) pair is
|
|
19
|
+
* static, so a compromise of either private key retroactively opens every
|
|
20
|
+
* push wrapped for that pair.
|
|
21
|
+
* • Authenticity beyond the key pairing — nothing signs `senderPublicKey`.
|
|
22
|
+
*
|
|
23
|
+
* Superseded scheme: a single account-wide keypair whose private half the
|
|
24
|
+
* backend escrowed so it could sync to new devices. Key escrow was removed
|
|
25
|
+
* server-side (zeph@8a6d21b) and `GET /users/me/keys` has returned a public
|
|
26
|
+
* key only ever since. This client used to react by generating a fresh
|
|
27
|
+
* account keypair and PUTting it back — which overwrote the account public
|
|
28
|
+
* key and encrypted to a key no device held. Both that upload path and the
|
|
29
|
+
* account keypair are gone.
|
|
30
30
|
*/
|
|
31
31
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
-
exports.encryptEphemeral = exports.getDevicePublicKey = exports.initDeviceCrypto = exports.
|
|
32
|
+
exports.encryptEphemeral = exports.getDevicePublicKey = exports.initDeviceCrypto = exports.encryptFileForDevices = exports.encryptPushBodyForDevices = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.selectRecipients = exports.initCrypto = void 0;
|
|
33
33
|
/// <reference lib="dom" />
|
|
34
34
|
const fs_1 = require("fs");
|
|
35
35
|
const os_1 = require("os");
|
|
@@ -91,108 +91,61 @@ const encrypt = async (plaintext, senderPrivateKey, recipientPublicKey) => {
|
|
|
91
91
|
keyIv: toBase64(keyIv.buffer),
|
|
92
92
|
};
|
|
93
93
|
};
|
|
94
|
-
// ───
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const sharedKey = await deriveAesKey(senderPrivateKey, recipientPublicKey);
|
|
101
|
-
const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
|
|
102
|
-
const keyIv = crypto.getRandomValues(new Uint8Array(12));
|
|
103
|
-
const encryptedKey = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawFileKey);
|
|
104
|
-
return {
|
|
105
|
-
ciphertext: Buffer.from(ciphertext),
|
|
106
|
-
iv: toBase64(iv.buffer),
|
|
107
|
-
encryptedKey: toBase64(encryptedKey),
|
|
108
|
-
keyIv: toBase64(keyIv.buffer),
|
|
109
|
-
};
|
|
110
|
-
};
|
|
111
|
-
// ─── Key persistence (~/.config/zeph/keys.json) ───
|
|
112
|
-
const KEYS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.config', 'zeph');
|
|
113
|
-
const KEYS_PATH = (0, path_1.join)(KEYS_DIR, 'keys.json');
|
|
114
|
-
const loadStoredKeys = () => {
|
|
94
|
+
// ─── Superseded account keystore ───
|
|
95
|
+
//
|
|
96
|
+
// Held the escrowed account keypair. Nothing reads it any more; it is deleted
|
|
97
|
+
// on sight so a private key the server no longer issues stops sitting on disk.
|
|
98
|
+
const LEGACY_KEYS_PATH = (0, path_1.join)((0, os_1.homedir)(), '.config', 'zeph', 'keys.json');
|
|
99
|
+
const deleteLegacyKeys = () => {
|
|
115
100
|
try {
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return null;
|
|
101
|
+
(0, fs_1.unlinkSync)(LEGACY_KEYS_PATH);
|
|
120
102
|
}
|
|
121
|
-
}
|
|
122
|
-
const storeKeys = (exported) => {
|
|
123
|
-
(0, fs_1.mkdirSync)(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
124
|
-
(0, fs_1.writeFileSync)(KEYS_PATH, JSON.stringify(exported, null, 2), { mode: 0o600 });
|
|
103
|
+
catch { /* not present — fine */ }
|
|
125
104
|
};
|
|
126
105
|
// ─── Cached state ───
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Whether the account has opted into E2E (`encryptionEnabled`, ADR-0008).
|
|
108
|
+
* Kept separate from the device keypair because that keypair also backs
|
|
109
|
+
* stream frames, which are encrypted regardless — reading its presence as
|
|
110
|
+
* consent would turn push encryption on for everyone.
|
|
111
|
+
*/
|
|
112
|
+
let pushCryptoEnabled = false;
|
|
113
|
+
let cachedLegacyPublicKey = null;
|
|
130
114
|
let initPromise = null;
|
|
131
115
|
/**
|
|
132
|
-
* Initialize
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
116
|
+
* Initialize push/file encryption.
|
|
117
|
+
*
|
|
118
|
+
* Encryption turns on only when the account has explicitly opted in —
|
|
119
|
+
* `encryptionEnabled` from `GET /users/me/keys` is the single authoritative
|
|
120
|
+
* signal (ADR-0008). Server unreachable or flag off leaves it off and every
|
|
121
|
+
* send goes out in the clear.
|
|
122
|
+
*
|
|
123
|
+
* When it is on, this delegates to the per-device keypair: nothing is asked
|
|
124
|
+
* of the server but the flag, and nothing is ever uploaded.
|
|
125
|
+
*
|
|
126
|
+
* Safe to call concurrently — deduplicates to a single init.
|
|
127
|
+
* Returns this host's public key when encryption is active, '' otherwise.
|
|
136
128
|
*/
|
|
137
129
|
const initCrypto = (apiKey, baseUrl) => {
|
|
138
130
|
if (initPromise)
|
|
139
131
|
return initPromise;
|
|
140
132
|
initPromise = (async () => {
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// Server says encryption disabled — skip crypto init
|
|
147
|
-
if (serverResult && !serverResult.encryptionEnabled) {
|
|
148
|
-
cachedKeyPair = null;
|
|
149
|
-
cachedExportedPublicKey = null;
|
|
150
|
-
cachedOwnPublicKey = null;
|
|
151
|
-
return '';
|
|
152
|
-
}
|
|
153
|
-
if (serverResult?.keys) {
|
|
154
|
-
// Server has keys — adopt them (server is source of truth)
|
|
155
|
-
if (!stored || stored.publicKey !== serverResult.keys.publicKey) {
|
|
156
|
-
storeKeys(serverResult.keys);
|
|
157
|
-
}
|
|
158
|
-
cachedKeyPair = await importKeyPair(serverResult.keys);
|
|
159
|
-
cachedExportedPublicKey = serverResult.keys.publicKey;
|
|
160
|
-
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
161
|
-
return serverResult.keys.publicKey;
|
|
162
|
-
}
|
|
163
|
-
// Server has no keys
|
|
164
|
-
if (stored) {
|
|
165
|
-
// Upload local keys to server
|
|
166
|
-
await uploadServerKeys(stored, apiKey, baseUrl);
|
|
167
|
-
cachedKeyPair = await importKeyPair(stored);
|
|
168
|
-
cachedExportedPublicKey = stored.publicKey;
|
|
169
|
-
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
170
|
-
return stored.publicKey;
|
|
171
|
-
}
|
|
172
|
-
// No keys anywhere — generate + upload
|
|
173
|
-
const keyPair = await generateKeyPair();
|
|
174
|
-
const exported = await exportKeyPair(keyPair);
|
|
175
|
-
storeKeys(exported);
|
|
176
|
-
await uploadServerKeys(exported, apiKey, baseUrl);
|
|
177
|
-
cachedKeyPair = keyPair;
|
|
178
|
-
cachedExportedPublicKey = exported.publicKey;
|
|
179
|
-
cachedOwnPublicKey = keyPair.publicKey;
|
|
180
|
-
return exported.publicKey;
|
|
133
|
+
// Local-only mode (no apiKey): used by tests and offline setups. There is
|
|
134
|
+
// no flag to consult, so encryption stays off rather than being inferred.
|
|
135
|
+
if (!apiKey) {
|
|
136
|
+
pushCryptoEnabled = false;
|
|
137
|
+
return '';
|
|
181
138
|
}
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return
|
|
139
|
+
const state = await fetchEncryptionState(apiKey, baseUrl);
|
|
140
|
+
if (state)
|
|
141
|
+
deleteLegacyKeys();
|
|
142
|
+
if (!state?.encryptionEnabled) {
|
|
143
|
+
pushCryptoEnabled = false;
|
|
144
|
+
return '';
|
|
188
145
|
}
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
cachedKeyPair = keyPair;
|
|
193
|
-
cachedExportedPublicKey = exported.publicKey;
|
|
194
|
-
cachedOwnPublicKey = keyPair.publicKey;
|
|
195
|
-
return exported.publicKey;
|
|
146
|
+
const publicKey = await (0, exports.initDeviceCrypto)();
|
|
147
|
+
pushCryptoEnabled = true;
|
|
148
|
+
return publicKey;
|
|
196
149
|
})().catch((err) => {
|
|
197
150
|
initPromise = null;
|
|
198
151
|
throw err;
|
|
@@ -200,107 +153,136 @@ const initCrypto = (apiKey, baseUrl) => {
|
|
|
200
153
|
return initPromise;
|
|
201
154
|
};
|
|
202
155
|
exports.initCrypto = initCrypto;
|
|
203
|
-
const
|
|
156
|
+
const fetchEncryptionState = async (apiKey, baseUrl) => {
|
|
204
157
|
try {
|
|
205
158
|
const url = `${(baseUrl ?? 'https://api.zeph.to/v1').replace(/\/$/, '')}/users/me/keys`;
|
|
206
159
|
const res = await fetch(url, { headers: { 'X-API-Key': apiKey } });
|
|
207
160
|
if (!res.ok)
|
|
208
161
|
return null;
|
|
209
162
|
const json = await res.json();
|
|
210
|
-
|
|
211
|
-
const encryptionEnabled = json.data?.encryptionEnabled ?? (keys ? true : false);
|
|
163
|
+
cachedLegacyPublicKey = json.data?.encryptionKeys?.publicKey ?? null;
|
|
212
164
|
return {
|
|
213
|
-
|
|
214
|
-
|
|
165
|
+
encryptionEnabled: json.data?.encryptionEnabled === true,
|
|
166
|
+
legacyPublicKey: cachedLegacyPublicKey,
|
|
215
167
|
};
|
|
216
168
|
}
|
|
217
169
|
catch {
|
|
218
170
|
return null;
|
|
219
171
|
}
|
|
220
172
|
};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Keep only devices this host can actually encrypt for.
|
|
175
|
+
*
|
|
176
|
+
* A device without a public key has never run a build that registers one, and
|
|
177
|
+
* a device still advertising the account-wide key has not migrated to
|
|
178
|
+
* per-device E2E — wrapping for either produces a push it cannot open, which
|
|
179
|
+
* is worse than sending plaintext it can read.
|
|
180
|
+
*/
|
|
181
|
+
const selectRecipients = (devices) => devices
|
|
182
|
+
.filter((d) => !!d.publicKey && d.publicKey !== cachedLegacyPublicKey)
|
|
183
|
+
.map(({ deviceId, publicKey }) => ({ deviceId, publicKey }));
|
|
184
|
+
exports.selectRecipients = selectRecipients;
|
|
185
|
+
/**
|
|
186
|
+
* Wrap one raw AES key for every recipient device.
|
|
187
|
+
*
|
|
188
|
+
* The payload is encrypted once and only the wrapped key repeats, so an
|
|
189
|
+
* attachment costs one S3 object regardless of device count. A recipient
|
|
190
|
+
* whose public key will not import is dropped rather than failing the send —
|
|
191
|
+
* one broken device record must not silence every push.
|
|
192
|
+
*/
|
|
193
|
+
const wrapForDevices = async (rawKey, senderPrivateKey, recipients) => {
|
|
194
|
+
const entries = await Promise.all(recipients.map(async ({ deviceId, publicKey }) => {
|
|
195
|
+
try {
|
|
196
|
+
const sharedKey = await deriveAesKey(senderPrivateKey, await importPublicKey(publicKey));
|
|
197
|
+
const keyIv = crypto.getRandomValues(new Uint8Array(12));
|
|
198
|
+
const wrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawKey);
|
|
199
|
+
return [
|
|
200
|
+
deviceId,
|
|
201
|
+
JSON.stringify({ encryptedKey: toBase64(wrapped), keyIv: toBase64(keyIv.buffer) }),
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
console.error(`[Crypto] Skipping device ${deviceId} — unusable public key:`, err);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}));
|
|
209
|
+
const keyMap = {};
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
if (entry)
|
|
212
|
+
keyMap[entry[0]] = entry[1];
|
|
235
213
|
}
|
|
236
|
-
|
|
214
|
+
if (Object.keys(keyMap).length === 0)
|
|
215
|
+
throw new Error('No recipient device accepted the wrapped key');
|
|
216
|
+
return keyMap;
|
|
237
217
|
};
|
|
238
|
-
|
|
218
|
+
// Gated on the account opt-in, not on the keypair's existence: the same
|
|
219
|
+
// keypair backs stream frames, which are encrypted regardless, so presence
|
|
220
|
+
// alone would turn push encryption on for accounts that never asked.
|
|
221
|
+
const getKeyPair = () => (pushCryptoEnabled ? deviceKeyPair : null);
|
|
239
222
|
exports.getKeyPair = getKeyPair;
|
|
240
|
-
const getPublicKey = () =>
|
|
223
|
+
const getPublicKey = () => (pushCryptoEnabled ? deviceExportedPublicKey : null);
|
|
241
224
|
exports.getPublicKey = getPublicKey;
|
|
242
225
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
226
|
+
* Drop the cached keys so every later send goes out as plaintext.
|
|
227
|
+
*
|
|
228
|
+
* Callers use this when the server rejects an encrypted send with
|
|
229
|
+
* `PRO_REQUIRED` (ADR-0008): E2E is a Pro feature, and a long-lived process
|
|
230
|
+
* that initialized crypto before a downgrade would otherwise 403 on every push.
|
|
231
|
+
* Same end state as the "server says encryption disabled" branch in initCrypto.
|
|
232
|
+
* Keys on disk are left alone, but this process will not pick them up again:
|
|
233
|
+
* `initCrypto` is memoized on `initPromise` and `ZephHook.ensureCrypto` short-
|
|
234
|
+
* circuits on `cryptoInitialized`. A restart after an upgrade re-adopts them.
|
|
245
235
|
*/
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
throw new Error('Crypto not initialized');
|
|
249
|
-
const recipientKey = await importPublicKey(recipientPublicKeyRaw);
|
|
250
|
-
const payload = await encrypt(JSON.stringify({ title: input.title, body: input.body, url: input.url }), cachedKeyPair.privateKey, recipientKey);
|
|
251
|
-
return {
|
|
252
|
-
body: JSON.stringify({ ciphertext: payload.ciphertext, iv: payload.iv }),
|
|
253
|
-
encryptedKey: JSON.stringify({ encryptedKey: payload.encryptedKey, keyIv: payload.keyIv }),
|
|
254
|
-
senderPublicKey: cachedExportedPublicKey,
|
|
255
|
-
isEncrypted: true,
|
|
256
|
-
};
|
|
236
|
+
const disableCrypto = () => {
|
|
237
|
+
pushCryptoEnabled = false;
|
|
257
238
|
};
|
|
258
|
-
exports.
|
|
239
|
+
exports.disableCrypto = disableCrypto;
|
|
259
240
|
/**
|
|
260
|
-
* Encrypt push body for
|
|
241
|
+
* Encrypt a push body for the given recipient devices.
|
|
242
|
+
*
|
|
243
|
+
* Returns the wire fields the API expects: `body` carries the ciphertext and
|
|
244
|
+
* IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
|
|
245
|
+
* recipients need to derive the same secret back.
|
|
261
246
|
*/
|
|
262
|
-
const
|
|
263
|
-
if (!
|
|
247
|
+
const encryptPushBodyForDevices = async (input, recipients) => {
|
|
248
|
+
if (!deviceKeyPair || !deviceExportedPublicKey)
|
|
264
249
|
throw new Error('Crypto not initialized');
|
|
265
|
-
const
|
|
250
|
+
const messageKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
251
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
252
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, messageKey, new TextEncoder().encode(JSON.stringify({ title: input.title, body: input.body, url: input.url })));
|
|
253
|
+
const rawMessageKey = await crypto.subtle.exportKey('raw', messageKey);
|
|
266
254
|
return {
|
|
267
|
-
body: JSON.stringify({ ciphertext:
|
|
268
|
-
|
|
269
|
-
senderPublicKey:
|
|
255
|
+
body: JSON.stringify({ ciphertext: toBase64(ciphertext), iv: toBase64(iv.buffer) }),
|
|
256
|
+
deviceKeyMap: await wrapForDevices(rawMessageKey, deviceKeyPair.privateKey, recipients),
|
|
257
|
+
senderPublicKey: deviceExportedPublicKey,
|
|
270
258
|
isEncrypted: true,
|
|
271
259
|
};
|
|
272
260
|
};
|
|
273
|
-
exports.
|
|
261
|
+
exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
|
|
274
262
|
/**
|
|
275
|
-
* Encrypt file content for
|
|
276
|
-
* Returns encrypted buffer + key material for file attachment metadata.
|
|
263
|
+
* Encrypt file content for the given recipient devices.
|
|
277
264
|
*/
|
|
278
|
-
const
|
|
279
|
-
if (!
|
|
265
|
+
const encryptFileForDevices = async (content, recipients) => {
|
|
266
|
+
if (!deviceKeyPair)
|
|
280
267
|
throw new Error('Crypto not initialized');
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
*/
|
|
293
|
-
const encryptFileForSelf = async (content) => {
|
|
294
|
-
if (!cachedKeyPair || !cachedOwnPublicKey)
|
|
295
|
-
throw new Error('Crypto not initialized');
|
|
296
|
-
const result = await encryptFileContent(content, cachedKeyPair.privateKey, cachedOwnPublicKey);
|
|
268
|
+
// Binary content must be encrypted byte for byte — running a Buffer through
|
|
269
|
+
// TextEncoder would UTF-8 mangle every non-ASCII byte. Today's only caller
|
|
270
|
+
// passes markdown, so this is a guard against the first binary sender rather
|
|
271
|
+
// than a live fix (the MCP twin took that bug in production).
|
|
272
|
+
const buffer = typeof content === 'string'
|
|
273
|
+
? new TextEncoder().encode(content)
|
|
274
|
+
: new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
|
|
275
|
+
const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
276
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
277
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
|
|
278
|
+
const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
|
|
297
279
|
return {
|
|
298
|
-
ciphertext:
|
|
299
|
-
iv:
|
|
300
|
-
|
|
280
|
+
ciphertext: Buffer.from(ciphertext),
|
|
281
|
+
iv: toBase64(iv.buffer),
|
|
282
|
+
deviceKeyMap: await wrapForDevices(rawFileKey, deviceKeyPair.privateKey, recipients),
|
|
301
283
|
};
|
|
302
284
|
};
|
|
303
|
-
exports.
|
|
285
|
+
exports.encryptFileForDevices = encryptFileForDevices;
|
|
304
286
|
const DEVICE_KEYS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.zeph');
|
|
305
287
|
const DEVICE_KEYS_PATH = (0, path_1.join)(DEVICE_KEYS_DIR, 'device-keys.json');
|
|
306
288
|
let deviceKeyPair = null;
|
package/dist/gate.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface GateVerdict {
|
|
|
19
19
|
* (most non-Claude agents pass no counts): in normal mode the push still
|
|
20
20
|
* fires — preserving the historical always-push behavior of the dumb
|
|
21
21
|
* hooks — while quiet/loud now work everywhere.
|
|
22
|
+
*
|
|
23
|
+
* These defaults cannot rescue a quiet dial: quiet only lets a `high` marker
|
|
24
|
+
* through, and a hook with no turn facts has no marker either. That is why
|
|
25
|
+
* the installed templates pass `--pushmode-default normal` (see templates.ts)
|
|
26
|
+
* — for them quiet is not a lower volume, it is permanent silence.
|
|
22
27
|
*/
|
|
23
28
|
export declare const GATE_DEFAULTS: {
|
|
24
29
|
readonly toolCount: 2;
|
|
@@ -41,6 +46,46 @@ export declare const remoteMarkerPath: (hash: string) => string;
|
|
|
41
46
|
export declare const remoteDigest: (text: string) => string;
|
|
42
47
|
/** True when the user ran /zeph-mute for this project. */
|
|
43
48
|
export declare const isMuted: (dir: string) => boolean;
|
|
44
|
-
/**
|
|
45
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Push mode for an install that has never set a dial. The twin is
|
|
51
|
+
* plugin/hooks/gate.sh's missing-5th-argument default; the shared vectors
|
|
52
|
+
* never reach either one (every vector passes pushMode explicitly), so both
|
|
53
|
+
* sides pin it in their own tests.
|
|
54
|
+
*/
|
|
55
|
+
export declare const PUSHMODE_DEFAULT: GatePushMode;
|
|
56
|
+
/**
|
|
57
|
+
* `notify` flag naming the push mode to assume when the project has no dial.
|
|
58
|
+
* Written by templates.ts into every hook-driven agent's completion hook and
|
|
59
|
+
* read back in cli.ts — shared so the two can never drift apart.
|
|
60
|
+
*/
|
|
61
|
+
export declare const PUSHMODE_DEFAULT_FLAG = "pushmode-default";
|
|
62
|
+
/**
|
|
63
|
+
* The user's session push-mode dial (/zeph-quiet | /zeph-loud | /zeph-normal).
|
|
64
|
+
*
|
|
65
|
+
* Three failure shapes, three answers — "no dial" is the only one that gets
|
|
66
|
+
* the quiet default:
|
|
67
|
+
* - no dial file → `fallback` (PUSHMODE_DEFAULT unless the caller
|
|
68
|
+
* overrides it with --pushmode-default)
|
|
69
|
+
* - unusable dial file → normal. A missing project hash, an unreadable
|
|
70
|
+
* file, or a garbled/empty value is a broken
|
|
71
|
+
* setting, and resolving breakage to silence
|
|
72
|
+
* leaves the user with no symptom to debug. The
|
|
73
|
+
* point of the new default is quiet, not hidden
|
|
74
|
+
* errors.
|
|
75
|
+
* - readable dial file → whatever it says.
|
|
76
|
+
*/
|
|
77
|
+
export declare const readPushMode: (dir: string, fallback?: GatePushMode) => GatePushMode;
|
|
78
|
+
/**
|
|
79
|
+
* Push mode for a `--auto` notify: the user's dial if they set one, otherwise
|
|
80
|
+
* the mode named by `--pushmode-default`, otherwise the built-in quiet.
|
|
81
|
+
*
|
|
82
|
+
* The dial outranks the flag deliberately. The flag exists so an agent whose
|
|
83
|
+
* hook cannot participate in the heuristic still pushes out of the box; if it
|
|
84
|
+
* outranked the dial, `/zeph-quiet` would silently do nothing for that agent.
|
|
85
|
+
*
|
|
86
|
+
* A flag value that isn't one of the three modes resolves to `normal`, not to
|
|
87
|
+
* the quiet default — same rule as a garbled dial file. A caller that passes
|
|
88
|
+
* nonsense has a bug, and answering a bug with silence hides it.
|
|
89
|
+
*/
|
|
90
|
+
export declare const autoPushMode: (dir: string, flag: string | boolean | undefined) => GatePushMode;
|
|
46
91
|
//# sourceMappingURL=gate.d.ts.map
|
package/dist/gate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED
|
|
1
|
+
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AAEX,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,UACS,CAAC;AAEpE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,YACR,CAAC;AAErD,eAAO,MAAM,UAAU,GAAI,OAAO,SAAS,KAAG,WAW7C,CAAC;AAeF,eAAO,MAAM,QAAQ,QAAO,MACoD,CAAC;AA2BjF,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAOlD,CAAC;AAWF,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,MAA4C,CAAC;AAE7F;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,MAG1B,CAAC;AAEnB,0DAA0D;AAC1D,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,OAGrC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,YAAsB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,GACvB,KAAK,MAAM,EACX,WAAU,YAA+B,KACxC,YAUF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,YACW,CAAC"}
|
package/dist/gate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.readPushMode = exports.isMuted = exports.remoteDigest = exports.remoteMarkerPath = exports.projectHash = exports.stateDir = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
|
|
3
|
+
exports.autoPushMode = exports.readPushMode = exports.PUSHMODE_DEFAULT_FLAG = exports.PUSHMODE_DEFAULT = exports.isMuted = exports.remoteDigest = exports.remoteMarkerPath = exports.projectHash = exports.stateDir = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Push-gate decision — the portable half of the Zeph Stop-hook logic.
|
|
6
6
|
*
|
|
@@ -30,6 +30,11 @@ const path_1 = require("path");
|
|
|
30
30
|
* (most non-Claude agents pass no counts): in normal mode the push still
|
|
31
31
|
* fires — preserving the historical always-push behavior of the dumb
|
|
32
32
|
* hooks — while quiet/loud now work everywhere.
|
|
33
|
+
*
|
|
34
|
+
* These defaults cannot rescue a quiet dial: quiet only lets a `high` marker
|
|
35
|
+
* through, and a hook with no turn facts has no marker either. That is why
|
|
36
|
+
* the installed templates pass `--pushmode-default normal` (see templates.ts)
|
|
37
|
+
* — for them quiet is not a lower volume, it is permanent silence.
|
|
33
38
|
*/
|
|
34
39
|
exports.GATE_DEFAULTS = {
|
|
35
40
|
toolCount: 2,
|
|
@@ -133,14 +138,41 @@ const isMuted = (dir) => {
|
|
|
133
138
|
return hash !== null && findStateFile('muted', hash) !== null;
|
|
134
139
|
};
|
|
135
140
|
exports.isMuted = isMuted;
|
|
136
|
-
/**
|
|
137
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Push mode for an install that has never set a dial. The twin is
|
|
143
|
+
* plugin/hooks/gate.sh's missing-5th-argument default; the shared vectors
|
|
144
|
+
* never reach either one (every vector passes pushMode explicitly), so both
|
|
145
|
+
* sides pin it in their own tests.
|
|
146
|
+
*/
|
|
147
|
+
exports.PUSHMODE_DEFAULT = 'quiet';
|
|
148
|
+
/**
|
|
149
|
+
* `notify` flag naming the push mode to assume when the project has no dial.
|
|
150
|
+
* Written by templates.ts into every hook-driven agent's completion hook and
|
|
151
|
+
* read back in cli.ts — shared so the two can never drift apart.
|
|
152
|
+
*/
|
|
153
|
+
exports.PUSHMODE_DEFAULT_FLAG = 'pushmode-default';
|
|
154
|
+
/**
|
|
155
|
+
* The user's session push-mode dial (/zeph-quiet | /zeph-loud | /zeph-normal).
|
|
156
|
+
*
|
|
157
|
+
* Three failure shapes, three answers — "no dial" is the only one that gets
|
|
158
|
+
* the quiet default:
|
|
159
|
+
* - no dial file → `fallback` (PUSHMODE_DEFAULT unless the caller
|
|
160
|
+
* overrides it with --pushmode-default)
|
|
161
|
+
* - unusable dial file → normal. A missing project hash, an unreadable
|
|
162
|
+
* file, or a garbled/empty value is a broken
|
|
163
|
+
* setting, and resolving breakage to silence
|
|
164
|
+
* leaves the user with no symptom to debug. The
|
|
165
|
+
* point of the new default is quiet, not hidden
|
|
166
|
+
* errors.
|
|
167
|
+
* - readable dial file → whatever it says.
|
|
168
|
+
*/
|
|
169
|
+
const readPushMode = (dir, fallback = exports.PUSHMODE_DEFAULT) => {
|
|
138
170
|
const hash = (0, exports.projectHash)(dir);
|
|
139
171
|
if (!hash)
|
|
140
172
|
return 'normal';
|
|
141
173
|
const file = findStateFile('pushmode', hash);
|
|
142
174
|
if (!file)
|
|
143
|
-
return
|
|
175
|
+
return fallback;
|
|
144
176
|
try {
|
|
145
177
|
return (0, exports.normalizePushMode)((0, fs_1.readFileSync)(file, 'utf-8').replace(/\s+/g, ''));
|
|
146
178
|
}
|
|
@@ -149,3 +181,17 @@ const readPushMode = (dir) => {
|
|
|
149
181
|
}
|
|
150
182
|
};
|
|
151
183
|
exports.readPushMode = readPushMode;
|
|
184
|
+
/**
|
|
185
|
+
* Push mode for a `--auto` notify: the user's dial if they set one, otherwise
|
|
186
|
+
* the mode named by `--pushmode-default`, otherwise the built-in quiet.
|
|
187
|
+
*
|
|
188
|
+
* The dial outranks the flag deliberately. The flag exists so an agent whose
|
|
189
|
+
* hook cannot participate in the heuristic still pushes out of the box; if it
|
|
190
|
+
* outranked the dial, `/zeph-quiet` would silently do nothing for that agent.
|
|
191
|
+
*
|
|
192
|
+
* A flag value that isn't one of the three modes resolves to `normal`, not to
|
|
193
|
+
* the quiet default — same rule as a garbled dial file. A caller that passes
|
|
194
|
+
* nonsense has a bug, and answering a bug with silence hides it.
|
|
195
|
+
*/
|
|
196
|
+
const autoPushMode = (dir, flag) => (0, exports.readPushMode)(dir, typeof flag === 'string' ? (0, exports.normalizePushMode)(flag) : exports.PUSHMODE_DEFAULT);
|
|
197
|
+
exports.autoPushMode = autoPushMode;
|
package/dist/installer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AA4BzC;;;GAGG;AACH;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,YAAY,MAAM,GAAG,SAAS,EAAE,SAAS,OAAO,KAAG,OACxD,CAAC;AAoBzB;6EAC6E;AAC7E,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAqBhF,CAAC;AAgQF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,KAAK,EAAE,EAAE,MAAM,MAAM,KAAG,KAAK,EAKxE,CAAC;AA8EF,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,
|
|
1
|
+
{"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AA4BzC;;;GAGG;AACH;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,YAAY,MAAM,GAAG,SAAS,EAAE,SAAS,OAAO,KAAG,OACxD,CAAC;AAoBzB;6EAC6E;AAC7E,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAqBhF,CAAC;AAgQF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,KAAK,EAAE,EAAE,MAAM,MAAM,KAAG,KAAK,EAKxE,CAAC;AA8EF,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAgH1F,CAAC"}
|
package/dist/installer.js
CHANGED
|
@@ -489,9 +489,13 @@ const handleInstall = async (args) => {
|
|
|
489
489
|
// Hooks fire for every session of every configured agent, so say it here —
|
|
490
490
|
// at the moment the hooks get installed — together with the volume dials.
|
|
491
491
|
console.log(' Notifications now fire for EVERY session of each agent above');
|
|
492
|
-
console.log(' (not only sessions launched with `zeph cc`).
|
|
493
|
-
console.log('
|
|
494
|
-
console.log('
|
|
492
|
+
console.log(' (not only sessions launched with `zeph cc`).');
|
|
493
|
+
console.log('');
|
|
494
|
+
console.log(' In Claude Code the default is QUIET: you get pushed when the agent');
|
|
495
|
+
console.log(' asks you something and when a session finishes, not on every turn.');
|
|
496
|
+
console.log(' Dial it any time:');
|
|
497
|
+
console.log(' /zeph-normal push on every turn that did real work');
|
|
498
|
+
console.log(' /zeph-loud push on every turn');
|
|
495
499
|
console.log(' /zeph-mute full silence, current project');
|
|
496
500
|
console.log(' /zeph-status show what is in effect\n');
|
|
497
501
|
return 0;
|
package/dist/templates.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAsGA,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAItB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,QAAyE,CAAC;AAEpG,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAAyE,CAAC;AAElG,oDAAoD;AACpD,eAAO,MAAM,UAAU,QAAyE,CAAC;AAEjG,oFAAoF;AACpF,eAAO,MAAM,YAAY,QAAyE,CAAC;AAEnG,gEAAgE;AAChE,eAAO,MAAM,UAAU,QAAkE,CAAC;AAE1F,4FAA4F;AAC5F,eAAO,MAAM,UAAU,QAAkE,CAAC;AAI1F,eAAO,MAAM,YAAY,QAKd,CAAC;AAEZ,eAAO,MAAM,cAAc,QAOhB,CAAC;AAEZ,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;CAuBxB,CAAC;AAYF,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;CASvB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,OAAO,KAAG,OAUhD,CAAC;AAEF,eAAO,MAAM,aAAa,QASf,CAAC;AASZ,eAAO,MAAM,eAAe,oFAA+E,CAAC;AAC5G,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAQjD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,KAAG,MAWnE,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,MAOrD,CAAC"}
|