@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.0
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.
- package/CHANGELOG.md +26 -0
- package/bin/agentic-security.js +87 -0
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +1 -1
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +5 -2
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +1 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Honest accuracy reporting (QA-004.AC01, QA-004.AC02).
|
|
2
|
+
//
|
|
3
|
+
// Turns a score (score.js) into the figures a reader can check: raw TP/FP/FN/TN counts first, then precision, recall and F1 by
|
|
4
|
+
// language and by family, micro and macro aggregates, 95% intervals by the registered method, completion with its own interval,
|
|
5
|
+
// and every count of what was NOT scored and why. Two rules shape the whole file:
|
|
6
|
+
//
|
|
7
|
+
// 1. A figure never appears without the counts that produced it, and a stratum too small to support a figure is reported
|
|
8
|
+
// `unmeasured` with its raw counts, not dropped and not given a flattering point estimate. The per-family floor comes from
|
|
9
|
+
// the protocol's own thresholds.
|
|
10
|
+
// 2. Deduplication changes what is SCORED, never what is HIDDEN. Repeated alerts for one root cause score once; the operational
|
|
11
|
+
// alert burden (every alert a reviewer would actually have to read) is reported beside it, in its own section.
|
|
12
|
+
//
|
|
13
|
+
// The four populations (regression, adjudicated development, sealed, operational) stay separate: a report carries exactly one
|
|
14
|
+
// population label, there is no function here that merges two, and `checkPopulationsDisjoint` refuses overlapping membership.
|
|
15
|
+
//
|
|
16
|
+
// Pure: no fs, no clock.
|
|
17
|
+
|
|
18
|
+
import { SCHEMA_VERSION } from '../assurance/schema-kit.js';
|
|
19
|
+
import { digestOf } from '../assurance/identity.js';
|
|
20
|
+
import { groupTargets } from './grouping.js';
|
|
21
|
+
import { clusterAlerts } from './score.js';
|
|
22
|
+
import { groupedBootstrap, wilsonInterval, f1Of, precisionOf, recallOf, INTERVAL_METHODS } from './interval.js';
|
|
23
|
+
|
|
24
|
+
const REPORT_SCHEMA = 'agentic-security/evaluation-report';
|
|
25
|
+
const POPULATIONS = Object.freeze(['regression', 'development', 'sealed', 'operational']);
|
|
26
|
+
|
|
27
|
+
const interval = (iv) => ({ method: iv.method, level: iv.level, status: iv.status, low: iv.low, high: iv.high, groups: iv.groups ?? null, replicates: iv.replicates ?? null, ...(iv.reason ? { reason: iv.reason } : {}) });
|
|
28
|
+
|
|
29
|
+
function stratum(counts, intervals, { minPositives, label }) {
|
|
30
|
+
const positives = counts.tp + counts.fn;
|
|
31
|
+
const metrics = { precision: precisionOf(counts), recall: recallOf(counts), f1: f1Of(counts) };
|
|
32
|
+
if (positives < minPositives) {
|
|
33
|
+
return {
|
|
34
|
+
raw: { tp: counts.tp, fp: counts.fp, fn: counts.fn, tn: counts.tn }, status: 'unmeasured',
|
|
35
|
+
reason: `${positives} positive${positives === 1 ? '' : 's'} in ${label}, fewer than the ${minPositives} the protocol requires for a measured figure`,
|
|
36
|
+
descriptive: metrics, intervals: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { raw: { tp: counts.tp, fp: counts.fp, fn: counts.fn, tn: counts.tn }, status: 'measured', metrics, intervals };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} o
|
|
44
|
+
* @param {object} o.score a score (score.js); its `perTarget` tallies drive the intervals
|
|
45
|
+
* @param {object} o.protocol the frozen protocol the score is bound to (thresholds, targets and grouping come from it)
|
|
46
|
+
* @param {'regression'|'development'|'sealed'|'operational'} o.population
|
|
47
|
+
* @param {object} [o.run] the run record the score came from, for status counts and the operational alert burden
|
|
48
|
+
* @param {number} [o.replicates]
|
|
49
|
+
*/
|
|
50
|
+
export function buildAccuracyReport({ score, protocol, population, run = null, replicates }) {
|
|
51
|
+
if (!POPULATIONS.includes(population)) throw new Error(`population must be one of ${POPULATIONS.join(', ')}`);
|
|
52
|
+
if (score.protocolHash !== protocol.protocolHash) throw new Error('the score was produced under a different protocol');
|
|
53
|
+
const t = protocol.thresholds;
|
|
54
|
+
const { groupOf } = groupTargets(protocol.targets);
|
|
55
|
+
const units = Object.entries(score.perTarget || {}).map(([id, c]) => ({ id, ...c })).sort((a, b) => a.id.localeCompare(b.id));
|
|
56
|
+
const boot = (name, statistic, filter) => groupedBootstrap({
|
|
57
|
+
units: filter ? units.filter(filter) : units, groupOf, statistic, name, protocolHash: protocol.protocolHash, ...(replicates ? { replicates } : {}),
|
|
58
|
+
});
|
|
59
|
+
const triple = (prefix, pick) => ({
|
|
60
|
+
f1: interval(boot(`${prefix}:f1`, (a) => f1Of(pick(a)), null)),
|
|
61
|
+
precision: interval(boot(`${prefix}:precision`, (a) => precisionOf(pick(a)), null)),
|
|
62
|
+
recall: interval(boot(`${prefix}:recall`, (a) => recallOf(pick(a)), null)),
|
|
63
|
+
});
|
|
64
|
+
const forLang = (lang) => {
|
|
65
|
+
const sub = (name, stat) => interval(groupedBootstrap({ units: units.filter((u) => u.language === lang), groupOf, statistic: stat, name: `lang:${lang}:${name}`, protocolHash: protocol.protocolHash, ...(replicates ? { replicates } : {}) }));
|
|
66
|
+
return { f1: sub('f1', (a) => f1Of(a.total)), precision: sub('precision', (a) => precisionOf(a.total)), recall: sub('recall', (a) => recallOf(a.total)) };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const e2e = score.endToEnd;
|
|
70
|
+
const byLanguage = {};
|
|
71
|
+
for (const [lang, c] of Object.entries(e2e.byLanguage)) byLanguage[lang] = stratum(c, forLang(lang), { minPositives: t.minPositivesPerCoreLanguage, label: `${lang}` });
|
|
72
|
+
// Family strata: no per-family interval is attempted below the floor; above it, the per-target tallies are not family-split, so
|
|
73
|
+
// the interval is reported as unavailable rather than borrowed from the language level.
|
|
74
|
+
const byFamily = {};
|
|
75
|
+
for (const [fam, c] of Object.entries(e2e.byFamily)) {
|
|
76
|
+
byFamily[fam] = stratum(c, { note: 'per-family intervals need family-split tallies; not computed in this build' }, { minPositives: t.minPositivesPerFamily, label: `family ${fam}` });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const macroStat = (a) => {
|
|
80
|
+
const f = Object.values(a.byLanguage).map(f1Of).filter((x) => x !== null);
|
|
81
|
+
return f.length ? f.reduce((s, x) => s + x, 0) / f.length : null;
|
|
82
|
+
};
|
|
83
|
+
const completion = score.completion;
|
|
84
|
+
const statusCounts = run ? { ...run.totals } : completion.totals || {};
|
|
85
|
+
const alertBurdenSection = run ? alertBurden(run, protocol.matching?.lineWindow) : null;
|
|
86
|
+
|
|
87
|
+
const report = {
|
|
88
|
+
schema: REPORT_SCHEMA, schemaVersion: SCHEMA_VERSION, population, synthetic: !!protocol.synthetic,
|
|
89
|
+
protocolHash: protocol.protocolHash, scoreHash: score.scoreHash, runId: score.runId, split: score.split,
|
|
90
|
+
definitions: {
|
|
91
|
+
truePositive: 'an adjudicated defect label matched by a completed scan finding (location range plus family or CWE)',
|
|
92
|
+
falsePositive: 'a root-cause cluster of findings inside an adjudicated negative, in its family (repeated alerts for one flaw score once)',
|
|
93
|
+
falseNegative: 'an adjudicated defect label with no match, INCLUDING one behind a timeout, error, unavailable or quarantined scan',
|
|
94
|
+
trueNegative: 'an adjudicated negative with no finding in its family',
|
|
95
|
+
precision: 'TP / (TP + FP)', recall: 'TP / (TP + FN)', f1: '2TP / (2TP + FP + FN)',
|
|
96
|
+
micro: 'counts pooled across languages, then the ratio', macro: 'mean of per-language F1 over languages where F1 is defined',
|
|
97
|
+
},
|
|
98
|
+
counts: { endToEnd: { micro: { tp: e2e.micro.tp, fp: e2e.micro.fp, fn: e2e.micro.fn, tn: e2e.micro.tn } }, conditionalOnCompletion: { micro: { tp: score.conditional.micro.tp, fp: score.conditional.micro.fp, fn: score.conditional.micro.fn, tn: score.conditional.micro.tn } } },
|
|
99
|
+
aggregate: {
|
|
100
|
+
micro: { precision: e2e.micro.precision, recall: e2e.micro.recall, f1: e2e.micro.f1, intervals: triple('micro', (a) => a.total) },
|
|
101
|
+
macro: { f1: e2e.macroF1, languages: e2e.groups, interval: interval(boot('macro:f1', macroStat, null)) },
|
|
102
|
+
note: 'end to end: failures on known positives are misses. Conditional-on-completion figures are additional and live under counts.conditionalOnCompletion.',
|
|
103
|
+
},
|
|
104
|
+
byLanguage, byFamily,
|
|
105
|
+
completion: { ...completion, interval: wilsonInterval(completion.completed, completion.outcomes), statusCounts },
|
|
106
|
+
disclosed: {
|
|
107
|
+
unsupported: { outcomesUnavailable: statusCounts.unavailable ?? 0, outcomesQuarantined: statusCounts.quarantined ?? 0 },
|
|
108
|
+
unscored: {
|
|
109
|
+
unlabeledTargets: score.unscored.unlabeledTargets.length, nonAdjudicatedLabels: score.unscored.nonAdjudicatedLabels,
|
|
110
|
+
unlabeledFindings: score.unscored.unlabeledFindings.count, positivesOutsideRun: score.unscored.positivesOutsideRun,
|
|
111
|
+
},
|
|
112
|
+
unknownLabels: { count: score.unscored.nonAdjudicatedLabels, note: 'candidate, disputed or rejected labels are never scored' },
|
|
113
|
+
trueNegativeFailures: { count: score.negativeFailures.length, note: 'a negative whose scan did not complete is neither a true negative nor a false positive', items: score.negativeFailures },
|
|
114
|
+
reviewQueue: { count: score.reviewQueue.length, note: 'findings no adjudicated label accounts for; they may be genuine and are not counted as false positives' },
|
|
115
|
+
},
|
|
116
|
+
operationalAlertBurden: alertBurdenSection,
|
|
117
|
+
intervalMethods: { registered: t.intervalMethod, definitions: INTERVAL_METHODS },
|
|
118
|
+
};
|
|
119
|
+
report.reportHash = digestOf({ ...report, reportHash: undefined });
|
|
120
|
+
return report;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The operational burden: how many alerts a reviewer would actually face, reported SEPARATELY from the deduplicated scoring.
|
|
125
|
+
* `rawAlerts` counts every finding on every completed outcome; `rootCauses` collapses repeats. Neither number replaces the other.
|
|
126
|
+
*/
|
|
127
|
+
export function alertBurden(run, lineWindow = 3) {
|
|
128
|
+
let raw = 0; let roots = 0; let completedTargets = 0;
|
|
129
|
+
const byTarget = {};
|
|
130
|
+
for (const o of run.outcomes || []) {
|
|
131
|
+
if (o.status !== 'completed') continue;
|
|
132
|
+
completedTargets++;
|
|
133
|
+
const n = (o.findings || []).length;
|
|
134
|
+
const c = clusterAlerts(o.findings || [], lineWindow).length;
|
|
135
|
+
raw += n; roots += c;
|
|
136
|
+
const key = o.targetId;
|
|
137
|
+
byTarget[key] = { rawAlerts: (byTarget[key]?.rawAlerts || 0) + n, rootCauses: (byTarget[key]?.rootCauses || 0) + c };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
rawAlerts: raw, rootCauses: roots, duplicatesCollapsed: raw - roots, completedOutcomes: completedTargets,
|
|
141
|
+
alertsPerCompletedOutcome: completedTargets ? raw / completedTargets : null,
|
|
142
|
+
rootCausesPerCompletedOutcome: completedTargets ? roots / completedTargets : null,
|
|
143
|
+
byTarget,
|
|
144
|
+
note: 'rawAlerts is what a reviewer reads; rootCauses is what scoring counts. Deduplication never hides burden.',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The four populations must not share a target. Returns the overlaps; empty means disjoint. */
|
|
149
|
+
export function checkPopulationsDisjoint(populations) {
|
|
150
|
+
const seen = new Map(); const overlaps = [];
|
|
151
|
+
for (const name of POPULATIONS) {
|
|
152
|
+
for (const id of populations?.[name] || []) {
|
|
153
|
+
if (seen.has(id) && seen.get(id) !== name) overlaps.push({ targetId: id, populations: [seen.get(id), name] });
|
|
154
|
+
else seen.set(id, name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return overlaps;
|
|
158
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Reproducible evaluation runs and layer ablations (QA-003).
|
|
2
|
+
//
|
|
3
|
+
// A run executes the engine over the protocol's pinned targets and records, for
|
|
4
|
+
// every target and variant, the outcome (completed / timeout / error /
|
|
5
|
+
// unavailable / quarantined), its findings, timing, cost, failure reason and the
|
|
6
|
+
// hashes of what went in and what came out. Nothing is dropped: a target that
|
|
7
|
+
// timed out is a recorded outcome, and scoring (score.js) counts it as a MISS
|
|
8
|
+
// when a known positive sat behind it.
|
|
9
|
+
//
|
|
10
|
+
// Run identity covers everything that can change a result: protocol hash, engine
|
|
11
|
+
// and bundle, layer configuration, provider/model/settings/cache, budget, seed,
|
|
12
|
+
// replicate, split, and the digests of the targets themselves. Change any one and
|
|
13
|
+
// the id changes, so two runs are never confused or silently merged.
|
|
14
|
+
//
|
|
15
|
+
// The engine never sees labels: this module receives workspace directories only,
|
|
16
|
+
// stages them through custody.js (which audits and quarantines), and runs the
|
|
17
|
+
// scan in a child process under a scrubbed environment.
|
|
18
|
+
|
|
19
|
+
import * as crypto from 'node:crypto';
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { SCHEMA_VERSION } from '../assurance/schema-kit.js';
|
|
26
|
+
import { digestOf } from '../assurance/identity.js';
|
|
27
|
+
import { STATE_DIR_NAME } from '../state-dir.js';
|
|
28
|
+
import { validateProtocol } from './protocol.js';
|
|
29
|
+
import { stageWorkspace, engineEnvironment } from './custody.js';
|
|
30
|
+
import { RESULT_BEGIN, RESULT_END, LAYER_ENV } from './scan-child.js';
|
|
31
|
+
|
|
32
|
+
const RUN_SCHEMA = 'agentic-security/evaluation-run';
|
|
33
|
+
export const ABLATION_LAYERS = Object.freeze(Object.keys(LAYER_ENV));
|
|
34
|
+
const OUTCOME_STATUSES = Object.freeze(['completed', 'timeout', 'error', 'unavailable', 'quarantined']);
|
|
35
|
+
const SCAN_CHILD = path.join(path.dirname(fileURLToPath(import.meta.url)), 'scan-child.js');
|
|
36
|
+
const SKIP = new Set(['.git', STATE_DIR_NAME, 'node_modules']);
|
|
37
|
+
|
|
38
|
+
/** Content digest of a directory tree: sorted relative paths plus bytes. Symlinks are hashed by target, never followed. */
|
|
39
|
+
export function digestTree(dir, { maxFiles = 50000 } = {}) {
|
|
40
|
+
const h = crypto.createHash('sha256');
|
|
41
|
+
let n = 0;
|
|
42
|
+
const walk = (d, rel) => {
|
|
43
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
44
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
45
|
+
const full = path.join(d, e.name);
|
|
46
|
+
if (e.isSymbolicLink()) { h.update(`L:${r}:${fs.readlinkSync(full)}\n`); continue; }
|
|
47
|
+
if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(full, r); continue; }
|
|
48
|
+
if (!e.isFile()) continue;
|
|
49
|
+
if (++n > maxFiles) throw new Error(`digestTree: more than ${maxFiles} files`);
|
|
50
|
+
h.update(`F:${r}:${fs.statSync(full).size}\n`);
|
|
51
|
+
h.update(fs.readFileSync(full));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(dir, '');
|
|
55
|
+
return `sha256:${h.digest('hex')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The digest a protocol pins for one target: both variants' trees. */
|
|
59
|
+
export function targetDigestOf({ pre, post }) { return digestOf({ pre: pre ?? null, post: post ?? null }); }
|
|
60
|
+
|
|
61
|
+
const keyOf = (o) => digestOf(o);
|
|
62
|
+
|
|
63
|
+
/** Everything that can change a run's results. Labels are deliberately absent: the engine never sees them. */
|
|
64
|
+
export function runIdentityMaterial({ protocol, config, budget, split, replicate = 0, targetDigests }) {
|
|
65
|
+
return {
|
|
66
|
+
protocolHash: protocol.protocolHash,
|
|
67
|
+
engine: protocol.engine,
|
|
68
|
+
config: {
|
|
69
|
+
layer: config.layer, provider: config.provider ?? null, model: config.model ?? null,
|
|
70
|
+
settings: config.settings ?? {}, cache: config.cache ?? { mode: 'none' }, seed: config.seed ?? null,
|
|
71
|
+
},
|
|
72
|
+
budget, split, replicate,
|
|
73
|
+
targets: Object.fromEntries(Object.entries(targetDigests).sort(([a], [b]) => a.localeCompare(b))),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export const runIdentity = (m) => `erun:${keyOf({ kind: 'evaluation-run', ...runIdentityMaterial(m) }).slice('sha256:'.length, 'sha256:'.length + 20)}`;
|
|
77
|
+
/** Same as the run id but with the replicate left out: the id of the experiment replicates belong to. */
|
|
78
|
+
const experimentIdentity = (m) => `eexp:${keyOf({ kind: 'evaluation-experiment', ...runIdentityMaterial({ ...m, replicate: 0 }) }).slice('sha256:'.length, 'sha256:'.length + 20)}`;
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------- the default scan function
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run one engine scan in a child process. The child is killed (whole process
|
|
84
|
+
* group) when the timeout expires. Returns `{ findings }` or throws an Error whose
|
|
85
|
+
* `code` is TIMEOUT, UNAVAILABLE or ERROR.
|
|
86
|
+
*/
|
|
87
|
+
export function defaultScanFn(workspaceDir, { layer, timeoutMs, env, diagnose = false, childPath = SCAN_CHILD }) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const child = spawn(process.execPath, [childPath, workspaceDir, layer, ...(diagnose ? ['diagnose'] : [])], { env, cwd: workspaceDir, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
90
|
+
let out = ''; let err = ''; let done = false;
|
|
91
|
+
const cap = 64 * 1024 * 1024;
|
|
92
|
+
const fail = (code, message) => { if (done) return; done = true; clearTimeout(timer); const e = new Error(message); e.code = code; reject(e); };
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } }
|
|
95
|
+
fail('TIMEOUT', `no result within ${timeoutMs} ms`);
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
child.stdout.on('data', (b) => { if (out.length < cap) out += b; });
|
|
98
|
+
child.stderr.on('data', (b) => { if (err.length < 65536) err += b; });
|
|
99
|
+
child.on('error', (e) => fail('ERROR', e.message));
|
|
100
|
+
child.on('close', (code) => {
|
|
101
|
+
if (done) return;
|
|
102
|
+
if (code === 3) return fail('UNAVAILABLE', err.trim().slice(0, 300) || 'layer unavailable');
|
|
103
|
+
if (code !== 0) return fail('ERROR', `scan exited ${code}: ${err.trim().slice(-300)}`);
|
|
104
|
+
const a = out.indexOf(RESULT_BEGIN); const b = out.indexOf(RESULT_END);
|
|
105
|
+
if (a < 0 || b < a) return fail('ERROR', 'scan produced no result block');
|
|
106
|
+
try { done = true; clearTimeout(timer); resolve(JSON.parse(out.slice(a + RESULT_BEGIN.length, b))); } catch (e) { done = false; fail('ERROR', `unreadable result: ${e.message}`); }
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sortFindings = (fs_) => [...fs_].sort((x, y) => `${x.file}|${x.line}|${x.family}|${x.cwe}|${x.id}`.localeCompare(`${y.file}|${y.line}|${y.family}|${y.cwe}|${y.id}`));
|
|
112
|
+
|
|
113
|
+
function emptyOutcome(targetId, variant, status, failureReason, extra = {}) {
|
|
114
|
+
return { targetId, variant, status, findings: [], durationMs: 0, costUsd: null, costSource: 'unmeasured', failureReason, inputHash: null, outputHash: digestOf([]), cached: false, ...extra };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Execute one configuration over the protocol's targets.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} o
|
|
121
|
+
* @param {object} o.protocol a frozen, valid protocol
|
|
122
|
+
* @param {object} o.config { layer, provider?, model?, settings?, cache?, seed? }
|
|
123
|
+
* @param {Function} o.resolveTarget (target, variant) => { dir } | { unavailable: reason }
|
|
124
|
+
* @param {Function} [o.scanFn] (workspace, { layer, timeoutMs, seed, env }) => { findings, costUsd? }
|
|
125
|
+
* @param {string[]} [o.protectedTerms] terms that must not appear in an engine workspace
|
|
126
|
+
* @param {'dev'|'sealed'|'all'} [o.split]
|
|
127
|
+
* @param {boolean} [o.allowSealed] required to touch sealed targets (the custodian's call)
|
|
128
|
+
* @param {Map} [o.cache] outcome cache, keyed by every semantically relevant input
|
|
129
|
+
*/
|
|
130
|
+
export async function runEvaluation(o) {
|
|
131
|
+
const { protocol, config, resolveTarget, scanFn = defaultScanFn, protectedTerms = [], split = 'dev', allowSealed = false, cache = null, replicate = 0, workRoot, now = Date.now } = o;
|
|
132
|
+
const v = validateProtocol(protocol);
|
|
133
|
+
if (!v.ok) return { ok: false, errors: v.errors, run: null };
|
|
134
|
+
if (!ABLATION_LAYERS.includes(config?.layer)) return { ok: false, errors: [{ code: 'UNKNOWN_ENUM', path: 'config.layer', message: `layer must be one of ${ABLATION_LAYERS.join(', ')}` }], run: null };
|
|
135
|
+
if ((split === 'sealed' || split === 'all') && !allowSealed) {
|
|
136
|
+
return { ok: false, errors: [{ code: 'SEALED_ACCESS', path: 'split', message: 'only the evaluation custodian may run the sealed split (allowSealed)' }], run: null };
|
|
137
|
+
}
|
|
138
|
+
const wanted = new Set(split === 'all' ? [...protocol.splits.dev, ...protocol.splits.sealed] : protocol.splits[split] || []);
|
|
139
|
+
const targets = protocol.targets.filter((t) => wanted.has(t.id)).sort((a, b) => a.id.localeCompare(b.id));
|
|
140
|
+
const budget = { perTargetTimeoutMs: protocol.limits.perTargetTimeoutMs, spendCeilingUsd: protocol.limits.spendCeilingUsd };
|
|
141
|
+
const cfg = { ...config, seed: config.seed ?? null };
|
|
142
|
+
|
|
143
|
+
const ownRoot = !workRoot;
|
|
144
|
+
const root = workRoot || fs.mkdtempSync(path.join(os.tmpdir(), 'eval-run-'));
|
|
145
|
+
const outcomes = []; const targetDigests = {};
|
|
146
|
+
let spent = 0;
|
|
147
|
+
try {
|
|
148
|
+
for (const t of targets) {
|
|
149
|
+
const variants = t.postCommit ? ['pre', 'post'] : ['pre'];
|
|
150
|
+
const resolved = {}; const digests = {};
|
|
151
|
+
for (const variant of variants) {
|
|
152
|
+
const r = await resolveTarget(t, variant);
|
|
153
|
+
resolved[variant] = r;
|
|
154
|
+
if (r?.dir) { try { digests[variant] = digestTree(r.dir); } catch (e) { resolved[variant] = { unavailable: `digest failed: ${e.message}` }; } }
|
|
155
|
+
}
|
|
156
|
+
const allResolved = variants.every((vn) => resolved[vn]?.dir);
|
|
157
|
+
const combined = allResolved ? targetDigestOf({ pre: digests.pre, post: digests.post ?? null }) : null;
|
|
158
|
+
// `targetDigests` carries the pinned digest even when the tree is unavailable, so identity is stable.
|
|
159
|
+
targetDigests[t.id] = combined || t.digest;
|
|
160
|
+
for (const variant of variants) {
|
|
161
|
+
const base = { targetId: t.id, variant };
|
|
162
|
+
if (!resolved[variant]?.dir) { outcomes.push(emptyOutcome(t.id, variant, 'unavailable', resolved[variant]?.unavailable || 'target could not be resolved')); continue; }
|
|
163
|
+
if (combined !== t.digest) { outcomes.push(emptyOutcome(t.id, variant, 'unavailable', 'target-digest-mismatch: the tree does not match the digest pinned in the protocol')); continue; }
|
|
164
|
+
const inputHash = digestOf({ digest: digests[variant], layer: cfg.layer, budget, seed: cfg.seed, replicate, engine: protocol.engine, provider: cfg.provider ?? null, model: cfg.model ?? null, settings: cfg.settings ?? {}, cache: cfg.cache ?? { mode: 'none' } });
|
|
165
|
+
if (cache && cfg.cache?.mode === 'enabled' && cache.has(inputHash)) { outcomes.push({ ...cache.get(inputHash), ...base, cached: true }); continue; }
|
|
166
|
+
if (spent >= budget.spendCeilingUsd && budget.spendCeilingUsd > 0 && cfg.layer === 'model-assisted') {
|
|
167
|
+
outcomes.push(emptyOutcome(t.id, variant, 'error', 'spend-ceiling-reached', { inputHash })); continue;
|
|
168
|
+
}
|
|
169
|
+
const staged = stageWorkspace({ srcDir: resolved[variant].dir, destDir: path.join(root, `${t.id}-${variant}`.replace(/[^\w.-]/g, '_')), terms: protectedTerms });
|
|
170
|
+
if (!staged.ok) { outcomes.push(emptyOutcome(t.id, variant, 'quarantined', `leakage control: ${staged.leaks.map((l) => `${l.kind}${l.file ? `@${l.file}` : ''}`).join(', ')}`, { inputHash })); continue; }
|
|
171
|
+
const env = engineEnvironment({ PATH: process.env.PATH, LANG: 'C', HOME: root, ...(cfg.layer === 'model-assisted' ? pickLlmEnv(process.env) : {}) });
|
|
172
|
+
const t0 = now();
|
|
173
|
+
try {
|
|
174
|
+
const res = await scanFn(staged.destDir, { layer: cfg.layer, timeoutMs: budget.perTargetTimeoutMs, seed: cfg.seed, env });
|
|
175
|
+
const findings = sortFindings(res?.findings || []);
|
|
176
|
+
const cost = typeof res?.costUsd === 'number' ? res.costUsd : null;
|
|
177
|
+
if (cost) spent += cost;
|
|
178
|
+
const oc = { ...base, status: 'completed', findings, durationMs: now() - t0, costUsd: cost, costSource: cost === null ? 'unmeasured' : 'reported-by-scan', failureReason: null, inputHash, outputHash: digestOf(findings), cached: false };
|
|
179
|
+
if (cache && cfg.cache?.mode === 'enabled') cache.set(inputHash, oc);
|
|
180
|
+
outcomes.push(oc);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
const status = e?.code === 'TIMEOUT' ? 'timeout' : e?.code === 'UNAVAILABLE' ? 'unavailable' : 'error';
|
|
183
|
+
outcomes.push(emptyOutcome(t.id, variant, status, String(e?.message || e).slice(0, 400), { durationMs: now() - t0, inputHash }));
|
|
184
|
+
} finally {
|
|
185
|
+
fs.rmSync(staged.destDir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
if (ownRoot) fs.rmSync(root, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
const material = { protocol, config: cfg, budget, split, replicate, targetDigests };
|
|
193
|
+
const totals = Object.fromEntries(OUTCOME_STATUSES.map((s) => [s, outcomes.filter((x) => x.status === s).length]));
|
|
194
|
+
const run = {
|
|
195
|
+
schema: RUN_SCHEMA, schemaVersion: SCHEMA_VERSION,
|
|
196
|
+
runId: runIdentity(material), experimentId: experimentIdentity(material),
|
|
197
|
+
protocolHash: protocol.protocolHash, engine: protocol.engine, split, replicate,
|
|
198
|
+
config: runIdentityMaterial(material).config, budget,
|
|
199
|
+
targetSetDigest: digestOf(runIdentityMaterial(material).targets),
|
|
200
|
+
sealedTargetIds: split === 'dev' ? [] : targets.filter((t) => protocol.splits.sealed.includes(t.id)).map((t) => t.id),
|
|
201
|
+
outcomes, totals, totalCostUsd: outcomes.reduce((s, x) => s + (x.costUsd || 0), 0),
|
|
202
|
+
rawHash: digestOf(outcomes.map((x) => ({ t: x.targetId, v: x.variant, s: x.status, o: x.outputHash }))),
|
|
203
|
+
};
|
|
204
|
+
return { ok: true, errors: [], run };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function pickLlmEnv(env) {
|
|
208
|
+
const out = {};
|
|
209
|
+
for (const [k, v] of Object.entries(env)) if (/^AGENTIC_SECURITY_LLM_/.test(k)) out[k] = v;
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Replicates of one configuration. Deterministic layers must reproduce their
|
|
215
|
+
* output hashes exactly; the model-assisted layer is stochastic and needs at
|
|
216
|
+
* least three replicates with recorded seeds, or the result is invalid.
|
|
217
|
+
*/
|
|
218
|
+
export async function runReplicates({ n = 1, seedSupported = false, ...rest }) {
|
|
219
|
+
const stochastic = rest.config?.layer === 'model-assisted';
|
|
220
|
+
const runs = [];
|
|
221
|
+
for (let i = 0; i < n; i++) {
|
|
222
|
+
const config = { ...rest.config, seed: seedSupported ? (rest.config.seed ?? 0) + i : (rest.config.seed ?? null) };
|
|
223
|
+
const r = await runEvaluation({ ...rest, config, replicate: i });
|
|
224
|
+
if (!r.ok) return { ok: false, errors: r.errors, runs, valid: false, reasons: ['run rejected'] };
|
|
225
|
+
runs.push(r.run);
|
|
226
|
+
}
|
|
227
|
+
const reasons = [];
|
|
228
|
+
if (stochastic && n < 3) reasons.push(`a stochastic configuration needs at least 3 measured replicates, got ${n}`);
|
|
229
|
+
if (stochastic && !seedSupported) reasons.push('seeds are not supported by this provider; replicate variance is recorded without a seed');
|
|
230
|
+
const differing = [];
|
|
231
|
+
if (n >= 2) {
|
|
232
|
+
const first = runs[0];
|
|
233
|
+
for (const o of first.outcomes) {
|
|
234
|
+
const hashes = runs.map((r) => r.outcomes.find((x) => x.targetId === o.targetId && x.variant === o.variant)?.outputHash);
|
|
235
|
+
if (new Set(hashes).size > 1) differing.push(`${o.targetId}/${o.variant}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const determinism = n < 2 ? { checked: false, identical: null, differing: [] } : { checked: true, identical: differing.length === 0, differing };
|
|
239
|
+
if (!stochastic && determinism.checked && !determinism.identical) reasons.push(`deterministic configuration produced different output across replicates: ${differing.join(', ')}`);
|
|
240
|
+
return { ok: true, errors: [], runs, stochastic, determinism, valid: reasons.filter((x) => !x.startsWith('seeds are not supported')).length === 0, reasons };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Run every layer over identical targets, matching policy and ceilings. Only the
|
|
245
|
+
* layer differs between configurations; anything else that differs is refused.
|
|
246
|
+
*/
|
|
247
|
+
export async function runAblations({ layers = ABLATION_LAYERS, config = {}, ...rest }) {
|
|
248
|
+
const runs = [];
|
|
249
|
+
for (const layer of layers) {
|
|
250
|
+
const r = await runEvaluation({ ...rest, config: { ...config, layer } });
|
|
251
|
+
if (!r.ok) return { ok: false, errors: r.errors, runs };
|
|
252
|
+
runs.push(r.run);
|
|
253
|
+
}
|
|
254
|
+
const cmp = assertAblationComparable(runs);
|
|
255
|
+
return { ok: cmp.ok, errors: cmp.errors, runs, comparable: cmp.ok };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Ablations are comparable only if protocol, split, target set and budget are identical and only the layer differs. */
|
|
259
|
+
export function assertAblationComparable(runs) {
|
|
260
|
+
const errors = [];
|
|
261
|
+
const [a, ...others] = runs || [];
|
|
262
|
+
if (!a) return { ok: false, errors: [{ code: 'MISSING_FIELD', path: 'runs', message: 'no runs to compare' }] };
|
|
263
|
+
for (const r of others) {
|
|
264
|
+
for (const [field, same] of [
|
|
265
|
+
['protocolHash', r.protocolHash === a.protocolHash], ['split', r.split === a.split],
|
|
266
|
+
['targetSetDigest', r.targetSetDigest === a.targetSetDigest], ['budget', digestOf(r.budget) === digestOf(a.budget)],
|
|
267
|
+
['config.provider', r.config.provider === a.config.provider], ['config.model', r.config.model === a.config.model],
|
|
268
|
+
['config.settings', digestOf(r.config.settings) === digestOf(a.config.settings)], ['config.cache', digestOf(r.config.cache) === digestOf(a.config.cache)],
|
|
269
|
+
]) if (!same) errors.push({ code: 'ABLATION_MISMATCH', path: field, message: `${r.config.layer} differs from ${a.config.layer} in ${field}; ablations may differ only in layer` });
|
|
270
|
+
if (r.runId === a.runId) errors.push({ code: 'DUPLICATE_ID', path: 'runId', message: 'two ablations share a run identity' });
|
|
271
|
+
}
|
|
272
|
+
return { ok: errors.length === 0, errors };
|
|
273
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Child-process entry for ONE engine scan inside an evaluation run (QA-003).
|
|
2
|
+
//
|
|
3
|
+
// `node scan-child.js <workspace> <layer-config>` where the layer config is one
|
|
4
|
+
// of the ablation names in runner.js. It exists as a separate process so that:
|
|
5
|
+
// - a timeout can KILL the scan (an in-process scan cannot be interrupted);
|
|
6
|
+
// - the environment is exactly what the runner passed, with no label paths;
|
|
7
|
+
// - state writes cannot touch the host's scan state.
|
|
8
|
+
//
|
|
9
|
+
// A third argument `diagnose` adds a `diagnostics` block to the result (why-missed.js): the suppression ledger with its
|
|
10
|
+
// identities, the filter and dedupe stage evidence, the sources and sinks the engine recognised, and how the intermediate
|
|
11
|
+
// representation fared on the files named in AGENTIC_SECURITY_DIAG_FILES. Diagnose mode only OBSERVES; the findings it returns are
|
|
12
|
+
// the same findings the plain mode returns (pinned by test).
|
|
13
|
+
//
|
|
14
|
+
// It reads only the staged workspace. Exit codes: 0 ok (JSON on stdout between
|
|
15
|
+
// the markers), 3 the requested layer is unavailable here (a model-assisted run
|
|
16
|
+
// with no configured endpoint), 1 anything else.
|
|
17
|
+
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const RESULT_BEGIN = '@@EVAL-RESULT-BEGIN@@';
|
|
22
|
+
export const RESULT_END = '@@EVAL-RESULT-END@@';
|
|
23
|
+
|
|
24
|
+
export const LAYER_ENV = {
|
|
25
|
+
'deterministic-only': { AGENTIC_SECURITY_DEEP: '0', AGENTIC_SECURITY_LLM_VALIDATE: '0' },
|
|
26
|
+
'deep-taint': { AGENTIC_SECURITY_DEEP: '1', AGENTIC_SECURITY_LLM_VALIDATE: '0' },
|
|
27
|
+
'model-assisted': { AGENTIC_SECURITY_DEEP: '1' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function diagnosticsOf(scan) {
|
|
31
|
+
const slim = (x) => ({ file: x.file ?? null, line: Number.isInteger(x.line) ? x.line : null, vuln: x.vuln ?? x.label ?? null, category: x.category ?? null });
|
|
32
|
+
const diag = {
|
|
33
|
+
suppressions: (scan.suppressions || []).map((x) => ({ vuln: x.vuln ?? null, file: x.file ?? null, line: Number.isInteger(x.line) ? x.line : null, reason: String(x.reason ?? ''), id: x.id ?? null, cwe: x.cwe ?? null, family: x.family ?? null })),
|
|
34
|
+
stageEvidence: scan.stageEvidence || { dedupe: [], guard: [] },
|
|
35
|
+
sources: (scan.sources || []).map(slim), sinks: (scan.sinks || []).map(slim),
|
|
36
|
+
ir: { files: {}, parseFailures: [] },
|
|
37
|
+
};
|
|
38
|
+
let wanted = [];
|
|
39
|
+
try { wanted = JSON.parse(process.env.AGENTIC_SECURITY_DIAG_FILES || '[]'); } catch { wanted = []; }
|
|
40
|
+
if (wanted.length) {
|
|
41
|
+
try {
|
|
42
|
+
const irMod = await import('../../ir/index.js');
|
|
43
|
+
irMod._resetIrParseFailures();
|
|
44
|
+
const ir = await irMod.buildProjectIRAsync(Object.fromEntries(wanted.filter((f) => scan.fc && typeof scan.fc[f] === 'string').map((f) => [f, scan.fc[f]])));
|
|
45
|
+
for (const f of wanted) {
|
|
46
|
+
const entry = ir.perFile?.[f];
|
|
47
|
+
diag.ir.files[f] = entry ? { lowered: true, functions: (entry.functions || []).map((fn) => ({ line: fn.line, name: fn.name })) } : { lowered: false, functions: [] };
|
|
48
|
+
}
|
|
49
|
+
diag.ir.parseFailures = irMod.irParseFailures();
|
|
50
|
+
} catch (e) { diag.ir.error = String(e?.message || e).slice(0, 200); }
|
|
51
|
+
}
|
|
52
|
+
return diag;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
const [dir, layer, mode] = process.argv.slice(2);
|
|
57
|
+
if (!dir || !LAYER_ENV[layer]) { process.stderr.write(`usage: scan-child <workspace> <${Object.keys(LAYER_ENV).join('|')}>\n`); process.exit(1); }
|
|
58
|
+
if (layer === 'model-assisted' && !process.env.AGENTIC_SECURITY_LLM_ENDPOINT) {
|
|
59
|
+
process.stderr.write('model-assisted requires AGENTIC_SECURITY_LLM_ENDPOINT; none is configured\n');
|
|
60
|
+
process.exit(3);
|
|
61
|
+
}
|
|
62
|
+
Object.assign(process.env, LAYER_ENV[layer]);
|
|
63
|
+
if (mode === 'diagnose') process.env.AGENTIC_SECURITY_STAGE_EVIDENCE = '1';
|
|
64
|
+
const { setStateWritesEnabled } = await import('../state-dir.js');
|
|
65
|
+
setStateWritesEnabled(false);
|
|
66
|
+
const { runScan } = await import('../../runScan.js');
|
|
67
|
+
const { scan } = await runScan(path.resolve(dir), { deep: layer !== 'deterministic-only' });
|
|
68
|
+
const findings = (scan.findings || []).map((f) => ({
|
|
69
|
+
id: f.id ?? null, file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null,
|
|
70
|
+
family: f.family ?? null, cwe: f.cwe ?? null, severity: f.severity ?? null, parser: f.parser ?? null, vuln: f.vuln ?? null,
|
|
71
|
+
}));
|
|
72
|
+
const result = { findings };
|
|
73
|
+
if (mode === 'diagnose') result.diagnostics = await diagnosticsOf(scan);
|
|
74
|
+
process.stdout.write(`${RESULT_BEGIN}${JSON.stringify(result)}${RESULT_END}\n`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Only when run as a script: runner.js imports the markers and must not start a scan.
|
|
78
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
79
|
+
main().then(() => process.exit(0), (e) => { process.stderr.write(`${e?.stack || e}\n`); process.exit(1); });
|
|
80
|
+
}
|