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.
- package/README.md +1 -1
- package/atris/atris.md +6 -0
- package/bin/atris.js +1 -0
- package/commands/aeo.js +15 -2
- package/commands/next.js +16 -6
- package/commands/now.js +6 -1
- package/commands/security-review.js +239 -8
- package/commands/sentinel-powers.js +284 -0
- package/commands/task.js +9 -2
- package/lib/agent-next.js +255 -0
- package/lib/campaign-keep.js +1 -0
- package/lib/first-minute.js +13 -6
- package/lib/member-context.js +11 -2
- package/lib/security-audit.js +191 -12
- package/lib/sentinel-agents.js +584 -0
- package/lib/sentinel-cloud.js +561 -0
- package/lib/sentinel-events.js +320 -0
- package/lib/sentinel-guard-install.js +163 -0
- package/lib/sentinel-guard.js +856 -0
- package/lib/sentinel-watch.js +529 -0
- package/lib/task-core.js +3 -3
- package/lib/task-db.js +12 -3
- package/package.json +1 -1
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Sentinel on Atris: hosted engines, credit quotes, run upload, and the
|
|
4
|
+
// review timer. lib/security-audit.js does the audit; this file only talks
|
|
5
|
+
// to the Atris backend and the local timer.
|
|
6
|
+
//
|
|
7
|
+
// engines atris:fast hunts, atris:pro verifies and critiques, through
|
|
8
|
+
// POST /api/atris2/turn. The backend's turn billing charges the
|
|
9
|
+
// credits; this file only reads what each turn's receipt reports.
|
|
10
|
+
// quote GET /api/atris2/billing/quote prices one call per model, so the
|
|
11
|
+
// caller can show an estimate before any spend.
|
|
12
|
+
// upload POST /api/sentinel/runs gets summary fields only (titles,
|
|
13
|
+
// locations, counts). Never source, traces, or code lines.
|
|
14
|
+
// schedule the same launchd/cron timer `atris worktree sweep --install` uses.
|
|
15
|
+
//
|
|
16
|
+
// Zero external deps (Node built-ins only).
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const http = require('http');
|
|
22
|
+
const https = require('https');
|
|
23
|
+
|
|
24
|
+
const HUNTER_MODEL = 'atris:fast';
|
|
25
|
+
const VERIFIER_MODEL = 'atris:pro';
|
|
26
|
+
const LOCAL_ENGINES = ['codex', 'claude'];
|
|
27
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'informational'];
|
|
28
|
+
const MAX_UPLOAD_BYTES = 1024 * 1024;
|
|
29
|
+
const MAX_UPLOAD_FINDINGS = 500;
|
|
30
|
+
const SCHEDULE_EVERY = { day: 86400, week: 7 * 86400 };
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------- identity
|
|
33
|
+
|
|
34
|
+
function authToken({ env = process.env, loadCredentials } = {}) {
|
|
35
|
+
if (env.OBELISK_AUTH_TOKEN) return env.OBELISK_AUTH_TOKEN;
|
|
36
|
+
if (env.ATRIS_TOKEN) return env.ATRIS_TOKEN;
|
|
37
|
+
try {
|
|
38
|
+
const load = loadCredentials || require('../utils/auth').loadCredentials;
|
|
39
|
+
const creds = load();
|
|
40
|
+
return creds && creds.token ? String(creds.token) : '';
|
|
41
|
+
} catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function apiBase() {
|
|
47
|
+
return require('../utils/api').getApiBaseUrl();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A local engine counts as available when its binary is on PATH.
|
|
51
|
+
function localEngineAvailable(name, { env = process.env, platform = process.platform } = {}) {
|
|
52
|
+
const exts = platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
|
53
|
+
for (const dir of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
54
|
+
for (const ext of exts) {
|
|
55
|
+
try { fs.accessSync(path.join(dir, `${name}${ext}`), fs.constants.X_OK); return true; } catch {}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Which engines run the review. An explicit choice always wins; with none,
|
|
62
|
+
// local engines stay the default and Atris models step in only when no local
|
|
63
|
+
// engine is installed and the user is logged in.
|
|
64
|
+
function resolveEngines({ engine = null, hunter = null, verifier = null, token = '', env = process.env } = {}) {
|
|
65
|
+
if (engine && engine !== 'atris' && engine !== 'local') throw new Error('--engine must be atris or local');
|
|
66
|
+
if (engine === 'atris' && (hunter || verifier)) throw new Error('pick --engine atris or --hunter and --verifier, not both');
|
|
67
|
+
if (engine === 'atris') {
|
|
68
|
+
if (!token) throw new Error('reviews on atris models need a login; run atris login first');
|
|
69
|
+
return { mode: 'atris', hunter: HUNTER_MODEL, verifier: VERIFIER_MODEL, chosen: 'flag' };
|
|
70
|
+
}
|
|
71
|
+
if (engine === 'local' || hunter || verifier) return { mode: 'local', hunter: hunter || null, verifier: verifier || null, chosen: 'flag' };
|
|
72
|
+
if (LOCAL_ENGINES.some((name) => localEngineAvailable(name, { env }))) return { mode: 'local', hunter: null, verifier: null, chosen: 'default' };
|
|
73
|
+
if (token) return { mode: 'atris', hunter: HUNTER_MODEL, verifier: VERIFIER_MODEL, chosen: 'no_local_engine' };
|
|
74
|
+
return { mode: 'local', hunter: null, verifier: null, chosen: 'default' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function usesAtrisModels(meta) {
|
|
78
|
+
const e = (meta && meta.engines) || {};
|
|
79
|
+
return [e.hunter, e.verifier, e.critic].some((x) => /^atris:/i.test(String(x || '')));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- http
|
|
83
|
+
|
|
84
|
+
function request(method, url, { token, body = null, accept = 'application/json', timeoutMs = 30000, onData = null } = {}) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
let parsed;
|
|
87
|
+
try { parsed = new URL(url); } catch (e) { reject(new Error(`bad url: ${url}`)); return; }
|
|
88
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
89
|
+
const data = body === null ? null : Buffer.from(JSON.stringify(body));
|
|
90
|
+
const headers = { Accept: accept, 'User-Agent': 'atris-cli-sentinel' };
|
|
91
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
92
|
+
if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = data.length; }
|
|
93
|
+
const req = transport.request({
|
|
94
|
+
hostname: parsed.hostname,
|
|
95
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
96
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
97
|
+
method,
|
|
98
|
+
headers,
|
|
99
|
+
}, (res) => {
|
|
100
|
+
res.setEncoding('utf8');
|
|
101
|
+
let text = '';
|
|
102
|
+
res.on('data', (chunk) => { text += chunk; if (onData && res.statusCode >= 200 && res.statusCode < 300) onData(chunk); });
|
|
103
|
+
res.on('end', () => {
|
|
104
|
+
let json = null;
|
|
105
|
+
try { json = text ? JSON.parse(text) : null; } catch {}
|
|
106
|
+
resolve({ status: res.statusCode, text, json });
|
|
107
|
+
});
|
|
108
|
+
res.on('error', reject);
|
|
109
|
+
});
|
|
110
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`)));
|
|
111
|
+
req.on('error', reject);
|
|
112
|
+
if (data) req.write(data);
|
|
113
|
+
req.end();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function httpError(res) {
|
|
118
|
+
const detail = res.json && (res.json.detail || res.json.error);
|
|
119
|
+
const text = typeof detail === 'string' ? detail : (detail ? JSON.stringify(detail) : String(res.text || '').slice(0, 200));
|
|
120
|
+
return `the server said ${res.status}${text ? `: ${text}` : ''}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------- engines
|
|
124
|
+
|
|
125
|
+
// The strict secret check: every scanner rule, matched raw. No allow
|
|
126
|
+
// markers and no placeholder exemptions, so `example` next to a token does
|
|
127
|
+
// not let it through.
|
|
128
|
+
function strictSecretHit(text) {
|
|
129
|
+
let rules = [];
|
|
130
|
+
try { rules = require('./security-scan').SECRET_RULES || []; } catch {}
|
|
131
|
+
return String(text || '').split('\n').some((part) => rules.some((rule) => {
|
|
132
|
+
try { rule.re.lastIndex = 0; return rule.re.test(part); } catch { return true; }
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Long encoded runs: 40+ base64 characters holding at least one digit, plus
|
|
137
|
+
// or equals sign (plain words and slash paths have none), or 40+ hex digits.
|
|
138
|
+
const LONG_BASE64 = /[A-Za-z0-9+/=_-]{40,}/g;
|
|
139
|
+
const LONG_HEX = /[0-9a-fA-F]{40,}/;
|
|
140
|
+
function hasEncodedRun(line) {
|
|
141
|
+
if (LONG_HEX.test(line)) return true;
|
|
142
|
+
for (const m of String(line).match(LONG_BASE64) || []) if (/[0-9+=]/.test(m)) return true;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const PRIVATE_KEY_BEGIN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/;
|
|
147
|
+
const PRIVATE_KEY_END = /-----END [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/;
|
|
148
|
+
|
|
149
|
+
// Every message bound for an Atris model passes through here, so no prompt
|
|
150
|
+
// part (seeded lines, cited code, source blocks, candidate JSON) can skip
|
|
151
|
+
// it. Whole private key blocks go, BEGIN through END (to the end of the
|
|
152
|
+
// message when END never comes). Then any line with a secret rule match,
|
|
153
|
+
// read as is or with JSON escapes undone, or with a long encoded run goes.
|
|
154
|
+
const HIDDEN_LINE = '[line hidden: looks like a secret]';
|
|
155
|
+
function redactOutgoing(message) {
|
|
156
|
+
let hidden = 0;
|
|
157
|
+
const lines = String(message || '').split('\n');
|
|
158
|
+
let inKey = false;
|
|
159
|
+
const out = lines.map((line) => {
|
|
160
|
+
const variants = [line, line.replace(/\\"/g, '"').replace(/\\'/g, "'"), line.replace(/\\n/g, '\n')];
|
|
161
|
+
if (!inKey && variants.some((v) => PRIVATE_KEY_BEGIN.test(v))) {
|
|
162
|
+
inKey = !variants.some((v) => PRIVATE_KEY_END.test(v));
|
|
163
|
+
hidden += 1;
|
|
164
|
+
return HIDDEN_LINE;
|
|
165
|
+
}
|
|
166
|
+
if (inKey) {
|
|
167
|
+
if (PRIVATE_KEY_END.test(line)) inKey = false;
|
|
168
|
+
hidden += 1;
|
|
169
|
+
return HIDDEN_LINE;
|
|
170
|
+
}
|
|
171
|
+
if (variants.some((v) => PRIVATE_KEY_END.test(v) || strictSecretHit(v) || hasEncodedRun(v))) {
|
|
172
|
+
hidden += 1;
|
|
173
|
+
return HIDDEN_LINE;
|
|
174
|
+
}
|
|
175
|
+
return line;
|
|
176
|
+
});
|
|
177
|
+
return { text: out.join('\n'), hidden };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// One turn on an Atris model. The reply streams as server-sent events: text
|
|
181
|
+
// arrives as text_delta, the final answer as result, and the receipt carries
|
|
182
|
+
// what the turn was charged.
|
|
183
|
+
async function runAtrisTurn({ model, prompt, token, base = apiBase(), timeoutMs = 15 * 60 * 1000, maxOutputTokens = null }) {
|
|
184
|
+
let buffer = '';
|
|
185
|
+
let text = '';
|
|
186
|
+
let result = null;
|
|
187
|
+
let credits = 0;
|
|
188
|
+
let billed = false;
|
|
189
|
+
const errors = [];
|
|
190
|
+
const readBilling = (billing) => {
|
|
191
|
+
if (!billing || typeof billing !== 'object') return;
|
|
192
|
+
billed = true;
|
|
193
|
+
if (billing.billed === false) return;
|
|
194
|
+
const c = Number(billing.credits_charged);
|
|
195
|
+
if (Number.isFinite(c) && c > 0) credits += c;
|
|
196
|
+
};
|
|
197
|
+
const handle = (block) => {
|
|
198
|
+
const data = block.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.replace(/^data:\s?/, '')).join('\n').trim();
|
|
199
|
+
if (!data || data === '[DONE]') return;
|
|
200
|
+
let ev;
|
|
201
|
+
try { ev = JSON.parse(data); } catch { return; }
|
|
202
|
+
if (!ev || typeof ev !== 'object') return;
|
|
203
|
+
if ((ev.type === 'text_delta' || ev.type === 'text') && ev.content) text += ev.content;
|
|
204
|
+
if (ev.type === 'result' && ev.result) result = String(ev.result);
|
|
205
|
+
if (ev.type === 'error' || ev.error) errors.push(String(ev.error || ev.message || 'stream error'));
|
|
206
|
+
readBilling((ev.receipt && ev.receipt.billing) || ev.billing);
|
|
207
|
+
};
|
|
208
|
+
const onData = (chunk) => {
|
|
209
|
+
buffer += chunk;
|
|
210
|
+
let at = buffer.indexOf('\n\n');
|
|
211
|
+
while (at !== -1) { handle(buffer.slice(0, at)); buffer = buffer.slice(at + 2); at = buffer.indexOf('\n\n'); }
|
|
212
|
+
};
|
|
213
|
+
const { text: message } = redactOutgoing(prompt);
|
|
214
|
+
const body = { message, model, max_turns: 1, disable_connector_tools: true };
|
|
215
|
+
if (Number.isInteger(maxOutputTokens) && maxOutputTokens >= 16) body.max_output_tokens = Math.min(8192, maxOutputTokens);
|
|
216
|
+
let res;
|
|
217
|
+
try {
|
|
218
|
+
res = await request('POST', `${base}/atris2/turn`, { token, body, accept: 'text/event-stream', timeoutMs, onData });
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return { ok: false, stdout: '', reason: e.message, credits_charged: 0, billed: false };
|
|
221
|
+
}
|
|
222
|
+
if (res.status < 200 || res.status >= 300) return { ok: false, stdout: '', reason: httpError(res), credits_charged: 0, billed: res.status === 402 ? false : billed };
|
|
223
|
+
if (buffer.trim()) handle(buffer);
|
|
224
|
+
// A plain JSON reply (no stream) still counts.
|
|
225
|
+
if (!text && !result && res.json && typeof res.json === 'object') {
|
|
226
|
+
result = String(res.json.result || res.json.output || res.json.text || '');
|
|
227
|
+
readBilling((res.json.receipt && res.json.receipt.billing) || res.json.billing);
|
|
228
|
+
}
|
|
229
|
+
const stdout = result || text;
|
|
230
|
+
if (!stdout && errors.length) return { ok: false, stdout: '', reason: errors.join('; '), credits_charged: credits, billed };
|
|
231
|
+
return { ok: Boolean(stdout), stdout, reason: stdout ? undefined : 'empty answer', credits_charged: credits, billed };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const CAP_REASON = 'stopped at the approved credit limit';
|
|
235
|
+
|
|
236
|
+
// The spend guard every hosted call goes through. `max` is the approved
|
|
237
|
+
// limit. Before a call starts, its own worst case (quoted at its exact size
|
|
238
|
+
// and output limit) is held against the limit along with every call still
|
|
239
|
+
// running; a call that could pass the limit never starts. After a call, the
|
|
240
|
+
// receipt's charge is counted, or the worst case when no receipt came back.
|
|
241
|
+
function spendGuard({ max = 0, quoteCall } = {}) {
|
|
242
|
+
return { max, spent: 0, held: 0, blocked: 0, calls: 0, limits: {}, quoteCall };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A runJobs for runAudit: atris:* jobs go to the backend, anything else to
|
|
246
|
+
// the local engine runner.
|
|
247
|
+
function atrisJobRunner({ token, base = null, localRunJobs = null, spend = null } = {}) {
|
|
248
|
+
return async (jobs, { root, concurrency = 3, timeoutMs } = {}) => {
|
|
249
|
+
const answers = new Array(jobs.length);
|
|
250
|
+
const hosted = [];
|
|
251
|
+
const local = [];
|
|
252
|
+
jobs.forEach((job, i) => (/^atris:/i.test(String(job.engine || '')) ? hosted : local).push(i));
|
|
253
|
+
if (local.length) {
|
|
254
|
+
const run = localRunJobs || ((js, o) => {
|
|
255
|
+
const { runEngineAskJobs } = require('./engine-ask');
|
|
256
|
+
return runEngineAskJobs(js, o);
|
|
257
|
+
});
|
|
258
|
+
const got = await run(local.map((i) => jobs[i]), { root, concurrency, timeoutMs });
|
|
259
|
+
local.forEach((i, k) => { answers[i] = got[k]; });
|
|
260
|
+
}
|
|
261
|
+
const url = base || apiBase();
|
|
262
|
+
const one = async (job) => {
|
|
263
|
+
const model = job.engine;
|
|
264
|
+
const outTokens = Number.isInteger(job.max_output_tokens) ? job.max_output_tokens : 8192;
|
|
265
|
+
if (!spend) return runAtrisTurn({ model, prompt: job.prompt, token, base: url, timeoutMs, maxOutputTokens: outTokens });
|
|
266
|
+
const bytes = Buffer.byteLength(redactOutgoing(job.prompt).text);
|
|
267
|
+
const limit = spend.limits[model];
|
|
268
|
+
if (limit && bytes > limit) { spend.blocked += 1; return { ok: false, stdout: '', reason: `message is larger than the size this run was priced for (${bytes} of ${limit} bytes)`, credits_charged: 0 }; }
|
|
269
|
+
let worst;
|
|
270
|
+
try { worst = await (spend.quoteCall || ((m, i, o) => quoteCall(m, i, o, { token, base: url })))(model, bytes, outTokens); } catch (e) { spend.blocked += 1; return { ok: false, stdout: '', reason: `no price for this call: ${e.message}`, credits_charged: 0 }; }
|
|
271
|
+
if (spend.spent + spend.held + worst > spend.max) { spend.blocked += 1; return { ok: false, stdout: '', reason: CAP_REASON, credits_charged: 0 }; }
|
|
272
|
+
spend.held += worst;
|
|
273
|
+
spend.calls += 1;
|
|
274
|
+
let answer;
|
|
275
|
+
try { answer = await runAtrisTurn({ model, prompt: job.prompt, token, base: url, timeoutMs, maxOutputTokens: outTokens }); } finally { spend.held -= worst; }
|
|
276
|
+
spend.spent += answer.billed ? Number(answer.credits_charged) || 0 : worst;
|
|
277
|
+
return answer;
|
|
278
|
+
};
|
|
279
|
+
let next = 0;
|
|
280
|
+
const work = async () => {
|
|
281
|
+
while (next < hosted.length) {
|
|
282
|
+
const i = hosted[next++];
|
|
283
|
+
answers[i] = await one(jobs[i]);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, hosted.length)) }, work));
|
|
287
|
+
return answers;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------- quote
|
|
292
|
+
|
|
293
|
+
// The price of one call at these exact counts, capped by the backend's own
|
|
294
|
+
// most-per-request when it reports one.
|
|
295
|
+
async function quoteCall(model, inputTokens, outputTokens, { token, base = apiBase(), timeoutMs = 15000 } = {}) {
|
|
296
|
+
const qs = new URLSearchParams({ model, input_tokens: String(Math.ceil(inputTokens)), output_tokens: String(Math.ceil(outputTokens)) });
|
|
297
|
+
const res = await request('GET', `${base}/atris2/billing/quote?${qs}`, { token, timeoutMs });
|
|
298
|
+
if (res.status < 200 || res.status >= 300 || !res.json || !Number.isFinite(Number(res.json.credits))) {
|
|
299
|
+
throw new Error(`could not get a price for ${model}: ${httpError(res)}`);
|
|
300
|
+
}
|
|
301
|
+
return Number(res.json.credits);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Prices every hosted row of a spend estimate: each call at its largest
|
|
305
|
+
// allowed size and full output limit, times the calls. Throws when any quote
|
|
306
|
+
// fails, so a run never spends without a price shown.
|
|
307
|
+
async function quoteEstimate(estimate, { token, base = apiBase(), timeoutMs = 15000 } = {}) {
|
|
308
|
+
const rows = [];
|
|
309
|
+
for (const row of estimate.calls || []) {
|
|
310
|
+
const per = await quoteCall(row.model, row.input_tokens, row.output_tokens, { token, base, timeoutMs });
|
|
311
|
+
rows.push({ ...row, credits_per_call: per, credits: per * row.calls });
|
|
312
|
+
}
|
|
313
|
+
const credits = rows.reduce((n, r) => n + r.credits, 0);
|
|
314
|
+
const limits = {};
|
|
315
|
+
for (const r of rows) if (r.max_input_bytes) limits[r.model] = Math.max(limits[r.model] || 0, r.max_input_bytes);
|
|
316
|
+
return { rows, credits, limits };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function describeQuote(quote) {
|
|
320
|
+
const parts = quote.rows.map((r) => `${r.calls} ${r.role === 'hunter' ? 'search' : 'check'} call${r.calls === 1 ? '' : 's'} on ${r.model} at most ${r.credits_per_call} credit${r.credits_per_call === 1 ? '' : 's'} each`);
|
|
321
|
+
return [
|
|
322
|
+
`this review runs on atris models and costs at most ${quote.credits} credits`,
|
|
323
|
+
` ${parts.join('; ')}`,
|
|
324
|
+
' the run stops before it could pass that. most runs spend far less',
|
|
325
|
+
' your code is sent to atris models to be read. lines that look like secrets are hidden first',
|
|
326
|
+
].join('\n');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------- upload
|
|
330
|
+
|
|
331
|
+
function readJsonFile(file) {
|
|
332
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The upload guarantee: POST /api/sentinel/runs carries only plain
|
|
336
|
+
// sentences, file:line locations and counts. Never source, traces, code
|
|
337
|
+
// lines or identifiers. Every sentence passes the allowlist below or is
|
|
338
|
+
// replaced whole with FALLBACK_SENTENCE; nothing is cleaned up and kept.
|
|
339
|
+
//
|
|
340
|
+
// Accepted limit: a model could still describe a secret in ordinary words
|
|
341
|
+
// ("the password is the word swordfish followed by seven"). No filter can
|
|
342
|
+
// tell that from a normal sentence, so the prompts ask for sentences with
|
|
343
|
+
// no code, identifiers, file contents or secrets, and this is where the
|
|
344
|
+
// guarantee stops.
|
|
345
|
+
const FALLBACK_SENTENCE = 'See the full report on your machine.';
|
|
346
|
+
const PROSE_CHARS = /^[A-Za-z0-9 .,'():-]+$/;
|
|
347
|
+
|
|
348
|
+
function plain(value, max = 500) {
|
|
349
|
+
const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
|
350
|
+
if (!s) return '';
|
|
351
|
+
if (s.length > max) return FALLBACK_SENTENCE;
|
|
352
|
+
if (!PROSE_CHARS.test(s)) return FALLBACK_SENTENCE;
|
|
353
|
+
if (/[A-Za-z0-9]\(/.test(s)) return FALLBACK_SENTENCE; // a call shape: word(
|
|
354
|
+
if (s.replace(/[A-Za-z]'(?=[A-Za-z])/g, '').includes("'")) return FALLBACK_SENTENCE; // quotes, not apostrophes
|
|
355
|
+
if (/\([^)]*[,'][^)]*\)/.test(s)) return FALLBACK_SENTENCE; // argument lists
|
|
356
|
+
if (/[A-Za-z0-9]\.[A-Za-z0-9]/.test(s)) return FALLBACK_SENTENCE; // a.b: paths, members, versions
|
|
357
|
+
if (/[a-z][A-Z]/.test(s)) return FALLBACK_SENTENCE; // camelCase
|
|
358
|
+
if (/[A-Za-z0-9]{20,}/.test(s)) return FALLBACK_SENTENCE; // tokens and long ids
|
|
359
|
+
if (strictSecretHit(s)) return FALLBACK_SENTENCE;
|
|
360
|
+
return s;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// file:line from a trace step, kept as is when it is a plain repo path.
|
|
364
|
+
function location(sink) {
|
|
365
|
+
if (!sink || typeof sink.file !== 'string') return '';
|
|
366
|
+
const file = sink.file.trim();
|
|
367
|
+
if (!/^[\w./@+-]+$/.test(file) || file.split('/').includes('..') || file.length > 280) return '';
|
|
368
|
+
return `${file}${Number.isInteger(sink.line) && sink.line > 0 ? `:${sink.line}` : ''}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function hardeningFromReport(text) {
|
|
372
|
+
const lines = String(text || '').split('\n');
|
|
373
|
+
const start = lines.findIndex((l) => /^##\s+Hardening notes/i.test(l));
|
|
374
|
+
if (start === -1) return [];
|
|
375
|
+
const out = [];
|
|
376
|
+
for (const l of lines.slice(start + 1)) {
|
|
377
|
+
if (/^##\s/.test(l)) break;
|
|
378
|
+
const m = l.match(/^\s*-\s+(.*)$/);
|
|
379
|
+
const note = m ? plain(m[1], 400) : '';
|
|
380
|
+
if (note) out.push(note);
|
|
381
|
+
}
|
|
382
|
+
return [...new Set(out)].slice(0, 50);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function needsFromMarkdown(text) {
|
|
386
|
+
const out = [];
|
|
387
|
+
let cur = null;
|
|
388
|
+
let inBlocked = false;
|
|
389
|
+
for (const l of String(text || '').split('\n')) {
|
|
390
|
+
const h = l.match(/^##\s+(.*)$/);
|
|
391
|
+
if (h) { cur = { title: plain(h[1], 300), blocked_on: '' }; out.push(cur); inBlocked = false; continue; }
|
|
392
|
+
if (!cur) continue;
|
|
393
|
+
if (/^Blocked on:/i.test(l)) { inBlocked = true; continue; }
|
|
394
|
+
if (/^\S.*:$/.test(l)) { inBlocked = false; continue; }
|
|
395
|
+
const m = l.match(/^-\s+(.*)$/);
|
|
396
|
+
if (inBlocked && m && !cur.blocked_on) cur.blocked_on = plain(m[1], 400);
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function count(v) {
|
|
402
|
+
const n = Number(v);
|
|
403
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The contract body (POST /api/sentinel/runs), built from a run folder.
|
|
407
|
+
function buildUploadBody(dir) {
|
|
408
|
+
const meta = readJsonFile(path.join(dir, 'run-metadata.json'));
|
|
409
|
+
if (!meta) throw new Error(`no run-metadata.json in ${dir}`);
|
|
410
|
+
const records = readJsonFile(path.join(dir, 'findings.json')) || [];
|
|
411
|
+
let report = '';
|
|
412
|
+
try { report = fs.readFileSync(path.join(dir, 'REPORT.md'), 'utf8'); } catch {}
|
|
413
|
+
let nvText = '';
|
|
414
|
+
try { nvText = fs.readFileSync(path.join(dir, 'NEEDS-VALIDATION.md'), 'utf8'); } catch {}
|
|
415
|
+
|
|
416
|
+
const cov = meta.coverage || {};
|
|
417
|
+
const counts = meta.counts || {};
|
|
418
|
+
const sev = counts.severity || {};
|
|
419
|
+
const rank = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
|
|
420
|
+
const confirmed = records.filter((r) => r && r.verdict === 'confirmed')
|
|
421
|
+
.sort((a, b) => (rank[(a.severity || {}).overall] ?? 9) - (rank[(b.severity || {}).overall] ?? 9));
|
|
422
|
+
const findings = confirmed.slice(0, MAX_UPLOAD_FINDINGS).map((r, i) => {
|
|
423
|
+
const trace = Array.isArray(r.trace) ? r.trace : [];
|
|
424
|
+
const sink = trace[trace.length - 1] || {};
|
|
425
|
+
const severity = SEVERITIES.includes((r.severity || {}).overall) ? r.severity.overall : 'informational';
|
|
426
|
+
return {
|
|
427
|
+
id: `f-${require('crypto').createHash('sha256').update(String(r.fingerprint || `f${i + 1}`)).digest('hex').slice(0, 12)}`,
|
|
428
|
+
severity,
|
|
429
|
+
title: plain(r.title, 300),
|
|
430
|
+
location: location(sink),
|
|
431
|
+
// Only the plain summary leaves; the code-level strategy stays local.
|
|
432
|
+
fix: plain((r.remediation || {}).summary, 600) || FALLBACK_SENTENCE,
|
|
433
|
+
status: 'open',
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
let needs = records.filter((r) => r && r.verdict === 'needs_validation').map((r) => ({
|
|
437
|
+
title: plain(r.title, 300),
|
|
438
|
+
blocked_on: plain(Array.isArray(r.blockers) && r.blockers[0] ? r.blockers[0] : '', 400),
|
|
439
|
+
}));
|
|
440
|
+
if (!needs.length && nvText) needs = needsFromMarkdown(nvText);
|
|
441
|
+
const engines = meta.engines || {};
|
|
442
|
+
const body = {
|
|
443
|
+
repo: String(meta.repo || path.basename(path.dirname(dir))),
|
|
444
|
+
run_id: String(meta.run_id || path.basename(dir)),
|
|
445
|
+
started_at: meta.started_at || null,
|
|
446
|
+
finished_at: meta.finished_at || null,
|
|
447
|
+
profile: String(meta.profile || 'standard'),
|
|
448
|
+
source: usesAtrisModels({ engines }) ? 'atris-models' : 'cli-local',
|
|
449
|
+
coverage: { in_scope: count(cov.in_scope), done: count(cov.done), deferred: count(cov.deferred), blocked: count(cov.blocked) },
|
|
450
|
+
counts: {
|
|
451
|
+
confirmed: count(counts.confirmed),
|
|
452
|
+
needs_validation: count(counts.needs_validation),
|
|
453
|
+
rejected: count(counts.rejected),
|
|
454
|
+
severity: Object.fromEntries(SEVERITIES.map((s) => [s, count(sev[s])])),
|
|
455
|
+
},
|
|
456
|
+
findings,
|
|
457
|
+
needs_validation: needs.slice(0, MAX_UPLOAD_FINDINGS),
|
|
458
|
+
hardening: hardeningFromReport(report),
|
|
459
|
+
credits_used: Number(meta.credits_used) > 0 ? Number(meta.credits_used) : 0,
|
|
460
|
+
};
|
|
461
|
+
while (Buffer.byteLength(JSON.stringify(body)) > MAX_UPLOAD_BYTES && (body.findings.length || body.needs_validation.length || body.hardening.length)) {
|
|
462
|
+
if (body.hardening.length) body.hardening.pop();
|
|
463
|
+
else if (body.needs_validation.length) body.needs_validation.pop();
|
|
464
|
+
else body.findings.pop();
|
|
465
|
+
}
|
|
466
|
+
return body;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function uploadRun(dir, { token, base = apiBase(), timeoutMs = 20000 } = {}) {
|
|
470
|
+
if (!token) return { ok: false, reason: 'not logged in' };
|
|
471
|
+
let body;
|
|
472
|
+
try { body = buildUploadBody(dir); } catch (e) { return { ok: false, reason: e.message }; }
|
|
473
|
+
try {
|
|
474
|
+
const res = await request('POST', `${base}/sentinel/runs`, { token, body, timeoutMs });
|
|
475
|
+
if (res.status < 200 || res.status >= 300) return { ok: false, reason: httpError(res), body };
|
|
476
|
+
return { ok: true, id: res.json && res.json.id ? String(res.json.id) : null, body };
|
|
477
|
+
} catch (e) {
|
|
478
|
+
return { ok: false, reason: e.message, body };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------- schedule
|
|
483
|
+
|
|
484
|
+
function schedulePlan({ target, every, homeDir = os.homedir(), platform = process.platform, atrisBin, envPath = process.env.PATH || '' }) {
|
|
485
|
+
if (!SCHEDULE_EVERY[every]) throw new Error('--every must be day or week');
|
|
486
|
+
const { planKeep } = require('./campaign-keep');
|
|
487
|
+
return planKeep({
|
|
488
|
+
root: target,
|
|
489
|
+
home: path.join(homeDir, '.atris', 'sentinel'),
|
|
490
|
+
platform,
|
|
491
|
+
homeDir,
|
|
492
|
+
atrisBin,
|
|
493
|
+
envPath,
|
|
494
|
+
job: {
|
|
495
|
+
name: 'sentinel-review',
|
|
496
|
+
args: ['sentinel', '--quick', '--scheduled'],
|
|
497
|
+
every_seconds: SCHEDULE_EVERY[every],
|
|
498
|
+
marker: 'ATRIS_SENTINEL_REVIEW',
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// What the owner approved when the timer went on: which engines, and for
|
|
504
|
+
// atris models the most credits one run may spend. A scheduled run reads
|
|
505
|
+
// this and nothing else; with no record it does not run at all.
|
|
506
|
+
function scheduleKey(target) {
|
|
507
|
+
let real = path.resolve(target);
|
|
508
|
+
try { real = fs.realpathSync(real); } catch {}
|
|
509
|
+
return require('crypto').createHash('sha256').update(real).digest('hex').slice(0, 16);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function approvalPath(target, homeDir = os.homedir()) {
|
|
513
|
+
return path.join(homeDir, '.atris', 'sentinel', 'schedules', `${scheduleKey(target)}.json`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function writeApproval(target, approval, homeDir) {
|
|
517
|
+
const file = approvalPath(target, homeDir);
|
|
518
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
519
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
520
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ target: path.resolve(target), ...approval }, null, 2)}\n`, { mode: 0o600 });
|
|
521
|
+
fs.renameSync(tmp, file);
|
|
522
|
+
return file;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function readApproval(target, homeDir) {
|
|
526
|
+
const a = readJsonFile(approvalPath(target, homeDir));
|
|
527
|
+
if (!a || (a.engine !== 'atris' && a.engine !== 'local')) return null;
|
|
528
|
+
if (a.engine === 'atris' && !(Number(a.max_credits) > 0)) return null;
|
|
529
|
+
return a;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function removeApproval(target, homeDir) {
|
|
533
|
+
const file = approvalPath(target, homeDir);
|
|
534
|
+
const had = fs.existsSync(file);
|
|
535
|
+
if (had) fs.unlinkSync(file);
|
|
536
|
+
return had;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
module.exports = {
|
|
540
|
+
FALLBACK_SENTENCE,
|
|
541
|
+
plain,
|
|
542
|
+
request,
|
|
543
|
+
httpError,
|
|
544
|
+
writeApproval,
|
|
545
|
+
readApproval,
|
|
546
|
+
removeApproval,
|
|
547
|
+
CAP_REASON,
|
|
548
|
+
redactOutgoing,
|
|
549
|
+
spendGuard,
|
|
550
|
+
quoteCall,
|
|
551
|
+
SCHEDULE_EVERY,
|
|
552
|
+
authToken,
|
|
553
|
+
apiBase,
|
|
554
|
+
resolveEngines,
|
|
555
|
+
atrisJobRunner,
|
|
556
|
+
quoteEstimate,
|
|
557
|
+
describeQuote,
|
|
558
|
+
buildUploadBody,
|
|
559
|
+
uploadRun,
|
|
560
|
+
schedulePlan,
|
|
561
|
+
};
|