@clear-capabilities/agentic-security-scanner 0.195.22 → 0.195.24

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 (45) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/3596.index.js +1 -1
  3. package/dist/550.index.js +9 -8
  4. package/dist/5620.index.js +59 -10
  5. package/dist/5937.index.js +88 -16
  6. package/dist/7838.index.js +32 -5
  7. package/dist/9441.index.js +50 -7
  8. package/dist/9824.index.js +19 -13
  9. package/dist/agentic-security.mjs +9 -9
  10. package/dist/agentic-security.mjs.sha256 +1 -1
  11. package/dist/{c1e282247b06d51c8ffb.json → daefda0819bb18309b75.json} +3 -3
  12. package/package.json +3 -3
  13. package/src/discovery/detector-spec-synthesis.js +3 -3
  14. package/src/discovery/llm-invoke.js +3 -3
  15. package/src/egress/transport.js +148 -0
  16. package/src/lineage/CLAUDE.md +12 -0
  17. package/src/lineage/DESIGN_GRAPH_BUILDER.md +23 -0
  18. package/src/lineage/driver.js +19 -2
  19. package/src/lineage/engine.js +24 -1
  20. package/src/lineage/graph-builder.js +3 -1
  21. package/src/lineage/lineage-source-catalog.js +143 -0
  22. package/src/lineage/source-seeding.js +142 -16
  23. package/src/lineage/summaries.js +21 -5
  24. package/src/llm-validator/index.js +4 -2
  25. package/src/llm-validator/local-endpoint.js +4 -1
  26. package/src/llm-validator/ollama-provider.js +8 -6
  27. package/src/posture/adversary-agent.js +27 -10
  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/sca/llm-function-extract.js +17 -13
@@ -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 }),
@@ -206,6 +206,14 @@ function describePartial(isScaLike, reason) {
206
206
  reasons: ['rename_detected_not_followed'],
207
207
  };
208
208
  }
209
+ // GA-05: the parent of the candidate commit could not be inspected (the replay there failed), so absence in it is unproven.
210
+ // This is not "we could not see far enough" (shallow) and not "it was never true" (never confirmed): it is "we do not know".
211
+ if (reason === 'parent-not-inspectable') {
212
+ return {
213
+ limitation: 'a candidate commit was found but the replay in its parent failed, so absence there is unproven; the introducing commit is the earliest observable, not a verified boundary',
214
+ reasons: ['parent_not_inspectable'],
215
+ };
216
+ }
209
217
  return {
210
218
  limitation: reason
211
219
  ? `earliest observable — history could not confirm a verified parent boundary (${reason})`
@@ -25,6 +25,9 @@
25
25
 
26
26
  import { candidateCommitsForLine, candidateCommitsForFiles, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
27
27
  import { replayAt } from './predicate-replay.js';
28
+
29
+ /** Test seam: a replacement `replayAt` (same signature) so a replay failure in a parent commit can be produced on demand. */
30
+ export const _replayHooks = { replayAt: null };
28
31
  import { PROVENANCE_METHOD } from './schema.js';
29
32
  import { checkAbsentInSomeParent, detectRevert, detectCherryPick } from './dag-walk.js';
30
33
  import { loadRepoLineage } from './repo-lineage.js';
@@ -230,7 +233,7 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
230
233
  const replayCache = new Map();
231
234
  const replay = (sha) => {
232
235
  if (replayCache.has(sha)) return replayCache.get(sha);
233
- const p = replayAt(scanRoot, sha, files, stableId, { deep: needsDeepReplay(finding) });
236
+ const p = (_replayHooks.replayAt || replayAt)(scanRoot, sha, files, stableId, { deep: needsDeepReplay(finding) });
234
237
  replayCache.set(sha, p);
235
238
  return p;
236
239
  };
@@ -286,13 +289,23 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
286
289
  }
287
290
 
288
291
  const presentInParent = await replay(parent);
289
- const absentInParent = !presentInParent.present;
290
- if (!absentInParent) continue; // predicate already true in parent — keep walking older candidates
292
+ if (presentInParent.present) continue; // predicate already true in parent — keep walking older candidates
293
+ // GA-05: `present: false` is proof of absence only when the parent WAS inspected and the predicate did not reproduce
294
+ // (`stableId-not-reproduced`), or the file did not exist there at all (`no-files-at-commit`: a file that is new in this
295
+ // commit). A parent the replay could not inspect (`replay-error`) proves nothing, so the boundary is not verified and the
296
+ // answer is `partial` with that reason, never `complete`.
297
+ if (presentInParent.reason === 'replay-error') {
298
+ return {
299
+ status: 'partial', reason: 'parent-not-inspectable', commitsConsidered,
300
+ findingOrigin: originFrom(meta, { absentInParents: [] }),
301
+ method: PROVENANCE_METHOD.SEMANTIC_REPLAY, parentBoundaryVerified: false,
302
+ };
303
+ }
291
304
 
292
305
  return {
293
306
  status: 'complete', method: PROVENANCE_METHOD.SEMANTIC_REPLAY, commitsConsidered,
294
307
  findingOrigin: originFrom(meta, { absentInParents: [parent] }),
295
- parentBoundaryVerified: true,
308
+ parentBoundaryVerified: true, absenceEvidence: presentInParent.reason,
296
309
  };
297
310
  }
298
311
 
@@ -8,9 +8,11 @@
8
8
  // model call — this is the deterministic half; a model may PROPOSE the candidate pattern this CLI is handed, but
9
9
  // it never decides promotion).
10
10
  import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { execFileSync } from 'node:child_process';
11
13
  import { statePath } from '../state-dir.js';
12
14
  import { packTrust, signPublishedRule } from './store.js';
13
- import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules } from './regression.js';
15
+ import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules, promotedRuleIds } from './regression.js';
14
16
  import { draftRuleIR, validateRuleIR, ruleIRDigest } from './ir.js';
15
17
  import { checkEligibility } from './eligibility.js';
16
18
  import { buildCorpus } from './corpus.js';
@@ -35,6 +37,12 @@ function readJsonArg(pathOrInline) {
35
37
 
36
38
  export async function cmdRules(args, root) {
37
39
  const sub = args._[1];
40
+ // GA-07: a ledger that failed its integrity check (broken chain, bad or missing signature) refuses EVERY command, not only the
41
+ // ones that change state; nothing below can read a tampered history as fact or write over it.
42
+ if (['synthesize', 'evaluate', 'promote', 'status', 'rollback', 'cost', 'regress'].includes(sub)) {
43
+ const guard = loadLedger(root);
44
+ if (guard.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${guard.tamperReason}).`); return 4; }
45
+ }
38
46
  if (sub === 'synthesize') return cmdSynthesize(args, root);
39
47
  if (sub === 'evaluate') return cmdEvaluate(args, root);
40
48
  if (sub === 'promote') return cmdPromote(args, root);
@@ -42,7 +50,7 @@ export async function cmdRules(args, root) {
42
50
  if (sub === 'rollback') return cmdRollback(args, root);
43
51
  if (sub === 'cost') return cmdCost(args, root);
44
52
  if (sub === 'regress') return cmdRegress(args, root);
45
- console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> | status [rule-id] | rollback <rule-id> | cost <rule-id>');
53
+ console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> --reviewed-by <person> [--scope <text>] | status [rule-id] | rollback <rule-id> | cost <rule-id> | regress [rule-id]');
46
54
  return 4;
47
55
  }
48
56
 
@@ -61,7 +69,7 @@ async function cmdSynthesize(args, root) {
61
69
  // The candidate pattern is the model's or the person's, when they gave one. Without one, or with --auto, the synthesis loop proposes
62
70
  // from the vulnerable and patched code, scores each attempt on the real corpus and its mutations, and revises (see proposal.js).
63
71
  let finalSpec = spec; let synthesis = null;
64
- const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [] };
72
+ const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [], independentVulnerableCode: spec.independentVulnerableCode ?? [], independentCleanCode: spec.independentCleanCode ?? [] };
65
73
  const leak = checkLeakage(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
66
74
  if (!leak.ok) { console.error(`Refusing: the material for this rule shows benchmark or answer-key signals (${leak.offendingTerms.join('; ')}). Nothing was written.`); return 4; }
67
75
  if (args.flags.auto || (!spec.match && !spec.sink)) {
@@ -120,7 +128,7 @@ async function cmdEvaluate(args, root) {
120
128
  originatingFinding: ir.provenance.originatingFindingId, originatingCommit: ir.provenance.originatingCommit, evidenceState: ir.provenance.evidenceState,
121
129
  generatingModel: ir.provenance.model, promptConfigVersion: ir.provenance.promptConfigVersion,
122
130
  positiveCorpusHash: corpusHash(corpus.positives), negativeCorpusHash: corpusHash(corpus.negatives),
123
- benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
131
+ benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, independent: gate.independent, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
124
132
  leakageCheck: leakage, mutatorCoverage: mutatorCoverage(corpusSpec.originalCode), discoveryCost: corpusSpec.discoveryCost ?? prev?.discoveryCost ?? null,
125
133
  promotionEvent: prev?.promotionEvent ?? null, evaluatedAt: new Date().toISOString(),
126
134
  });
@@ -136,23 +144,63 @@ async function cmdEvaluate(args, root) {
136
144
  function isStr(v) { return typeof v === 'string' && v.length > 0; }
137
145
  const fmtPct = (n) => (n == null ? 'unmeasured' : `${(n * 100).toFixed(1)}%`);
138
146
 
147
+ /** Does this commit exist in the project's history? `true`, `false`, or `null` when the project is not a git repository. */
148
+ function commitExists(root, sha) {
149
+ if (!fs.existsSync(path.join(root, '.git'))) return null;
150
+ try { execFileSync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: root, stdio: 'ignore' }); return true; } catch { return false; }
151
+ }
152
+
153
+ const NOT_A_REVIEWER = /^(?:agent|model|auto|bot|llm|ai)$/i;
154
+
155
+ // Promotion is a reviewed, human decision (GA-07). It needs the reviewer's name, a rule that is exactly the one that was evaluated, a
156
+ // provenance that checks out, and no conflict with a rule that already exists; a model's recommendation on its own promotes nothing.
139
157
  async function cmdPromote(args, root) {
140
158
  const id = args._[2];
141
- if (!id) { console.error('usage: rules promote <rule-id>'); return 4; }
159
+ if (!id) { console.error('usage: rules promote <rule-id> --reviewed-by <person> [--scope <text>] [--reason <text>]'); return 4; }
160
+ const reviewer = args.flags['reviewed-by'];
161
+ if (!isStr(reviewer) || NOT_A_REVIEWER.test(reviewer.trim())) {
162
+ console.error('usage: rules promote <rule-id> --reviewed-by <person>. A promotion records the person who reviewed the rule and its scope; a model or agent recommendation is not a review, so nothing is promoted without a named reviewer.');
163
+ return 4;
164
+ }
142
165
  const ledger = loadLedger(root);
143
166
  if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
144
167
  if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
145
168
  const ir = loadIR(root, id);
146
169
  if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
170
+ // the rule promoted must be the rule evaluated: an IR edited after `rules evaluate` has no evidence
171
+ const prov = loadProvenance(root, id);
172
+ if (!prov) { console.error(`Promotion refused: no evaluation record for ${id}; run rules evaluate first.`); return 4; }
173
+ if (prov.ruleHash !== ruleIRDigest(ir)) { console.error(`Promotion refused: the rule ${id} changed after it was evaluated (evaluated ${String(prov.ruleHash).slice(0, 12)}, now ${ruleIRDigest(ir).slice(0, 12)}); run rules evaluate again.`); return 4; }
174
+ // a named originating commit must be real history, not a typed-in string
175
+ const commit = ir.provenance?.originatingCommit;
176
+ if (commit) {
177
+ const exists = commitExists(root, commit);
178
+ if (exists === false) { console.error(`Promotion refused: originating commit ${commit} is not in this repository's history (invalid provenance).`); return 4; }
179
+ if (exists === null) { console.error(`Promotion refused: originating commit ${commit} cannot be verified because this project is not a git repository.`); return 4; }
180
+ }
181
+ // conflicts: a hand-written rule file with the same id, or a promoted rule with the identical match
182
+ const live = statePath(root, 'rules', `${id}.yml`);
183
+ let firstLine = null; // null: no file there (or unreadable, which is treated as absent below only when the read fails with ENOENT)
184
+ try { firstLine = fs.readFileSync(live, 'utf8').split('\n')[0]; } catch (e) { if (e.code !== 'ENOENT') firstLine = ''; }
185
+ if (firstLine !== null && !firstLine.startsWith('# Compiled by the rule compiler')) { console.error(`Promotion refused: a rule file that the compiler did not write already exists at ${live}; choose another rule id or remove that file deliberately.`); return 4; }
186
+ for (const other of promotedRuleIds(ledger)) {
187
+ if (other === id) continue;
188
+ const oir = loadIR(root, other);
189
+ if (oir && JSON.stringify(oir.match) === JSON.stringify(ir.match) && JSON.stringify(oir.languages) === JSON.stringify(ir.languages)) {
190
+ console.error(`Promotion refused: ${id} duplicates the promoted rule ${other} (identical match on the same languages); roll one back or narrow this one.`);
191
+ return 4;
192
+ }
193
+ }
147
194
  // other rules may have been promoted since this one was evaluated: hold it to the false-positive corpus as it is NOW
148
195
  const fpNow = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
149
196
  if (fpNow.length) { console.error(`Promotion refused: ${id} fires on ${fpNow.length} item(s) of the shared false-positive corpus (${fpNow.slice(0, 3).map((h) => h.label).join(', ')}). Re-evaluate or narrow the rule.`); return 1; }
150
197
  const pub = publishCustomRule(root, ir);
151
198
  if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
152
- const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', actor: args.flags.actor || 'operator' });
199
+ const scope = isStr(args.flags.scope) ? args.flags.scope : null;
200
+ const review = { reviewer: reviewer.trim(), scope, independent: prov.benchmarkResults?.independent ?? null };
201
+ const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', evidence: review, actor: args.flags.actor || reviewer.trim() });
153
202
  saveLedger(root, ledger);
154
- const prov = loadProvenance(root, id);
155
- if (prov) saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || 'operator', reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
203
+ saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || reviewer.trim(), reviewer: reviewer.trim(), scope, reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
156
204
  console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
157
205
  // Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
158
206
  // one with the operator's key when there is one, and say plainly whether the scanner will load it.
@@ -20,11 +20,15 @@ function toRuntimeRule(ir) {
20
20
 
21
21
  const EXT = { javascript: 'x.js', typescript: 'x.ts', python: 'x.py', go: 'x.go', ruby: 'x.rb', java: 'x.java', rust: 'x.rs', csharp: 'x.cs', php: 'x.php', any: 'x.txt' };
22
22
 
23
- export function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [] } = {}) {
23
+ export function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [], independentVulnerableCode = [], independentCleanCode = [] } = {}) {
24
24
  const positives = [{ label: 'original', code: originalCode }];
25
25
  for (const v of generateVariants(originalCode)) positives.push({ label: `sibling:${v.mutator}`, code: v.code });
26
+ // Independent examples (GA-07): vulnerable and safe code that is NOT the source finding and NOT a mutation of it, supplied by the
27
+ // reviewer from other places. They are never mutated, so they score as what they are: evidence from outside the rule's own material.
28
+ independentVulnerableCode.forEach((code, i) => positives.push({ label: `independent-vulnerable-${i}`, code }));
26
29
 
27
30
  const negatives = [];
31
+ independentCleanCode.forEach((code, i) => negatives.push({ label: `independent-clean-${i}`, code }));
28
32
  if (patchedCode) {
29
33
  negatives.push({ label: 'patched', code: patchedCode });
30
34
  for (const v of generateVariants(patchedCode)) negatives.push({ label: `patched-sibling:${v.mutator}`, code: v.code });
@@ -60,6 +64,10 @@ export function scoreRule(ir, corpus) {
60
64
  const recall = corpus.positives.length === 0 ? null : tp / corpus.positives.length;
61
65
  // "Protected negative failures" per PRD 3: a rule firing on a sanitizer/guard-carrying variant specifically.
62
66
  const protectedFailures = negResults.filter((n) => n.label.startsWith('protected-') && n.fired).length;
67
+ // Independent evidence, counted apart: how many examples from outside the finding's own material there were, and how they scored.
68
+ const indPos = posResults.filter((p) => p.label.startsWith('independent-vulnerable-'));
69
+ const indNeg = negResults.filter((n) => n.label.startsWith('independent-clean-'));
70
+ const independent = { positives: indPos.length, negatives: indNeg.length, detected: indPos.filter((p) => p.fired).length, falseAlarms: indNeg.filter((n) => n.fired).length };
63
71
 
64
- return { precision, recall, tp, fp, tn, fn, protectedFailures, positives: posResults, negatives: negResults };
72
+ return { precision, recall, tp, fp, tn, fn, protectedFailures, independent, positives: posResults, negatives: negResults };
65
73
  }
@@ -74,9 +74,18 @@ export function validateRuleIR(ir) {
74
74
  return errs;
75
75
  }
76
76
 
77
+ /** JSON with every object's keys sorted at every depth, so the digest does not depend on insertion order. */
78
+ function stableStringify(v) {
79
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
80
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`;
81
+ return JSON.stringify(v);
82
+ }
83
+
77
84
  export function ruleIRDigest(ir) {
78
85
  const { state: _s, ...rest } = ir;
79
- return sha256(JSON.stringify(rest, Object.keys(rest).sort()));
86
+ // Until 0.195.23 this passed the TOP-LEVEL key list as the JSON replacer, which also filters nested objects: `match.pattern` and
87
+ // every structural regex were serialized as `{}`, so the digest never covered the one thing a rule is. Sorted at every depth now.
88
+ return sha256(stableStringify(rest));
80
89
  }
81
90
 
82
91
  /** Build a DRAFT rule IR. Caller supplies the candidate pattern — this module never invents one. */
@@ -4,7 +4,11 @@
4
4
  // actually recorded, and nothing in this module writes to it.
5
5
  import { scoreRule } from './corpus.js';
6
6
 
7
- export const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall: 0.80, maxProtectedFailures: 0 });
7
+ // Since 0.195.23 (GA-07) promotion also needs independent evidence: at least one vulnerable and one safe example that are not the
8
+ // source finding or a mutation of it, and the rule must detect every independent vulnerable example and stay quiet on every
9
+ // independent safe one. The synthesis loop still scores attempts without them (`evaluateRule`); only the promotion decision
10
+ // (`gatePromotion`) requires them, so a candidate can be proposed before a reviewer has gathered the outside examples.
11
+ export const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall: 0.80, maxProtectedFailures: 0, minIndependentVulnerable: 1, minIndependentClean: 1 });
8
12
 
9
13
  // Deliberately independent of posture/evaluation/custody.js: that module's term list and guard are reserved for
10
14
  // the evaluation custodian's sealed-label boundary (QA-002.AC03), and a test enforces that nothing outside
@@ -59,5 +63,11 @@ export function gatePromotion({ ir, corpus, leakageCheck, thresholds } = {}) {
59
63
  const evalResult = evaluateRule(ir, corpus, thresholds);
60
64
  const reasons = [...evalResult.reasons];
61
65
  if (!leakageCheck || leakageCheck.ok !== true) reasons.push(leakageCheck ? `leakage check failed: ${leakageCheck.offendingTerms.join(', ')}` : 'leakage check was not run');
62
- return { pass: reasons.length === 0, reasons, evaluation: evalResult };
66
+ const t = evalResult.thresholds;
67
+ const ind = evalResult.score.independent ?? { positives: 0, negatives: 0, detected: 0, falseAlarms: 0 };
68
+ if (ind.positives < t.minIndependentVulnerable) reasons.push(`${ind.positives} independent vulnerable example(s) beyond the source finding and its mutations (min ${t.minIndependentVulnerable}): supply independentVulnerableCode`);
69
+ else if (ind.detected < ind.positives) reasons.push(`misses ${ind.positives - ind.detected} of ${ind.positives} independent vulnerable example(s)`);
70
+ if (ind.negatives < t.minIndependentClean) reasons.push(`${ind.negatives} independent safe example(s) (min ${t.minIndependentClean}): supply independentCleanCode`);
71
+ else if (ind.falseAlarms > 0) reasons.push(`fires on ${ind.falseAlarms} of ${ind.negatives} independent safe example(s)`);
72
+ return { pass: reasons.length === 0, reasons, evaluation: evalResult, independent: ind };
63
73
  }
@@ -41,7 +41,7 @@ function operatorFalsePositives(root) {
41
41
  return out;
42
42
  }
43
43
 
44
- function promotedRuleIds(ledger) {
44
+ export function promotedRuleIds(ledger) {
45
45
  return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
46
46
  }
47
47
 
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
9
9
  import { newLedger, verifyLedger } from './lifecycle.js';
10
+ import { signLastScan, verifyLastScan } from '../integrity.js';
10
11
  import { compileToCustomRule } from './ir.js';
11
12
  import { verifyRulePack, loadTrustedKeys, signRulePack } from '../rule-pack-signing.js';
12
13
 
@@ -15,20 +16,39 @@ const EXT = { javascript: 'js', typescript: 'ts', python: 'py', go: 'go', ruby:
15
16
 
16
17
  function ruleDir(scanRoot, id) { return statePath(scanRoot, DIR, id); }
17
18
 
18
- export function loadLedger(scanRoot) {
19
+ /**
20
+ * The lifecycle ledger is hash-chained (lifecycle.js) AND signed with the per-install HMAC key (the same key as last-scan.json),
21
+ * since 0.195.23 (GA-07): the chain alone could be recomputed by anyone who could edit the file. A ledger that fails either check,
22
+ * or that cannot be parsed, is returned WITH `tampered: true` and its reason, and `saveLedger` refuses to write it, so a bad ledger
23
+ * is never quietly replaced by a fresh one and its history lost. An unsigned ledger with entries (written before 0.195.23) is
24
+ * accepted only with AGENTIC_SECURITY_RULES_UNSIGNED=1, the same switch that accepts an unsigned rules override, and is signed on
25
+ * its next save.
26
+ */
27
+ export function loadLedger(scanRoot, env = process.env) {
19
28
  const f = statePath(scanRoot, DIR, 'ledger.json');
20
29
  if (!fs.existsSync(f)) return newLedger();
21
- try {
22
- const ledger = JSON.parse(fs.readFileSync(f, 'utf8'));
23
- const v = verifyLedger(ledger);
24
- return v.ok ? ledger : { ...newLedger(), tampered: true, tamperReason: v.reason };
25
- } catch { return newLedger(); }
30
+ let text;
31
+ try { text = fs.readFileSync(f, 'utf8'); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `cannot read the ledger: ${e.message}` }; }
32
+ let ledger;
33
+ try { ledger = JSON.parse(text); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `the ledger is not valid JSON: ${e.message}` }; }
34
+ if (!ledger || !Array.isArray(ledger.entries)) return { ...newLedger(), tampered: true, tamperReason: 'the ledger has no entries array' };
35
+ const v = verifyLedger(ledger);
36
+ if (!v.ok) return { ...ledger, tampered: true, tamperReason: v.reason };
37
+ const sig = verifyLastScan(text, `${f}.sig`);
38
+ if (sig === false) return { ...ledger, tampered: true, tamperReason: 'the ledger signature does not verify under this install key' };
39
+ if (sig === null && ledger.entries.length && env.AGENTIC_SECURITY_RULES_UNSIGNED !== '1') {
40
+ return { ...ledger, tampered: true, tamperReason: 'the ledger is unsigned (written before 0.195.23 or by another tool); set AGENTIC_SECURITY_RULES_UNSIGNED=1 once to accept and re-sign it' };
41
+ }
42
+ return ledger;
26
43
  }
27
44
 
28
45
  export function saveLedger(scanRoot, ledger) {
46
+ if (ledger?.tampered) return { ok: false, reason: `refusing to overwrite a ledger that failed its integrity check (${ledger.tamperReason})` };
29
47
  if (!ensureStateDir(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
30
48
  const f = statePath(scanRoot, DIR, 'ledger.json');
31
- return { ok: safeWriteState(f, JSON.stringify(ledger, null, 2)) };
49
+ const text = JSON.stringify(ledger, null, 2);
50
+ const ok = safeWriteState(f, text) && safeWriteState(`${f}.sig`, signLastScan(text));
51
+ return { ok };
32
52
  }
33
53
 
34
54
  export function saveIR(scanRoot, ir) {