reprobe 0.0.0-stage → 0.1.0

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,324 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { globSync, readFileSync } from 'node:fs';
3
+ import { basename, dirname, resolve } from 'node:path';
4
+ import { config, firstLine } from "../config.js";
5
+ import { run } from "../exec.js";
6
+ // Transcripts sometimes keep the colour codes without their ESC byte, so the ESC is optional.
7
+ const ANSI = /\u001b?\[[0-9;]{1,12}m/g;
8
+ const HTTP = /(?:✅|❌|⚠️)?\s*\b(GET|POST|PUT|PATCH|DELETE)\s+(\/\S*)\s+(\d{3})\b/;
9
+ const KV = /(\w+)=("([^"]*)"|[^\s"]+)/g;
10
+ function timestampMs(value) {
11
+ const normalized = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?$/.test(value) ? `${value}Z` : value;
12
+ return Date.parse(normalized);
13
+ }
14
+ function safeWindow(w) {
15
+ const timestamp = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/;
16
+ if (!timestamp.test(w.from) || !timestamp.test(w.to))
17
+ return null;
18
+ const from = Date.parse(w.from);
19
+ const to = Date.parse(w.to);
20
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from > to)
21
+ return null;
22
+ return { from: new Date(from).toISOString(), to: new Date(to).toISOString() };
23
+ }
24
+ function kv(text) {
25
+ const out = {};
26
+ for (const m of text.matchAll(KV))
27
+ out[m[1]] = m[3] ?? m[2];
28
+ return out;
29
+ }
30
+ /** Normalises the three shapes Railway logs come in: `--json` objects, `<ts> [level] <nest line>`, and `<ts> [LEVEL] key=value`. */
31
+ export function parseLogLine(raw) {
32
+ const line = raw.replace(ANSI, '').trimEnd();
33
+ let at = null;
34
+ let text = '';
35
+ let fields = {};
36
+ if (line.startsWith('{')) {
37
+ try {
38
+ fields = JSON.parse(line);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ at = typeof fields.timestamp === 'string' ? fields.timestamp : null;
44
+ text = String(fields.message ?? '').replace(ANSI, '');
45
+ if (fields.event === 'request_completed' || fields.path) {
46
+ text = `${fields.event ?? 'request'} ${fields.method ?? ''} ${fields.path ?? ''} ${fields.statusCode ?? ''} clientVersion=${fields.clientVersion ?? '?'} userId=${fields.userId ?? '?'} ${text}`.trim();
47
+ }
48
+ }
49
+ else {
50
+ const m = line.match(/^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z)\s+(?:\[(\w+)\]\s*)?(.*)$/);
51
+ if (!m)
52
+ return null;
53
+ at = m[1];
54
+ text = m[3];
55
+ fields = { level: m[2], ...kv(text) };
56
+ }
57
+ if (!at || !text.trim())
58
+ return null;
59
+ const timestamp = timestampMs(at);
60
+ if (!Number.isFinite(timestamp))
61
+ return null;
62
+ const nest = text.match(/\b(LOG|ERROR|WARN|DEBUG|VERBOSE)\s+\[([\w.-]+)\]/);
63
+ const http = text.match(HTTP);
64
+ const status = Number(fields.statusCode ?? http?.[3] ?? NaN);
65
+ const level = (nest?.[1] ?? String(fields.level ?? 'info')).toLowerCase().replace('log', 'info');
66
+ return {
67
+ at: new Date(timestamp).toISOString(),
68
+ text: text.replace(/\s+/g, ' ').trim(),
69
+ level: level === 'warning' ? 'warn' : level,
70
+ context: nest?.[2] ?? null,
71
+ method: fields.method || http?.[1] || null,
72
+ path: fields.path || http?.[2] || null,
73
+ status: Number.isFinite(status) ? status : null,
74
+ userId: typeof fields.userId === 'string' && fields.userId !== '?' ? fields.userId : null,
75
+ clientVersion: typeof fields.clientVersion === 'string' && fields.clientVersion !== '?' ? fields.clientVersion : null,
76
+ };
77
+ }
78
+ export function inWindow(at, w) {
79
+ const value = timestampMs(at);
80
+ const from = timestampMs(w.from);
81
+ const to = timestampMs(w.to);
82
+ return Number.isFinite(value) && Number.isFinite(from) && Number.isFinite(to) && value >= from && value <= to;
83
+ }
84
+ /** Lines worth keeping: anything about the people we care about, every warning/error, every failed request. */
85
+ export function relevant(l, ids) {
86
+ if (l.level === 'error' || l.level === 'warn')
87
+ return true;
88
+ if (l.status !== null && l.status >= 400)
89
+ return true;
90
+ if (/Exception|Error:/.test(l.text))
91
+ return true;
92
+ return ids.some((id) => l.text.includes(id) || (id.length > 8 && l.text.includes(`${id.slice(0, 8)}…`)));
93
+ }
94
+ const hash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 10);
95
+ function logEvidence(l, source, extra = {}) {
96
+ return {
97
+ id: `${source}:${l.at}:${hash(l.text)}`,
98
+ source,
99
+ at: l.at,
100
+ kind: l.status !== null ? (l.status >= 500 ? 'http.5xx' : l.status >= 400 ? 'http.4xx' : 'http.ok') : `log.${l.level}`,
101
+ summary: (l.method && l.path && l.status !== null ? `${l.method} ${l.path} ${l.status}${l.userId ? ` user ${l.userId.slice(0, 8)}` : ''}${l.clientVersion ? ` app ${l.clientVersion}` : ''} · ${l.text}` : l.text).slice(0, 260),
102
+ data: { ...l, ...extra },
103
+ };
104
+ }
105
+ /**
106
+ * Railway emits a multi-line message (an error object, a stack trace) as one line per row, all with
107
+ * the same timestamp. Fold those rows back into the line that started them, in source order.
108
+ */
109
+ export function mergeContinuations(lines) {
110
+ const out = [];
111
+ for (const l of lines) {
112
+ const prev = out.at(-1);
113
+ const starts = /\[Nest\]|\b(?:LOG|ERROR|WARN|DEBUG|VERBOSE)\s+\[|request_completed|^\S+=/.test(l.text) || l.status !== null;
114
+ const continuation = /^(?:at\s|Caused by:|status:|message:|stack:|code:|details?:)/i.test(l.text);
115
+ const boundary = prev && (prev.context !== null && l.context !== null && prev.context !== l.context ||
116
+ prev.userId !== null && l.userId !== null && prev.userId !== l.userId ||
117
+ prev.clientVersion !== null && l.clientVersion !== null && prev.clientVersion !== l.clientVersion ||
118
+ l.level !== prev.level && !continuation);
119
+ if (prev && prev.at === l.at && !starts && !boundary) {
120
+ prev.text = `${prev.text} | ${l.text}`.slice(0, 4000);
121
+ continue;
122
+ }
123
+ out.push({ ...l });
124
+ }
125
+ return out;
126
+ }
127
+ export function dedupe(lines) {
128
+ const seen = new Set();
129
+ return lines
130
+ .filter((l) => {
131
+ const k = `${l.at}|${l.text}`;
132
+ if (seen.has(k))
133
+ return false;
134
+ seen.add(k);
135
+ return true;
136
+ })
137
+ .sort((a, b) => a.at.localeCompare(b.at) || a.text.localeCompare(b.text));
138
+ }
139
+ async function railwayLines(src, w, runner) {
140
+ const opts = { cwd: src.railway.cwd ? resolve(config.repo, src.railway.cwd) : config.repo, timeoutMs: 120_000 };
141
+ const service = src.railway.service;
142
+ const list = await runner('railway', ['deployment', 'list', '--service', service, '--json'], opts);
143
+ if (list.code === 127)
144
+ return { source: 'logs', state: 'missing-credentials', detail: 'railway CLI not installed' };
145
+ if (list.code !== 0)
146
+ return { source: 'logs', state: 'error', detail: `railway deployment list: ${firstLine(list.stderr)}` };
147
+ let deployments = [];
148
+ try {
149
+ const value = JSON.parse(list.stdout);
150
+ if (!Array.isArray(value) || value.some((d) => !d || typeof d.id !== 'string' || !d.id || typeof d.createdAt !== 'string' || !Number.isFinite(timestampMs(d.createdAt)))) {
151
+ throw new Error('invalid deployment list shape');
152
+ }
153
+ deployments = value;
154
+ }
155
+ catch {
156
+ return { source: 'logs', state: 'error', detail: 'railway deployment list: invalid JSON or deployment list shape' };
157
+ }
158
+ deployments.sort((a, b) => timestampMs(a.createdAt) - timestampMs(b.createdAt));
159
+ const from = timestampMs(w.from);
160
+ const to = timestampMs(w.to);
161
+ const overlapping = deployments.filter((d, i) => timestampMs(d.createdAt) <= to && timestampMs(deployments[i + 1]?.createdAt ?? '9999-12-31T23:59:59.999Z') >= from);
162
+ if (!overlapping.length) {
163
+ return { source: 'logs', state: 'expired', detail: `no Railway deployment of ${service} covers the window; oldest listed deployment started ${deployments[0]?.createdAt ?? 'unknown'}` };
164
+ }
165
+ const lines = [];
166
+ const errors = [];
167
+ for (const d of overlapping) {
168
+ const r = await runner('railway', ['logs', d.id, '--service', service, '--since', w.from, '--until', w.to, '--json'], opts);
169
+ if (r.code !== 0) {
170
+ errors.push(`${d.id.slice(0, 8)}: ${firstLine(r.stderr)}`);
171
+ continue;
172
+ }
173
+ lines.push(...mergeContinuations(r.stdout.split('\n').map(parseLogLine).filter((l) => l !== null && inWindow(l.at, w))));
174
+ }
175
+ return { lines, errors, note: `${overlapping.length} deployment(s)` };
176
+ }
177
+ async function commandLines(src, w, runner) {
178
+ const args = src.command.slice(1).map((a) => a.replaceAll('{since}', w.from).replaceAll('{until}', w.to));
179
+ const r = await runner(src.command[0], args, { cwd: src.cwd ? resolve(config.repo, src.cwd) : config.repo, timeoutMs: 120_000 });
180
+ if (r.code === 127)
181
+ return { source: 'logs', state: 'missing-credentials', detail: `${src.command[0]} not installed` };
182
+ if (r.code !== 0)
183
+ return { source: 'logs', state: 'error', detail: `${src.command[0]}: ${firstLine(r.stderr || r.stdout)}` };
184
+ return { lines: mergeContinuations(r.stdout.split('\n').map(parseLogLine).filter((l) => l !== null && inWindow(l.at, w))), errors: [], note: `\`${src.command[0]}\`` };
185
+ }
186
+ function fileLines(src, w) {
187
+ const files = globSync(src.file, { cwd: config.repo }).map((f) => resolve(config.repo, f)).sort();
188
+ if (!files.length)
189
+ return { source: 'logs', state: 'not-found', detail: `no file matches ${src.file}` };
190
+ const lines = [];
191
+ const errors = [];
192
+ for (const f of files) {
193
+ try {
194
+ lines.push(...mergeContinuations(readFileSync(f, 'utf8').split('\n').map(parseLogLine).filter((l) => l !== null && inWindow(l.at, w))));
195
+ }
196
+ catch (e) {
197
+ errors.push(`${basename(f)}: ${e.message}`);
198
+ }
199
+ }
200
+ return { lines, errors, note: errors.length ? `${files.length - errors.length} readable file(s)` : `${files.length} file(s)` };
201
+ }
202
+ /** Every configured log source, filtered to lines that mention the ids or look like failures. */
203
+ export async function gatherLogs(w, ids, runner = run) {
204
+ const sources = config.sources.logs ?? [];
205
+ if (!sources.length)
206
+ return [{ status: { source: 'logs', state: 'skipped', detail: 'no log source in the config' }, evidence: [] }];
207
+ const normalizedWindow = safeWindow(w);
208
+ if (!normalizedWindow)
209
+ return sources.map((src) => ({ status: { source: 'logs', state: 'error', detail: `${src.name}: invalid log window; expected ordered ISO timestamps` }, evidence: [] }));
210
+ const out = [];
211
+ for (const src of sources) {
212
+ const got = 'railway' in src ? await railwayLines(src, normalizedWindow, runner) : 'command' in src ? await commandLines(src, normalizedWindow, runner) : fileLines(src, normalizedWindow);
213
+ if ('state' in got) {
214
+ out.push({ status: { ...got, detail: `${src.name}: ${got.detail}` }, evidence: [] });
215
+ continue;
216
+ }
217
+ const kept = dedupe(got.lines.filter((l) => relevant(l, ids))).slice(0, 800);
218
+ if (!kept.length) {
219
+ const stale = 'railway' in src && Date.now() - Date.parse(w.to) > 7 * 86_400_000;
220
+ out.push({
221
+ status: {
222
+ source: 'logs',
223
+ state: got.errors.length ? 'error' : stale ? 'expired' : 'empty',
224
+ detail: `${src.name}: ${got.errors.length ? got.errors.join('; ') : stale ? 'no lines returned: logs past retention (~7 days on Railway)' : 'no relevant lines in the window'}`,
225
+ },
226
+ evidence: [],
227
+ });
228
+ continue;
229
+ }
230
+ out.push({
231
+ status: { source: 'logs', state: 'ok', detail: `${src.name}: ${kept.length} relevant lines from ${got.note}${got.errors.length ? `; failed: ${got.errors.join('; ')}` : ''}` },
232
+ evidence: kept.map((l) => logEvidence(l, 'logs')),
233
+ });
234
+ }
235
+ return out;
236
+ }
237
+ // ── archived logs inside saved agent transcripts ─────────────────────────────
238
+ export const globRegex = (g) => new RegExp(`^${g.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
239
+ function hourPrefixes(w) {
240
+ const out = [];
241
+ for (let t = timestampMs(w.from) - 3_600_000; t <= timestampMs(w.to); t += 3_600_000)
242
+ out.push(new Date(t).toISOString().slice(0, 13) + ':');
243
+ return [...new Set(out)];
244
+ }
245
+ function toolResultTexts(o) {
246
+ if (o?.type === 'assistant' || o?.message?.role === 'assistant')
247
+ return [];
248
+ const content = o?.message?.content;
249
+ if (!Array.isArray(content))
250
+ return [];
251
+ const out = [];
252
+ for (const b of content) {
253
+ if (b?.type !== 'tool_result')
254
+ continue;
255
+ if (typeof b.content === 'string')
256
+ out.push(b.content);
257
+ else if (Array.isArray(b.content))
258
+ for (const c of b.content)
259
+ if (typeof c?.text === 'string')
260
+ out.push(c.text);
261
+ }
262
+ return out;
263
+ }
264
+ /**
265
+ * Log-shaped tool output from a saved Claude Code transcript. Assistant messages are excluded, but
266
+ * the transcript does not prove which command produced the output or whether it was edited later.
267
+ */
268
+ export async function gatherArchive(w, ids, runner = run) {
269
+ const archive = config.sources.archive;
270
+ if (!archive)
271
+ return { status: { source: 'archive', state: 'skipped', detail: 'no archive source in the config' }, evidence: [] };
272
+ const args = ['-l', '-F', '--glob', '*.jsonl', '--glob', '!**/subagents/**'];
273
+ for (const p of hourPrefixes(w))
274
+ args.push('-e', p);
275
+ args.push(archive.dir);
276
+ const r = await runner('rg', args, { timeoutMs: 120_000 });
277
+ if (r.code === 127)
278
+ return { status: { source: 'archive', state: 'skipped', detail: 'ripgrep (rg) not installed' }, evidence: [] };
279
+ if (r.code !== 0 && r.code !== 1)
280
+ return { status: { source: 'archive', state: 'error', detail: `transcript search: ${firstLine(r.stderr)}` }, evidence: [] };
281
+ const files = r.stdout
282
+ .split('\n')
283
+ .filter(Boolean)
284
+ .filter((f) => globRegex(archive.glob).test(basename(dirname(f))))
285
+ .sort();
286
+ if (!files.length)
287
+ return { status: { source: 'archive', state: 'empty', detail: `no saved transcript in ${archive.dir} mentions the window` }, evidence: [] };
288
+ const found = new Map();
289
+ for (const file of files) {
290
+ const rows = readFileSync(file, 'utf8').split('\n');
291
+ rows.forEach((row, i) => {
292
+ if (!row.includes('tool_result'))
293
+ return;
294
+ let o;
295
+ try {
296
+ o = JSON.parse(row);
297
+ }
298
+ catch {
299
+ return;
300
+ }
301
+ for (const text of toolResultTexts(o)) {
302
+ const parsed = text
303
+ .split('\n')
304
+ .map((raw) => parseLogLine(raw.replace(/^\s*\d+[\t→]/, '')))
305
+ .filter((l) => l !== null && inWindow(l.at, w));
306
+ for (const l of mergeContinuations(parsed)) {
307
+ if (!relevant(l, ids))
308
+ continue;
309
+ const k = `${l.at}|${l.text}`;
310
+ if (!found.has(k))
311
+ found.set(k, { line: l, file, row: i + 1 });
312
+ }
313
+ }
314
+ });
315
+ }
316
+ const kept = [...found.values()].sort((a, b) => a.line.at.localeCompare(b.line.at) || a.line.text.localeCompare(b.line.text)).slice(0, 800);
317
+ if (!kept.length)
318
+ return { status: { source: 'archive', state: 'empty', detail: `${files.length} transcript(s) touch the window but hold no relevant log lines` }, evidence: [] };
319
+ const sessions = new Set(kept.map((k) => basename(k.file)));
320
+ return {
321
+ status: { source: 'archive', state: 'ok', detail: `${kept.length} log-shaped tool output lines from ${sessions.size} saved transcript(s); original command and source are unverified` },
322
+ evidence: kept.map((k) => ({ ...logEvidence(k.line, 'archive', { file: k.file, row: k.row }), link: `${k.file}:${k.row}` })),
323
+ };
324
+ }
@@ -0,0 +1,22 @@
1
+ import { type Runner } from '../exec.ts';
2
+ import type { SourceStatus } from '../types.ts';
3
+ /**
4
+ * Defence in depth on top of the read-only transaction: only a single SELECT/WITH statement, and no
5
+ * statement keyword that could write even if the transaction mode were somehow lost.
6
+ */
7
+ export declare function assertReadOnlySql(sql: string): void;
8
+ /** The exact script psql reads: every query runs inside a READ ONLY transaction that is rolled back. */
9
+ export declare function readOnlyScript(sql: string): string;
10
+ export declare function bindSql(sql: string, vars: Record<string, string>): string;
11
+ export declare function pgEnv(url: string): Record<string, string>;
12
+ export declare class Pg {
13
+ private url;
14
+ private runner;
15
+ constructor(url: string, runner?: Runner);
16
+ query<T = Record<string, unknown>>(sql: string, vars?: Record<string, string>): Promise<T[]>;
17
+ }
18
+ export declare function connect(runner?: Runner): Promise<{
19
+ db: Pg | null;
20
+ status: SourceStatus;
21
+ }>;
22
+ export declare const utc: (v: unknown) => string | null;
@@ -0,0 +1,209 @@
1
+ import { config, firstLine, resolveSecret } from "../config.js";
2
+ import { run } from "../exec.js";
3
+ /**
4
+ * Defence in depth on top of the read-only transaction: only a single SELECT/WITH statement, and no
5
+ * statement keyword that could write even if the transaction mode were somehow lost.
6
+ */
7
+ export function assertReadOnlySql(sql) {
8
+ const s = sql.trim();
9
+ if (!/^(select|with)\b/i.test(s))
10
+ throw new Error('reprobe only runs SELECT/WITH queries');
11
+ if (s.split(/\r?\n/).some((line) => /^\s*\\/.test(line)))
12
+ throw new Error('reprobe refuses psql meta-commands');
13
+ const tokens = [];
14
+ let lastStringWasEscape = false;
15
+ for (let i = 0; i < s.length;) {
16
+ if (/\s/.test(s[i])) {
17
+ i++;
18
+ continue;
19
+ }
20
+ if (s.startsWith('--', i)) {
21
+ i += 2;
22
+ while (i < s.length && s[i] !== '\n' && s[i] !== '\r')
23
+ i++;
24
+ // PostgreSQL accepts both LF and CR line endings; consume CRLF as one ending.
25
+ if (s[i] === '\r' && s[i + 1] === '\n')
26
+ i += 2;
27
+ else if (i < s.length)
28
+ i++;
29
+ continue;
30
+ }
31
+ if (s.startsWith('/*', i)) {
32
+ let depth = 1;
33
+ i += 2;
34
+ while (i < s.length && depth) {
35
+ if (s.startsWith('/*', i)) {
36
+ depth++;
37
+ i += 2;
38
+ }
39
+ else if (s.startsWith('*/', i)) {
40
+ depth--;
41
+ i += 2;
42
+ }
43
+ else
44
+ i++;
45
+ }
46
+ if (depth)
47
+ throw new Error('reprobe refuses unterminated SQL comments');
48
+ continue;
49
+ }
50
+ const startsQuotedString = s[i] === "'" || ((s[i] === 'e' || s[i] === 'E') && s[i + 1] === "'");
51
+ if (lastStringWasEscape && startsQuotedString) {
52
+ throw new Error('reprobe refuses newline-continued escape strings');
53
+ }
54
+ lastStringWasEscape = false;
55
+ let escapeString = false;
56
+ if ((s[i] === 'e' || s[i] === 'E') && s[i + 1] === "'") {
57
+ escapeString = true;
58
+ i++;
59
+ }
60
+ if (s[i] === "'") {
61
+ i++;
62
+ let closed = false;
63
+ while (i < s.length) {
64
+ if (s[i] === "'" && s[i + 1] === "'") {
65
+ i += 2;
66
+ continue;
67
+ }
68
+ if (s[i] === "'") {
69
+ i++;
70
+ closed = true;
71
+ break;
72
+ }
73
+ if (escapeString && s[i] === '\\')
74
+ i++;
75
+ i++;
76
+ }
77
+ if (!closed)
78
+ throw new Error('reprobe refuses unterminated SQL strings');
79
+ lastStringWasEscape = escapeString;
80
+ continue;
81
+ }
82
+ const dollar = s.slice(i).match(/^\$([A-Za-z_][A-Za-z0-9_]*|)\$/)?.[0];
83
+ if (dollar) {
84
+ const end = s.indexOf(dollar, i + dollar.length);
85
+ if (end < 0)
86
+ throw new Error('reprobe refuses unterminated dollar-quoted SQL');
87
+ i = end + dollar.length;
88
+ continue;
89
+ }
90
+ if (s[i] === '$')
91
+ throw new Error('reprobe refuses unrecognized dollar-quoted SQL');
92
+ if (/^u&"/i.test(s.slice(i, i + 3)))
93
+ throw new Error('reprobe refuses Unicode-escaped identifiers');
94
+ if (s[i] === '"') {
95
+ i++;
96
+ let name = '';
97
+ let closed = false;
98
+ while (i < s.length) {
99
+ if (s[i] === '"' && s[i + 1] === '"') {
100
+ name += '"';
101
+ i += 2;
102
+ continue;
103
+ }
104
+ if (s[i] === '"') {
105
+ i++;
106
+ closed = true;
107
+ break;
108
+ }
109
+ name += s[i++];
110
+ }
111
+ if (!closed)
112
+ throw new Error('reprobe refuses unterminated quoted identifiers');
113
+ tokens.push(name.toLowerCase());
114
+ continue;
115
+ }
116
+ if (s[i] === ';')
117
+ throw new Error('reprobe refuses multi-statement SQL');
118
+ if (s[i] === '\\')
119
+ throw new Error('reprobe refuses psql meta-commands or escaped identifiers');
120
+ const word = s.slice(i).match(/^[A-Za-z_][A-Za-z0-9_$]*/)?.[0];
121
+ if (word) {
122
+ tokens.push(word.toLowerCase());
123
+ i += word.length;
124
+ continue;
125
+ }
126
+ tokens.push(s[i++]);
127
+ }
128
+ const banned = new Set(['insert', 'update', 'delete', 'merge', 'drop', 'alter', 'create', 'truncate', 'grant', 'revoke', 'copy', 'call', 'do', 'vacuum', 'analyze', 'lock', 'set', 'reset', 'refresh', 'listen', 'notify', 'prepare', 'execute', 'into', 'nextval', 'setval', 'set_config', 'pg_terminate_backend', 'pg_cancel_backend', 'pg_logical_emit_message', 'pg_advisory_lock', 'pg_advisory_lock_shared', 'pg_advisory_unlock', 'pg_advisory_unlock_shared', 'pg_advisory_unlock_all', 'lo_import', 'lo_export']);
129
+ for (const token of tokens) {
130
+ if (banned.has(token) || token.startsWith('dblink') || token.startsWith('lo_') || token.startsWith('pg_advisory_') || token.startsWith('pg_try_advisory_') || token.startsWith('pg_stat_reset') || ['pg_notify', 'pg_read_file', 'pg_read_binary_file', 'pg_ls_dir', 'pg_stat_file', 'pg_reload_conf', 'pg_rotate_logfile', 'pg_create_restore_point'].includes(token)) {
131
+ throw new Error(`reprobe refuses SQL containing "${token}"`);
132
+ }
133
+ }
134
+ }
135
+ /** The exact script psql reads: every query runs inside a READ ONLY transaction that is rolled back. */
136
+ export function readOnlyScript(sql) {
137
+ assertReadOnlySql(sql);
138
+ return `BEGIN TRANSACTION READ ONLY;\nSELECT coalesce(json_agg(t), '[]'::json) FROM (${sql}\n) t;\nROLLBACK;\n`;
139
+ }
140
+ export function bindSql(sql, vars) {
141
+ return sql.replace(/:'([A-Za-z_][A-Za-z0-9_]*)'/g, (_placeholder, name) => {
142
+ if (!Object.prototype.hasOwnProperty.call(vars, name))
143
+ throw new Error(`missing SQL value: ${name}`);
144
+ const value = vars[name];
145
+ if (value.includes('\0'))
146
+ throw new Error(`SQL value contains NUL: ${name}`);
147
+ const escaped = value.replace(/'/g, "''").replace(/\r/g, "' || chr(13) || '").replace(/\n/g, "' || chr(10) || '");
148
+ return `'${escaped}'`;
149
+ });
150
+ }
151
+ export function pgEnv(url) {
152
+ const u = new URL(url);
153
+ return {
154
+ PGHOST: u.hostname,
155
+ PGPORT: u.port || '5432',
156
+ PGUSER: decodeURIComponent(u.username),
157
+ PGPASSWORD: decodeURIComponent(u.password),
158
+ PGDATABASE: u.pathname.replace(/^\//, ''),
159
+ PGSSLMODE: u.searchParams.get('sslmode') ?? 'prefer',
160
+ PGCONNECT_TIMEOUT: '10',
161
+ PGAPPNAME: 'reprobe-readonly',
162
+ // A second guard: even outside the explicit transaction, the session default is read-only.
163
+ PGOPTIONS: '-c default_transaction_read_only=on -c statement_timeout=20000 -c standard_conforming_strings=on',
164
+ };
165
+ }
166
+ export class Pg {
167
+ url;
168
+ runner;
169
+ constructor(url, runner = run) {
170
+ this.url = url;
171
+ this.runner = runner;
172
+ }
173
+ async query(sql, vars = {}) {
174
+ const args = ['-X', '-q', '-A', '-t', '-v', 'ON_ERROR_STOP=1'];
175
+ args.push('-f', '-');
176
+ const r = await this.runner('psql', args, { env: pgEnv(this.url), input: readOnlyScript(bindSql(sql, vars)), timeoutMs: 30_000 });
177
+ if (r.code !== 0)
178
+ throw new Error(`psql: ${firstLine(r.stderr) || `exit ${r.code}`}`);
179
+ // json_agg separates elements with newlines, so the result spans lines.
180
+ const out = r.stdout.trim();
181
+ const start = out.indexOf('[');
182
+ return JSON.parse(start < 0 ? '[]' : out.slice(start));
183
+ }
184
+ }
185
+ export async function connect(runner = run) {
186
+ const pgConf = config.sources.postgres;
187
+ if (!pgConf)
188
+ return { db: null, status: { source: 'postgres', state: 'skipped', detail: 'no postgres source in the config' } };
189
+ const { value: url, detail } = await resolveSecret(pgConf.url, runner);
190
+ if (!url)
191
+ return { db: null, status: { source: 'postgres', state: 'missing-credentials', detail } };
192
+ const db = new Pg(url, runner);
193
+ try {
194
+ const [row] = await db.query(`SELECT current_setting('transaction_read_only') AS ro`);
195
+ if (row?.ro !== 'on') {
196
+ return { db: null, status: { source: 'postgres', state: 'error', detail: 'refusing to continue: session is not read-only' } };
197
+ }
198
+ }
199
+ catch (e) {
200
+ return { db: null, status: { source: 'postgres', state: 'unreachable', detail: e.message } };
201
+ }
202
+ return { db, status: { source: 'postgres', state: 'ok', detail: `${detail}, read-only transaction verified` } };
203
+ }
204
+ export const utc = (v) => {
205
+ if (typeof v !== 'string' || !v)
206
+ return null;
207
+ const date = new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(v) ? v : `${v}Z`);
208
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
209
+ };
@@ -0,0 +1,24 @@
1
+ import type { Evidence, SourceStatus, Window } from '../types.ts';
2
+ import type { Runner } from '../exec.ts';
3
+ export interface IssueRow {
4
+ id: string;
5
+ shortId: string;
6
+ title: string;
7
+ lastSeen: string;
8
+ status: string;
9
+ }
10
+ /** Sentry is reached only through sentry-cli, which holds its own credentials. */
11
+ export declare function sentryAuthState(runner?: Runner): Promise<SourceStatus>;
12
+ /** Logged in is not enough: CI tokens authenticate but get 403 on issues. */
13
+ export declare function sentryReadState(runner?: Runner): Promise<SourceStatus>;
14
+ /** Parses the ASCII table from `sentry-cli issues list`. Columns are mapped by header name. */
15
+ export declare function parseIssueTable(text: string, project: string): IssueRow[];
16
+ export declare function gatherSentry(opts: {
17
+ window: Window;
18
+ userId?: string;
19
+ issue?: string;
20
+ runner?: Runner;
21
+ }): Promise<{
22
+ status: SourceStatus;
23
+ evidence: Evidence[];
24
+ }>;