feat(budget,provenance): reasoning budget modes and JOASNSCOPE provenance

Budget (opt-in, unlimited by default so an un-budgeted run is unchanged):
- crates/harness/src/budget.rs — modes, phase shares, Token Governor
- CLI: --budget/--token-limit/--deep-test-limit/--coverage-first/
  --depth-first/--sample-per-route; same controls in the web wizard
- pipeline honours it: vote_n narrows, evidence rounds are capped

Run control parity in the web console:
- /pause in the REPL, backed by a pause gate in the model pool: in-flight
  agents finish, then the run holds with every finding kept
- POST /api/exploit/:id/{pause,continue,report} + GET .../log

Provenance (crates/harness/src/provenance.rs):
- JOASNSCOPE sigil leads every canary, so a marker found in a response,
  a log or someone else's report extracts whole and names its build
- per-build fingerprint, per-run id, optional per-customer build id
- findings.json stamped with _engine; signed provenance.json manifest
- structural signature survives rewording but not a changed result set
- prompts watermarked at the single pool chokepoint
- `neurosploit provenance show|scan|verify`

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-09-14 01:14:03 -03:00
co-authored by Claude Opus 5
parent 40b047b9e7
commit 408350539f
16 changed files with 1464 additions and 26 deletions
+53
View File
@@ -442,6 +442,19 @@ state.authMode = 'api';
// review (step 5)
// ---------------------------------------------------------------------------
/// What the budget controls add up to, in the operator's words. "unlimited"
/// is spelled out rather than left blank, because the absence of a cap is the
/// thing worth confirming before launching.
function budgetSummary() {
const mode = $('#fieldBudget').value;
const limit = Number($('#fieldTokenLimit').value) || 0;
if (mode === 'unlimited' && !limit) return 'unlimited — full run, no cap';
const parts = [mode, $('#fieldOrder').value];
if (limit) parts.push(`${limit.toLocaleString()} tokens max`);
parts.push(`${$('#fieldSampleRoute').value}/route`);
return parts.join(' · ');
}
function renderReview() {
const target = $('#fieldTarget').value.trim();
const repo = $('#fieldRepo').value.trim();
@@ -457,6 +470,7 @@ function renderReview() {
{ k: 'Leads selected', v: `${state.selected.size} of ${allAgents().length}${state.selected.size === 0 ? ' — auto (recon-driven)' : ''}` },
{ k: 'Custom leads', v: String(state.customLeads.length) },
{ k: 'Votes / chain / recon', v: `${$('#fieldVotes').value} / ${$('#fieldChain').value} / ${$('#fieldRecon').value}` },
{ k: 'Budget', v: budgetSummary() },
{ k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') },
];
$('#reviewGrid').innerHTML = items.map((it) => `
@@ -495,6 +509,12 @@ async function startExploitation() {
focus: focusParts.join('; ') || undefined,
objective: $('#fieldObjective').value.trim() || undefined,
outOfScope: $('#fieldOutOfScope').value.trim() || undefined,
// Budget is opt-in: 'unlimited' sends nothing, so a run nobody budgeted is
// the same full run it was before this control existed.
budget: $('#fieldBudget').value,
tokenLimit: Number($('#fieldTokenLimit').value) || undefined,
order: $('#fieldOrder').value,
samplePerRoute: Number($('#fieldSampleRoute').value) || undefined,
auth: state.auth.header || undefined,
roles: state.auth.roles.length ? state.auth.roles : undefined,
creds: state.credsPath || undefined,
@@ -762,6 +782,7 @@ function applySnapshot(snap) {
state.currentJob.pinnedAgents = snap.pinnedAgents;
updatePinnedLine();
}
if (state.currentJob) state.currentJob.phase = snap.phase;
$('#progressLabel').textContent = `${snap.agentsDone} / ${snap.agents || '?'} agents`;
$('#progressBar').classList.toggle('indeterminate', !snap.agents);
if (snap.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`;
@@ -769,6 +790,12 @@ function applySnapshot(snap) {
$('#btnOpenReport').href = `/api/runs/${snap.runId}/asset/report.html`;
show($('#btnOpenReport'), true);
}
const paused = (snap.phase || '').startsWith('paused');
$('#btnPauseRun').textContent = paused ? '▶ Continue' : '⏸ Pause';
$('#btnPauseRun').classList.toggle('btn-warn', paused);
$('#btnPauseRun').disabled = !!snap.done || !snap.interactive;
$('#btnReportNow').disabled = !snap.interactive;
$('#btnDownloadLog').href = `/api/exploit/${snap.id}/log`;
if (snap.done) $('#phaseDot').classList.add('static');
}
@@ -776,6 +803,32 @@ $('#btnStopRun').addEventListener('click', async () => {
if (!state.currentJob) return;
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
});
// Pause is a toggle against the run's own phase, so the button always says
// what pressing it will do rather than what the run currently is.
$('#btnPauseRun').addEventListener('click', async () => {
if (!state.currentJob) return;
const paused = (state.currentJob.phase || '').startsWith('paused');
const verb = paused ? 'continue' : 'pause';
try {
await api(`/api/exploit/${state.currentJob.id}/${verb}`, { method: 'POST' });
toast(paused ? 'Resuming the run.' : 'Pausing — in-flight agents finish first, findings are kept.', 'ok');
} catch (e) {
toast(e.message, 'error', 8000);
}
});
// Report from where it stopped: the REPL's /report writes from the evidence on
// disk, so a run that is paused, stalled or simply long can be read now.
$('#btnReportNow').addEventListener('click', async () => {
if (!state.currentJob) return;
try {
await api(`/api/exploit/${state.currentJob.id}/report`, { method: 'POST' });
toast('Building a report from what has been found so far — it appears above when written.', 'ok', 7000);
} catch (e) {
toast(e.message, 'error', 8000);
}
});
function leaveLiveJob() {
localStorage.removeItem(ACTIVE_JOB_KEY);
clearInterval(state.currentJob?.pocPoll);
+28
View File
@@ -215,6 +215,31 @@
<div class="field-help">Deeper recon finds more surface but costs more tokens.</div>
</div>
</div>
<div>
<div class="section-title">Reasoning budget</div>
<div class="section-desc">Optional. Left on <em>unlimited</em>, the run behaves exactly as it always has — full depth, no cap.</div>
</div>
<div class="field-row">
<div class="field-group"><label class="field-label" for="fieldBudget">Budget mode</label>
<select class="narrow" id="fieldBudget">
<option value="unlimited" selected>unlimited · full run (default)</option>
<option value="eco">eco · cover ground, reason only on strong signal</option>
<option value="balanced">balanced · investigate what looks suspicious</option>
<option value="aggressive">aggressive · multiple hypotheses, deep validation</option>
</select>
<div class="field-help">How compute is spent, not how much there is.</div>
</div>
<div class="field-group"><label class="field-label" for="fieldTokenLimit">Token limit</label><input class="narrow" id="fieldTokenLimit" type="number" min="0" step="10000" placeholder="none" /><div class="field-help">Hard ceiling for the run. Empty or 0 = none.</div></div>
<div class="field-group"><label class="field-label" for="fieldOrder">Order</label>
<select class="narrow" id="fieldOrder">
<option value="coverage-first" selected>coverage-first</option>
<option value="depth-first">depth-first</option>
</select>
<div class="field-help">Map everything first, or chase a lead as it appears.</div>
</div>
<div class="field-group"><label class="field-label" for="fieldSampleRoute">Sample / route</label><input class="narrow" id="fieldSampleRoute" type="number" min="1" max="50" value="3" /><div class="field-help">Requests per endpoint family (<code>/api/users/{id}</code> is sampled, not enumerated).</div></div>
</div>
</div>
<!-- Step 5 — Review -->
@@ -250,6 +275,9 @@
<div class="run-actions">
<button class="btn" id="btnOpenTerm3">❭_ Terminal</button>
<a class="btn" id="btnOpenReport" target="_blank" hidden>Open report</a>
<button class="btn" id="btnPauseRun" title="Hold the run where it is — in-flight agents finish, nothing is lost">⏸ Pause</button>
<a class="btn" id="btnDownloadLog" download>Download log</a>
<button class="btn" id="btnReportNow" title="Write the report from what has been found so far">Report so far</button>
<button class="btn btn-danger" id="btnStopRun">Stop</button>
<button class="btn" id="btnBackToBoard">← New engagement</button>
</div>
+5
View File
@@ -274,7 +274,12 @@ a { color: var(--accent); text-decoration: none; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger { background: transparent; border-color: var(--sev-critical-fg); color: var(--sev-critical-fg); }
.btn-danger:hover { background: var(--sev-critical-bg); }
/* A paused run is a state to notice, not an error — the resume button wears
the warning hue so the run never looks quietly stalled. */
.btn-warn { background: var(--sev-medium-bg); border-color: var(--sev-medium-fg); color: var(--sev-medium-fg); }
.btn-warn:hover { background: var(--sev-medium-bg); filter: brightness(1.12); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
a.btn:disabled, a.btn[aria-disabled="true"] { pointer-events: none; }
.btn:focus-visible { outline-offset: 2px; }
/* ============================================================ Wizard */
+47 -1
View File
@@ -529,7 +529,9 @@ function ingestLine(job, rawLine) {
const low = line.toLowerCase();
job.push({ type: 'log', line });
if (low.includes('token/quota exhausted') || low.includes('run is paused')) job.phase = 'paused (quota)';
if (low.includes('paused by operator')) job.phase = 'paused (operator)';
else if (low.includes('resumed by operator') || low.includes('▶ resumed')) job.phase = 'running';
else if (low.includes('token/quota exhausted') || low.includes('run is paused')) job.phase = 'paused (quota)';
else if (low.includes('authentication failed') || low.includes('circuit breaker')) job.phase = 'paused (auth)';
else if (low.startsWith('recon') || low.startsWith('ai-recon') || low.includes('recon round') || low.startsWith('probe:')) job.phase = 'recon';
else if (low.includes('selected') && low.includes('agent')) {
@@ -588,6 +590,14 @@ function buildArgs(body) {
if (body.focus) args.push('--focus', body.focus);
if (body.objective) args.push('--objective', body.objective);
if (body.outOfScope) args.push('--out-of-scope', body.outOfScope);
// Budget: omitted entirely means the full run, exactly as before budgets
// existed — the web console never caps a run the operator didn't cap.
if (body.budget && body.budget !== 'unlimited') args.push('--budget', body.budget);
if (body.tokenLimit) args.push('--token-limit', String(body.tokenLimit));
if (body.deepTestLimit) args.push('--deep-test-limit', String(body.deepTestLimit));
if (body.order === 'depth-first') args.push('--depth-first');
else if (body.order === 'coverage-first') args.push('--coverage-first');
if (body.samplePerRoute) args.push('--sample-per-route', String(body.samplePerRoute));
// Authorization: the signed grant caps the scope, the extra in-scope entries
// can only narrow within it, and the environment scales every risk score.
for (const entry of body.inScope || []) args.push('--in-scope', entry);
@@ -650,6 +660,12 @@ function authArgs(body) {
if (body.capability) args.push('--capability-token', body.capability);
if (body.environment) args.push('--environment', body.environment);
if (body.policyProfile) args.push('--policy', body.policyProfile);
if (body.budget && body.budget !== 'unlimited') args.push('--budget', body.budget);
if (body.tokenLimit) args.push('--token-limit', String(body.tokenLimit));
if (body.deepTestLimit) args.push('--deep-test-limit', String(body.deepTestLimit));
if (body.order === 'depth-first') args.push('--depth-first');
else if (body.order === 'coverage-first') args.push('--coverage-first');
if (body.samplePerRoute) args.push('--sample-per-route', String(body.samplePerRoute));
return args;
}
@@ -964,6 +980,36 @@ const server = http.createServer(async (req, res) => {
}
return sendJson(res, 200, { ok: true });
}
// Pause / resume / report-where-it-stopped. All three are REPL commands,
// so they only exist on a REPL-backed job — a one-shot CLI subprocess has
// no stdin listener to take them.
m = p.match(/^\/api\/exploit\/([^/]+)\/(pause|continue|report)$/);
if (req.method === 'POST' && m) {
const job = jobs.get(m[1]);
if (!job) return sendJson(res, 404, { error: 'job not found' });
if (!job.repl || !job.child?.stdin?.writable) {
return sendJson(res, 409, { error: 'this job is not an interactive session — pause/continue/report need a REPL-backed run (run, whitebox or greybox)' });
}
const cmd = { pause: '/pause', continue: '/continue', report: '/report' }[m[2]];
job.child.stdin.write(cmd + '\n');
if (m[2] === 'pause') job.phase = 'paused (operator)';
else if (m[2] === 'continue' && job.phase.startsWith('paused')) job.phase = 'resuming';
return sendJson(res, 200, { ok: true, sent: cmd });
}
// The whole log, as text — for downloading or pasting into a ticket.
m = p.match(/^\/api\/exploit\/([^/]+)\/log$/);
if (req.method === 'GET' && m) {
const job = jobs.get(m[1]);
if (!job) return sendJson(res, 404, { error: 'job not found' });
const body = job.feed.filter((e) => e.type === 'log').map((e) => e.line).join('\n') + '\n';
res.writeHead(200, {
'content-type': 'text/plain; charset=utf-8',
'content-disposition': `attachment; filename="neurosploit-${job.runId || job.id}.log"`,
});
return res.end(body);
}
m = p.match(/^\/api\/exploit\/([^/]+)\/input$/);
if (req.method === 'POST' && m) {
const job = jobs.get(m[1]);