atris 3.82.3 → 3.82.5
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/README.md +1 -1
- package/atris/atris.md +6 -0
- package/bin/atris.js +2 -0
- package/commands/aeo.js +15 -2
- package/commands/next.js +16 -6
- package/commands/now.js +6 -1
- package/commands/security-review.js +172 -17
- package/commands/sentinel-powers.js +284 -0
- package/commands/task.js +9 -2
- package/lib/agent-next.js +255 -0
- package/lib/first-minute.js +8 -4
- package/lib/member-context.js +11 -2
- package/lib/security-audit.js +526 -13
- package/lib/sentinel-agents.js +584 -0
- package/lib/sentinel-cloud.js +13 -5
- package/lib/sentinel-events.js +431 -0
- package/lib/sentinel-guard-install.js +163 -0
- package/lib/sentinel-guard.js +876 -0
- package/lib/sentinel-watch.js +535 -0
- package/lib/task-core.js +3 -3
- package/lib/task-db.js +12 -3
- package/package.json +1 -1
|
@@ -0,0 +1,431 @@
|
|
|
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
|
+
// The project name for any Sentinel upload: lib/sentinel-guard.js
|
|
116
|
+
// projectName (git remote repo name, else folder name) passed through the
|
|
117
|
+
// secret check above.
|
|
118
|
+
function repoName(root) {
|
|
119
|
+
return cleanRepo(require('./sentinel-guard').projectName(root || process.cwd()));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function makeEvent({ kind, repo, severity = 'info', title, detail = '', status, fingerprint: fp, occurredAt = new Date() }) {
|
|
123
|
+
if (!KINDS.includes(kind)) throw new Error(`unknown event kind: ${kind}`);
|
|
124
|
+
const { plain, FALLBACK_SENTENCE } = cloud();
|
|
125
|
+
const sev = SEVERITIES.includes(severity) ? severity : 'info';
|
|
126
|
+
// Titles and details come from fillPlain already. plain() runs again here
|
|
127
|
+
// so no caller can skip the allowlist.
|
|
128
|
+
const cleanTitle = guardText(title, MAX_TITLE) || FALLBACK_SENTENCE;
|
|
129
|
+
const cleanDetail = detail ? guardText(detail, MAX_DETAIL) : '';
|
|
130
|
+
const ev = {
|
|
131
|
+
kind,
|
|
132
|
+
repo: cleanRepo(repo),
|
|
133
|
+
severity: sev,
|
|
134
|
+
title: cleanTitle,
|
|
135
|
+
status: status === 'resolved' ? 'resolved' : (status === 'open' ? 'open' : (kind === 'threat' || kind === 'agent_check' ? 'open' : 'resolved')),
|
|
136
|
+
fingerprint: /^f-[0-9a-f]{12}$/.test(String(fp || '')) ? fp : fingerprint(kind, repo, title),
|
|
137
|
+
occurred_at: (occurredAt instanceof Date ? occurredAt : new Date(occurredAt)).toISOString(),
|
|
138
|
+
};
|
|
139
|
+
if (cleanDetail) ev.detail = cleanDetail;
|
|
140
|
+
// Last pass: the secret scan over every field that leaves.
|
|
141
|
+
for (const k of Object.keys(ev)) {
|
|
142
|
+
if (!strictSecret(String(ev[k]))) continue;
|
|
143
|
+
if (k === 'repo') ev.repo = 'project';
|
|
144
|
+
else if (k === 'title') ev.title = FALLBACK_SENTENCE;
|
|
145
|
+
else if (k === 'detail') delete ev.detail;
|
|
146
|
+
else if (k === 'fingerprint') ev.fingerprint = fingerprint(kind, ev.repo, ev.title);
|
|
147
|
+
}
|
|
148
|
+
return ev;
|
|
149
|
+
function guardText(text, max) {
|
|
150
|
+
const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
|
|
151
|
+
if (!s) return '';
|
|
152
|
+
if (s.length > max) return FALLBACK_SENTENCE;
|
|
153
|
+
// Versions (1.3.1) are the one shape plain() refuses that a filled
|
|
154
|
+
// template may hold. Mask them for the check, keep them in the text.
|
|
155
|
+
// Package names sit only in "the <name> package you use", and only
|
|
156
|
+
// when the name has a package or module shape.
|
|
157
|
+
const masked = s
|
|
158
|
+
.replace(/\bthe (\S+) package you use\b/g, (all, name) => (slotOk('package', name) || slotOk('module', name) ? 'the slotvalue package you use' : all))
|
|
159
|
+
.replace(/\bv?[0-9]+(?:\.[0-9A-Za-z+-]+)+\b/g, 'slotvalue');
|
|
160
|
+
if (plain(masked, max) !== masked) return FALLBACK_SENTENCE;
|
|
161
|
+
if (strictSecret(s)) return FALLBACK_SENTENCE;
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------- upload
|
|
167
|
+
|
|
168
|
+
function chunks(events) {
|
|
169
|
+
const out = [];
|
|
170
|
+
let cur = [];
|
|
171
|
+
const size = (list) => Buffer.byteLength(JSON.stringify({ events: list }));
|
|
172
|
+
for (const ev of events) {
|
|
173
|
+
if (cur.length >= MAX_EVENTS_PER_CALL || (cur.length && size([...cur, ev]) > MAX_BODY_BYTES)) { out.push(cur); cur = []; }
|
|
174
|
+
cur.push(ev);
|
|
175
|
+
}
|
|
176
|
+
if (cur.length) out.push(cur);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function postEvents(events, { token, base, timeoutMs = 15000 } = {}) {
|
|
181
|
+
if (!events || !events.length) return { ok: true, saved: 0, calls: 0 };
|
|
182
|
+
if (!token) return { ok: false, reason: 'not logged in', saved: 0 };
|
|
183
|
+
// Every event passes makeEvent once more on the way out, whoever built it.
|
|
184
|
+
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() }));
|
|
185
|
+
const c = cloud();
|
|
186
|
+
let url = base;
|
|
187
|
+
if (!url) { try { url = c.apiBase(); } catch (e) { return { ok: false, reason: e.message, saved: 0 }; } }
|
|
188
|
+
let saved = 0;
|
|
189
|
+
let calls = 0;
|
|
190
|
+
for (const batch of chunks(events)) {
|
|
191
|
+
let res;
|
|
192
|
+
try {
|
|
193
|
+
res = await c.request('POST', `${url}/sentinel/events`, { token, body: { events: batch }, timeoutMs });
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { ok: false, reason: e.message, saved, calls };
|
|
196
|
+
}
|
|
197
|
+
calls += 1;
|
|
198
|
+
if (res.status < 200 || res.status >= 300) return { ok: false, reason: c.httpError(res), saved, calls };
|
|
199
|
+
saved += Number(res.json && res.json.saved) || batch.length;
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, saved, calls };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------- resolve
|
|
205
|
+
|
|
206
|
+
function statePath(root, kind, env = process.env) {
|
|
207
|
+
let real = path.resolve(root);
|
|
208
|
+
try { real = fs.realpathSync(real); } catch {}
|
|
209
|
+
return path.join(sentinelDir(env), 'state', `${hash(real).slice(0, 16)}-${kind}.json`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readState(root, kind, env) {
|
|
213
|
+
try {
|
|
214
|
+
const s = JSON.parse(fs.readFileSync(statePath(root, kind, env), 'utf8'));
|
|
215
|
+
return s && typeof s.open === 'object' && s.open ? s.open : {};
|
|
216
|
+
} catch { return {}; }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function writeState(root, kind, open, env) {
|
|
220
|
+
const file = statePath(root, kind, env);
|
|
221
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
222
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
223
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ root: path.resolve(root), kind, open, updated_at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
224
|
+
fs.renameSync(tmp, file);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The open events of this run, plus a resolved copy of each finding the
|
|
228
|
+
// last upload reported open that is gone now.
|
|
229
|
+
function withResolved(root, kind, openEvents, { env = process.env, now = new Date() } = {}) {
|
|
230
|
+
const before = readState(root, kind, env);
|
|
231
|
+
const current = new Set(openEvents.map((e) => e.fingerprint));
|
|
232
|
+
const resolved = [];
|
|
233
|
+
for (const [fp, prev] of Object.entries(before)) {
|
|
234
|
+
if (current.has(fp)) continue;
|
|
235
|
+
resolved.push(makeEvent({ kind, repo: prev.repo, severity: prev.severity, title: prev.title, detail: prev.detail, status: 'resolved', fingerprint: fp, occurredAt: now }));
|
|
236
|
+
}
|
|
237
|
+
const next = {};
|
|
238
|
+
for (const e of openEvents) next[e.fingerprint] = { repo: e.repo, severity: e.severity, title: e.title, detail: e.detail || '' };
|
|
239
|
+
return { events: [...openEvents, ...resolved], resolved, commit: () => writeState(root, kind, next, env) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Upload one run's findings of one kind and move the state on success.
|
|
243
|
+
async function reportFindings(root, kind, openEvents, { token, base, env = process.env, upload = true, extra = [] } = {}) {
|
|
244
|
+
const plan = withResolved(root, kind, openEvents, { env });
|
|
245
|
+
if (!upload) return { ok: true, skipped: 'no-upload', sent: 0, resolved: plan.resolved.length };
|
|
246
|
+
if (!token) return { ok: false, skipped: 'not-logged-in', reason: 'not logged in', sent: 0, resolved: plan.resolved.length };
|
|
247
|
+
const res = await postEvents([...extra, ...plan.events], { token, base });
|
|
248
|
+
if (res.ok) {
|
|
249
|
+
try { plan.commit(); } catch {}
|
|
250
|
+
try { await flushQueue({ token, base, env }); } catch {}
|
|
251
|
+
}
|
|
252
|
+
return { ...res, sent: res.ok ? plan.events.length + extra.length : 0, resolved: plan.resolved.length };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------- review fixes
|
|
256
|
+
|
|
257
|
+
// Review findings a later review closed (status fixed in a review upload).
|
|
258
|
+
// The overview counts a fix when a stored open event comes back resolved,
|
|
259
|
+
// so a finding never sent open goes first as open (dated when its review
|
|
260
|
+
// found it) and then as resolved in a second call. One call would store it
|
|
261
|
+
// resolved and the fix would never count. Local state remembers what was
|
|
262
|
+
// sent: uploading the same review again never counts a fix twice.
|
|
263
|
+
// Open review findings are not sent as events: the overview already counts
|
|
264
|
+
// them from the review itself.
|
|
265
|
+
//
|
|
266
|
+
// The record is keyed by the shared project name, not the checkout path, so
|
|
267
|
+
// every copy of one project on this machine shares it. Each entry is keyed
|
|
268
|
+
// by the closure id: the project name plus the finding's fingerprint, the
|
|
269
|
+
// same pair the server stores the event under.
|
|
270
|
+
//
|
|
271
|
+
// Accepted limit: the server has no idempotency field yet. Two machines
|
|
272
|
+
// (or two uploads racing on one) can each send the open then resolved pair
|
|
273
|
+
// for one finding. Both land on the same stored event and it stays one
|
|
274
|
+
// row. Its fixed time moves to the later send.
|
|
275
|
+
const REVIEW_STATE_KIND = 'review';
|
|
276
|
+
const FIXED_DETAIL = 'A later review found the line that closes this problem.';
|
|
277
|
+
|
|
278
|
+
function reviewStatePath(repo, env) {
|
|
279
|
+
return path.join(sentinelDir(env), 'state', `review-${hash(cleanRepo(repo)).slice(0, 16)}.json`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function closureId(repo, fp) {
|
|
283
|
+
return `${cleanRepo(repo)}/${fp}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function readStateFile(file) {
|
|
287
|
+
try {
|
|
288
|
+
const s = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
289
|
+
return { open: (s && s.open) || {}, resolved: (s && s.resolved) || {} };
|
|
290
|
+
} catch { return { open: {}, resolved: {} }; }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Earlier builds keyed this record by checkout path; fold that one in so a
|
|
294
|
+
// fix already sent from this checkout is not sent again.
|
|
295
|
+
function readReviewState(root, repo, env) {
|
|
296
|
+
const state = readStateFile(reviewStatePath(repo, env));
|
|
297
|
+
if (root) {
|
|
298
|
+
const legacy = readStateFile(statePath(root, REVIEW_STATE_KIND, env));
|
|
299
|
+
for (const part of ['open', 'resolved']) {
|
|
300
|
+
for (const [fp, at] of Object.entries(legacy[part])) {
|
|
301
|
+
const id = fp.includes('/') ? fp : closureId(repo, fp);
|
|
302
|
+
if (!state[part][id]) state[part][id] = at;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return state;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function writeReviewState(repo, state, env) {
|
|
310
|
+
const file = reviewStatePath(repo, env);
|
|
311
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
312
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
313
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ repo: cleanRepo(repo), kind: REVIEW_STATE_KIND, ...state, updated_at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
314
|
+
fs.renameSync(tmp, file);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function reviewFixEvent(body, f, status, at) {
|
|
318
|
+
return makeEvent({
|
|
319
|
+
kind: 'threat',
|
|
320
|
+
repo: body.repo,
|
|
321
|
+
severity: f.severity === 'informational' ? 'info' : f.severity,
|
|
322
|
+
title: f.title,
|
|
323
|
+
detail: status === 'resolved' ? FIXED_DETAIL : '',
|
|
324
|
+
status,
|
|
325
|
+
fingerprint: f.id,
|
|
326
|
+
occurredAt: at,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function reportReviewFixes(root, body, { token, base, env = process.env, foundAt = null, now = new Date() } = {}) {
|
|
331
|
+
const repo = cleanRepo(body && body.repo);
|
|
332
|
+
const findings = ((body && body.findings) || []).filter((f) => f && /^f-[0-9a-f]{12}$/.test(String(f.id || '')));
|
|
333
|
+
const fixed = findings.filter((f) => f.status === 'fixed');
|
|
334
|
+
const state = readReviewState(root, repo, env);
|
|
335
|
+
const id = (f) => closureId(repo, f.id);
|
|
336
|
+
// A finding open again can be fixed again later and count again.
|
|
337
|
+
const back = findings.filter((f) => f.status === 'open' && state.resolved[id(f)]);
|
|
338
|
+
if (back.length) {
|
|
339
|
+
for (const f of back) delete state.resolved[id(f)];
|
|
340
|
+
try { writeReviewState(repo, state, env); } catch {}
|
|
341
|
+
}
|
|
342
|
+
if (!fixed.length) return { ok: true, sent: 0, fixed: 0 };
|
|
343
|
+
const todo = fixed.filter((f) => !state.resolved[id(f)]);
|
|
344
|
+
if (!todo.length) return { ok: true, sent: 0, fixed: 0, already: fixed.length };
|
|
345
|
+
if (!token) return { ok: false, reason: 'not logged in', sent: 0, fixed: 0 };
|
|
346
|
+
const found = foundAt && !Number.isNaN(Date.parse(foundAt)) ? new Date(foundAt) : now;
|
|
347
|
+
const opening = todo.filter((f) => !state.open[id(f)]);
|
|
348
|
+
if (opening.length) {
|
|
349
|
+
const res = await postEvents(opening.map((f) => reviewFixEvent(body, f, 'open', found)), { token, base });
|
|
350
|
+
if (!res.ok) return { ...res, sent: 0, fixed: 0 };
|
|
351
|
+
for (const f of opening) state.open[id(f)] = found.toISOString();
|
|
352
|
+
try { writeReviewState(repo, state, env); } catch {}
|
|
353
|
+
}
|
|
354
|
+
const res = await postEvents(todo.map((f) => reviewFixEvent(body, f, 'resolved', now)), { token, base });
|
|
355
|
+
if (!res.ok) return { ...res, sent: opening.length, fixed: 0 };
|
|
356
|
+
for (const f of todo) { state.resolved[id(f)] = now.toISOString(); delete state.open[id(f)]; }
|
|
357
|
+
try { writeReviewState(repo, state, env); } catch {}
|
|
358
|
+
return { ok: true, sent: opening.length + todo.length, fixed: todo.length };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------- queue
|
|
362
|
+
|
|
363
|
+
function queuePath(env = process.env) {
|
|
364
|
+
return path.join(sentinelDir(env), 'queue.jsonl');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readQueueFile(file) {
|
|
368
|
+
let text = '';
|
|
369
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
370
|
+
const out = [];
|
|
371
|
+
for (const line of text.split('\n')) {
|
|
372
|
+
if (!line.trim()) continue;
|
|
373
|
+
try {
|
|
374
|
+
const ev = JSON.parse(line);
|
|
375
|
+
if (ev && KINDS.includes(ev.kind)) out.push(ev);
|
|
376
|
+
} catch {}
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function queueSize(env = process.env) {
|
|
382
|
+
return readQueueFile(queuePath(env)).length;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Send every queued event. The queue is moved aside first so the hook can
|
|
386
|
+
// keep appending; on failure the unsent events go back.
|
|
387
|
+
async function flushQueue({ token, base, env = process.env } = {}) {
|
|
388
|
+
const file = queuePath(env);
|
|
389
|
+
if (!fs.existsSync(file)) return { ok: true, sent: 0 };
|
|
390
|
+
if (!token) return { ok: false, reason: 'not logged in', sent: 0, waiting: queueSize(env) };
|
|
391
|
+
const sending = `${file}.sending-${process.pid}-${Date.now()}`;
|
|
392
|
+
try { fs.renameSync(file, sending); } catch (e) { return { ok: false, reason: e.message, sent: 0 }; }
|
|
393
|
+
// Rebuild each event through makeEvent so a hand-edited queue line still
|
|
394
|
+
// leaves only plain prose.
|
|
395
|
+
const events = [];
|
|
396
|
+
for (const raw of readQueueFile(sending)) {
|
|
397
|
+
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 {}
|
|
398
|
+
}
|
|
399
|
+
const res = await postEvents(events, { token, base });
|
|
400
|
+
if (res.ok) {
|
|
401
|
+
try { fs.unlinkSync(sending); } catch {}
|
|
402
|
+
return { ok: true, sent: events.length };
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
const back = fs.readFileSync(sending, 'utf8');
|
|
406
|
+
fs.appendFileSync(file, back.endsWith('\n') || !back ? back : `${back}\n`, { mode: 0o600 });
|
|
407
|
+
fs.unlinkSync(sending);
|
|
408
|
+
} catch {}
|
|
409
|
+
return { ok: false, reason: res.reason, sent: 0, waiting: events.length };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = {
|
|
413
|
+
KINDS,
|
|
414
|
+
SEVERITIES,
|
|
415
|
+
fingerprint,
|
|
416
|
+
fillPlain,
|
|
417
|
+
slotOk,
|
|
418
|
+
repoName,
|
|
419
|
+
cleanRepo,
|
|
420
|
+
makeEvent,
|
|
421
|
+
postEvents,
|
|
422
|
+
chunks,
|
|
423
|
+
reportFindings,
|
|
424
|
+
reportReviewFixes,
|
|
425
|
+
readState,
|
|
426
|
+
statePath,
|
|
427
|
+
queuePath,
|
|
428
|
+
queueSize,
|
|
429
|
+
flushQueue,
|
|
430
|
+
sentinelDir,
|
|
431
|
+
};
|
|
@@ -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 };
|