quoxcli 0.4.0 → 0.6.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.
Files changed (199) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -0,0 +1,617 @@
1
+ /**
2
+ * quox notes seal init|status|new|open|edit|print: ENQRYPT sealed notes (ENQRYPT stream E4, enqrypt ADR-0001/0002/0003).
3
+ *
4
+ * Sealing and opening happen HERE, on the client, with the owner's ENQRYPT identity (passphrase-protected key
5
+ * file, default ~/.quox/enqrypt). NotesQ only ever receives the sealed envelope: the server, the collector and
6
+ * agents cannot read a sealed note. Titles, collections and tags are NOT sealed (they stay searchable).
7
+ * ENQRYPT is an adapter dependency, loaded on demand; core Quox does not need it.
8
+ */
9
+ import { readFileSync, existsSync, mkdirSync, statSync, lstatSync, openSync, writeSync, closeSync, fsyncSync, renameSync, unlinkSync } from 'fs';
10
+ import { homedir } from 'os';
11
+ import { join, resolve, dirname } from 'path';
12
+ import { randomUUID, randomBytes, createHash } from 'crypto';
13
+ import { pathToFileURL } from 'url';
14
+ import { QuoxError, QuoxApiError } from '../core/errors.js';
15
+ import { resolveEndpoint } from '../api/endpoints.js';
16
+
17
+ export const SEAL_PURPOSE = 'notesq.note';
18
+ export const SEAL_MARK = 'enqrypt-sealed-note';
19
+ const FENCE = 'enqrypt-envelope';
20
+ const FP_RE = /^[0-9a-f]{64}$/;
21
+ const B64U_RE = /^[A-Za-z0-9_-]+$/;
22
+
23
+ export const keystoreDir = (opt) => resolve(opt || join(homedir(), '.quox', 'enqrypt'));
24
+
25
+ /** Markdown NotesQ stores for a sealed note: a heading, a plain explanation, and the envelope. Nothing secret. */
26
+ export function sealedNoteMarkdown({ title, envelope, fingerprint }) {
27
+ const b64 = Buffer.from(envelope).toString('base64url');
28
+ const wrapped = b64.match(/.{1,76}/g).join('\n');
29
+ return `# ${title}\n\n🔐 Sealed with ENQRYPT (HPKE, RFC 9180). The server stores only this envelope and cannot read it.\n`
30
+ + `Open it on a device holding the key with fingerprint \`${fingerprint.slice(0, 16)}…\`: \`quox notes seal open <id>\`\n\n`
31
+ + '```' + FENCE + '\n' + wrapped + '\n```\n';
32
+ }
33
+
34
+ export function extractEnvelope(markdown) {
35
+ const blocks = [...String(markdown || '').matchAll(new RegExp('```' + FENCE + '\\n([\\s\\S]*?)\\n```', 'g'))];
36
+ if (blocks.length !== 1) throw new QuoxError(blocks.length ? 'This note has more than one ENQRYPT envelope; refusing to guess.' : 'This note holds no ENQRYPT envelope.', 1);
37
+ const b64 = blocks[0][1].replace(/\n/g, '');
38
+ if (!B64U_RE.test(b64)) throw new QuoxError('The ENQRYPT envelope in this note is damaged.', 1);
39
+ return new Uint8Array(Buffer.from(b64, 'base64url'));
40
+ }
41
+
42
+ export function sealProperties({ fingerprint, objectId, version, collection, tags }) {
43
+ const p = { enqrypt: SEAL_MARK, enqrypt_fp: fingerprint, enqrypt_object: objectId, enqrypt_version: version };
44
+ if (collection) p.collection = collection;
45
+ if (tags) p.tags = tags;
46
+ return p;
47
+ }
48
+
49
+ export function readSealProperties(props) {
50
+ const p = props || {};
51
+ const version = Number(p.enqrypt_version);
52
+ if (p.enqrypt !== SEAL_MARK) throw new QuoxError('This note is not an ENQRYPT sealed note.', 1);
53
+ if (!FP_RE.test(String(p.enqrypt_fp)) || !/^[0-9a-f-]{36}$/.test(String(p.enqrypt_object)) || !Number.isSafeInteger(version) || version < 1) {
54
+ throw new QuoxError('This sealed note has damaged ENQRYPT properties.', 1);
55
+ }
56
+ return { fingerprint: p.enqrypt_fp, objectId: p.enqrypt_object, version };
57
+ }
58
+
59
+ // Rollback floor: the highest version this client has opened or written per sealed object. Older envelopes are
60
+ // refused. ENQRYPT-NOTES F11: the floor fails CLOSED. A floor file that is damaged, unreadable or not a floor map is
61
+ // refused, never read as 0; a floor file that has been used before (marker file, or a floor already seen in this
62
+ // process) and is now missing is refused too. Only a keystore that has never recorded a floor starts at 0. Writes
63
+ // are a fresh read, merge and atomic replace (temp file, fsync, rename) under a cross-process lock file, modelled on
64
+ // the ENQRYPT CLI's state files, so concurrent quox processes cannot lose each other's floors.
65
+ const floorFile = (dir) => join(dir, 'notes-floor.json');
66
+ const floorMarker = (dir) => join(dir, 'notes-floor.used');
67
+ const FLOOR_LOCK_STALE_MS = 30_000;
68
+ const FLOOR_LOCK_TIMEOUT_MS = 10_000;
69
+ const seenFloors = new Map(); // resolved dir -> Map(objectId -> highest floor seen in this process)
70
+
71
+ function floorRefusal(dir, why) {
72
+ return new QuoxError(
73
+ `The ENQRYPT rollback floor file ${floorFile(dir)} is ${why}, so this device cannot tell whether the server is serving an older version of a sealed note. Nothing was opened or written. `
74
+ + `Restore it from a backup of ${dir}. Only if you accept that an older version could be accepted once, delete ${floorFile(dir)} and ${floorMarker(dir)} to start the floors again.`, 1);
75
+ }
76
+
77
+ function memFloors(dir) {
78
+ const k = resolve(dir);
79
+ if (!seenFloors.has(k)) seenFloors.set(k, new Map());
80
+ return seenFloors.get(k);
81
+ }
82
+
83
+ // RT-13: the floor, its marker, the binding record and the lock are files this CLI writes itself (atomic rename,
84
+ // never through a link). A symbolic link in their place was planted, so it is refused, never followed.
85
+ const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
86
+ function markerPresent(dir) {
87
+ if (isLink(floorMarker(dir))) throw floorRefusal(dir, 'unverifiable (its marker file is a symbolic link)');
88
+ return existsSync(floorMarker(dir));
89
+ }
90
+
91
+ function loadFloors(dir) {
92
+ let text;
93
+ if (isLink(floorFile(dir))) throw floorRefusal(dir, 'a symbolic link (not a file this device wrote)');
94
+ try { text = readFileSync(floorFile(dir), 'utf8'); } catch (e) {
95
+ if (e && e.code === 'ENOENT') {
96
+ if (markerPresent(dir) || memFloors(dir).size > 0) throw floorRefusal(dir, 'missing');
97
+ return {};
98
+ }
99
+ throw floorRefusal(dir, `unreadable (${e && e.code ? e.code : 'read error'})`);
100
+ }
101
+ let all;
102
+ try { all = JSON.parse(text); } catch { throw floorRefusal(dir, 'damaged (not valid JSON)'); }
103
+ if (!all || typeof all !== 'object' || Array.isArray(all)) throw floorRefusal(dir, 'damaged (not a floor map)');
104
+ for (const v of Object.values(all)) {
105
+ if (!Number.isSafeInteger(v) || v < 1) throw floorRefusal(dir, 'damaged (a floor is not a positive whole number)');
106
+ }
107
+ return all;
108
+ }
109
+
110
+ export function readFloor(dir, objectId) {
111
+ const fromFile = Number(loadFloors(dir)[objectId]) || 0;
112
+ return Math.max(fromFile, memFloors(dir).get(objectId) || 0);
113
+ }
114
+
115
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
116
+ function pidAlive(pid) {
117
+ if (!Number.isInteger(pid) || pid <= 0) return false;
118
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
119
+ }
120
+ function readLockFile(lock) {
121
+ if (isLink(lock)) return { pid: 0, t: 0, nonce: '', link: true }; // RT-13/14: never follow a planted link
122
+ try { return JSON.parse(readFileSync(lock, 'utf8')); } catch (e) { return e.code === 'ENOENT' ? null : { pid: 0, t: 0, nonce: '' }; }
123
+ }
124
+ function lockAge(lock) {
125
+ try { return Date.now() - lstatSync(lock).mtimeMs; } catch { return 0; }
126
+ }
127
+ // RT-14: a lock whose time is further in the future than the stale bound (clock skew, or planted) is stale too,
128
+ // otherwise it would block every seal command forever.
129
+ const lockAged = (p) => { const age = lockAge(p); return age > FLOOR_LOCK_STALE_MS || age < -FLOOR_LOCK_STALE_MS; };
130
+ function breakStaleLock(lock, seen) {
131
+ const brk = `${lock}.break`;
132
+ let fd;
133
+ try { fd = openSync(brk, 'wx', 0o600); } catch (e) {
134
+ if (e.code !== 'EEXIST') throw e;
135
+ if (lockAged(brk)) { try { unlinkSync(brk); } catch { /* raced */ } }
136
+ return;
137
+ }
138
+ try {
139
+ const now = readLockFile(lock);
140
+ if (now && now.nonce === seen.nonce) { try { unlinkSync(lock); } catch { /* raced */ } }
141
+ } finally { closeSync(fd); try { unlinkSync(brk); } catch { /* raced */ } }
142
+ }
143
+ function withFloorLock(dir, fn) {
144
+ const lock = `${floorFile(dir)}.lock`;
145
+ const nonce = randomUUID();
146
+ const deadline = Date.now() + FLOOR_LOCK_TIMEOUT_MS;
147
+ for (;;) {
148
+ let fd = null;
149
+ try { fd = openSync(lock, 'wx', 0o600); } catch (e) { if (e.code !== 'EEXIST') throw e; }
150
+ if (fd !== null) {
151
+ try { writeSync(fd, JSON.stringify({ pid: process.pid, t: Date.now(), nonce })); } finally { closeSync(fd); }
152
+ break;
153
+ }
154
+ const held = readLockFile(lock);
155
+ if (held) {
156
+ // A lock with no nonce yet (holder between create and write) is only judged stale once it is old.
157
+ const stale = held.link || (held.nonce ? (!pidAlive(held.pid) || lockAged(lock)) : lockAged(lock));
158
+ if (stale) { breakStaleLock(lock, held); continue; }
159
+ }
160
+ if (Date.now() > deadline) throw new QuoxError(`The ENQRYPT rollback floor file is locked by another quox process (${lock}). Nothing was written; try again.`, 1);
161
+ sleepSync(5 + Math.floor(Math.random() * 20));
162
+ }
163
+ try { return fn(); } finally {
164
+ const h = readLockFile(lock);
165
+ if (h && h.nonce === nonce) { try { unlinkSync(lock); } catch { /* already gone */ } }
166
+ }
167
+ }
168
+ function atomicWrite(file, data) {
169
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
170
+ try {
171
+ const fd = openSync(tmp, 'wx', 0o600);
172
+ try { writeSync(fd, data); fsyncSync(fd); } finally { closeSync(fd); }
173
+ renameSync(tmp, file);
174
+ } catch (e) { try { unlinkSync(tmp); } catch { /* not created */ } throw e; }
175
+ try { const d = openSync(dirname(file), 'r'); try { fsyncSync(d); } finally { closeSync(d); } } catch { /* directory fsync is best effort */ }
176
+ }
177
+
178
+ export function bumpFloor(dir, objectId, version) {
179
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
180
+ withFloorLock(dir, () => {
181
+ const all = loadFloors(dir); // fresh read inside the lock; a damaged file refuses rather than being overwritten
182
+ const mem = memFloors(dir);
183
+ const next = Math.max(Number(all[objectId]) || 0, mem.get(objectId) || 0, version);
184
+ if (all[objectId] !== next) {
185
+ all[objectId] = next;
186
+ atomicWrite(floorFile(dir), JSON.stringify(all, null, 2) + '\n');
187
+ }
188
+ if (!markerPresent(dir)) atomicWrite(floorMarker(dir), 'This keystore has recorded ENQRYPT rollback floors in notes-floor.json. Do not delete either file.\n');
189
+ mem.set(objectId, next);
190
+ });
191
+ }
192
+
193
+ // NOTE BINDING (red team RT-15). ENQRYPT authenticates (op, purpose, objectId) but not which NotesQ note an
194
+ // envelope belongs to, so a server could serve note A's envelope (and properties) under note B. A BOUND object id
195
+ // commits to the note id: `<nonce8>-<h4>-0b1d-<h4>-<h12>`, the fixed third group marking the format (never a
196
+ // random v4 UUID, whose third group starts with 4) and h = sha256("notesq.note\0" + noteId + "\0" + nonce). The
197
+ // object id is inside the authenticated envelope, so an envelope bound to note A cannot verify as note B. Notes
198
+ // sealed before binding (random UUID object ids) still open, with a warning; this device also records which object
199
+ // it last saw for each note (notes-bind.json, next to the floor) and refuses a mismatch; `seal edit` re-seals an
200
+ // unbound note under a bound id. `seal new` cannot bind at creation (the server assigns the id after the envelope is
201
+ // sent), so a new note is bound by this device's record until its first `seal edit`.
202
+ const BIND_MARK = '0b1d';
203
+ const bindHash = (noteId, nonce) => createHash('sha256').update(`notesq.note\0${noteId}\0${nonce}`).digest('hex');
204
+ export function bindObjectId(noteId, nonce = randomBytes(4).toString('hex')) {
205
+ const h = bindHash(noteId, nonce);
206
+ return `${nonce}-${h.slice(0, 4)}-${BIND_MARK}-${h.slice(4, 8)}-${h.slice(8, 20)}`;
207
+ }
208
+ export const isBoundObjectId = (objectId) => /^[0-9a-f]{8}-[0-9a-f]{4}-0b1d-[0-9a-f]{4}-[0-9a-f]{12}$/.test(String(objectId));
209
+ export function objectBindsNote(objectId, noteId) {
210
+ if (!isBoundObjectId(objectId) || !noteId) return false;
211
+ return bindObjectId(String(noteId), String(objectId).slice(0, 8)) === objectId;
212
+ }
213
+
214
+ const bindFile = (dir) => join(dir, 'notes-bind.json');
215
+ function loadBinds(dir) {
216
+ let text;
217
+ if (isLink(bindFile(dir))) throw new QuoxError(`The note binding record ${bindFile(dir)} is a symbolic link (not a file this device wrote). Nothing was opened or written.`, 1);
218
+ try { text = readFileSync(bindFile(dir), 'utf8'); } catch (e) {
219
+ if (e && e.code === 'ENOENT') return {};
220
+ throw new QuoxError(`The note binding record ${bindFile(dir)} is unreadable (${e && e.code ? e.code : 'read error'}). Nothing was opened or written.`, 1);
221
+ }
222
+ let all;
223
+ try { all = JSON.parse(text); } catch { all = null; }
224
+ if (!all || typeof all !== 'object' || Array.isArray(all) || Object.values(all).some((v) => typeof v !== 'string')) {
225
+ throw new QuoxError(`The note binding record ${bindFile(dir)} is damaged. Nothing was opened or written. Restore it from a backup of ${dir}.`, 1);
226
+ }
227
+ return all;
228
+ }
229
+ export function recordBinding(dir, noteId, objectId) {
230
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
231
+ withFloorLock(dir, () => {
232
+ const all = loadBinds(dir);
233
+ if (all[noteId] === objectId) return;
234
+ all[noteId] = objectId;
235
+ atomicWrite(bindFile(dir), JSON.stringify(all, null, 2) + '\n');
236
+ });
237
+ }
238
+
239
+ /**
240
+ * After an envelope OPENED under objectId, check it belongs to noteId. Refuses a swap; warns for an unbound note
241
+ * this device cannot vouch for. Records the binding. Returns 'bound' | 'recorded' | 'unverified'.
242
+ */
243
+ export function checkNoteBinding(dir, noteId, objectId, { warn = (m) => console.error(m) } = {}) {
244
+ const swapped = (why) => new QuoxError(`Refusing note ${noteId}: ${why} The server may be serving another note's sealed envelope under this id. Nothing was shown or written.`, 1);
245
+ if (isBoundObjectId(objectId)) {
246
+ if (!objectBindsNote(objectId, noteId)) throw swapped(`its sealed envelope (object ${objectId}) is bound to a different note.`);
247
+ recordBinding(dir, noteId, objectId);
248
+ return 'bound';
249
+ }
250
+ const all = loadBinds(dir);
251
+ if (all[noteId] && all[noteId] !== objectId && !objectBindsNote(all[noteId], noteId)) throw swapped(`this device last saw it as sealed object ${all[noteId]}, but the server sent object ${objectId}.`);
252
+ if (all[noteId] && objectBindsNote(all[noteId], noteId)) throw swapped(`this device last saw it bound to object ${all[noteId]}, but the server sent an unbound envelope (object ${objectId}).`);
253
+ const other = Object.keys(all).find((k) => k !== noteId && all[k] === objectId);
254
+ if (other) throw swapped(`its sealed envelope (object ${objectId}) belongs to note ${other} on this device.`);
255
+ const known = all[noteId] === objectId;
256
+ if (!known) warn(`Warning: note ${noteId} was sealed before notes were bound to their ids, and this device has no record of it, so it cannot prove the server sent the right note. Re-seal it to bind it: quox notes seal edit ${noteId}`);
257
+ recordBinding(dir, noteId, objectId);
258
+ return known ? 'recorded' : 'unverified';
259
+ }
260
+
261
+ /** A passphrase file must be private to its owner; anything looser is refused rather than trusted. */
262
+ export function readPassphraseFile(path) {
263
+ const st = statSync(path);
264
+ if ((st.mode & 0o077) !== 0) throw new QuoxError(`Refusing ${path}: passphrase files must be chmod 600.`, 2);
265
+ return readFileSync(path, 'utf8').replace(/\r?\n$/, '');
266
+ }
267
+
268
+ // quox#1675: ENQRYPT is not on npm yet. The CLI looks in QUOX_ENQRYPT_PATH, then its own install location
269
+ // ~/.quox/enqrypt-src (a clone of quoxai/enqrypt main with its dependencies), then the package name. A directory
270
+ // counts only if it holds src/seal.mjs, and what loads must export every function the CLI calls (an old checkout
271
+ // is refused, not half used). A developer's working checkout is never picked up implicitly.
272
+ export const ENQRYPT_PACKAGE = '@quoxai/enqrypt-script';
273
+ export const ENQRYPT_NEEDS = ['createKeystore', 'unlockKeystore', 'readPublicIdentity', 'fingerprint', 'seal', 'open', 'parseEnvelope', 'toAsciiArmor', 'fromAsciiArmor'];
274
+ export const enqryptInstallDir = (home = homedir()) => join(home, '.quox', 'enqrypt-src');
275
+ export function enqryptSources({ env = process.env, home = homedir() } = {}) {
276
+ const dirs = [env.QUOX_ENQRYPT_PATH, enqryptInstallDir(home)].filter((d, i) => d && (i === 0 || existsSync(join(d, 'src', 'seal.mjs'))));
277
+ return [...dirs, ENQRYPT_PACKAGE];
278
+ }
279
+
280
+ export async function loadEnqryptFrom(src, importer = (u) => import(u)) {
281
+ const at = (sub) => (src === ENQRYPT_PACKAGE ? `${src}/${sub}` : pathToFileURL(join(src, 'src', `${sub}.mjs`)).href);
282
+ const [seal, keystore, armor] = await Promise.all([importer(at('seal')), importer(at('keystore')), importer(at('armor'))]);
283
+ const E = { ...seal, ...keystore, ...armor };
284
+ const missing = ENQRYPT_NEEDS.filter((f) => typeof E[f] !== 'function');
285
+ if (missing.length) throw new Error(`${src} is too old for quox notes seal (missing ${missing.join(', ')})`);
286
+ return E;
287
+ }
288
+
289
+ async function defaultLoadEnqrypt() {
290
+ const reasons = [];
291
+ for (const src of enqryptSources()) {
292
+ try { return await loadEnqryptFrom(src); } catch (e) {
293
+ if (src !== ENQRYPT_PACKAGE) reasons.push(/too old/.test(e.message) ? e.message : `${src} did not load`);
294
+ }
295
+ }
296
+ const tried = reasons.length ? ` Tried: ${reasons.join('; ')}.` : '';
297
+ throw new QuoxError(`ENQRYPT is not available on this machine. It is not on npm yet: clone quoxai/enqrypt into ${enqryptInstallDir()} and run \`npm ci\` there, or set QUOX_ENQRYPT_PATH to a checkout.${tried} Never \`npm install enqrypt\`: that name is an unrelated malicious package.`, 1);
298
+ }
299
+
300
+ async function askPassphrase(message, confirm = false) {
301
+ if (!process.stdin.isTTY) throw new QuoxError('No terminal to ask for the passphrase: pass --passphrase-file <chmod-600 file>.', 2);
302
+ const { default: inquirer } = await import('inquirer');
303
+ const { p } = await inquirer.prompt([{ type: 'password', name: 'p', message, mask: '*' }]);
304
+ if (confirm) {
305
+ const { q } = await inquirer.prompt([{ type: 'password', name: 'q', message: 'Repeat the passphrase', mask: '*' }]);
306
+ if (p !== q) throw new QuoxError('The passphrases did not match. Nothing was created.', 2);
307
+ }
308
+ return p;
309
+ }
310
+
311
+ // Properties a sealed note keeps visible (organisation only). Every other property of a converted note moves
312
+ // inside the seal, because a property can hold a secret as easily as the body can.
313
+ const VISIBLE_PROPS = ['collection', 'tags', 'pinned', 'archived'];
314
+ export const NEUTRAL_TITLE = 'Sealed note';
315
+
316
+ // RT-05: sealed means the seal mark, or a well-formed envelope block (same rule as quoxbrain lib/sealedNote.js), not
317
+ // a note that merely mentions the fence or carries some other enqrypt_* property.
318
+ const ENVELOPE_BLOCK_RE = new RegExp('```' + FENCE + '[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n[ \\t]*```', 'g');
319
+ export function isAlreadySealed(note) {
320
+ const p = (note && note.properties) || {};
321
+ if (p.enqrypt === SEAL_MARK) return true;
322
+ const text = String((note && (note.body ?? note.markdown)) || '');
323
+ return [...text.matchAll(ENVELOPE_BLOCK_RE)].some((m) => { const b = m[1].replace(/\s+/g, ''); return b.length >= 16 && B64U_RE.test(b); });
324
+ }
325
+
326
+ // RT-17: files attached with `quox notes attach` live in the org files store, linked from the body. Sealing the body
327
+ // moves the link inside the seal but leaves the FILE readable, so convert names them and refuses unless told to
328
+ // go ahead. Same pattern as ATTACH_REF in notes.js (a test pins the two equal).
329
+ export const NOTE_ATTACH_REF = /!?\[([^\]]*)\]\((\/api\/orgs\/[^/)\s]+\/files\/([^/?#)\s]+))\)/g;
330
+ export function noteAttachments(text) {
331
+ const seen = new Map();
332
+ for (const m of String(text || '').matchAll(NOTE_ATTACH_REF)) {
333
+ const fileId = decodeURIComponent(m[3]);
334
+ if (!seen.has(fileId)) seen.set(fileId, { name: m[1] || fileId, file_id: fileId });
335
+ }
336
+ return [...seen.values()];
337
+ }
338
+ const listAttachments = (a) => a.map((x) => `${x.name} (file ${x.file_id})`).join(', ');
339
+
340
+ /** Plain lines for what the server's seal purge removed, and what it cannot reach. */
341
+ export function purgeReport(id, r) {
342
+ const p = (r && r.purged) || {};
343
+ const n = (v) => (Number.isFinite(v) ? v : 0);
344
+ const lines = [
345
+ `Purged from the server for note ${id}: the text of ${n(p.revisions)} earlier revisions, ${n(p.embeddings)} embeddings (${n(p.ann_points_queued)} vector index points queued for deletion), ${n(p.beliefs_retracted)} derived belief(s) retracted. The search index now holds only the sealed envelope.`,
346
+ ];
347
+ if (p.belief_retract_error) lines.push(`Some derived beliefs could not be retracted yet (${p.belief_retract_error}); the retraction is queued and retried by the server.`);
348
+ lines.push((r && r.backups) || 'Backups taken before this purge still hold the old plaintext until they expire.');
349
+ return lines;
350
+ }
351
+
352
+ export function registerNotesSeal(notes, ctx) {
353
+ const { program, client, auth, call, getNote, putNote, readContent, isJson, printJson, announce } = ctx;
354
+ const loadEnqrypt = ctx.loadEnqrypt || defaultLoadEnqrypt;
355
+ const passphrase = (options, msg, confirm) => (options.passphraseFile ? readPassphraseFile(options.passphraseFile) : askPassphrase(msg, confirm));
356
+ const mapErr = (e) => {
357
+ if (e && (e.name === 'EnqryptError' || e.name === 'KeystoreError')) return new QuoxError(`ENQRYPT refused: ${e.code} (${e.message}). Nothing was shown.`, 1);
358
+ return e;
359
+ };
360
+ const unlock = async (E, options) => {
361
+ try { return await E.unlockKeystore({ dir: keystoreDir(options.keystore), passphrase: await passphrase(options, 'ENQRYPT passphrase') }); }
362
+ catch (e) { throw mapErr(e); }
363
+ };
364
+
365
+ const seal = notes.command('seal').description('ENQRYPT sealed notes: encrypted on this device, the server stores only the envelope (prototype, security review pending)');
366
+ const common = (cmd) => cmd
367
+ .option('--keystore <dir>', 'ENQRYPT keystore directory (default ~/.quox/enqrypt)')
368
+ .option('--passphrase-file <path>', 'read the passphrase from a chmod-600 file instead of asking');
369
+
370
+ common(seal.command('init').description('Create your ENQRYPT key (passphrase-protected) and show the one-time recovery key'))
371
+ .action(async (options) => {
372
+ const E = await loadEnqrypt();
373
+ const dir = keystoreDir(options.keystore);
374
+ if (existsSync(join(dir, 'identity.age'))) throw new QuoxError(`An ENQRYPT key already exists in ${dir}. Nothing was changed.`, 1);
375
+ console.log('If you lose BOTH this passphrase and the recovery key shown next, your sealed notes cannot be recovered by anyone.');
376
+ let out;
377
+ try { out = await E.createKeystore({ dir, passphrase: await passphrase(options, 'New ENQRYPT passphrase (10+ characters)', true) }); }
378
+ catch (e) { throw mapErr(e); }
379
+ if (isJson(program)) return printJson({ dir, fingerprint: out.fingerprint, recovery_key: out.recoveryKey });
380
+ console.log(`\nKey created in ${dir}\nFingerprint: ${out.fingerprint}\n`);
381
+ console.log('RECOVERY KEY (shown once; store it offline, it opens every sealed note):');
382
+ console.log(` ${out.recoveryKey}\n`);
383
+ announce('seal init', `key ${out.fingerprint.slice(0, 16)}…`);
384
+ });
385
+
386
+ common(seal.command('status').description('Show whether this device has an ENQRYPT key, and its fingerprint'))
387
+ .action(async (options) => {
388
+ const dir = keystoreDir(options.keystore);
389
+ let E;
390
+ try { E = await loadEnqrypt(); } catch (e) {
391
+ // status must report, not fail, when the seal backend is absent (the other verbs still refuse).
392
+ const reason = e && e.message ? e.message : 'ENQRYPT could not be loaded.';
393
+ if (isJson(program)) return printJson({ dir, backend: 'unavailable', fingerprint: null, reason });
394
+ return console.log(`ENQRYPT backend: not available on this machine, so no key can be read from ${dir}.\n${reason}`);
395
+ }
396
+ try {
397
+ const pub = await E.readPublicIdentity({ dir });
398
+ if (isJson(program)) return printJson({ dir, fingerprint: pub.fingerprint });
399
+ console.log(`ENQRYPT key in ${dir}\nFingerprint: ${pub.fingerprint}`);
400
+ } catch (e) {
401
+ if (e && e.code === 'MISSING') return console.log(`No ENQRYPT key in ${dir}. Create one: quox notes seal init`);
402
+ throw mapErr(e);
403
+ }
404
+ });
405
+
406
+ common(seal.command('new').description('Create a sealed note from --file or --stdin. The title is NOT sealed.'))
407
+ .option('--scope <scope>', 'user | org (default user)', 'user')
408
+ .option('--title <t>', 'visible title (not sealed)', 'Sealed note')
409
+ .option('--collection <c>', 'collection (not sealed)')
410
+ .option('--tag <t>', 'tag (repeatable, not sealed)', (v, prev) => (prev || []).concat(v))
411
+ .option('--file <path>', 'read the secret text from a file')
412
+ .option('--stdin', 'read the secret text from stdin')
413
+ .action(async (options) => {
414
+ auth.requireAuth();
415
+ if (!['user', 'org'].includes(options.scope)) throw new QuoxError('--scope must be user or org.', 2);
416
+ const text = await readContent(options);
417
+ if (text === null) throw new QuoxError('seal new needs --file <path> or --stdin (the secret is never taken from a flag).', 2);
418
+ const E = await loadEnqrypt();
419
+ const me = await unlock(E, options);
420
+ const fingerprint = await E.fingerprint(me.publicKey);
421
+ const objectId = randomUUID();
422
+ let envelope;
423
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, seq: 1, plaintext: new TextEncoder().encode(text) }); }
424
+ catch (e) { throw mapErr(e); }
425
+ const ep = resolveEndpoint('notesq.notes.create');
426
+ const body = { scope: options.scope, markdown: sealedNoteMarkdown({ title: options.title, envelope, fingerprint }), properties: sealProperties({ fingerprint, objectId, version: 1, collection: options.collection, tags: options.tag }) };
427
+ const data = await call(() => client.post(ep.path, { service: ep.service, body }));
428
+ bumpFloor(keystoreDir(options.keystore), objectId, 1);
429
+ if (data && data.note && data.note.id) recordBinding(keystoreDir(options.keystore), String(data.note.id), objectId);
430
+ if (isJson(program)) return printJson({ id: data.note?.id, object: objectId, version: 1, fingerprint });
431
+ console.log(`Sealed note ${data.note?.id} created (${text.length} characters sealed; the title "${options.title}" is visible).`);
432
+ announce('seal new', `sealed note ${data.note?.id}`);
433
+ });
434
+
435
+ common(seal.command('print').description('Print a sealed note as a plain ASCII armour block (ciphertext only, safe to print or file; no passphrase needed)'))
436
+ .argument('<id>', 'note id')
437
+ .action(async (id) => {
438
+ auth.requireAuth();
439
+ const n = await getNote(id);
440
+ const props = readSealProperties(n.properties);
441
+ const envelope = extractEnvelope(n.markdown ?? n.body);
442
+ const E = await loadEnqrypt();
443
+ const armor = await E.toAsciiArmor(envelope, { Version: '1', Key: props.fingerprint.slice(0, 16), Object: props.objectId, Note: String(id).slice(0, 64) });
444
+ if (isJson(program)) return printJson({ id, armor });
445
+ process.stdout.write(armor);
446
+ });
447
+
448
+ common(seal.command('open').description('Open a sealed note on this device and print it (by note id, or offline from a printed armour block)'))
449
+ .argument('[id]', 'note id (omit with --armor-file)')
450
+ .option('--armor-file <path>', 'open a printed/saved ASCII armour block instead of fetching the note ("-" = stdin)')
451
+ .action(async (id, options) => {
452
+ let envelope, objectId, expectFp = null, label = id;
453
+ const E = await loadEnqrypt();
454
+ if (options.armorFile) {
455
+ if (id) throw new QuoxError('Give a note id or --armor-file, not both.', 2);
456
+ const text = options.armorFile === '-' ? await readContent({ stdin: true }) : readFileSync(options.armorFile, 'utf8');
457
+ let parsed;
458
+ try { parsed = await E.fromAsciiArmor(text); } catch (e) { throw new QuoxError(`That armour block was refused: ${e.message}. Nothing was opened.`, 1); }
459
+ envelope = parsed.envelope;
460
+ objectId = parsed.headers.Object;
461
+ if (!/^[0-9a-f-]{36}$/.test(String(objectId))) throw new QuoxError('That armour block has no valid Object header; it cannot be bound to a note.', 1);
462
+ label = parsed.headers.Note || 'armour';
463
+ } else {
464
+ if (!id) throw new QuoxError('seal open needs a note id or --armor-file <path>.', 2);
465
+ auth.requireAuth();
466
+ const n = await getNote(id);
467
+ const props = readSealProperties(n.properties);
468
+ envelope = extractEnvelope(n.markdown ?? n.body);
469
+ objectId = props.objectId;
470
+ expectFp = props.fingerprint;
471
+ }
472
+ const me = await unlock(E, options);
473
+ const fingerprint = await E.fingerprint(me.publicKey);
474
+ if (expectFp && fingerprint !== expectFp) throw new QuoxError(`This note was sealed to key ${expectFp.slice(0, 16)}…, not this device's key ${fingerprint.slice(0, 16)}….`, 1);
475
+ const dir = keystoreDir(options.keystore);
476
+ let plain;
477
+ try { plain = await E.open({ recipient: me, senderPublicKey: me.publicKey, senderFingerprint: fingerprint, envelope, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, minVersion: readFloor(dir, objectId) }); }
478
+ catch (e) { throw mapErr(e); }
479
+ if (!options.armorFile) checkNoteBinding(dir, String(id), objectId);
480
+ else if (isBoundObjectId(objectId) && label !== 'armour' && !objectBindsNote(objectId, label)) throw new QuoxError(`Refusing that armour block: its sealed object ${objectId} is not bound to note ${label}. Nothing was opened.`, 1);
481
+ const v = E.parseEnvelope(envelope).seq;
482
+ bumpFloor(dir, objectId, v);
483
+ const text = new TextDecoder().decode(plain);
484
+ if (isJson(program)) return printJson({ id: label, version: v, text });
485
+ process.stdout.write(text.endsWith('\n') ? text : text + '\n');
486
+ });
487
+
488
+ common(seal.command('edit').description('Replace a sealed note\'s secret text (--file or --stdin); writes a new sealed version'))
489
+ .argument('<id>', 'note id')
490
+ .option('--file <path>', 'read the new secret text from a file')
491
+ .option('--stdin', 'read the new secret text from stdin')
492
+ .action(async (id, options) => {
493
+ auth.requireAuth();
494
+ const text = await readContent(options);
495
+ if (text === null) throw new QuoxError('seal edit needs --file <path> or --stdin.', 2);
496
+ const cur = await getNote(id);
497
+ const props = readSealProperties(cur.properties);
498
+ const E = await loadEnqrypt();
499
+ const me = await unlock(E, options);
500
+ const fingerprint = await E.fingerprint(me.publicKey);
501
+ if (fingerprint !== props.fingerprint) throw new QuoxError('This note was sealed to a different key; refusing to re-seal it.', 1);
502
+ // ENQRYPT-NOTES F10: the next version comes from the AUTHENTICATED envelope, never from the server's
503
+ // enqrypt_version property. Opening the current envelope proves its seq and object binding, and refuses it
504
+ // (ROLLBACK) when it is older than this device's floor, so a server cannot bury the owner's newest text.
505
+ const dir = keystoreDir(options.keystore);
506
+ const current = extractEnvelope(cur.markdown ?? cur.body);
507
+ const floor = readFloor(dir, props.objectId);
508
+ try { await E.open({ recipient: me, senderPublicKey: me.publicKey, senderFingerprint: fingerprint, envelope: current, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId: props.objectId, minVersion: floor }); }
509
+ catch (e) { throw mapErr(e); }
510
+ checkNoteBinding(dir, String(id), props.objectId); // RT-15: refuse another note's envelope before writing
511
+ const version = Math.max(E.parseEnvelope(current).seq, floor) + 1;
512
+ // RT-15: an unbound note is re-sealed under a fresh object id bound to this note; the old object's floor is
513
+ // raised too, so its older envelopes stay refused.
514
+ const rebind = !isBoundObjectId(props.objectId);
515
+ const objectId = rebind ? bindObjectId(String(id)) : props.objectId;
516
+ let envelope;
517
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, seq: version, plaintext: new TextEncoder().encode(text) }); }
518
+ catch (e) { throw mapErr(e); }
519
+ const title = String(cur.title || 'Sealed note');
520
+ const data = await putNote(id, { markdown: sealedNoteMarkdown({ title, envelope, fingerprint }), properties: { ...cur.properties, enqrypt_object: objectId, enqrypt_version: version }, base_hash: cur.content_hash });
521
+ bumpFloor(dir, props.objectId, version);
522
+ if (rebind) { bumpFloor(dir, objectId, version); recordBinding(dir, String(id), objectId); }
523
+ if (isJson(program)) return printJson({ id, version, revision: data.revision?.rev, ...(rebind ? { bound: true, object: objectId } : {}) });
524
+ console.log(`Sealed note ${id} updated to sealed version ${version}. Earlier revisions hold earlier envelopes, never plaintext.`);
525
+ if (rebind) console.log(`It is now bound to its note id (sealed object ${objectId}), so a server cannot pass another note's envelope off as this one.`);
526
+ announce('seal edit', `sealed note ${id} v${version}`);
527
+ });
528
+
529
+ // RT-04: the purge is bound to the exact sealed body this device checked (expected_hash), so a note that changes
530
+ // between the check and the purge is refused (409) instead of purged.
531
+ const runPurge = async (id, expectedHash) => {
532
+ const ep = resolveEndpoint('notesq.notes.sealPurge', { id });
533
+ try { return await call(() => client.post(ep.path, { service: ep.service, body: expectedHash ? { expected_hash: expectedHash } : {} })); } catch (e) {
534
+ if (e instanceof QuoxApiError && e.status === 403) throw new QuoxError(`Purge refused (${e.code}): only the author, the owner or an org admin can purge a sealed note, and only from a human session.`, 1);
535
+ if (e instanceof QuoxApiError && e.status === 409) throw new QuoxError(`Purge refused (${e.code}): the note is not sealed (or changed); nothing was purged.`, 1);
536
+ throw e;
537
+ }
538
+ };
539
+
540
+ common(seal.command('convert').description('Seal an EXISTING note on this device, then purge its earlier plaintext from the server (human only, irreversible, requires --yes)'))
541
+ .argument('<id>', 'note id')
542
+ .option('--keep-title', 'keep the current title visible (by default it is replaced with a neutral one)')
543
+ .option('--attachments-stay-plaintext', 'seal the text even though the note links attached files, which are NOT sealed and stay readable in the org files store')
544
+ .option('--yes', 'confirm: the note is sealed and its earlier plaintext revisions, embeddings and derived beliefs are purged')
545
+ .action(async (id, options) => {
546
+ auth.requireAuth();
547
+ if (!options.yes) throw new QuoxError(`Refusing to seal ${id} without --yes: sealing an existing note purges its earlier plaintext from the server (revisions, embeddings, derived beliefs) and cannot be undone.`, 2);
548
+ const cur = await getNote(id);
549
+ if (isAlreadySealed(cur)) throw new QuoxError(`Note ${id} is already sealed. Use \`quox notes seal edit ${id}\` to change it.`, 1);
550
+ const text = String(cur.body ?? cur.markdown ?? '');
551
+ const attachments = noteAttachments(`${text}\n${cur.markdown ?? ''}`);
552
+ if (attachments.length && !options.attachmentsStayPlaintext) {
553
+ throw new QuoxError(`Refusing to seal ${id}: it links ${attachments.length} attached file(s) that seal convert cannot seal: ${listAttachments(attachments)}. `
554
+ + 'Sealing would hide the links inside the seal while the files stay readable in the org files store. Nothing was changed. '
555
+ + `Download and delete them first (\`quox notes download <fileId>\`), or pass --attachments-stay-plaintext to seal only the text.`, 1);
556
+ }
557
+ const E = await loadEnqrypt();
558
+ const me = await unlock(E, options);
559
+ const fingerprint = await E.fingerprint(me.publicKey);
560
+ const objectId = bindObjectId(String(id)); // RT-15: the note id is known, so the sealed object is bound to it
561
+ let envelope;
562
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, seq: 1, plaintext: new TextEncoder().encode(text) }); }
563
+ catch (e) { throw mapErr(e); }
564
+ const oldTitle = String(cur.title || '');
565
+ const title = options.keepTitle && oldTitle ? oldTitle : NEUTRAL_TITLE;
566
+ const props = cur.properties || {};
567
+ const visible = Object.fromEntries(VISIBLE_PROPS.filter((k) => props[k] !== undefined && props[k] !== null).map((k) => [k, props[k]]));
568
+ const movedInside = Object.keys(props).filter((k) => !VISIBLE_PROPS.includes(k) && k !== 'title');
569
+ const properties = { ...sealProperties({ fingerprint, objectId, version: 1 }), ...visible };
570
+ const sealedPut = await putNote(id, { markdown: sealedNoteMarkdown({ title, envelope, fingerprint }), properties, base_hash: cur.content_hash });
571
+ bumpFloor(keystoreDir(options.keystore), objectId, 1);
572
+ recordBinding(keystoreDir(options.keystore), String(id), objectId);
573
+ let purged;
574
+ try { purged = await runPurge(id, sealedPut && sealedPut.note && sealedPut.note.content_hash); } catch (e) {
575
+ const why = (e && e.message) || 'unknown error';
576
+ throw new QuoxError(`Note ${id} is sealed now, but the purge was refused (${why}): its earlier revisions still hold the plaintext on the server. Finish from a human session: quox notes seal purge ${id} --yes`, 1);
577
+ }
578
+ if (isJson(program)) return printJson({ id, object: objectId, version: 1, fingerprint, title, kept_title: title === oldTitle, moved_inside_seal: movedInside, attachments_not_sealed: attachments, ...purged });
579
+ console.log(attachments.length
580
+ ? `Sealed note ${id} (${text.length} characters sealed on this device; the server now stores only the envelope for the note's text).`
581
+ : `Sealed note ${id} (${text.length} characters sealed on this device; the server now stores only the envelope).`);
582
+ if (attachments.length) console.log(`NOT sealed: ${attachments.length} attached file(s) stay readable in the org files store: ${listAttachments(attachments)}. Their links are now inside the seal.`);
583
+ console.log(title === oldTitle && options.keepTitle
584
+ ? `The title "${oldTitle}" stays visible: titles are NOT sealed.`
585
+ : `The title "${oldTitle}" was replaced with "${NEUTRAL_TITLE}" (titles are not sealed; use --keep-title to keep one visible).`);
586
+ if (movedInside.length) console.log(`These properties are now inside the seal, not visible: ${movedInside.join(', ')}. Still visible: ${Object.keys(visible).join(', ') || 'none'}.`);
587
+ for (const line of purgeReport(id, purged)) console.log(line);
588
+ announce('seal convert', `sealed note ${id}, history purged`);
589
+ });
590
+
591
+ common(seal.command('purge').description('Purge a sealed note\'s earlier plaintext from the server: revisions, embeddings, derived beliefs (human only, irreversible, requires --yes; first opens the note with your key)'))
592
+ .argument('<id>', 'note id')
593
+ .option('--yes', 'confirm the purge')
594
+ .action(async (id, options) => {
595
+ auth.requireAuth();
596
+ if (!options.yes) throw new QuoxError(`Refusing to purge ${id} without --yes: the earlier text of a sealed note is removed from the server and cannot be recovered.`, 2);
597
+ // RT-04: anyone who could write the note could dress plaintext up as sealed, and a purge would then erase its
598
+ // real history. So the purge runs only after THIS device opens the live envelope with the owner's key.
599
+ const cur = await getNote(id);
600
+ const props = readSealProperties(cur.properties);
601
+ const envelope = extractEnvelope(cur.markdown ?? cur.body);
602
+ const E = await loadEnqrypt();
603
+ const me = await unlock(E, options);
604
+ const fingerprint = await E.fingerprint(me.publicKey);
605
+ if (fingerprint !== props.fingerprint) throw new QuoxError(`Purge refused: note ${id} is not sealed to this device's key (${fingerprint.slice(0, 16)}…). Nothing was purged.`, 1);
606
+ try { await E.open({ recipient: me, senderPublicKey: me.publicKey, senderFingerprint: fingerprint, envelope, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId: props.objectId, minVersion: readFloor(keystoreDir(options.keystore), props.objectId) }); }
607
+ catch (e) {
608
+ const why = e && e.code ? e.code : (e && e.message) || 'open failed';
609
+ throw new QuoxError(`Purge refused: the envelope in note ${id} does not open with your key (${why}), so it may not be a genuine sealed note and its earlier revisions could be the only copy of the real text. Nothing was purged.`, 1);
610
+ }
611
+ checkNoteBinding(keystoreDir(options.keystore), String(id), props.objectId); // RT-15: another note's envelope is refused
612
+ const purged = await runPurge(id, cur.content_hash);
613
+ if (isJson(program)) return printJson({ id, ...purged });
614
+ for (const line of purgeReport(id, purged)) console.log(line);
615
+ announce('seal purge', `sealed note ${id} history purged`);
616
+ });
617
+ }
@@ -12,6 +12,7 @@
12
12
  * live loop gate: budget, risk, interval).
13
13
  */
14
14
 
15
+ import { withStepUp } from '../core/stepUpPrompt.js';
15
16
  import { formatOutput, formatDetail, printSuccess, printWarning } from '../core/output.js';
16
17
  import { parseJSON } from './helpers.js';
17
18
  import { resolveEndpoint } from '../api/endpoints.js';
@@ -181,7 +182,8 @@ export function registerObjectiveCommands(program, ctx) {
181
182
  .argument('<id>', 'objective ID')
182
183
  .action(async (id) => {
183
184
  auth.requireAuth();
184
- const data = await client.post(resolveEndpoint('objectives.approve-plan', { id }).path, { service: resolveEndpoint('objectives.approve-plan', { id }).service });
185
+ const planEp = resolveEndpoint('objectives.approve-plan', { id });
186
+ const data = await withStepUp((headers) => client.post(planEp.path, { service: planEp.service, headers }), { client, orgId: auth.getOrgId?.(program.opts().org) || null, io: ctx.stepUpIO, what: 'Approving this plan' });
185
187
  printSuccess(`Objective ${id}: ${data.status}${data.message ? ` — ${data.message}` : ''}`);
186
188
  });
187
189
 
@@ -231,7 +233,8 @@ export function registerObjectiveCommands(program, ctx) {
231
233
  const body = {};
232
234
  if (options.notes) body.notes = options.notes;
233
235
  const params = { id: objectiveId, checkpointId };
234
- const data = await client.post(resolveEndpoint('objectives.checkpoint-approve', params).path, { service: resolveEndpoint('objectives.checkpoint-approve', params).service, body });
236
+ const cpEp = resolveEndpoint('objectives.checkpoint-approve', params);
237
+ const data = await withStepUp((headers) => client.post(cpEp.path, { service: cpEp.service, headers, body }), { client, orgId: auth.getOrgId?.(program.opts().org) || null, io: ctx.stepUpIO, what: 'Approving this checkpoint' });
235
238
  // LOOPQ-HALT: "approved" is not "running". When the server says the loop did not start, say so in words.
236
239
  if (data.started === false) {
237
240
  printWarning(`Checkpoint ${checkpointId}: ${data.status}, but the objective did NOT start (objective ${data.objective_status || 'pending'}).`);