auroq-os 2.5.8 → 2.5.10

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.
@@ -1,7 +1,7 @@
1
1
  project:
2
2
  name: "Auroq OS"
3
3
  type: greenfield
4
- version: 2.5.8
4
+ version: 2.5.10
5
5
  installedAt: '2026-03-16'
6
6
 
7
7
  ide:
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+ // Public Supabase project configuration, shared with lib/supabase.js. Never use a service-role key.
3
+ module.exports = { url: 'https://tzvfkdqzdkftcqfourom.supabase.co', publicKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InR6dmZrZHF6ZGtmdGNxZm91cm9tIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5NTA2MTQsImV4cCI6MjA4NDUyNjYxNH0.Ajmg36iq6Aj3x9L5DjpYZKOYpnjImqxVtU9rw5vkEAM' };
@@ -0,0 +1,226 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const { spawn } = require('node:child_process');
7
+ const S = require('./security.cjs');
8
+ const VERSION = 'ahi-hooks-v2';
9
+ const MAX_INPUT = 1024 * 1024;
10
+ const MAX_EVENT = 60 * 1024;
11
+ const MAX_QUEUE = 100 * 1024 * 1024;
12
+ const MAX_FILES = 10000;
13
+ const TTL = 7 * 24 * 3600 * 1000;
14
+ const ACTIVE_TURN_TTL = 60 * 60 * 1000;
15
+ const IDLE_TTL = 60 * 1000;
16
+ const TERMINAL = ['Stop', 'SubagentStop', 'StopFailure', 'SessionEnd'];
17
+ const TYPES = { UserPromptSubmit: 'message.user', Stop: 'message.assistant', SubagentStop: 'message.assistant', PreToolUse: 'tool.started', PostToolUse: 'tool.completed', PostToolUseFailure: 'tool.failed', SessionStart: 'session.started', SessionEnd: 'session.ended', Interrupt: 'coverage.gap', StopFailure: 'coverage.gap' };
18
+ function credential() {
19
+ const cred = S.read(path.join(os.homedir(), '.arcane', 'credentials.json'));
20
+ return cred && /^[a-zA-Z0-9_-]{1,200}$/.test(cred.user?.id || '') && typeof cred.access_token === 'string' ? cred : null;
21
+ }
22
+ function configured(cwd, host = 'claude-code') {
23
+ if (host === 'codex') {
24
+ try {
25
+ const toml = fs.readFileSync(path.join(cwd, '.codex', 'config.toml'), 'utf8');
26
+ const block = toml.match(/^\[mcp_servers\.arcane\]\s*\n([\s\S]*?)(?=^\[|$(?![\s\S]))/m)?.[1];
27
+ if (!block || /^\s*enabled\s*=\s*false/m.test(block)) return false;
28
+ } catch { return false; }
29
+ }
30
+ const mcp = S.read(path.join(cwd, '.mcp.json'))?.mcpServers?.arcane;
31
+ if (!mcp || mcp.disabled === true || mcp.enabled === false) return false;
32
+ if (host === 'claude-code') {
33
+ for (const file of ['settings.json', 'settings.local.json']) {
34
+ const settings = S.read(path.join(cwd, '.claude', file), {});
35
+ if (settings.disableAllHooks || settings.disabledMcpjsonServers?.includes('arcane')) return false;
36
+ }
37
+ }
38
+ return true;
39
+ }
40
+ function resolveProject(cwd) {
41
+ const original = fs.realpathSync(cwd); let current = original;
42
+ for (let depth = 0; depth < 32; depth++) {
43
+ if (fs.existsSync(path.join(current, '.mcp.json')) || fs.existsSync(path.join(current, '.claude/hooks/ahi-capture.cjs'))) return current;
44
+ if (fs.existsSync(path.join(current, '.git'))) return original;
45
+ const parent = path.dirname(current); if (parent === current) break; current = parent;
46
+ }
47
+ return original;
48
+ }
49
+ function context(cwd = process.cwd(), host = 'claude-code', initialize = false) {
50
+ const project = resolveProject(cwd); const cred = credential();
51
+ if (!cred) return null;
52
+ const accountKey = S.hash(cred.user.id); const projectKey = S.hash(project);
53
+ const root = path.join(os.homedir(), '.arcane', 'ahi-v2');
54
+ const accountDir = path.join(root, accountKey); const dir = path.join(accountDir, projectKey);
55
+ const ctx = { project, host, cred, authId: cred.user.id, accountKey, projectKey, accountDir, dir, outbox: path.join(dir, 'outbox'), orderDir: path.join(dir, 'order'), scope: `${accountKey}:${projectKey}` };
56
+ if (initialize) {
57
+ for (const folder of [root, accountDir, dir, ctx.outbox, ctx.orderDir]) { S.safePath(folder); fs.mkdirSync(folder, { recursive: true, mode: 0o700 }); }
58
+ }
59
+ return ctx;
60
+ }
61
+ function lock(ctx, name, waitMs = ctx.lockWaitMs ?? 1500) {
62
+ const dir = path.join(ctx.dir, name); const start = Date.now();
63
+ while (true) {
64
+ try { fs.mkdirSync(dir, { mode: 0o700 }); S.atomic(path.join(dir, 'owner.json'), { pid: process.pid, created: Date.now() }, false, false); return () => { try { fs.rmSync(dir, { recursive: true }); } catch {} }; }
65
+ catch (e) {
66
+ if (e.code !== 'EEXIST') throw e;
67
+ S.safePath(dir);
68
+ const owner = S.read(path.join(dir, 'owner.json'));
69
+ let alive = true;
70
+ if (owner?.pid) { try { process.kill(owner.pid, 0); } catch (err) { alive = err.code !== 'ESRCH'; } }
71
+ else { try { alive = Date.now() - fs.statSync(dir).mtimeMs < 10000; } catch { continue; } }
72
+ if (!alive) { try { fs.rmSync(dir, { recursive: true }); } catch {} continue; }
73
+ if (Date.now() - start >= waitMs) return null;
74
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
75
+ }
76
+ }
77
+ }
78
+ function state(ctx) { return S.read(path.join(ctx.dir, 'status.json'), { captured: 0, acked: 0, discarded: 0, excluded: 0, coverage: 'partial' }); }
79
+ function update(ctx, changes) { S.atomic(path.join(ctx.dir, 'status.json'), { ...state(ctx), ...changes }); }
80
+ function record(ctx, kind, reason, count = 1) {
81
+ const release = lock(ctx, 'state.lock'); if (!release) return;
82
+ try {
83
+ const current = state(ctx);
84
+ const changes = { [kind]: (current[kind] || 0) + count, last_problem: reason, last_problem_at: new Date().toISOString(), coverage: 'partial' };
85
+ update(ctx, changes);
86
+ const file = path.join(ctx.dir, 'receipts.jsonl');
87
+ if (fs.existsSync(file) && fs.statSync(file).size > 1024 * 1024) fs.renameSync(file, path.join(ctx.dir, 'receipts.previous.jsonl'));
88
+ fs.appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), kind, reason, count }) + '\n', { mode: 0o600 });
89
+ } finally { release(); }
90
+ }
91
+ function validLease(ctx, session) {
92
+ const lease = S.read(path.join(ctx.dir, `lease-${S.hash(`${ctx.host}:${session || ''}`)}.json`));
93
+ // Boot marker + monotonic uptime prevent restart/offline and clock rollback extending a lease.
94
+ return lease?.enabled === true && lease.host === ctx.host && lease.native_session_id === session && lease.auth_user_id === ctx.authId && lease.boot === boot() && os.uptime() >= lease.uptime && os.uptime() < lease.untilUptime && Date.now() < lease.until ? lease : null;
95
+ }
96
+ function boot() { return Math.round((Date.now() / 1000 - os.uptime()) / 10); }
97
+ function orderName(row) {
98
+ return `${String(row.captured_at).padStart(13, '0')}-${String(row.capture_order || 0).padStart(10, '0')}-${row.event.event_id}`;
99
+ }
100
+ function queueFiles(ctx) {
101
+ try {
102
+ const indexed = fs.readdirSync(ctx.orderDir).filter(name => /^\d{13}-\d{10}-[0-9a-f-]{36}$/.test(name)).sort();
103
+ const rows = indexed.map(name => ({ order: name, file: path.join(ctx.outbox, `${name.slice(-36)}.json`) })).filter(x => fs.existsSync(x.file));
104
+ const known = new Set(rows.map(x => path.basename(x.file)));
105
+ // Recovery for an interrupted upgrade/legacy v2 writer: rare orphans only, not every ciphertext per batch.
106
+ for (const name of fs.readdirSync(ctx.outbox).filter(n => /^[0-9a-f-]{36}\.json$/.test(n) && !known.has(n))) {
107
+ const file = path.join(ctx.outbox, name); const envelope = S.read(file);
108
+ const time = Number(envelope?.queued_at) || fs.statSync(file).mtimeMs;
109
+ rows.push({ order: `${String(Math.floor(time)).padStart(13, '0')}-${String(envelope?.capture_order || 0).padStart(10, '0')}-${name}`, file });
110
+ }
111
+ return rows.sort((a, b) => a.order.localeCompare(b.order)).map(x => x.file);
112
+ } catch { return []; }
113
+ }
114
+ function removeOrder(ctx, eventId) {
115
+ try { for (const name of fs.readdirSync(ctx.orderDir).filter(x => x.endsWith(eventId))) fs.unlinkSync(path.join(ctx.orderDir, name)); } catch {}
116
+ }
117
+ function queueBytes(ctx) {
118
+ try { return fs.readdirSync(ctx.outbox).reduce((n, name) => { const st = fs.lstatSync(path.join(ctx.outbox, name)); return n + (st.isFile() ? st.size : 0); }, 0); } catch { return 0; }
119
+ }
120
+ function queueRow(ctx, row, publicKey) {
121
+ const file = path.join(ctx.outbox, `${row.event.event_id}.json`);
122
+ if (fs.existsSync(file)) return false;
123
+ if (S.read(path.join(ctx.dir, `terminal-${row.event.event_id}.json`))) return false;
124
+ const sealed = S.seal(row, publicKey, ctx.scope); sealed.session_key = S.hash(`${row.host}:${row.native_session_id}`); sealed.queued_at = row.captured_at; sealed.capture_order = row.capture_order || 0;
125
+ const order = path.join(ctx.orderDir, orderName(row));
126
+ try { S.atomic(order, '', true); } catch (e) { if (e.code !== 'EEXIST') throw e; }
127
+ S.atomic(file, sealed, true);
128
+ return true;
129
+ }
130
+ function launch(ctx) {
131
+ if (process.env.NODE_ENV === 'test' && process.env.AHI_TEST_NO_WORKER === '1') return;
132
+ try {
133
+ const owner = S.read(path.join(ctx.dir, 'worker.lock', 'owner.json'));
134
+ if (owner?.pid) { try { process.kill(owner.pid, 0); return; } catch {} }
135
+ const child = spawn(process.execPath, [path.join(__dirname, 'worker.cjs'), ctx.project, ctx.host], { cwd: ctx.project, detached: true, windowsHide: true, stdio: 'ignore' });
136
+ child.unref();
137
+ } catch { record(ctx, 'excluded', 'WORKER_UNAVAILABLE', 0); }
138
+ }
139
+ function sessionFile(ctx, session) { return path.join(ctx.dir, `session-${S.hash(`${ctx.host}:${session}`)}.json`); }
140
+ function eventFrom(input, session, occurrence) {
141
+ const name = input.hook_event_name || input.hookEventName;
142
+ const native = input.tool_use_id || input.call_id || input.event_id || input.message_id || (input.turn_id ? `${input.turn_id}:${input.agent_id || ''}` : null);
143
+ const origin = `${name}:${native || `local-${occurrence}`}`;
144
+ let type = TYPES[name];
145
+ if (name === 'PostToolUse' && (input.tool_response?.isError || input.tool_response?.error)) type = 'tool.failed';
146
+ let raw;
147
+ if (type === 'message.user') raw = { text: input.prompt };
148
+ else if (type === 'message.assistant') raw = { text: input.last_assistant_message, agent: input.agent_type || null };
149
+ else if (type?.startsWith('tool.')) raw = { tool: input.tool_name, input: input.tool_input ?? null, output: input.tool_response ?? null, error: input.error ?? null };
150
+ else raw = { reason: name === 'Interrupt' ? 'HOST_INTERRUPTED' : name === 'StopFailure' ? 'HOST_FAILED' : name };
151
+ let reason = null;
152
+ if (!type) { type = 'coverage.gap'; reason = 'UNSUPPORTED_HOOK'; }
153
+ if ((type === 'message.user' || type === 'message.assistant') && typeof raw.text !== 'string') { type = 'coverage.gap'; reason = 'MISSING_MESSAGE_TEXT'; }
154
+ const clean = S.scrub(raw);
155
+ if (Buffer.byteLength(JSON.stringify(clean.value)) > MAX_EVENT) { type = 'coverage.gap'; reason = 'EVENT_TOO_LARGE'; }
156
+ const payload = reason ? { reason, excluded_count: 1 } : { ...clean.value, identity_quality: native ? 'native' : 'local-occurrence', coverage: 'partial', excluded_fields: clean.excluded };
157
+ return { origin, event: { type, occurred_at: new Date().toISOString(), evidence_kind: 'observed', payload, redacted: clean.redacted, truncated: Boolean(reason || clean.excluded) } };
158
+ }
159
+ function capture(input, cwd = process.cwd(), host = 'claude-code') {
160
+ if (!['claude-code', 'codex'].includes(host) || !configured(cwd, host)) return { capture: 'off', reason: 'MCP_DISABLED' };
161
+ const ctx = context(cwd, host, true);
162
+ if (!ctx) return { capture: 'off', reason: 'AUTH_REQUIRED' };
163
+ const nativeSession = input.session_id || input.sessionId;
164
+ const session = typeof nativeSession === 'string' ? (/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(nativeSession) ? nativeSession.toLowerCase() : S.id(nativeSession)) : null;
165
+ const name = input.hook_event_name || input.hookEventName;
166
+ // Allow an ordinary concurrent capture to finish; two bounded local waits stay below the host's 3s timeout.
167
+ if (TERMINAL.includes(name)) ctx.lockWaitMs = 500;
168
+ if (typeof session !== 'string' || !/^[a-zA-Z0-9_-]{1,200}$/.test(session) || !TYPES[name]) { record(ctx, 'excluded', 'UNSUPPORTED_SCHEMA'); return { capture: 'unsupported' }; }
169
+ S.atomic(path.join(ctx.dir, 'activity.json'), { at: Date.now(), host }, false, false);
170
+ const release = lock(ctx, 'capture.lock');
171
+ if (!release) { record(ctx, 'excluded', 'CAPTURE_BUSY'); return { capture: 'error' }; }
172
+ try {
173
+ const sf = sessionFile(ctx, session); const sess = S.read(sf, { seen_mcp: false, occurrence: 0 });
174
+ if (sess.revoked) return { capture: 'off', reason: 'SESSION_REVOKED' };
175
+ const usesMcp = /^mcp__arcane__/.test(input.tool_name || '') && /^(PreToolUse|PostToolUse|PostToolUseFailure)$/.test(name);
176
+ if (usesMcp) sess.seen_mcp = true;
177
+ sess.native_session_id = session; sess.occurrence++; sess.last_hook = name; sess.host = host; sess.active = name !== 'SessionEnd'; sess.last_seen = Date.now();
178
+ // Only real host activity extends a turn. License heartbeats never touch this deadline.
179
+ if (name === 'UserPromptSubmit' || ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'].includes(name)) {
180
+ sess.turn_active = true; sess.turn_deadline = sess.last_seen + ACTIVE_TURN_TTL; sess.turn_expired = false;
181
+ } else if (['Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(name)) {
182
+ sess.turn_active = false; sess.turn_deadline = null;
183
+ } else if (name === 'SubagentStop' && sess.turn_active) {
184
+ // The child finishing is activity, not the end of its parent's turn.
185
+ sess.turn_deadline = sess.last_seen + ACTIVE_TURN_TTL;
186
+ }
187
+ S.atomic(sf, sess);
188
+ if (!sess.seen_mcp) { launch(ctx); return { capture: 'waiting_mcp', reason: 'NO_MCP_USE_IN_SESSION' }; }
189
+ if (!validLease(ctx, session)) { launch(ctx); return { capture: 'waiting_lease' }; }
190
+ const publicKey = S.read(path.join(ctx.dir, 'public-key.json'))?.key;
191
+ if (!publicKey || S.hash(publicKey) !== validLease(ctx, session).key_fingerprint) { record(ctx, 'excluded', 'SECURE_STORAGE_UNAVAILABLE'); launch(ctx); return { capture: 'unsupported' }; }
192
+ const files = queueFiles(ctx); const bytes = queueBytes(ctx);
193
+ if (files.length >= MAX_FILES || bytes >= MAX_QUEUE) { record(ctx, 'excluded', 'QUEUE_QUOTA'); launch(ctx); return { capture: 'limited' }; }
194
+ const current = state(ctx);
195
+ if (current.excluded > (current.reported_excluded || 0)) {
196
+ const gapId = S.id(`${ctx.scope}:local-exclusions:${current.excluded}`);
197
+ const gap = { schema_version: 1, host, adapter_version: VERSION, project_key: ctx.projectKey, native_session_id: session, captured_at: Date.now(), event: { event_id: gapId, source_event_id: `CoverageGap:${S.hash(gapId)}`, type: 'coverage.gap', occurred_at: new Date().toISOString(), evidence_kind: 'observed', redacted: false, truncated: true, payload: { reason: 'LOCAL_EXCLUSIONS', scope: 'project', excluded_count: current.excluded - (current.reported_excluded || 0), latest_reason: current.last_problem || 'UNKNOWN', coverage: 'partial' } } };
198
+ queueRow(ctx, gap, publicKey);
199
+ update(ctx, { reported_excluded: current.excluded });
200
+ }
201
+ const item = eventFrom(input, session, sess.occurrence);
202
+ const eventId = S.id(`${ctx.scope}:${host}:${session}:${item.origin}:${S.hash(JSON.stringify(item.event.payload))}`);
203
+ item.event.event_id = eventId; item.event.source_event_id = `${name}:${S.hash(`${item.origin}:${S.hash(JSON.stringify(item.event.payload))}`)}`;
204
+ // Stable identity/revisions, plus first observed timestamp, survive transport retries.
205
+ const row = { schema_version: 1, host, adapter_version: VERSION, project_key: ctx.projectKey, native_session_id: session, captured_at: Date.now(), capture_order: sess.occurrence, event: item.event };
206
+ const added = queueRow(ctx, row, publicKey);
207
+ if (added) {
208
+ const releaseState = lock(ctx, 'state.lock');
209
+ if (releaseState) { try { const st = state(ctx); update(ctx, { captured: st.captured + 1, last_capture_at: new Date().toISOString(), capability: `${host}:hooks-partial`, last_host: host }); } finally { releaseState(); } }
210
+ }
211
+ launch(ctx); return { capture: 'queued', event_id: eventId, duplicate: !added };
212
+ } finally { release(); }
213
+ }
214
+ function status(cwd = process.cwd(), host = 'claude-code') {
215
+ cwd = resolveProject(cwd);
216
+ const on = configured(cwd, host); const ctx = context(cwd, host);
217
+ const st = ctx ? state(ctx) : {};
218
+ const installed = fs.existsSync(path.join(cwd, '.claude/hooks/ahi-capture.cjs'));
219
+ const files = ctx ? queueFiles(ctx) : [];
220
+ const sessions = ctx && fs.existsSync(ctx.dir) ? fs.readdirSync(ctx.dir).filter(x => /^session-[a-f0-9]+\.json$/.test(x)).map(x => S.read(path.join(ctx.dir, x))).filter(Boolean) : [];
221
+ const activeTurns = sessions.filter(x => x.host === host && x.active && x.turn_active && x.turn_deadline > Date.now()).length;
222
+ const lease = ctx ? sessions.some(x => x.host === host && x.seen_mcp && x.active && validLease(ctx, x.native_session_id)) : null;
223
+ const reason = !on ? 'MCP_DISABLED' : !installed ? 'HOOK_NOT_INSTALLED' : !ctx ? 'AUTH_REQUIRED' : !lease ? 'NO_VALID_LICENSE_LEASE' : !st.last_capture_at ? 'NO_CAPTURE_PROVEN' : null;
224
+ return { ok: true, configured: on, capture: reason ? 'inactive' : 'active_partial', reason, host, adapter_version: VERSION, coverage: 'partial', active_turns: activeTurns, active_turn_timeout_ms: ACTIVE_TURN_TTL, capability: 'visible_hook_messages_and_tools', unsupported: ['intermediate_assistant_messages', 'transcripts', 'binary_artifacts', 'global_history'], pending: files.length, pending_bytes: ctx ? queueBytes(ctx) : 0, last_ack_at: st.last_ack_at || null, captured: st.captured || 0, acked: st.acked || 0, discarded: st.discarded || 0, excluded: st.excluded || 0, last_problem: st.last_problem || null, pause: false, legacy_plaintext_exists: fs.existsSync(path.join(cwd, '.auroq/ahi/events.jsonl')), legacy_imported: false, secure_storage: S.read(ctx ? path.join(ctx.dir, 'public-key.json') : '', {})?.key ? 'public_envelope_os_keystore' : 'unavailable' };
225
+ }
226
+ module.exports = { VERSION, MAX_INPUT, MAX_EVENT, MAX_QUEUE, MAX_FILES, TTL, ACTIVE_TURN_TTL, IDLE_TTL, TERMINAL, TYPES, credential, configured, resolveProject, context, lock, state, update, record, validLease, boot, queueFiles, queueBytes, queueRow, removeOrder, launch, capture, status };
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const crypto = require('node:crypto');
5
+ const { spawnSync } = require('node:child_process');
6
+ const SECRET_KEY = /(?:password|passwd|senha|secret|token|authorization|cookie|api.?key|private.?key|credentials?|^env$|^environment$)/i;
7
+ const REDACTED = '[REDACTED]';
8
+ function hash(value) { return crypto.createHash('sha256').update(String(value)).digest('hex'); }
9
+ function id(value) { const h = hash(value); return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`; }
10
+ function scrub(value) {
11
+ let redacted = false; let excluded = 0;
12
+ const replace = (s, re, out = REDACTED) => s.replace(re, () => { redacted = true; return out; });
13
+ function text(s) {
14
+ s = replace(s, /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g);
15
+ s = s.replace(/((?:minha\s+)?(?:senha|password|token|api[_-]?key)\s+(?:é|eh|is)\s+)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;}]+)/gi, (_, prefix) => { redacted = true; return `${prefix}${REDACTED}`; });
16
+ s = replace(s, /\b(?:Bearer|Basic)\s+\S+/gi);
17
+ s = replace(s, /\b(?:sk-(?:proj-|ant-|live_|test_)?|gh[pousr]_|github_pat_|xox[baprs]-|AKIA|ASIA|AIza)[A-Za-z0-9_\-]{12,}/g);
18
+ s = replace(s, /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g);
19
+ s = s.replace(/([a-z][a-z0-9+.-]*:\/\/)([^\s/@]+@)/gi, (_, scheme) => { redacted = true; return `${scheme}${REDACTED}@`; });
20
+ s = s.replace(/([?&](?:[^=\s]*(?:token|key|secret|password|signature|credential)[^=\s]*)=)[^&#\s]*/gi, (_, prefix) => { redacted = true; return `${prefix}${REDACTED}`; });
21
+ s = s.replace(/((?:password|passwd|senha|secret|token|api[_-]?key|authorization|cookie)[\"']?\s*[=:]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;}]+)/gi, (_, prefix) => { redacted = true; return `${prefix}${REDACTED}`; });
22
+ // Unknown long opaque strings are not safe to archive. Fail closed for that value.
23
+ s = replace(s, /\b[A-Za-z0-9_+/=-]{32,}\b/g);
24
+ return s;
25
+ }
26
+ function walk(v, depth) {
27
+ if (depth > 10) { excluded++; return '[EXCLUDED:depth]'; }
28
+ if (typeof v === 'string') {
29
+ if (/^\s*[\[{"]/.test(v)) { try { return JSON.stringify(walk(JSON.parse(v), depth + 1)); } catch {} }
30
+ return text(v);
31
+ }
32
+ if (v === null || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v))) return v;
33
+ if (Array.isArray(v)) { if (v.length > 200) excluded += v.length - 200; return v.slice(0, 200).map(x => walk(x, depth + 1)); }
34
+ if (v && typeof v === 'object') {
35
+ const out = {}; const entries = Object.entries(v);
36
+ if (entries.length > 200) excluded += entries.length - 200;
37
+ for (const [k, x] of entries.slice(0, 200)) {
38
+ if (['__proto__', 'constructor', 'prototype'].includes(k)) { excluded++; continue; }
39
+ if (SECRET_KEY.test(k)) { out[k] = REDACTED; redacted = true; }
40
+ else out[text(k)] = walk(x, depth + 1);
41
+ }
42
+ return out;
43
+ }
44
+ excluded++; return '[EXCLUDED:type]';
45
+ }
46
+ const clean = walk(value, 0);
47
+ return { value: clean, redacted, excluded };
48
+ }
49
+ function safePath(file) {
50
+ let cursor = path.resolve(file);
51
+ while (cursor !== path.dirname(cursor)) {
52
+ try { if (fs.lstatSync(cursor).isSymbolicLink()) throw new Error('UNSAFE_SYMLINK'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
53
+ cursor = path.dirname(cursor);
54
+ }
55
+ }
56
+ function powershell(script, input) {
57
+ // Windows PowerShell 5.1 must build its own module path when launched through Node/pwsh 7.
58
+ // Keep the caller's environment unchanged, including differently cased Windows names.
59
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => name.toLowerCase() !== 'psmodulepath'));
60
+ const result = spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(`$ErrorActionPreference='Stop'; $PSModuleAutoLoadingPreference='None'; ${script}`, 'utf16le').toString('base64')], { input, env, encoding: 'utf8', timeout: 5000, windowsHide: true, maxBuffer: 65536 });
61
+ if (result.status !== 0 || result.error) throw new Error('SECURE_STORAGE_UNAVAILABLE');
62
+ return result.stdout.trim();
63
+ }
64
+ function protectMany(dirs) {
65
+ const unique = [...new Set(dirs)]; if (!unique.length) return;
66
+ for (const dir of unique) { safePath(dir); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); }
67
+ if (process.platform === 'win32') {
68
+ // Core .NET APIs avoid the variable cold cost of preparing PowerShell cmdlet modules.
69
+ // Keep the exact same private DACL: current owner + SYSTEM, no inherited external access.
70
+ const encoded = unique.map(dir => `'${Buffer.from(dir).toString('base64')}'`).join(',');
71
+ powershell(`$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; foreach($encoded in @(${encoded})){ $p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded)); $acl=[Security.AccessControl.DirectorySecurity]::new(); $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); foreach($s in @($sid,[Security.Principal.SecurityIdentifier]::new('S-1-5-18'))){ $rule=[Security.AccessControl.FileSystemAccessRule]::new($s,[Security.AccessControl.FileSystemRights]::FullControl,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow); [void]$acl.AddAccessRule($rule) }; [void][IO.Directory]::SetAccessControl($p,$acl) }`);
72
+ } else {
73
+ for (const dir of unique) {
74
+ fs.chmodSync(dir, 0o700);
75
+ const stat = fs.statSync(dir);
76
+ if ((stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid())) throw new Error('UNSAFE_PERMISSIONS');
77
+ }
78
+ }
79
+ }
80
+ function protect(dir) { protectMany([dir]); }
81
+ // Non-durable mode is only for transient process lock/activity hints, never queue/ACK/key material.
82
+ function atomic(file, value, exclusive = false, durable = true) {
83
+ safePath(file);
84
+ const tmp = `${file}.${crypto.randomUUID()}.tmp`;
85
+ const fd = fs.openSync(tmp, 'wx', 0o600);
86
+ try { fs.writeFileSync(fd, typeof value === 'string' ? value : JSON.stringify(value)); if (durable) fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
87
+ try {
88
+ if (exclusive) { fs.linkSync(tmp, file); fs.unlinkSync(tmp); }
89
+ else fs.renameSync(tmp, file);
90
+ if (durable && process.platform !== 'win32') { const dir = fs.openSync(path.dirname(file), 'r'); try { fs.fsyncSync(dir); } finally { fs.closeSync(dir); } }
91
+ } catch (e) { try { fs.unlinkSync(tmp); } catch {} throw e; }
92
+ }
93
+ function read(file, fallback = null) {
94
+ try { safePath(file); const st = fs.statSync(file); if (st.size > 1024 * 1024) return fallback; return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
95
+ }
96
+ function keyFor(ctx, create = false) {
97
+ if (process.env.NODE_ENV === 'test' && process.env.AHI_TEST_MODE === '1' && process.env.AHI_TEST_NATIVE_KEYSTORE !== '1') {
98
+ if (!/^[a-f0-9]{64}$/.test(process.env.AHI_TEST_KEY || '')) throw new Error('SECURE_STORAGE_UNAVAILABLE');
99
+ return Buffer.from(process.env.AHI_TEST_KEY, 'hex');
100
+ }
101
+ if (!/^[a-f0-9]{64}$/.test(ctx.accountKey)) throw new Error('UNSAFE_ACCOUNT_KEY');
102
+ if (process.platform === 'darwin') {
103
+ const service = 'education.arka.ahi.v2'; const account = ctx.accountKey;
104
+ const keychain = ctx.keychainPath && /^[A-Za-z0-9_./-]+$/.test(ctx.keychainPath) ? ctx.keychainPath : null;
105
+ const find = () => spawnSync('/usr/bin/security', ['find-generic-password', '-s', service, '-a', account, '-w', ...(keychain ? [keychain] : [])], { encoding: 'utf8', timeout: 1200, maxBuffer: 4096 });
106
+ let result = find();
107
+ if (result.status !== 0 && create) {
108
+ const key = crypto.randomBytes(32).toString('hex');
109
+ // Interactive command from stdin keeps key material out of process arguments.
110
+ const add = spawnSync('/usr/bin/security', ['-i'], { input: `add-generic-password -s ${service} -a ${account} -w ${key}${keychain ? ` ${keychain}` : ''}\n`, encoding: 'utf8', timeout: 1500, maxBuffer: 4096 });
111
+ if (add.error) throw new Error('SECURE_STORAGE_UNAVAILABLE');
112
+ result = find();
113
+ }
114
+ const raw = (result.stdout || '').trim();
115
+ if (result.status !== 0 || !/^[a-f0-9]{64}$/.test(raw)) throw new Error('SECURE_STORAGE_UNAVAILABLE');
116
+ return Buffer.from(raw, 'hex');
117
+ }
118
+ if (process.platform === 'win32') {
119
+ const file = path.join(ctx.accountDir, 'key.dpapi');
120
+ if (!fs.existsSync(file)) {
121
+ if (!create) throw new Error('SECURE_STORAGE_UNAVAILABLE');
122
+ const wrapped = powershell("[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'); $b=[byte[]]::new(32); $rng=[Security.Cryptography.RandomNumberGenerator]::Create(); try { $rng.GetBytes($b); [Console]::Write([Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser))) } finally { $rng.Dispose(); [Array]::Clear($b,0,$b.Length) }");
123
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(wrapped) || Buffer.from(wrapped, 'base64').length < 32) throw new Error('SECURE_STORAGE_UNAVAILABLE');
124
+ try { atomic(file, wrapped, true); } catch (e) { if (e.code !== 'EEXIST') throw e; }
125
+ }
126
+ safePath(file);
127
+ const wrapped = fs.readFileSync(file, 'utf8');
128
+ const raw = powershell("[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'); $b=[Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); $plain=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); try { [Console]::Write([Convert]::ToBase64String($plain)) } finally { [Array]::Clear($plain,0,$plain.Length) }", wrapped);
129
+ const key = Buffer.from(raw, 'base64'); if (key.length !== 32) throw new Error('SECURE_STORAGE_UNAVAILABLE'); return key;
130
+ }
131
+ throw new Error('UNSUPPORTED_OS_KEYSTORE');
132
+ }
133
+ function encrypt(value, key, scope) {
134
+ const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); cipher.setAAD(Buffer.from(`ahi-envelope-v2:${scope}`));
135
+ const body = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]);
136
+ return { v: 2, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), body: body.toString('base64') };
137
+ }
138
+ function decrypt(value, key, scope) {
139
+ if (!value || value.v !== 2) throw new Error('QUEUE_CORRUPT');
140
+ const cipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(value.iv, 'base64')); cipher.setAAD(Buffer.from(`ahi-envelope-v2:${scope}`)); cipher.setAuthTag(Buffer.from(value.tag, 'base64'));
141
+ return JSON.parse(Buffer.concat([cipher.update(Buffer.from(value.body, 'base64')), cipher.final()]).toString('utf8'));
142
+ }
143
+ function privateKey(seed) {
144
+ return crypto.createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b656e04220420', 'hex'), seed]), format: 'der', type: 'pkcs8' });
145
+ }
146
+ function publicKey(seed) { return crypto.createPublicKey(privateKey(seed)).export({ format: 'der', type: 'spki' }).toString('base64'); }
147
+ function seal(value, recipient, scope) {
148
+ const pair = crypto.generateKeyPairSync('x25519');
149
+ const publicRecipient = crypto.createPublicKey({ key: Buffer.from(recipient, 'base64'), format: 'der', type: 'spki' });
150
+ const shared = crypto.diffieHellman({ privateKey: pair.privateKey, publicKey: publicRecipient });
151
+ const key = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.from(scope), Buffer.from('ahi-envelope-v2'), 32));
152
+ const result = encrypt(value, key, scope); key.fill(0); shared.fill(0);
153
+ result.ephemeral = pair.publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
154
+ return result;
155
+ }
156
+ function open(value, seed, scope) {
157
+ const publicSender = crypto.createPublicKey({ key: Buffer.from(value.ephemeral, 'base64'), format: 'der', type: 'spki' });
158
+ const shared = crypto.diffieHellman({ privateKey: privateKey(seed), publicKey: publicSender });
159
+ const key = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.from(scope), Buffer.from('ahi-envelope-v2'), 32));
160
+ try { return decrypt(value, key, scope); } finally { key.fill(0); shared.fill(0); }
161
+ }
162
+ module.exports = { hash, id, scrub, protect, protectMany, safePath, atomic, read, keyFor, encrypt, decrypt, publicKey, seal, open };