quoxcli 0.5.0 → 0.6.1
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/CHANGELOG.md +65 -1
- package/lib/api/endpoints/admin.js +21 -0
- package/lib/api/endpoints/calendarq.js +8 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lens.js +34 -0
- package/lib/api/endpoints/lucid.js +18 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +6 -7
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/v1.js +57 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +86 -0
- package/lib/commands/approvals.js +5 -1
- package/lib/commands/calendar.js +27 -0
- package/lib/commands/chat.js +1 -1
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/fleet.js +164 -0
- package/lib/commands/fleetRecipe.js +174 -0
- package/lib/commands/lens.js +268 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +423 -0
- package/lib/commands/mail.js +287 -7
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +73 -1
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +507 -54
- package/lib/commands/research.js +78 -0
- package/lib/commands/settings.js +7 -1
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +74 -9
- package/lib/commands/wazuhIntake.js +55 -6
- package/lib/core/commandRegistrars.js +2 -0
- package/lib/core/config.js +14 -1
- package/lib/core/offload.js +83 -0
- package/lib/core/output.js +29 -4
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/version.js +15 -2
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +258 -49
- package/lib/quoxon/approvalsView.js +3 -1
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/bang.js +7 -1
- package/lib/quoxon/commandShort.js +1 -1
- package/lib/quoxon/commands.js +5 -5
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- package/lib/quoxon/dragonCompanion.js +24 -12
- package/lib/quoxon/events.js +12 -1
- package/lib/quoxon/extensions.js +2 -0
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/inboxView.js +5 -5
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +2 -1
- package/lib/quoxon/main.js +8 -2
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/render.js +5 -2
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/secretShape.js +2 -0
- package/lib/quoxon/slash/broadcast.js +30 -6
- package/lib/quoxon/slash/broadcastRun.js +12 -6
- package/lib/quoxon/slash/cliLeaves.json +54 -0
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +41 -0
- package/lib/quoxon/slash/paletteController.js +6 -2
- package/lib/quoxon/slash/restart.js +2 -0
- package/lib/quoxon/slotChat.js +7 -3
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/whatsnew.jsonl +7 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
- package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
- package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
- package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
- package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
- package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
- package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
- package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
- package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
- package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
- package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
- package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
- package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
- package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
- package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
- package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
- package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
- package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
- package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
- package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
- package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
- package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
- package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
- package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
- package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
- package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
- package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
- package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
|
@@ -10,10 +10,46 @@
|
|
|
10
10
|
* GET /api/v1/health/aggregate — same endpoint `quox status` uses
|
|
11
11
|
* GET /governance/incidents — list
|
|
12
12
|
* GET /governance/incidents/:id — detail + linked AEE envelopes
|
|
13
|
+
* POST /governance/incidents/:id/transition — claim (self only) / move status, witnessed
|
|
14
|
+
* (quox.governance.incident.updated; lib/governanceIncidentLifecycle.js)
|
|
15
|
+
*
|
|
16
|
+
* `incidents list --json` prints a bare array of incidents, the CLI-wide
|
|
17
|
+
* formatOutput convention for list verbs; proof cases read it by index
|
|
18
|
+
* (docs/matrix/suites/monitoring.json mon-incidents-open, mon-incident-detail),
|
|
19
|
+
* so the shape stays as is. `get --json` prints the incident object.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import { formatOutput, formatDetail } from '../core/output.js';
|
|
16
23
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
24
|
+
import { QuoxError } from '../core/errors.js';
|
|
25
|
+
|
|
26
|
+
// WAZ P11 slice 1b: statuses a person can move an incident to from the CLI. The collector
|
|
27
|
+
// validates against INCIDENT_STATUSES and enforces the resolution rule too; this only saves a call.
|
|
28
|
+
export const MOVE_STATUSES = ['investigating', 'resolved', 'closed', 'false_positive'];
|
|
29
|
+
const NEEDS_RESOLUTION = new Set(['resolved', 'closed', 'false_positive']);
|
|
30
|
+
const RESOLUTION_MAX = 500;
|
|
31
|
+
|
|
32
|
+
function printTransition(verb, id, data) {
|
|
33
|
+
const inc = data.incident || {};
|
|
34
|
+
const t = data.transition || {};
|
|
35
|
+
const w = data.witnessed || {};
|
|
36
|
+
const who = inc.assigned_to ? ` · assigned to ${inc.assigned_to}` : '';
|
|
37
|
+
const move = t.from && t.to ? `${t.from} -> ${t.to}` : (inc.status || '-');
|
|
38
|
+
const wit = w.stored ? `witnessed${w.id ? ` (${w.id})` : ''}` : `NOT witnessed (${w.reason || 'no envelope stored'})`;
|
|
39
|
+
console.log(`${w.stored ? '✔' : '⚠'} incidents ${verb} │ ${inc.id || id} · ${move}${who} │ ${wit}`);
|
|
40
|
+
if (inc.resolution && NEEDS_RESOLUTION.has(inc.status)) console.log(` resolution: ${inc.resolution}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Module level (not a closure) so cli/scripts/export-leaf-methods.mjs sees the POST as evidence for both leaves.
|
|
44
|
+
async function postTransition(client, program, verb, id, body) {
|
|
45
|
+
const ep = resolveEndpoint('governance.incidents.transition', { id });
|
|
46
|
+
const data = await client.post(ep.path, { service: ep.service, body });
|
|
47
|
+
if (!data || data.error || !data.incident) {
|
|
48
|
+
throw new QuoxError(`quox monitoring incidents ${verb}: ${ep.method} ${ep.path} returned an error body: ${(data && (data.error || data.message)) || 'response had no incident'}`, 1);
|
|
49
|
+
}
|
|
50
|
+
if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
51
|
+
printTransition(verb, id, data);
|
|
52
|
+
}
|
|
17
53
|
|
|
18
54
|
export function registerMonitoringCommands(program, ctx) {
|
|
19
55
|
const { client, auth } = ctx;
|
|
@@ -81,6 +117,42 @@ export function registerMonitoringCommands(program, ctx) {
|
|
|
81
117
|
.action(async (id) => {
|
|
82
118
|
auth.requireAuth();
|
|
83
119
|
const data = await client.get(resolveEndpoint('governance.incidents.get', { id }).path, { service: resolveEndpoint('governance.incidents.get', { id }).service });
|
|
84
|
-
|
|
120
|
+
const inc = data.incident || data;
|
|
121
|
+
// WAZ-11 (audit batch 5, N3): on a Wazuh incident the governance `escalated` flag means escalated as UNOWNED
|
|
122
|
+
// (inbox item + LUCID ask), not sent to QuoxSecurity; the human label says so. The field (and --json) is unchanged.
|
|
123
|
+
const labels = inc && inc.incident_type === 'wazuh_alert' ? { escalated: 'unowned-escalated' } : undefined;
|
|
124
|
+
formatDetail(inc, { format: program.opts().output, title: `Incident: ${id}`, ...(labels ? { labels } : {}) });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
incidents
|
|
128
|
+
.command('claim')
|
|
129
|
+
.description('Claim a governance incident: assigns it to you and moves open to investigating (witnessed)')
|
|
130
|
+
.argument('<id>', 'incident ID from `quox monitoring incidents list`')
|
|
131
|
+
.action(async (id) => {
|
|
132
|
+
auth.requireAuth();
|
|
133
|
+
await postTransition(client, program, 'claim', id, { assigned_to: 'me' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
incidents
|
|
137
|
+
.command('status')
|
|
138
|
+
.description(`Move a governance incident to ${MOVE_STATUSES.join('|')}; resolved, closed and false_positive need --resolution (witnessed)`)
|
|
139
|
+
.argument('<id>', 'incident ID from `quox monitoring incidents list`')
|
|
140
|
+
.argument('<status>', MOVE_STATUSES.join('|'))
|
|
141
|
+
.option('--resolution <text>', `what was done or why it is closing (required for resolved, closed, false_positive; max ${RESOLUTION_MAX} chars)`)
|
|
142
|
+
.action(async (id, status, options) => {
|
|
143
|
+
if (!MOVE_STATUSES.includes(status)) {
|
|
144
|
+
throw new QuoxError(`quox monitoring incidents status: status must be one of: ${MOVE_STATUSES.join(', ')} (got "${status}").`, 1);
|
|
145
|
+
}
|
|
146
|
+
const resolution = typeof options.resolution === 'string' ? options.resolution.trim() : '';
|
|
147
|
+
if (NEEDS_RESOLUTION.has(status) && !resolution) {
|
|
148
|
+
throw new QuoxError(`quox monitoring incidents status: moving to ${status} needs --resolution "<what was done>".`, 1);
|
|
149
|
+
}
|
|
150
|
+
if (resolution.length > RESOLUTION_MAX) {
|
|
151
|
+
throw new QuoxError(`quox monitoring incidents status: --resolution is at most ${RESOLUTION_MAX} characters (got ${resolution.length}).`, 1);
|
|
152
|
+
}
|
|
153
|
+
auth.requireAuth();
|
|
154
|
+
const body = { status };
|
|
155
|
+
if (resolution) body.resolution = resolution;
|
|
156
|
+
await postTransition(client, program, 'status', id, body);
|
|
85
157
|
});
|
|
86
158
|
}
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* agents cannot read a sealed note. Titles, collections and tags are NOT sealed (they stay searchable).
|
|
7
7
|
* ENQRYPT is an adapter dependency, loaded on demand; core Quox does not need it.
|
|
8
8
|
*/
|
|
9
|
-
import { readFileSync,
|
|
9
|
+
import { readFileSync, existsSync, mkdirSync, statSync, lstatSync, openSync, writeSync, closeSync, fsyncSync, renameSync, unlinkSync } from 'fs';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
|
-
import { join, resolve } from 'path';
|
|
12
|
-
import { randomUUID } from 'crypto';
|
|
11
|
+
import { join, resolve, dirname } from 'path';
|
|
12
|
+
import { randomUUID, randomBytes, createHash } from 'crypto';
|
|
13
13
|
import { pathToFileURL } from 'url';
|
|
14
|
-
import { QuoxError } from '../core/errors.js';
|
|
14
|
+
import { QuoxError, QuoxApiError } from '../core/errors.js';
|
|
15
15
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
16
16
|
|
|
17
17
|
export const SEAL_PURPOSE = 'notesq.note';
|
|
@@ -56,17 +56,206 @@ export function readSealProperties(props) {
|
|
|
56
56
|
return { fingerprint: p.enqrypt_fp, objectId: p.enqrypt_object, version };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
// Rollback floor: the highest version this client has opened per sealed object. Older envelopes are
|
|
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.
|
|
60
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
|
+
|
|
61
110
|
export function readFloor(dir, objectId) {
|
|
62
|
-
|
|
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
|
+
}
|
|
63
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
|
+
|
|
64
178
|
export function bumpFloor(dir, objectId, version) {
|
|
65
|
-
let all = {};
|
|
66
|
-
try { all = JSON.parse(readFileSync(floorFile(dir), 'utf8')); } catch { /* first use */ }
|
|
67
|
-
if (!(Number(all[objectId]) >= version)) all[objectId] = version;
|
|
68
179
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
69
|
-
|
|
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';
|
|
70
259
|
}
|
|
71
260
|
|
|
72
261
|
/** A passphrase file must be private to its owner; anything looser is refused rather than trusted. */
|
|
@@ -76,15 +265,36 @@ export function readPassphraseFile(path) {
|
|
|
76
265
|
return readFileSync(path, 'utf8').replace(/\r?\n$/, '');
|
|
77
266
|
}
|
|
78
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
|
+
|
|
79
289
|
async function defaultLoadEnqrypt() {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
} catch {
|
|
86
|
-
throw new QuoxError('ENQRYPT is not available on this machine. It is not published yet: set QUOX_ENQRYPT_PATH to a checkout of quoxai/enqrypt (never `npm install enqrypt`, that name is an unrelated malicious package).', 1);
|
|
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
|
+
}
|
|
87
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);
|
|
88
298
|
}
|
|
89
299
|
|
|
90
300
|
async function askPassphrase(message, confirm = false) {
|
|
@@ -98,6 +308,47 @@ async function askPassphrase(message, confirm = false) {
|
|
|
98
308
|
return p;
|
|
99
309
|
}
|
|
100
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
|
+
|
|
101
352
|
export function registerNotesSeal(notes, ctx) {
|
|
102
353
|
const { program, client, auth, call, getNote, putNote, readContent, isJson, printJson, announce } = ctx;
|
|
103
354
|
const loadEnqrypt = ctx.loadEnqrypt || defaultLoadEnqrypt;
|
|
@@ -175,6 +426,7 @@ export function registerNotesSeal(notes, ctx) {
|
|
|
175
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 }) };
|
|
176
427
|
const data = await call(() => client.post(ep.path, { service: ep.service, body }));
|
|
177
428
|
bumpFloor(keystoreDir(options.keystore), objectId, 1);
|
|
429
|
+
if (data && data.note && data.note.id) recordBinding(keystoreDir(options.keystore), String(data.note.id), objectId);
|
|
178
430
|
if (isJson(program)) return printJson({ id: data.note?.id, object: objectId, version: 1, fingerprint });
|
|
179
431
|
console.log(`Sealed note ${data.note?.id} created (${text.length} characters sealed; the title "${options.title}" is visible).`);
|
|
180
432
|
announce('seal new', `sealed note ${data.note?.id}`);
|
|
@@ -224,6 +476,8 @@ export function registerNotesSeal(notes, ctx) {
|
|
|
224
476
|
let plain;
|
|
225
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) }); }
|
|
226
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);
|
|
227
481
|
const v = E.parseEnvelope(envelope).seq;
|
|
228
482
|
bumpFloor(dir, objectId, v);
|
|
229
483
|
const text = new TextDecoder().decode(plain);
|
|
@@ -245,15 +499,119 @@ export function registerNotesSeal(notes, ctx) {
|
|
|
245
499
|
const me = await unlock(E, options);
|
|
246
500
|
const fingerprint = await E.fingerprint(me.publicKey);
|
|
247
501
|
if (fingerprint !== props.fingerprint) throw new QuoxError('This note was sealed to a different key; refusing to re-seal it.', 1);
|
|
248
|
-
|
|
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;
|
|
249
516
|
let envelope;
|
|
250
|
-
try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId
|
|
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) }); }
|
|
251
518
|
catch (e) { throw mapErr(e); }
|
|
252
519
|
const title = String(cur.title || 'Sealed note');
|
|
253
|
-
const data = await putNote(id, { markdown: sealedNoteMarkdown({ title, envelope, fingerprint }), properties: { ...cur.properties, enqrypt_version: version }, base_hash: cur.content_hash });
|
|
254
|
-
bumpFloor(
|
|
255
|
-
if (
|
|
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 } : {}) });
|
|
256
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.`);
|
|
257
526
|
announce('seal edit', `sealed note ${id} v${version}`);
|
|
258
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
|
+
});
|
|
259
617
|
}
|