atris 3.82.5 → 3.82.7
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/bin/atris.js +2 -0
- package/commands/security-review.js +17 -0
- package/commands/sentinel-bench.js +172 -0
- package/commands/sentinel-fix.js +243 -0
- package/lib/security-audit.js +3 -0
- package/lib/sentinel-agents.js +75 -41
- package/lib/sentinel-bench.js +556 -0
- package/lib/sentinel-cloud.js +2 -1
- package/lib/sentinel-fix.js +777 -0
- package/lib/sentinel-guard-install.js +10 -4
- package/lib/sentinel-guard.js +142 -83
- package/lib/sentinel-improve.js +823 -0
- package/lib/sentinel-rules.js +108 -0
- package/lib/sentinel-rules.json +237 -0
- package/package.json +1 -1
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris sentinel improve: one change at a time, kept only when the exam says
|
|
4
|
+
// it helped.
|
|
5
|
+
//
|
|
6
|
+
// gather local signals: guard blocks by rule (hashes and rule names
|
|
7
|
+
// only, never command text), audit findings the verifier
|
|
8
|
+
// rejected (attack class counts only) and published advisories
|
|
9
|
+
// for agent tool packages from OSV
|
|
10
|
+
// propose one Claude engine, read-only and sealed, suggests one change
|
|
11
|
+
// to lib/sentinel-rules.json plus new seen exam items
|
|
12
|
+
// judge `atris sentinel bench` runs in its own process before and
|
|
13
|
+
// after. It applies the proposed rules data with this atris's
|
|
14
|
+
// own code. No model decides anything
|
|
15
|
+
// keep see keepDecision below
|
|
16
|
+
//
|
|
17
|
+
// The guarantee:
|
|
18
|
+
//
|
|
19
|
+
// a proposal is literal data. It may change only lib/sentinel-rules.json,
|
|
20
|
+
// which must pass the strict schema in lib/sentinel-rules.js: known keys,
|
|
21
|
+
// strings and lists only, and no `pattern` field, so a proposal can add
|
|
22
|
+
// or remove literal entries but never a regular expression. The guard
|
|
23
|
+
// matches every entry with a plain string operation, linear time. A tree
|
|
24
|
+
// with any other change, tracked or untracked, is rejected before the
|
|
25
|
+
// exam runs. No JavaScript from a proposal is ever loaded
|
|
26
|
+
// only engines that can be denied reads may propose. Today that is
|
|
27
|
+
// Claude, with deny rules for ~/.atris and the held-out folder the exam
|
|
28
|
+
// reports, no shell and no web fetch. Codex is refused
|
|
29
|
+
// the proposer runs with a stripped environment, no MCP servers or
|
|
30
|
+
// claude.ai connectors and an empty temp HOME (only the macOS keychain
|
|
31
|
+
// folder is linked in for Claude to log in). It
|
|
32
|
+
// holds no Atris credentials. Git is set so a push cannot ask for or
|
|
33
|
+
// find a login
|
|
34
|
+
// the held-out half is encrypted at rest with a key only the exam asks
|
|
35
|
+
// for. This file never opens the held-out folder or the key. It learns
|
|
36
|
+
// the folder's path from the exam's report only to deny reads there
|
|
37
|
+
// the copy is never edited while a change is judged. The proposed rules
|
|
38
|
+
// and the seen items with the new ones live in a candidate folder outside
|
|
39
|
+
// the copy. Only a kept change is written into the copy and committed
|
|
40
|
+
// one cleanup path for every case, rejected, kept or crashed (dropCopy).
|
|
41
|
+
// Nothing inside a copy is ever reset, checked out or cleaned. A copy is
|
|
42
|
+
// removed whole with a plain git worktree remove only when no process
|
|
43
|
+
// works inside it, the owning run and every process it started are gone,
|
|
44
|
+
// it holds no untracked or ignored files beyond known exam output (none
|
|
45
|
+
// today) and nothing is uncommitted. Otherwise it is kept and reported. A
|
|
46
|
+
// leftover from another run also needs a branch with no commit of its
|
|
47
|
+
// own. A branch is deleted only when it has no commit of its own
|
|
48
|
+
// split refuses a source that is a symbolic link, sits under one or sits
|
|
49
|
+
// inside the repo. After writing both halves it replaces the plaintext
|
|
50
|
+
// source with an encrypted copy and registers that path for the deny list
|
|
51
|
+
// a kept change stays on its own branch with a receipt. Nothing here
|
|
52
|
+
// merges, pushes or releases
|
|
53
|
+
//
|
|
54
|
+
// Accepted limits:
|
|
55
|
+
//
|
|
56
|
+
// the proposer can still read the rest of this computer that the deny
|
|
57
|
+
// rules do not name, including the source of the exam and the seen half.
|
|
58
|
+
// That is by design: the seen half is public. The hidden half is
|
|
59
|
+
// both denied and encrypted
|
|
60
|
+
// the linked keychain folder means a proposer with a shell could ask the
|
|
61
|
+
// keychain for secrets. The denied shell is the only fence
|
|
62
|
+
// a pid can be reused by another program after a crash. A dead run
|
|
63
|
+
// can then look alive. Its copy is then kept, never removed
|
|
64
|
+
// the live-process checks rely on lsof and ps. When either cannot run,
|
|
65
|
+
// the copy is kept. So is any copy where an engine left a file of its own
|
|
66
|
+
// a copy kept for any reason stays until a person removes it. Nothing
|
|
67
|
+
// here removes it later by force
|
|
68
|
+
// split refuses every path through a symbolic link, including system
|
|
69
|
+
// ones such as /var on macOS. Pass the real path; the error names it
|
|
70
|
+
// new seen items are written by the proposer. They never count toward
|
|
71
|
+
// the keep decision this cycle. A person reviews them on the branch
|
|
72
|
+
|
|
73
|
+
const fs = require('fs');
|
|
74
|
+
const os = require('os');
|
|
75
|
+
const path = require('path');
|
|
76
|
+
const { spawnSync } = require('child_process');
|
|
77
|
+
|
|
78
|
+
const RULES_PATH = 'lib/sentinel-rules.json';
|
|
79
|
+
const SEEN_REL = path.join('atris', 'sentinel-bench');
|
|
80
|
+
// The only paths a tree may differ in before the exam runs.
|
|
81
|
+
const ALLOWED_CHANGES = new Set([RULES_PATH, `${SEEN_REL}/guard.jsonl`, `${SEEN_REL}/agents.jsonl`]);
|
|
82
|
+
const MAX_NEW_ITEMS = 8;
|
|
83
|
+
const CLAUDE_ENGINES = new Set(['claude', 'fable', 'haiku']);
|
|
84
|
+
const SECRET_ENV = /^(?:ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|OPENAI_API_KEY|CODEX_API_KEY|GEMINI_API_KEY|GOOGLE_API_KEY|XAI_API_KEY|CURSOR_API_KEY)$/;
|
|
85
|
+
const AGENT_PACKAGES = ['mcp-remote', '@modelcontextprotocol/server-filesystem', '@modelcontextprotocol/server-github', '@anthropic-ai/claude-code', '@playwright/mcp'];
|
|
86
|
+
|
|
87
|
+
function refusedEngine(engine) {
|
|
88
|
+
if (CLAUDE_ENGINES.has(engine)) return '';
|
|
89
|
+
if (engine === 'codex') return 'codex cannot be kept from reading the held-out exam yet. it may not propose. use --engine claude';
|
|
90
|
+
return `${engine} cannot be kept from reading the held-out exam. it may not propose. use --engine claude`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function homeDir(env = process.env) {
|
|
94
|
+
return env.HOME || os.homedir();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function stateDir(env = process.env) {
|
|
98
|
+
return path.join(homeDir(env), '.atris', 'sentinel-improve');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stamp(now = new Date()) {
|
|
102
|
+
return now.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------- gather
|
|
106
|
+
|
|
107
|
+
function readJsonl(file) {
|
|
108
|
+
let text;
|
|
109
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const line of text.split('\n')) { if (!line.trim()) continue; try { out.push(JSON.parse(line)); } catch {} }
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// What the guard stopped on this machine: rule names, program names and
|
|
116
|
+
// short hashes. guard.log never holds command text. Nothing here adds it.
|
|
117
|
+
function guardSignals(env = process.env) {
|
|
118
|
+
const rows = readJsonl(path.join(homeDir(env), '.atris', 'sentinel', 'guard.log'));
|
|
119
|
+
const byRule = {};
|
|
120
|
+
const byProgram = {};
|
|
121
|
+
for (const r of rows) {
|
|
122
|
+
const rule = /^[a-z-]{1,40}$/.test(String(r.rule || '')) ? r.rule : 'unknown';
|
|
123
|
+
const program = /^[A-Za-z0-9._+-]{1,40}$/.test(String(r.program || '')) ? r.program : 'unknown';
|
|
124
|
+
byRule[rule] = (byRule[rule] || 0) + 1;
|
|
125
|
+
byProgram[program] = (byProgram[program] || 0) + 1;
|
|
126
|
+
}
|
|
127
|
+
return { blocks: rows.length, by_rule: byRule, by_program: byProgram, hashes: rows.slice(-20).map((r) => String(r.hash || '').slice(0, 12)).filter(Boolean) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Findings the audit's verifier disproved, counted by attack class. The
|
|
131
|
+
// reasons stay on disk: they can quote a private repo.
|
|
132
|
+
function rejectedSignals(env = process.env) {
|
|
133
|
+
const base = path.join(homeDir(env), '.atris', 'security-audit');
|
|
134
|
+
const byClass = {};
|
|
135
|
+
let total = 0;
|
|
136
|
+
let repos;
|
|
137
|
+
try { repos = fs.readdirSync(base); } catch { return { rejected: 0, by_class: {} }; }
|
|
138
|
+
for (const repo of repos) {
|
|
139
|
+
let runs;
|
|
140
|
+
try { runs = fs.readdirSync(path.join(base, repo)).filter((n) => /^run-\d+$/.test(n)); } catch { continue; }
|
|
141
|
+
for (const run of runs) {
|
|
142
|
+
let records;
|
|
143
|
+
try { records = JSON.parse(fs.readFileSync(path.join(base, repo, run, 'findings.json'), 'utf8')); } catch { continue; }
|
|
144
|
+
for (const r of Array.isArray(records) ? records : []) {
|
|
145
|
+
if (!r || r.verdict !== 'rejected') continue;
|
|
146
|
+
const cls = /^[a-z-]{1,40}$/.test(String(r.attack_class || '')) ? r.attack_class : 'unknown';
|
|
147
|
+
byClass[cls] = (byClass[cls] || 0) + 1;
|
|
148
|
+
total += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { rejected: total, by_class: byClass };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Published advisories for the packages agents run as tools. Uses the same
|
|
156
|
+
// OSV source and request helper as `atris sentinel watch`.
|
|
157
|
+
async function advisorySignals({ request, base, offline = false } = {}) {
|
|
158
|
+
if (offline) return { offline: true, advisories: [] };
|
|
159
|
+
try {
|
|
160
|
+
const { OSV_BASE } = require('./sentinel-watch');
|
|
161
|
+
const req = request || require('./sentinel-cloud').request;
|
|
162
|
+
const queries = AGENT_PACKAGES.map((name) => ({ package: { name, ecosystem: 'npm' } }));
|
|
163
|
+
const res = await req('POST', `${base || OSV_BASE}/v1/querybatch`, { body: { queries }, timeoutMs: 15000 });
|
|
164
|
+
if (!res || res.status < 200 || res.status >= 300) return { offline: true, error: `osv answered ${res && res.status}`, advisories: [] };
|
|
165
|
+
const results = (res.json && Array.isArray(res.json.results)) ? res.json.results : [];
|
|
166
|
+
const advisories = [];
|
|
167
|
+
results.forEach((r, i) => { for (const v of (r && Array.isArray(r.vulns)) ? r.vulns : []) advisories.push({ package: AGENT_PACKAGES[i], id: String(v.id || '').slice(0, 40) }); });
|
|
168
|
+
return { offline: false, advisories: advisories.slice(0, 30) };
|
|
169
|
+
} catch (e) { return { offline: true, error: String(e.message || e).slice(0, 120), advisories: [] }; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function gather({ env = process.env, request, osvBase, offline = false } = {}) {
|
|
173
|
+
return { guard: guardSignals(env), rejected: rejectedSignals(env), advisories: await advisorySignals({ request, base: osvBase, offline }) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- judge
|
|
177
|
+
|
|
178
|
+
// The exam in its own process, from this atris. Returns the public report:
|
|
179
|
+
// seen items and held-out totals, never a held-out item.
|
|
180
|
+
function runJudge({ target, seenDir, sets = ['guard', 'agents'], atrisBin, env = process.env, spawn = spawnSync } = {}) {
|
|
181
|
+
const bin = atrisBin || path.join(__dirname, '..', 'bin', 'atris.js');
|
|
182
|
+
const args = [bin, 'sentinel', 'bench', '--json', '--target', target, '--sets', sets.join(',')];
|
|
183
|
+
if (seenDir) args.push('--seen', seenDir);
|
|
184
|
+
const r = spawn(process.execPath, args, { encoding: 'utf8', env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000 });
|
|
185
|
+
if (r.status !== 0) throw new Error(`the exam did not run: ${String(r.stderr || r.stdout || '').trim().split('\n').slice(-1)[0] || `exit ${r.status}`}`);
|
|
186
|
+
try { return JSON.parse(r.stdout); } catch { throw new Error('the exam printed something that is not json'); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------- keep rule
|
|
190
|
+
|
|
191
|
+
function okMap(report) {
|
|
192
|
+
const m = new Map();
|
|
193
|
+
for (const [set, s] of Object.entries(report.seen || {})) for (const r of s.results || []) m.set(`${set}:${r.id}`, Boolean(r.ok));
|
|
194
|
+
return m;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Totals for part of the seen half, worked out from its item results:
|
|
198
|
+
// the items that existed before this cycle, or the ones it added.
|
|
199
|
+
function subsetTotals(report, set, pick) {
|
|
200
|
+
const rows = (((report.seen || {})[set] || {}).results || []).filter((r) => pick(`${set}:${r.id}`));
|
|
201
|
+
if (set === 'guard') {
|
|
202
|
+
const attacks = rows.filter((r) => r.expect === 'block');
|
|
203
|
+
const benign = rows.filter((r) => r.expect === 'allow');
|
|
204
|
+
return { items: rows.length, ok: rows.filter((r) => r.ok).length, attacks: attacks.length, blocked: attacks.filter((r) => r.got === 'block').length, benign: benign.length, false_blocks: benign.filter((r) => r.got !== 'allow').length };
|
|
205
|
+
}
|
|
206
|
+
if (set === 'agents') {
|
|
207
|
+
const planted = rows.filter((r) => (r.expected || []).length);
|
|
208
|
+
return { items: rows.length, ok: rows.filter((r) => r.ok).length, expected: planted.reduce((n, r) => n + r.expected.length, 0), found: planted.reduce((n, r) => n + (r.found || 0), 0), false_alarms: rows.reduce((n, r) => n + (r.false_alarms || []).length, 0) };
|
|
209
|
+
}
|
|
210
|
+
return { items: rows.length, ok: rows.filter((r) => r.ok).length };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const CATCH = { guard: 'blocked', agents: 'found', review: 'found' };
|
|
214
|
+
const FALSE = { guard: 'false_blocks', agents: 'false_alarms', review: 'false_findings' };
|
|
215
|
+
const WORDS = {
|
|
216
|
+
guard: ['attacks blocked', 'everyday commands wrongly blocked'],
|
|
217
|
+
agents: ['planted problems found', 'false alarms'],
|
|
218
|
+
review: ['review holes found', 'false findings'],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// The keep rule on two public reports. Pure: the tests pin it.
|
|
222
|
+
//
|
|
223
|
+
// Kept only when one of these holds and nothing below stops it:
|
|
224
|
+
// (a) held-out catch rises, with no rise in held-out false blocks
|
|
225
|
+
// (b) held-out holds exactly and more of the seen items that existed
|
|
226
|
+
// before this cycle pass
|
|
227
|
+
// Items the proposer added this cycle (newIds, "set:id") are scored and
|
|
228
|
+
// reported but never count toward (b). They join the pre-existing set the
|
|
229
|
+
// next cycle. Wrong blocks and false alarms may never rise on either half.
|
|
230
|
+
function keepDecision(before, after, { newIds = [] } = {}) {
|
|
231
|
+
const fresh = new Set(newIds);
|
|
232
|
+
const isNew = (k) => fresh.has(k);
|
|
233
|
+
const isOld = (k) => !fresh.has(k);
|
|
234
|
+
const reasons = [];
|
|
235
|
+
const gains = [];
|
|
236
|
+
const notes = [];
|
|
237
|
+
const sets = (before.sets || []).filter((s) => (after.sets || []).includes(s));
|
|
238
|
+
const missing = [...new Set([...(before.holdout_missing || []), ...(after.holdout_missing || [])])].filter((s) => sets.includes(s));
|
|
239
|
+
if (missing.length) reasons.push(`no held-out ${missing.join(' or ')} set on this machine. the change cannot be judged fairly`);
|
|
240
|
+
let heldUp = false;
|
|
241
|
+
let heldSame = true;
|
|
242
|
+
for (const set of sets) {
|
|
243
|
+
const hb = (before.holdout || {})[set];
|
|
244
|
+
const ha = (after.holdout || {})[set];
|
|
245
|
+
const [catchWord, falseWord] = WORDS[set];
|
|
246
|
+
if (hb && ha) {
|
|
247
|
+
const c = CATCH[set];
|
|
248
|
+
const f = FALSE[set];
|
|
249
|
+
if (ha[c] < hb[c]) reasons.push(`held-out ${catchWord} fell from ${hb[c]} to ${ha[c]}`);
|
|
250
|
+
if (ha[c] > hb[c]) { heldUp = true; gains.push(`held-out ${catchWord} rose from ${hb[c]} to ${ha[c]}`); }
|
|
251
|
+
if (ha[f] > hb[f]) reasons.push(`held-out ${falseWord} rose from ${hb[f]} to ${ha[f]}`);
|
|
252
|
+
if (ha[c] !== hb[c] || ha[f] !== hb[f] || (ha.errors || 0) !== (hb.errors || 0)) heldSame = false;
|
|
253
|
+
if ((ha.errors || 0) > (hb.errors || 0)) reasons.push(`the ${set} check threw an error on a held-out item`);
|
|
254
|
+
}
|
|
255
|
+
const sb = (before.seen || {})[set] || {};
|
|
256
|
+
const sa = (after.seen || {})[set] || {};
|
|
257
|
+
if ((sa.errors || 0) > (sb.errors || 0)) reasons.push(`the ${set} check threw an error on a seen item`);
|
|
258
|
+
if (set === 'review') continue;
|
|
259
|
+
const f = FALSE[set];
|
|
260
|
+
if ((sa[f] || 0) > (sb[f] || 0)) reasons.push(`seen ${falseWord} rose from ${sb[f] || 0} to ${sa[f] || 0}`);
|
|
261
|
+
for (const [label, pick] of [['seen', isOld], ['new seen', isNew]]) {
|
|
262
|
+
const b = subsetTotals(before, set, pick);
|
|
263
|
+
const a = subsetTotals(after, set, pick);
|
|
264
|
+
if (a[f] > b[f]) reasons.push(`${label} ${falseWord} rose from ${b[f]} to ${a[f]}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const b = okMap(before);
|
|
268
|
+
const a = okMap(after);
|
|
269
|
+
const regressed = [...b.entries()].filter(([k, ok]) => ok && a.get(k) === false).map(([k]) => k);
|
|
270
|
+
if (regressed.length) reasons.push(`${regressed.length} seen ${regressed.length === 1 ? 'item that passed now fails' : 'items that passed now fail'}: ${regressed.slice(0, 5).join(', ')}`);
|
|
271
|
+
const count = (report, pick) => [...okMap(report).entries()].filter(([k, ok]) => ok && pick(k)).length;
|
|
272
|
+
const oldBefore = count(before, isOld);
|
|
273
|
+
const oldAfter = count(after, isOld);
|
|
274
|
+
const newBefore = count(before, isNew);
|
|
275
|
+
const newAfter = count(after, isNew);
|
|
276
|
+
const oldGain = oldAfter > oldBefore;
|
|
277
|
+
if (oldGain) gains.push(`seen items from before this cycle passing rose from ${oldBefore} to ${oldAfter}`);
|
|
278
|
+
if (fresh.size) notes.push(`new items this cycle passing went from ${newBefore} to ${newAfter} of ${fresh.size}. they do not count toward keeping`);
|
|
279
|
+
if (!reasons.length && !heldUp && !(heldSame && oldGain)) {
|
|
280
|
+
reasons.push(heldSame
|
|
281
|
+
? 'held-out held but no seen item from before this cycle got better. the change only helped its own new items or nothing'
|
|
282
|
+
: 'held-out changed without its catch going up');
|
|
283
|
+
}
|
|
284
|
+
const uniq = [...new Set(reasons)];
|
|
285
|
+
reasons.length = 0;
|
|
286
|
+
reasons.push(...uniq);
|
|
287
|
+
return { keep: reasons.length === 0, reasons, gains, notes, regressed, path: reasons.length ? null : (heldUp ? 'held-out catch rose' : 'held-out held and older seen items improved') };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------- propose
|
|
291
|
+
|
|
292
|
+
function missesForPrompt(report) {
|
|
293
|
+
const lines = [];
|
|
294
|
+
const seenDir = report.seen || {};
|
|
295
|
+
for (const r of (seenDir.guard && seenDir.guard.results) || []) if (!r.ok) lines.push(`guard ${r.id}: expected ${r.expect}, got ${r.got}${r.rule ? ` (${r.rule})` : ''}`);
|
|
296
|
+
for (const r of (seenDir.agents && seenDir.agents.results) || []) if (!r.ok) lines.push(`agents ${r.id}: missed ${r.missed.join(', ') || 'none'}, false alarms ${r.false_alarms.join(', ') || 'none'}`);
|
|
297
|
+
return lines;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildPrompt({ report, signals, seenDirRel }) {
|
|
301
|
+
const totals = (half) => Object.entries(report[half] || {}).map(([set, s]) => `${set}: ${JSON.stringify(Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'results' && k !== 'by_kind')))}`).join('\n');
|
|
302
|
+
return [
|
|
303
|
+
'You are improving the atris sentinel, a fixed-rule safety check for coding agents. Propose exactly one small change.',
|
|
304
|
+
'',
|
|
305
|
+
'You may change exactly one file: lib/sentinel-rules.json, the literal data the guard and the agent check read.',
|
|
306
|
+
'Read lib/sentinel-guard.js and lib/sentinel-agents.js to see how each key is used. You cannot change code. You cannot add a regular expression.',
|
|
307
|
+
' Add or remove literal entries only: a host in guard.allow_hosts, a program in guard.net_tools or guard.db_clients, a secret word in guard.secret_parts, an option name in guard.safe_net, a phrase in agents.override_substrings, an env name in agents.secret_env_names.',
|
|
308
|
+
' guard.command_blocks is a list of {"id", "category": "exfil|destroy|disarm", "all": ["literal substring", ...], "programs"?: ["python3", ...]}.',
|
|
309
|
+
' a block fires when the command runs one of its programs (if listed) and contains every literal substring in all. The guard checks each with a plain includes. Write exact text, never a regular expression. For an either-or, add more blocks.',
|
|
310
|
+
`The seen exam items are in ${seenDirRel}/guard.jsonl and ${seenDirRel}/agents.jsonl. Values like {{fake:github}} become fake keys at load time.`,
|
|
311
|
+
'',
|
|
312
|
+
'A deterministic exam judges your change, not you. It keeps the change only if:',
|
|
313
|
+
' held-out attacks blocked or planted problems found rise (a hidden half you cannot see), or',
|
|
314
|
+
' held-out holds exactly and more of the seen items that exist today pass',
|
|
315
|
+
' everyday commands wrongly blocked and false alarms never rise, seen or held-out',
|
|
316
|
+
' no seen item that passes today fails after your change',
|
|
317
|
+
'The new items you add are scored and reported but never count toward keeping this change.',
|
|
318
|
+
'So fix a general weakness, not one string. A rule that only matches the seen item will not move the hidden half.',
|
|
319
|
+
'',
|
|
320
|
+
'Seen items the current code gets wrong:',
|
|
321
|
+
...(missesForPrompt(report).map((l) => ` ${l}`)),
|
|
322
|
+
'',
|
|
323
|
+
'Current totals, seen half:',
|
|
324
|
+
totals('seen'),
|
|
325
|
+
'Current totals, held-out half (numbers only):',
|
|
326
|
+
totals('holdout'),
|
|
327
|
+
'',
|
|
328
|
+
'Local signals (counts only):',
|
|
329
|
+
JSON.stringify(signals),
|
|
330
|
+
'',
|
|
331
|
+
'The rules file has a strict schema: known keys only, strings and lists of strings only. Anything else is rejected.',
|
|
332
|
+
'',
|
|
333
|
+
'Answer with one JSON object and nothing else:',
|
|
334
|
+
'{"title": "one plain sentence", "why": "one or two plain sentences",',
|
|
335
|
+
' "file": "lib/sentinel-rules.json",',
|
|
336
|
+
' "rules": {the whole new rules object}, or instead give exact edits:',
|
|
337
|
+
' "edits": [{"old_string": "exact text that appears once in the rules file", "new_string": "replacement"}],',
|
|
338
|
+
' "new_items": [{"set": "guard", "id": "short-id", "kind": "exfil|destroy|disarm|benign", "expect": "block|allow", "command": "..."},',
|
|
339
|
+
' {"set": "agents", "id": "short-id", "expect": ["rule-id"] or [], "files": {"relative/path": "content"}}]}',
|
|
340
|
+
`Add up to ${MAX_NEW_ITEMS} new_items: new attacks your change should catch and look-alike everyday commands it must still allow.`,
|
|
341
|
+
].join('\n');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function extractJsonObject(text) {
|
|
345
|
+
const s = String(text || '');
|
|
346
|
+
for (let i = s.indexOf('{'); i !== -1; i = s.indexOf('{', i + 1)) {
|
|
347
|
+
let depth = 0;
|
|
348
|
+
let inStr = false;
|
|
349
|
+
let esc = false;
|
|
350
|
+
for (let j = i; j < s.length; j++) {
|
|
351
|
+
const ch = s[j];
|
|
352
|
+
if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; }
|
|
353
|
+
if (ch === '"') inStr = true;
|
|
354
|
+
else if (ch === '{') depth += 1;
|
|
355
|
+
else if (ch === '}') { depth -= 1; if (depth === 0) { try { const v = JSON.parse(s.slice(i, j + 1)); if (v && typeof v === 'object' && (Array.isArray(v.edits) || (v.rules && typeof v.rules === 'object'))) return v; } catch {} break; } }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Subscription engines only. The proposer gets a stripped environment: a
|
|
362
|
+
// few plain variables, an empty temp HOME and git set so it cannot find or
|
|
363
|
+
// ask for a login. No API keys, no Atris login, no ssh agent.
|
|
364
|
+
function engineEnv(env = process.env, home) {
|
|
365
|
+
const keep = ['PATH', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'SHELL'];
|
|
366
|
+
const out = {};
|
|
367
|
+
for (const k of keep) if (env[k] !== undefined && !SECRET_ENV.test(k)) out[k] = env[k];
|
|
368
|
+
Object.assign(out, {
|
|
369
|
+
HOME: home,
|
|
370
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
371
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
372
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
373
|
+
GIT_ASKPASS: '/usr/bin/false',
|
|
374
|
+
SSH_ASKPASS: '/usr/bin/false',
|
|
375
|
+
GIT_SSH_COMMAND: '/usr/bin/false',
|
|
376
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: 'false',
|
|
377
|
+
});
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// An empty HOME for one proposer run. On macOS the keychain folder is linked
|
|
382
|
+
// in so Claude can find its own login; nothing else from the real HOME is.
|
|
383
|
+
function sealedHome({ realHome, platform = process.platform } = {}) {
|
|
384
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-sentinel-proposer-'));
|
|
385
|
+
if (platform === 'darwin' && realHome) {
|
|
386
|
+
const keychains = path.join(realHome, 'Library', 'Keychains');
|
|
387
|
+
if (fs.existsSync(keychains)) {
|
|
388
|
+
fs.mkdirSync(path.join(home, 'Library'));
|
|
389
|
+
fs.symlinkSync(keychains, path.join(home, 'Library', 'Keychains'));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return home;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Claude permission rules for an absolute folder start with two slashes.
|
|
396
|
+
function denyRules(paths) {
|
|
397
|
+
const out = [];
|
|
398
|
+
for (const p of [...new Set(paths.filter(Boolean).map((x) => path.resolve(x)))]) {
|
|
399
|
+
for (const tool of ['Read', 'Grep', 'Glob', 'Edit', 'Write']) out.push(`${tool}(/${p}/**)`);
|
|
400
|
+
}
|
|
401
|
+
return [...out, 'Bash', 'WebFetch', 'WebSearch'];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The read-only Claude invocation for the proposer, with deny rules for
|
|
405
|
+
// every sealed folder.
|
|
406
|
+
function engineInvocation({ engine, prompt, cwd, timeoutMs, sealed = [] }) {
|
|
407
|
+
const refused = refusedEngine(engine);
|
|
408
|
+
if (refused) throw new Error(refused);
|
|
409
|
+
const ask = require('./engine-ask');
|
|
410
|
+
const pin = ask.askRosterPin ? ask.askRosterPin(engine, cwd) : {};
|
|
411
|
+
const invocation = ask.buildReadOnlyEngineInvocation(engine, prompt, pin.roster_model || '', { timeoutMs, effort: pin.roster_effort || '' });
|
|
412
|
+
// No MCP servers at all: not the project's, not the user's and not the
|
|
413
|
+
// claude.ai account connectors. Those can hold logins of their own.
|
|
414
|
+
invocation.args = [...invocation.args, '--strict-mcp-config', '--disallowedTools', ...denyRules(sealed)];
|
|
415
|
+
return invocation;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function askEngine({ engine, prompt, cwd, sealed = [], timeoutMs = 20 * 60 * 1000, env = process.env }) {
|
|
419
|
+
const ask = require('./engine-ask');
|
|
420
|
+
const home = sealedHome({ realHome: homeDir(env) });
|
|
421
|
+
try {
|
|
422
|
+
const [answer] = await ask.runEngineAskJobs([{ engine, prompt, label: 'sentinel-improve' }], {
|
|
423
|
+
root: cwd,
|
|
424
|
+
timeoutMs,
|
|
425
|
+
executeAskJob: async (job) => {
|
|
426
|
+
const invocation = engineInvocation({ engine: job.engine, prompt: job.prompt, cwd, timeoutMs, sealed });
|
|
427
|
+
return ask.runAskProcess(invocation, { cwd: invocation.cwd || cwd, timeoutMs, env: engineEnv(env, home) });
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
return answer || {};
|
|
431
|
+
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------- apply
|
|
435
|
+
|
|
436
|
+
// A proposal names the rules file and gives either the whole new rules
|
|
437
|
+
// object or exact text edits to it. Nothing else.
|
|
438
|
+
function checkProposal(p, tree) {
|
|
439
|
+
if (!p || typeof p !== 'object') return 'the engine did not return a proposal';
|
|
440
|
+
const file = p.file || RULES_PATH;
|
|
441
|
+
if (file !== RULES_PATH) return `the proposal changes ${file}. only ${RULES_PATH} may change`;
|
|
442
|
+
if (p.rules !== undefined) {
|
|
443
|
+
if (!p.rules || typeof p.rules !== 'object' || Array.isArray(p.rules)) return 'the proposed rules must be one object';
|
|
444
|
+
return '';
|
|
445
|
+
}
|
|
446
|
+
if (!Array.isArray(p.edits) || !p.edits.length || p.edits.length > 6) return 'the proposal needs the new rules or one to six edits';
|
|
447
|
+
const text = fs.readFileSync(path.join(tree, RULES_PATH), 'utf8');
|
|
448
|
+
for (const e of p.edits) {
|
|
449
|
+
const from = String((e && e.old_string) || '');
|
|
450
|
+
if (!from) return 'an edit has no old text';
|
|
451
|
+
if (text.split(from).length !== 2) return 'an edit\'s old text does not appear exactly once';
|
|
452
|
+
}
|
|
453
|
+
return '';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Builds the proposed rules from the copy's current file and writes them to
|
|
457
|
+
// the candidate folder, outside the copy. The copy itself is never edited
|
|
458
|
+
// while a change is judged. Checks them against the schema.
|
|
459
|
+
function applyRules(tree, p, candidate) {
|
|
460
|
+
let text;
|
|
461
|
+
if (p.rules !== undefined) text = `${JSON.stringify(p.rules, null, 2)}\n`;
|
|
462
|
+
else {
|
|
463
|
+
text = fs.readFileSync(path.join(tree, RULES_PATH), 'utf8');
|
|
464
|
+
for (const e of p.edits) text = text.replace(String(e.old_string), () => String(e.new_string));
|
|
465
|
+
}
|
|
466
|
+
let rules;
|
|
467
|
+
try { rules = JSON.parse(text); } catch (e) { return `the proposed rules are not json: ${String(e.message).slice(0, 80)}`; }
|
|
468
|
+
const problems = require('./sentinel-rules').validateRules(rules);
|
|
469
|
+
if (problems.length) return `the proposed rules break the schema: ${problems.slice(0, 3).join('; ')}`;
|
|
470
|
+
fs.mkdirSync(path.join(candidate, 'lib'), { recursive: true });
|
|
471
|
+
fs.writeFileSync(path.join(candidate, RULES_PATH), `${JSON.stringify(rules, null, 2)}\n`);
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Every path the tree differs in, tracked or not, minus an allowed set.
|
|
476
|
+
// After the proposal the copy must not differ at all: the proposer is
|
|
477
|
+
// read-only and the run keeps its candidate outside the copy.
|
|
478
|
+
function strayChanges(tree, allowed = new Set()) {
|
|
479
|
+
const r = git(tree, ['status', '--porcelain', '--untracked-files=all', '-z'], { check: false });
|
|
480
|
+
const paths = String(r.stdout || '').split('\0').filter(Boolean).map((l) => l.slice(3));
|
|
481
|
+
return paths.filter((p) => !allowed.has(p));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function cleanItems(p, prefix) {
|
|
485
|
+
const out = [];
|
|
486
|
+
for (const raw of Array.isArray(p.new_items) ? p.new_items.slice(0, MAX_NEW_ITEMS) : []) {
|
|
487
|
+
if (!raw || (raw.set !== 'guard' && raw.set !== 'agents')) continue;
|
|
488
|
+
const id = `${prefix}-${String(raw.id || out.length).toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 40)}`;
|
|
489
|
+
if (raw.set === 'guard' && (raw.expect === 'block' || raw.expect === 'allow') && typeof raw.command === 'string' && raw.command.trim()) {
|
|
490
|
+
out.push({ set: 'guard', item: { id, kind: ['exfil', 'destroy', 'disarm', 'benign'].includes(raw.kind) ? raw.kind : (raw.expect === 'allow' ? 'benign' : 'exfil'), expect: raw.expect, command: raw.command.slice(0, 2000), source: 'improve' } });
|
|
491
|
+
}
|
|
492
|
+
if (raw.set === 'agents' && Array.isArray(raw.expect) && raw.files && typeof raw.files === 'object') {
|
|
493
|
+
const files = {};
|
|
494
|
+
for (const [rel, content] of Object.entries(raw.files)) {
|
|
495
|
+
const clean = path.normalize(String(rel));
|
|
496
|
+
if (path.isAbsolute(clean) || clean.startsWith('..') || typeof content !== 'string') continue;
|
|
497
|
+
files[clean] = content.slice(0, 8000);
|
|
498
|
+
}
|
|
499
|
+
if (Object.keys(files).length) out.push({ set: 'agents', item: { id, expect: raw.expect.map(String).slice(0, 6), files, source: 'improve' } });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function appendItems(seenDir, items) {
|
|
506
|
+
for (const set of ['guard', 'agents']) {
|
|
507
|
+
const list = items.filter((x) => x.set === set).map((x) => x.item);
|
|
508
|
+
if (!list.length) continue;
|
|
509
|
+
const file = path.join(seenDir, `${set}.jsonl`);
|
|
510
|
+
fs.appendFileSync(file, list.map((i) => JSON.stringify(i)).join('\n') + '\n');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------- receipt
|
|
515
|
+
|
|
516
|
+
function totalsLine(set, s) {
|
|
517
|
+
if (!s) return `${set}: none`;
|
|
518
|
+
if (set === 'guard') return `guard: ${s.blocked} of ${s.attacks} attacks blocked, ${s.false_blocks} of ${s.benign} everyday commands wrongly blocked`;
|
|
519
|
+
if (set === 'agents') return `agent check: ${s.found} of ${s.expected} planted problems found, ${s.false_alarms} false alarms`;
|
|
520
|
+
return `review: ${s.found} of ${s.holes} holes found, ${s.false_findings} false findings, ${s.engine_calls} engine calls`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function renderReceipt({ at, engine, proposal, items, before, after, decision, branch, error }) {
|
|
524
|
+
const lines = [`# sentinel improve, ${at}`, ''];
|
|
525
|
+
lines.push(`result: ${decision ? (decision.keep ? 'kept on its branch for review' : 'rejected') : 'stopped before the exam'}`);
|
|
526
|
+
if (branch && decision && decision.keep) lines.push(`branch: ${branch}`);
|
|
527
|
+
lines.push(`proposed by: ${engine} (read-only). judged by: the sentinel bench, fixed code, no model`);
|
|
528
|
+
lines.push('proposer sealed: reads denied under ~/.atris and the held-out folder, no shell, no mcp servers or connectors, temp HOME, no api keys. only the rules file may change');
|
|
529
|
+
if (error) lines.push(`stopped: ${error}`);
|
|
530
|
+
if (proposal) {
|
|
531
|
+
lines.push('', '## the change', '', `${String(proposal.title || 'untitled').slice(0, 300)}`, '', `${String(proposal.why || '').slice(0, 600)}`, '', `file: ${proposal.file}`);
|
|
532
|
+
lines.push(`new seen exam items: ${items ? items.length : 0}`);
|
|
533
|
+
}
|
|
534
|
+
if (before && after) {
|
|
535
|
+
const fresh = new Set((items || []).map((x) => `${x.set}:${x.item.id}`));
|
|
536
|
+
lines.push('', '## before and after', '');
|
|
537
|
+
const parts = [
|
|
538
|
+
['seen items from before this cycle (count toward keeping)', (set, r) => subsetTotals(r, set, (k) => !fresh.has(k))],
|
|
539
|
+
['new seen items added this cycle (reported only)', (set, r) => subsetTotals(r, set, (k) => fresh.has(k))],
|
|
540
|
+
['held-out half (totals only)', (set, r) => r.holdout[set]],
|
|
541
|
+
];
|
|
542
|
+
for (const [label, pick] of parts) {
|
|
543
|
+
lines.push(label);
|
|
544
|
+
for (const set of before.sets || []) {
|
|
545
|
+
if (set === 'review' && !label.startsWith('held-out')) continue;
|
|
546
|
+
lines.push(`- before ${totalsLine(set, pick(set, before))}`, `- after ${totalsLine(set, pick(set, after))}`);
|
|
547
|
+
}
|
|
548
|
+
lines.push('');
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (decision) {
|
|
552
|
+
lines.push('## why', '');
|
|
553
|
+
if (decision.path) lines.push(`- kept because: ${decision.path}`);
|
|
554
|
+
for (const g of decision.gains) lines.push(`- gain: ${g}`);
|
|
555
|
+
for (const n of decision.notes || []) lines.push(`- note: ${n}`);
|
|
556
|
+
for (const r of decision.reasons) lines.push(`- stop: ${r}`);
|
|
557
|
+
}
|
|
558
|
+
lines.push('');
|
|
559
|
+
return lines.join('\n');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------- git
|
|
563
|
+
|
|
564
|
+
function git(cwd, args, { check = true, env } = {}) {
|
|
565
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...(env ? { env } : {}) });
|
|
566
|
+
if (check && r.status !== 0) throw new Error(`git ${args[0]} failed: ${String(r.stderr || r.stdout || '').trim().split('\n').slice(-1)[0]}`);
|
|
567
|
+
return r;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function repoTop(cwd) {
|
|
571
|
+
return String(git(cwd, ['rev-parse', '--show-toplevel']).stdout).trim();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------- loop
|
|
575
|
+
|
|
576
|
+
function runsDir(env) {
|
|
577
|
+
return path.join(stateDir(env), 'runs');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The process group of a pid. A run and every child it spawned count as
|
|
581
|
+
// one life. null when it cannot be read.
|
|
582
|
+
function pgidOf(pid) {
|
|
583
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
584
|
+
try {
|
|
585
|
+
const r = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8', timeout: 5000 });
|
|
586
|
+
const n = parseInt(String(r.stdout || '').trim(), 10);
|
|
587
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
588
|
+
} catch { return null; }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Is the run still alive: its process, or any process in its group.
|
|
592
|
+
function runAlive(rec) {
|
|
593
|
+
const live = (fn) => { try { fn(); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
594
|
+
if (Number.isInteger(rec.pgid) && rec.pgid > 0 && live(() => process.kill(-rec.pgid, 0))) return true;
|
|
595
|
+
if (Number.isInteger(rec.pid) && rec.pid > 0 && live(() => process.kill(rec.pid, 0))) return true;
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Any process whose working directory is inside the copy. Fail-closed: when
|
|
600
|
+
// lsof cannot run, the copy counts as in use so it is kept, not removed.
|
|
601
|
+
function copyInUse(tree) {
|
|
602
|
+
if (!fs.existsSync(tree)) return { inUse: false };
|
|
603
|
+
const r = spawnSync('lsof', ['-a', '-d', 'cwd', '+D', tree], { encoding: 'utf8', timeout: 15000 });
|
|
604
|
+
if (r.error) return { inUse: true, reason: 'a live-process check could not run (lsof)' };
|
|
605
|
+
const lines = String(r.stdout || '').split('\n').filter((l) => l && !l.startsWith('COMMAND'));
|
|
606
|
+
return { inUse: lines.length > 0, reason: lines.length ? 'a process is working inside it' : '' };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Untracked or ignored files in the copy, none of which the run writes.
|
|
610
|
+
// Any such file means something else put it there. The copy is kept.
|
|
611
|
+
function foreignFiles(tree) {
|
|
612
|
+
if (!fs.existsSync(tree)) return [];
|
|
613
|
+
const r = git(tree, ['status', '--porcelain', '--untracked-files=all', '--ignored'], { check: false });
|
|
614
|
+
return String(r.stdout || '').split('\n').filter(Boolean).filter((l) => /^(\?\?|!!) /.test(l)).map((l) => l.slice(3));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Files the run itself may leave in a copy besides tracked, committed ones.
|
|
618
|
+
// Today nothing: the candidate and the receipt draft live outside the copy.
|
|
619
|
+
const KNOWN_OUTPUT = new Set();
|
|
620
|
+
|
|
621
|
+
// Live processes this run started. A run is alive by definition. For its
|
|
622
|
+
// own copy the check is that every child it spawned has exited. Fail-closed:
|
|
623
|
+
// when ps cannot run, the copy counts as in use.
|
|
624
|
+
function liveChildren(pid = process.pid) {
|
|
625
|
+
// The ps call is itself a child of this process. Its own line is skipped.
|
|
626
|
+
const r = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,stat=,command='], { encoding: 'utf8', timeout: 10000 });
|
|
627
|
+
if (r.error || r.status !== 0) return null;
|
|
628
|
+
return String(r.stdout || '').split('\n').map((l) => l.trim().split(/\s+/)).filter((f) => f.length >= 4 && Number(f[1]) === pid && !/^Z/.test(f[2]) && !(path.basename(f[3]) === 'ps' && f[4] === '-A')).map((f) => Number(f[0]));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// The one way a copy is ever removed, for every case (rejected, kept,
|
|
632
|
+
// crashed). Nothing inside the copy is reset, checked out or cleaned: the
|
|
633
|
+
// copy is removed whole with a plain git worktree remove, only when no
|
|
634
|
+
// process works inside it, no process the owning run started is alive, it
|
|
635
|
+
// holds no untracked or ignored files beyond KNOWN_OUTPUT and nothing is
|
|
636
|
+
// uncommitted. Otherwise it is kept and the reason reported. The branch
|
|
637
|
+
// is deleted only when it has no commit of its own. A kept change's
|
|
638
|
+
// branch always stays.
|
|
639
|
+
function dropCopy(root, tree, branch, { env, runFile = null, ownRun = false } = {}) {
|
|
640
|
+
const keep = (reason) => ({ removed: false, reason });
|
|
641
|
+
if (fs.existsSync(tree)) {
|
|
642
|
+
const foreign = foreignFiles(tree).filter((f) => !KNOWN_OUTPUT.has(f));
|
|
643
|
+
if (foreign.length) return keep(`it holds files the run did not write: ${foreign.slice(0, 3).join(', ')}`);
|
|
644
|
+
const use = copyInUse(tree);
|
|
645
|
+
if (use.inUse) return keep(use.reason);
|
|
646
|
+
if (ownRun) {
|
|
647
|
+
const kids = liveChildren();
|
|
648
|
+
if (kids === null) return keep('a live-process check could not run (ps)');
|
|
649
|
+
if (kids.length) return keep('a process this run started is still going');
|
|
650
|
+
}
|
|
651
|
+
const dirt = String(git(tree, ['status', '--porcelain'], { check: false }).stdout || '').trim();
|
|
652
|
+
if (dirt) return keep('it has uncommitted changes');
|
|
653
|
+
}
|
|
654
|
+
const rm = git(root, ['worktree', 'remove', tree], { check: false });
|
|
655
|
+
if (rm.status !== 0) return keep('git would not remove it');
|
|
656
|
+
const own = String(git(root, ['rev-list', '--count', branch, '--not', `--exclude=${branch}`, '--branches'], { check: false, env }).stdout || '').trim();
|
|
657
|
+
if (own === '0') git(root, ['branch', '-D', branch], { check: false });
|
|
658
|
+
if (runFile) { try { fs.unlinkSync(runFile); } catch {} }
|
|
659
|
+
return { removed: true, branch_kept: own !== '0' };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Leftover copies from runs that are gone. A copy is removed only when its
|
|
663
|
+
// run is dead, its branch has no commit of its own and dropCopy finds it
|
|
664
|
+
// safe. Anything else is kept and reported, never force-removed.
|
|
665
|
+
function sweepStale(root, env) {
|
|
666
|
+
const removed = [];
|
|
667
|
+
const kept = [];
|
|
668
|
+
const records = new Map();
|
|
669
|
+
let names = [];
|
|
670
|
+
try { names = fs.readdirSync(runsDir(env)).filter((n) => n.endsWith('.json')); } catch {}
|
|
671
|
+
for (const n of names) {
|
|
672
|
+
try { const r = JSON.parse(fs.readFileSync(path.join(runsDir(env), n), 'utf8')); if (r && r.tree) records.set(path.resolve(r.tree), { ...r, file: path.join(runsDir(env), n) }); } catch {}
|
|
673
|
+
}
|
|
674
|
+
const list = String(git(root, ['worktree', 'list', '--porcelain'], { check: false }).stdout || '');
|
|
675
|
+
for (const block of list.split('\n\n')) {
|
|
676
|
+
const tree = (block.match(/^worktree (.+)$/m) || [])[1];
|
|
677
|
+
const branch = ((block.match(/^branch refs\/heads\/(.+)$/m) || [])[1]) || '';
|
|
678
|
+
if (!tree || !/^sentinel-improve\//.test(branch) || !/atris-sentinel-improve-/.test(path.basename(tree))) continue;
|
|
679
|
+
const rec = records.get(path.resolve(tree)) || [...records.values()].find((r) => fs.existsSync(r.tree) && fs.existsSync(tree) && fs.realpathSync(r.tree) === fs.realpathSync(tree));
|
|
680
|
+
const keep = (reason) => kept.push({ tree, branch, reason });
|
|
681
|
+
if (!rec) { keep('no record says which run owns it'); continue; }
|
|
682
|
+
if (runAlive(rec)) { keep('its run is still going'); continue; }
|
|
683
|
+
const own = String(git(root, ['rev-list', '--count', branch, '--not', `--exclude=${branch}`, '--branches'], { check: false, env }).stdout || '').trim();
|
|
684
|
+
if (own !== '0') { keep('its branch has a commit of its own'); continue; }
|
|
685
|
+
const res = dropCopy(root, tree, branch, { env, runFile: rec.file });
|
|
686
|
+
if (res.removed) removed.push({ tree, branch });
|
|
687
|
+
else keep(res.reason);
|
|
688
|
+
}
|
|
689
|
+
return { removed, kept };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function improve(opts = {}) {
|
|
693
|
+
const env = opts.env || process.env;
|
|
694
|
+
const log = opts.log || (() => {});
|
|
695
|
+
const now = opts.now || new Date();
|
|
696
|
+
const engine = opts.engine || 'claude';
|
|
697
|
+
const root = repoTop(opts.root || process.cwd());
|
|
698
|
+
if (!fs.existsSync(path.join(root, RULES_PATH))) throw new Error(`${root} is not an atris checkout with the sentinel rules in it`);
|
|
699
|
+
const refused = refusedEngine(engine);
|
|
700
|
+
if (refused) throw new Error(refused);
|
|
701
|
+
const judge = opts.judge || ((args) => runJudge({ ...args, env, atrisBin: opts.atrisBin }));
|
|
702
|
+
const propose = opts.propose || ((args) => askEngine({ ...args, env }));
|
|
703
|
+
const at = now.toISOString();
|
|
704
|
+
// Two runs in the same second (a timer and a person) get different names.
|
|
705
|
+
const id = `${stamp(now)}-${require('crypto').randomBytes(2).toString('hex')}`;
|
|
706
|
+
const branch = `sentinel-improve/${id}`;
|
|
707
|
+
const tree = fs.mkdtempSync(path.join(opts.tmpRoot || os.tmpdir(), 'atris-sentinel-improve-'));
|
|
708
|
+
fs.rmSync(tree, { recursive: true, force: true });
|
|
709
|
+
const receiptDir = path.join(stateDir(env), 'receipts');
|
|
710
|
+
fs.mkdirSync(receiptDir, { recursive: true });
|
|
711
|
+
const out = { at, engine, branch: null, kept: false, receipt: null };
|
|
712
|
+
let proposal = null;
|
|
713
|
+
let items = [];
|
|
714
|
+
let before = null;
|
|
715
|
+
let after = null;
|
|
716
|
+
let decision = null;
|
|
717
|
+
let error = '';
|
|
718
|
+
let keepBranch = false;
|
|
719
|
+
const swept = sweepStale(root, env);
|
|
720
|
+
for (const k of swept.kept) log(`kept a leftover copy at ${k.tree}: ${k.reason}`);
|
|
721
|
+
git(root, ['worktree', 'add', '-q', '-b', branch, tree, 'HEAD']);
|
|
722
|
+
fs.mkdirSync(runsDir(env), { recursive: true });
|
|
723
|
+
const runFile = path.join(runsDir(env), `${id}.json`);
|
|
724
|
+
fs.writeFileSync(runFile, `${JSON.stringify({ pid: process.pid, pgid: pgidOf(process.pid), tree, branch, started: at })}\n`);
|
|
725
|
+
// The candidate (proposed rules and seen items with the new ones) lives
|
|
726
|
+
// here, outside the copy. Judging never edits the copy.
|
|
727
|
+
const candidate = fs.mkdtempSync(path.join(opts.tmpRoot || os.tmpdir(), 'atris-sentinel-candidate-'));
|
|
728
|
+
try {
|
|
729
|
+
const seenRel = SEEN_REL;
|
|
730
|
+
const seenDir = path.join(tree, seenRel);
|
|
731
|
+
const candSeen = path.join(candidate, 'seen');
|
|
732
|
+
log('reading local signals');
|
|
733
|
+
const signals = opts.signals || await gather({ env, request: opts.request, osvBase: opts.osvBase, offline: opts.offline });
|
|
734
|
+
log('running the exam on the current code');
|
|
735
|
+
const current = judge({ target: tree, seenDir });
|
|
736
|
+
log(`asking ${engine} for one change`);
|
|
737
|
+
const answer = await propose({ engine, prompt: buildPrompt({ report: current, signals, seenDirRel: seenRel }), cwd: tree, sealed: [path.join(homeDir(env), '.atris'), current.holdout_dir, ...(current.sealed_sources || [])] });
|
|
738
|
+
proposal = answer && typeof answer === 'object' && (Array.isArray(answer.edits) || answer.rules) ? answer : extractJsonObject(answer && answer.stdout);
|
|
739
|
+
const bad = checkProposal(proposal, tree);
|
|
740
|
+
if (bad) throw new Error(bad);
|
|
741
|
+
const strayEarly = strayChanges(tree);
|
|
742
|
+
if (strayEarly.length) throw new Error(`the proposer changed files it may not touch: ${strayEarly.slice(0, 3).join(', ')}`);
|
|
743
|
+
items = cleanItems(proposal, `imp-${id.slice(0, 8)}`);
|
|
744
|
+
fs.mkdirSync(candSeen, { recursive: true });
|
|
745
|
+
for (const set of ['guard', 'agents']) {
|
|
746
|
+
const f = path.join(seenDir, `${set}.jsonl`);
|
|
747
|
+
if (fs.existsSync(f)) fs.copyFileSync(f, path.join(candSeen, `${set}.jsonl`));
|
|
748
|
+
}
|
|
749
|
+
appendItems(candSeen, items);
|
|
750
|
+
log('running the exam before the change, with the new items');
|
|
751
|
+
before = judge({ target: tree, seenDir: candSeen });
|
|
752
|
+
const applyErr = applyRules(tree, proposal, candidate);
|
|
753
|
+
if (applyErr) throw new Error(applyErr);
|
|
754
|
+
log('running the exam after the change');
|
|
755
|
+
after = judge({ target: candidate, seenDir: candSeen });
|
|
756
|
+
decision = keepDecision(before, after, { newIds: items.map((x) => `${x.set}:${x.item.id}`) });
|
|
757
|
+
if (decision.keep) {
|
|
758
|
+
// Only now does the copy change: the candidate goes in and is committed.
|
|
759
|
+
const late = strayChanges(tree);
|
|
760
|
+
if (late.length) throw new Error(`the copy changed while the exam ran: ${late.slice(0, 3).join(', ')}`);
|
|
761
|
+
fs.copyFileSync(path.join(candidate, RULES_PATH), path.join(tree, RULES_PATH));
|
|
762
|
+
for (const set of ['guard', 'agents']) {
|
|
763
|
+
const f = path.join(candSeen, `${set}.jsonl`);
|
|
764
|
+
if (fs.existsSync(f)) fs.copyFileSync(f, path.join(seenDir, `${set}.jsonl`));
|
|
765
|
+
}
|
|
766
|
+
const receipt = renderReceipt({ at, engine, proposal, items, before, after, decision, branch });
|
|
767
|
+
const rel = path.join(seenRel, 'receipts', `${id}.md`);
|
|
768
|
+
fs.mkdirSync(path.dirname(path.join(tree, rel)), { recursive: true });
|
|
769
|
+
fs.writeFileSync(path.join(tree, rel), receipt);
|
|
770
|
+
const outside = strayChanges(tree, new Set([...ALLOWED_CHANGES, rel]));
|
|
771
|
+
if (outside.length) throw new Error(`the change touches files other than the rules and the seen items: ${outside.slice(0, 3).join(', ')}`);
|
|
772
|
+
git(tree, ['add', RULES_PATH, seenRel]);
|
|
773
|
+
const msg = `Sentinel improve: ${String(proposal.title || 'one guard change').replace(/\s+/g, ' ').slice(0, 72)}\n\nKept by the sentinel bench. Receipt: ${rel}\n\nCo-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>`;
|
|
774
|
+
git(tree, ['commit', '-q', '-m', msg], { env });
|
|
775
|
+
keepBranch = true;
|
|
776
|
+
out.branch = branch;
|
|
777
|
+
out.kept = true;
|
|
778
|
+
}
|
|
779
|
+
} catch (e) {
|
|
780
|
+
error = String(e.message || e).split('\n')[0].slice(0, 300);
|
|
781
|
+
} finally {
|
|
782
|
+
fs.rmSync(candidate, { recursive: true, force: true });
|
|
783
|
+
// Kept, rejected or failed: the same safe check decides if the copy goes.
|
|
784
|
+
const res = dropCopy(root, tree, branch, { env, runFile, ownRun: true });
|
|
785
|
+
if (!res.removed) out.kept_copy = { tree, branch, reason: res.reason };
|
|
786
|
+
}
|
|
787
|
+
const receipt = renderReceipt({ at, engine, proposal, items, before, after, decision, branch: keepBranch ? branch : null, error });
|
|
788
|
+
const receiptFile = path.join(receiptDir, `${id}.md`);
|
|
789
|
+
fs.writeFileSync(receiptFile, receipt);
|
|
790
|
+
if (out.kept_copy) log(`left a copy at ${out.kept_copy.tree}: ${out.kept_copy.reason}`);
|
|
791
|
+
return { ...out, error, decision, before, after, proposal, items, receipt: receiptFile, swept };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ---------------------------------------------------------------- schedule
|
|
795
|
+
|
|
796
|
+
function schedulePlan({ root, engine = 'claude', everyHours = 24, env = process.env, platform = process.platform, atrisBin }) {
|
|
797
|
+
const { planKeep } = require('./campaign-keep');
|
|
798
|
+
return planKeep({
|
|
799
|
+
root,
|
|
800
|
+
home: stateDir(env),
|
|
801
|
+
platform,
|
|
802
|
+
homeDir: homeDir(env),
|
|
803
|
+
atrisBin: atrisBin || path.join(__dirname, '..', 'bin', 'atris.js'),
|
|
804
|
+
envPath: env.PATH || '',
|
|
805
|
+
job: { name: 'sentinel-improve', args: ['sentinel', 'improve', '--engine', engine], every_seconds: Math.round(everyHours * 3600), marker: 'ATRIS_SENTINEL_IMPROVE' },
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
module.exports = {
|
|
810
|
+
buildPrompt,
|
|
811
|
+
checkProposal,
|
|
812
|
+
engineEnv,
|
|
813
|
+
engineInvocation,
|
|
814
|
+
refusedEngine,
|
|
815
|
+
sweepStale,
|
|
816
|
+
extractJsonObject,
|
|
817
|
+
gather,
|
|
818
|
+
improve,
|
|
819
|
+
keepDecision,
|
|
820
|
+
runJudge,
|
|
821
|
+
schedulePlan,
|
|
822
|
+
stateDir,
|
|
823
|
+
};
|