atris 3.82.2 → 3.82.4

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.
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ // Sentinel events: what Sentinel did or found between reviews, sent to
4
+ // POST /api/sentinel/events (the v2 contract). agents, guard and watch all
5
+ // report through here.
6
+ //
7
+ // text every title and detail is a fixed sentence template. The only
8
+ // values put into it are slots that pass a strict shape check
9
+ // (package names, versions, advisory ids, counts). The filled
10
+ // sentence then passes the same plain-prose allowlist and secret
11
+ // scan review uploads use (lib/sentinel-cloud.js plain()).
12
+ // resolve the last reported open findings per project and kind live in
13
+ // ~/.atris/sentinel/state. A finding that is gone on a later run
14
+ // is sent again as resolved. The state moves only after an upload
15
+ // succeeds, so a failed upload is caught up next time.
16
+ // queue the guard hook appends blocked events to
17
+ // ~/.atris/sentinel/queue.jsonl. flushQueue() sends them later.
18
+ //
19
+ // Upload failures never throw: every call returns { ok, reason }.
20
+ //
21
+ // Zero external deps (Node built-ins only).
22
+
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+
28
+ const KINDS = ['blocked', 'threat', 'agent_check', 'scan_started', 'scan_finished'];
29
+ const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
30
+ const MAX_EVENTS_PER_CALL = 200;
31
+ const MAX_BODY_BYTES = 256 * 1024;
32
+ const MAX_TITLE = 300;
33
+ const MAX_DETAIL = 1000;
34
+
35
+ function cloud() {
36
+ return require('./sentinel-cloud');
37
+ }
38
+
39
+ function homeDir(env = process.env) {
40
+ return env.HOME || os.homedir();
41
+ }
42
+
43
+ function sentinelDir(env = process.env) {
44
+ return path.join(homeDir(env), '.atris', 'sentinel');
45
+ }
46
+
47
+ function hash(...parts) {
48
+ return crypto.createHash('sha256').update(parts.map((p) => String(p == null ? '' : p)).join('\u0000')).digest('hex');
49
+ }
50
+
51
+ function fingerprint(...parts) {
52
+ return `f-${hash(...parts).slice(0, 12)}`;
53
+ }
54
+
55
+ // ---------------------------------------------------------------- text
56
+
57
+ // Shapes a slot value may take. Anything else is replaced by the slot's
58
+ // fallback words, never cleaned up and kept.
59
+ const SLOT_SHAPES = {
60
+ package: /^(?:@[a-z0-9][a-z0-9._-]{0,80}\/)?[A-Za-z0-9][A-Za-z0-9._-]{0,100}$/,
61
+ module: /^[a-z0-9][a-z0-9._-]{0,80}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,80}){0,6}$/,
62
+ version: /^v?[0-9][0-9A-Za-z.+-]{0,40}$/,
63
+ advisory: /^[A-Z]{2,10}-[A-Za-z0-9-]{3,40}$/,
64
+ count: /^[0-9]{1,7}$/,
65
+ word: /^[A-Za-z][A-Za-z -]{0,60}$/,
66
+ };
67
+
68
+ function slotOk(kind, value) {
69
+ const shape = SLOT_SHAPES[kind];
70
+ if (!shape) return false;
71
+ const v = String(value == null ? '' : value);
72
+ if (!shape.test(v)) return false;
73
+ if (strictSecret(v)) return false;
74
+ return true;
75
+ }
76
+
77
+ function strictSecret(text) {
78
+ let rules = [];
79
+ try { rules = require('./security-scan').SECRET_RULES || []; } catch {}
80
+ return rules.some((rule) => { try { rule.re.lastIndex = 0; return rule.re.test(text); } catch { return true; } });
81
+ }
82
+
83
+ // fillPlain('Version {version} fixes it.', { version: ['version', '1.3.1', 'a newer version'] })
84
+ // The template, with each slot swapped for a plain placeholder word, must
85
+ // pass plain(). Then each slot goes in only when it matches its shape.
86
+ function fillPlain(template, slots = {}, max = MAX_TITLE) {
87
+ const { plain, FALLBACK_SENTENCE } = cloud();
88
+ const names = Object.keys(slots);
89
+ let probe = String(template);
90
+ for (const name of names) probe = probe.split(`{${name}}`).join('slotvalue');
91
+ if (/\{[a-z]+\}/.test(probe)) return FALLBACK_SENTENCE;
92
+ if (plain(probe, max) !== probe.replace(/\s+/g, ' ').trim()) return FALLBACK_SENTENCE;
93
+ let out = String(template);
94
+ for (const name of names) {
95
+ const [kind, value, fallback] = slots[name];
96
+ const v = slotOk(kind, value) ? String(value) : String(fallback || 'one');
97
+ out = out.split(`{${name}}`).join(v);
98
+ }
99
+ out = out.replace(/\s+/g, ' ').trim();
100
+ if (out.length > max) return FALLBACK_SENTENCE;
101
+ return out;
102
+ }
103
+
104
+ // ---------------------------------------------------------------- events
105
+
106
+ // A project name leaves only when it is a plain folder name: letters,
107
+ // digits, dot, dash and underscore, at most 100, and no secret shape.
108
+ // Anything else is sent as "project".
109
+ const REPO_SHAPE = /^[A-Za-z0-9._-]{1,100}$/;
110
+ function cleanRepo(name) {
111
+ const s = String(name == null ? '' : name);
112
+ return REPO_SHAPE.test(s) && !strictSecret(s) ? s : 'project';
113
+ }
114
+
115
+ function repoName(root) {
116
+ return cleanRepo(path.basename(path.resolve(root || process.cwd())));
117
+ }
118
+
119
+ function makeEvent({ kind, repo, severity = 'info', title, detail = '', status, fingerprint: fp, occurredAt = new Date() }) {
120
+ if (!KINDS.includes(kind)) throw new Error(`unknown event kind: ${kind}`);
121
+ const { plain, FALLBACK_SENTENCE } = cloud();
122
+ const sev = SEVERITIES.includes(severity) ? severity : 'info';
123
+ // Titles and details come from fillPlain already. plain() runs again here
124
+ // so no caller can skip the allowlist.
125
+ const cleanTitle = guardText(title, MAX_TITLE) || FALLBACK_SENTENCE;
126
+ const cleanDetail = detail ? guardText(detail, MAX_DETAIL) : '';
127
+ const ev = {
128
+ kind,
129
+ repo: cleanRepo(repo),
130
+ severity: sev,
131
+ title: cleanTitle,
132
+ status: status === 'resolved' ? 'resolved' : (status === 'open' ? 'open' : (kind === 'threat' || kind === 'agent_check' ? 'open' : 'resolved')),
133
+ fingerprint: /^f-[0-9a-f]{12}$/.test(String(fp || '')) ? fp : fingerprint(kind, repo, title),
134
+ occurred_at: (occurredAt instanceof Date ? occurredAt : new Date(occurredAt)).toISOString(),
135
+ };
136
+ if (cleanDetail) ev.detail = cleanDetail;
137
+ // Last pass: the secret scan over every field that leaves.
138
+ for (const k of Object.keys(ev)) {
139
+ if (!strictSecret(String(ev[k]))) continue;
140
+ if (k === 'repo') ev.repo = 'project';
141
+ else if (k === 'title') ev.title = FALLBACK_SENTENCE;
142
+ else if (k === 'detail') delete ev.detail;
143
+ else if (k === 'fingerprint') ev.fingerprint = fingerprint(kind, ev.repo, ev.title);
144
+ }
145
+ return ev;
146
+ function guardText(text, max) {
147
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
148
+ if (!s) return '';
149
+ if (s.length > max) return FALLBACK_SENTENCE;
150
+ // Versions (1.3.1) are the one shape plain() refuses that a filled
151
+ // template may hold. Mask them for the check, keep them in the text.
152
+ // Package names sit only in "the <name> package you use", and only
153
+ // when the name has a package or module shape.
154
+ const masked = s
155
+ .replace(/\bthe (\S+) package you use\b/g, (all, name) => (slotOk('package', name) || slotOk('module', name) ? 'the slotvalue package you use' : all))
156
+ .replace(/\bv?[0-9]+(?:\.[0-9A-Za-z+-]+)+\b/g, 'slotvalue');
157
+ if (plain(masked, max) !== masked) return FALLBACK_SENTENCE;
158
+ if (strictSecret(s)) return FALLBACK_SENTENCE;
159
+ return s;
160
+ }
161
+ }
162
+
163
+ // ---------------------------------------------------------------- upload
164
+
165
+ function chunks(events) {
166
+ const out = [];
167
+ let cur = [];
168
+ const size = (list) => Buffer.byteLength(JSON.stringify({ events: list }));
169
+ for (const ev of events) {
170
+ if (cur.length >= MAX_EVENTS_PER_CALL || (cur.length && size([...cur, ev]) > MAX_BODY_BYTES)) { out.push(cur); cur = []; }
171
+ cur.push(ev);
172
+ }
173
+ if (cur.length) out.push(cur);
174
+ return out;
175
+ }
176
+
177
+ async function postEvents(events, { token, base, timeoutMs = 15000 } = {}) {
178
+ if (!events || !events.length) return { ok: true, saved: 0, calls: 0 };
179
+ if (!token) return { ok: false, reason: 'not logged in', saved: 0 };
180
+ // Every event passes makeEvent once more on the way out, whoever built it.
181
+ events = events.map((e) => makeEvent({ kind: e.kind, repo: e.repo, severity: e.severity, title: e.title, detail: e.detail, status: e.status, fingerprint: e.fingerprint, occurredAt: e.occurred_at || new Date() }));
182
+ const c = cloud();
183
+ let url = base;
184
+ if (!url) { try { url = c.apiBase(); } catch (e) { return { ok: false, reason: e.message, saved: 0 }; } }
185
+ let saved = 0;
186
+ let calls = 0;
187
+ for (const batch of chunks(events)) {
188
+ let res;
189
+ try {
190
+ res = await c.request('POST', `${url}/sentinel/events`, { token, body: { events: batch }, timeoutMs });
191
+ } catch (e) {
192
+ return { ok: false, reason: e.message, saved, calls };
193
+ }
194
+ calls += 1;
195
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: c.httpError(res), saved, calls };
196
+ saved += Number(res.json && res.json.saved) || batch.length;
197
+ }
198
+ return { ok: true, saved, calls };
199
+ }
200
+
201
+ // ---------------------------------------------------------------- resolve
202
+
203
+ function statePath(root, kind, env = process.env) {
204
+ let real = path.resolve(root);
205
+ try { real = fs.realpathSync(real); } catch {}
206
+ return path.join(sentinelDir(env), 'state', `${hash(real).slice(0, 16)}-${kind}.json`);
207
+ }
208
+
209
+ function readState(root, kind, env) {
210
+ try {
211
+ const s = JSON.parse(fs.readFileSync(statePath(root, kind, env), 'utf8'));
212
+ return s && typeof s.open === 'object' && s.open ? s.open : {};
213
+ } catch { return {}; }
214
+ }
215
+
216
+ function writeState(root, kind, open, env) {
217
+ const file = statePath(root, kind, env);
218
+ fs.mkdirSync(path.dirname(file), { recursive: true });
219
+ const tmp = `${file}.${process.pid}.tmp`;
220
+ fs.writeFileSync(tmp, `${JSON.stringify({ root: path.resolve(root), kind, open, updated_at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
221
+ fs.renameSync(tmp, file);
222
+ }
223
+
224
+ // The open events of this run, plus a resolved copy of each finding the
225
+ // last upload reported open that is gone now.
226
+ function withResolved(root, kind, openEvents, { env = process.env, now = new Date() } = {}) {
227
+ const before = readState(root, kind, env);
228
+ const current = new Set(openEvents.map((e) => e.fingerprint));
229
+ const resolved = [];
230
+ for (const [fp, prev] of Object.entries(before)) {
231
+ if (current.has(fp)) continue;
232
+ resolved.push(makeEvent({ kind, repo: prev.repo, severity: prev.severity, title: prev.title, detail: prev.detail, status: 'resolved', fingerprint: fp, occurredAt: now }));
233
+ }
234
+ const next = {};
235
+ for (const e of openEvents) next[e.fingerprint] = { repo: e.repo, severity: e.severity, title: e.title, detail: e.detail || '' };
236
+ return { events: [...openEvents, ...resolved], resolved, commit: () => writeState(root, kind, next, env) };
237
+ }
238
+
239
+ // Upload one run's findings of one kind and move the state on success.
240
+ async function reportFindings(root, kind, openEvents, { token, base, env = process.env, upload = true, extra = [] } = {}) {
241
+ const plan = withResolved(root, kind, openEvents, { env });
242
+ if (!upload) return { ok: true, skipped: 'no-upload', sent: 0, resolved: plan.resolved.length };
243
+ if (!token) return { ok: false, skipped: 'not-logged-in', reason: 'not logged in', sent: 0, resolved: plan.resolved.length };
244
+ const res = await postEvents([...extra, ...plan.events], { token, base });
245
+ if (res.ok) {
246
+ try { plan.commit(); } catch {}
247
+ try { await flushQueue({ token, base, env }); } catch {}
248
+ }
249
+ return { ...res, sent: res.ok ? plan.events.length + extra.length : 0, resolved: plan.resolved.length };
250
+ }
251
+
252
+ // ---------------------------------------------------------------- queue
253
+
254
+ function queuePath(env = process.env) {
255
+ return path.join(sentinelDir(env), 'queue.jsonl');
256
+ }
257
+
258
+ function readQueueFile(file) {
259
+ let text = '';
260
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
261
+ const out = [];
262
+ for (const line of text.split('\n')) {
263
+ if (!line.trim()) continue;
264
+ try {
265
+ const ev = JSON.parse(line);
266
+ if (ev && KINDS.includes(ev.kind)) out.push(ev);
267
+ } catch {}
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function queueSize(env = process.env) {
273
+ return readQueueFile(queuePath(env)).length;
274
+ }
275
+
276
+ // Send every queued event. The queue is moved aside first so the hook can
277
+ // keep appending; on failure the unsent events go back.
278
+ async function flushQueue({ token, base, env = process.env } = {}) {
279
+ const file = queuePath(env);
280
+ if (!fs.existsSync(file)) return { ok: true, sent: 0 };
281
+ if (!token) return { ok: false, reason: 'not logged in', sent: 0, waiting: queueSize(env) };
282
+ const sending = `${file}.sending-${process.pid}-${Date.now()}`;
283
+ try { fs.renameSync(file, sending); } catch (e) { return { ok: false, reason: e.message, sent: 0 }; }
284
+ // Rebuild each event through makeEvent so a hand-edited queue line still
285
+ // leaves only plain prose.
286
+ const events = [];
287
+ for (const raw of readQueueFile(sending)) {
288
+ try { events.push(makeEvent({ kind: raw.kind, repo: raw.repo, severity: raw.severity, title: raw.title, detail: raw.detail, status: raw.status, fingerprint: raw.fingerprint, occurredAt: raw.occurred_at || new Date() })); } catch {}
289
+ }
290
+ const res = await postEvents(events, { token, base });
291
+ if (res.ok) {
292
+ try { fs.unlinkSync(sending); } catch {}
293
+ return { ok: true, sent: events.length };
294
+ }
295
+ try {
296
+ const back = fs.readFileSync(sending, 'utf8');
297
+ fs.appendFileSync(file, back.endsWith('\n') || !back ? back : `${back}\n`, { mode: 0o600 });
298
+ fs.unlinkSync(sending);
299
+ } catch {}
300
+ return { ok: false, reason: res.reason, sent: 0, waiting: events.length };
301
+ }
302
+
303
+ module.exports = {
304
+ KINDS,
305
+ SEVERITIES,
306
+ fingerprint,
307
+ fillPlain,
308
+ slotOk,
309
+ repoName,
310
+ makeEvent,
311
+ postEvents,
312
+ chunks,
313
+ reportFindings,
314
+ readState,
315
+ statePath,
316
+ queuePath,
317
+ queueSize,
318
+ flushQueue,
319
+ sentinelDir,
320
+ };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ // Installs the sentinel guard (lib/sentinel-guard.js) as a Claude Code
4
+ // PreToolUse hook, the same way `atris spend guard install` installs its
5
+ // hook (lib/claude-spend-guard.js): the hook is a copy of the guard file,
6
+ // so it keeps working if the CLI moves, and the settings file is rewritten
7
+ // atomically and never clobbered when it does not parse.
8
+ //
9
+ // project <project>/.claude/hooks/sentinel-guard.js, wired in
10
+ // <project>/.claude/settings.json through $CLAUDE_PROJECT_DIR
11
+ // global ~/.claude/hooks/sentinel-guard.js, wired in
12
+ // ~/.claude/settings.json with an absolute node path
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+ const guard = require('./sentinel-guard');
19
+
20
+ const SCRIPT_NAME = `${guard.GUARD_MARK}.js`;
21
+ const GUARD_SOURCE = path.join(__dirname, 'sentinel-guard.js');
22
+
23
+ function homeDir(env = process.env) {
24
+ return env.HOME || os.homedir();
25
+ }
26
+
27
+ function locations({ global = false, root = process.cwd(), env = process.env } = {}) {
28
+ const base = global ? path.join(homeDir(env), '.claude') : path.join(path.resolve(root), '.claude');
29
+ return { scope: global ? 'global' : 'project', settings: path.join(base, 'settings.json'), script: path.join(base, 'hooks', SCRIPT_NAME) };
30
+ }
31
+
32
+ function nodeBinary() {
33
+ for (const candidate of ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']) {
34
+ if (fs.existsSync(candidate)) return candidate;
35
+ }
36
+ return process.execPath;
37
+ }
38
+
39
+ function quote(p) {
40
+ return /^[A-Za-z0-9_./-]+$/.test(p) ? p : `"${String(p).replace(/(["\\$`])/g, '\\$1')}"`;
41
+ }
42
+
43
+ function hookCommand(loc) {
44
+ if (loc.scope === 'project') return `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${SCRIPT_NAME}"`;
45
+ return `${quote(nodeBinary())} ${quote(loc.script)}`;
46
+ }
47
+
48
+ function readSettings(file) {
49
+ if (!fs.existsSync(file)) return {};
50
+ const text = fs.readFileSync(file, 'utf8');
51
+ if (!text.trim()) return {};
52
+ const json = JSON.parse(text); // throws on bad JSON: never overwrite settings we cannot read
53
+ if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error(`${file} is not a settings object`);
54
+ return json;
55
+ }
56
+
57
+ function writeSettings(file, settings) {
58
+ fs.mkdirSync(path.dirname(file), { recursive: true });
59
+ const tmp = `${file}.atris-tmp-${process.pid}`;
60
+ fs.writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`);
61
+ fs.renameSync(tmp, file);
62
+ }
63
+
64
+ function isGuardHook(h) {
65
+ return String((h && h.command) || '').includes(guard.GUARD_MARK);
66
+ }
67
+
68
+ function isGuardGroup(g) {
69
+ return Boolean(g && Array.isArray(g.hooks) && g.hooks.some(isGuardHook));
70
+ }
71
+
72
+ // Take only the guard's own hook out of each group. Other hooks in the same
73
+ // group stay; a group is dropped only when the guard was all it held.
74
+ function withoutGuard(groups) {
75
+ const out = [];
76
+ for (const g of groups) {
77
+ if (!isGuardGroup(g)) { out.push(g); continue; }
78
+ const rest = g.hooks.filter((h) => !isGuardHook(h));
79
+ if (rest.length) out.push({ ...g, hooks: rest });
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function fileHash(file) {
85
+ try { return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return null; }
86
+ }
87
+
88
+ function status({ global = false, root, env = process.env } = {}) {
89
+ const loc = locations({ global, root, env });
90
+ let settings = {};
91
+ let readable = true;
92
+ try { settings = readSettings(loc.settings); } catch { readable = false; }
93
+ const groups = (settings.hooks && settings.hooks.PreToolUse) || [];
94
+ const wired = readable && Array.isArray(groups) && groups.some(isGuardGroup);
95
+ const present = fs.existsSync(loc.script);
96
+ const current = present && fileHash(loc.script) === fileHash(GUARD_SOURCE);
97
+ const hooksOff = settings.disableAllHooks === true;
98
+ return { ...loc, installed: wired && present, wired, present, current, readable, hooks_off: hooksOff };
99
+ }
100
+
101
+ // Where a background flush finds the atris CLI. Written once at install.
102
+ function writeFlushConfig({ env = process.env, atrisBin } = {}) {
103
+ const dir = path.join(homeDir(env), '.atris', 'sentinel');
104
+ const file = path.join(dir, 'guard.json');
105
+ let cfg = {};
106
+ try { cfg = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
107
+ cfg.atris_bin = atrisBin || path.resolve(__dirname, '..', 'bin', 'atris.js');
108
+ cfg.node = nodeBinary();
109
+ if (!Array.isArray(cfg.allow_hosts)) cfg.allow_hosts = [];
110
+ fs.mkdirSync(dir, { recursive: true });
111
+ fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
112
+ return file;
113
+ }
114
+
115
+ function install({ global = false, root, env = process.env, atrisBin } = {}) {
116
+ const loc = locations({ global, root, env });
117
+ const settings = readSettings(loc.settings);
118
+ fs.mkdirSync(path.dirname(loc.script), { recursive: true });
119
+ fs.copyFileSync(GUARD_SOURCE, loc.script);
120
+ fs.chmodSync(loc.script, 0o755);
121
+ settings.hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
122
+ const groups = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
123
+ const kept = withoutGuard(groups);
124
+ // First in line, so it decides before any other hook runs.
125
+ settings.hooks.PreToolUse = [
126
+ { matcher: guard.MATCHER, hooks: [{ type: 'command', command: hookCommand(loc), timeout: 10 }] },
127
+ ...kept,
128
+ ];
129
+ writeSettings(loc.settings, settings);
130
+ try { writeFlushConfig({ env, atrisBin }); } catch {}
131
+ return status({ global, root, env });
132
+ }
133
+
134
+ function uninstall({ global = false, root, env = process.env } = {}) {
135
+ const loc = locations({ global, root, env });
136
+ const settings = readSettings(loc.settings);
137
+ if (settings.hooks && Array.isArray(settings.hooks.PreToolUse)) {
138
+ const kept = withoutGuard(settings.hooks.PreToolUse);
139
+ if (kept.length) settings.hooks.PreToolUse = kept;
140
+ else delete settings.hooks.PreToolUse;
141
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
142
+ writeSettings(loc.settings, settings);
143
+ }
144
+ try { fs.unlinkSync(loc.script); } catch {}
145
+ return status({ global, root, env });
146
+ }
147
+
148
+ // Blocks in the guard log over the last day.
149
+ function recentBlocks({ env = process.env, sinceMs = 24 * 3600 * 1000, now = Date.now() } = {}) {
150
+ let text = '';
151
+ try { text = fs.readFileSync(path.join(homeDir(env), '.atris', 'sentinel', 'guard.log'), 'utf8'); } catch { return []; }
152
+ const out = [];
153
+ for (const line of text.split('\n')) {
154
+ if (!line.trim()) continue;
155
+ try {
156
+ const r = JSON.parse(line);
157
+ if (now - Date.parse(r.at) <= sinceMs) out.push(r);
158
+ } catch {}
159
+ }
160
+ return out;
161
+ }
162
+
163
+ module.exports = { locations, status, install, uninstall, recentBlocks, hookCommand, SCRIPT_NAME };