@clear-capabilities/agentic-security-scanner 0.195.21 → 0.195.23
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/CHANGELOG.md +26 -0
- package/bin/agentic-security.js +5 -2
- package/dist/{7feb08356355c30181a1.json → 01087ded263953062654.json} +3 -3
- package/dist/550.index.js +9 -8
- package/dist/5620.index.js +59 -10
- package/dist/5937.index.js +88 -16
- package/dist/7838.index.js +32 -5
- package/dist/9441.index.js +50 -7
- package/dist/9824.index.js +19 -13
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/discovery/CLAUDE.md +4 -0
- package/src/discovery/detector-spec-synthesis.js +203 -0
- package/src/discovery/llm-invoke.js +3 -3
- package/src/egress/transport.js +148 -0
- package/src/engine.js +9 -0
- package/src/integrations/index.js +18 -9
- package/src/llm-validator/index.js +4 -2
- package/src/llm-validator/local-endpoint.js +4 -1
- package/src/llm-validator/ollama-provider.js +8 -6
- package/src/pipeline/scan-health.js +15 -2
- package/src/posture/CLAUDE.md +1 -0
- package/src/posture/adversary-agent.js +27 -10
- package/src/posture/assurance/config.js +2 -0
- package/src/posture/assurance/decision.js +45 -0
- package/src/posture/assurance/rollout.js +3 -0
- package/src/posture/custom-rules.js +13 -1
- package/src/posture/fleet/cli.js +2 -4
- package/src/posture/fleet/fleet-manifest.js +4 -1
- package/src/posture/fleet/job-runner.js +28 -4
- package/src/posture/fleet/org-health.js +23 -0
- package/src/posture/fleet/scan-cache.js +1 -1
- package/src/posture/flow-narration.js +10 -8
- package/src/posture/llm-redteam.js +3 -1
- package/src/posture/provenance/coordinator.js +8 -0
- package/src/posture/provenance/origin-resolver.js +17 -4
- package/src/posture/rule-compiler/cli.js +56 -8
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/ir.js +10 -1
- package/src/posture/rule-compiler/promotion.js +12 -2
- package/src/posture/rule-compiler/regression.js +1 -1
- package/src/posture/rule-compiler/store.js +27 -7
- package/src/posture/strategic-metrics.js +50 -7
- package/src/report/index.js +7 -6
- package/src/sca/llm-function-extract.js +17 -13
|
@@ -206,6 +206,14 @@ function describePartial(isScaLike, reason) {
|
|
|
206
206
|
reasons: ['rename_detected_not_followed'],
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
|
+
// GA-05: the parent of the candidate commit could not be inspected (the replay there failed), so absence in it is unproven.
|
|
210
|
+
// This is not "we could not see far enough" (shallow) and not "it was never true" (never confirmed): it is "we do not know".
|
|
211
|
+
if (reason === 'parent-not-inspectable') {
|
|
212
|
+
return {
|
|
213
|
+
limitation: 'a candidate commit was found but the replay in its parent failed, so absence there is unproven; the introducing commit is the earliest observable, not a verified boundary',
|
|
214
|
+
reasons: ['parent_not_inspectable'],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
209
217
|
return {
|
|
210
218
|
limitation: reason
|
|
211
219
|
? `earliest observable — history could not confirm a verified parent boundary (${reason})`
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
import { candidateCommitsForLine, candidateCommitsForFiles, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
|
|
27
27
|
import { replayAt } from './predicate-replay.js';
|
|
28
|
+
|
|
29
|
+
/** Test seam: a replacement `replayAt` (same signature) so a replay failure in a parent commit can be produced on demand. */
|
|
30
|
+
export const _replayHooks = { replayAt: null };
|
|
28
31
|
import { PROVENANCE_METHOD } from './schema.js';
|
|
29
32
|
import { checkAbsentInSomeParent, detectRevert, detectCherryPick } from './dag-walk.js';
|
|
30
33
|
import { loadRepoLineage } from './repo-lineage.js';
|
|
@@ -230,7 +233,7 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
|
|
|
230
233
|
const replayCache = new Map();
|
|
231
234
|
const replay = (sha) => {
|
|
232
235
|
if (replayCache.has(sha)) return replayCache.get(sha);
|
|
233
|
-
const p = replayAt(scanRoot, sha, files, stableId, { deep: needsDeepReplay(finding) });
|
|
236
|
+
const p = (_replayHooks.replayAt || replayAt)(scanRoot, sha, files, stableId, { deep: needsDeepReplay(finding) });
|
|
234
237
|
replayCache.set(sha, p);
|
|
235
238
|
return p;
|
|
236
239
|
};
|
|
@@ -286,13 +289,23 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
|
|
|
286
289
|
}
|
|
287
290
|
|
|
288
291
|
const presentInParent = await replay(parent);
|
|
289
|
-
|
|
290
|
-
|
|
292
|
+
if (presentInParent.present) continue; // predicate already true in parent — keep walking older candidates
|
|
293
|
+
// GA-05: `present: false` is proof of absence only when the parent WAS inspected and the predicate did not reproduce
|
|
294
|
+
// (`stableId-not-reproduced`), or the file did not exist there at all (`no-files-at-commit`: a file that is new in this
|
|
295
|
+
// commit). A parent the replay could not inspect (`replay-error`) proves nothing, so the boundary is not verified and the
|
|
296
|
+
// answer is `partial` with that reason, never `complete`.
|
|
297
|
+
if (presentInParent.reason === 'replay-error') {
|
|
298
|
+
return {
|
|
299
|
+
status: 'partial', reason: 'parent-not-inspectable', commitsConsidered,
|
|
300
|
+
findingOrigin: originFrom(meta, { absentInParents: [] }),
|
|
301
|
+
method: PROVENANCE_METHOD.SEMANTIC_REPLAY, parentBoundaryVerified: false,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
291
304
|
|
|
292
305
|
return {
|
|
293
306
|
status: 'complete', method: PROVENANCE_METHOD.SEMANTIC_REPLAY, commitsConsidered,
|
|
294
307
|
findingOrigin: originFrom(meta, { absentInParents: [parent] }),
|
|
295
|
-
parentBoundaryVerified: true,
|
|
308
|
+
parentBoundaryVerified: true, absenceEvidence: presentInParent.reason,
|
|
296
309
|
};
|
|
297
310
|
}
|
|
298
311
|
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
// model call — this is the deterministic half; a model may PROPOSE the candidate pattern this CLI is handed, but
|
|
9
9
|
// it never decides promotion).
|
|
10
10
|
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
11
13
|
import { statePath } from '../state-dir.js';
|
|
12
14
|
import { packTrust, signPublishedRule } from './store.js';
|
|
13
|
-
import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules } from './regression.js';
|
|
15
|
+
import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules, promotedRuleIds } from './regression.js';
|
|
14
16
|
import { draftRuleIR, validateRuleIR, ruleIRDigest } from './ir.js';
|
|
15
17
|
import { checkEligibility } from './eligibility.js';
|
|
16
18
|
import { buildCorpus } from './corpus.js';
|
|
@@ -35,6 +37,12 @@ function readJsonArg(pathOrInline) {
|
|
|
35
37
|
|
|
36
38
|
export async function cmdRules(args, root) {
|
|
37
39
|
const sub = args._[1];
|
|
40
|
+
// GA-07: a ledger that failed its integrity check (broken chain, bad or missing signature) refuses EVERY command, not only the
|
|
41
|
+
// ones that change state; nothing below can read a tampered history as fact or write over it.
|
|
42
|
+
if (['synthesize', 'evaluate', 'promote', 'status', 'rollback', 'cost', 'regress'].includes(sub)) {
|
|
43
|
+
const guard = loadLedger(root);
|
|
44
|
+
if (guard.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${guard.tamperReason}).`); return 4; }
|
|
45
|
+
}
|
|
38
46
|
if (sub === 'synthesize') return cmdSynthesize(args, root);
|
|
39
47
|
if (sub === 'evaluate') return cmdEvaluate(args, root);
|
|
40
48
|
if (sub === 'promote') return cmdPromote(args, root);
|
|
@@ -42,7 +50,7 @@ export async function cmdRules(args, root) {
|
|
|
42
50
|
if (sub === 'rollback') return cmdRollback(args, root);
|
|
43
51
|
if (sub === 'cost') return cmdCost(args, root);
|
|
44
52
|
if (sub === 'regress') return cmdRegress(args, root);
|
|
45
|
-
console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> | status [rule-id] | rollback <rule-id> | cost <rule-id>');
|
|
53
|
+
console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> --reviewed-by <person> [--scope <text>] | status [rule-id] | rollback <rule-id> | cost <rule-id> | regress [rule-id]');
|
|
46
54
|
return 4;
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -61,7 +69,7 @@ async function cmdSynthesize(args, root) {
|
|
|
61
69
|
// The candidate pattern is the model's or the person's, when they gave one. Without one, or with --auto, the synthesis loop proposes
|
|
62
70
|
// from the vulnerable and patched code, scores each attempt on the real corpus and its mutations, and revises (see proposal.js).
|
|
63
71
|
let finalSpec = spec; let synthesis = null;
|
|
64
|
-
const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [] };
|
|
72
|
+
const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [], independentVulnerableCode: spec.independentVulnerableCode ?? [], independentCleanCode: spec.independentCleanCode ?? [] };
|
|
65
73
|
const leak = checkLeakage(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
|
|
66
74
|
if (!leak.ok) { console.error(`Refusing: the material for this rule shows benchmark or answer-key signals (${leak.offendingTerms.join('; ')}). Nothing was written.`); return 4; }
|
|
67
75
|
if (args.flags.auto || (!spec.match && !spec.sink)) {
|
|
@@ -120,7 +128,7 @@ async function cmdEvaluate(args, root) {
|
|
|
120
128
|
originatingFinding: ir.provenance.originatingFindingId, originatingCommit: ir.provenance.originatingCommit, evidenceState: ir.provenance.evidenceState,
|
|
121
129
|
generatingModel: ir.provenance.model, promptConfigVersion: ir.provenance.promptConfigVersion,
|
|
122
130
|
positiveCorpusHash: corpusHash(corpus.positives), negativeCorpusHash: corpusHash(corpus.negatives),
|
|
123
|
-
benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
|
|
131
|
+
benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, independent: gate.independent, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
|
|
124
132
|
leakageCheck: leakage, mutatorCoverage: mutatorCoverage(corpusSpec.originalCode), discoveryCost: corpusSpec.discoveryCost ?? prev?.discoveryCost ?? null,
|
|
125
133
|
promotionEvent: prev?.promotionEvent ?? null, evaluatedAt: new Date().toISOString(),
|
|
126
134
|
});
|
|
@@ -136,23 +144,63 @@ async function cmdEvaluate(args, root) {
|
|
|
136
144
|
function isStr(v) { return typeof v === 'string' && v.length > 0; }
|
|
137
145
|
const fmtPct = (n) => (n == null ? 'unmeasured' : `${(n * 100).toFixed(1)}%`);
|
|
138
146
|
|
|
147
|
+
/** Does this commit exist in the project's history? `true`, `false`, or `null` when the project is not a git repository. */
|
|
148
|
+
function commitExists(root, sha) {
|
|
149
|
+
if (!fs.existsSync(path.join(root, '.git'))) return null;
|
|
150
|
+
try { execFileSync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: root, stdio: 'ignore' }); return true; } catch { return false; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const NOT_A_REVIEWER = /^(?:agent|model|auto|bot|llm|ai)$/i;
|
|
154
|
+
|
|
155
|
+
// Promotion is a reviewed, human decision (GA-07). It needs the reviewer's name, a rule that is exactly the one that was evaluated, a
|
|
156
|
+
// provenance that checks out, and no conflict with a rule that already exists; a model's recommendation on its own promotes nothing.
|
|
139
157
|
async function cmdPromote(args, root) {
|
|
140
158
|
const id = args._[2];
|
|
141
|
-
if (!id) { console.error('usage: rules promote <rule-id>'); return 4; }
|
|
159
|
+
if (!id) { console.error('usage: rules promote <rule-id> --reviewed-by <person> [--scope <text>] [--reason <text>]'); return 4; }
|
|
160
|
+
const reviewer = args.flags['reviewed-by'];
|
|
161
|
+
if (!isStr(reviewer) || NOT_A_REVIEWER.test(reviewer.trim())) {
|
|
162
|
+
console.error('usage: rules promote <rule-id> --reviewed-by <person>. A promotion records the person who reviewed the rule and its scope; a model or agent recommendation is not a review, so nothing is promoted without a named reviewer.');
|
|
163
|
+
return 4;
|
|
164
|
+
}
|
|
142
165
|
const ledger = loadLedger(root);
|
|
143
166
|
if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
|
|
144
167
|
if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
|
|
145
168
|
const ir = loadIR(root, id);
|
|
146
169
|
if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
|
|
170
|
+
// the rule promoted must be the rule evaluated: an IR edited after `rules evaluate` has no evidence
|
|
171
|
+
const prov = loadProvenance(root, id);
|
|
172
|
+
if (!prov) { console.error(`Promotion refused: no evaluation record for ${id}; run rules evaluate first.`); return 4; }
|
|
173
|
+
if (prov.ruleHash !== ruleIRDigest(ir)) { console.error(`Promotion refused: the rule ${id} changed after it was evaluated (evaluated ${String(prov.ruleHash).slice(0, 12)}, now ${ruleIRDigest(ir).slice(0, 12)}); run rules evaluate again.`); return 4; }
|
|
174
|
+
// a named originating commit must be real history, not a typed-in string
|
|
175
|
+
const commit = ir.provenance?.originatingCommit;
|
|
176
|
+
if (commit) {
|
|
177
|
+
const exists = commitExists(root, commit);
|
|
178
|
+
if (exists === false) { console.error(`Promotion refused: originating commit ${commit} is not in this repository's history (invalid provenance).`); return 4; }
|
|
179
|
+
if (exists === null) { console.error(`Promotion refused: originating commit ${commit} cannot be verified because this project is not a git repository.`); return 4; }
|
|
180
|
+
}
|
|
181
|
+
// conflicts: a hand-written rule file with the same id, or a promoted rule with the identical match
|
|
182
|
+
const live = statePath(root, 'rules', `${id}.yml`);
|
|
183
|
+
let firstLine = null; // null: no file there (or unreadable, which is treated as absent below only when the read fails with ENOENT)
|
|
184
|
+
try { firstLine = fs.readFileSync(live, 'utf8').split('\n')[0]; } catch (e) { if (e.code !== 'ENOENT') firstLine = ''; }
|
|
185
|
+
if (firstLine !== null && !firstLine.startsWith('# Compiled by the rule compiler')) { console.error(`Promotion refused: a rule file that the compiler did not write already exists at ${live}; choose another rule id or remove that file deliberately.`); return 4; }
|
|
186
|
+
for (const other of promotedRuleIds(ledger)) {
|
|
187
|
+
if (other === id) continue;
|
|
188
|
+
const oir = loadIR(root, other);
|
|
189
|
+
if (oir && JSON.stringify(oir.match) === JSON.stringify(ir.match) && JSON.stringify(oir.languages) === JSON.stringify(ir.languages)) {
|
|
190
|
+
console.error(`Promotion refused: ${id} duplicates the promoted rule ${other} (identical match on the same languages); roll one back or narrow this one.`);
|
|
191
|
+
return 4;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
147
194
|
// other rules may have been promoted since this one was evaluated: hold it to the false-positive corpus as it is NOW
|
|
148
195
|
const fpNow = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
149
196
|
if (fpNow.length) { console.error(`Promotion refused: ${id} fires on ${fpNow.length} item(s) of the shared false-positive corpus (${fpNow.slice(0, 3).map((h) => h.label).join(', ')}). Re-evaluate or narrow the rule.`); return 1; }
|
|
150
197
|
const pub = publishCustomRule(root, ir);
|
|
151
198
|
if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
|
|
152
|
-
const
|
|
199
|
+
const scope = isStr(args.flags.scope) ? args.flags.scope : null;
|
|
200
|
+
const review = { reviewer: reviewer.trim(), scope, independent: prov.benchmarkResults?.independent ?? null };
|
|
201
|
+
const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', evidence: review, actor: args.flags.actor || reviewer.trim() });
|
|
153
202
|
saveLedger(root, ledger);
|
|
154
|
-
|
|
155
|
-
if (prov) saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || 'operator', reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
|
|
203
|
+
saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || reviewer.trim(), reviewer: reviewer.trim(), scope, reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
|
|
156
204
|
console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
|
|
157
205
|
// Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
|
|
158
206
|
// one with the operator's key when there is one, and say plainly whether the scanner will load it.
|
|
@@ -20,11 +20,15 @@ function toRuntimeRule(ir) {
|
|
|
20
20
|
|
|
21
21
|
const EXT = { javascript: 'x.js', typescript: 'x.ts', python: 'x.py', go: 'x.go', ruby: 'x.rb', java: 'x.java', rust: 'x.rs', csharp: 'x.cs', php: 'x.php', any: 'x.txt' };
|
|
22
22
|
|
|
23
|
-
export function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [] } = {}) {
|
|
23
|
+
export function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [], independentVulnerableCode = [], independentCleanCode = [] } = {}) {
|
|
24
24
|
const positives = [{ label: 'original', code: originalCode }];
|
|
25
25
|
for (const v of generateVariants(originalCode)) positives.push({ label: `sibling:${v.mutator}`, code: v.code });
|
|
26
|
+
// Independent examples (GA-07): vulnerable and safe code that is NOT the source finding and NOT a mutation of it, supplied by the
|
|
27
|
+
// reviewer from other places. They are never mutated, so they score as what they are: evidence from outside the rule's own material.
|
|
28
|
+
independentVulnerableCode.forEach((code, i) => positives.push({ label: `independent-vulnerable-${i}`, code }));
|
|
26
29
|
|
|
27
30
|
const negatives = [];
|
|
31
|
+
independentCleanCode.forEach((code, i) => negatives.push({ label: `independent-clean-${i}`, code }));
|
|
28
32
|
if (patchedCode) {
|
|
29
33
|
negatives.push({ label: 'patched', code: patchedCode });
|
|
30
34
|
for (const v of generateVariants(patchedCode)) negatives.push({ label: `patched-sibling:${v.mutator}`, code: v.code });
|
|
@@ -60,6 +64,10 @@ export function scoreRule(ir, corpus) {
|
|
|
60
64
|
const recall = corpus.positives.length === 0 ? null : tp / corpus.positives.length;
|
|
61
65
|
// "Protected negative failures" per PRD 3: a rule firing on a sanitizer/guard-carrying variant specifically.
|
|
62
66
|
const protectedFailures = negResults.filter((n) => n.label.startsWith('protected-') && n.fired).length;
|
|
67
|
+
// Independent evidence, counted apart: how many examples from outside the finding's own material there were, and how they scored.
|
|
68
|
+
const indPos = posResults.filter((p) => p.label.startsWith('independent-vulnerable-'));
|
|
69
|
+
const indNeg = negResults.filter((n) => n.label.startsWith('independent-clean-'));
|
|
70
|
+
const independent = { positives: indPos.length, negatives: indNeg.length, detected: indPos.filter((p) => p.fired).length, falseAlarms: indNeg.filter((n) => n.fired).length };
|
|
63
71
|
|
|
64
|
-
return { precision, recall, tp, fp, tn, fn, protectedFailures, positives: posResults, negatives: negResults };
|
|
72
|
+
return { precision, recall, tp, fp, tn, fn, protectedFailures, independent, positives: posResults, negatives: negResults };
|
|
65
73
|
}
|
|
@@ -74,9 +74,18 @@ export function validateRuleIR(ir) {
|
|
|
74
74
|
return errs;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** JSON with every object's keys sorted at every depth, so the digest does not depend on insertion order. */
|
|
78
|
+
function stableStringify(v) {
|
|
79
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
|
|
80
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`;
|
|
81
|
+
return JSON.stringify(v);
|
|
82
|
+
}
|
|
83
|
+
|
|
77
84
|
export function ruleIRDigest(ir) {
|
|
78
85
|
const { state: _s, ...rest } = ir;
|
|
79
|
-
|
|
86
|
+
// Until 0.195.23 this passed the TOP-LEVEL key list as the JSON replacer, which also filters nested objects: `match.pattern` and
|
|
87
|
+
// every structural regex were serialized as `{}`, so the digest never covered the one thing a rule is. Sorted at every depth now.
|
|
88
|
+
return sha256(stableStringify(rest));
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
/** Build a DRAFT rule IR. Caller supplies the candidate pattern — this module never invents one. */
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
// actually recorded, and nothing in this module writes to it.
|
|
5
5
|
import { scoreRule } from './corpus.js';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Since 0.195.23 (GA-07) promotion also needs independent evidence: at least one vulnerable and one safe example that are not the
|
|
8
|
+
// source finding or a mutation of it, and the rule must detect every independent vulnerable example and stay quiet on every
|
|
9
|
+
// independent safe one. The synthesis loop still scores attempts without them (`evaluateRule`); only the promotion decision
|
|
10
|
+
// (`gatePromotion`) requires them, so a candidate can be proposed before a reviewer has gathered the outside examples.
|
|
11
|
+
export const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall: 0.80, maxProtectedFailures: 0, minIndependentVulnerable: 1, minIndependentClean: 1 });
|
|
8
12
|
|
|
9
13
|
// Deliberately independent of posture/evaluation/custody.js: that module's term list and guard are reserved for
|
|
10
14
|
// the evaluation custodian's sealed-label boundary (QA-002.AC03), and a test enforces that nothing outside
|
|
@@ -59,5 +63,11 @@ export function gatePromotion({ ir, corpus, leakageCheck, thresholds } = {}) {
|
|
|
59
63
|
const evalResult = evaluateRule(ir, corpus, thresholds);
|
|
60
64
|
const reasons = [...evalResult.reasons];
|
|
61
65
|
if (!leakageCheck || leakageCheck.ok !== true) reasons.push(leakageCheck ? `leakage check failed: ${leakageCheck.offendingTerms.join(', ')}` : 'leakage check was not run');
|
|
62
|
-
|
|
66
|
+
const t = evalResult.thresholds;
|
|
67
|
+
const ind = evalResult.score.independent ?? { positives: 0, negatives: 0, detected: 0, falseAlarms: 0 };
|
|
68
|
+
if (ind.positives < t.minIndependentVulnerable) reasons.push(`${ind.positives} independent vulnerable example(s) beyond the source finding and its mutations (min ${t.minIndependentVulnerable}): supply independentVulnerableCode`);
|
|
69
|
+
else if (ind.detected < ind.positives) reasons.push(`misses ${ind.positives - ind.detected} of ${ind.positives} independent vulnerable example(s)`);
|
|
70
|
+
if (ind.negatives < t.minIndependentClean) reasons.push(`${ind.negatives} independent safe example(s) (min ${t.minIndependentClean}): supply independentCleanCode`);
|
|
71
|
+
else if (ind.falseAlarms > 0) reasons.push(`fires on ${ind.falseAlarms} of ${ind.negatives} independent safe example(s)`);
|
|
72
|
+
return { pass: reasons.length === 0, reasons, evaluation: evalResult, independent: ind };
|
|
63
73
|
}
|
|
@@ -41,7 +41,7 @@ function operatorFalsePositives(root) {
|
|
|
41
41
|
return out;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function promotedRuleIds(ledger) {
|
|
44
|
+
export function promotedRuleIds(ledger) {
|
|
45
45
|
return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
|
|
9
9
|
import { newLedger, verifyLedger } from './lifecycle.js';
|
|
10
|
+
import { signLastScan, verifyLastScan } from '../integrity.js';
|
|
10
11
|
import { compileToCustomRule } from './ir.js';
|
|
11
12
|
import { verifyRulePack, loadTrustedKeys, signRulePack } from '../rule-pack-signing.js';
|
|
12
13
|
|
|
@@ -15,20 +16,39 @@ const EXT = { javascript: 'js', typescript: 'ts', python: 'py', go: 'go', ruby:
|
|
|
15
16
|
|
|
16
17
|
function ruleDir(scanRoot, id) { return statePath(scanRoot, DIR, id); }
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The lifecycle ledger is hash-chained (lifecycle.js) AND signed with the per-install HMAC key (the same key as last-scan.json),
|
|
21
|
+
* since 0.195.23 (GA-07): the chain alone could be recomputed by anyone who could edit the file. A ledger that fails either check,
|
|
22
|
+
* or that cannot be parsed, is returned WITH `tampered: true` and its reason, and `saveLedger` refuses to write it, so a bad ledger
|
|
23
|
+
* is never quietly replaced by a fresh one and its history lost. An unsigned ledger with entries (written before 0.195.23) is
|
|
24
|
+
* accepted only with AGENTIC_SECURITY_RULES_UNSIGNED=1, the same switch that accepts an unsigned rules override, and is signed on
|
|
25
|
+
* its next save.
|
|
26
|
+
*/
|
|
27
|
+
export function loadLedger(scanRoot, env = process.env) {
|
|
19
28
|
const f = statePath(scanRoot, DIR, 'ledger.json');
|
|
20
29
|
if (!fs.existsSync(f)) return newLedger();
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
let text;
|
|
31
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `cannot read the ledger: ${e.message}` }; }
|
|
32
|
+
let ledger;
|
|
33
|
+
try { ledger = JSON.parse(text); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `the ledger is not valid JSON: ${e.message}` }; }
|
|
34
|
+
if (!ledger || !Array.isArray(ledger.entries)) return { ...newLedger(), tampered: true, tamperReason: 'the ledger has no entries array' };
|
|
35
|
+
const v = verifyLedger(ledger);
|
|
36
|
+
if (!v.ok) return { ...ledger, tampered: true, tamperReason: v.reason };
|
|
37
|
+
const sig = verifyLastScan(text, `${f}.sig`);
|
|
38
|
+
if (sig === false) return { ...ledger, tampered: true, tamperReason: 'the ledger signature does not verify under this install key' };
|
|
39
|
+
if (sig === null && ledger.entries.length && env.AGENTIC_SECURITY_RULES_UNSIGNED !== '1') {
|
|
40
|
+
return { ...ledger, tampered: true, tamperReason: 'the ledger is unsigned (written before 0.195.23 or by another tool); set AGENTIC_SECURITY_RULES_UNSIGNED=1 once to accept and re-sign it' };
|
|
41
|
+
}
|
|
42
|
+
return ledger;
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
export function saveLedger(scanRoot, ledger) {
|
|
46
|
+
if (ledger?.tampered) return { ok: false, reason: `refusing to overwrite a ledger that failed its integrity check (${ledger.tamperReason})` };
|
|
29
47
|
if (!ensureStateDir(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
30
48
|
const f = statePath(scanRoot, DIR, 'ledger.json');
|
|
31
|
-
|
|
49
|
+
const text = JSON.stringify(ledger, null, 2);
|
|
50
|
+
const ok = safeWriteState(f, text) && safeWriteState(`${f}.sig`, signLastScan(text));
|
|
51
|
+
return { ok };
|
|
32
52
|
}
|
|
33
53
|
|
|
34
54
|
export function saveIR(scanRoot, ir) {
|
|
@@ -63,7 +63,7 @@ export function evidenceConversion(findings) {
|
|
|
63
63
|
const byState = Object.fromEntries(EVIDENCE_STATES.map((s) => [s, 0]));
|
|
64
64
|
for (const f of list) byState[evidenceStateOf(f)]++;
|
|
65
65
|
const converted = list.length - byState['single-signal'];
|
|
66
|
-
return { ...rate(converted, list.length), byState, basis: 'findings that reached corroborated or stronger, over all findings' };
|
|
66
|
+
return { status: list.length ? 'measured' : 'unmeasured', ...rate(converted, list.length), byState, basis: 'findings that reached corroborated or stronger, over all findings of the last scan' };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const strongest = (a) => {
|
|
@@ -72,21 +72,52 @@ const strongest = (a) => {
|
|
|
72
72
|
return disappeared && a.testsRan === true && a.testsOk !== false && a.pocOk === true;
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
const isoOrNull = (v) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? v : null);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One attempt per finding (GA-11): a fix tried five times is one remediation, not five, so the LATEST recorded attempt for each
|
|
79
|
+
* finding id is the one counted and the earlier ones are reported as `repeated`. An attempt with no finding id cannot be matched to
|
|
80
|
+
* another and counts on its own. Order is by `at` where every record has one, else by position in the log.
|
|
81
|
+
*/
|
|
82
|
+
function dedupeAttempts(attempts) {
|
|
83
|
+
const list = Array.isArray(attempts) ? attempts.filter((a) => a && typeof a === 'object') : [];
|
|
84
|
+
const latest = new Map();
|
|
85
|
+
const loose = [];
|
|
86
|
+
list.forEach((a, i) => {
|
|
87
|
+
const key = typeof a.stableId === 'string' && a.stableId ? a.stableId : (typeof a.findingId === 'string' && a.findingId ? a.findingId : null);
|
|
88
|
+
if (!key) { loose.push(a); return; }
|
|
89
|
+
const prev = latest.get(key);
|
|
90
|
+
const newer = !prev || (isoOrNull(a.at) && isoOrNull(prev.at) ? Date.parse(a.at) >= Date.parse(prev.at) : i > prev._i);
|
|
91
|
+
if (newer) latest.set(key, Object.assign(Object.create(null), a, { _i: i }));
|
|
92
|
+
});
|
|
93
|
+
const kept = [...latest.values()].map((a) => { const { _i, ...rest } = a; return rest; }).concat(loose);
|
|
94
|
+
return { attempts: kept, recorded: list.length, repeated: list.length - kept.length };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The time span the recorded attempts cover: `from` and `to` are the earliest and latest `at`, or `null` when none carries one. */
|
|
98
|
+
function attemptsWindow(attempts) {
|
|
99
|
+
const times = (Array.isArray(attempts) ? attempts : []).map((a) => a && isoOrNull(a.at)).filter(Boolean).sort();
|
|
100
|
+
return { from: times[0] ?? null, to: times[times.length - 1] ?? null };
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
/** Of the fix attempts, how many reached the strongest verification tier. A lower bound: some classes have no independent proof to run. */
|
|
76
104
|
export function verifiedRemediationRate(attempts) {
|
|
77
|
-
const
|
|
105
|
+
const { attempts: list, recorded, repeated } = dedupeAttempts(attempts);
|
|
78
106
|
const full = list.filter(strongest).length;
|
|
79
107
|
const disappearedOnly = list.filter((a) => typeof a.verifiedFull !== 'boolean' && (a.rescanOk === true || (a.ok === true && a.rescanOk !== false)) && !(a.testsRan === true && a.testsOk !== false) && a.pocOk !== true).length;
|
|
108
|
+
const failed = list.filter((a) => a.ok === false || a.verifiedFull === false).length;
|
|
80
109
|
return {
|
|
110
|
+
status: list.length ? 'measured' : 'unmeasured',
|
|
81
111
|
...rate(full, list.length),
|
|
82
|
-
disappearedOnly,
|
|
83
|
-
|
|
112
|
+
recorded, repeated, failed, disappearedOnly,
|
|
113
|
+
window: attemptsWindow(attempts),
|
|
114
|
+
basis: 'the latest attempt per finding that passed the re-scan, the project tests and the independent attack replay, over the latest attempt per finding. A lower bound where a class has no replay',
|
|
84
115
|
};
|
|
85
116
|
}
|
|
86
117
|
|
|
87
|
-
/** Nothing measures it independently of the rule compiler's own corpus, so it is reported as
|
|
118
|
+
/** Nothing measures it independently of the rule compiler's own corpus, so it is reported as unmeasured, with the reason. */
|
|
88
119
|
export function securityLearningRate() {
|
|
89
|
-
return { status: '
|
|
120
|
+
return { status: 'unmeasured', reason: 'the rule compiler grades its rules against a corpus built from the same finding and the reviewer-supplied independent examples; no independent longitudinal measurement of novel weaknesses converted into reusable knowledge exists' };
|
|
90
121
|
}
|
|
91
122
|
|
|
92
123
|
/**
|
|
@@ -95,11 +126,21 @@ export function securityLearningRate() {
|
|
|
95
126
|
* @param {object[]} [o.fixAttempts] fix verification attempts
|
|
96
127
|
*/
|
|
97
128
|
export function computeStrategicMetrics({ scan = null, fixAttempts = [] } = {}) {
|
|
129
|
+
const attempts = Array.isArray(fixAttempts) ? fixAttempts : [];
|
|
98
130
|
return {
|
|
99
131
|
schema: STRATEGIC_METRICS_SCHEMA, version: STRATEGIC_METRICS_VERSION,
|
|
132
|
+
// The scope and time basis (GA-11): these are per-scan figures for ONE scan plus the attempts recorded on this machine, never an
|
|
133
|
+
// organisation trend. The scan is named by its id and start time; the attempts by the span their timestamps cover.
|
|
134
|
+
scope: {
|
|
135
|
+
kind: 'per-scan',
|
|
136
|
+
scanId: scan && typeof scan.scanId === 'string' ? scan.scanId : null,
|
|
137
|
+
scanStartedAt: scan ? isoOrNull(scan.startedAt) : null,
|
|
138
|
+
attempts: { ...attemptsWindow(attempts), recorded: attempts.filter((a) => a && typeof a === 'object').length },
|
|
139
|
+
trend: 'not a trend: one scan and this machine\'s recorded attempts; compare scans by scanId to see movement',
|
|
140
|
+
},
|
|
100
141
|
assuranceCoverage: assuranceCoverage(scan && scan.scanHealth),
|
|
101
142
|
evidenceConversion: evidenceConversion(scan && scan.findings),
|
|
102
|
-
verifiedRemediationRate: verifiedRemediationRate(
|
|
143
|
+
verifiedRemediationRate: verifiedRemediationRate(attempts),
|
|
103
144
|
securityLearningRate: securityLearningRate(),
|
|
104
145
|
};
|
|
105
146
|
}
|
|
@@ -109,6 +150,7 @@ const pc = (r) => (r && r.percent !== null && r.percent !== undefined ? `${r.per
|
|
|
109
150
|
export function renderStrategicMetrics(m) {
|
|
110
151
|
const ac = m.assuranceCoverage; const ec = m.evidenceConversion; const vr = m.verifiedRemediationRate;
|
|
111
152
|
const L = [];
|
|
153
|
+
if (m.scope) L.push(`Scope: one scan${m.scope.scanId ? ` (${m.scope.scanId}${m.scope.scanStartedAt ? `, started ${m.scope.scanStartedAt}` : ''})` : ''} and ${m.scope.attempts.recorded} fix attempt(s) recorded on this machine${m.scope.attempts.from ? ` between ${m.scope.attempts.from} and ${m.scope.attempts.to}` : ''}; not a trend`);
|
|
112
154
|
L.push(`Assurance Coverage: ${ac.percent === null ? 'not measurable' : `${ac.percent}% (the lower of the two components)`}${ac.scanStatus ? `; scan ${ac.scanStatus}` : ''}`);
|
|
113
155
|
if (ac.files) L.push(` files: ${ac.files.numerator} of ${ac.files.denominator} completed (${pc(ac.files)}); ${ac.files.skipped} skipped, ${ac.files.unanalysed} not analysed, ${ac.files.timedOut} timed out`);
|
|
114
156
|
if (ac.analyzers) L.push(` analyzers: ${ac.analyzers.numerator} of ${ac.analyzers.denominator} completed (${pc(ac.analyzers)}); ${ac.analyzers.failed} failed, ${ac.analyzers.timedOut} timed out`);
|
|
@@ -117,6 +159,7 @@ export function renderStrategicMetrics(m) {
|
|
|
117
159
|
L.push(` ${EVIDENCE_STATES.map((s) => `${s} ${ec.byState[s]}`).join(', ')}`);
|
|
118
160
|
L.push(`Verified Remediation Rate: ${vr.numerator} of ${vr.denominator} fix attempts reached the strongest verification tier (${pc(vr)})${vr.denominator ? '; a lower bound where a class has no attack replay' : ''}`);
|
|
119
161
|
if (vr.disappearedOnly) L.push(` ${vr.disappearedOnly} satisfied only the re-scan, which is also what deleting the code produces`);
|
|
162
|
+
if (vr.repeated) L.push(` ${vr.recorded} attempt(s) recorded; ${vr.repeated} earlier attempt(s) on the same finding counted once, by the latest`);
|
|
120
163
|
L.push(`Security Learning Rate: not measured. ${m.securityLearningRate.reason}`);
|
|
121
164
|
return L.join('\n');
|
|
122
165
|
}
|
package/src/report/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { alertFace, approveFace } from './mascot.js';
|
|
|
5
5
|
import { SCANNER_VERSION } from '../posture/version.js';
|
|
6
6
|
import { proofBlock } from '../posture/proof-artifact.js';
|
|
7
7
|
import { portfolioAssuranceEnabled, scanVerdictWording, NOT_A_GUARANTEE } from '../posture/portfolio/wording.js';
|
|
8
|
+
import { assuranceDecision } from '../posture/assurance/decision.js';
|
|
8
9
|
import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/legacy-compat.js';
|
|
9
10
|
// Finding Provenance (M0/M1). NOTE the name: `findingProvenance` is the
|
|
10
11
|
// git-origin record from posture/provenance/. It is NOT `finding.provenance`
|
|
@@ -1661,11 +1662,13 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1661
1662
|
// findings on a scan that didn't finish cleanly is "we don't know," not
|
|
1662
1663
|
// "safe."
|
|
1663
1664
|
const scanIncomplete = scan.scanHealth && scan.scanHealth.status && scan.scanHealth.status !== 'complete';
|
|
1665
|
+
// One decision for every surface (posture/assurance/decision.js, 0.195.22): a result with NO scan health is `unknown`, never clean.
|
|
1666
|
+
const decision = assuranceDecision({ actionable: actionable.length, scanHealth: scan.scanHealth || null });
|
|
1664
1667
|
|
|
1665
1668
|
const lines = [];
|
|
1666
1669
|
const bar = '─────────────────────────────────────────';
|
|
1667
1670
|
// Patch the mascot reacts to the result — APPROVE if clean, ALERT if findings.
|
|
1668
|
-
const clean =
|
|
1671
|
+
const clean = decision.decision === 'deployable';
|
|
1669
1672
|
lines.push(clean ? approveFace({ color }) : alertFace({ color }));
|
|
1670
1673
|
lines.push(bar);
|
|
1671
1674
|
// X-703.AC03: with the `portfolio-assurance` feature on, the headline is the bounded claim followed by scope and gaps. Off
|
|
@@ -1675,14 +1678,12 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1675
1678
|
const w = scanVerdictWording({ clean, scanIncomplete: !!scanIncomplete, actionableCount: actionable.length });
|
|
1676
1679
|
lines.push(c(` ${w.icon} ${w.headline}`, w.tier === 'ok' ? SEV_COLOR.low + BOLD : w.tier === 'incomplete' ? SEV_COLOR.high + BOLD : SEV_COLOR.critical + BOLD));
|
|
1677
1680
|
lines.push(c(` Scope: completed supported checks only${scanIncomplete ? '; gaps listed below' : '; no gaps recorded by scan health'}. ${NOT_A_GUARANTEE}`, DIM));
|
|
1678
|
-
} else if (clean) {
|
|
1679
|
-
lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
|
|
1680
|
-
} else if (actionable.length === 0) {
|
|
1681
|
-
lines.push(c(' ⚠️ Scan incomplete — cannot confirm safe to deploy', SEV_COLOR.high + BOLD));
|
|
1682
1681
|
} else {
|
|
1683
|
-
|
|
1682
|
+
const tone = decision.decision === 'deployable' ? SEV_COLOR.low : decision.decision === 'not_deployable' ? SEV_COLOR.critical : SEV_COLOR.high;
|
|
1683
|
+
lines.push(c(` ${decision.icon} ${decision.headline}`, tone + BOLD));
|
|
1684
1684
|
}
|
|
1685
1685
|
lines.push(bar);
|
|
1686
|
+
if (decision.decision === 'unknown') lines.push(c(` ${decision.reason}`, SEV_COLOR.high));
|
|
1686
1687
|
if (scanIncomplete) {
|
|
1687
1688
|
const conditions = Array.isArray(scan.scanHealth.conditions) ? scan.scanHealth.conditions : [];
|
|
1688
1689
|
lines.push(c(` ${scan.scanHealth.status}: ${conditions[0] || 'analysis did not complete cleanly'}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`, SEV_COLOR.high));
|
|
@@ -10,6 +10,8 @@ import * as crypto from 'node:crypto';
|
|
|
10
10
|
import * as fs from 'node:fs';
|
|
11
11
|
import * as path from 'node:path';
|
|
12
12
|
import { evaluateEgress } from '../egress/policy.js';
|
|
13
|
+
import { modelFetch } from '../egress/transport.js';
|
|
14
|
+
import { resolveProvider, buildProviderRequest } from '../llm-validator/providers.js';
|
|
13
15
|
|
|
14
16
|
const CACHE_DIR = process.env.XDG_CONFIG_HOME
|
|
15
17
|
? path.join(process.env.XDG_CONFIG_HOME, 'agentic-security', 'llm-sca-cache')
|
|
@@ -37,24 +39,26 @@ export function isLlmScaEnabled() {
|
|
|
37
39
|
return process.env.AGENTIC_SECURITY_LLM_SCA === '1';
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
// The provider is resolved the same way the validator resolves it (GA-06.3): the `local` preset refuses a non-loopback endpoint here
|
|
43
|
+
// too, instead of this module reading the raw endpoint variable and calling it regardless. The Ollama provider speaks a different
|
|
44
|
+
// protocol and this extractor does not implement it, so it is treated as not configured.
|
|
45
|
+
function _endpointConfig(env = process.env) {
|
|
46
|
+
const r = resolveProvider({ role: 'validate', env });
|
|
47
|
+
if (!r.ok || r.config.provider === 'ollama') return null;
|
|
48
|
+
return { endpoint: r.config.endpoint, apiKey: r.config.apiKey, model: r.config.model || 'unknown', shape: r.config.shape };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
async function _askLlm(prompt, config) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const resp = await fetch(config.endpoint, {
|
|
52
|
+
const req = buildProviderRequest(config, prompt, 256);
|
|
53
|
+
const resp = await modelFetch(config.endpoint, {
|
|
51
54
|
method: 'POST',
|
|
52
|
-
headers,
|
|
53
|
-
body: JSON.stringify(
|
|
54
|
-
|
|
55
|
-
});
|
|
55
|
+
headers: req.headers,
|
|
56
|
+
body: JSON.stringify(req.body),
|
|
57
|
+
}, { timeoutMs: 15000 });
|
|
56
58
|
if (!resp.ok) return null;
|
|
57
|
-
|
|
59
|
+
let text = await resp.text();
|
|
60
|
+
// a vendor shape wraps the model's text in its own envelope; the generic shape's reply is searched as it came
|
|
61
|
+
try { const t = req.extractText(JSON.parse(text)); if (typeof t === 'string' && t) text = t; } catch { /* not an envelope */ }
|
|
58
62
|
const jsonMatch = text.match(/\{[^{}]*"functions"\s*:\s*\[[^\]]*\][^{}]*\}/);
|
|
59
63
|
if (!jsonMatch) return null;
|
|
60
64
|
try { return JSON.parse(jsonMatch[0]); } catch { return null; }
|