@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 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. Turn the volume down without uninstalling: `/zeph-quiet --global`
49
- (blockers only, all projects), `/zeph-mute` (silence, current project),
50
- `/zeph-status` (what's in effect). See [Mute & push mode](#mute--push-mode).
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` | `/zeph-quiet --global` — the machine-wide default |
495
- | 4 | *(none)* | `normal` |
496
-
497
- So `/zeph-quiet --global` quiets every project that has no dial of its
498
- own, and a per-project dial always overrides it. Mute has no `-default`
499
- form on purpose: it is keyed on presence, not content, so a global mute
500
- could never be lifted for a single project.
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. The wrapping key is derived
608
- via ECDH P-256 and synced across your own devices on first run so every
609
- device can read the same push. Toggle encryption in the Zeph app
610
- (Settings Encryption); when disabled, the CLI sends plaintext. No
611
- configuration needed.
612
-
613
- **Threat model honesty:** keys are persisted on the Zeph backend to
614
- enable cross-device sync, so this is *device-shared* encryption not
615
- true end-to-end. It protects push contents from passive network
616
- observers and from a leaked database snapshot taken without the key
617
- store, but it does **not** protect against the Zeph backend itself (it
618
- has the keys it serves to your devices). A true E2E mode (per-device
619
- keypairs, server stores only public keys, no key escrow) is on the
620
- roadmap.
621
-
622
- The `zeph listener` ignores `isEncrypted` pushes for now it has no
623
- per-device key to decrypt them. Stop-hook auto-pushes and `zeph_ask`
624
- responses are not part of the `@<session>` injection path, so this
625
- doesn't affect normal use.
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.readPushMode)(projectDir),
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
- * Device-shared encryption for Hook SDK — self-contained ECDH P-256 +
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
- * Threat model honesty (do not call this "E2E" without a footnote):
7
+ * How it works (ADR-0007):
8
8
  *
9
- * The Zeph backend persists the per-user private key in plaintext so it
10
- * can be synced down to a fresh device (fetchServerKeys / uploadServerKeys
11
- * below). That means the backend can decrypt any push body this is NOT
12
- * end-to-end in the standard sense. What it gives you is:
13
- * Protection against passive network observers
14
- * Protection against a leaked DB snapshot taken without the key store
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
- * True E2E would require a per-device keypair (server stores only public
26
- * keys; senders wrap the message key once per recipient device public
27
- * key). That refactor is on the roadmap; until then, treat push bodies as
28
- * sensitive-but-not-secret.
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 crypto: sync keys with server, then fallback to local/generate.
38
- * Server is source of truth for per-user key pair.
39
- * Safe to call concurrently deduplicates to single init.
40
- * Returns the exported public key (Base64 SPKI).
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 a recipient.
59
- * Returns fields ready to merge into the sendPush payload.
60
- */
61
- export declare const encryptPushBody: (input: {
62
- title?: string;
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 encryptPushBodyForSelf: (input: {
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
- encryptedKey: string;
98
+ deviceKeyMap: DeviceKeyMap;
81
99
  senderPublicKey: string;
82
100
  isEncrypted: true;
83
101
  }>;
84
102
  /**
85
- * Encrypt file content for a recipient.
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 encryptFileForRecipient: (content: string, recipientPublicKeyRaw: string) => Promise<{
105
+ export declare const encryptFileForDevices: (content: string | Buffer, recipients: DeviceRecipient[]) => Promise<{
89
106
  ciphertext: Buffer;
90
107
  iv: string;
91
- encryptedKey: string;
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()
@@ -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;AA6ED;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAsE5E,CAAC;AA2CF,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,QAAO,IAIhC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACtD,uBAAuB,MAAM,KAC5B,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAeA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,KACrD,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAaA,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,uBAAuB,GAClC,SAAS,MAAM,EACf,uBAAuB,MAAM,KAC5B,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CASlE,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,KACd,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAQlE,CAAC;AAWF;;;;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"}
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
- * Device-shared encryption for Hook SDK — self-contained ECDH P-256 +
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
- * Threat model honesty (do not call this "E2E" without a footnote):
8
+ * How it works (ADR-0007):
9
9
  *
10
- * The Zeph backend persists the per-user private key in plaintext so it
11
- * can be synced down to a fresh device (fetchServerKeys / uploadServerKeys
12
- * below). That means the backend can decrypt any push body this is NOT
13
- * end-to-end in the standard sense. What it gives you is:
14
- * Protection against passive network observers
15
- * Protection against a leaked DB snapshot taken without the key store
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
- * True E2E would require a per-device keypair (server stores only public
27
- * keys; senders wrap the message key once per recipient device public
28
- * key). That refactor is on the roadmap; until then, treat push bodies as
29
- * sensitive-but-not-secret.
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.encryptFileForSelf = exports.encryptFileForRecipient = exports.encryptPushBodyForSelf = exports.encryptPushBody = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.initCrypto = void 0;
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
- // ─── File encryption ───
95
- const encryptFileContent = async (content, senderPrivateKey, recipientPublicKey) => {
96
- const buffer = new TextEncoder().encode(content).buffer;
97
- const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
98
- const iv = crypto.getRandomValues(new Uint8Array(12));
99
- const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
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
- return JSON.parse((0, fs_1.readFileSync)(KEYS_PATH, 'utf-8'));
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
- let cachedKeyPair = null;
128
- let cachedExportedPublicKey = null;
129
- let cachedOwnPublicKey = null;
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 crypto: sync keys with server, then fallback to local/generate.
133
- * Server is source of truth for per-user key pair.
134
- * Safe to call concurrently deduplicates to single init.
135
- * Returns the exported public key (Base64 SPKI).
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
- // Try local cache first
142
- const stored = loadStoredKeys();
143
- // Try server sync if API key available
144
- if (apiKey) {
145
- const serverResult = await fetchServerKeys(apiKey, baseUrl);
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
- // No API key local-only mode
183
- if (stored) {
184
- cachedKeyPair = await importKeyPair(stored);
185
- cachedExportedPublicKey = stored.publicKey;
186
- cachedOwnPublicKey = cachedKeyPair.publicKey;
187
- return stored.publicKey;
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 keyPair = await generateKeyPair();
190
- const exported = await exportKeyPair(keyPair);
191
- storeKeys(exported);
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 fetchServerKeys = async (apiKey, baseUrl) => {
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
- const keys = json.data?.encryptionKeys;
211
- const encryptionEnabled = json.data?.encryptionEnabled ?? (keys ? true : false);
163
+ cachedLegacyPublicKey = json.data?.encryptionKeys?.publicKey ?? null;
212
164
  return {
213
- keys: keys?.publicKey && keys?.privateKey ? keys : null,
214
- encryptionEnabled,
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
- // SECURITY: only the PUBLIC key is ever sent to the server. The server
222
- // rejects private-key uploads outright (per-device E2E escrow removed),
223
- // and a private key must never leave this host. Sending the full
224
- // ExportedKeyPair previously leaked the private key onto the wire on every
225
- // init and the rejection was swallowed silently. The per-device migration
226
- // (see ADR-0007) reworks this path; until then, register the public key only.
227
- const uploadServerKeys = async (keys, apiKey, baseUrl) => {
228
- try {
229
- const url = `${(baseUrl ?? 'https://api.zeph.to/v1').replace(/\/$/, '')}/users/me/keys`;
230
- await fetch(url, {
231
- method: 'PUT',
232
- headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
233
- body: JSON.stringify({ publicKey: keys.publicKey }),
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
- catch { /* non-critical */ }
214
+ if (Object.keys(keyMap).length === 0)
215
+ throw new Error('No recipient device accepted the wrapped key');
216
+ return keyMap;
237
217
  };
238
- const getKeyPair = () => cachedKeyPair;
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 = () => cachedExportedPublicKey;
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
- cachedKeyPair = null;
255
- cachedExportedPublicKey = null;
256
- cachedOwnPublicKey = null;
237
+ pushCryptoEnabled = false;
257
238
  };
258
239
  exports.disableCrypto = disableCrypto;
259
240
  /**
260
- * Encrypt push body for a recipient.
261
- * Returns fields ready to merge into the sendPush payload.
262
- */
263
- const encryptPushBody = async (input, recipientPublicKeyRaw) => {
264
- if (!cachedKeyPair || !cachedExportedPublicKey)
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 encryptPushBodyForSelf = async (input) => {
280
- if (!cachedKeyPair || !cachedExportedPublicKey || !cachedOwnPublicKey)
247
+ const encryptPushBodyForDevices = async (input, recipients) => {
248
+ if (!deviceKeyPair || !deviceExportedPublicKey)
281
249
  throw new Error('Crypto not initialized');
282
- const payload = await encrypt(JSON.stringify({ title: input.title, body: input.body, url: input.url }), cachedKeyPair.privateKey, cachedOwnPublicKey);
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: payload.ciphertext, iv: payload.iv }),
285
- encryptedKey: JSON.stringify({ encryptedKey: payload.encryptedKey, keyIv: payload.keyIv }),
286
- senderPublicKey: cachedExportedPublicKey,
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.encryptPushBodyForSelf = encryptPushBodyForSelf;
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 self (all own devices).
263
+ * Encrypt file content for the given recipient devices.
309
264
  */
310
- const encryptFileForSelf = async (content) => {
311
- if (!cachedKeyPair || !cachedOwnPublicKey)
265
+ const encryptFileForDevices = async (content, recipients) => {
266
+ if (!deviceKeyPair)
312
267
  throw new Error('Crypto not initialized');
313
- 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);
314
279
  return {
315
- ciphertext: result.ciphertext,
316
- iv: result.iv,
317
- encryptedKey: JSON.stringify({ encryptedKey: result.encryptedKey, keyIv: result.keyIv }),
280
+ ciphertext: Buffer.from(ciphertext),
281
+ iv: toBase64(iv.buffer),
282
+ deviceKeyMap: await wrapForDevices(rawFileKey, deviceKeyPair.privateKey, recipients),
318
283
  };
319
284
  };
320
- exports.encryptFileForSelf = encryptFileForSelf;
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
- /** The user's session push-mode dial (/zeph-quiet | /zeph-loud), default normal. */
45
- export declare const readPushMode: (dir: string) => GatePushMode;
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
@@ -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;;;;;GAKG;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,oFAAoF;AACpF,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,KAAG,YAU1C,CAAC"}
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
- /** The user's session push-mode dial (/zeph-quiet | /zeph-loud), default normal. */
137
- const readPushMode = (dir) => {
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 'normal';
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;
@@ -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,CA4G1F,CAAC"}
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`). Dial the volume');
493
- console.log(' any time — in Claude Code:');
494
- console.log(' /zeph-quiet --global only blockers + high-priority, all projects');
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;
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AA4FA,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"}
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
- // in normal mode the push still fires (gate defaults assume real work), but
31
- // the /zeph-quiet | /zeph-loud dial now works for every hook-driven agent.
32
- // Older installed `zeph` versions parse `--auto` as an unknown boolean flag
33
- // and ignore it graceful backward compatibility.
34
- const NOTIFY_CMD = '$(command -v zeph || echo "npx -y @zeph-to/cli") notify --title "Task done" --auto 2>/dev/null || true';
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
@@ -37,6 +37,8 @@ export interface PushItem {
37
37
  title?: string;
38
38
  body?: string;
39
39
  createdAt: string;
40
+ /** E2E: when true, `title`/`body` are absent and the ciphertext is in `body`. */
41
+ isEncrypted?: boolean;
40
42
  }
41
43
  export interface ListResult {
42
44
  pushes: PushItem[];
@@ -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;CACnB;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"}
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"}
@@ -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.
@@ -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;YASlB,YAAY;IAY1B;;;;;;;;;;;OAWG;IACG,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC;YAgB7C,UAAU;YAqCV,cAAc;IAsDtB,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;IAmB9C,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"}
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
- return !!(0, crypto_js_1.getKeyPair)();
56
- try {
57
- await (0, crypto_js_1.initCrypto)(this.apiKey, this.baseUrl);
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
- catch {
62
- this.cryptoInitialized = true;
63
- return false;
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 canEncrypt = await this.ensureCrypto();
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, canEncrypt);
129
+ return this.notifyWithFile(payload, body, bodyBytes, recipients);
108
130
  }
109
131
  // Encrypt push body if possible
110
132
  let sendPayload = { ...payload };
111
- if (canEncrypt) {
133
+ if (recipients) {
112
134
  try {
113
- const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: payload.title, body: payload.body, url: payload.url });
114
- sendPayload = { ...sendPayload, title: undefined, body: enc.body, isEncrypted: true, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
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, canEncrypt) {
153
+ async notifyWithFile(payload, body, fileSize, recipients) {
128
154
  const fileName = 'response.md';
129
- let fileType = inferMimeType(fileName);
130
- // Encrypt file content if possible
131
- let uploadContent = body;
132
- let uploadSize = fileSize;
133
- let fileIv;
134
- let fileEncryptedKey;
135
- if (canEncrypt) {
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
- const encrypted = await (0, crypto_js_1.encryptFileForSelf)(body);
138
- uploadContent = encrypted.ciphertext;
139
- uploadSize = encrypted.ciphertext.length;
140
- fileType = 'application/octet-stream';
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] File encryption failed, sending plaintext:', err);
169
+ console.error('[Crypto] Encryption failed, sending plaintext:', err);
146
170
  }
147
171
  }
148
- const upload = await this.requestUpload({ fileName, fileType, fileSize: uploadSize });
149
- await this.uploadToS3(upload.uploadUrl, uploadContent, fileType);
150
- const preview = body.length > PREVIEW_LENGTH ? body.slice(0, PREVIEW_LENGTH) + '...' : body;
151
- // Encrypt push body
152
- let sendPayload = {
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
- body: preview,
179
+ title: encrypted ? undefined : payload.title,
180
+ body: encrypted ? encrypted.push.body : preview,
155
181
  type: payload.type ?? 'file',
156
- files: [{ fileKey: upload.fileKey, fileName, fileSize, fileType: inferMimeType(fileName), iv: fileIv, encryptedKey: fileEncryptedKey }],
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: p.title,
203
- body: p.body?.slice(0, 100),
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "1.27.1",
3
+ "version": "2.0.0",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",