@clear-capabilities/agentic-security-scanner 0.172.0 → 0.172.1

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.
@@ -20,14 +20,14 @@ import { redactPayload } from '../egress/redact.js';
20
20
  import { evaluateEgress } from '../egress/policy.js';
21
21
  import { resolveProvider } from '../llm-validator/providers.js';
22
22
  import { resolveLlmInvokeWithDecision } from '../discovery/llm-invoke.js';
23
- import { hashOf, safeRelPath, sha256 } from './canonical.js';
23
+ import { hashOf, safeLabel, safeRelPath, sha256 } from './canonical.js';
24
24
  import { estimateTokens } from './packer.js';
25
25
  import { REVIEW_SCHEMA_VERSION, packRef } from './prompts/packs.js';
26
26
 
27
27
  export const CONCLUSIONS = Object.freeze(['potential_issue', 'no_issue_observed', 'insufficient_evidence']);
28
28
  export const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
29
29
  const FORBIDDEN_MODEL_FIELDS = Object.freeze(['severity', 'proofTier', 'proof_tier', 'confirmation', 'confirmed', 'status', 'verdict', 'shipVerdict', 'ship_verdict', 'exploitability', 'cvss', 'exploit_confirmed', 'EXPLOIT_CONFIRMED']);
30
- const MAX_CITATION_SPAN = 200;
30
+ const MAX_CITATION_SPAN = 60;
31
31
  const MAX_STR = 1200;
32
32
  const MAX_ITEMS = 24;
33
33
 
@@ -65,8 +65,10 @@ export function buildReviewPrompt({ pack, entrypoints, packageTexts, contextHash
65
65
  for (const g of pack.guidance) lines.push(`- ${g}`);
66
66
  lines.push('', 'OUTPUT (JSON, evidence listed first):', JSON.stringify(OUTPUT_SHAPE, null, 2), '');
67
67
  entrypoints.forEach((ep, i) => {
68
- const fenced = fenceUntrusted(packageTexts[i], `context-${i + 1}`);
69
- lines.push(`ENTRY POINT ${i + 1}: ${ep.label} (id ${ep.id})`);
68
+ // The label comes from a route string in the repository, so it is DATA: it goes inside the fence, sanitised, and never
69
+ // into the instruction text around it.
70
+ const fenced = fenceUntrusted(`ENTRY POINT LABEL (data): ${safeLabel(ep.label, 120)}\n${packageTexts[i]}`, `context-${i + 1}`);
71
+ lines.push(`ENTRY POINT ${i + 1} (id ${ep.id})`);
70
72
  lines.push(fenced.text, '');
71
73
  });
72
74
  const prompt = lines.join('\n');
@@ -102,18 +104,39 @@ export function redactPackageText(text, { scanRoot }) {
102
104
  }
103
105
 
104
106
  /** Provider/model identity used in the job key, from the same resolver the rest of the discovery layer uses. */
105
- export function describeModel() {
107
+ export function isLoopbackEndpoint(endpoint) {
108
+ if (!endpoint) return false;
109
+ const parts = String(endpoint).split(',').map(x => x.trim()).filter(Boolean);
110
+ if (!parts.length) return false;
111
+ return parts.every(u => {
112
+ try {
113
+ const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
114
+ return h === 'localhost' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h);
115
+ } catch { return false; }
116
+ });
117
+ }
118
+
119
+ export function describeModel({ allowRemote = false } = {}) {
120
+ let m;
106
121
  try {
107
122
  const r = resolveProvider({ role: 'hunt' });
108
- if (r.ok) return { provider: r.config.provider, model: r.config.model || process.env.AGENTIC_SECURITY_LLM_MODEL || 'endpoint-default', endpoint: r.config.endpoint || null };
123
+ if (r.ok) m = { provider: r.config.provider, model: r.config.model || process.env.AGENTIC_SECURITY_LLM_MODEL || 'endpoint-default', endpoint: r.config.endpoint || null };
109
124
  } catch { /* fall through */ }
110
- const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
111
- return { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
125
+ if (!m) {
126
+ const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
127
+ m = { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
128
+ }
129
+ // `remote` is true for any endpoint that is not loopback; an endpoint-less provider is judged remote too, since its host is not visible here.
130
+ const remote = m.provider ? !(m.provider === 'local' || isLoopbackEndpoint(m.endpoint)) : false;
131
+ return { ...m, remote, remoteApproved: allowRemote === true };
112
132
  }
113
133
 
114
134
  /** Evaluate the egress policy for every file that would appear in the prompt. Returns { allowed, reason }. */
115
135
  export function checkEgressForFiles(files, { scanRoot, model }) {
116
136
  if (!model.endpoint) return { allowed: true, evaluated: false };
137
+ // Source bundles leave the machine only when the operator has said so. A loopback endpoint needs no approval; any
138
+ // other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
139
+ if (model.remote && !model.remoteApproved) return { allowed: false, reason: 'remote-endpoint-not-approved (pass --allow-remote or set review.allowRemote after reviewing the egress policy)', file: files[0] || null, evaluated: false };
117
140
  for (const file of files) {
118
141
  const d = evaluateEgress({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file });
119
142
  if (!d.allowed) return { allowed: false, reason: d.reason, file, evaluated: true };
@@ -149,14 +172,29 @@ function normalizeCitations(list) {
149
172
  return { items, malformed };
150
173
  }
151
174
 
152
- /** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained. */
175
+ const CLAIM_STOPWORDS = new Set(['this', 'that', 'with', 'from', 'which', 'where', 'when', 'does', 'have', 'been', 'would', 'could', 'should', 'into', 'over', 'only', 'also', 'shows', 'show', 'line', 'lines', 'code', 'file', 'function']);
176
+
177
+ /**
178
+ * A claim is grounded when at least one meaningful word in it (or the first five letters of a longer one, so "ownership"
179
+ * meets `ownerId`) appears in the lines it cites. This does not prove the claim is true; it stops a citation that points at
180
+ * real lines while describing something else, and an empty claim, from being accepted as evidence.
181
+ */
182
+ export function claimGrounded(claim, citedText) {
183
+ const hay = String(citedText).toLowerCase();
184
+ const words = (String(claim).toLowerCase().match(/[a-z_][a-z0-9_]{3,}/g) || []).filter(w => !CLAIM_STOPWORDS.has(w));
185
+ return words.some(w => hay.includes(w.length > 5 ? w.slice(0, 5) : w));
186
+ }
187
+
188
+ /** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained, and its claim is about those lines. */
153
189
  export function resolveCitation(c, segments, fileContents) {
154
190
  if (c.startLine < 1 || c.endLine < c.startLine || c.endLine - c.startLine > MAX_CITATION_SPAN) return 'range-invalid';
155
191
  const text = fileContents[c.file];
156
192
  if (typeof text !== 'string') return 'file-not-in-snapshot';
157
- if (c.endLine > text.split('\n').length) return 'beyond-end-of-file';
193
+ const fileLines = text.split('\n');
194
+ if (c.endLine > fileLines.length) return 'beyond-end-of-file';
158
195
  const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
159
- return inside ? null : 'outside-provided-context';
196
+ if (!inside) return 'outside-provided-context';
197
+ return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
160
198
  }
161
199
 
162
200
  function decline(base, reasons, extra = {}) {
@@ -245,13 +283,24 @@ export function verifyReview({ raw, pack, entrypoints, manifests, packageIndexes
245
283
  out.downgradedFrom = 'no_issue_observed';
246
284
  out.conclusion = 'insufficient_evidence';
247
285
  out.downgradeReason = 'no-mitigation-cited';
286
+ } else if (dim) {
287
+ // The cited mitigation must be a control the deterministic pass also recognised, and one the text actually shows:
288
+ // a model cannot clear a route by pointing at an arbitrary line or at a control known only from its name.
289
+ const recognised = mit.items.some(c => manifests.some(m => ((m.protection[dim] || {}).evidence || []).some(e => e.file === c.file && e.line >= c.startLine && e.line <= c.endLine)));
290
+ const onlyInferred = manifests.every(m => (m.protection[dim] || {}).state === 'present' && (m.protection[dim] || {}).strength === 'inferred');
291
+ if (!recognised || onlyInferred) {
292
+ out.downgradedFrom = 'no_issue_observed';
293
+ out.conclusion = 'insufficient_evidence';
294
+ out.downgradeReason = !recognised ? 'mitigation-not-a-recognised-control' : 'only-inferred-controls';
295
+ }
248
296
  }
249
297
  }
250
298
 
251
299
  // how the model's reading compares with the deterministic reading (metadata only; neither overrides the other)
252
300
  if (dim) {
253
301
  const states = manifests.map(m => m.protection[dim]?.state);
254
- const present = states.every(s => s === 'present');
302
+ // `present` counts as agreement only when the control is shown by the text; a control known only from its name is neutral.
303
+ const present = manifests.every(m => m.protection[dim]?.state === 'present' && m.protection[dim]?.strength !== 'inferred');
255
304
  const absent = states.some(s => s === 'not_present' || s === 'evaluated_and_ineffective');
256
305
  out.deterministicProtection = { dimension: dim, states };
257
306
  out.deterministicAgreement = out.conclusion === 'potential_issue' ? (absent ? 'agrees' : present ? 'conflicts' : 'neutral') : (out.conclusion === 'no_issue_observed' ? (present ? 'agrees' : absent ? 'conflicts' : 'neutral') : 'neutral');