@cleocode/core 2026.4.11 → 2026.4.13

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.
Files changed (184) hide show
  1. package/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
  2. package/dist/codebase-map/analyzers/architecture.js +0 -1
  3. package/dist/codebase-map/analyzers/architecture.js.map +1 -1
  4. package/dist/conduit/local-transport.d.ts +18 -8
  5. package/dist/conduit/local-transport.d.ts.map +1 -1
  6. package/dist/conduit/local-transport.js +23 -13
  7. package/dist/conduit/local-transport.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +6 -0
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.js +175 -68950
  16. package/dist/index.js.map +1 -7
  17. package/dist/init.d.ts +1 -2
  18. package/dist/init.d.ts.map +1 -1
  19. package/dist/init.js +1 -2
  20. package/dist/init.js.map +1 -1
  21. package/dist/internal.d.ts +8 -3
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +13 -6
  24. package/dist/internal.js.map +1 -1
  25. package/dist/memory/learnings.d.ts +2 -2
  26. package/dist/memory/patterns.d.ts +6 -6
  27. package/dist/output.d.ts +32 -11
  28. package/dist/output.d.ts.map +1 -1
  29. package/dist/output.js +67 -67
  30. package/dist/output.js.map +1 -1
  31. package/dist/paths.js +80 -14
  32. package/dist/paths.js.map +1 -1
  33. package/dist/skills/dynamic-skill-generator.d.ts +0 -2
  34. package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
  35. package/dist/skills/dynamic-skill-generator.js.map +1 -1
  36. package/dist/store/agent-registry-accessor.d.ts +203 -12
  37. package/dist/store/agent-registry-accessor.d.ts.map +1 -1
  38. package/dist/store/agent-registry-accessor.js +618 -100
  39. package/dist/store/agent-registry-accessor.js.map +1 -1
  40. package/dist/store/api-key-kdf.d.ts +73 -0
  41. package/dist/store/api-key-kdf.d.ts.map +1 -0
  42. package/dist/store/api-key-kdf.js +84 -0
  43. package/dist/store/api-key-kdf.js.map +1 -0
  44. package/dist/store/cleanup-legacy.js +171 -0
  45. package/dist/store/cleanup-legacy.js.map +1 -0
  46. package/dist/store/conduit-sqlite.d.ts +184 -0
  47. package/dist/store/conduit-sqlite.d.ts.map +1 -0
  48. package/dist/store/conduit-sqlite.js +570 -0
  49. package/dist/store/conduit-sqlite.js.map +1 -0
  50. package/dist/store/global-salt.d.ts +78 -0
  51. package/dist/store/global-salt.d.ts.map +1 -0
  52. package/dist/store/global-salt.js +147 -0
  53. package/dist/store/global-salt.js.map +1 -0
  54. package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
  55. package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
  56. package/dist/store/migrate-signaldock-to-conduit.js +555 -0
  57. package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
  58. package/dist/store/nexus-sqlite.js +28 -3
  59. package/dist/store/nexus-sqlite.js.map +1 -1
  60. package/dist/store/signaldock-sqlite.d.ts +122 -19
  61. package/dist/store/signaldock-sqlite.d.ts.map +1 -1
  62. package/dist/store/signaldock-sqlite.js +401 -251
  63. package/dist/store/signaldock-sqlite.js.map +1 -1
  64. package/dist/store/sqlite-backup.js +122 -4
  65. package/dist/store/sqlite-backup.js.map +1 -1
  66. package/dist/system/backup.d.ts +0 -26
  67. package/dist/system/backup.d.ts.map +1 -1
  68. package/dist/system/runtime.d.ts +0 -2
  69. package/dist/system/runtime.d.ts.map +1 -1
  70. package/dist/system/runtime.js +3 -3
  71. package/dist/system/runtime.js.map +1 -1
  72. package/dist/tasks/add.d.ts +1 -1
  73. package/dist/tasks/add.d.ts.map +1 -1
  74. package/dist/tasks/add.js +98 -23
  75. package/dist/tasks/add.js.map +1 -1
  76. package/dist/tasks/complete.d.ts.map +1 -1
  77. package/dist/tasks/complete.js +4 -1
  78. package/dist/tasks/complete.js.map +1 -1
  79. package/dist/tasks/find.d.ts.map +1 -1
  80. package/dist/tasks/find.js +4 -1
  81. package/dist/tasks/find.js.map +1 -1
  82. package/dist/tasks/labels.d.ts.map +1 -1
  83. package/dist/tasks/labels.js +4 -1
  84. package/dist/tasks/labels.js.map +1 -1
  85. package/dist/tasks/relates.d.ts.map +1 -1
  86. package/dist/tasks/relates.js +16 -4
  87. package/dist/tasks/relates.js.map +1 -1
  88. package/dist/tasks/show.d.ts.map +1 -1
  89. package/dist/tasks/show.js +4 -1
  90. package/dist/tasks/show.js.map +1 -1
  91. package/dist/tasks/update.d.ts.map +1 -1
  92. package/dist/tasks/update.js +32 -6
  93. package/dist/tasks/update.js.map +1 -1
  94. package/dist/validation/engine.d.ts.map +1 -1
  95. package/dist/validation/engine.js +16 -4
  96. package/dist/validation/engine.js.map +1 -1
  97. package/dist/validation/param-utils.d.ts +5 -3
  98. package/dist/validation/param-utils.d.ts.map +1 -1
  99. package/dist/validation/param-utils.js +8 -6
  100. package/dist/validation/param-utils.js.map +1 -1
  101. package/dist/validation/protocols/_shared.d.ts.map +1 -1
  102. package/dist/validation/protocols/_shared.js +13 -6
  103. package/dist/validation/protocols/_shared.js.map +1 -1
  104. package/package.json +9 -7
  105. package/src/adapters/__tests__/manager.test.ts +0 -1
  106. package/src/codebase-map/analyzers/architecture.ts +0 -1
  107. package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
  108. package/src/conduit/__tests__/local-transport.test.ts +14 -12
  109. package/src/conduit/local-transport.ts +23 -13
  110. package/src/config.ts +0 -1
  111. package/src/errors.ts +24 -0
  112. package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
  113. package/src/init.ts +1 -2
  114. package/src/internal.ts +96 -2
  115. package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
  116. package/src/memory/__tests__/engine-compat.test.ts +2 -2
  117. package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
  118. package/src/observability/__tests__/index.test.ts +4 -4
  119. package/src/observability/__tests__/log-filter.test.ts +4 -4
  120. package/src/output.ts +73 -75
  121. package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
  122. package/src/sessions/__tests__/session-grade.test.ts +2 -2
  123. package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
  124. package/src/skills/dynamic-skill-generator.ts +0 -2
  125. package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
  126. package/src/store/__tests__/api-key-kdf.test.ts +113 -0
  127. package/src/store/__tests__/backup-crypto.test.ts +101 -0
  128. package/src/store/__tests__/backup-pack.test.ts +491 -0
  129. package/src/store/__tests__/backup-unpack.test.ts +298 -0
  130. package/src/store/__tests__/conduit-sqlite.test.ts +413 -0
  131. package/src/store/__tests__/global-salt.test.ts +195 -0
  132. package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -0
  133. package/src/store/__tests__/regenerators.test.ts +234 -0
  134. package/src/store/__tests__/restore-conflict-report.test.ts +274 -0
  135. package/src/store/__tests__/restore-json-merge.test.ts +521 -0
  136. package/src/store/__tests__/signaldock-sqlite.test.ts +652 -0
  137. package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
  138. package/src/store/__tests__/sqlite-backup.test.ts +5 -1
  139. package/src/store/__tests__/t310-integration.test.ts +1150 -0
  140. package/src/store/__tests__/t310-readiness.test.ts +111 -0
  141. package/src/store/__tests__/t311-integration.test.ts +661 -0
  142. package/src/store/agent-registry-accessor.ts +847 -140
  143. package/src/store/api-key-kdf.ts +104 -0
  144. package/src/store/backup-crypto.ts +209 -0
  145. package/src/store/backup-pack.ts +739 -0
  146. package/src/store/backup-unpack.ts +583 -0
  147. package/src/store/conduit-sqlite.ts +655 -0
  148. package/src/store/global-salt.ts +175 -0
  149. package/src/store/migrate-signaldock-to-conduit.ts +669 -0
  150. package/src/store/regenerators.ts +243 -0
  151. package/src/store/restore-conflict-report.ts +317 -0
  152. package/src/store/restore-json-merge.ts +653 -0
  153. package/src/store/signaldock-sqlite.ts +431 -254
  154. package/src/store/sqlite-backup.ts +185 -10
  155. package/src/store/t310-readiness.ts +119 -0
  156. package/src/system/backup.ts +2 -62
  157. package/src/system/runtime.ts +4 -6
  158. package/src/tasks/__tests__/error-hints.test.ts +256 -0
  159. package/src/tasks/add.ts +99 -9
  160. package/src/tasks/complete.ts +4 -1
  161. package/src/tasks/find.ts +4 -1
  162. package/src/tasks/labels.ts +4 -1
  163. package/src/tasks/relates.ts +16 -4
  164. package/src/tasks/show.ts +4 -1
  165. package/src/tasks/update.ts +32 -3
  166. package/src/validation/__tests__/error-hints.test.ts +97 -0
  167. package/src/validation/engine.ts +16 -1
  168. package/src/validation/param-utils.ts +10 -7
  169. package/src/validation/protocols/_shared.ts +14 -6
  170. package/src/validation/protocols/cant/architecture-decision.cant +80 -0
  171. package/src/validation/protocols/cant/artifact-publish.cant +95 -0
  172. package/src/validation/protocols/cant/consensus.cant +74 -0
  173. package/src/validation/protocols/cant/contribution.cant +82 -0
  174. package/src/validation/protocols/cant/decomposition.cant +92 -0
  175. package/src/validation/protocols/cant/implementation.cant +67 -0
  176. package/src/validation/protocols/cant/provenance.cant +88 -0
  177. package/src/validation/protocols/cant/release.cant +96 -0
  178. package/src/validation/protocols/cant/research.cant +66 -0
  179. package/src/validation/protocols/cant/specification.cant +67 -0
  180. package/src/validation/protocols/cant/testing.cant +88 -0
  181. package/src/validation/protocols/cant/validation.cant +65 -0
  182. package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
  183. package/templates/config.template.json +0 -1
  184. package/templates/global-config.template.json +0 -1
@@ -0,0 +1,104 @@
1
+ /**
2
+ * API key derivation functions for the T310 conduit/signaldock separation.
3
+ *
4
+ * New KDF (T310):
5
+ * HMAC-SHA256(machineKey || globalSalt, utf8(agentId)) → 32 bytes
6
+ *
7
+ * Legacy KDF (pre-T310, migration use only):
8
+ * HMAC-SHA256(machineKey, utf8(projectPath)) → 32 bytes
9
+ *
10
+ * The new scheme binds each key to machine + per-machine salt + agent identity,
11
+ * replacing the project-path scheme that does not generalise to global-tier identity.
12
+ *
13
+ * T355 will compose these primitives with `getGlobalSalt()` (T348).
14
+ * This module has NO runtime dependency on global-salt.ts.
15
+ *
16
+ * @task T349
17
+ * @epic T310
18
+ * @see ADR-037 §5 — KDF contract
19
+ * @module store/api-key-kdf
20
+ */
21
+
22
+ import { createHmac } from 'node:crypto';
23
+
24
+ /**
25
+ * Input parameters for the new T310 KDF.
26
+ *
27
+ * @task T349
28
+ * @epic T310
29
+ */
30
+ export interface DeriveApiKeyInput {
31
+ /** Machine-local key from the existing machine-key mechanism (v2026.4.11). */
32
+ machineKey: Buffer;
33
+ /** 32-byte global salt from global-salt.ts (T348). */
34
+ globalSalt: Buffer;
35
+ /** Agent ID that the API key belongs to. */
36
+ agentId: string;
37
+ }
38
+
39
+ /**
40
+ * Derives a 32-byte API key using the new T310 KDF:
41
+ *
42
+ * HMAC-SHA256(machineKey || globalSalt, utf8(agentId)) → 32 bytes
43
+ *
44
+ * Binds the key to machine + per-machine salt + agent identity. Replaces
45
+ * the pre-T310 `HMAC-SHA256(machineKey, projectPath)` scheme, which does
46
+ * not generalise to global-tier identity.
47
+ *
48
+ * @param input - The derivation input parameters.
49
+ * @returns A 32-byte Buffer containing the derived API key.
50
+ * @throws {Error} If machineKey is empty, globalSalt is not exactly 32 bytes,
51
+ * or agentId is empty.
52
+ *
53
+ * @task T349
54
+ * @epic T310
55
+ */
56
+ export function deriveApiKey(input: DeriveApiKeyInput): Buffer {
57
+ if (input.machineKey.length === 0) {
58
+ throw new Error('deriveApiKey: machineKey cannot be empty');
59
+ }
60
+ if (input.globalSalt.length !== 32) {
61
+ throw new Error(
62
+ `deriveApiKey: globalSalt must be exactly 32 bytes, got ${input.globalSalt.length}`,
63
+ );
64
+ }
65
+ if (input.agentId.length === 0) {
66
+ throw new Error('deriveApiKey: agentId cannot be empty');
67
+ }
68
+
69
+ const key = Buffer.concat([input.machineKey, input.globalSalt]);
70
+ const hmac = createHmac('sha256', key);
71
+ hmac.update(input.agentId, 'utf8');
72
+ return hmac.digest();
73
+ }
74
+
75
+ /**
76
+ * Derives the LEGACY pre-T310 API key using the old project-path scheme:
77
+ *
78
+ * HMAC-SHA256(machineKey, utf8(projectPath)) → 32 bytes
79
+ *
80
+ * Used ONLY by the migration executor (T358) to identify old keys that need
81
+ * invalidation. Not for ongoing use after T310.
82
+ *
83
+ * @param machineKey - Machine-local key from the existing machine-key mechanism.
84
+ * @param projectPath - Project path used as the HMAC message in the old scheme.
85
+ * @returns A 32-byte Buffer containing the derived legacy key.
86
+ * @throws {Error} If machineKey is empty or projectPath is empty.
87
+ *
88
+ * @deprecated Use deriveApiKey() for all new code. This exists for migration
89
+ * identification only (T358). Any new caller is a bug.
90
+ *
91
+ * @task T349
92
+ * @epic T310
93
+ */
94
+ export function deriveLegacyProjectKey(machineKey: Buffer, projectPath: string): Buffer {
95
+ if (machineKey.length === 0) {
96
+ throw new Error('deriveLegacyProjectKey: machineKey cannot be empty');
97
+ }
98
+ if (projectPath.length === 0) {
99
+ throw new Error('deriveLegacyProjectKey: projectPath cannot be empty');
100
+ }
101
+ const hmac = createHmac('sha256', machineKey);
102
+ hmac.update(projectPath, 'utf8');
103
+ return hmac.digest();
104
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Encryption/decryption helpers for `.enc.cleobundle.tar.gz` bundles.
3
+ *
4
+ * Uses AES-256-GCM with a scrypt-derived key (Node built-in `node:crypto` only;
5
+ * no native bindings). scrypt is memory-hard and NIST-approved. Argon2id (PHC
6
+ * winner, spec §7.1) was the original target but adds a native binding
7
+ * dependency that violates ADR-010. This module documents the trade-off:
8
+ * scrypt with N=2^15 provides equivalent interactive-session security and full
9
+ * cross-platform portability.
10
+ *
11
+ * Binary layout of an encrypted bundle:
12
+ * [8] magic bytes "CLEOENC1" (0x43 0x4C 0x45 0x4F 0x45 0x4E 0x43 0x31)
13
+ * [1] format version (0x01)
14
+ * [7] reserved (zero-filled)
15
+ * [32] scrypt salt (random, per-bundle)
16
+ * [12] AES-256-GCM nonce (random, per-bundle)
17
+ * [N] ciphertext (the tar.gz bytes)
18
+ * [16] AES-256-GCM authentication tag
19
+ *
20
+ * Total fixed overhead: 76 bytes header + 16 bytes auth tag = 92 bytes.
21
+ *
22
+ * @task T345
23
+ * @epic T311
24
+ * @see ADR-038 §5 — opt-in encrypted backups for portable export/import
25
+ * @module store/backup-crypto
26
+ */
27
+
28
+ import crypto from 'node:crypto';
29
+
30
+ /** Magic bytes that identify a CLEO encrypted bundle: ASCII "CLEOENC1". */
31
+ const MAGIC = Buffer.from('CLEOENC1', 'utf8'); // 8 bytes
32
+
33
+ /** Current format version byte written at offset 8. */
34
+ const VERSION = 0x01; // 1 byte
35
+
36
+ /** Reserved bytes at offsets 9–15 (zero-filled). */
37
+ const RESERVED = Buffer.alloc(7); // 7 bytes
38
+
39
+ /** Byte length of the per-bundle scrypt salt (offset 16). */
40
+ const SALT_SIZE = 32;
41
+
42
+ /** Byte length of the AES-256-GCM nonce (offset 48). */
43
+ const NONCE_SIZE = 12;
44
+
45
+ /** AES key length in bytes (AES-256). */
46
+ const KEY_SIZE = 32;
47
+
48
+ /** AES-256-GCM authentication tag length in bytes. */
49
+ const AUTH_TAG_SIZE = 16;
50
+
51
+ /**
52
+ * scrypt CPU/memory cost parameter (N = 2^15 = 32768).
53
+ * Provides ~64 MB memory hardness per derivation — equivalent to OWASP
54
+ * interactive-login recommendation when Argon2id is unavailable.
55
+ */
56
+ const SCRYPT_N = 2 ** 15;
57
+
58
+ /** scrypt block size parameter. */
59
+ const SCRYPT_R = 8;
60
+
61
+ /** scrypt parallelism parameter. */
62
+ const SCRYPT_P = 1;
63
+
64
+ /** Derived key length in bytes — must equal KEY_SIZE. */
65
+ const SCRYPT_KEY_LEN: typeof KEY_SIZE = KEY_SIZE;
66
+
67
+ /**
68
+ * Minimum valid byte length of an encrypted bundle.
69
+ * = magic(8) + version(1) + reserved(7) + salt(32) + nonce(12) + auth-tag(16)
70
+ */
71
+ const MIN_ENCRYPTED_LENGTH = 8 + 1 + 7 + SALT_SIZE + NONCE_SIZE + AUTH_TAG_SIZE;
72
+
73
+ /**
74
+ * Derives a 32-byte AES key from a user passphrase and a per-bundle salt
75
+ * using Node's built-in scrypt (RFC 7914).
76
+ *
77
+ * Parameters are chosen to match OWASP Argon2id interactive-login guidance
78
+ * adapted for scrypt: ~64 MB memory, single-threaded, ~100 ms on a 2024 laptop.
79
+ *
80
+ * @param passphrase - UTF-8 user passphrase (must be non-empty).
81
+ * @param salt - 32-byte random per-bundle salt.
82
+ * @returns 32-byte Buffer suitable for AES-256-GCM.
83
+ *
84
+ * @task T345
85
+ * @epic T311
86
+ */
87
+ function deriveKey(passphrase: string, salt: Buffer): Buffer {
88
+ return crypto.scryptSync(passphrase, salt, SCRYPT_KEY_LEN, {
89
+ N: SCRYPT_N,
90
+ r: SCRYPT_R,
91
+ p: SCRYPT_P,
92
+ // maxmem guard: 128 * N * r * 2 bytes — prevents accidental OOM
93
+ maxmem: 128 * SCRYPT_N * SCRYPT_R * 2,
94
+ }) as Buffer;
95
+ }
96
+
97
+ /**
98
+ * Encrypts a plaintext tarball buffer with AES-256-GCM.
99
+ *
100
+ * A fresh random 32-byte salt and 12-byte nonce are generated for every call,
101
+ * so two encryptions of the same plaintext produce different ciphertexts.
102
+ *
103
+ * Output binary layout:
104
+ * ```
105
+ * Offset Length Field
106
+ * 0 8 Magic bytes "CLEOENC1"
107
+ * 8 1 Format version 0x01
108
+ * 9 7 Reserved (zeros)
109
+ * 16 32 scrypt salt
110
+ * 48 12 GCM nonce
111
+ * 60 N Ciphertext
112
+ * 60+N 16 GCM auth tag
113
+ * ```
114
+ *
115
+ * @param plaintext - Raw `.cleobundle.tar.gz` bytes to encrypt.
116
+ * @param passphrase - User-supplied passphrase (must be non-empty).
117
+ * @returns Encrypted bundle bytes ready to write as `.enc.cleobundle.tar.gz`.
118
+ * @throws {Error} If `passphrase` is empty.
119
+ *
120
+ * @task T345
121
+ * @epic T311
122
+ */
123
+ export function encryptBundle(plaintext: Buffer, passphrase: string): Buffer {
124
+ if (passphrase.length === 0) {
125
+ throw new Error('encryptBundle: passphrase cannot be empty');
126
+ }
127
+
128
+ const salt = crypto.randomBytes(SALT_SIZE);
129
+ const nonce = crypto.randomBytes(NONCE_SIZE);
130
+ const key = deriveKey(passphrase, salt);
131
+
132
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
133
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
134
+ const authTag = cipher.getAuthTag();
135
+
136
+ return Buffer.concat([MAGIC, Buffer.from([VERSION]), RESERVED, salt, nonce, ciphertext, authTag]);
137
+ }
138
+
139
+ /**
140
+ * Decrypts an `.enc.cleobundle.tar.gz` payload back to its original tar.gz bytes.
141
+ *
142
+ * Validates magic bytes, format version, and the AES-256-GCM authentication
143
+ * tag. Any mismatch throws descriptively so callers can map to the correct
144
+ * exit code (E_BUNDLE_DECRYPT = 70).
145
+ *
146
+ * @param encrypted - Full encrypted bundle bytes (as read from disk).
147
+ * @param passphrase - User-supplied passphrase.
148
+ * @returns Decrypted tar.gz bytes.
149
+ * @throws {Error} `"decryptBundle: payload too short"` — buffer smaller than minimum.
150
+ * @throws {Error} `"decryptBundle: magic mismatch (not a cleo encrypted bundle)"` — invalid magic.
151
+ * @throws {Error} `"decryptBundle: unsupported version <n>, expected 1"` — unknown version byte.
152
+ * @throws {Error} `"decryptBundle: authentication failed (wrong passphrase or corrupted bundle)"` — GCM tag invalid.
153
+ *
154
+ * @task T345
155
+ * @epic T311
156
+ */
157
+ export function decryptBundle(encrypted: Buffer, passphrase: string): Buffer {
158
+ if (encrypted.length < MIN_ENCRYPTED_LENGTH) {
159
+ throw new Error('decryptBundle: payload too short');
160
+ }
161
+
162
+ if (!encrypted.subarray(0, 8).equals(MAGIC)) {
163
+ throw new Error('decryptBundle: magic mismatch (not a cleo encrypted bundle)');
164
+ }
165
+
166
+ const version = encrypted[8];
167
+ if (version !== VERSION) {
168
+ throw new Error(`decryptBundle: unsupported version ${version}, expected ${VERSION}`);
169
+ }
170
+
171
+ // Bytes 9–15 are reserved — ignored.
172
+ const salt = encrypted.subarray(16, 16 + SALT_SIZE);
173
+ const nonce = encrypted.subarray(16 + SALT_SIZE, 16 + SALT_SIZE + NONCE_SIZE);
174
+ const ciphertext = encrypted.subarray(
175
+ 16 + SALT_SIZE + NONCE_SIZE,
176
+ encrypted.length - AUTH_TAG_SIZE,
177
+ );
178
+ const authTag = encrypted.subarray(encrypted.length - AUTH_TAG_SIZE);
179
+
180
+ const key = deriveKey(passphrase, salt);
181
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
182
+ decipher.setAuthTag(authTag);
183
+
184
+ try {
185
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
186
+ } catch {
187
+ throw new Error('decryptBundle: authentication failed (wrong passphrase or corrupted bundle)');
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Tests whether a buffer starts with the CLEO encrypted bundle magic bytes
193
+ * `"CLEOENC1"`. Reads only the first 8 bytes; does not validate any other
194
+ * part of the header.
195
+ *
196
+ * Useful for detecting encrypted bundles before attempting decryption, e.g.
197
+ * when deciding whether to prompt for a passphrase.
198
+ *
199
+ * @param header - At least 8 bytes from the start of the file (may be longer).
200
+ * @returns `true` if the magic bytes match; `false` if the buffer is too short
201
+ * or the magic does not match.
202
+ *
203
+ * @task T345
204
+ * @epic T311
205
+ */
206
+ export function isEncryptedBundle(header: Buffer): boolean {
207
+ if (header.length < 8) return false;
208
+ return header.subarray(0, 8).equals(MAGIC);
209
+ }