mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 20:41:51 +02:00
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:
co-authored by
Claude Opus 5
parent
40b047b9e7
commit
408350539f
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user