@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.
@@ -155,15 +155,42 @@ function loadHieMetadata(rootReal, hieDir, { fileContents }) {
155
155
  return { status: 'ok', tool: doc.tool, compiler: doc.compiler, declarations, references, exportHash: (0,canonical/* hashOf */.qQ)({ declarations, references }) };
156
156
  }
157
157
 
158
+ function uncorroboratedReferences(graph, references, fileContents) {
159
+ const byName = new Map();
160
+ for (const sym of graph.symbols) {
161
+ if (sym.kind !== 'function' || sym.id.includes('::<anon>@')) continue;
162
+ if (!byName.has(sym.name)) byName.set(sym.name, []);
163
+ byName.get(sym.name).push(sym);
164
+ }
165
+ const bad = [];
166
+ for (const r of references) {
167
+ const owners = byName.get(r.from) || [];
168
+ if (owners.length !== 1) continue; // an ambiguous or unknown name is already counted as unmatched and adds nothing
169
+ const sym = owners[0];
170
+ const text = fileContents[sym.file];
171
+ const callee = String(r.to).split(/[.:]/).pop();
172
+ const lineText = typeof text === 'string' ? text.split('\n')[r.line - 1] : undefined;
173
+ const inside = r.line >= sym.startLine && r.line <= sym.endLine;
174
+ if (!inside || lineText === undefined || !callee || !lineText.includes(callee)) bad.push(`${r.from}->${r.to}@${r.line}`);
175
+ }
176
+ return bad;
177
+ }
178
+
158
179
  /**
159
180
  * Merge an imported export into the static graph. Returns { graph, semantic } where `semantic` carries the provenance
160
181
  * and the static-versus-compiler difference report.
161
182
  */
162
183
  function mergeHieIntoGraph(graph, meta, fileContents) {
163
- void fileContents;
164
184
  if (meta.status !== 'ok') {
165
185
  return { graph, semantic: { source: ['static-ir'], status: meta.status, reason: meta.reason || null, detail: meta.detail || null, diff: null } };
166
186
  }
187
+ // The export is untrusted even when its file hashes match: it is checked into the repository it describes. A reference
188
+ // is accepted only if the line it names, inside the declaration it names, actually mentions the callee. One that does
189
+ // not degrades the whole import, so a crafted export cannot add edges that the source does not contain.
190
+ const uncorroborated = uncorroboratedReferences(graph, meta.references, fileContents || {});
191
+ if (uncorroborated.length) {
192
+ return { graph, semantic: { source: ['static-ir'], status: 'degraded', reason: 'reference-not-in-source', detail: uncorroborated.slice(0, 5).join(', '), diff: null } };
193
+ }
167
194
  const idsByName = new Map();
168
195
  for (const s of graph.symbols) {
169
196
  if (s.kind !== 'function' || s.id.includes('::<anon>@')) continue;
@@ -414,10 +441,13 @@ async function adjudicate(candidates, opts = {}) {
414
441
  /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
415
442
 
416
443
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
444
+ /* harmony export */ IW: () => (/* binding */ safeLabel),
417
445
  /* harmony export */ Qz: () => (/* binding */ safeRelPath),
446
+ /* harmony export */ bt: () => (/* binding */ cleanForTerminal),
418
447
  /* harmony export */ dj: () => (/* binding */ canonicalJson),
419
448
  /* harmony export */ fQ: () => (/* binding */ normalizeText),
420
449
  /* harmony export */ iq: () => (/* binding */ sliceLines),
450
+ /* harmony export */ kH: () => (/* binding */ cmpCodeUnit),
421
451
  /* harmony export */ qQ: () => (/* binding */ hashOf),
422
452
  /* harmony export */ sc: () => (/* binding */ sha256),
423
453
  /* harmony export */ v6: () => (/* binding */ readRepoFile),
@@ -443,6 +473,31 @@ function sha256(text) {
443
473
  return node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash('sha256').update(text).digest('hex');
444
474
  }
445
475
 
476
+ /**
477
+ * Code-unit order, independent of the host locale. `localeCompare` orders by the process locale, so the same checkout
478
+ * could be sorted differently on two machines; every ordering that reaches a hashed value goes through this instead.
479
+ */
480
+ function cmpCodeUnit(a, b) {
481
+ const x = String(a); const y = String(b);
482
+ return x < y ? -1 : x > y ? 1 : 0;
483
+ }
484
+
485
+ // Control characters (C0, DEL, C1), the bidirectional override/isolate marks and the line/paragraph separators. A
486
+ // repository controls the text of its own route strings and file names, so anything printed to a terminal or placed
487
+ // in a prompt outside the untrusted fence must not be able to carry an escape sequence or reorder what a reader sees.
488
+ const UNSAFE_DISPLAY = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
489
+
490
+ /** Make text safe to print: unsafe characters become '?'. Newline and tab are kept; use `safeLabel` for one-line values. */
491
+ function cleanForTerminal(text) {
492
+ return String(text).replace(UNSAFE_DISPLAY, '?');
493
+ }
494
+
495
+ /** A one-line, bounded, printable form of a name taken from source (a route path, a file name). */
496
+ function safeLabel(text, max = 120) {
497
+ const t = String(text).replace(/[\n\r\t]+/g, ' ').replace(UNSAFE_DISPLAY, '?').replace(/ {2,}/g, ' ').trim();
498
+ return t.length > max ? `${t.slice(0, max - 3)}...` : t;
499
+ }
500
+
446
501
  /** CRLF/CR -> LF so a Windows checkout and a Unix one produce the same spans and hashes. */
447
502
  function normalizeText(text) {
448
503
  return String(text).replace(/\r\n?/g, '\n');
@@ -762,25 +817,29 @@ var state_dir = __webpack_require__(31174);
762
817
 
763
818
 
764
819
 
820
+
765
821
  const flag = (a, k) => (a.flags[k] === undefined ? undefined : a.flags[k]);
766
822
  const truthy = (v) => v === true || (typeof v === 'string' && v !== 'false' && v !== '0');
767
823
  const num = (v) => (v === undefined || v === true ? undefined : Number(v));
768
824
 
825
+ /** Everything this command prints goes through here: repository-supplied names must not carry escape sequences to a terminal. */
826
+ function say(text) { console.log((0,canonical/* cleanForTerminal */.bt)(text)); }
827
+
769
828
  function out(format, text, obj) {
770
- if (format === 'json') console.log(JSON.stringify(obj, null, 2));
771
- else if (format === 'jsonl') console.log(JSON.stringify(obj));
772
- else console.log(text);
829
+ if (format === 'json') say(JSON.stringify(obj, null, 2));
830
+ else if (format === 'jsonl') say(JSON.stringify(obj));
831
+ else say(text);
773
832
  }
774
833
 
775
834
  function usage() {
776
- console.log([
835
+ say([
777
836
  'Usage: agentic-security context <subcommand> [--root <dir>] [--format text|json|jsonl]',
778
837
  '',
779
838
  ' build Resolve entry points and write one inspectable context bundle each (no model, no network)',
780
839
  ' [--entrypoints http,webhook,queue,cron,upload,cli] [--profile compact|standard|extended] [--max-tokens N] [--route <label|id>] [--no-write]',
781
840
  ' inspect Show one bundle: --route <label|id> | --hash <contextHash> [--show-context] [--package N]',
782
841
  ' diff Routes added/removed/changed between two refs: --from <ref> [--to <ref>] (omit --to for the working tree)',
783
- ' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--concurrency N]',
842
+ ' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--allow-remote] [--concurrency N]',
784
843
  ' [--max-calls N] [--max-wall-seconds N] [--max-cost-usd N --cost-per-call-usd N] [--retry-failed] [--refute] [--strict]',
785
844
  ' [--dry-run] [--export-batch] [--import-batch <results.jsonl> --batch-id <id>]',
786
845
  ' status Coverage so far: entry points, bundles, review jobs',
@@ -802,7 +861,7 @@ async function doBuild(root, args, { write = true } = {}) {
802
861
  if (flag(args, 'max-tokens')) config.context.maxTokens = num(flag(args, 'max-tokens'));
803
862
  const events = [];
804
863
  const jsonl = flag(args, 'format') === 'jsonl';
805
- const built = await (0,build/* buildContext */.g)(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) console.log(JSON.stringify(e)); } });
864
+ const built = await (0,build/* buildContext */.g)(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) say(JSON.stringify(e)); } });
806
865
  if (!built.ok) return { ok: false, built, config, problems };
807
866
  let persisted = null;
808
867
  if (write) persisted = persistBuild(root, built, config);
@@ -895,7 +954,7 @@ async function cmdInspect(root, args, format) {
895
954
  ` boundaries (calls into libraries, not first-party code): ${m.boundaries.length}`,
896
955
  ];
897
956
  if (text) lines.push('', text.ok ? text.text : `(context text unavailable: ${text.code})`);
898
- console.log(lines.join('\n'));
957
+ say(lines.join('\n'));
899
958
  return 0;
900
959
  }
901
960
 
@@ -995,7 +1054,7 @@ async function cmdExport(root, args, format) {
995
1054
  if (!h || h === true) { out(format, 'context export: --hash <contextHash> is required', { ok: false }); return 1; }
996
1055
  const r = (0,store/* exportBundle */.Nl)(root, String(h));
997
1056
  if (!r.ok) { out(format, `context export: refused (${r.problems.join(', ')})`, { ok: false, problems: r.problems }); return 1; }
998
- console.log(JSON.stringify(r.export, null, 2));
1057
+ say(JSON.stringify(r.export, null, 2));
999
1058
  return 0;
1000
1059
  }
1001
1060
 
@@ -1043,7 +1102,7 @@ async function cmdReview(root, args, format) {
1043
1102
  const r = await doBuild(root, args, { write: true });
1044
1103
  if (!r.ok) { out(format, `context review: build failed (${r.built.reason})`, { ok: false, reason: r.built.reason }); return r.built.reason === 'too-many-files' ? 2 : 1; }
1045
1104
  const { built } = r;
1046
- const model = (0,review/* describeModel */.Vy)();
1105
+ const model = (0,review/* describeModel */.Vy)({ allowRemote: truthy(flag(args, 'allow-remote')) || r.config.review.allowRemote === true });
1047
1106
  const { jobs, droppedByJobLimit } = (0,queue/* planJobs */.KR)({ bundles: built.bundles, packs, model, maxJobs: Math.min(queue/* HARD_LIMITS */.BS.maxJobs, num(flag(args, 'max-jobs')) || queue/* HARD_LIMITS */.BS.maxJobs) });
1048
1107
  const loadJob = loadJobFrom(built, packs);
1049
1108
  const ctxBase = { root: built.root, fileContents: built.fileContents, model };
@@ -1081,7 +1140,7 @@ async function cmdReview(root, args, format) {
1081
1140
  try {
1082
1141
  result = await (0,queue/* runQueue */.rj)(jobs, {
1083
1142
  ...ctxBase, invoker, loadJob, signal: ac.signal, retryFailed: truthy(flag(args, 'retry-failed')),
1084
- onEvent: jsonl ? (e) => console.log(JSON.stringify(e)) : undefined,
1143
+ onEvent: jsonl ? (e) => say(JSON.stringify(e)) : undefined,
1085
1144
  limits: {
1086
1145
  concurrency: num(flag(args, 'concurrency')) || config.review.concurrency,
1087
1146
  maxCalls: num(flag(args, 'max-calls')) || config.review.maxCalls,
@@ -1199,7 +1258,7 @@ const DEFAULT_CONFIG = Object.freeze({
1199
1258
  maxFiles: 3000,
1200
1259
  semanticMetadata: { mode: 'off', haskellHieDir: null },
1201
1260
  context: { profile: 'standard', maxTokens: null, pruneLowValue: true, maxSymbols: 60, maxDepth: 12 },
1202
- review: { enabled: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
1261
+ review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
1203
1262
  replay: { contentAddressed: true, retention: 'inherit' },
1204
1263
  });
1205
1264
 
@@ -1240,6 +1299,7 @@ function normalizeConfig(raw) {
1240
1299
  },
1241
1300
  review: {
1242
1301
  enabled: rv.enabled === true,
1302
+ allowRemote: rv.allowRemote === true,
1243
1303
  questionPacks: Array.isArray(rv.questionPacks) && rv.questionPacks.length ? rv.questionPacks.filter(x => typeof x === 'string') : d.review.questionPacks,
1244
1304
  concurrency: clamp(rv.concurrency, 1, 16, d.review.concurrency),
1245
1305
  maxCalls: clamp(rv.maxCalls, 1, 5000, d.review.maxCalls),
@@ -1278,7 +1338,7 @@ function loadConfig(root) {
1278
1338
  /* harmony export */ kl: () => (/* binding */ analyzeSymbolText),
1279
1339
  /* harmony export */ qr: () => (/* binding */ dependencyParams)
1280
1340
  /* harmony export */ });
1281
- /* unused harmony exports CONTROL_KINDS, isSecuritySignificant */
1341
+ /* unused harmony exports CONTROL_KINDS, ownerBinding, isSecuritySignificant */
1282
1342
  //
1283
1343
  // Security-relevant facts inside ONE symbol's source: authentication / authorization / ownership / tenant controls,
1284
1344
  // and the sensitive operations they have to precede.
@@ -1328,7 +1388,46 @@ function signatureText(lines, declLine, maxLines = 12) {
1328
1388
  for (let i = declLine - 1; i < Math.min(lines.length, declLine - 1 + maxLines); i++) { t += ' ' + lines[i]; if (/\)\s*(?:->[^:]*)?:\s*(?:#.*)?$/.test(lines[i])) break; }
1329
1389
  return t;
1330
1390
  }
1331
- const OWNER_FIELD = /(?:owner|tenant|org(?:anization)?|account|created_?by|author|user_?id|userId|team|workspace)/i;
1391
+ // An owner/tenant FIELD NAME, not any word that merely contains one: `ownerId`, `owner_id`, `tenant`, `getOwnerId`
1392
+ // qualify; `owner_name` and `cols` do not (the trailing boundary rejects them).
1393
+ const OWNER_KEY = '\\w*?(?:[Oo]wner|[Tt]enant|[Oo]rg(?:ani[sz]ation)?|[Aa]ccount|[Cc]reated_?[Bb]y|[Aa]uthor|[Tt]eam|[Ww]orkspace)(?:_?(?:[Ii]d|ID))?\\b|[Uu]ser_?(?:[Ii]d|ID)\\b';
1394
+ const OWNER_KEY_RE = new RegExp(`(?<![\\w])(?:${OWNER_KEY})`);
1395
+ const TENANT_WORD = /tenant|org|workspace|team|account/i;
1396
+ // a SQL string comparing an owner column with a bound parameter: `owner_id = ?`, `owner_id = $1`, `tenant_id = :t`
1397
+ const SQL_OWNER_PLACEHOLDER = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*(?:=|\\bIN\\b|\\bin\\b)\\s*\\(?\\s*(?:\\?|\\$\\d+|:\\w+|%s|@\\w+)`);
1398
+ // an owner column compared with the principal written straight into the string: `owner_id = ${req.user.id}`
1399
+ const SQL_OWNER_INTERPOLATED = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*=\\s*(?:'?\\s*\\+\\s*|\\$\\{\\s*)(?:${QUALIFIED_PRINCIPAL.source}|${BARE_PRINCIPAL.source})`);
1400
+ const PRINCIPAL_GLOBAL = new RegExp(QUALIFIED_PRINCIPAL.source, 'g');
1401
+ const STRING_LITERAL = /(["'`])(?:\\.|(?!\1).)*\1/g;
1402
+
1403
+ /**
1404
+ * Is an owner/tenant FIELD actually tied to the principal in this text? The field and the principal must sit in the
1405
+ * same clause (split on , ; && || braces), so `{ audit: req.user.id, cols: 'owner_name' }` is a decoy and
1406
+ * `{ ownerId: req.user.id }` is a filter. A SQL string with an owner column against a bound placeholder counts when the
1407
+ * principal is passed in the same call. Returns null or { level: 'qualified'|'bare', tenant }.
1408
+ */
1409
+ function ownerBinding(text, extra = null) {
1410
+ let best = null;
1411
+ const take = (level, tenant) => { if (!best || (level === 'qualified' && best.level !== 'qualified')) best = { level, tenant }; };
1412
+ const code = text.replace(STRING_LITERAL, (m) => (SQL_OWNER_PLACEHOLDER.test(m) || SQL_OWNER_INTERPOLATED.test(m) ? m : '""'));
1413
+ for (const clause of code.split(/[,;{}]|&&|\|\|/)) {
1414
+ // the principal's own name (`req.user`, `current_user`) is not an owner field, so it is removed before looking for one
1415
+ const forKey = clause.replace(STRING_LITERAL, ' ').replace(PRINCIPAL_GLOBAL, ' ');
1416
+ if (!OWNER_KEY_RE.test(forKey)) continue;
1417
+ const pr = principalIn(clause, extra);
1418
+ if (pr) take(pr, TENANT_WORD.test(clause));
1419
+ }
1420
+ for (const m of text.match(STRING_LITERAL) || []) {
1421
+ const placeholder = SQL_OWNER_PLACEHOLDER.test(m);
1422
+ if (placeholder) {
1423
+ const pr = principalIn(text, extra);
1424
+ if (pr) take(pr, TENANT_WORD.test(m));
1425
+ }
1426
+ if (SQL_OWNER_INTERPOLATED.test(m)) take('qualified', TENANT_WORD.test(m));
1427
+ }
1428
+ return best;
1429
+ }
1430
+
1332
1431
  const COMPARE = /(?:===|!==|==|!=|\.equals\(|\bis not\b|\bis\b|\bnot in\b|\bin\b|\.includes\(|\.has\()/;
1333
1432
  const REJECT = /(?:\breturn\b|\bthrow\b|\braise\b|\babort\b|\bexit\b|\b40[0-9]\b|\bforbidden\b|\bunauthori[sz]ed\b|\bHttpError\b|\breject\b|\bnot_?allowed\b|\bdeny\b|\bdenied\b|throwError|\bfinish\b|\bhalt\b|\bPermissionDenied\b|\bAccessDenied\b|status\()/i;
1334
1433
 
@@ -1339,6 +1438,13 @@ const DB_RECEIVER = /(?:^|\b)(?:db|database|conn|connection|pool|client|cursor|s
1339
1438
  const SQL_TEXT = /\b(?:select\s[\s\S]{1,120}\sfrom|insert\s+into|update\s+\w+\s+set|delete\s+from)\b/i;
1340
1439
  const HS_SENSITIVE = /(?:^|\.)(?:runDB|selectList|selectFirst|getBy|getJust|insert_?|insertEntity|update|updateWhere|delete|deleteWhere|replace|upsert|query|query_|execute|execute_|readFile|writeFile|callProcess|readProcess)$|^(?:Database\.|System\.Process\.|System\.Directory\.)/;
1341
1440
 
1441
+ // Names that answer a question rather than enforce a rule.
1442
+ const PREDICATE_NAME = /^(?:is|has|can|cannot|allow|owned|belongs)/i;
1443
+ function bareCallStatement(trimmedLine, name) {
1444
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1445
+ return new RegExp(`^(?:await\\s+)?${esc}\\s*\\(`).test(trimmedLine) && /\)\s*;?\s*(?:\/\/.*|\/\*.*)?$/.test(trimmedLine);
1446
+ }
1447
+
1342
1448
  const WRITE_VERB = /(?:insert|update|upsert|delete|remove|destroy|save|create|write|append|unlink|rm|exec|spawn|system|popen|post|put|patch|replace)/i;
1343
1449
 
1344
1450
  function kindOfAuthName(name) {
@@ -1413,8 +1519,12 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
1413
1519
  }
1414
1520
  for (const name of calls) {
1415
1521
  if (AUTH_NAME.test(name) && !/^(?:if|for|while|switch|catch|function)$/.test(name)) {
1416
- const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(name.split(/[.:]/).pop());
1417
- push({ line: ln, category: 'control', kind: kindOfAuthName(name), subkind: null, evidence: 'inferred', basis: 'call-name', rejectionSeen: rej, text: clip(raw) });
1522
+ const lastSeg = name.split(/[.:]/).pop();
1523
+ // A predicate (isOwner, hasRole, can) only returns an answer. Called as a bare statement its answer is thrown away,
1524
+ // so it guards nothing, whatever the lines around it say.
1525
+ const discarded = !haskell && PREDICATE_NAME.test(lastSeg) && bareCallStatement(t, name);
1526
+ const rej = !discarded && (REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(lastSeg));
1527
+ push({ line: ln, category: 'control', kind: kindOfAuthName(name), subkind: null, evidence: 'inferred', basis: discarded ? 'call-name-result-discarded' : 'call-name', rejectionSeen: rej, text: clip(raw) });
1418
1528
  }
1419
1529
  const last = name.split(/[.:]/).pop();
1420
1530
  const recv = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : '';
@@ -1430,9 +1540,9 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
1430
1540
  push({ line: ln, category: 'sensitive', kind: sub, subkind: verb, evidence: 'observed', basis: 'call-name', rejectionSeen: null, text: clip(raw) });
1431
1541
  // a data operation whose own statement mentions the principal and an owner/tenant field is scoped by it
1432
1542
  const stmt = statementAt(lines, ln);
1433
- const pr = sub.startsWith('data-') && OWNER_FIELD.test(stmt) ? principalIn(stmt, extraPrincipal) : null;
1543
+ const pr = sub.startsWith('data-') ? ownerBinding(stmt, extraPrincipal) : null;
1434
1544
  if (pr) {
1435
- push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(stmt) ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr === 'qualified' ? 'observed' : 'inferred', basis: pr === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
1545
+ push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
1436
1546
  }
1437
1547
  }
1438
1548
  }
@@ -1440,17 +1550,17 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
1440
1550
  const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
1441
1551
  push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
1442
1552
  const stmt = statementAt(lines, ln);
1443
- const pr = OWNER_FIELD.test(stmt) ? principalIn(stmt, extraPrincipal) : null;
1553
+ const pr = ownerBinding(stmt, extraPrincipal);
1444
1554
  if (pr) {
1445
- push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(stmt) ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr === 'qualified' ? 'observed' : 'inferred', basis: pr === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
1555
+ push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
1446
1556
  }
1447
1557
  }
1448
1558
 
1449
1559
  // a resource field compared with the principal: the ownership check itself
1450
- const cmpPr = COMPARE.test(raw) && OWNER_FIELD.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? principalIn(raw, extraPrincipal) : null;
1560
+ const cmpPr = COMPARE.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? ownerBinding(raw, extraPrincipal) : null;
1451
1561
  if (cmpPr) {
1452
1562
  const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' '));
1453
- push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(raw) ? 'tenant-scope' : 'ownership', subkind: null, evidence: cmpPr === 'qualified' ? 'observed' : 'inferred', basis: cmpPr === 'qualified' ? 'comparison' : 'comparison-bare-identifier', rejectionSeen: rej, text: clip(raw) });
1563
+ push({ line: ln, category: 'control', kind: cmpPr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: cmpPr.level === 'qualified' ? 'observed' : 'inferred', basis: cmpPr.level === 'qualified' ? 'comparison' : 'comparison-bare-identifier', rejectionSeen: rej, text: clip(raw) });
1454
1564
  }
1455
1565
  }
1456
1566
  // A function whose NAME says it is a guard and whose BODY shows a rejection path is a guard function. This is
@@ -1805,7 +1915,7 @@ async function collectEntrypoints({ fileContents, graph, config = {} }) {
1805
1915
  const n = (ord.get(stem) || 0) + 1;
1806
1916
  ord.set(stem, n);
1807
1917
  r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
1808
- r.label = `${r.method ? r.method + ' ' : ''}${r.pathTemplate}`.trim() || r.kind;
1918
+ r.label = (0,_canonical_js__WEBPACK_IMPORTED_MODULE_0__/* .safeLabel */ .IW)(`${r.method ? r.method + ' ' : ''}${r.pathTemplate}`) || r.kind;
1809
1919
  void hname;
1810
1920
  let disposition;
1811
1921
  const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
@@ -3134,7 +3244,8 @@ function importBatch(root, batchId, resultsText, ctx) {
3134
3244
  /* harmony export */ });
3135
3245
  /* unused harmony exports DEFAULT_LIMITS, PROTECTION_DIMENSIONS */
3136
3246
  /* harmony import */ var _controls_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(17202);
3137
- /* harmony import */ var _graph_schema_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44287);
3247
+ /* harmony import */ var _canonical_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44652);
3248
+ /* harmony import */ var _graph_schema_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(44287);
3138
3249
  //
3139
3250
  // Deterministic, bounded reachability from an entry point's handler.
3140
3251
  //
@@ -3151,6 +3262,7 @@ function importBatch(root, batchId, resultsText, ctx) {
3151
3262
 
3152
3263
 
3153
3264
 
3265
+
3154
3266
  const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
3155
3267
 
3156
3268
  function indexGraph(graph) {
@@ -3207,7 +3319,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
3207
3319
  }
3208
3320
 
3209
3321
  // middleware runs before the handler: it is enqueued first and ordered at line -1
3210
- for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || String(a.name).localeCompare(String(b.name)))) {
3322
+ for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || (0,_canonical_js__WEBPACK_IMPORTED_MODULE_1__/* .cmpCodeUnit */ .kH)(a.name, b.name))) {
3211
3323
  if (mw.symbolId && byId.has(mw.symbolId)) enqueue(mw.symbolId, 0, mw.inherited ? 'inherited-middleware' : 'middleware', -1, [mw.symbolId], mw.resolution === 'resolved' ? 'exact' : 'name-match');
3212
3324
  }
3213
3325
  if (root.symbolId) {
@@ -3239,7 +3351,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
3239
3351
  if (e.to) {
3240
3352
  if (cur.depth + 1 > lim.maxDepth) { capped = true; continue; }
3241
3353
  enqueue(e.to, cur.depth + 1, 'callee', via, [...cur.path, e.to], 'exact');
3242
- } else if (_graph_schema_js__WEBPACK_IMPORTED_MODULE_1__/* .MATERIAL_REASONS */ .U2.has(e.unresolvedReason)) {
3354
+ } else if (_graph_schema_js__WEBPACK_IMPORTED_MODULE_2__/* .MATERIAL_REASONS */ .U2.has(e.unresolvedReason)) {
3243
3355
  unknowns.push({ type: e.kind, site: `${sym.file}:${e.line}`, from: cur.symbolId, calleeName: e.calleeName, reason: e.unresolvedReason, material: true, candidates: e.candidates });
3244
3356
  // Candidates are shown to the model as CANDIDATES (low confidence); the unknown stays material regardless.
3245
3357
  if (e.candidates.length && e.candidates.length <= 3 && cur.depth + 1 <= lim.maxDepth) {
@@ -3300,11 +3412,14 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3300
3412
  for (const dim of PROTECTION_DIMENSIONS) {
3301
3413
  const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
3302
3414
  const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
3303
- const before = ctl.filter(f => firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens));
3415
+ // A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
3416
+ const effective = (f) => f.rejectionSeen !== false;
3417
+ const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
3304
3418
  const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
3305
3419
  let state; let why;
3306
3420
  if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
3307
3421
  else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
3422
+ else if (ctl.length && ctl.every(f => !effective(f))) { state = 'evaluated_and_ineffective'; why = 'a check is called but nothing in view acts on its answer'; }
3308
3423
  else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
3309
3424
  else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
3310
3425
  else if (unknowns.some(u => u.material) || capped) { state = 'not_resolved'; why = 'an unresolved call or a traversal limit could hide a control'; }
@@ -3312,6 +3427,8 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3312
3427
  else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
3313
3428
  dims[dim] = {
3314
3429
  state, why, firstSensitiveLine: firstSens,
3430
+ // How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
3431
+ strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
3315
3432
  evidence: ctl.slice(0, 6).map(f => ({ file: f.file, line: f.line, kind: f.kind, evidence: f.evidence, basis: f.basis, order: firstSens === null ? 'no-sensitive-op' : f.effLine < firstSens ? 'before-sensitive' : f.effLine > firstSens ? 'after-sensitive' : 'same-step', rejectionSeen: f.rejectionSeen })),
3316
3433
  };
3317
3434
  }
@@ -3333,7 +3450,7 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3333
3450
  /* harmony export */ sj: () => (/* binding */ verifyReview),
3334
3451
  /* harmony export */ z6: () => (/* binding */ runReviewOnce)
3335
3452
  /* harmony export */ });
3336
- /* unused harmony exports CONCLUSIONS, LIKELIHOODS, resolveCitation, isRetryableError */
3453
+ /* unused harmony exports CONCLUSIONS, LIKELIHOODS, isLoopbackEndpoint, claimGrounded, resolveCitation, isRetryableError */
3337
3454
  /* harmony import */ var _util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(17097);
3338
3455
  /* harmony import */ var _egress_redact_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(11723);
3339
3456
  /* harmony import */ var _egress_policy_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(45712);
@@ -3371,7 +3488,7 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3371
3488
  const CONCLUSIONS = Object.freeze(['potential_issue', 'no_issue_observed', 'insufficient_evidence']);
3372
3489
  const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
3373
3490
  const FORBIDDEN_MODEL_FIELDS = Object.freeze(['severity', 'proofTier', 'proof_tier', 'confirmation', 'confirmed', 'status', 'verdict', 'shipVerdict', 'ship_verdict', 'exploitability', 'cvss', 'exploit_confirmed', 'EXPLOIT_CONFIRMED']);
3374
- const MAX_CITATION_SPAN = 200;
3491
+ const MAX_CITATION_SPAN = 60;
3375
3492
  const MAX_STR = 1200;
3376
3493
  const MAX_ITEMS = 24;
3377
3494
 
@@ -3409,8 +3526,10 @@ function buildReviewPrompt({ pack, entrypoints, packageTexts, contextHash }) {
3409
3526
  for (const g of pack.guidance) lines.push(`- ${g}`);
3410
3527
  lines.push('', 'OUTPUT (JSON, evidence listed first):', JSON.stringify(OUTPUT_SHAPE, null, 2), '');
3411
3528
  entrypoints.forEach((ep, i) => {
3412
- const fenced = (0,_util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__/* .fenceUntrusted */ .C0)(packageTexts[i], `context-${i + 1}`);
3413
- lines.push(`ENTRY POINT ${i + 1}: ${ep.label} (id ${ep.id})`);
3529
+ // The label comes from a route string in the repository, so it is DATA: it goes inside the fence, sanitised, and never
3530
+ // into the instruction text around it.
3531
+ const fenced = (0,_util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__/* .fenceUntrusted */ .C0)(`ENTRY POINT LABEL (data): ${(0,_canonical_js__WEBPACK_IMPORTED_MODULE_5__/* .safeLabel */ .IW)(ep.label, 120)}\n${packageTexts[i]}`, `context-${i + 1}`);
3532
+ lines.push(`ENTRY POINT ${i + 1} (id ${ep.id})`);
3414
3533
  lines.push(fenced.text, '');
3415
3534
  });
3416
3535
  const prompt = lines.join('\n');
@@ -3446,18 +3565,39 @@ function redactPackageText(text, { scanRoot }) {
3446
3565
  }
3447
3566
 
3448
3567
  /** Provider/model identity used in the job key, from the same resolver the rest of the discovery layer uses. */
3449
- function describeModel() {
3568
+ function isLoopbackEndpoint(endpoint) {
3569
+ if (!endpoint) return false;
3570
+ const parts = String(endpoint).split(',').map(x => x.trim()).filter(Boolean);
3571
+ if (!parts.length) return false;
3572
+ return parts.every(u => {
3573
+ try {
3574
+ const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
3575
+ return h === 'localhost' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h);
3576
+ } catch { return false; }
3577
+ });
3578
+ }
3579
+
3580
+ function describeModel({ allowRemote = false } = {}) {
3581
+ let m;
3450
3582
  try {
3451
3583
  const r = (0,_llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_3__.resolveProvider)({ role: 'hunt' });
3452
- 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 };
3584
+ 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 };
3453
3585
  } catch { /* fall through */ }
3454
- const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
3455
- return { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
3586
+ if (!m) {
3587
+ const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
3588
+ m = { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
3589
+ }
3590
+ // `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.
3591
+ const remote = m.provider ? !(m.provider === 'local' || isLoopbackEndpoint(m.endpoint)) : false;
3592
+ return { ...m, remote, remoteApproved: allowRemote === true };
3456
3593
  }
3457
3594
 
3458
3595
  /** Evaluate the egress policy for every file that would appear in the prompt. Returns { allowed, reason }. */
3459
3596
  function checkEgressForFiles(files, { scanRoot, model }) {
3460
3597
  if (!model.endpoint) return { allowed: true, evaluated: false };
3598
+ // Source bundles leave the machine only when the operator has said so. A loopback endpoint needs no approval; any
3599
+ // other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
3600
+ 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 };
3461
3601
  for (const file of files) {
3462
3602
  const d = (0,_egress_policy_js__WEBPACK_IMPORTED_MODULE_2__/* .evaluateEgress */ .nn)({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file });
3463
3603
  if (!d.allowed) return { allowed: false, reason: d.reason, file, evaluated: true };
@@ -3493,14 +3633,29 @@ function normalizeCitations(list) {
3493
3633
  return { items, malformed };
3494
3634
  }
3495
3635
 
3496
- /** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained. */
3636
+ 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']);
3637
+
3638
+ /**
3639
+ * A claim is grounded when at least one meaningful word in it (or the first five letters of a longer one, so "ownership"
3640
+ * meets `ownerId`) appears in the lines it cites. This does not prove the claim is true; it stops a citation that points at
3641
+ * real lines while describing something else, and an empty claim, from being accepted as evidence.
3642
+ */
3643
+ function claimGrounded(claim, citedText) {
3644
+ const hay = String(citedText).toLowerCase();
3645
+ const words = (String(claim).toLowerCase().match(/[a-z_][a-z0-9_]{3,}/g) || []).filter(w => !CLAIM_STOPWORDS.has(w));
3646
+ return words.some(w => hay.includes(w.length > 5 ? w.slice(0, 5) : w));
3647
+ }
3648
+
3649
+ /** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained, and its claim is about those lines. */
3497
3650
  function resolveCitation(c, segments, fileContents) {
3498
3651
  if (c.startLine < 1 || c.endLine < c.startLine || c.endLine - c.startLine > MAX_CITATION_SPAN) return 'range-invalid';
3499
3652
  const text = fileContents[c.file];
3500
3653
  if (typeof text !== 'string') return 'file-not-in-snapshot';
3501
- if (c.endLine > text.split('\n').length) return 'beyond-end-of-file';
3654
+ const fileLines = text.split('\n');
3655
+ if (c.endLine > fileLines.length) return 'beyond-end-of-file';
3502
3656
  const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
3503
- return inside ? null : 'outside-provided-context';
3657
+ if (!inside) return 'outside-provided-context';
3658
+ return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
3504
3659
  }
3505
3660
 
3506
3661
  function decline(base, reasons, extra = {}) {
@@ -3589,13 +3744,24 @@ function verifyReview({ raw, pack, entrypoints, manifests, packageIndexes, fileC
3589
3744
  out.downgradedFrom = 'no_issue_observed';
3590
3745
  out.conclusion = 'insufficient_evidence';
3591
3746
  out.downgradeReason = 'no-mitigation-cited';
3747
+ } else if (dim) {
3748
+ // The cited mitigation must be a control the deterministic pass also recognised, and one the text actually shows:
3749
+ // a model cannot clear a route by pointing at an arbitrary line or at a control known only from its name.
3750
+ 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)));
3751
+ const onlyInferred = manifests.every(m => (m.protection[dim] || {}).state === 'present' && (m.protection[dim] || {}).strength === 'inferred');
3752
+ if (!recognised || onlyInferred) {
3753
+ out.downgradedFrom = 'no_issue_observed';
3754
+ out.conclusion = 'insufficient_evidence';
3755
+ out.downgradeReason = !recognised ? 'mitigation-not-a-recognised-control' : 'only-inferred-controls';
3756
+ }
3592
3757
  }
3593
3758
  }
3594
3759
 
3595
3760
  // how the model's reading compares with the deterministic reading (metadata only; neither overrides the other)
3596
3761
  if (dim) {
3597
3762
  const states = manifests.map(m => m.protection[dim]?.state);
3598
- const present = states.every(s => s === 'present');
3763
+ // `present` counts as agreement only when the control is shown by the text; a control known only from its name is neutral.
3764
+ const present = manifests.every(m => m.protection[dim]?.state === 'present' && m.protection[dim]?.strength !== 'inferred');
3599
3765
  const absent = states.some(s => s === 'not_present' || s === 'evaluated_and_ineffective');
3600
3766
  out.deterministicProtection = { dimension: dim, states };
3601
3767
  out.deterministicAgreement = out.conclusion === 'potential_issue' ? (absent ? 'agrees' : present ? 'conflicts' : 'neutral') : (out.conclusion === 'no_issue_observed' ? (present ? 'agrees' : absent ? 'conflicts' : 'neutral') : 'neutral');