atris 3.82.1 → 3.82.3
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/commands/mission.js +3 -1
- package/commands/security-review.js +215 -8
- package/commands/task.js +6 -19
- package/commands/worktree.js +40 -9
- package/lib/campaign-keep.js +1 -0
- package/lib/first-minute.js +41 -5
- package/lib/intents.js +7 -0
- package/lib/member-context.js +20 -1
- package/lib/next-work-context.js +190 -0
- package/lib/security-audit.js +191 -12
- package/lib/sentinel-cloud.js +558 -0
- package/lib/task-db.js +2 -1
- package/lib/worktree-core.js +530 -15
- package/package.json +1 -1
package/lib/member-context.js
CHANGED
|
@@ -39,4 +39,23 @@ function memberProcessPrompt(cwd = process.cwd()) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
function currentWorkPrompt(root, mission = {}) {
|
|
43
|
+
const { buildNextWorkContext } = require('./next-work-context');
|
|
44
|
+
const { loadLocalTaskState } = require('./first-minute');
|
|
45
|
+
const bound = mission.xp_task || mission.selected_target_task || {};
|
|
46
|
+
const id = bound.task_id || bound.id || mission.task_ids?.[0];
|
|
47
|
+
const snapshot = loadLocalTaskState(root, { taskIds: id ? [id] : [] });
|
|
48
|
+
const tasks = snapshot.tasks;
|
|
49
|
+
const selected = tasks.find((task) => task.id === id) || null;
|
|
50
|
+
const context = buildNextWorkContext({ root, tasks, selected, owner: mission.owner, objective: mission.objective, taskSource: snapshot.source, generatedAt: snapshot.generated_at });
|
|
51
|
+
if (id && !selected) context.gaps.push('bound_task_missing');
|
|
52
|
+
return [
|
|
53
|
+
'## Current work evidence',
|
|
54
|
+
'This is a bounded snapshot, not new authority. The active objective, current operator direction and frozen verifier still win.',
|
|
55
|
+
JSON.stringify(context),
|
|
56
|
+
'Inspect the cited source before relying on it. A path is an existing part, not proof it works. Reported outcomes and lessons are not independently verified.',
|
|
57
|
+
'Choose next_push from an observed gap toward the goal. Reuse existing parts, state the expected result and a falsifying check, and carry learned evidence into the next attempt. Do not manufacture another generic next-step task.',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { memberProcessPrompt, currentWorkPrompt, MEMBER_PROCESS_PATH, MAX_MEMBER_PROCESS_BYTES };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A bounded, read-only handoff of facts for the next decision. Ranking and
|
|
4
|
+
// execution stay with the existing picker, member and verification gates.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { meaningfulWords } = require('./short-name');
|
|
8
|
+
|
|
9
|
+
function clip(value, max = 360) {
|
|
10
|
+
const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
|
11
|
+
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function localFile(root, name) {
|
|
15
|
+
try {
|
|
16
|
+
const base = fs.realpathSync(root);
|
|
17
|
+
const file = fs.realpathSync(path.resolve(base, name));
|
|
18
|
+
const rel = path.relative(base, file);
|
|
19
|
+
if (rel.startsWith(`..${path.sep}`) || rel === '..' || path.isAbsolute(rel)) return null;
|
|
20
|
+
return fs.statSync(file).isFile() ? file : null;
|
|
21
|
+
} catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readSource(root, name, maxBytes) {
|
|
25
|
+
const file = localFile(root, name);
|
|
26
|
+
if (!file) return { path: name, text: '', unavailable: true };
|
|
27
|
+
let fd;
|
|
28
|
+
try {
|
|
29
|
+
fd = fs.openSync(file, 'r');
|
|
30
|
+
const stat = fs.fstatSync(fd);
|
|
31
|
+
const buffer = Buffer.alloc(Math.min(stat.size, maxBytes));
|
|
32
|
+
const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
33
|
+
return { path: name, text: buffer.subarray(0, count).toString('utf8'), truncated: stat.size > maxBytes, modified_at: stat.mtime.toISOString() };
|
|
34
|
+
} catch { return { path: name, text: '', unavailable: true }; }
|
|
35
|
+
finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Keep the offline projection fallback, but a known row's current database
|
|
39
|
+
// status always wins. Include finished rows so their outcomes are not lost.
|
|
40
|
+
function reconcileTaskRows(rows, projected, lookup = () => null) {
|
|
41
|
+
const known = new Map(rows.map((row) => [row.id, row]));
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const merged = [];
|
|
44
|
+
for (const row of projected) {
|
|
45
|
+
if (!row || typeof row !== 'object') continue;
|
|
46
|
+
// Older offline snapshots have only a display reference. Preserve that
|
|
47
|
+
// evidence without pretending it has a database identity or authority.
|
|
48
|
+
if (!row.id) { merged.push(row); continue; }
|
|
49
|
+
if (seen.has(row.id)) continue;
|
|
50
|
+
seen.add(row.id);
|
|
51
|
+
const current = known.get(row.id) || lookup(row.id);
|
|
52
|
+
if (current !== false) merged.push(current || row);
|
|
53
|
+
}
|
|
54
|
+
return [...merged, ...rows.filter((row) => !seen.has(row.id))];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function matchingParts(root, map, query) {
|
|
58
|
+
const wanted = new Set(meaningfulWords(query, 100));
|
|
59
|
+
const found = new Map();
|
|
60
|
+
map.text.split(/\r?\n/).forEach((line, index) => {
|
|
61
|
+
if (!line.startsWith('|')) return;
|
|
62
|
+
const score = meaningfulWords(line, 100).filter((word) => wanted.has(word)).length;
|
|
63
|
+
if (!score) return;
|
|
64
|
+
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
65
|
+
const name = match[1].replace(/:\d+$/, '');
|
|
66
|
+
if (path.isAbsolute(name) || !localFile(root, name) || found.has(name)) continue;
|
|
67
|
+
found.set(name, { path: name, source: `${map.path}:${index + 1}`, description: clip(line.replace(/`/g, ''), 180), score });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return [...found.values()].sort((a, b) => b.score - a.score).slice(0, 5)
|
|
71
|
+
.map(({ score, ...part }) => part);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function recentOutcomes(tasks, selected) {
|
|
75
|
+
return tasks.filter((task) => ['done', 'failed', 'review'].includes(task.status))
|
|
76
|
+
.sort((a, b) => Number(b.id === selected?.id) - Number(a.id === selected?.id)
|
|
77
|
+
|| Number(b.updated_at || 0) - Number(a.updated_at || 0))
|
|
78
|
+
.slice(0, 3).map((task) => {
|
|
79
|
+
const meta = task.metadata || {};
|
|
80
|
+
return {
|
|
81
|
+
task_id: clip(task.id, 120),
|
|
82
|
+
title: clip(task.title, 160),
|
|
83
|
+
status: task.status,
|
|
84
|
+
result: clip(task.result || meta.result),
|
|
85
|
+
lesson: clip(task.review?.lesson || meta.latest_agent_lesson),
|
|
86
|
+
next: clip(task.review?.next_task || meta.latest_agent_next_task),
|
|
87
|
+
proof: clip(task.review?.proof || meta.latest_agent_proof),
|
|
88
|
+
validation: task.status === 'failed' ? 'failed' : task.status === 'review' ? 'review_pending'
|
|
89
|
+
: meta.accepted_by && meta.accepted_at ? 'accepted' : 'reported',
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function otherOwner(task, owner) {
|
|
95
|
+
const assigned = typeof task.metadata?.assigned_to === 'string' ? task.metadata.assigned_to.trim() : '';
|
|
96
|
+
return Boolean((task.claimed_by && task.claimed_by !== owner) || (assigned && assigned !== owner));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function candidateEvidence(tasks, selected, goal, owner) {
|
|
100
|
+
const words = new Set(meaningfulWords(goal?.text, 100));
|
|
101
|
+
const { heldForPerson } = require('./member-task-pick');
|
|
102
|
+
return tasks.filter((task) => task.id !== selected?.id && ['open', 'claimed', 'review'].includes(task.status))
|
|
103
|
+
.map((task) => {
|
|
104
|
+
const meta = task.metadata || {};
|
|
105
|
+
const why = clip(meta.why_it_matters || task.explanation?.why_it_matters, 240);
|
|
106
|
+
const overlap = new Set(meaningfulWords(`${task.title} ${meta.objective || ''} ${why}`, 100).filter((word) => words.has(word))).size;
|
|
107
|
+
return {
|
|
108
|
+
task_id: clip(task.id, 120), title: clip(task.title, 160), status: task.status,
|
|
109
|
+
owner: clip(task.claimed_by || meta.assigned_to, 80) || null,
|
|
110
|
+
why: why || null,
|
|
111
|
+
held: heldForPerson(task) || (otherOwner(task, owner) ? 'other_owner' : null),
|
|
112
|
+
goal_word_overlap: overlap,
|
|
113
|
+
};
|
|
114
|
+
}).sort((a, b) => b.goal_word_overlap - a.goal_word_overlap).slice(0, 3);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildNextWorkContext({ root, tasks = [], selected = null, owner = '', taskSource = 'projection', objective = '', generatedAt = null }) {
|
|
118
|
+
const scoreboard = readSource(root, '.atris/state/scoreboard.json', 16384);
|
|
119
|
+
const map = readSource(root, 'atris/MAP.md', 65536);
|
|
120
|
+
let goal = null;
|
|
121
|
+
try {
|
|
122
|
+
const value = scoreboard.truncated ? null : JSON.parse(scoreboard.text);
|
|
123
|
+
if (clip(value?.goal)) goal = { text: clip(value.goal, 600), source: scoreboard.path, updated_at: typeof value.updated_at === 'string' ? clip(value.updated_at, 80) : null };
|
|
124
|
+
} catch { /* Missing or unreadable knowledge stays unknown. */ }
|
|
125
|
+
const meta = selected?.metadata || {};
|
|
126
|
+
const rawCheck = typeof (meta.verify || meta.verifier) === 'string' ? (meta.verify || meta.verifier).trim() : '';
|
|
127
|
+
// Never offer an ellipsis as an executable command.
|
|
128
|
+
const check = rawCheck.length <= 600 ? rawCheck : '';
|
|
129
|
+
const { taskCheck, heldForPerson } = require('./member-task-pick');
|
|
130
|
+
const runnable = check && taskCheck({ metadata: { verify: check } }).runnable;
|
|
131
|
+
const parts = matchingParts(root, map, [objective, selected?.title, meta.objective, goal?.text].filter(Boolean).join(' '));
|
|
132
|
+
const gaps = [];
|
|
133
|
+
if (!goal) gaps.push('current_goal_missing');
|
|
134
|
+
if (taskSource === 'none') gaps.push('task_state_missing');
|
|
135
|
+
if (scoreboard.truncated) gaps.push('goal_source_truncated');
|
|
136
|
+
if (map.unavailable) gaps.push('parts_map_missing');
|
|
137
|
+
else if (!parts.length) gaps.push('matching_parts_missing');
|
|
138
|
+
if (map.truncated) gaps.push('parts_map_truncated');
|
|
139
|
+
if (selected && !runnable) gaps.push('runnable_check_missing');
|
|
140
|
+
if (rawCheck.length > 600) gaps.push('check_too_large_for_snapshot');
|
|
141
|
+
if (taskSource === 'projection' || taskSource === 'mixed') gaps.push('task_snapshot_not_authoritative');
|
|
142
|
+
if (generatedAt && Date.now() - Date.parse(generatedAt) > 3 * 86400000) gaps.push('task_snapshot_stale');
|
|
143
|
+
const why = clip(meta.why_it_matters || selected?.explanation?.why_it_matters);
|
|
144
|
+
if (selected && !why) gaps.push('reason_missing');
|
|
145
|
+
let kind = selected && runnable ? 'execute' : 'discover';
|
|
146
|
+
if (gaps.includes('task_snapshot_not_authoritative') || gaps.includes('task_snapshot_stale')) kind = 'discover';
|
|
147
|
+
let reason = selected ? 'Inspect the relevant source and establish the missing evidence before changing it.'
|
|
148
|
+
: 'No selectable job is recorded. Find one observed gap toward the current goal, with a falsifying check.';
|
|
149
|
+
if (kind === 'execute') reason = why || 'Run the recorded check against the relevant source before claiming progress.';
|
|
150
|
+
if (selected && !['open', 'claimed', 'review'].includes(selected.status)) {
|
|
151
|
+
kind = 'discover'; reason = 'The bound task is no longer active. Inspect its recorded result before proposing another gap.';
|
|
152
|
+
gaps.push(selected.status === 'done' ? 'bound_task_already_finished' : 'bound_task_not_active');
|
|
153
|
+
} else if (selected && heldForPerson(selected)) { kind = 'human_decision'; reason = 'This work is held for a person by the existing task policy.'; }
|
|
154
|
+
else if (selected?.status === 'review') {
|
|
155
|
+
const certified = selected.review?.agent_certified || meta.agent_certified || Number(meta.agent_review_pass_count) >= 2;
|
|
156
|
+
kind = certified ? 'human_decision' : selected.claimed_by === owner ? 'wait' : 'review';
|
|
157
|
+
reason = certified ? 'The recorded work needs human acceptance.' : 'A different actor must check the finished diff before more work is chosen.';
|
|
158
|
+
} else if (selected && otherOwner(selected, owner)) {
|
|
159
|
+
kind = 'wait'; reason = 'Another owner holds this job. Do not duplicate their work.';
|
|
160
|
+
}
|
|
161
|
+
const context = {
|
|
162
|
+
schema: 'atris.next_work_context.v1',
|
|
163
|
+
workspace_root: path.resolve(root),
|
|
164
|
+
captured_at: new Date().toISOString(),
|
|
165
|
+
task_source: taskSource,
|
|
166
|
+
goal,
|
|
167
|
+
parts,
|
|
168
|
+
candidates: candidateEvidence(tasks, selected, goal, owner),
|
|
169
|
+
recent_outcomes: recentOutcomes(tasks, selected),
|
|
170
|
+
gaps,
|
|
171
|
+
next_step: { kind, reason, check: check || null },
|
|
172
|
+
sources: [scoreboard, map].map(({ text, ...source }) => source),
|
|
173
|
+
selection_basis: 'existing queue policy; candidate keyword overlap is a discovery hint, not measured business value',
|
|
174
|
+
};
|
|
175
|
+
// Keep the packet bounded even with long unicode text or unusual paths.
|
|
176
|
+
while (Buffer.byteLength(JSON.stringify(context)) > 10000) {
|
|
177
|
+
if (!gaps.includes('context_budget_limited')) gaps.push('context_budget_limited');
|
|
178
|
+
if (context.parts.length) context.parts.pop();
|
|
179
|
+
else if (context.candidates.length) context.candidates.pop();
|
|
180
|
+
else if (context.recent_outcomes.length) context.recent_outcomes.pop();
|
|
181
|
+
else {
|
|
182
|
+
context.next_step.check = null;
|
|
183
|
+
if (context.next_step.kind === 'execute') context.next_step.kind = 'discover';
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return context;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { buildNextWorkContext, reconcileTaskRows };
|
package/lib/security-audit.js
CHANGED
|
@@ -671,6 +671,128 @@ async function defaultRunJobs(jobs, { root, concurrency, timeoutMs }) {
|
|
|
671
671
|
return runEngineAskJobs(pinned, { root, concurrency, timeoutMs });
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
// Atris-hosted engines (atris:fast, atris:pro) run on the Atris backend and
|
|
675
|
+
// cannot open files, so their prompts carry the source they need. Secrets
|
|
676
|
+
// are hidden at the one send point in lib/sentinel-cloud.js.
|
|
677
|
+
const HOSTED_ENGINE = /^atris:[a-z0-9._-]+$/i;
|
|
678
|
+
const HOSTED_SOURCE_BYTES = { hunter: 96 * 1024, verifier: 32 * 1024 };
|
|
679
|
+
const HOSTED_CONTEXT_LINES = 40;
|
|
680
|
+
// The output limit sent with each hosted turn (the turn API accepts at most 8192).
|
|
681
|
+
const HOSTED_OUTPUT_TOKENS = { hunter: 8192, verifier: 4096 };
|
|
682
|
+
// Largest message a hosted check (verifier, recheck, critic) may send. The
|
|
683
|
+
// quote prices every check call at this size, so the quote is a ceiling.
|
|
684
|
+
const HOSTED_CHECK_MAX_BYTES = 64 * 1024;
|
|
685
|
+
const ENV_STYLE_FILE = /(?:^|\/)(?:\.env(?:\.[\w.-]+)?|\.envrc|.*\.(?:pem|key|p12|pfx|jks|keystore|crt|cer|der|asc|gpg|kdbx)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|\.(?:npmrc|pypirc|netrc|pgpass|htpasswd)|credentials(?:\.[\w]+)?|secrets?(?:\.[\w]+)?|service[-_]?account[\w.-]*\.json)$/i;
|
|
686
|
+
|
|
687
|
+
function isHostedEngine(engine) {
|
|
688
|
+
return HOSTED_ENGINE.test(String(engine || ''));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// A file a hosted engine may ever see: tracked by git, not ignored, inside
|
|
692
|
+
// the requested scope, not a skipped folder, and not a key or .env-style file.
|
|
693
|
+
function sensitiveFile(rel) {
|
|
694
|
+
if (ENV_STYLE_FILE.test(rel)) return true;
|
|
695
|
+
try { return require('./security-scan').sensitiveFileFindings([rel]).length > 0; } catch { return false; }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function hostedAllowedFiles(root, scope) {
|
|
699
|
+
let tracked = null;
|
|
700
|
+
try {
|
|
701
|
+
tracked = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 }).split('\0').filter(Boolean);
|
|
702
|
+
} catch { return new Set(); }
|
|
703
|
+
let ignored = new Set();
|
|
704
|
+
try {
|
|
705
|
+
const r = require('child_process').spawnSync('git', ['check-ignore', '--no-index', '-z', '--stdin'], { cwd: root, input: tracked.join('\0'), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
706
|
+
ignored = new Set(String(r.stdout || '').split('\0').filter(Boolean));
|
|
707
|
+
} catch {}
|
|
708
|
+
return new Set(tracked.filter((rel) => isRepoRel(rel) && !skippedPath(rel) && inScope(rel, scope) && !ignored.has(rel) && !sensitiveFile(rel)));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Numbered source for a hosted engine. `ranges` maps a file to line windows;
|
|
712
|
+
// a file with no window is shown whole. Stops at the byte cap. Only files in
|
|
713
|
+
// `allowed` are ever read.
|
|
714
|
+
function hostedSource(root, files, { maxBytes, ranges = new Map(), allowed = null } = {}) {
|
|
715
|
+
const out = [];
|
|
716
|
+
let used = 0;
|
|
717
|
+
const skipped = [];
|
|
718
|
+
for (const rel of [...new Set(files)].filter(isRepoRel).filter((f) => allowed && allowed.has(f))) {
|
|
719
|
+
const text = readText(root, rel);
|
|
720
|
+
if (text === null) continue;
|
|
721
|
+
const lines = text.split('\n');
|
|
722
|
+
const windows = ranges.get(rel);
|
|
723
|
+
const keep = new Set();
|
|
724
|
+
if (windows && windows.length) {
|
|
725
|
+
for (const n of windows) for (let i = Math.max(1, n - HOSTED_CONTEXT_LINES); i <= Math.min(lines.length, n + HOSTED_CONTEXT_LINES); i++) keep.add(i);
|
|
726
|
+
} else {
|
|
727
|
+
for (let i = 1; i <= lines.length; i++) keep.add(i);
|
|
728
|
+
}
|
|
729
|
+
const body = [];
|
|
730
|
+
let last = 0;
|
|
731
|
+
for (const n of [...keep].sort((a, b) => a - b)) {
|
|
732
|
+
if (last && n !== last + 1) body.push(' ...');
|
|
733
|
+
const line = lines[n - 1];
|
|
734
|
+
// Secrets are hidden at the one send point (lib/sentinel-cloud.js
|
|
735
|
+
// redactOutgoing), which sees whole key blocks.
|
|
736
|
+
body.push(`${String(n).padStart(5)} ${line}`);
|
|
737
|
+
last = n;
|
|
738
|
+
}
|
|
739
|
+
const block = `### ${rel}\n\`\`\`\n${body.join('\n')}\n\`\`\``;
|
|
740
|
+
if (used + block.length > maxBytes) { skipped.push(rel); continue; }
|
|
741
|
+
used += block.length;
|
|
742
|
+
out.push(block);
|
|
743
|
+
}
|
|
744
|
+
if (!out.length && !skipped.length) return '';
|
|
745
|
+
return [
|
|
746
|
+
'## Source',
|
|
747
|
+
'You cannot open files in this session. The source you need is below with line numbers; cite files and lines from it.',
|
|
748
|
+
skipped.length ? `These files were too large to include: ${skipped.join(', ')}. Mark units that depend on them "blocked".` : '',
|
|
749
|
+
...out,
|
|
750
|
+
].filter(Boolean).join('\n\n');
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function traceFiles(trace) {
|
|
754
|
+
const ranges = new Map();
|
|
755
|
+
for (const t of trace || []) {
|
|
756
|
+
if (!t || !isRepoRel(t.file)) continue;
|
|
757
|
+
if (!ranges.has(t.file)) ranges.set(t.file, []);
|
|
758
|
+
if (Number.isInteger(t.line)) ranges.get(t.file).push(t.line);
|
|
759
|
+
}
|
|
760
|
+
return ranges;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function withHostedSource(root, engine, prompt, { files = [], trace = null, kind = 'hunter', allowed = null } = {}) {
|
|
764
|
+
if (!isHostedEngine(engine)) return prompt;
|
|
765
|
+
if (kind === 'critic') return `${prompt}\n\nYou cannot open files in this session. Judge gaps from the architecture and ledger above.`;
|
|
766
|
+
const ranges = trace ? traceFiles(trace) : new Map();
|
|
767
|
+
const list = trace ? [...ranges.keys()] : files;
|
|
768
|
+
const src = hostedSource(root, list, { maxBytes: HOSTED_SOURCE_BYTES[kind] || HOSTED_SOURCE_BYTES.verifier, ranges, allowed });
|
|
769
|
+
return src ? `${prompt}\n\n${src}` : prompt;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Units as a hosted hunter may see them: seeded lines only from allowed files.
|
|
773
|
+
function hostedUnits(units, allowed) {
|
|
774
|
+
if (!allowed) return units;
|
|
775
|
+
return units.map((u) => ({ ...u, hot_lines: (u.hot_lines || []).filter((h) => allowed.has(h.file)) }));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// A trace as a hosted engine may see it: code lines only from allowed files.
|
|
779
|
+
function hostedTrace(trace, allowed) {
|
|
780
|
+
if (!allowed) return trace;
|
|
781
|
+
return (trace || []).map((t) => {
|
|
782
|
+
if (!t || allowed.has(t.file)) return t;
|
|
783
|
+
const out = { ...t };
|
|
784
|
+
delete out.code;
|
|
785
|
+
return out;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Titles and fix summaries are the only finding text that leaves the
|
|
790
|
+
// machine (the account dashboard). Ask for plain words up front; the upload
|
|
791
|
+
// filter replaces anything else.
|
|
792
|
+
const PLAIN_TITLE_HINT = 'one plain sentence saying what goes wrong, with no code, identifiers, file contents or secrets';
|
|
793
|
+
const PLAIN_FIX_HINT = 'one plain sentence describing the change in words, with no code, identifiers, file contents or secrets, for example "Pass the command and its arguments separately instead of building one shell string"';
|
|
794
|
+
const PLAIN_TEXT_RULE = `Write "title" as ${PLAIN_TITLE_HINT}. Write remediation "summary" as ${PLAIN_FIX_HINT}. Keep code, names and lines in remediation "strategy", which stays in the local report.`;
|
|
795
|
+
|
|
674
796
|
function bullet(lines) {
|
|
675
797
|
return lines.map((l) => `- ${l}`).join('\n');
|
|
676
798
|
}
|
|
@@ -692,13 +814,13 @@ function hunterPrompt({ architecture, units, exclusions, peerIds }) {
|
|
|
692
814
|
const contract = {
|
|
693
815
|
units: [{ coverage_id: 'one assigned id', disposition: 'covered|candidate|blocked', reviewed_paths: ['repo/relative/path'], checks: [{ invariant: 'the control you checked', method: 'source', result: 'what the source established', reviewed_paths: ['repo/relative/path'] }], candidate_fingerprints: [], unresolved: [] }],
|
|
694
816
|
candidates: [{
|
|
695
|
-
proposed_verdict: 'confirmed|needs_validation', fingerprint: 'path:scope/root-cause-name', title:
|
|
817
|
+
proposed_verdict: 'confirmed|needs_validation', fingerprint: 'path:scope/root-cause-name', title: PLAIN_TITLE_HINT, description: '', attack_class: classes[0].id,
|
|
696
818
|
coverage_ids: [], attacker: 'the lower-trust principal and what they start with', root_cause: '(confirmed) the source defect', claimed_root_cause: '(needs_validation) the suspected defect',
|
|
697
819
|
intended_behavior: '', impact: 'the concrete result across the boundary',
|
|
698
820
|
trace: [{ kind: 'entrypoint|propagation|sink', file: 'repo/relative/path', line: 1, description: '' }],
|
|
699
821
|
conditions: [{ kind: CONDITION_KINDS.join('|'), description: '' }],
|
|
700
822
|
severity: { likelihood: { score: SCORES.join('|'), reason: '' }, impact: { score: SCORES.join('|'), reason: '' }, overall: SCORES.join('|') },
|
|
701
|
-
remediation: { strategy: 'the smallest source change at the last trusted decision point', regression_test: 'the test that would fail today' },
|
|
823
|
+
remediation: { strategy: 'the smallest source change at the last trusted decision point', summary: PLAIN_FIX_HINT, regression_test: 'the test that would fail today' },
|
|
702
824
|
blockers: ['(needs_validation) the exact missing fact'], validation_plan: [{ context: 'local|deployment', step: '' }],
|
|
703
825
|
}],
|
|
704
826
|
hardening: ['a concrete note that is not a finding'],
|
|
@@ -726,15 +848,16 @@ function hunterPrompt({ architecture, units, exclusions, peerIds }) {
|
|
|
726
848
|
exclusions.length ? `## Already on record, do not report again\n${bullet(exclusions.slice(0, 20).map((e) => `${e.fingerprint}: ${e.title} (${e.why})`))}\n` : '',
|
|
727
849
|
peerIds ? `Other hunters own ${peerIds} other units this wave. Stay inside your assigned paths; put anything else in "uncovered".\n` : '',
|
|
728
850
|
'## Result contract',
|
|
851
|
+
PLAIN_TEXT_RULE,
|
|
729
852
|
'Every assigned coverage_id appears exactly once in "units". "covered" means you read the paths and the invariant holds. "candidate" lists the fingerprints of the candidates you return. "blocked" means you read the code but one fact you cannot see decides it; say which in "unresolved". Every check lists the paths it read.',
|
|
730
853
|
'Return only this JSON object, no prose before or after it:',
|
|
731
854
|
JSON.stringify(contract, null, 2),
|
|
732
855
|
].join('\n');
|
|
733
856
|
}
|
|
734
857
|
|
|
735
|
-
function withCode(root, trace) {
|
|
858
|
+
function withCode(root, trace, allowed = null) {
|
|
736
859
|
return (trace || []).map((t) => {
|
|
737
|
-
const text = t && isRepoRel(t.file) ? readText(root, t.file) : null;
|
|
860
|
+
const text = t && isRepoRel(t.file) && (!allowed || allowed.has(t.file)) ? readText(root, t.file) : null;
|
|
738
861
|
const line = text && Number.isInteger(t.line) ? text.split('\n')[t.line - 1] : undefined;
|
|
739
862
|
return line === undefined ? t : { ...t, code: line.trim().slice(0, 200) };
|
|
740
863
|
});
|
|
@@ -747,7 +870,7 @@ function verifierPrompt({ candidate, cls }) {
|
|
|
747
870
|
verdict: 'confirmed|needs_validation|rejected',
|
|
748
871
|
reason: 'what you read and why it holds or fails',
|
|
749
872
|
preventing_layer: 'file:line of the control that stops it, or null',
|
|
750
|
-
corrected: { title: '', root_cause: '', attacker: '', impact: '', trace: [{ kind: 'entrypoint|propagation|sink', file: '', line: 1, description: '' }], conditions: [], severity: { likelihood: { score: '', reason: '' }, impact: { score: '', reason: '' }, overall: '' }, remediation: { strategy: '', regression_test: '' } },
|
|
873
|
+
corrected: { title: '', root_cause: '', attacker: '', impact: '', trace: [{ kind: 'entrypoint|propagation|sink', file: '', line: 1, description: '' }], conditions: [], severity: { likelihood: { score: '', reason: '' }, impact: { score: '', reason: '' }, overall: '' }, remediation: { strategy: '', summary: '', regression_test: '' } },
|
|
751
874
|
blockers: ['(needs_validation) the exact missing fact'],
|
|
752
875
|
validation_plan: [{ context: 'local|deployment', step: '' }],
|
|
753
876
|
};
|
|
@@ -764,6 +887,7 @@ function verifierPrompt({ candidate, cls }) {
|
|
|
764
887
|
'A library version or behavior is not outside source when the target has it installed. Read the lockfile, the venv or .venv site-packages, or node_modules before sending it to the owner.',
|
|
765
888
|
'"rejected": the source disproves it. Name the line that does.',
|
|
766
889
|
'Use "corrected" only for fields you would change. Read-only: do not run the target or touch the network.',
|
|
890
|
+
`If the title or remediation summary has code, names or file contents, rewrite it in "corrected". ${PLAIN_TEXT_RULE}`,
|
|
767
891
|
]),
|
|
768
892
|
'',
|
|
769
893
|
`## Attack class: ${cls.title}`,
|
|
@@ -791,6 +915,7 @@ function recheckPrompt({ record }) {
|
|
|
791
915
|
bullet([
|
|
792
916
|
'Open each cited file:line. Every sentence in the record must match what the code does now.',
|
|
793
917
|
'Check the severity against the anchors. Overall can never exceed impact.',
|
|
918
|
+
`If the title or remediation summary has code, names or file contents, return it rewritten in "corrected". ${PLAIN_TEXT_RULE}`,
|
|
794
919
|
'If the record is right, return {"ok": true}.',
|
|
795
920
|
'If a detail is wrong but the problem is real, return {"ok": false, "verdict": "confirmed", "corrected": {...only the fields to change}}.',
|
|
796
921
|
'If the problem is not real after all, return {"ok": false, "verdict": "rejected", "reason": "..."} or {"ok": false, "verdict": "needs_validation", "blockers": [...], "validation_plan": [{"context": "local|deployment", "step": "..."}]}.',
|
|
@@ -1007,7 +1132,7 @@ function buildRecord(root, cand, verdict, extra) {
|
|
|
1007
1132
|
trace,
|
|
1008
1133
|
conditions: (cand.conditions || []).filter((c) => c && CONDITION_KINDS.includes(c.kind) && nonEmptyString(c.description)).map((c) => ({ kind: c.kind, description: str(c.description, 400) })),
|
|
1009
1134
|
severity: clampSeverity(cand.severity),
|
|
1010
|
-
remediation: { strategy: str(cand.remediation && cand.remediation.strategy), regression_test: str(cand.remediation && cand.remediation.regression_test) },
|
|
1135
|
+
remediation: { strategy: str(cand.remediation && cand.remediation.strategy), summary: str(cand.remediation && cand.remediation.summary, 400), regression_test: str(cand.remediation && cand.remediation.regression_test) },
|
|
1011
1136
|
evidence_level: 'source-traced, not executed',
|
|
1012
1137
|
verified_by: extra.by ? [extra.by] : [],
|
|
1013
1138
|
};
|
|
@@ -1021,7 +1146,8 @@ function mergeCorrected(cand, corrected) {
|
|
|
1021
1146
|
if (Array.isArray(corrected.conditions) && corrected.conditions.length) out.conditions = corrected.conditions;
|
|
1022
1147
|
const s = corrected.severity;
|
|
1023
1148
|
if (s && s.impact && SCORES.includes(s.impact.score) && SCORES.includes(s.overall) && s.likelihood && SCORES.includes(s.likelihood.score)) out.severity = s;
|
|
1024
|
-
if (corrected.remediation && nonEmptyString(corrected.remediation.strategy)) out.remediation = corrected.remediation;
|
|
1149
|
+
if (corrected.remediation && nonEmptyString(corrected.remediation.strategy)) out.remediation = { ...(cand.remediation || {}), ...corrected.remediation };
|
|
1150
|
+
else if (corrected.remediation && nonEmptyString(corrected.remediation.summary)) out.remediation = { ...(cand.remediation || {}), summary: corrected.remediation.summary };
|
|
1025
1151
|
return out;
|
|
1026
1152
|
}
|
|
1027
1153
|
|
|
@@ -1209,6 +1335,23 @@ function pickBatches(ledger, profile, allowance) {
|
|
|
1209
1335
|
return batches;
|
|
1210
1336
|
}
|
|
1211
1337
|
|
|
1338
|
+
// The most a hosted run can send: every budgeted call, each at its largest
|
|
1339
|
+
// allowed message and its full output limit. Hunter messages are measured
|
|
1340
|
+
// from this run's real first-wave batches; the largest one becomes the hunter
|
|
1341
|
+
// limit for the whole run. One token per byte is a true ceiling: a token is
|
|
1342
|
+
// never shorter than a byte.
|
|
1343
|
+
function spendEstimate({ root, architecture, ledger, profile, plan, exclusions, hunter, verifier, allowed }) {
|
|
1344
|
+
const batches = pickBatches(ledger, profile, plan.hunters);
|
|
1345
|
+
const sizes = batches.map((units) => Buffer.byteLength(withHostedSource(root, hunter, hunterPrompt({ architecture, units: hostedUnits(units, allowed), exclusions, peerIds: plan.hunters }), { files: units.flatMap((u) => u.starting_paths), kind: 'hunter', allowed })));
|
|
1346
|
+
const hunterMax = Math.max(4096, ...sizes);
|
|
1347
|
+
const criticBytes = Buffer.byteLength(criticPrompt({ architecture, units: ledger, final: true })) + 1024;
|
|
1348
|
+
const checkMax = Math.max(HOSTED_CHECK_MAX_BYTES, criticBytes);
|
|
1349
|
+
const rows = [];
|
|
1350
|
+
if (isHostedEngine(hunter)) rows.push({ role: 'hunter', model: hunter, calls: plan.hunters, input_tokens: hunterMax, output_tokens: HOSTED_OUTPUT_TOKENS.hunter, max_input_bytes: hunterMax });
|
|
1351
|
+
if (isHostedEngine(verifier)) rows.push({ role: 'verifier', model: verifier, calls: plan.total - plan.hunters, input_tokens: checkMax, output_tokens: HOSTED_OUTPUT_TOKENS.verifier, max_input_bytes: checkMax });
|
|
1352
|
+
return { profile, budget: plan.total, calls: rows };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1212
1355
|
async function runAudit(opts = {}) {
|
|
1213
1356
|
const root = path.resolve(opts.root || process.cwd());
|
|
1214
1357
|
const profile = PROFILES.includes(opts.profile) ? opts.profile : 'standard';
|
|
@@ -1253,8 +1396,15 @@ async function runAudit(opts = {}) {
|
|
|
1253
1396
|
started_at: now().toISOString(),
|
|
1254
1397
|
run_status: 'in_progress',
|
|
1255
1398
|
invocations: { used: 0, hunters: 0, critics: 0, verifiers: 0, rechecks: 0 },
|
|
1399
|
+
credits_used: 0,
|
|
1256
1400
|
method: 'adapted from cloudflare/security-audit-skill (MIT)',
|
|
1257
1401
|
};
|
|
1402
|
+
// What a hosted engine may ever be shown (null for local engines, which
|
|
1403
|
+
// read the checkout themselves).
|
|
1404
|
+
const hostedAny = isHostedEngine(hunter) || isHostedEngine(verifier);
|
|
1405
|
+
const allowed = hostedAny ? hostedAllowedFiles(root, scope) : null;
|
|
1406
|
+
const hunterAllowed = isHostedEngine(hunter) ? allowed : null;
|
|
1407
|
+
const verifierAllowed = isHostedEngine(verifier) ? allowed : null;
|
|
1258
1408
|
const ledgerFile = path.join(dir, 'coverage-ledger.json');
|
|
1259
1409
|
const saveLedger = () => {
|
|
1260
1410
|
ledger.sort(cmpId);
|
|
@@ -1302,12 +1452,29 @@ async function runAudit(opts = {}) {
|
|
|
1302
1452
|
return finish('incomplete', 'budget_cannot_fund_hunters_and_reserves', [], carried.map((r) => r.fingerprint));
|
|
1303
1453
|
}
|
|
1304
1454
|
|
|
1455
|
+
// Hosted engines spend credits: the caller sees an upper-bound estimate and
|
|
1456
|
+
// says yes before the first call. A no leaves nothing behind.
|
|
1457
|
+
if (opts.confirmSpend && hostedAny) {
|
|
1458
|
+
const estimate = spendEstimate({ root, architecture, ledger, profile, plan, exclusions, hunter, verifier, allowed });
|
|
1459
|
+
let ok = false;
|
|
1460
|
+
try { ok = await opts.confirmSpend(estimate); } catch (e) { fs.rmSync(dir, { recursive: true, force: true }); throw e; }
|
|
1461
|
+
if (!ok) {
|
|
1462
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1463
|
+
return { declined: true, dir: null, meta, ledger, records: [], estimate };
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1305
1467
|
const call = async (kind, jobs) => {
|
|
1306
1468
|
if (!jobs.length) return [];
|
|
1307
1469
|
meta.invocations.used += jobs.length;
|
|
1308
1470
|
meta.invocations[kind] += jobs.length;
|
|
1309
1471
|
writeJson(path.join(dir, 'run-metadata.json'), meta);
|
|
1310
1472
|
const answers = await runJobs(jobs, { root, concurrency, timeoutMs });
|
|
1473
|
+
for (const a of answers || []) {
|
|
1474
|
+
const c = Number(a && a.credits_charged);
|
|
1475
|
+
if (Number.isFinite(c) && c > 0) meta.credits_used += c;
|
|
1476
|
+
}
|
|
1477
|
+
writeJson(path.join(dir, 'run-metadata.json'), meta);
|
|
1311
1478
|
return answers;
|
|
1312
1479
|
};
|
|
1313
1480
|
|
|
@@ -1319,7 +1486,7 @@ async function runAudit(opts = {}) {
|
|
|
1319
1486
|
const failures = [];
|
|
1320
1487
|
const runCritic = async (wave, final) => {
|
|
1321
1488
|
criticsLeft -= 1;
|
|
1322
|
-
const [answer] = await call('critics', [{ engine: verifier, prompt: criticPrompt({ architecture, units: ledger, final }), label: `sentinel critic ${final ? 'final' : `w${wave}`}` }]);
|
|
1489
|
+
const [answer] = await call('critics', [{ engine: verifier, prompt: withHostedSource(root, verifier, criticPrompt({ architecture, units: ledger, final }), { kind: 'critic' }), max_output_tokens: isHostedEngine(verifier) ? HOSTED_OUTPUT_TOKENS.verifier : undefined, label: `sentinel critic ${final ? 'final' : `w${wave}`}` }]);
|
|
1323
1490
|
const parsed = answer && answer.ok !== false ? extractJson(answer.stdout, (v) => Array.isArray(v.missing_units) || Array.isArray(v.reassign_ids)) : null;
|
|
1324
1491
|
if (!parsed) return { accepted: 0, failed: true };
|
|
1325
1492
|
const fresh = (parsed.missing_units || []).map((raw) => proposedUnit(root, raw, { profile, scope })).filter(Boolean);
|
|
@@ -1340,11 +1507,11 @@ async function runAudit(opts = {}) {
|
|
|
1340
1507
|
const jobs = batches.map((units, i) => {
|
|
1341
1508
|
const agentId = `h${wave}-${String(i + 1).padStart(2, '0')}`;
|
|
1342
1509
|
for (const u of units) { u.status = 'in_progress'; u.agent_id = agentId; u.wave = wave; }
|
|
1343
|
-
return { engine: hunter, prompt: hunterPrompt({ architecture, units, exclusions, peerIds: waveUnits - units.length }), label: `sentinel hunter ${agentId}`, _units: units, _agent: agentId };
|
|
1510
|
+
return { engine: hunter, prompt: withHostedSource(root, hunter, hunterPrompt({ architecture, units: hostedUnits(units, hunterAllowed), exclusions, peerIds: waveUnits - units.length }), { files: units.flatMap((u) => u.starting_paths), kind: 'hunter', allowed: hunterAllowed }), max_output_tokens: isHostedEngine(hunter) ? HOSTED_OUTPUT_TOKENS.hunter : undefined, label: `sentinel hunter ${agentId}`, _units: units, _agent: agentId };
|
|
1344
1511
|
});
|
|
1345
1512
|
saveLedger();
|
|
1346
1513
|
log(`wave ${wave}: ${jobs.length} hunter${jobs.length === 1 ? '' : 's'} on ${jobs.reduce((n, j) => n + j._units.length, 0)} areas (${hunter})`);
|
|
1347
|
-
const answers = await call('hunters', jobs.map(({ engine, prompt, label }) => ({ engine, prompt, label })));
|
|
1514
|
+
const answers = await call('hunters', jobs.map(({ engine, prompt, label, max_output_tokens: m }) => ({ engine, prompt, label, ...(m ? { max_output_tokens: m } : {}) })));
|
|
1348
1515
|
const uncovered = [];
|
|
1349
1516
|
answers.forEach((answer, i) => {
|
|
1350
1517
|
const res = applyHunterResult(root, jobs[i]._units, answer, jobs[i]._agent, hunter);
|
|
@@ -1417,13 +1584,22 @@ async function runAudit(opts = {}) {
|
|
|
1417
1584
|
records.push(buildRecord(root, cand, 'rejected', { reason: `cited source does not resolve: ${errs.slice(0, 3).join('; ')}`, by: { engine: 'atris', agent_id: 'citation-check' } }));
|
|
1418
1585
|
continue;
|
|
1419
1586
|
}
|
|
1587
|
+
// A hosted verifier is only ever shown files this review may send. A
|
|
1588
|
+
// trace outside them (a model citing .env, or a path outside the scope)
|
|
1589
|
+
// never reaches it.
|
|
1590
|
+
const outside = verifierAllowed ? [...new Set((cand.trace || []).map((t) => t && t.file).filter((f) => !verifierAllowed.has(f)))] : [];
|
|
1591
|
+
if (outside.length) {
|
|
1592
|
+
if (cand._carried) pending.push(cand.fingerprint);
|
|
1593
|
+
else records.push(buildRecord(root, cand, 'rejected', { reason: `cites files this review may not send to atris models: ${outside.slice(0, 3).join(', ')}`, by: { engine: 'atris', agent_id: 'scope-check' } }));
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1420
1596
|
toVerify.push(cand);
|
|
1421
1597
|
}
|
|
1422
1598
|
const verifyBudget = Math.max(0, total - meta.invocations.used);
|
|
1423
1599
|
const verifyNow = toVerify.slice(0, verifyBudget);
|
|
1424
1600
|
const notVerified = toVerify.slice(verifyBudget);
|
|
1425
1601
|
log(`verifying ${verifyNow.length} candidate${verifyNow.length === 1 ? '' : 's'} with ${verifier}`);
|
|
1426
|
-
const vJobs = verifyNow.map((cand, i) => ({ engine: verifier, prompt: verifierPrompt({ candidate: { ...cand, trace: withCode(root, cand.trace), _carried: undefined, _unit_class: undefined, carried_from: undefined, verified_by: undefined }, cls: CLASS_BY_ID.get(cand.attack_class) }), label: `sentinel verifier v-${String(i + 1).padStart(2, '0')}` }));
|
|
1602
|
+
const vJobs = verifyNow.map((cand, i) => ({ engine: verifier, prompt: withHostedSource(root, verifier, verifierPrompt({ candidate: { ...cand, trace: withCode(root, cand.trace, verifierAllowed), _carried: undefined, _unit_class: undefined, carried_from: undefined, verified_by: undefined }, cls: CLASS_BY_ID.get(cand.attack_class) }), { trace: cand.trace, kind: 'verifier', allowed: verifierAllowed }), max_output_tokens: isHostedEngine(verifier) ? HOSTED_OUTPUT_TOKENS.verifier : undefined, label: `sentinel verifier v-${String(i + 1).padStart(2, '0')}` }));
|
|
1427
1603
|
const vAnswers = await call('verifiers', vJobs);
|
|
1428
1604
|
vAnswers.forEach((answer, i) => {
|
|
1429
1605
|
const cand = verifyNow[i];
|
|
@@ -1440,7 +1616,7 @@ async function runAudit(opts = {}) {
|
|
|
1440
1616
|
const confirmed = records.filter((r) => r.verdict === 'confirmed');
|
|
1441
1617
|
const room = Math.max(0, total - meta.invocations.used);
|
|
1442
1618
|
const checkNow = confirmed.slice(0, room);
|
|
1443
|
-
const rJobs = checkNow.map((rec, i) => ({ engine: verifier, prompt: recheckPrompt({ record: rec }), label: `sentinel recheck r-${String(i + 1).padStart(2, '0')}` }));
|
|
1619
|
+
const rJobs = checkNow.map((rec, i) => ({ engine: verifier, prompt: withHostedSource(root, verifier, recheckPrompt({ record: verifierAllowed ? { ...rec, trace: hostedTrace(rec.trace, verifierAllowed) } : rec }), { trace: rec.trace, kind: 'verifier', allowed: verifierAllowed }), max_output_tokens: isHostedEngine(verifier) ? HOSTED_OUTPUT_TOKENS.verifier : undefined, label: `sentinel recheck r-${String(i + 1).padStart(2, '0')}` }));
|
|
1444
1620
|
const rAnswers = await call('rechecks', rJobs);
|
|
1445
1621
|
rAnswers.forEach((answer, i) => {
|
|
1446
1622
|
const rec = checkNow[i];
|
|
@@ -1544,6 +1720,9 @@ module.exports = {
|
|
|
1544
1720
|
citationErrors,
|
|
1545
1721
|
decide,
|
|
1546
1722
|
runAudit,
|
|
1723
|
+
isHostedEngine,
|
|
1724
|
+
hostedAllowedFiles,
|
|
1547
1725
|
latestRun,
|
|
1726
|
+
repoName,
|
|
1548
1727
|
shouldFailAudit,
|
|
1549
1728
|
};
|