@zeph-to/cli 1.27.1 → 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 +64 -55
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +150 -185
- 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 +9 -0
- package/dist/zeph-hook.d.ts.map +1 -1
- package/dist/zeph-hook.js +81 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,9 +45,11 @@ binary.
|
|
|
45
45
|
|
|
46
46
|
Once installed, the hooks fire in **every** session of each configured
|
|
47
47
|
agent — `zeph cc` is the phone-control bridge, not the notification
|
|
48
|
-
switch.
|
|
49
|
-
|
|
50
|
-
`/zeph-
|
|
48
|
+
switch. In Claude Code the routine per-turn push starts off (`quiet` is
|
|
49
|
+
the default); `/zeph-normal` turns it on for a project, `/zeph-loud`
|
|
50
|
+
pushes on every turn, `/zeph-mute` silences a project entirely, and
|
|
51
|
+
`/zeph-status` shows what's in effect. See
|
|
52
|
+
[Mute & push mode](#mute--push-mode).
|
|
51
53
|
|
|
52
54
|
`~/.zeph/config.json` is the single source of truth — the CLI, the MCP
|
|
53
55
|
server, the plugin hooks, and the listener all read it. You never need
|
|
@@ -424,6 +426,7 @@ zeph notify --title "Hello" --json
|
|
|
424
426
|
| `--device <id>` | Target device ID |
|
|
425
427
|
| `--session <id>` | AI session ID so the push threads into that session's chat (or `ZEPH_SESSION_ID` env) |
|
|
426
428
|
| `--auto` | Apply the push gate before sending — honors the `/zeph-quiet` / `/zeph-loud` push-mode dial, per project or machine-wide (`--global`); gated-out exits silently with code 0 |
|
|
429
|
+
| `--pushmode-default <m>` | Mode `--auto` assumes when the project has no dial: `quiet` (built-in), `normal`, `loud`. A dial the user set always wins |
|
|
427
430
|
| `--marker <m>` | Push Signal marker for `--auto`: `skip`, `push`, `high` |
|
|
428
431
|
| `--tools <n>`, `--nonreadonly <n>` | Turn tool counts feeding `--auto`'s heuristic (defaults assume real work) |
|
|
429
432
|
|
|
@@ -491,13 +494,25 @@ this order — first hit wins:
|
|
|
491
494
|
|-------|------|--------|
|
|
492
495
|
| 1 | `$STATE_DIR/pushmode-<hash>` | `/zeph-quiet` · `/zeph-loud` · `/zeph-normal` |
|
|
493
496
|
| 2 | `/tmp/zeph-pushmode-<hash>` | older versions (honored only when you own the file) |
|
|
494
|
-
| 3 | `$STATE_DIR/pushmode-default` |
|
|
495
|
-
| 4 |
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
497
|
+
| 3 | `$STATE_DIR/pushmode-default` | the `--global` form of any dial — the machine-wide default |
|
|
498
|
+
| 4 | `--pushmode-default <mode>` | the calling hook (the installed ones pass `normal`) |
|
|
499
|
+
| 5 | *(nothing above)* | `quiet` |
|
|
500
|
+
|
|
501
|
+
**Row 5 changed**: an install with no dial anywhere used to be `normal`.
|
|
502
|
+
It is now `quiet`, so upgrading turns the routine per-turn push off until
|
|
503
|
+
you run `/zeph-normal`. Row 4 is why the hooks this CLI installs are
|
|
504
|
+
unaffected: they name `normal` themselves, since a hook that supplies no
|
|
505
|
+
turn counts also supplies no `high` marker, and `quiet` would make it
|
|
506
|
+
permanently silent rather than merely quieter. Row 4 sits *below* the
|
|
507
|
+
state files on purpose — the flag names a default, it does not override a
|
|
508
|
+
dial the user set.
|
|
509
|
+
|
|
510
|
+
A dial file that exists but reads empty resolves to `normal`, not to row
|
|
511
|
+
5: an empty file is a failed write, and resolving breakage to silence
|
|
512
|
+
leaves no symptom to debug.
|
|
513
|
+
|
|
514
|
+
Mute has no `-default` form on purpose: it is keyed on presence, not
|
|
515
|
+
content, so a global mute could never be lifted for a single project.
|
|
501
516
|
|
|
502
517
|
Legacy `/tmp/zeph-muted-<hash>` files are still honored when owned by the
|
|
503
518
|
current user (the state dir moved out of world-writable `/tmp`).
|
|
@@ -604,25 +619,40 @@ session.
|
|
|
604
619
|
|
|
605
620
|
## Encryption
|
|
606
621
|
|
|
607
|
-
Push bodies are encrypted with AES-256-GCM.
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
622
|
+
Push bodies and long-body attachments are encrypted with AES-256-GCM.
|
|
623
|
+
This host holds its own ECDH P-256 keypair in `~/.zeph/device-keys.json`
|
|
624
|
+
— generated on first use, and the private half never leaves the machine.
|
|
625
|
+
Each push is encrypted once, and its AES key is wrapped separately for
|
|
626
|
+
every device on your account using ECDH against that device's public
|
|
627
|
+
key.
|
|
628
|
+
|
|
629
|
+
Toggle encryption in the Zeph app (Settings → Encryption); when it is
|
|
630
|
+
off, the CLI sends plaintext. No configuration needed.
|
|
631
|
+
|
|
632
|
+
**Threat model:** against a passive backend — a leaked snapshot, an
|
|
633
|
+
operator reading the table — the stored ciphertext and wrapped keys are
|
|
634
|
+
useless, so push contents stay private. Three limits worth knowing:
|
|
635
|
+
|
|
636
|
+
- **No protection from an active malicious operator.** Recipient public
|
|
637
|
+
keys come from `GET /devices` on that same server, unsigned and
|
|
638
|
+
unpinned. A backend that injects a device record carrying its own key
|
|
639
|
+
gets the message key wrapped for it, and reads everything. Closing
|
|
640
|
+
this needs out-of-band device verification (ADR-0007 Phase 4, not
|
|
641
|
+
built).
|
|
642
|
+
- **No forward secrecy.** The ECDH secret for a given sender/device pair
|
|
643
|
+
is static, so compromising either private key opens every past push
|
|
644
|
+
wrapped for that pair.
|
|
645
|
+
- **`senderPublicKey` is unsigned**, so a swapped one makes a push
|
|
646
|
+
undecryptable — that direction fails closed rather than leaking.
|
|
647
|
+
|
|
648
|
+
A device that has not registered a per-device public key cannot be sent
|
|
649
|
+
to; it is skipped, and if no device qualifies the push goes out in the
|
|
650
|
+
clear rather than arriving as something nothing can open.
|
|
651
|
+
|
|
652
|
+
The `zeph listener` ignores `isEncrypted` pushes for now — it does not
|
|
653
|
+
try to decrypt them. Stop-hook auto-pushes and `zeph_ask` responses are
|
|
654
|
+
not part of the `@<session>` injection path, so this doesn't affect
|
|
655
|
+
normal use.
|
|
626
656
|
|
|
627
657
|
## Requirements
|
|
628
658
|
|
package/dist/cli.js
CHANGED
|
@@ -96,6 +96,10 @@ Notify options:
|
|
|
96
96
|
--session <id> AI session ID (or set ZEPH_SESSION_ID env)
|
|
97
97
|
--auto Apply the push gate before sending (honors the
|
|
98
98
|
/zeph-quiet | /zeph-loud dial; silent exit when gated)
|
|
99
|
+
--pushmode-default <m>
|
|
100
|
+
Mode --auto assumes when the project has no dial
|
|
101
|
+
(quiet|normal|loud) [default: quiet]. A dial set with
|
|
102
|
+
/zeph-quiet | /zeph-loud | /zeph-normal always wins
|
|
99
103
|
--marker <m> Push Signal marker for --auto (skip|push|high)
|
|
100
104
|
--tools <n> Turn tool count for --auto [default: assume real work]
|
|
101
105
|
--nonreadonly <n> Non-read-only tool count for --auto
|
|
@@ -254,13 +258,14 @@ const handleNotify = async (args) => {
|
|
|
254
258
|
// GATE_DEFAULTS ("assume real work") so dumb hooks keep their historical
|
|
255
259
|
// always-push behavior in normal mode, while the /zeph-quiet | /zeph-loud
|
|
256
260
|
// dial now works for every hook-driven agent. Gated-out → silent success.
|
|
261
|
+
// With no dial the mode falls back to --pushmode-default, then to quiet.
|
|
257
262
|
if (args.auto === true) {
|
|
258
263
|
const verdict = (0, gate_js_1.decidePush)({
|
|
259
264
|
toolCount: gateCount(args.tools, gate_js_1.GATE_DEFAULTS.toolCount),
|
|
260
265
|
nonReadonlyCount: gateCount(args.nonreadonly, gate_js_1.GATE_DEFAULTS.nonReadonlyCount),
|
|
261
266
|
alreadyAsked: gate_js_1.GATE_DEFAULTS.alreadyAsked,
|
|
262
267
|
marker: (0, gate_js_1.normalizeMarker)(typeof args.marker === 'string' ? args.marker : undefined),
|
|
263
|
-
pushMode: (0, gate_js_1.
|
|
268
|
+
pushMode: (0, gate_js_1.autoPushMode)(projectDir, args[gate_js_1.PUSHMODE_DEFAULT_FLAG]),
|
|
264
269
|
});
|
|
265
270
|
if (!verdict.push)
|
|
266
271
|
return 0;
|
package/dist/crypto.d.ts
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Per-device encryption for the Hook SDK — self-contained ECDH P-256 +
|
|
3
3
|
* AES-256-GCM. Mirrors @zeph/crypto API but bundled inline (no external
|
|
4
4
|
* dependency). Uses Web Crypto API via node:crypto webcrypto — Node.js 18+
|
|
5
5
|
* (the `crypto` global only exists unflagged from Node 19, so we import it).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* How it works (ADR-0007):
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* • Cross-device readability (all your devices share one keypair)
|
|
16
|
-
* What it does NOT give you:
|
|
17
|
-
* • Protection against the Zeph backend itself
|
|
18
|
-
* • Forward secrecy — encryptPushBodyForSelf / encryptFileForSelf do
|
|
19
|
-
* ECDH(self, self), which collapses to a static derived key. A single
|
|
20
|
-
* device compromise (since all your devices share the same keypair)
|
|
21
|
-
* lets the attacker decrypt every past push for which they have the
|
|
22
|
-
* ciphertext. The per-message AES key is random, but its wrap key is
|
|
23
|
-
* static, so wrapped keys are decryptable forever.
|
|
9
|
+
* This host holds its own ECDH keypair in ~/.zeph/device-keys.json. The
|
|
10
|
+
* private half is generated here and never leaves — the server only ever
|
|
11
|
+
* sees public keys. A push is encrypted once with a random AES key, and
|
|
12
|
+
* that key is wrapped separately for each of the user's registered devices
|
|
13
|
+
* using ECDH(this host, that device). Same keypair the stream frames below
|
|
14
|
+
* already used; push and file bodies now share it.
|
|
24
15
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
16
|
+
* What it does not give you:
|
|
17
|
+
* • Forward secrecy — the ECDH secret for a given (sender, device) pair is
|
|
18
|
+
* static, so a compromise of either private key retroactively opens every
|
|
19
|
+
* push wrapped for that pair.
|
|
20
|
+
* • Authenticity beyond the key pairing — nothing signs `senderPublicKey`.
|
|
21
|
+
*
|
|
22
|
+
* Superseded scheme: a single account-wide keypair whose private half the
|
|
23
|
+
* backend escrowed so it could sync to new devices. Key escrow was removed
|
|
24
|
+
* server-side (zeph@8a6d21b) and `GET /users/me/keys` has returned a public
|
|
25
|
+
* key only ever since. This client used to react by generating a fresh
|
|
26
|
+
* account keypair and PUTting it back — which overwrote the account public
|
|
27
|
+
* key and encrypted to a key no device held. Both that upload path and the
|
|
28
|
+
* account keypair are gone.
|
|
29
29
|
*/
|
|
30
30
|
interface EncryptedPayload {
|
|
31
31
|
ciphertext: string;
|
|
@@ -34,12 +34,40 @@ interface EncryptedPayload {
|
|
|
34
34
|
keyIv: string;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Initialize
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* Initialize push/file encryption.
|
|
38
|
+
*
|
|
39
|
+
* Encryption turns on only when the account has explicitly opted in —
|
|
40
|
+
* `encryptionEnabled` from `GET /users/me/keys` is the single authoritative
|
|
41
|
+
* signal (ADR-0008). Server unreachable or flag off leaves it off and every
|
|
42
|
+
* send goes out in the clear.
|
|
43
|
+
*
|
|
44
|
+
* When it is on, this delegates to the per-device keypair: nothing is asked
|
|
45
|
+
* of the server but the flag, and nothing is ever uploaded.
|
|
46
|
+
*
|
|
47
|
+
* Safe to call concurrently — deduplicates to a single init.
|
|
48
|
+
* Returns this host's public key when encryption is active, '' otherwise.
|
|
41
49
|
*/
|
|
42
50
|
export declare const initCrypto: (apiKey?: string, baseUrl?: string) => Promise<string>;
|
|
51
|
+
/** One target device, as returned by `GET /devices`. */
|
|
52
|
+
export interface DeviceRecipient {
|
|
53
|
+
deviceId: string;
|
|
54
|
+
/** Base64 SPKI of that device's per-device public key. */
|
|
55
|
+
publicKey: string;
|
|
56
|
+
}
|
|
57
|
+
/** deviceId → JSON `{ encryptedKey, keyIv }`, the wire shape the clients parse. */
|
|
58
|
+
export type DeviceKeyMap = Record<string, string>;
|
|
59
|
+
/**
|
|
60
|
+
* Keep only devices this host can actually encrypt for.
|
|
61
|
+
*
|
|
62
|
+
* A device without a public key has never run a build that registers one, and
|
|
63
|
+
* a device still advertising the account-wide key has not migrated to
|
|
64
|
+
* per-device E2E — wrapping for either produces a push it cannot open, which
|
|
65
|
+
* is worse than sending plaintext it can read.
|
|
66
|
+
*/
|
|
67
|
+
export declare const selectRecipients: (devices: {
|
|
68
|
+
deviceId: string;
|
|
69
|
+
publicKey?: string;
|
|
70
|
+
}[]) => DeviceRecipient[];
|
|
43
71
|
export declare const getKeyPair: () => CryptoKeyPair | null;
|
|
44
72
|
export declare const getPublicKey: () => string | null;
|
|
45
73
|
/**
|
|
@@ -55,48 +83,29 @@ export declare const getPublicKey: () => string | null;
|
|
|
55
83
|
*/
|
|
56
84
|
export declare const disableCrypto: () => void;
|
|
57
85
|
/**
|
|
58
|
-
* Encrypt push body for
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
body?: string;
|
|
64
|
-
url?: string;
|
|
65
|
-
}, recipientPublicKeyRaw: string) => Promise<{
|
|
66
|
-
body: string;
|
|
67
|
-
encryptedKey: string;
|
|
68
|
-
senderPublicKey: string;
|
|
69
|
-
isEncrypted: true;
|
|
70
|
-
}>;
|
|
71
|
-
/**
|
|
72
|
-
* Encrypt push body for self (all own devices).
|
|
86
|
+
* Encrypt a push body for the given recipient devices.
|
|
87
|
+
*
|
|
88
|
+
* Returns the wire fields the API expects: `body` carries the ciphertext and
|
|
89
|
+
* IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
|
|
90
|
+
* recipients need to derive the same secret back.
|
|
73
91
|
*/
|
|
74
|
-
export declare const
|
|
92
|
+
export declare const encryptPushBodyForDevices: (input: {
|
|
75
93
|
title?: string;
|
|
76
94
|
body?: string;
|
|
77
95
|
url?: string;
|
|
78
|
-
}) => Promise<{
|
|
96
|
+
}, recipients: DeviceRecipient[]) => Promise<{
|
|
79
97
|
body: string;
|
|
80
|
-
|
|
98
|
+
deviceKeyMap: DeviceKeyMap;
|
|
81
99
|
senderPublicKey: string;
|
|
82
100
|
isEncrypted: true;
|
|
83
101
|
}>;
|
|
84
102
|
/**
|
|
85
|
-
* Encrypt file content for
|
|
86
|
-
* Returns encrypted buffer + key material for file attachment metadata.
|
|
103
|
+
* Encrypt file content for the given recipient devices.
|
|
87
104
|
*/
|
|
88
|
-
export declare const
|
|
105
|
+
export declare const encryptFileForDevices: (content: string | Buffer, recipients: DeviceRecipient[]) => Promise<{
|
|
89
106
|
ciphertext: Buffer;
|
|
90
107
|
iv: string;
|
|
91
|
-
|
|
92
|
-
}>;
|
|
93
|
-
/**
|
|
94
|
-
* Encrypt file content for self (all own devices).
|
|
95
|
-
*/
|
|
96
|
-
export declare const encryptFileForSelf: (content: string) => Promise<{
|
|
97
|
-
ciphertext: Buffer;
|
|
98
|
-
iv: string;
|
|
99
|
-
encryptedKey: string;
|
|
108
|
+
deviceKeyMap: DeviceKeyMap;
|
|
100
109
|
}>;
|
|
101
110
|
/**
|
|
102
111
|
* Flat ephemeral envelope — field-for-field what the web's decrypt()
|
package/dist/crypto.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AA+EH,UAAU,gBAAgB;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AA+EH,UAAU,gBAAgB;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;AAkDD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAyB5E,CAAC;AAsCF,wDAAwD;AACxD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,mFAAmF;AACnF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAElD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,KAClD,eAAe,EAG8C,CAAC;AA2CjE,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAkD,CAAC;AACjG,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA4D,CAAC;AAEtG;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,QAAO,IAEhC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACpC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACtD,YAAY,eAAe,EAAE,KAC5B,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,YAAY,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAkBA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,qBAAqB,GAChC,SAAS,MAAM,GAAG,MAAM,EACxB,YAAY,eAAe,EAAE,KAC5B,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,YAAY,CAAA;CAAE,CAsBxE,CAAC;AAcF;;;;GAIG;AACH,MAAM,WAAW,yBAA0B,SAAQ,gBAAgB;IACjE,eAAe,EAAE,MAAM,CAAC;CACzB;AAsBD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,QAAO,OAAO,CAAC,MAAM,CAoBjD,CAAC;AAEF,eAAO,MAAM,kBAAkB,QAAO,MAAM,GAAG,IAA+B,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,WAAW,MAAM,EACjB,uBAAuB,MAAM,KAC5B,OAAO,CAAC,yBAAyB,CAKnC,CAAC"}
|
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,44 +153,74 @@ 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
226
|
* Drop the cached keys so every later send goes out as plaintext.
|
|
@@ -251,73 +234,55 @@ exports.getPublicKey = getPublicKey;
|
|
|
251
234
|
* circuits on `cryptoInitialized`. A restart after an upgrade re-adopts them.
|
|
252
235
|
*/
|
|
253
236
|
const disableCrypto = () => {
|
|
254
|
-
|
|
255
|
-
cachedExportedPublicKey = null;
|
|
256
|
-
cachedOwnPublicKey = null;
|
|
237
|
+
pushCryptoEnabled = false;
|
|
257
238
|
};
|
|
258
239
|
exports.disableCrypto = disableCrypto;
|
|
259
240
|
/**
|
|
260
|
-
* Encrypt push body for
|
|
261
|
-
*
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
throw new Error('Crypto not initialized');
|
|
266
|
-
const recipientKey = await importPublicKey(recipientPublicKeyRaw);
|
|
267
|
-
const payload = await encrypt(JSON.stringify({ title: input.title, body: input.body, url: input.url }), cachedKeyPair.privateKey, recipientKey);
|
|
268
|
-
return {
|
|
269
|
-
body: JSON.stringify({ ciphertext: payload.ciphertext, iv: payload.iv }),
|
|
270
|
-
encryptedKey: JSON.stringify({ encryptedKey: payload.encryptedKey, keyIv: payload.keyIv }),
|
|
271
|
-
senderPublicKey: cachedExportedPublicKey,
|
|
272
|
-
isEncrypted: true,
|
|
273
|
-
};
|
|
274
|
-
};
|
|
275
|
-
exports.encryptPushBody = encryptPushBody;
|
|
276
|
-
/**
|
|
277
|
-
* Encrypt push body for self (all own devices).
|
|
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.
|
|
278
246
|
*/
|
|
279
|
-
const
|
|
280
|
-
if (!
|
|
247
|
+
const encryptPushBodyForDevices = async (input, recipients) => {
|
|
248
|
+
if (!deviceKeyPair || !deviceExportedPublicKey)
|
|
281
249
|
throw new Error('Crypto not initialized');
|
|
282
|
-
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);
|
|
283
254
|
return {
|
|
284
|
-
body: JSON.stringify({ ciphertext:
|
|
285
|
-
|
|
286
|
-
senderPublicKey:
|
|
255
|
+
body: JSON.stringify({ ciphertext: toBase64(ciphertext), iv: toBase64(iv.buffer) }),
|
|
256
|
+
deviceKeyMap: await wrapForDevices(rawMessageKey, deviceKeyPair.privateKey, recipients),
|
|
257
|
+
senderPublicKey: deviceExportedPublicKey,
|
|
287
258
|
isEncrypted: true,
|
|
288
259
|
};
|
|
289
260
|
};
|
|
290
|
-
exports.
|
|
291
|
-
/**
|
|
292
|
-
* Encrypt file content for a recipient.
|
|
293
|
-
* Returns encrypted buffer + key material for file attachment metadata.
|
|
294
|
-
*/
|
|
295
|
-
const encryptFileForRecipient = async (content, recipientPublicKeyRaw) => {
|
|
296
|
-
if (!cachedKeyPair)
|
|
297
|
-
throw new Error('Crypto not initialized');
|
|
298
|
-
const recipientKey = await importPublicKey(recipientPublicKeyRaw);
|
|
299
|
-
const result = await encryptFileContent(content, cachedKeyPair.privateKey, recipientKey);
|
|
300
|
-
return {
|
|
301
|
-
ciphertext: result.ciphertext,
|
|
302
|
-
iv: result.iv,
|
|
303
|
-
encryptedKey: JSON.stringify({ encryptedKey: result.encryptedKey, keyIv: result.keyIv }),
|
|
304
|
-
};
|
|
305
|
-
};
|
|
306
|
-
exports.encryptFileForRecipient = encryptFileForRecipient;
|
|
261
|
+
exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
|
|
307
262
|
/**
|
|
308
|
-
* Encrypt file content for
|
|
263
|
+
* Encrypt file content for the given recipient devices.
|
|
309
264
|
*/
|
|
310
|
-
const
|
|
311
|
-
if (!
|
|
265
|
+
const encryptFileForDevices = async (content, recipients) => {
|
|
266
|
+
if (!deviceKeyPair)
|
|
312
267
|
throw new Error('Crypto not initialized');
|
|
313
|
-
|
|
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);
|
|
314
279
|
return {
|
|
315
|
-
ciphertext:
|
|
316
|
-
iv:
|
|
317
|
-
|
|
280
|
+
ciphertext: Buffer.from(ciphertext),
|
|
281
|
+
iv: toBase64(iv.buffer),
|
|
282
|
+
deviceKeyMap: await wrapForDevices(rawFileKey, deviceKeyPair.privateKey, recipients),
|
|
318
283
|
};
|
|
319
284
|
};
|
|
320
|
-
exports.
|
|
285
|
+
exports.encryptFileForDevices = encryptFileForDevices;
|
|
321
286
|
const DEVICE_KEYS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.zeph');
|
|
322
287
|
const DEVICE_KEYS_PATH = (0, path_1.join)(DEVICE_KEYS_DIR, 'device-keys.json');
|
|
323
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"}
|
package/dist/templates.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// and the agents can't drift apart.
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
21
|
exports.removeManagedBlock = exports.upsertManagedBlock = exports.ZEPH_MARK_END = exports.ZEPH_MARK_START = exports.COPILOT_HOOKS = exports.isZephHookGroup = exports.CODEX_HOOKS = exports.GEMINI_HOOKS = exports.WINDSURF_HOOKS = exports.CURSOR_HOOKS = exports.AIDER_RULE = exports.CLINE_RULE = exports.COPILOT_RULE = exports.CODEX_RULE = exports.GEMINI_RULE = exports.WINDSURF_RULE = exports.CURSOR_RULE = void 0;
|
|
22
|
+
const gate_js_1 = require("./gate.js");
|
|
22
23
|
const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
|
|
23
24
|
// Graceful resolution: prefer the installed `zeph` CLI, but fall back to
|
|
24
25
|
// `npx -y @zeph-to/cli` so the hook still fires when the user
|
|
@@ -27,11 +28,20 @@ const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
|
|
|
27
28
|
// pattern in plugin/hooks/zeph-{stop,ask}.sh.
|
|
28
29
|
//
|
|
29
30
|
// `--auto` applies the shared push-gate before sending (see src/gate.ts):
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
31
|
+
// the push still fires (gate defaults assume real work), and the
|
|
32
|
+
// /zeph-quiet | /zeph-loud dial works for every hook-driven agent.
|
|
33
|
+
//
|
|
34
|
+
// `--pushmode-default normal` is what keeps the first half of that true. The
|
|
35
|
+
// built-in default for a project with no dial is quiet, and quiet only lets a
|
|
36
|
+
// `high` Push Signal marker through — a marker these hooks have no way to
|
|
37
|
+
// emit. Without the flag they would install and then never push again. The
|
|
38
|
+
// user's own dial still outranks it, so /zeph-quiet keeps working here.
|
|
39
|
+
//
|
|
40
|
+
// Older installed `zeph` versions parse both flags as unknown booleans and
|
|
41
|
+
// ignore them — graceful backward compatibility, and for `--pushmode-default`
|
|
42
|
+
// specifically the old build's behavior was already "normal when no dial".
|
|
43
|
+
const NOTIFY_CMD = '$(command -v zeph || echo "npx -y @zeph-to/cli") notify --title "Task done" --auto '
|
|
44
|
+
+ `--${gate_js_1.PUSHMODE_DEFAULT_FLAG} normal 2>/dev/null || true`;
|
|
35
45
|
// Prompt-submit hook command — remote-origin detection (ADR-0002). Reads
|
|
36
46
|
// the hook JSON on stdin and prints additionalContext JSON on a marker
|
|
37
47
|
// match (see src/remote-hook.ts). stdout IS the hook response, so only
|
package/dist/types.d.ts
CHANGED
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACzC,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,CAAC;IAChD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;oEAEgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,MAAM,CAAC;CACxD;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACzC,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,CAAC;IAChD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;oEAEgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,MAAM,CAAC;CACxD;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,QAAQ,EAAE,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE;QACL,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;CACH"}
|
package/dist/zeph-hook.d.ts
CHANGED
|
@@ -5,6 +5,15 @@ export declare class ZephHook {
|
|
|
5
5
|
private readonly timeoutMs;
|
|
6
6
|
private cryptoInitialized;
|
|
7
7
|
constructor(options: ZephOptions);
|
|
8
|
+
/**
|
|
9
|
+
* Resolve who this push can be encrypted for, or null when it cannot be.
|
|
10
|
+
*
|
|
11
|
+
* The device list is fetched per send rather than cached: a phone that
|
|
12
|
+
* registered its key a minute ago must be able to read the next push, and a
|
|
13
|
+
* long-lived listener would otherwise keep wrapping for a stale set.
|
|
14
|
+
* Failures here are not fatal — plaintext the user can read beats a
|
|
15
|
+
* notification that never arrives.
|
|
16
|
+
*/
|
|
8
17
|
private ensureCrypto;
|
|
9
18
|
/**
|
|
10
19
|
* A push must not be lost because E2E turned out to be unavailable.
|
package/dist/zeph-hook.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"zeph-hook.d.ts","sourceRoot":"","sources":["../src/zeph-hook.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAY,gBAAgB,EAAE,gBAAgB,EAAoB,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAoCxL,qBAAa,QAAQ;IACnB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,iBAAiB,CAAS;gBAEtB,OAAO,EAAE,WAAW;
|
|
1
|
+
{"version":3,"file":"zeph-hook.d.ts","sourceRoot":"","sources":["../src/zeph-hook.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAY,gBAAgB,EAAE,gBAAgB,EAAoB,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAoCxL,qBAAa,QAAQ;IACnB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,iBAAiB,CAAS;gBAEtB,OAAO,EAAE,WAAW;IAShC;;;;;;;;OAQG;YACW,YAAY;IAyB1B;;;;;;;;;;;OAWG;IACG,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC;YAgB7C,UAAU;YAyCV,cAAc;IA6DtB,aAAa,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAK7G,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcrF,IAAI,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IAuB9C,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAKlD,UAAU,IAAI,OAAO,CAAC,gBAAgB,CAAC;IAK7C;;;;OAIG;IACG,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;YASxF,OAAO;IAiCrB,OAAO,CAAC,UAAU;CASnB"}
|
package/dist/zeph-hook.js
CHANGED
|
@@ -50,17 +50,39 @@ class ZephHook {
|
|
|
50
50
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
51
51
|
this.timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve who this push can be encrypted for, or null when it cannot be.
|
|
55
|
+
*
|
|
56
|
+
* The device list is fetched per send rather than cached: a phone that
|
|
57
|
+
* registered its key a minute ago must be able to read the next push, and a
|
|
58
|
+
* long-lived listener would otherwise keep wrapping for a stale set.
|
|
59
|
+
* Failures here are not fatal — plaintext the user can read beats a
|
|
60
|
+
* notification that never arrives.
|
|
61
|
+
*/
|
|
53
62
|
async ensureCrypto() {
|
|
54
|
-
if (this.cryptoInitialized)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
if (!this.cryptoInitialized) {
|
|
64
|
+
try {
|
|
65
|
+
await (0, crypto_js_1.initCrypto)(this.apiKey, this.baseUrl);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// fall through — getKeyPair() stays null and we send plaintext
|
|
69
|
+
}
|
|
58
70
|
this.cryptoInitialized = true;
|
|
59
|
-
return !!(0, crypto_js_1.getKeyPair)();
|
|
60
71
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
if (!(0, crypto_js_1.getKeyPair)())
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const json = await this.request('GET', '/devices');
|
|
76
|
+
const recipients = (0, crypto_js_1.selectRecipients)(json.data ?? []);
|
|
77
|
+
if (recipients.length === 0) {
|
|
78
|
+
console.error('[Crypto] No device has a per-device public key — sending plaintext.');
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return recipients;
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.error('[Crypto] Could not list devices, sending plaintext:', err);
|
|
85
|
+
return null;
|
|
64
86
|
}
|
|
65
87
|
}
|
|
66
88
|
/**
|
|
@@ -99,19 +121,23 @@ class ZephHook {
|
|
|
99
121
|
// carry it. Explicit caller values win.
|
|
100
122
|
if (agentCtx)
|
|
101
123
|
payload = { ...agentCtx, ...payload };
|
|
102
|
-
const
|
|
124
|
+
const recipients = await this.ensureCrypto();
|
|
103
125
|
const body = payload.body;
|
|
104
126
|
const bodyBytes = body ? new TextEncoder().encode(body).byteLength : 0;
|
|
105
127
|
const isLongBody = bodyBytes > BODY_FILE_THRESHOLD;
|
|
106
128
|
if (isLongBody && body) {
|
|
107
|
-
return this.notifyWithFile(payload, body, bodyBytes,
|
|
129
|
+
return this.notifyWithFile(payload, body, bodyBytes, recipients);
|
|
108
130
|
}
|
|
109
131
|
// Encrypt push body if possible
|
|
110
132
|
let sendPayload = { ...payload };
|
|
111
|
-
if (
|
|
133
|
+
if (recipients) {
|
|
112
134
|
try {
|
|
113
|
-
const enc = await (0, crypto_js_1.
|
|
114
|
-
|
|
135
|
+
const enc = await (0, crypto_js_1.encryptPushBodyForDevices)({ title: payload.title, body: payload.body, url: payload.url }, recipients);
|
|
136
|
+
// `url: undefined` alongside the title: it is already sealed inside the
|
|
137
|
+
// ciphertext, and on a link push it IS the payload. Spreading `payload`
|
|
138
|
+
// above left the plaintext copy at the top level, handing the server the
|
|
139
|
+
// one thing `isEncrypted` promises it cannot see.
|
|
140
|
+
sendPayload = { ...sendPayload, title: undefined, url: undefined, body: enc.body, isEncrypted: true, deviceKeyMap: enc.deviceKeyMap, senderPublicKey: enc.senderPublicKey };
|
|
115
141
|
}
|
|
116
142
|
catch (err) {
|
|
117
143
|
console.error('[Crypto] Push encryption failed, sending plaintext:', err);
|
|
@@ -124,46 +150,51 @@ class ZephHook {
|
|
|
124
150
|
}
|
|
125
151
|
return { pushId };
|
|
126
152
|
}
|
|
127
|
-
async notifyWithFile(payload, body, fileSize,
|
|
153
|
+
async notifyWithFile(payload, body, fileSize, recipients) {
|
|
128
154
|
const fileName = 'response.md';
|
|
129
|
-
|
|
130
|
-
// Encrypt
|
|
131
|
-
let
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
let
|
|
135
|
-
if (
|
|
155
|
+
const preview = body.length > PREVIEW_LENGTH ? body.slice(0, PREVIEW_LENGTH) + '...' : body;
|
|
156
|
+
// Encrypt the attachment and the push body together, before anything is
|
|
157
|
+
// uploaded. Doing them one at a time around the upload let a failure land
|
|
158
|
+
// in between and ship ciphertext under a push with no `isEncrypted` — an
|
|
159
|
+
// attachment no client would even try to open.
|
|
160
|
+
let encrypted = null;
|
|
161
|
+
if (recipients) {
|
|
136
162
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
fileIv = encrypted.iv;
|
|
142
|
-
fileEncryptedKey = encrypted.encryptedKey;
|
|
163
|
+
encrypted = {
|
|
164
|
+
file: await (0, crypto_js_1.encryptFileForDevices)(body, recipients),
|
|
165
|
+
push: await (0, crypto_js_1.encryptPushBodyForDevices)({ title: payload.title, body: preview, url: payload.url }, recipients),
|
|
166
|
+
};
|
|
143
167
|
}
|
|
144
168
|
catch (err) {
|
|
145
|
-
console.error('[Crypto]
|
|
169
|
+
console.error('[Crypto] Encryption failed, sending plaintext:', err);
|
|
146
170
|
}
|
|
147
171
|
}
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
172
|
+
const uploadContent = encrypted?.file.ciphertext ?? body;
|
|
173
|
+
const uploadType = encrypted ? 'application/octet-stream' : inferMimeType(fileName);
|
|
174
|
+
const uploadSize = encrypted ? encrypted.file.ciphertext.length : fileSize;
|
|
175
|
+
const upload = await this.requestUpload({ fileName, fileType: uploadType, fileSize: uploadSize });
|
|
176
|
+
await this.uploadToS3(upload.uploadUrl, uploadContent, uploadType);
|
|
177
|
+
const sendPayload = {
|
|
153
178
|
...payload,
|
|
154
|
-
|
|
179
|
+
title: encrypted ? undefined : payload.title,
|
|
180
|
+
body: encrypted ? encrypted.push.body : preview,
|
|
155
181
|
type: payload.type ?? 'file',
|
|
156
|
-
files: [{
|
|
182
|
+
files: [{
|
|
183
|
+
fileKey: upload.fileKey,
|
|
184
|
+
fileName,
|
|
185
|
+
fileSize,
|
|
186
|
+
fileType: inferMimeType(fileName),
|
|
187
|
+
iv: encrypted?.file.iv,
|
|
188
|
+
deviceKeyMap: encrypted?.file.deviceKeyMap,
|
|
189
|
+
}],
|
|
190
|
+
...(encrypted && {
|
|
191
|
+
// See notifyOnce: the url must not survive in the clear either.
|
|
192
|
+
url: undefined,
|
|
193
|
+
isEncrypted: true,
|
|
194
|
+
deviceKeyMap: encrypted.push.deviceKeyMap,
|
|
195
|
+
senderPublicKey: encrypted.push.senderPublicKey,
|
|
196
|
+
}),
|
|
157
197
|
};
|
|
158
|
-
if (canEncrypt) {
|
|
159
|
-
try {
|
|
160
|
-
const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: payload.title, body: preview, url: payload.url });
|
|
161
|
-
sendPayload = { ...sendPayload, title: undefined, body: enc.body, isEncrypted: true, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
|
|
162
|
-
}
|
|
163
|
-
catch (err) {
|
|
164
|
-
console.error('[Crypto] Push encryption failed, sending plaintext:', err);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
198
|
const json = await this.request('POST', '/pushes/send', sendPayload);
|
|
168
199
|
const pushId = json.data?.pushId;
|
|
169
200
|
if (!pushId) {
|
|
@@ -199,8 +230,12 @@ class ZephHook {
|
|
|
199
230
|
const pushes = json.data.map((p) => ({
|
|
200
231
|
pushId: p.pushId,
|
|
201
232
|
type: p.type,
|
|
202
|
-
title
|
|
203
|
-
body:
|
|
233
|
+
// An encrypted push carries no plaintext title and a JSON envelope in
|
|
234
|
+
// `body`; slicing that renders `{"ciphertext":"AAAB...` as if it were the
|
|
235
|
+
// message. This host cannot decrypt (send-only), so say so instead.
|
|
236
|
+
title: p.isEncrypted ? undefined : p.title,
|
|
237
|
+
body: p.isEncrypted ? '[encrypted]' : p.body?.slice(0, 100),
|
|
238
|
+
isEncrypted: p.isEncrypted,
|
|
204
239
|
createdAt: p.createdAt,
|
|
205
240
|
}));
|
|
206
241
|
return { pushes, count: pushes.length, hasMore: json.pagination?.hasMore ?? false };
|