@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.
@@ -1 +1 @@
1
- edcc9e08f076b2bc50946c512557c15038e337fa568bc490586fc882ca1c11bc agentic-security.mjs
1
+ e7ecb2875e67c227bbc9a9643742632dbf486accded83c1ada02043d1c5307d1 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.172.0",
3
+ "version": "0.172.1",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -43,13 +43,13 @@
43
43
  "@babel/core": "^8.0.5",
44
44
  "@babel/preset-react": "^8.0.1",
45
45
  "@babel/preset-typescript": "^8.0.1",
46
- "js-yaml": "^5.4.2",
46
+ "js-yaml": "^5.5.0",
47
47
  "safe-regex": "^2.1.1"
48
48
  },
49
49
  "optionalDependencies": {
50
50
  "tree-sitter-wasms": "0.1.13",
51
51
  "web-tree-sitter": "0.20.8",
52
- "z3-solver": "^5.3.0"
52
+ "z3-solver": "^5.3.1"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/node": "^26.6.5",
@@ -121,7 +121,7 @@
121
121
  "verification:replay-example": "node ../scripts/verification-replay-example.mjs",
122
122
  "test:lifecycle": "node --test test/ci-hang-protection.test.js test/ci-test-shards.test.js test/pre-bash-guard.test.js test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/trust-boundary.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs test/workflow-confinement.test.js test/check-bundle-source.test.js test/ship.test.js",
123
123
  "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
124
- "test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js test/local-only-mode.test.js test/context-intelligence/build-js.test.js test/context-intelligence/build-python-haskell.test.js test/context-intelligence/hie-adapter.test.js test/context-intelligence/store.test.js test/context-intelligence/review-queue.test.js test/context-intelligence/packer-egress.test.js test/context-intelligence/evaluation.test.js test/context-intelligence/queue-diff.test.js test/context-intelligence/cli.test.js test/context-intelligence/resilience.test.js test/context-intelligence/frameworks.test.js test/context-intelligence/skew-git-rls.test.js test/context-intelligence/git-diff-candidates.test.js",
124
+ "test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js test/local-only-mode.test.js test/context-intelligence/build-js.test.js test/context-intelligence/build-python-haskell.test.js test/context-intelligence/hie-adapter.test.js test/context-intelligence/store.test.js test/context-intelligence/review-queue.test.js test/context-intelligence/packer-egress.test.js test/context-intelligence/evaluation.test.js test/context-intelligence/queue-diff.test.js test/context-intelligence/cli.test.js test/context-intelligence/resilience.test.js test/context-intelligence/frameworks.test.js test/context-intelligence/skew-git-rls.test.js test/context-intelligence/premortem.test.js test/context-intelligence/git-diff-candidates.test.js",
125
125
  "eval:independent": "node ../bench/independent-eval/runner.mjs",
126
126
  "eval:independent:gate": "node ../bench/independent-eval/runner.mjs --gate default",
127
127
  "smoke": "node bin/agentic-security.js scan test/fixtures/vulnerable-js",
@@ -10,6 +10,10 @@ Deterministic Context Intelligence (the `context` command). Opt-in and additive:
10
10
  - Model output cannot set severity. A citation outside the bundle invalidates the review. Severity comes from the existing confirmation tier.
11
11
  - Store writes are atomic and refuse links and unsafe paths. Stored packages are re-checked against their hashes.
12
12
  - A job with no verified result is missing coverage, never a clean result.
13
+ - Names from the repository (route strings, file names) are data: sanitised before they are printed, never placed in prompt text outside the fence.
14
+ - Source reaches a non-loopback endpoint only with an explicit approval, and the egress policy still applies after it.
15
+ - A citation is checked for range AND for being about the cited lines. `no_issue_observed` needs a control the deterministic pass also found.
16
+ - Orderings that reach a hashed value use `cmpCodeUnit`, never the locale.
13
17
  - Evaluation is synthetic. Scorecards must say so and must not state an accuracy claim. Holdout use is ledgered.
14
18
 
15
19
  ## Where things are
@@ -85,15 +85,42 @@ export function loadHieMetadata(rootReal, hieDir, { fileContents }) {
85
85
  return { status: 'ok', tool: doc.tool, compiler: doc.compiler, declarations, references, exportHash: hashOf({ declarations, references }) };
86
86
  }
87
87
 
88
+ function uncorroboratedReferences(graph, references, fileContents) {
89
+ const byName = new Map();
90
+ for (const sym of graph.symbols) {
91
+ if (sym.kind !== 'function' || sym.id.includes('::<anon>@')) continue;
92
+ if (!byName.has(sym.name)) byName.set(sym.name, []);
93
+ byName.get(sym.name).push(sym);
94
+ }
95
+ const bad = [];
96
+ for (const r of references) {
97
+ const owners = byName.get(r.from) || [];
98
+ if (owners.length !== 1) continue; // an ambiguous or unknown name is already counted as unmatched and adds nothing
99
+ const sym = owners[0];
100
+ const text = fileContents[sym.file];
101
+ const callee = String(r.to).split(/[.:]/).pop();
102
+ const lineText = typeof text === 'string' ? text.split('\n')[r.line - 1] : undefined;
103
+ const inside = r.line >= sym.startLine && r.line <= sym.endLine;
104
+ if (!inside || lineText === undefined || !callee || !lineText.includes(callee)) bad.push(`${r.from}->${r.to}@${r.line}`);
105
+ }
106
+ return bad;
107
+ }
108
+
88
109
  /**
89
110
  * Merge an imported export into the static graph. Returns { graph, semantic } where `semantic` carries the provenance
90
111
  * and the static-versus-compiler difference report.
91
112
  */
92
113
  export function mergeHieIntoGraph(graph, meta, fileContents) {
93
- void fileContents;
94
114
  if (meta.status !== 'ok') {
95
115
  return { graph, semantic: { source: ['static-ir'], status: meta.status, reason: meta.reason || null, detail: meta.detail || null, diff: null } };
96
116
  }
117
+ // The export is untrusted even when its file hashes match: it is checked into the repository it describes. A reference
118
+ // is accepted only if the line it names, inside the declaration it names, actually mentions the callee. One that does
119
+ // not degrades the whole import, so a crafted export cannot add edges that the source does not contain.
120
+ const uncorroborated = uncorroboratedReferences(graph, meta.references, fileContents || {});
121
+ if (uncorroborated.length) {
122
+ return { graph, semantic: { source: ['static-ir'], status: 'degraded', reason: 'reference-not-in-source', detail: uncorroborated.slice(0, 5).join(', '), diff: null } };
123
+ }
97
124
  const idsByName = new Map();
98
125
  for (const s of graph.symbols) {
99
126
  if (s.kind !== 'function' || s.id.includes('::<anon>@')) continue;
@@ -14,6 +14,31 @@ export function sha256(text) {
14
14
  return crypto.createHash('sha256').update(text).digest('hex');
15
15
  }
16
16
 
17
+ /**
18
+ * Code-unit order, independent of the host locale. `localeCompare` orders by the process locale, so the same checkout
19
+ * could be sorted differently on two machines; every ordering that reaches a hashed value goes through this instead.
20
+ */
21
+ export function cmpCodeUnit(a, b) {
22
+ const x = String(a); const y = String(b);
23
+ return x < y ? -1 : x > y ? 1 : 0;
24
+ }
25
+
26
+ // Control characters (C0, DEL, C1), the bidirectional override/isolate marks and the line/paragraph separators. A
27
+ // repository controls the text of its own route strings and file names, so anything printed to a terminal or placed
28
+ // in a prompt outside the untrusted fence must not be able to carry an escape sequence or reorder what a reader sees.
29
+ const UNSAFE_DISPLAY = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
30
+
31
+ /** Make text safe to print: unsafe characters become '?'. Newline and tab are kept; use `safeLabel` for one-line values. */
32
+ export function cleanForTerminal(text) {
33
+ return String(text).replace(UNSAFE_DISPLAY, '?');
34
+ }
35
+
36
+ /** A one-line, bounded, printable form of a name taken from source (a route path, a file name). */
37
+ export function safeLabel(text, max = 120) {
38
+ const t = String(text).replace(/[\n\r\t]+/g, ' ').replace(UNSAFE_DISPLAY, '?').replace(/ {2,}/g, ' ').trim();
39
+ return t.length > max ? `${t.slice(0, max - 3)}...` : t;
40
+ }
41
+
17
42
  /** CRLF/CR -> LF so a Windows checkout and a Unix one produce the same spans and hashes. */
18
43
  export function normalizeText(text) {
19
44
  return String(text).replace(/\r\n?/g, '\n');
@@ -16,6 +16,7 @@ import { readStoreJson, writeStoreJson, readManifest, readPackageText, verifySto
16
16
  import { diffManifestSets, cacheReport } from './diff.js';
17
17
  import { readGitSnapshot, readSnapshot } from './files.js';
18
18
  import { QUESTION_PACKS, packById } from './prompts/packs.js';
19
+ import { cleanForTerminal } from './canonical.js';
19
20
  import { describeModel, resolveInvoker, buildReviewPrompt, redactPackageText, checkEgressForFiles } from './review.js';
20
21
  import { planJobs, runQueue, exportBatch, importBatch, HARD_LIMITS } from './queue.js';
21
22
  import { reviewsToCandidates, adjudicate } from './candidates.js';
@@ -26,21 +27,24 @@ const flag = (a, k) => (a.flags[k] === undefined ? undefined : a.flags[k]);
26
27
  const truthy = (v) => v === true || (typeof v === 'string' && v !== 'false' && v !== '0');
27
28
  const num = (v) => (v === undefined || v === true ? undefined : Number(v));
28
29
 
30
+ /** Everything this command prints goes through here: repository-supplied names must not carry escape sequences to a terminal. */
31
+ function say(text) { console.log(cleanForTerminal(text)); }
32
+
29
33
  function out(format, text, obj) {
30
- if (format === 'json') console.log(JSON.stringify(obj, null, 2));
31
- else if (format === 'jsonl') console.log(JSON.stringify(obj));
32
- else console.log(text);
34
+ if (format === 'json') say(JSON.stringify(obj, null, 2));
35
+ else if (format === 'jsonl') say(JSON.stringify(obj));
36
+ else say(text);
33
37
  }
34
38
 
35
39
  function usage() {
36
- console.log([
40
+ say([
37
41
  'Usage: agentic-security context <subcommand> [--root <dir>] [--format text|json|jsonl]',
38
42
  '',
39
43
  ' build Resolve entry points and write one inspectable context bundle each (no model, no network)',
40
44
  ' [--entrypoints http,webhook,queue,cron,upload,cli] [--profile compact|standard|extended] [--max-tokens N] [--route <label|id>] [--no-write]',
41
45
  ' inspect Show one bundle: --route <label|id> | --hash <contextHash> [--show-context] [--package N]',
42
46
  ' diff Routes added/removed/changed between two refs: --from <ref> [--to <ref>] (omit --to for the working tree)',
43
- ' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--concurrency N]',
47
+ ' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--allow-remote] [--concurrency N]',
44
48
  ' [--max-calls N] [--max-wall-seconds N] [--max-cost-usd N --cost-per-call-usd N] [--retry-failed] [--refute] [--strict]',
45
49
  ' [--dry-run] [--export-batch] [--import-batch <results.jsonl> --batch-id <id>]',
46
50
  ' status Coverage so far: entry points, bundles, review jobs',
@@ -62,7 +66,7 @@ async function doBuild(root, args, { write = true } = {}) {
62
66
  if (flag(args, 'max-tokens')) config.context.maxTokens = num(flag(args, 'max-tokens'));
63
67
  const events = [];
64
68
  const jsonl = flag(args, 'format') === 'jsonl';
65
- const built = await buildContext(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) console.log(JSON.stringify(e)); } });
69
+ const built = await buildContext(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) say(JSON.stringify(e)); } });
66
70
  if (!built.ok) return { ok: false, built, config, problems };
67
71
  let persisted = null;
68
72
  if (write) persisted = persistBuild(root, built, config);
@@ -155,7 +159,7 @@ async function cmdInspect(root, args, format) {
155
159
  ` boundaries (calls into libraries, not first-party code): ${m.boundaries.length}`,
156
160
  ];
157
161
  if (text) lines.push('', text.ok ? text.text : `(context text unavailable: ${text.code})`);
158
- console.log(lines.join('\n'));
162
+ say(lines.join('\n'));
159
163
  return 0;
160
164
  }
161
165
 
@@ -255,7 +259,7 @@ async function cmdExport(root, args, format) {
255
259
  if (!h || h === true) { out(format, 'context export: --hash <contextHash> is required', { ok: false }); return 1; }
256
260
  const r = exportBundle(root, String(h));
257
261
  if (!r.ok) { out(format, `context export: refused (${r.problems.join(', ')})`, { ok: false, problems: r.problems }); return 1; }
258
- console.log(JSON.stringify(r.export, null, 2));
262
+ say(JSON.stringify(r.export, null, 2));
259
263
  return 0;
260
264
  }
261
265
 
@@ -303,7 +307,7 @@ async function cmdReview(root, args, format) {
303
307
  const r = await doBuild(root, args, { write: true });
304
308
  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; }
305
309
  const { built } = r;
306
- const model = describeModel();
310
+ const model = describeModel({ allowRemote: truthy(flag(args, 'allow-remote')) || r.config.review.allowRemote === true });
307
311
  const { jobs, droppedByJobLimit } = planJobs({ bundles: built.bundles, packs, model, maxJobs: Math.min(HARD_LIMITS.maxJobs, num(flag(args, 'max-jobs')) || HARD_LIMITS.maxJobs) });
308
312
  const loadJob = loadJobFrom(built, packs);
309
313
  const ctxBase = { root: built.root, fileContents: built.fileContents, model };
@@ -341,7 +345,7 @@ async function cmdReview(root, args, format) {
341
345
  try {
342
346
  result = await runQueue(jobs, {
343
347
  ...ctxBase, invoker, loadJob, signal: ac.signal, retryFailed: truthy(flag(args, 'retry-failed')),
344
- onEvent: jsonl ? (e) => console.log(JSON.stringify(e)) : undefined,
348
+ onEvent: jsonl ? (e) => say(JSON.stringify(e)) : undefined,
345
349
  limits: {
346
350
  concurrency: num(flag(args, 'concurrency')) || config.review.concurrency,
347
351
  maxCalls: num(flag(args, 'max-calls')) || config.review.maxCalls,
@@ -22,7 +22,7 @@ export const DEFAULT_CONFIG = Object.freeze({
22
22
  maxFiles: 3000,
23
23
  semanticMetadata: { mode: 'off', haskellHieDir: null },
24
24
  context: { profile: 'standard', maxTokens: null, pruneLowValue: true, maxSymbols: 60, maxDepth: 12 },
25
- review: { enabled: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
25
+ review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
26
26
  replay: { contentAddressed: true, retention: 'inherit' },
27
27
  });
28
28
 
@@ -63,6 +63,7 @@ export function normalizeConfig(raw) {
63
63
  },
64
64
  review: {
65
65
  enabled: rv.enabled === true,
66
+ allowRemote: rv.allowRemote === true,
66
67
  questionPacks: Array.isArray(rv.questionPacks) && rv.questionPacks.length ? rv.questionPacks.filter(x => typeof x === 'string') : d.review.questionPacks,
67
68
  concurrency: clamp(rv.concurrency, 1, 16, d.review.concurrency),
68
69
  maxCalls: clamp(rv.maxCalls, 1, 5000, d.review.maxCalls),
@@ -47,7 +47,46 @@ export function signatureText(lines, declLine, maxLines = 12) {
47
47
  for (let i = declLine - 1; i < Math.min(lines.length, declLine - 1 + maxLines); i++) { t += ' ' + lines[i]; if (/\)\s*(?:->[^:]*)?:\s*(?:#.*)?$/.test(lines[i])) break; }
48
48
  return t;
49
49
  }
50
- const OWNER_FIELD = /(?:owner|tenant|org(?:anization)?|account|created_?by|author|user_?id|userId|team|workspace)/i;
50
+ // An owner/tenant FIELD NAME, not any word that merely contains one: `ownerId`, `owner_id`, `tenant`, `getOwnerId`
51
+ // qualify; `owner_name` and `cols` do not (the trailing boundary rejects them).
52
+ 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';
53
+ const OWNER_KEY_RE = new RegExp(`(?<![\\w])(?:${OWNER_KEY})`);
54
+ const TENANT_WORD = /tenant|org|workspace|team|account/i;
55
+ // a SQL string comparing an owner column with a bound parameter: `owner_id = ?`, `owner_id = $1`, `tenant_id = :t`
56
+ const SQL_OWNER_PLACEHOLDER = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*(?:=|\\bIN\\b|\\bin\\b)\\s*\\(?\\s*(?:\\?|\\$\\d+|:\\w+|%s|@\\w+)`);
57
+ // an owner column compared with the principal written straight into the string: `owner_id = ${req.user.id}`
58
+ const SQL_OWNER_INTERPOLATED = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*=\\s*(?:'?\\s*\\+\\s*|\\$\\{\\s*)(?:${QUALIFIED_PRINCIPAL.source}|${BARE_PRINCIPAL.source})`);
59
+ const PRINCIPAL_GLOBAL = new RegExp(QUALIFIED_PRINCIPAL.source, 'g');
60
+ const STRING_LITERAL = /(["'`])(?:\\.|(?!\1).)*\1/g;
61
+
62
+ /**
63
+ * Is an owner/tenant FIELD actually tied to the principal in this text? The field and the principal must sit in the
64
+ * same clause (split on , ; && || braces), so `{ audit: req.user.id, cols: 'owner_name' }` is a decoy and
65
+ * `{ ownerId: req.user.id }` is a filter. A SQL string with an owner column against a bound placeholder counts when the
66
+ * principal is passed in the same call. Returns null or { level: 'qualified'|'bare', tenant }.
67
+ */
68
+ export function ownerBinding(text, extra = null) {
69
+ let best = null;
70
+ const take = (level, tenant) => { if (!best || (level === 'qualified' && best.level !== 'qualified')) best = { level, tenant }; };
71
+ const code = text.replace(STRING_LITERAL, (m) => (SQL_OWNER_PLACEHOLDER.test(m) || SQL_OWNER_INTERPOLATED.test(m) ? m : '""'));
72
+ for (const clause of code.split(/[,;{}]|&&|\|\|/)) {
73
+ // the principal's own name (`req.user`, `current_user`) is not an owner field, so it is removed before looking for one
74
+ const forKey = clause.replace(STRING_LITERAL, ' ').replace(PRINCIPAL_GLOBAL, ' ');
75
+ if (!OWNER_KEY_RE.test(forKey)) continue;
76
+ const pr = principalIn(clause, extra);
77
+ if (pr) take(pr, TENANT_WORD.test(clause));
78
+ }
79
+ for (const m of text.match(STRING_LITERAL) || []) {
80
+ const placeholder = SQL_OWNER_PLACEHOLDER.test(m);
81
+ if (placeholder) {
82
+ const pr = principalIn(text, extra);
83
+ if (pr) take(pr, TENANT_WORD.test(m));
84
+ }
85
+ if (SQL_OWNER_INTERPOLATED.test(m)) take('qualified', TENANT_WORD.test(m));
86
+ }
87
+ return best;
88
+ }
89
+
51
90
  const COMPARE = /(?:===|!==|==|!=|\.equals\(|\bis not\b|\bis\b|\bnot in\b|\bin\b|\.includes\(|\.has\()/;
52
91
  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;
53
92
 
@@ -58,6 +97,13 @@ const DB_RECEIVER = /(?:^|\b)(?:db|database|conn|connection|pool|client|cursor|s
58
97
  const SQL_TEXT = /\b(?:select\s[\s\S]{1,120}\sfrom|insert\s+into|update\s+\w+\s+set|delete\s+from)\b/i;
59
98
  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\.)/;
60
99
 
100
+ // Names that answer a question rather than enforce a rule.
101
+ const PREDICATE_NAME = /^(?:is|has|can|cannot|allow|owned|belongs)/i;
102
+ function bareCallStatement(trimmedLine, name) {
103
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
104
+ return new RegExp(`^(?:await\\s+)?${esc}\\s*\\(`).test(trimmedLine) && /\)\s*;?\s*(?:\/\/.*|\/\*.*)?$/.test(trimmedLine);
105
+ }
106
+
61
107
  const WRITE_VERB = /(?:insert|update|upsert|delete|remove|destroy|save|create|write|append|unlink|rm|exec|spawn|system|popen|post|put|patch|replace)/i;
62
108
 
63
109
  function kindOfAuthName(name) {
@@ -132,8 +178,12 @@ export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
132
178
  }
133
179
  for (const name of calls) {
134
180
  if (AUTH_NAME.test(name) && !/^(?:if|for|while|switch|catch|function)$/.test(name)) {
135
- 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());
136
- push({ line: ln, category: 'control', kind: kindOfAuthName(name), subkind: null, evidence: 'inferred', basis: 'call-name', rejectionSeen: rej, text: clip(raw) });
181
+ const lastSeg = name.split(/[.:]/).pop();
182
+ // A predicate (isOwner, hasRole, can) only returns an answer. Called as a bare statement its answer is thrown away,
183
+ // so it guards nothing, whatever the lines around it say.
184
+ const discarded = !haskell && PREDICATE_NAME.test(lastSeg) && bareCallStatement(t, name);
185
+ const rej = !discarded && (REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(lastSeg));
186
+ 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) });
137
187
  }
138
188
  const last = name.split(/[.:]/).pop();
139
189
  const recv = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : '';
@@ -149,9 +199,9 @@ export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
149
199
  push({ line: ln, category: 'sensitive', kind: sub, subkind: verb, evidence: 'observed', basis: 'call-name', rejectionSeen: null, text: clip(raw) });
150
200
  // a data operation whose own statement mentions the principal and an owner/tenant field is scoped by it
151
201
  const stmt = statementAt(lines, ln);
152
- const pr = sub.startsWith('data-') && OWNER_FIELD.test(stmt) ? principalIn(stmt, extraPrincipal) : null;
202
+ const pr = sub.startsWith('data-') ? ownerBinding(stmt, extraPrincipal) : null;
153
203
  if (pr) {
154
- 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) });
204
+ 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) });
155
205
  }
156
206
  }
157
207
  }
@@ -159,17 +209,17 @@ export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
159
209
  const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
160
210
  push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
161
211
  const stmt = statementAt(lines, ln);
162
- const pr = OWNER_FIELD.test(stmt) ? principalIn(stmt, extraPrincipal) : null;
212
+ const pr = ownerBinding(stmt, extraPrincipal);
163
213
  if (pr) {
164
- 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) });
214
+ 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) });
165
215
  }
166
216
  }
167
217
 
168
218
  // a resource field compared with the principal: the ownership check itself
169
- 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;
219
+ const cmpPr = COMPARE.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? ownerBinding(raw, extraPrincipal) : null;
170
220
  if (cmpPr) {
171
221
  const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' '));
172
- 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) });
222
+ 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) });
173
223
  }
174
224
  }
175
225
  // A function whose NAME says it is a guard and whose BODY shows a rejection path is a guard function. This is
@@ -12,7 +12,7 @@
12
12
  // not resolve, a handler matched by name only, a router whose mount point is not visible
13
13
  // unresolved no handler could be identified; the manifest still exists and says so
14
14
  // excluded_by_policy deliberately not processed (operator exclusion, or the entry-point cap)
15
- import { hashOf } from './canonical.js';
15
+ import { hashOf, safeLabel } from './canonical.js';
16
16
  import { callStatement, inlineBlockSpan, splitArgs } from './spans.js';
17
17
  import { dependencyParams, signatureText } from './controls.js';
18
18
  import { buildEntrypointInventory } from '../posture/entrypoint-inventory.js';
@@ -313,7 +313,7 @@ export async function collectEntrypoints({ fileContents, graph, config = {} }) {
313
313
  const n = (ord.get(stem) || 0) + 1;
314
314
  ord.set(stem, n);
315
315
  r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
316
- r.label = `${r.method ? r.method + ' ' : ''}${r.pathTemplate}`.trim() || r.kind;
316
+ r.label = safeLabel(`${r.method ? r.method + ' ' : ''}${r.pathTemplate}`) || r.kind;
317
317
  void hname;
318
318
  let disposition;
319
319
  const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
@@ -92,6 +92,8 @@ export function metricsFor(units, protocolHash, name) {
92
92
  precision: precisionOf(c), recall: recallOf(c), f1: f1Of(c),
93
93
  precisionInterval: pn > 0 ? wilsonInterval(c.tp, pn) : { status: 'unmeasured', reason: 'nothing was reported' },
94
94
  f1Interval: f1,
95
+ // A point estimate with no interval around it reads as a finding. It is reportable only when the interval is measured.
96
+ reportable: f1 && f1.status === 'measured',
95
97
  };
96
98
  }
97
99
 
@@ -127,6 +129,9 @@ function readLedger(root) {
127
129
  const pct = (x) => (x === null || x === undefined ? 'n/a' : (x * 100).toFixed(0) + '%');
128
130
  const interval = (i) => (!i || i.status !== 'measured' ? `unmeasured (${i && i.reason ? i.reason.split(',')[0] : 'no data'})` : `${pct(i.low)} to ${pct(i.high)}`);
129
131
 
132
+ // Precision, recall and F1 are printed only when their interval is measured; otherwise the counts stand alone.
133
+ const shown = (m, x) => (m.reportable ? pct(x) : 'withheld');
134
+
130
135
  export function renderMarkdown(card) {
131
136
  const L = [];
132
137
  L.push('# Context Intelligence evaluation scorecard', '');
@@ -141,18 +146,20 @@ export function renderMarkdown(card) {
141
146
  for (const s of card.systems) {
142
147
  if (s.status !== 'measured') { L.push(`| ${s.system} | ${s.status}: ${s.reason} | | | | | | | | | | |`); continue; }
143
148
  const m = s.metrics;
144
- L.push(`| ${s.system} | measured | ${m.counts.tp} | ${m.counts.fp} | ${m.counts.fn} | ${m.counts.tn} | ${pct(m.precision)} | ${pct(m.recall)} | ${pct(m.f1)} | ${interval(m.f1Interval)} | ${s.calls} | ${s.promptChars} |`);
149
+ L.push(`| ${s.system} | measured | ${m.counts.tp} | ${m.counts.fp} | ${m.counts.fn} | ${m.counts.tn} | ${shown(m, m.precision)} | ${shown(m, m.recall)} | ${shown(m, m.f1)} | ${interval(m.f1Interval)} | ${s.calls} | ${s.promptChars} |`);
145
150
  }
146
151
  L.push('', '## Economics (estimated; no price supplied)', '', '| system | median ctx tokens | P95 | P99 | model calls | calls per true positive | wall ms |', '|---|---|---|---|---|---|---|');
147
- for (const s of card.systems) if (s.economics) { const e = s.economics; L.push(`| ${s.system} | ${e.contextTokens.median ?? 'n/a'} | ${e.contextTokens.p95 ?? 'n/a'} | ${e.contextTokens.p99 ?? 'n/a'} | ${e.modelCalls} | ${e.callsPerTruePositive === null ? 'n/a' : e.callsPerTruePositive.toFixed(1)} | ${e.wallMs} |`); }
152
+ for (const s of card.systems) if (s.economics) { const e = s.economics; L.push(`| ${s.system} | ${e.contextTokens.median ?? 'n/a'} | ${e.contextTokens.p95 ?? 'n/a'} | ${e.contextTokens.p99 ?? 'n/a'} | ${e.modelCalls} | ${e.callsPerTruePositive === null || !s.metrics || !s.metrics.reportable ? 'withheld' : e.callsPerTruePositive.toFixed(1)} | ${e.wallMs} |`); }
153
+ L.push('', 'Precision, recall, F1 and calls per true positive are withheld while their interval is unmeasured (fewer than 10 independent groups). The counts above are what was observed; they are not an estimate of anything.');
148
154
  L.push('', `Replay: \`${card.replay}\``, '');
149
155
  if (card.leakage.length) { L.push('## Leakage hits', ...card.leakage.map(h => `- ${h.where} ${h.file || ''} ${h.term}`), ''); }
150
156
  return L.join('\n');
151
157
  }
152
158
 
153
159
  export function renderCsv(card) {
154
- const rows = [['system', 'case', 'variant', 'vulnerable', 'flagged', 'outcome', 'calls', 'promptChars']];
155
- for (const s of card.systems) for (const u of (s.units || [])) rows.push([s.system, u.caseId, u.variant, u.vulnerable, u.flagged, u.outcome, u.calls, u.promptChars]);
160
+ // The marker is a column, not a banner: a CSV is the format that gets pasted into a spreadsheet without its header text.
161
+ const rows = [['system', 'case', 'variant', 'vulnerable', 'flagged', 'outcome', 'calls', 'promptChars', 'synthetic']];
162
+ for (const s of card.systems) for (const u of (s.units || [])) rows.push([s.system, u.caseId, u.variant, u.vulnerable, u.flagged, u.outcome, u.calls, u.promptChars, card.synthetic === false ? false : true]);
156
163
  return rows.map(r => r.join(',')).join('\n') + '\n';
157
164
  }
158
165
 
@@ -189,6 +189,16 @@ function stripUntrustedKeepUser(t) { return stripUntrusted(t).replace(/""/g, '""
189
189
 
190
190
  function cite(l, role, claim) { return { role, file: l.file, startLine: l.n, endLine: l.n, claim }; }
191
191
 
192
+ /** A reader that cites a line says what is on it: the claim quotes the cited text, as a careful model would. */
193
+ function quoteCited(obj, blocks) {
194
+ const text = (c) => {
195
+ for (const b of blocks) if (b.file === c.file) { const l = b.lines.find(x => x.n === c.startLine); if (l) return l.text.trim().slice(0, 100); }
196
+ return '';
197
+ };
198
+ const fix = (list) => (Array.isArray(list) ? list.map(c => ({ ...c, claim: `${c.claim}: ${text(c)}` })) : list);
199
+ return { ...obj, evidence: fix(obj.evidence), observedMitigations: fix(obj.observedMitigations) };
200
+ }
201
+
192
202
  /** The decision for a pack, from the observations. Returns the review JSON object. */
193
203
  export function decide(packId, obs, ctx) {
194
204
  const base = { observedMitigations: [], attackerControl: [], uncertainties: [], likelihood: 'low' };
@@ -237,7 +247,7 @@ export function scriptedReader() {
237
247
  const invoke = async (prompt) => {
238
248
  if (/^QUESTION PACK: /m.test(prompt)) {
239
249
  const packId = /^QUESTION PACK: ([\w-]+)\//m.exec(prompt)[1];
240
- const label = /^ENTRY POINT 1: (.*?) \(id /m.exec(prompt)[1];
250
+ const label = (/^ENTRY POINT LABEL \(data\): (.*)$/m.exec(prompt) || [])[1] || '';
241
251
  const { blocks, unresolved, notIncluded, limit } = blocksFromReviewPrompt(prompt);
242
252
  const routes = findRoutes(blocks);
243
253
  const [method, ...rest] = label.split(' ');
@@ -246,7 +256,7 @@ export function scriptedReader() {
246
256
  if (!route) return JSON.stringify({ evidence: [], observedMitigations: [], conclusion: 'insufficient_evidence', invariant: 'unspecified', uncertainties: ['the registration is not in the provided context'] });
247
257
  const obs = analyseRoute(route, blocks);
248
258
  const complete = !unresolved && !notIncluded && !limit;
249
- return JSON.stringify(decide(packId, obs, { complete, unresolved }));
259
+ return JSON.stringify(quoteCited(decide(packId, obs, { complete, unresolved }), blocks));
250
260
  }
251
261
  // hunt prompt: report the candidates for the lens, with a location
252
262
  const lens = /Your lens is ([\w ]+)\./.exec(prompt);
@@ -12,6 +12,7 @@
12
12
  // * lose a control. Middleware and every callee are walked, and each visited symbol's controls are collected by the
13
13
  // same classifier the packer uses to refuse pruning.
14
14
  import { analyzeSymbolText } from './controls.js';
15
+ import { cmpCodeUnit } from './canonical.js';
15
16
  import { MATERIAL_REASONS } from './graph-schema.js';
16
17
 
17
18
  export const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
@@ -70,7 +71,7 @@ export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
70
71
  }
71
72
 
72
73
  // middleware runs before the handler: it is enqueued first and ordered at line -1
73
- for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || String(a.name).localeCompare(String(b.name)))) {
74
+ for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || cmpCodeUnit(a.name, b.name))) {
74
75
  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');
75
76
  }
76
77
  if (root.symbolId) {
@@ -163,11 +164,14 @@ export function evaluateProtection({ facts, unknowns, omittedWithControls = fals
163
164
  for (const dim of PROTECTION_DIMENSIONS) {
164
165
  const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
165
166
  const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
166
- const before = ctl.filter(f => firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens));
167
+ // A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
168
+ const effective = (f) => f.rejectionSeen !== false;
169
+ const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
167
170
  const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
168
171
  let state; let why;
169
172
  if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
170
173
  else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
174
+ 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'; }
171
175
  else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
172
176
  else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
173
177
  else if (unknowns.some(u => u.material) || capped) { state = 'not_resolved'; why = 'an unresolved call or a traversal limit could hide a control'; }
@@ -175,6 +179,8 @@ export function evaluateProtection({ facts, unknowns, omittedWithControls = fals
175
179
  else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
176
180
  dims[dim] = {
177
181
  state, why, firstSensitiveLine: firstSens,
182
+ // How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
183
+ strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
178
184
  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 })),
179
185
  };
180
186
  }