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.
@@ -39,4 +39,23 @@ function memberProcessPrompt(cwd = process.cwd()) {
39
39
  }
40
40
  }
41
41
 
42
- module.exports = { memberProcessPrompt, MEMBER_PROCESS_PATH, MAX_MEMBER_PROCESS_BYTES };
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 };
@@ -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: '', description: '', attack_class: classes[0].id,
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
  };