@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +5 -2
  3. package/dist/{7feb08356355c30181a1.json → 01087ded263953062654.json} +3 -3
  4. package/dist/550.index.js +9 -8
  5. package/dist/5620.index.js +59 -10
  6. package/dist/5937.index.js +88 -16
  7. package/dist/7838.index.js +32 -5
  8. package/dist/9441.index.js +50 -7
  9. package/dist/9824.index.js +19 -13
  10. package/dist/agentic-security.mjs +2 -2
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/package.json +3 -3
  13. package/src/discovery/CLAUDE.md +4 -0
  14. package/src/discovery/detector-spec-synthesis.js +203 -0
  15. package/src/discovery/llm-invoke.js +3 -3
  16. package/src/egress/transport.js +148 -0
  17. package/src/engine.js +9 -0
  18. package/src/integrations/index.js +18 -9
  19. package/src/llm-validator/index.js +4 -2
  20. package/src/llm-validator/local-endpoint.js +4 -1
  21. package/src/llm-validator/ollama-provider.js +8 -6
  22. package/src/pipeline/scan-health.js +15 -2
  23. package/src/posture/CLAUDE.md +1 -0
  24. package/src/posture/adversary-agent.js +27 -10
  25. package/src/posture/assurance/config.js +2 -0
  26. package/src/posture/assurance/decision.js +45 -0
  27. package/src/posture/assurance/rollout.js +3 -0
  28. package/src/posture/custom-rules.js +13 -1
  29. package/src/posture/fleet/cli.js +2 -4
  30. package/src/posture/fleet/fleet-manifest.js +4 -1
  31. package/src/posture/fleet/job-runner.js +28 -4
  32. package/src/posture/fleet/org-health.js +23 -0
  33. package/src/posture/fleet/scan-cache.js +1 -1
  34. package/src/posture/flow-narration.js +10 -8
  35. package/src/posture/llm-redteam.js +3 -1
  36. package/src/posture/provenance/coordinator.js +8 -0
  37. package/src/posture/provenance/origin-resolver.js +17 -4
  38. package/src/posture/rule-compiler/cli.js +56 -8
  39. package/src/posture/rule-compiler/corpus.js +10 -2
  40. package/src/posture/rule-compiler/ir.js +10 -1
  41. package/src/posture/rule-compiler/promotion.js +12 -2
  42. package/src/posture/rule-compiler/regression.js +1 -1
  43. package/src/posture/rule-compiler/store.js +27 -7
  44. package/src/posture/strategic-metrics.js +50 -7
  45. package/src/report/index.js +7 -6
  46. package/src/sca/llm-function-extract.js +17 -13
@@ -15,6 +15,8 @@ import * as path from 'node:path';
15
15
  import * as yaml from '../util/yaml.js';
16
16
  import { statePath } from '../posture/state-dir.js';
17
17
  import { portfolioAssuranceEnabled, HEADLINE, BLOCKED_HEADLINE } from '../posture/portfolio/wording.js';
18
+ // One decision for every surface (0.195.22): a digest built from a findings file with no scan health says it cannot confirm, never "safe".
19
+ import { assuranceDecision } from '../posture/assurance/decision.js';
18
20
 
19
21
  function _configPath(scanRoot) {
20
22
  return statePath(scanRoot, 'integrations.yml');
@@ -35,10 +37,10 @@ export function configHas(scanRoot, key) {
35
37
  // ─── Slack webhook (vibecoder) ───────────────────────────────────────────────
36
38
  export function buildSlackDigest(findings, summary, options = {}) {
37
39
  const project = options.project || 'project';
38
- const status = (summary.critical || 0) === 0 && (summary.high || 0) === 0
39
- ? '✅ safe to deploy' : '❌ not safe to deploy';
40
+ const d = assuranceDecision({ actionable: (summary.critical || 0) + (summary.high || 0), scanHealth: options.scanHealth || null });
41
+ const status = `${d.icon} ${d.headline.toLowerCase()}`;
40
42
  const statusLine = portfolioAssuranceEnabled({ option: options.portfolioAssurance })
41
- ? (status.startsWith('✅') ? `✅ ${HEADLINE}` : `❌ ${BLOCKED_HEADLINE}`) + ' (completed supported checks only)' : status;
43
+ ? (d.decision === 'deployable' ? `✅ ${HEADLINE} (completed supported checks only)` : d.decision === 'not_deployable' ? `❌ ${BLOCKED_HEADLINE}` : status) : status;
42
44
  const top = (findings || []).slice(0, 3);
43
45
 
44
46
  const lines = [
@@ -63,13 +65,15 @@ export function buildSlackDigest(findings, summary, options = {}) {
63
65
  // ─── Discord webhook (vibecoder) ─────────────────────────────────────────────
64
66
  export function buildDiscordDigest(findings, summary, options = {}) {
65
67
  const project = options.project || 'project';
66
- const safe = (summary.critical || 0) === 0 && (summary.high || 0) === 0;
68
+ const d = assuranceDecision({ actionable: (summary.critical || 0) + (summary.high || 0), scanHealth: options.scanHealth || null });
69
+ const safe = d.decision === 'deployable';
67
70
  const top = (findings || []).slice(0, 3);
71
+ const plain = `${d.icon} ${d.headline.toLowerCase()}`;
68
72
  return {
69
73
  embeds: [{
70
74
  title: `🛡 agentic-security — ${project}`,
71
- color: safe ? 0x2ecc71 : 0xe74c3c,
72
- description: portfolioAssuranceEnabled({ option: options.portfolioAssurance }) ? (safe ? `✅ ${HEADLINE} (completed supported checks only)` : `❌ ${BLOCKED_HEADLINE}`) : (safe ? '✅ safe to deploy' : '❌ not safe to deploy'),
75
+ color: safe ? 0x2ecc71 : d.decision === 'not_deployable' ? 0xe74c3c : 0xf1c40f,
76
+ description: portfolioAssuranceEnabled({ option: options.portfolioAssurance }) ? (safe ? `✅ ${HEADLINE} (completed supported checks only)` : d.decision === 'not_deployable' ? `❌ ${BLOCKED_HEADLINE}` : plain) : plain,
73
77
  fields: [
74
78
  { name: 'Critical', value: String(summary.critical || 0), inline: true },
75
79
  { name: 'High', value: String(summary.high || 0), inline: true },
@@ -252,10 +256,11 @@ export async function postPagerDutyEvent(finding) {
252
256
  // ─── Microsoft Teams (Adaptive Card via Incoming Webhook) ───────────────────
253
257
  // Activated by AGENTIC_SECURITY_TEAMS_WEBHOOK (a webhook URL from a Teams
254
258
  // channel's "Incoming Webhook" connector).
255
- export function buildTeamsCard(findings, summary) {
259
+ export function buildTeamsCard(findings, summary, options = {}) {
256
260
  const sev = summary || {};
257
261
  const total = (sev.critical || 0) + (sev.high || 0) + (sev.medium || 0) + (sev.low || 0);
258
262
  const top = (findings || []).filter(f => /critical|high/.test(f.severity || '')).slice(0, 5);
263
+ const d = assuranceDecision({ actionable: (sev.critical || 0) + (sev.high || 0), scanHealth: options.scanHealth || null });
259
264
  const card = {
260
265
  type: 'message',
261
266
  attachments: [{
@@ -266,6 +271,7 @@ export function buildTeamsCard(findings, summary) {
266
271
  version: '1.4',
267
272
  body: [
268
273
  { type: 'TextBlock', size: 'Medium', weight: 'Bolder', text: '🛡 agentic-security scan' },
274
+ { type: 'TextBlock', wrap: true, text: `${d.icon} ${d.headline}` },
269
275
  { type: 'FactSet', facts: [
270
276
  { title: 'Critical', value: String(sev.critical || 0) },
271
277
  { title: 'High', value: String(sev.high || 0) },
@@ -287,10 +293,10 @@ export function buildTeamsCard(findings, summary) {
287
293
  return card;
288
294
  }
289
295
 
290
- export async function postTeamsCard(findings, summary) {
296
+ export async function postTeamsCard(findings, summary, options = {}) {
291
297
  const url = process.env.AGENTIC_SECURITY_TEAMS_WEBHOOK;
292
298
  if (!url) return { ok: false, reason: 'teams-not-configured' };
293
- const card = buildTeamsCard(findings, summary);
299
+ const card = buildTeamsCard(findings, summary, options);
294
300
  try {
295
301
  const r = await fetch(url, {
296
302
  method: 'POST',
@@ -307,9 +313,12 @@ export async function postTeamsCard(findings, summary) {
307
313
  export function buildPrComment(findings, summary, options = {}) {
308
314
  const sev = summary;
309
315
  const top = (findings || []).filter(f => /critical|high/.test(f.severity)).slice(0, 10);
316
+ const d = assuranceDecision({ actionable: (sev.critical || 0) + (sev.high || 0), scanHealth: options.scanHealth || null });
310
317
  const body = [
311
318
  '## 🛡 agentic-security',
312
319
  '',
320
+ `**${d.icon} ${d.headline}**`,
321
+ '',
313
322
  '| Critical | High | Medium | Low | Info |',
314
323
  '|---:|---:|---:|---:|---:|',
315
324
  `| ${sev.critical||0} | ${sev.high||0} | ${sev.medium||0} | ${sev.low||0} | ${sev.info||0} |`,
@@ -77,6 +77,7 @@ import { localEndpointConfig } from './local-endpoint.js';
77
77
  import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
78
78
  import { evaluateEgress } from '../egress/policy.js';
79
79
  import { recordEgressCall, payloadMetrics } from '../egress/audit.js';
80
+ import { modelFetch } from '../egress/transport.js';
80
81
  import { MODEL_STATUS, summarizeModelStatus } from './model-status.js';
81
82
  import { callOllamaChat } from './ollama-provider.js';
82
83
 
@@ -408,12 +409,13 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shap
408
409
  // A model that accepts the connection and never answers must not hold a scan open forever: every call is bounded.
409
410
  // The result is an UNAVAILABLE finding (kept, unvalidated), never a silent pass.
410
411
  const ms = Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) > 0 ? Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) : 60_000;
411
- const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(ms) });
412
+ const r = await modelFetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) }, { timeoutMs: ms });
412
413
  if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
413
414
  const j = await r.json().catch(() => null);
414
415
  return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
415
416
  } catch (e) {
416
- return { ok: false, error: e && e.name === 'TimeoutError' ? 'timeout' : e.message };
417
+ // a redirect, an oversized body or a spent budget is an unavailable validator, named by its transport code, never a pass
418
+ return { ok: false, error: e && e.code === 'timeout' ? 'timeout' : (e && e.code ? `${e.code}: ${e.message}` : e.message) };
417
419
  }
418
420
  }
419
421
 
@@ -22,7 +22,10 @@
22
22
  // The connect timeout is short for the same reason — a dead port must not hang
23
23
  // a scan.
24
24
 
25
- const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
25
+ // `localhost` is the one name accepted; `[::1]` is how the URL parser spells the IPv6 loopback (a bare `::1` never reaches this set).
26
+ // `0.0.0.0` is NOT here (GA-06.3): it is the unspecified address, and a connect to it reaches a local interface only by an operating
27
+ // system convention, not by the address's meaning. The promise is "literal loopback", so it is refused like `[::]` and `http://0/`.
28
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
26
29
 
27
30
  export const DEFAULT_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1/chat/completions';
28
31
  export const DEFAULT_LOCAL_TIMEOUT_MS = 20000;
@@ -32,6 +32,7 @@
32
32
  // never to this module's error path. See ollama-offline-egress.test.js.
33
33
 
34
34
  import { isLoopbackUrl } from './local-endpoint.js';
35
+ import { modelFetch } from '../egress/transport.js';
35
36
  import { recordOOMEvent } from './oom-feedback.js';
36
37
 
37
38
  export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
@@ -199,16 +200,17 @@ export function parseOllamaChatResponse(json, model) {
199
200
  * "slow to generate" without a lower-level HTTP client.
200
201
  */
201
202
  async function _fetchOllama(url, init, { connectTimeoutMs, requestTimeoutMs }) {
202
- const controller = new AbortController();
203
- const totalTimer = setTimeout(() => controller.abort('total-timeout'), requestTimeoutMs);
203
+ void connectTimeoutMs; // see the note above: the client exposes no connect-only phase to bound separately
204
204
  try {
205
- const res = await fetch(url, { ...init, signal: controller.signal });
205
+ // the shared transport: no redirect is followed, the reply is capped while it is read, the deadline is the total timeout
206
+ const res = await modelFetch(url, init, { timeoutMs: requestTimeoutMs });
206
207
  return { ok: true, res };
207
208
  } catch (e) {
208
- if (controller.signal.aborted) return _err('ollama-timeout', `Ollama request timed out after ${requestTimeoutMs}ms.`);
209
+ if (e?.code === 'timeout') return _err('ollama-timeout', `Ollama request timed out after ${requestTimeoutMs}ms.`);
210
+ if (e?.code === 'redirect') return _err('ollama-unreachable', `Ollama answered with a redirect, which is never followed: ${e.message}`);
211
+ if (e?.code === 'response-too-large') return _err('ollama-malformed-response', e.message);
212
+ if (e?.code === 'budget-exhausted') return _err('ollama-timeout', e.message);
209
213
  return _err('ollama-unreachable', e?.message || String(e));
210
- } finally {
211
- clearTimeout(totalTimer);
212
214
  }
213
215
  }
214
216
 
@@ -185,9 +185,22 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
185
185
  // twice. Each leg is additive: a leg not present in `freshnessPartial`
186
186
  // leaves scanHealth's existing `freshness` object and conditions
187
187
  // untouched, exactly like every other optional input this module accepts.
188
+ // A feed whose freshness is UNKNOWN (`stale: null` because the feed was unavailable, never loaded, or cached with no timestamp) AND
189
+ // that the scan consulted for at least one item (`consulted` for the KEV catalog, `cvesChecked` for EPSS) is a condition exactly like
190
+ // a stale one (0.195.22, the GA plan's "missing scan health must remain unknown"): the result reasoned without data it needed, so it
191
+ // cannot be called complete, and strict mode fails closed on it through the status. Two things are deliberately NOT conditions: a feed
192
+ // the scan had nothing to look up in (it changed no result), and `offline-skipped`, which is the operator's own declared configuration
193
+ // (AGENTIC_SECURITY_OFFLINE=1), recorded as `stale: null` with its source and left to the scan-health contract's existing offline rule.
194
+ // A loaded catalog always carries a boolean, so this never fires on one.
195
+ const unknownFeed = (f, what) => {
196
+ if (!f || f.stale !== null || !f.source || ['cache-fresh', 'live', 'cache/live', 'network', 'offline-skipped'].includes(f.source)) return null;
197
+ const consulted = Number(f.consulted ?? f.cvesChecked ?? 0);
198
+ if (!(consulted > 0)) return null;
199
+ return `${what} freshness is unknown (${f.source}): ${consulted} item(s) were checked without it, and the absence of a finding that needs it is not evidence`;
200
+ };
188
201
  const FRESHNESS_CONDITION_BUILDERS = {
189
- kev: (f) => (f?.stale === true) ? `KEV catalog is stale (${f.ageDays} day(s) old) — recently-added CVEs may not be reflected` : null,
190
- epss: (f) => (f?.stale === true) ? `EPSS exploit-probability data is stale (${f.ageDays} day(s) old)` : null,
202
+ kev: (f) => (f?.stale === true) ? `KEV catalog is stale (${f.ageDays} day(s) old) — recently-added CVEs may not be reflected` : unknownFeed(f, 'KEV catalog'),
203
+ epss: (f) => (f?.stale === true) ? `EPSS exploit-probability data is stale (${f.ageDays} day(s) old)` : unknownFeed(f, 'EPSS exploit-probability data'),
191
204
  calibration: (f) => (f?.stale === true) ? `calibration data is stale (${f.ageDays} day(s) old${f.generatedAt ? `, last generated ${f.generatedAt}` : ''})` : null,
192
205
  customRules: (f) => (f?.stale === true) ? `${f.staleFiles.length} custom rule file(s) exceed their configured review interval` : null,
193
206
  advisories: (f) => (Number(f?.unanswered) > 0) ? `${f.unanswered} of ${f.componentsChecked} dependencies were not checked against advisory data (${f.source}) — the absence of a vulnerable dependency finding is not evidence that the dependency is safe` : null,
@@ -156,6 +156,7 @@ Foundation for the differentiation work (CORE-001, CORE-002, CORE-004). All of i
156
156
  - **`contracts.js`**: observation binding (references a lineage observation by id only; absence in a sampled observation cannot establish `blocked`), capability decision (only runner/proxy mediation with a backend and an active-probe digest may claim `enforced`; a hook or in-process check never can), routing label (a decided label must name an adjudication or trusted-execution source), release evidence (`complete` is derived, never trusted), and `validateRecordSet` for duplicate ids, dangling references and conflicting claims.
157
157
  - **`migrations.js`**: explicit v1 adapters from legacy shapes (proof tier, boolean verified, egress decision, attestation, lineage observation) and a legacy view back out. Evidence can be lost, never invented: a legacy `true` is `inconclusive`, `proof-failed` is `inconclusive` not `refuted`, and the only route to `confirmed` is an explicit `trustedRunner` assertion bound to a commit. The legacy view's `verified` is `true` only for confirmed and `false` only for refuted, otherwise `null`.
158
158
  - **`config.js`**: the unified feature configuration. Reuses the env/`.agentic-security/*.yml` mechanism. Precedence: kill switch (`AGENTIC_SECURITY_NO_ASSURANCE` or `AGENTIC_SECURITY_NO_<FEATURE>`) > explicit override > `AGENTIC_SECURITY_ASSURANCE_<FEATURE>` > `.agentic-security/assurance.yml` > default (everything off). **A project file can never enable a `high-risk-execution` feature** (the file lives in the scanned repo); invalid configuration fails closed; unsupported platforms return a typed `unsupported` with the disclosure. `runFeature` returns typed `blocked`/`unsupported`/`degraded`/`disabled` results and never prompts.
159
+ - **`decision.js`** (0.195.22, the GA plan GA-11.7): `assuranceDecision({ actionable, scanHealth })`, the ONE decision every user-facing surface states (`deployable`, `needs_review`, `not_deployable`, `unknown` when no scan health was recorded). `report/index.js` `toShipVerdict`, the Slack, Discord and Teams digests and the pull-request comment in `integrations/index.js` derive their wording from it; actionable findings outrank a missing health record, and zero findings with no health record are `unknown`, never clean. The default wording of the three older states is the one the docs and brand assets carry; the bounded wording stays the `portfolio-assurance` opt-in.
159
160
  - **`bounded-io.js`**: `guardedModelCall` is the only route for new model/network calls. It owns no HTTP client (the transport is injected): feature gate, then `evaluateEgress` BEFORE the payload exists, then `redactPayload`, then the request-size limit, then deadline plus bounded retries, then output cap, with every decision appended to the existing egress audit chain. `readFileBounded`, `capOutput`, `withDeadline`, `retryBounded` enforce the limits `config.js` claims; `maxMemoryMiB` is carried for a runner and disclosed as not enforced here.
160
161
  - **`baseline.js`, `baseline-inventory.js`**: baseline capture (`scripts/baseline-capture.mjs`, `npm run baseline:capture`). Read-only; records HEAD, dirty paths, exact source and bundle digests, tool versions, entry points, the seven mappings and an 18-entry capability inventory (implemented / partial / unsupported / unmeasured, every path and exported symbol verified at capture time, a missing one is a reported problem). Unknown facts are `{status:'unknown', reason}` with no value. `evaluateBaseline` invalidates exactly the capabilities whose cited files changed and lists unrelated user changes as retained/introduced. The manifest is machine-specific, so it is generated on demand and not committed. When you change a capability's source or tests, update its inventory entry in the same change.
161
162
 
@@ -26,6 +26,8 @@
26
26
 
27
27
  import * as crypto from 'node:crypto';
28
28
  import { evaluateEgress } from '../egress/policy.js';
29
+ import { modelFetch } from '../egress/transport.js';
30
+ import { resolveProvider } from '../llm-validator/providers.js';
29
31
 
30
32
  const MAX_CALLS_DEFAULT = 50;
31
33
  const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
@@ -96,11 +98,21 @@ export function isExceeded(transcript, budget) {
96
98
  // and parses the response as a single tool-call request. The endpoint is
97
99
  // expected to follow an OpenAI-style chat-completions shape; we extract a
98
100
  // JSON object of `{ tool, args }` from the model's reply.
101
+ // GA-06.3: the provider is resolved like the validator's, so the `local` preset's loopback refusal applies here, and a vendor key is
102
+ // sent only with that vendor's own preset (a bare OPENAI_API_KEY is never attached to an arbitrary endpoint). Ollama's protocol is not
103
+ // implemented here, so that preset counts as not configured for this agent.
104
+ export function _agentProvider(env = process.env) {
105
+ const r = resolveProvider({ role: 'verify', env });
106
+ if (!r.ok || r.config.provider === 'ollama') return null;
107
+ return r.config;
108
+ }
109
+
99
110
  export async function defaultLlmInvoke(transcript) {
100
- const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
101
- if (!endpoint) return null;
102
- const model = process.env.AGENTIC_SECURITY_LLM_MODEL || 'gpt-4o-mini';
103
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY || process.env.OPENAI_API_KEY || '';
111
+ const provider = _agentProvider();
112
+ if (!provider) return null;
113
+ const { endpoint } = provider;
114
+ const model = provider.model || 'gpt-4o-mini';
115
+ const apiKey = provider.apiKey || '';
104
116
  const headers = { 'Content-Type': 'application/json' };
105
117
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
106
118
  const tools = [...TOOL_ACL].map(t => ({ tool: t, description: TOOL_HELP[t] || '' }));
@@ -115,7 +127,7 @@ export async function defaultLlmInvoke(transcript) {
115
127
  };
116
128
  let res;
117
129
  try {
118
- res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
130
+ res = await modelFetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
119
131
  } catch (e) {
120
132
  return null;
121
133
  }
@@ -136,6 +148,9 @@ const TOOL_HELP = {
136
148
  'record_outcome': 'Terminate the run with a verdict (outcome: one of OUTCOMES)',
137
149
  };
138
150
 
151
+ /** Bounds for a tool probe against the target: a reply is kept to 4000 characters anyway, so 64 KiB and 30 s are generous. */
152
+ const PROBE_BOUNDS = Object.freeze({ timeoutMs: 30_000, maxResponseBytes: 64 * 1024 });
153
+
139
154
  // Default executeTool — wraps http.get / http.post against `transcript.target`.
140
155
  // Falls back to refusal when the tool is db.read_sandbox_copy (we do not ship
141
156
  // a sandboxed DB by default; the caller must supply one).
@@ -144,7 +159,8 @@ export async function defaultExecuteTool(call, transcript) {
144
159
  if (call.tool === 'http.get') {
145
160
  const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
146
161
  try {
147
- const r = await fetch(url, { method: 'GET', headers: call.args?.headers || {} });
162
+ // the probe goes through the shared transport too: no redirect off the target, a capped reply, a deadline
163
+ const r = await modelFetch(url, { method: 'GET', headers: call.args?.headers || {} }, PROBE_BOUNDS);
148
164
  const body = await r.text();
149
165
  return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
150
166
  } catch (e) { return { error: String(e?.message || e) }; }
@@ -152,11 +168,11 @@ export async function defaultExecuteTool(call, transcript) {
152
168
  if (call.tool === 'http.post') {
153
169
  const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
154
170
  try {
155
- const r = await fetch(url, {
171
+ const r = await modelFetch(url, {
156
172
  method: 'POST',
157
173
  headers: { 'Content-Type': 'application/json', ...(call.args?.headers || {}) },
158
174
  body: typeof call.args?.body === 'string' ? call.args.body : JSON.stringify(call.args?.body || {}),
159
- });
175
+ }, PROBE_BOUNDS);
160
176
  const body = await r.text();
161
177
  return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
162
178
  } catch (e) { return { error: String(e?.message || e) }; }
@@ -176,8 +192,9 @@ export async function runAgent(finding, opts = {}) {
176
192
  // called later in the loop below) never runs at all.
177
193
  let egressDecision = null;
178
194
  let llmInvoke = opts.llmInvoke || null;
179
- if (!llmInvoke && process.env.AGENTIC_SECURITY_LLM_ENDPOINT) {
180
- egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: process.env.AGENTIC_SECURITY_LLM_ENDPOINT });
195
+ const provider = llmInvoke ? null : _agentProvider();
196
+ if (!llmInvoke && provider) {
197
+ egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: provider.endpoint });
181
198
  if (egressDecision.allowed) llmInvoke = defaultLlmInvoke;
182
199
  }
183
200
  const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
@@ -58,6 +58,8 @@ export const FEATURES = Object.freeze({
58
58
  platformNote: 'runner-level filesystem, network and process enforcement is only advertised on Linux; macOS can supervise but not enforce',
59
59
  },
60
60
  'model-routing': { risk: 'model-network', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
61
+ // a detector-spec proposal drafted by a model for human review (Labs); the only route is guardedModelCall, with the egress policy evaluated before any sample file is read
62
+ 'detector-spec-synthesis': { risk: 'model-network', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
61
63
  'portfolio-assurance': { risk: 'passive', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
62
64
  });
63
65
 
@@ -0,0 +1,45 @@
1
+ // The one assurance decision every user-facing surface states (the GA plan, GA-11.7): the CLI ship verdict, the Slack, Discord and
2
+ // Teams digests and the pull-request comment all call `assuranceDecision` and print its wording, so a surface can no longer decide
3
+ // from severity counts alone. The decision vocabulary is the release verdict's (`release-verdict.js`): `deployable`, `needs_review`,
4
+ // `not_deployable`, plus `unknown` when no scan health was recorded, because a result that cannot say whether the scan completed
5
+ // cannot say anything about deployability.
6
+ //
7
+ // Rules, in order:
8
+ // - actionable findings (critical or high) -> not_deployable (an incomplete or missing scan health is noted)
9
+ // - no scan health at all (a findings file with no health record) -> unknown: zero findings with no record of completion prove nothing
10
+ // - scan health not `complete`, no actionable findings -> needs_review (the first condition is the reason)
11
+ // - complete, no actionable findings -> deployable
12
+ //
13
+ // Wording: the default strings are the ones the CLI, the docs and the brand assets have carried since the ship verdict existed, kept
14
+ // so that existing output stays recognisable; the bounded wording of the `portfolio-assurance` feature (`portfolio/wording.js`) is the
15
+ // opt-in alternative and the surfaces still choose it the way they did. What is new is the `unknown` state and that every surface
16
+ // derives its wording from the same decision.
17
+ // the four decisions: deployable, needs_review, not_deployable, unknown
18
+
19
+ const WORDING = Object.freeze({
20
+ deployable: { icon: '✅', headline: 'Safe to deploy' },
21
+ not_deployable: { icon: '❌', headline: 'Not safe to deploy' },
22
+ needs_review: { icon: '⚠️', headline: 'Scan incomplete — cannot confirm safe to deploy' },
23
+ unknown: { icon: '⚠️', headline: 'Cannot confirm safe to deploy — no scan health recorded' },
24
+ });
25
+
26
+ /**
27
+ * @param {object} p
28
+ * @param {number|Array} p.actionable the critical and high findings (a count or the array)
29
+ * @param {object|null} p.scanHealth scan.scanHealth, or null when none was recorded
30
+ * @returns {{decision:string, icon:string, headline:string, reason:string, actionable:number, scanStatus:string|null, condition:string|null}}
31
+ */
32
+ export function assuranceDecision({ actionable = 0, scanHealth = null } = {}) {
33
+ const n = Array.isArray(actionable) ? actionable.length : Number(actionable) || 0;
34
+ const health = scanHealth && typeof scanHealth === 'object' && typeof scanHealth.status === 'string' ? scanHealth : null;
35
+ const conditions = health && Array.isArray(health.conditions) ? health.conditions : [];
36
+ const condition = conditions[0] || (health && health.status !== 'complete' ? 'analysis did not complete cleanly' : null);
37
+ let decision;
38
+ let reason;
39
+ if (n > 0) { decision = 'not_deployable'; reason = `${n} actionable finding(s)${!health ? ' (no scan health recorded)' : health.status !== 'complete' ? `, and the scan is ${health.status}: ${condition}` : ''}`; }
40
+ else if (!health) { decision = 'unknown'; reason = 'no scan health was recorded with this result, so whether the scan completed is unknown'; }
41
+ else if (health.status !== 'complete') { decision = 'needs_review'; reason = `no actionable finding, but the scan is ${health.status}: ${condition}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`; }
42
+ else { decision = 'deployable'; reason = 'no critical or high finding and every analyzer finished'; }
43
+ return { decision, ...WORDING[decision], reason, actionable: n, scanStatus: health ? health.status : null, condition };
44
+ }
45
+
@@ -47,6 +47,8 @@ const FEATURE_SUITE = Object.freeze({
47
47
  'capability-enforcement': 'capabilities',
48
48
  'model-routing': 'routing',
49
49
  'portfolio-assurance': 'portfolio',
50
+ // the synthesis module is a CORE-004 consumer (the shared bounded call); its pins run in the foundation step
51
+ 'detector-spec-synthesis': 'foundation',
50
52
  });
51
53
 
52
54
  // What each feature must show before opt-in. Feature specific, and quoted from the PRD's own targets
@@ -58,6 +60,7 @@ const OPT_IN_SPECIFIC = Object.freeze({
58
60
  'invariant-scenarios': [review('contract-approval-path', 'Invariant contracts are approved by an authorized reviewer, never by the generator that proposed them')],
59
61
  'capability-enforcement': [measure('backend-probes', 'Every mandatory canary-secret and escape fixture is blocked on the advertised backend with zero canary leakage; macOS stays supervise-only')],
60
62
  'model-routing': [measure('promotion-thresholds', 'At least 200 paired adjudicated tasks overall and 30 per promoted stratum, lower 95% quality bound above -0.02, median cost 20% lower or quality 0.05 higher at no higher median cost, p95 latency within 1.2x')],
63
+ 'detector-spec-synthesis': [measure('proposal-validity', 'At least 95% schema-valid proposal completion on the frozen supported samples, safe refusal of malformed and adversarial replies, zero automatic installation, zero requests under deny and zero remote requests under local-only (the GA plan, GA-07)')],
61
64
  'portfolio-assurance': [measure('interrupted-run-convergence', 'Interrupted, duplicate-delivery and incremental runs converge to a fresh scoped result with no double-counted progress')],
62
65
  });
63
66
 
@@ -39,6 +39,7 @@ import * as path from 'node:path';
39
39
  import * as yaml from '../util/yaml.js';
40
40
  import { globFiles } from '../util/glob.js';
41
41
  import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
42
+ import { isUnsafeRegex } from '../sast/redos-nfa.js';
42
43
 
43
44
  import { statePath, safeWriteState } from './state-dir.js';
44
45
  const LANG_EXTS = {
@@ -156,8 +157,19 @@ export function loadCustomRules(scanRoot) {
156
157
  // Catches (a+)+, (a*)*, (.+)+, (\w+)+, (a|b)+, (a+|b)* etc.
157
158
  // Not exhaustive but eliminates the vast majority of catastrophic patterns.
158
159
  const _REDOS_NESTED_RE = /\([^)]*[+*][^)]*\)[+*?{]/;
160
+ // The textual heuristic above misses an ambiguous alternation under a quantifier (`(a|a)*b`) and the polynomial shapes; since
161
+ // 0.195.23 (GA-07) the NFA-based detector the SAST layer already ships (`sast/redos-nfa.js`) is consulted as well, so a custom or
162
+ // compiled rule is held to the same bar as the code the scanner flags for CWE-1333.
159
163
  function _isSafePattern(p) {
160
- return !_REDOS_NESTED_RE.test(p);
164
+ if (_REDOS_NESTED_RE.test(p)) return false;
165
+ return !isUnsafeRegex(p).unsafe;
166
+ }
167
+ /** Why a pattern is refused, for the loader's message and the rule compiler's validation. Null when it is safe. */
168
+ export function unsafePatternReason(p) {
169
+ if (typeof p !== 'string') return 'not a string';
170
+ if (_REDOS_NESTED_RE.test(p)) return 'nested quantifiers';
171
+ const r = isUnsafeRegex(p);
172
+ return r.unsafe ? (r.reason || 'superlinear matching') : null;
161
173
  }
162
174
 
163
175
  /** The loader's own rule normalizer, exported so anything that scores or validates a rule uses the SAME one the scanner loads
@@ -15,7 +15,7 @@ import { rollupFleet, renderFleetSummary } from '../fleet.js';
15
15
  import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
16
16
  import { createSqliteJobStore } from './job-store.js';
17
17
  import { planRun, runJobs, loadArtifacts, reclaimDeadWorkers, childScan } from './job-runner.js';
18
- import { computeOrgHealth, renderOrgHealth } from './org-health.js';
18
+ import { computeOrgHealth, renderOrgHealth, applyArtifactProblems } from './org-health.js';
19
19
  import { discoverEdges, splitDeclared } from './discover-edges.js';
20
20
  import { buildEnterpriseGraph, finalizeGraph, validateEnterpriseGraph, attackPaths, vulnerableSet, componentBlastRadius, renderBlastRadius, INTERNET } from './enterprise-graph.js';
21
21
 
@@ -90,9 +90,7 @@ function runOfJob(store, id) {
90
90
  async function finishRun({ args, root, store, runId, manifest, manifestFile, interrupted }) {
91
91
  const { artifacts, problems } = loadArtifacts({ store, runId, root });
92
92
  const jobs = store.listJobs(runId);
93
- const health = computeOrgHealth({ manifest, jobs });
94
- for (const p of problems) { const r = health.repositories.find((x) => x.id === p.repoId); if (r) { r.state = 'failed'; r.error = p.problem; } }
95
- if (problems.length) { health.counts.complete -= problems.filter((p) => health.repositories.find((x) => x.id === p.repoId)).length; health.counts.failed += problems.length; if (health.assurance !== 'red' && manifest.repositories.some((r) => r.required && problems.some((p) => p.repoId === r.id))) health.assurance = 'red'; health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`); }
93
+ const health = applyArtifactProblems(computeOrgHealth({ manifest, jobs }), manifest, problems);
96
94
 
97
95
  let previous = null;
98
96
  const rollupFile = statePath(root, ROLLUP_FILE);
@@ -14,7 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { load as loadYaml } from '../../util/yaml.js';
16
16
 
17
- export const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout', 'retries', 'resources']);
17
+ export const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout', 'retries', 'resources', 'cacheMaxAge']);
18
18
  export const REPO_KEYS = Object.freeze(['id', 'path', 'required', 'kind', 'internetFacing', 'environment', 'dataClasses', 'trustZone', 'calls', 'produces', 'consumes', 'reads', 'writes', 'providers', 'aliases']);
19
19
  export const RESOURCE_KEYS = Object.freeze(['id', 'kind', 'dataClasses', 'trustZone', 'internetFacing', 'environment', 'aliases']);
20
20
  export const REPO_KINDS = Object.freeze(['service', 'library', 'frontend', 'worker', 'gateway']);
@@ -93,12 +93,15 @@ export function validateFleetManifest(doc, { baseDir = '.' } = {}) {
93
93
  if (doc.workers != null && !(Number.isInteger(doc.workers) && doc.workers >= 1 && doc.workers <= 32)) errors.push('workers must be an integer in 1..32');
94
94
  if (doc.retries != null && !(Number.isInteger(doc.retries) && doc.retries >= 0 && doc.retries <= 5)) errors.push('retries must be an integer in 0..5');
95
95
  if (doc.perRepoTimeout != null && parseDuration(doc.perRepoTimeout, null) == null) errors.push(`perRepoTimeout is not a valid duration: ${doc.perRepoTimeout}`);
96
+ if (doc.cacheMaxAge != null && parseDuration(doc.cacheMaxAge, null) == null) errors.push(`cacheMaxAge is not a valid duration: ${doc.cacheMaxAge}`);
96
97
  if (errors.length) return { ok: false, errors };
97
98
  return {
98
99
  ok: true, errors: [],
99
100
  manifest: {
100
101
  organization: doc.organization, repositories: repos, resources,
101
102
  workers: doc.workers ?? 4, retries: doc.retries ?? 1, perRepoTimeoutMs: parseDuration(doc.perRepoTimeout, 20 * 60000),
103
+ // GA-09: a cached result older than this is stale and is not reused (the feeds it was enriched with have moved on); 7 days by default
104
+ cacheMaxAgeMs: parseDuration(doc.cacheMaxAge, 7 * 24 * 3600000),
102
105
  },
103
106
  };
104
107
  }
@@ -69,6 +69,21 @@ export function planRun({ manifest, manifestHash, store, runId = null, config =
69
69
  * Run (or continue) a planned run until no job is queued or leased by a live worker. Returns when the work is done, the signal
70
70
  * aborts, or `maxWaitMs` passes with only other workers' leases outstanding.
71
71
  */
72
+ export const DEFAULT_CACHE_MAX_AGE_MS = 7 * 24 * 3600000;
73
+
74
+ /**
75
+ * Is a cache hit fresh enough to reuse (GA-09)? A record with no readable `cachedAt` (written before 0.195.23) is never fresh, and a
76
+ * record older than `maxAgeMs` (the manifest's `cacheMaxAge`, 7 days by default) is stale. Returns `{ fresh, cacheAgeMs }`.
77
+ */
78
+ export function cacheFreshness(hit, maxAgeMs, nowMs) {
79
+ if (!hit) return { fresh: false, cacheAgeMs: null };
80
+ const t = typeof hit.cachedAt === 'string' ? Date.parse(hit.cachedAt) : NaN;
81
+ if (Number.isNaN(t)) return { fresh: false, cacheAgeMs: null };
82
+ const cacheAgeMs = Math.max(0, nowMs - t);
83
+ const max = Number.isFinite(maxAgeMs) && maxAgeMs > 0 ? maxAgeMs : DEFAULT_CACHE_MAX_AGE_MS;
84
+ return { fresh: cacheAgeMs <= max, cacheAgeMs };
85
+ }
86
+
72
87
  export async function runJobs({ manifest, store, runId, root, scan = childScan, scanOptions = {}, workers = manifest.workers, leaseMs = DEFAULTS.leaseMs, heartbeatMs = DEFAULTS.heartbeatMs, retryDelayMs = DEFAULTS.retryDelayMs, pollMs = DEFAULTS.pollMs, maxWaitMs = null, signal, now = () => Date.now(), onEvent = () => {}, cacheDir = null, artifactDir = null, useCache = true }) {
73
88
  const repoById = new Map(manifest.repositories.map((r) => [r.id, r]));
74
89
  const cacheD = cacheDir ?? statePath(root, 'fleet', 'cache');
@@ -78,7 +93,11 @@ export async function runJobs({ manifest, store, runId, root, scan = childScan,
78
93
  const writeArtifact = (job, payload) => {
79
94
  const text = JSON.stringify({ schema: 'agentic-security/fleet-scan-artifact', version: 1, runId, repoId: job.repoId, epoch: job.epoch, commit: job.commit, ...payload });
80
95
  const file = path.join(artD, `${job.repoId}.${job.epoch}.json`);
81
- fs.writeFileSync(file, text, { mode: 0o600 });
96
+ // written whole or not at all (GA-09): a worker killed mid-write leaves no half artifact for the hash check to find, only a
97
+ // stray temp file, and the job stays leased until the lease expires and another worker redoes it
98
+ const tmp = `${file}.${process.pid}.tmp`;
99
+ fs.writeFileSync(tmp, text, { mode: 0o600 });
100
+ fs.renameSync(tmp, file);
82
101
  return { file, sha256: sha(text) };
83
102
  };
84
103
 
@@ -100,11 +119,16 @@ export async function runJobs({ manifest, store, runId, root, scan = childScan,
100
119
  idleSince = null;
101
120
  const repo = repoById.get(job.repoId);
102
121
  onEvent({ type: 'start', repo: job.repoId, attempt: job.attempts });
103
- const cached = useCache ? readCache(cacheD, job.cacheKey) : null;
122
+ const hit = useCache ? readCache(cacheD, job.cacheKey) : null;
123
+ // GA-09: a hit is reused only while it is fresh. A result with no recorded time (written before 0.195.23) or older than the
124
+ // manifest's `cacheMaxAge` is stale: it is scanned again, never folded into a green organization as if it were current.
125
+ const { fresh, cacheAgeMs } = cacheFreshness(hit, manifest.cacheMaxAgeMs, now());
126
+ const cached = fresh ? hit : null;
127
+ if (hit && !cached) onEvent({ type: 'cache-stale', repo: job.repoId, cacheAgeMs });
104
128
  if (cached) {
105
- const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, lineage: cached.lineage ?? null, cached: true });
129
+ const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, lineage: cached.lineage ?? null, cached: true, cachedAt: cached.cachedAt, cacheAgeMs });
106
130
  const status = cached.entry?.scanHealth && cached.entry.scanHealth.status !== 'complete' ? 'partial' : 'succeeded';
107
- if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: cached.entry?.total ?? null, cached: true, artifact: path.basename(a.file) }, now: now() })) onEvent({ type: 'done', repo: job.repoId, cached: true, status });
131
+ if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: cached.entry?.total ?? null, cached: true, cacheAgeMs, artifact: path.basename(a.file) }, now: now() })) onEvent({ type: 'done', repo: job.repoId, cached: true, status });
108
132
  continue;
109
133
  }
110
134
  const ac = new AbortController();
@@ -45,6 +45,29 @@ export function computeOrgHealth({ manifest, jobs }) {
45
45
  return { counts, assurance, reasons, repositories: repos };
46
46
  }
47
47
 
48
+ /**
49
+ * Fold artifact problems (a result whose file is missing or does not match its recorded hash) into the health (GA-09). The repository
50
+ * becomes `failed`; a required one turns the organization red, an optional one makes it degraded (it used to stay green); the counts
51
+ * move from whatever state the repository had, not always from `complete`. Returns the same object, changed in place.
52
+ */
53
+ export function applyArtifactProblems(health, manifest, problems) {
54
+ if (!Array.isArray(problems) || !problems.length) return health;
55
+ const stateKey = { complete: 'complete', partial: 'partial', failed: 'failed', 'timed-out': 'timedOut', cancelled: 'cancelled', 'not-run': 'notRun' };
56
+ let requiredHit = false; let optionalHit = false;
57
+ for (const p of problems) {
58
+ const r = health.repositories.find((x) => x.id === p.repoId);
59
+ if (!r) continue;
60
+ if (r.state !== 'failed') { health.counts[stateKey[r.state]]--; health.counts.failed++; }
61
+ r.state = 'failed'; r.error = p.problem;
62
+ const required = manifest.repositories.some((m) => m.id === p.repoId && m.required);
63
+ if (required) requiredHit = true; else optionalHit = true;
64
+ }
65
+ if (requiredHit) health.assurance = 'red';
66
+ else if (optionalHit && health.assurance === 'green') health.assurance = 'degraded';
67
+ health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`);
68
+ return health;
69
+ }
70
+
48
71
  const n = (x) => x.toLocaleString('en-US');
49
72
  export function renderOrgHealth(h) {
50
73
  const c = h.counts;
@@ -102,7 +102,7 @@ export function writeCache(dir, key, payload) {
102
102
  if (!key || !/^[0-9a-f]{64}$/.test(key)) return false;
103
103
  try {
104
104
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
105
- const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, ...payload };
105
+ const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, cachedAt: new Date().toISOString(), ...payload };
106
106
  const doc = { ...body, digest: sha(JSON.stringify(body)) };
107
107
  const tmp = path.join(dir, `.${key}.${process.pid}.tmp`);
108
108
  fs.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
@@ -14,6 +14,8 @@
14
14
  // Fail-closed: any LLM error → template fallback, never a missing field.
15
15
 
16
16
  import { evaluateEgress } from '../egress/policy.js';
17
+ import { modelFetch } from '../egress/transport.js';
18
+ import { resolveProvider, buildProviderRequest } from '../llm-validator/providers.js';
17
19
 
18
20
  const TEMPLATES = {
19
21
  'sql-injection': (f) =>
@@ -63,14 +65,14 @@ function _renderTemplate(f) {
63
65
  // Optional LLM call. Disabled by default; opt-in via env. Falls back to the
64
66
  // template on any error.
65
67
  async function _renderLlm(f, scanRoot) {
66
- const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
67
- if (!endpoint) return null;
68
+ // GA-06.3: the provider is resolved like the validator's, so the `local` preset's loopback refusal applies here too, instead of the
69
+ // raw endpoint variable being called regardless of the preset. The Ollama protocol is not implemented here: treated as not configured.
70
+ const provider = resolveProvider({ role: 'explain' });
71
+ if (!provider.ok || provider.config.provider === 'ollama') return null;
72
+ const { endpoint } = provider.config;
68
73
  // FR-601: evaluated before the prompt below is built.
69
74
  const egressDecision = evaluateEgress({ scanRoot, purpose: 'flow-narration', endpoint });
70
75
  if (!egressDecision.allowed) return null;
71
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
72
- const headers = { 'Content-Type': 'application/json' };
73
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
74
76
  const prompt = `You are explaining a security finding to a developer in one paragraph.
75
77
  Vuln: ${f.vuln}
76
78
  CWE: ${f.cwe}
@@ -80,11 +82,11 @@ Snippet: ${(f.snippet || '').slice(0, 200)}
80
82
 
81
83
  Write ONE paragraph (5-7 sentences) covering: (1) how an attacker reaches this code, (2) what they get if exploited, (3) typical recovery cost. Plain English, no marketing language, no emoji.`;
82
84
  try {
83
- const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ prompt }) });
85
+ const req = buildProviderRequest(provider.config, prompt, 512);
86
+ const r = await modelFetch(endpoint, { method: 'POST', headers: req.headers, body: JSON.stringify(req.body) });
84
87
  if (!r.ok) return null;
85
88
  const j = await r.json().catch(() => null);
86
- const text = j && (j.response || j.text || j.content || j.output ||
87
- j.choices?.[0]?.message?.content || j.message?.content);
89
+ const text = req.extractText(j);
88
90
  if (typeof text !== 'string' || text.length < 30) return null;
89
91
  // Sanitize: strip control chars, markdown fences, HTML metachars.
90
92
  return text.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/[<>&]/g, ' ')
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { RED_TEAM_PROMPTS, ATTACK_STRATEGIES, PLUGIN_SEVERITY, categorizePrompts, pluginCoverage } from './llm-redteam-prompts.js';
16
16
  import { evaluateEgress } from '../egress/policy.js';
17
+ import { modelFetch } from '../egress/transport.js';
17
18
 
18
19
  // ─── STATIC mode ─────────────────────────────────────────────────────────
19
20
  // Scan the repo for prompt files / system-prompt strings and check whether
@@ -143,7 +144,8 @@ async function sendPrompt(endpoint, apiKey, prompt) {
143
144
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
144
145
  let response;
145
146
  try {
146
- const r = await fetch(endpoint, {
147
+ // the target under test is the operator's named endpoint; the shared transport still refuses a redirect off it and caps the reply
148
+ const r = await modelFetch(endpoint, {
147
149
  method: 'POST',
148
150
  headers,
149
151
  body: JSON.stringify({ prompt }),