@clear-capabilities/agentic-security-scanner 0.171.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.
- package/CHANGELOG.md +29 -0
- package/bin/agentic-security.js +6 -0
- package/dist/1336.index.js +891 -719
- package/dist/550.index.js +548 -0
- package/dist/7405.index.js +885 -0
- package/dist/9382.index.js +4386 -0
- package/dist/9579.index.js +1197 -0
- package/dist/agentic-security.mjs +4 -4
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/context-intelligence/CLAUDE.md +25 -0
- package/src/context-intelligence/adapters/existing-ir.js +14 -0
- package/src/context-intelligence/adapters/haskell-hie.js +165 -0
- package/src/context-intelligence/build.js +68 -0
- package/src/context-intelligence/candidates.js +115 -0
- package/src/context-intelligence/canonical.js +120 -0
- package/src/context-intelligence/capabilities.js +33 -0
- package/src/context-intelligence/cli.js +426 -0
- package/src/context-intelligence/config.js +91 -0
- package/src/context-intelligence/controls.js +244 -0
- package/src/context-intelligence/diff.js +97 -0
- package/src/context-intelligence/entrypoints.js +336 -0
- package/src/context-intelligence/evaluation/index.js +235 -0
- package/src/context-intelligence/evaluation/runner.js +139 -0
- package/src/context-intelligence/evaluation/scripted-reader.js +289 -0
- package/src/context-intelligence/files.js +80 -0
- package/src/context-intelligence/graph-schema.js +227 -0
- package/src/context-intelligence/imports.js +123 -0
- package/src/context-intelligence/manifest.js +99 -0
- package/src/context-intelligence/packer.js +224 -0
- package/src/context-intelligence/persist.js +40 -0
- package/src/context-intelligence/prompts/packs.js +82 -0
- package/src/context-intelligence/queue.js +355 -0
- package/src/context-intelligence/reachability.js +188 -0
- package/src/context-intelligence/repo-info.js +16 -0
- package/src/context-intelligence/review.js +370 -0
- package/src/context-intelligence/spans.js +147 -0
- package/src/context-intelligence/store.js +203 -0
- package/src/posture/artifact-registry.js +2 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
//
|
|
2
|
+
// `agentic-security context <build|inspect|diff|review|status|eval|verify|export|cleanup>`
|
|
3
|
+
//
|
|
4
|
+
// Additive: no existing command changes. `build`, `inspect`, `diff`, `status`, `verify`, `export` and `cleanup` read
|
|
5
|
+
// the repository and write only DCI's own state, with no model and no network. `review` is the only subcommand that
|
|
6
|
+
// can reach a model, and only through the governed discovery caller (egress policy, redaction, local-only).
|
|
7
|
+
//
|
|
8
|
+
// Exit codes: 0 done; 1 bad arguments or a refused operation; 2 the input was too large to analyse; 3 (with
|
|
9
|
+
// `--strict`) the run finished but some eligible work has no verified result; 130 cancelled.
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import { buildContext } from './build.js';
|
|
13
|
+
import { loadConfig } from './config.js';
|
|
14
|
+
import { persistBuild } from './persist.js';
|
|
15
|
+
import { readStoreJson, writeStoreJson, readManifest, readPackageText, verifyStoredBundle, cleanupStore, exportBundle, listStoredBundles, storeRoot } from './store.js';
|
|
16
|
+
import { diffManifestSets, cacheReport } from './diff.js';
|
|
17
|
+
import { readGitSnapshot, readSnapshot } from './files.js';
|
|
18
|
+
import { QUESTION_PACKS, packById } from './prompts/packs.js';
|
|
19
|
+
import { cleanForTerminal } from './canonical.js';
|
|
20
|
+
import { describeModel, resolveInvoker, buildReviewPrompt, redactPackageText, checkEgressForFiles } from './review.js';
|
|
21
|
+
import { planJobs, runQueue, exportBatch, importBatch, HARD_LIMITS } from './queue.js';
|
|
22
|
+
import { reviewsToCandidates, adjudicate } from './candidates.js';
|
|
23
|
+
import { capabilityMatrix } from './capabilities.js';
|
|
24
|
+
import { statePath } from '../posture/state-dir.js';
|
|
25
|
+
|
|
26
|
+
const flag = (a, k) => (a.flags[k] === undefined ? undefined : a.flags[k]);
|
|
27
|
+
const truthy = (v) => v === true || (typeof v === 'string' && v !== 'false' && v !== '0');
|
|
28
|
+
const num = (v) => (v === undefined || v === true ? undefined : Number(v));
|
|
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
|
+
|
|
33
|
+
function out(format, text, obj) {
|
|
34
|
+
if (format === 'json') say(JSON.stringify(obj, null, 2));
|
|
35
|
+
else if (format === 'jsonl') say(JSON.stringify(obj));
|
|
36
|
+
else say(text);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function usage() {
|
|
40
|
+
say([
|
|
41
|
+
'Usage: agentic-security context <subcommand> [--root <dir>] [--format text|json|jsonl]',
|
|
42
|
+
'',
|
|
43
|
+
' build Resolve entry points and write one inspectable context bundle each (no model, no network)',
|
|
44
|
+
' [--entrypoints http,webhook,queue,cron,upload,cli] [--profile compact|standard|extended] [--max-tokens N] [--route <label|id>] [--no-write]',
|
|
45
|
+
' inspect Show one bundle: --route <label|id> | --hash <contextHash> [--show-context] [--package N]',
|
|
46
|
+
' diff Routes added/removed/changed between two refs: --from <ref> [--to <ref>] (omit --to for the working tree)',
|
|
47
|
+
' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--allow-remote] [--concurrency N]',
|
|
48
|
+
' [--max-calls N] [--max-wall-seconds N] [--max-cost-usd N --cost-per-call-usd N] [--retry-failed] [--refute] [--strict]',
|
|
49
|
+
' [--dry-run] [--export-batch] [--import-batch <results.jsonl> --batch-id <id>]',
|
|
50
|
+
' status Coverage so far: entry points, bundles, review jobs',
|
|
51
|
+
' eval Frozen evaluation: --suite fixtures --baseline deterministic|hunt --variant dci [--format json]',
|
|
52
|
+
' verify Re-check stored bundles against their hashes and the current source',
|
|
53
|
+
' export One bundle as a single JSON document: --hash <contextHash>',
|
|
54
|
+
' cleanup Remove unreferenced old bundles (blocked by a legal hold): [--older-than-days N] [--dry-run]',
|
|
55
|
+
'',
|
|
56
|
+
'Question packs: ' + QUESTION_PACKS.map(p => p.id).join(', '),
|
|
57
|
+
].join('\n'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function entrypointFilter(arg) { return arg === undefined || arg === true ? undefined : String(arg); }
|
|
61
|
+
|
|
62
|
+
async function doBuild(root, args, { write = true } = {}) {
|
|
63
|
+
const { config, problems } = loadConfig(root);
|
|
64
|
+
if (flag(args, 'entrypoints')) config.entrypointKinds = String(flag(args, 'entrypoints')).split(',').map(s => s.trim()).filter(Boolean);
|
|
65
|
+
if (flag(args, 'profile')) config.context.profile = String(flag(args, 'profile'));
|
|
66
|
+
if (flag(args, 'max-tokens')) config.context.maxTokens = num(flag(args, 'max-tokens'));
|
|
67
|
+
const events = [];
|
|
68
|
+
const jsonl = flag(args, 'format') === 'jsonl';
|
|
69
|
+
const built = await buildContext(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) say(JSON.stringify(e)); } });
|
|
70
|
+
if (!built.ok) return { ok: false, built, config, problems };
|
|
71
|
+
let persisted = null;
|
|
72
|
+
if (write) persisted = persistBuild(root, built, config);
|
|
73
|
+
return { ok: true, built, config, problems, persisted, events };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function routeTable(built) {
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const e of built.entrypoints) {
|
|
79
|
+
const b = built.bundles.find(x => x.entrypoint.id === e.id);
|
|
80
|
+
const m = b && b.manifest;
|
|
81
|
+
rows.push(` ${e.disposition.padEnd(18)} ${m ? m.context.integrity.padEnd(11) : '-'.padEnd(11)} ${e.label} (${e.registrationSite.file}:${e.registrationSite.line})${e.resolutionReasons.length ? ' [' + e.resolutionReasons.join(', ') + ']' : ''}`);
|
|
82
|
+
}
|
|
83
|
+
return rows.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function cmdBuild(root, args, format) {
|
|
87
|
+
const r = await doBuild(root, args, { write: !truthy(flag(args, 'no-write')) });
|
|
88
|
+
if (!r.ok) {
|
|
89
|
+
out(format, `context build: ${r.built.reason}${r.built.detail && r.built.detail.count ? ` (${r.built.detail.count} files, limit ${r.built.detail.limit})` : ''}`, { ok: false, reason: r.built.reason, detail: r.built.detail });
|
|
90
|
+
return r.built.reason === 'too-many-files' ? 2 : 1;
|
|
91
|
+
}
|
|
92
|
+
const { built } = r;
|
|
93
|
+
const s = built.summary;
|
|
94
|
+
const obj = { ok: true, graph: { symbols: built.graph.symbols.length, edges: built.graph.edges.length, hash: built.graph.hash, source: built.semantic.source }, snapshotHash: built.snapshotHash, repository: built.repository, summary: s, semantic: built.semantic, configProblems: r.problems, persisted: r.persisted, skippedFiles: built.skipped.length };
|
|
95
|
+
const text = [
|
|
96
|
+
'Deterministic Context Intelligence: build',
|
|
97
|
+
` graph: ${built.graph.symbols.length} symbols, ${built.graph.edges.length} edges, ${built.graph.hash.slice(0, 12)} (${built.semantic.source.join(' + ')})`,
|
|
98
|
+
` entry points found ${s.entrypointsFound}: resolved ${s.resolved}, partially resolved ${s.partial}, unresolved ${s.unresolved}, excluded by policy ${s.excluded}`,
|
|
99
|
+
` bundles built ${s.bundlesBuilt}: complete ${s.complete}, partial ${s.partialBundles}, unavailable ${s.unavailable}`,
|
|
100
|
+
` unresolved material calls: ${s.unresolvedEdges} tokens (estimate, chars/4): ${s.tokens}`,
|
|
101
|
+
built.skipped.length ? ` ${built.skipped.length} file(s) skipped (symlink, oversize or unreadable)` : null,
|
|
102
|
+
built.semantic.status && built.semantic.status !== 'off' ? ` compiler metadata: ${built.semantic.status}${built.semantic.reason ? ` (${built.semantic.reason}${built.semantic.detail ? ': ' + built.semantic.detail : ''})` : ''}` : null,
|
|
103
|
+
r.problems.length ? ` config notes: ${r.problems.join('; ')}` : null,
|
|
104
|
+
r.persisted ? (r.persisted.ok ? ` written: ${r.persisted.written} bundle(s) under .agentic-security/context-intelligence/` : ` WRITE PROBLEMS: ${JSON.stringify(r.persisted.failures)}`) : ' (not written: --no-write)',
|
|
105
|
+
'',
|
|
106
|
+
routeTable(built),
|
|
107
|
+
'',
|
|
108
|
+
'A "complete" bundle means nothing unresolved, omitted, capped or split was found. It does not mean the route is safe.',
|
|
109
|
+
].filter(x => x !== null).join('\n');
|
|
110
|
+
out(format, text, obj);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findBundle(built, sel) {
|
|
115
|
+
return built.bundles.find(b => b.entrypoint.id === sel) || built.bundles.find(b => b.entrypoint.label === sel) || built.bundles.find(b => b.entrypoint.label.includes(sel));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function cmdInspect(root, args, format) {
|
|
119
|
+
let manifest;
|
|
120
|
+
let fileContents = null;
|
|
121
|
+
if (flag(args, 'hash')) {
|
|
122
|
+
const m = readManifest(root, String(flag(args, 'hash')));
|
|
123
|
+
if (!m.ok) { out(format, `context inspect: ${m.code}`, { ok: false, reason: m.code }); return 1; }
|
|
124
|
+
manifest = m.value;
|
|
125
|
+
} else if (flag(args, 'route') && flag(args, 'route') !== true) {
|
|
126
|
+
const idx = readStoreJson(root, 'index.json');
|
|
127
|
+
const sel = String(flag(args, 'route'));
|
|
128
|
+
let hash = null;
|
|
129
|
+
if (idx.ok) {
|
|
130
|
+
const hit = idx.value.entrypoints.find(e => e.entrypointId === sel) || idx.value.entrypoints.find(e => e.label === sel) || idx.value.entrypoints.find(e => e.label.includes(sel));
|
|
131
|
+
if (hit) hash = hit.contextHash;
|
|
132
|
+
}
|
|
133
|
+
if (!hash) { out(format, `context inspect: no stored bundle for '${sel}'. Run \`context build\` first.`, { ok: false, reason: 'not-built' }); return 1; }
|
|
134
|
+
const m = readManifest(root, hash);
|
|
135
|
+
if (!m.ok) { out(format, `context inspect: ${m.code}`, { ok: false, reason: m.code }); return 1; }
|
|
136
|
+
manifest = m.value;
|
|
137
|
+
} else { out(format, 'context inspect: pass --route <label|id> or --hash <contextHash>', { ok: false, reason: 'missing-selector' }); return 1; }
|
|
138
|
+
void fileContents;
|
|
139
|
+
const pkgIdx = Number.isInteger(num(flag(args, 'package'))) ? num(flag(args, 'package')) : 1;
|
|
140
|
+
const text = truthy(flag(args, 'show-context')) ? readPackageText(root, manifest, pkgIdx) : null;
|
|
141
|
+
const obj = { ok: true, manifest, context: text && text.ok ? text.text : undefined };
|
|
142
|
+
if (format === 'json' || format === 'jsonl') { out(format, '', obj); return 0; }
|
|
143
|
+
const m = manifest;
|
|
144
|
+
const lines = [
|
|
145
|
+
`${m.entrypoint.method} ${m.entrypoint.path} [${m.entrypoint.kind}] ${m.entrypoint.registrationSite.file}:${m.entrypoint.registrationSite.line}`,
|
|
146
|
+
` context hash ${m.contextHash.slice(0, 16)} integrity ${m.context.integrity}${m.context.integrityReasons.length ? ' (' + m.context.integrityReasons.join(', ') + ')' : ''} entry point ${m.status.entrypoint}`,
|
|
147
|
+
` packages: ${m.context.packages.map(p => `${p.index}/${p.total} ~${p.tokensEstimated} tok${p.standalone ? '' : ' (not standalone)'}`).join(', ')} tokenizer: ${m.context.tokenizer && m.context.tokenizer.name}`,
|
|
148
|
+
` handler: ${m.entrypoint.handlerSymbol || '(none)'} auth scheme (${m.entrypoint.authScheme.basis}): ${m.entrypoint.authScheme.status}`,
|
|
149
|
+
' protection:',
|
|
150
|
+
...Object.entries(m.protection).map(([k, v]) => ` ${k.padEnd(20)} ${v.state.padEnd(26)} ${v.why}`),
|
|
151
|
+
` controls (${m.controls.length}):`,
|
|
152
|
+
...m.controls.slice(0, 12).map(c => ` ${c.kind.padEnd(20)} ${c.file}:${c.line} ${c.evidence}/${c.basis}${c.rejectionSeen === false ? ' (no rejection seen)' : ''}`),
|
|
153
|
+
` sensitive operations (${m.sensitiveOperations.length}):`,
|
|
154
|
+
...m.sensitiveOperations.slice(0, 8).map(c => ` ${c.kind.padEnd(14)} ${c.file}:${c.line}`),
|
|
155
|
+
` unresolved material calls (${m.unknowns.filter(u => u.material).length}):`,
|
|
156
|
+
...m.unknowns.filter(u => u.material).slice(0, 10).map(u => ` ${u.site} ${u.calleeName} (${u.reason})`),
|
|
157
|
+
` omissions (${m.omissions.length}):`,
|
|
158
|
+
...m.omissions.slice(0, 8).map(o => ` ${o.symbolId || o.range} ${o.reason}`),
|
|
159
|
+
` boundaries (calls into libraries, not first-party code): ${m.boundaries.length}`,
|
|
160
|
+
];
|
|
161
|
+
if (text) lines.push('', text.ok ? text.text : `(context text unavailable: ${text.code})`);
|
|
162
|
+
say(lines.join('\n'));
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function snapshotFor(root, ref, config) {
|
|
167
|
+
if (ref === undefined || ref === true) return readSnapshot(root, { maxFiles: config.maxFiles });
|
|
168
|
+
return readGitSnapshot(root, String(ref), { maxFiles: config.maxFiles });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdDiff(root, args, format) {
|
|
172
|
+
const from = flag(args, 'from');
|
|
173
|
+
if (!from || from === true) { out(format, 'context diff: --from <ref> is required', { ok: false, reason: 'missing-from' }); return 1; }
|
|
174
|
+
const { config } = loadConfig(root);
|
|
175
|
+
const a = await snapshotFor(root, from, config);
|
|
176
|
+
const b = await snapshotFor(root, flag(args, 'to'), config);
|
|
177
|
+
for (const [label, s] of [['--from', a], ['--to', b]]) {
|
|
178
|
+
if (!s.ok) { out(format, `context diff: cannot read ${label}: ${s.reason}`, { ok: false, reason: s.reason, which: label }); return 1; }
|
|
179
|
+
}
|
|
180
|
+
const [ba, bb] = [await buildContext(root, { config, snapshot: a }), await buildContext(root, { config, snapshot: b })];
|
|
181
|
+
if (!ba.ok || !bb.ok) { out(format, 'context diff: build failed', { ok: false, reason: (ba.ok ? bb : ba).reason }); return 1; }
|
|
182
|
+
const d = diffManifestSets(ba.bundles.filter(x => x.manifest).map(x => x.manifest), bb.bundles.filter(x => x.manifest).map(x => x.manifest));
|
|
183
|
+
const cache = cacheReport(root, bb.bundles.filter(x => x.manifest).map(x => x.manifest));
|
|
184
|
+
const obj = { ok: true, from: { ref: String(from), commit: a.commit || null, snapshotHash: a.snapshotHash }, to: { ref: flag(args, 'to') === undefined ? 'working-tree' : String(flag(args, 'to')), commit: b.commit || null, snapshotHash: b.snapshotHash }, ...d, cache };
|
|
185
|
+
const text = [
|
|
186
|
+
`context diff ${obj.from.ref} -> ${obj.to.ref}`,
|
|
187
|
+
` routes: +${d.added.length} added, -${d.removed.length} removed, ${d.changed.length} changed, ${d.unchangedCount} unchanged`,
|
|
188
|
+
...d.added.map(r => ` + ${r.route} (${r.file}, ${r.integrity})`),
|
|
189
|
+
...d.removed.map(r => ` - ${r.route} (${r.file})`),
|
|
190
|
+
...d.changed.flatMap(c => [
|
|
191
|
+
` ~ ${c.route} (${c.file}) integrity ${c.integrity.from} -> ${c.integrity.to}`,
|
|
192
|
+
...c.authControlChanges.added.map(x => ` + control ${x}`),
|
|
193
|
+
...c.authControlChanges.removed.map(x => ` - control ${x}`),
|
|
194
|
+
...c.newSensitiveOperations.map(x => ` + sensitive operation ${x}`),
|
|
195
|
+
...c.unknownEdgeChanges.added.map(x => ` + unresolved ${x}`),
|
|
196
|
+
...Object.entries(c.protectionChanges).map(([k, v]) => ` protection ${k}: ${v.from} -> ${v.to}`),
|
|
197
|
+
]),
|
|
198
|
+
` stored bundles reusable for the target: ${cache.hits} hit, ${cache.misses} miss (index ${cache.indexStatus}, ${cache.invalidated} invalidated, ${cache.newRoutes} new)`,
|
|
199
|
+
].join('\n');
|
|
200
|
+
out(format, text, obj);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function statusSnapshot(root) {
|
|
205
|
+
const idx = readStoreJson(root, 'index.json');
|
|
206
|
+
const queuesDir = path.join(storeRoot(root), 'queues');
|
|
207
|
+
const reviewsDir = path.join(storeRoot(root), 'reviews');
|
|
208
|
+
const count = (d) => { try { return fs.readdirSync(d).filter(n => n.endsWith('.json')).length; } catch { return 0; } };
|
|
209
|
+
const queues = [];
|
|
210
|
+
try {
|
|
211
|
+
for (const n of fs.readdirSync(queuesDir).filter(x => x.endsWith('.json')).sort()) {
|
|
212
|
+
const q = readStoreJson(root, `queues/${n}`);
|
|
213
|
+
if (q.ok) {
|
|
214
|
+
const tally = {};
|
|
215
|
+
for (const j of Object.values(q.value.jobs || {})) tally[j.state] = (tally[j.state] || 0) + 1;
|
|
216
|
+
queues.push({ runId: q.value.runId, jobs: Object.keys(q.value.jobs || {}).length, states: tally });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch { /* no queues yet */ }
|
|
220
|
+
return { indexed: idx.ok, indexStatus: idx.ok ? 'ok' : idx.code, summary: idx.ok ? idx.value.summary : null, graph: idx.ok ? idx.value.graph : null, repository: idx.ok ? idx.value.repository : null, storedBundles: listStoredBundles(root).length, reviews: count(reviewsDir), queues };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function cmdStatus(root, args, format) {
|
|
224
|
+
const { config, problems } = loadConfig(root);
|
|
225
|
+
const st = statusSnapshot(root);
|
|
226
|
+
const obj = { ok: true, config: { enabled: config.enabled, entrypointKinds: config.entrypointKinds, semanticMetadata: config.semanticMetadata, context: config.context, review: config.review }, configProblems: problems, ...st, capabilities: capabilityMatrix() };
|
|
227
|
+
const text = [
|
|
228
|
+
'Deterministic Context Intelligence: status',
|
|
229
|
+
st.indexed
|
|
230
|
+
? ` last build: ${st.summary.entrypointsFound} entry points (${st.summary.resolved} resolved, ${st.summary.partial} partial, ${st.summary.unresolved} unresolved, ${st.summary.excluded} excluded), bundles complete ${st.summary.complete} / partial ${st.summary.partialBundles} / unavailable ${st.summary.unavailable}`
|
|
231
|
+
: ` no build recorded (${st.indexStatus}). Run \`context build\`.`,
|
|
232
|
+
st.indexed ? ` commit ${st.repository.commit || '(no git)'}${st.repository.dirty ? ' + uncommitted changes' : ''} graph ${st.graph.hash.slice(0, 12)}` : null,
|
|
233
|
+
` stored bundles: ${st.storedBundles} verified review results: ${st.reviews}`,
|
|
234
|
+
...st.queues.map(q => ` review run ${q.runId.slice(0, 10)}: ${q.jobs} jobs ${JSON.stringify(q.states)}`),
|
|
235
|
+
problems.length ? ` config notes: ${problems.join('; ')}` : null,
|
|
236
|
+
` DCI integration enabled: ${config.enabled} (the explicit \`context\` commands work either way)`,
|
|
237
|
+
].filter(x => x !== null).join('\n');
|
|
238
|
+
out(format, text, obj);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function cmdVerify(root, args, format) {
|
|
243
|
+
const { config } = loadConfig(root);
|
|
244
|
+
const snap = await readSnapshot(root, { maxFiles: config.maxFiles });
|
|
245
|
+
if (!snap.ok) { out(format, `context verify: ${snap.reason}`, { ok: false, reason: snap.reason }); return 1; }
|
|
246
|
+
const results = [];
|
|
247
|
+
for (const h of listStoredBundles(root)) {
|
|
248
|
+
const v = verifyStoredBundle(root, h, { fileContents: snap.fileContents });
|
|
249
|
+
results.push({ contextHash: h, ok: v.ok, problems: v.problems });
|
|
250
|
+
}
|
|
251
|
+
const bad = results.filter(r => !r.ok);
|
|
252
|
+
const obj = { ok: bad.length === 0, checked: results.length, mismatched: bad.length, results };
|
|
253
|
+
out(format, `context verify: ${results.length} bundle(s) checked, ${bad.length} do not match the current source or their own hashes${bad.length ? '\n' + bad.slice(0, 20).map(b => ` ${b.contextHash.slice(0, 12)}: ${b.problems.slice(0, 3).join(', ')}`).join('\n') : ''}`, obj);
|
|
254
|
+
return bad.length ? 1 : 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function cmdExport(root, args, format) {
|
|
258
|
+
const h = flag(args, 'hash');
|
|
259
|
+
if (!h || h === true) { out(format, 'context export: --hash <contextHash> is required', { ok: false }); return 1; }
|
|
260
|
+
const r = exportBundle(root, String(h));
|
|
261
|
+
if (!r.ok) { out(format, `context export: refused (${r.problems.join(', ')})`, { ok: false, problems: r.problems }); return 1; }
|
|
262
|
+
say(JSON.stringify(r.export, null, 2));
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function cmdCleanup(root, args, format) {
|
|
267
|
+
const r = cleanupStore(root, { olderThanDays: Number.isFinite(num(flag(args, 'older-than-days'))) ? num(flag(args, 'older-than-days')) : 30, dryRun: truthy(flag(args, 'dry-run')) });
|
|
268
|
+
const text = r.blockedByLegalHold
|
|
269
|
+
? `context cleanup: nothing removed, the context-intelligence artifact is under a legal hold (${r.blockedByLegalHold.owner}: ${r.blockedByLegalHold.reason})`
|
|
270
|
+
: `context cleanup: ${r.removed.length} bundle(s) ${r.dryRun ? 'would be ' : ''}removed, ${r.kept.length} kept`;
|
|
271
|
+
out(format, text, { ok: true, ...r });
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- review ------------------------------------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
function loadJobFrom(built, packs) {
|
|
278
|
+
const byEp = new Map(built.bundles.map(b => [b.entrypoint.id, b]));
|
|
279
|
+
const packMap = new Map(packs.map(p => [p.id, p]));
|
|
280
|
+
return (job) => {
|
|
281
|
+
const bundles = job.entrypointIds.map(id => byEp.get(id));
|
|
282
|
+
if (bundles.some(b => !b || !b.manifest)) return { error: 'entry point no longer has a bundle' };
|
|
283
|
+
const pack = packMap.get(job.packId);
|
|
284
|
+
if (!pack) return { error: 'question pack unavailable' };
|
|
285
|
+
for (let i = 0; i < bundles.length; i++) {
|
|
286
|
+
if (bundles[i].manifest.contextHash !== job.contextHashes[i]) return { error: 'stale-context: the bundle changed since this job was planned' };
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
pack, entrypoints: bundles.map(b => b.entrypoint), manifests: bundles.map(b => b.manifest),
|
|
290
|
+
packageTexts: bundles.map((b, i) => (b.pack.packages.find(p => p.index === job.packageIndexes[i]) || b.pack.packages[0]).text),
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function cmdReview(root, args, format) {
|
|
296
|
+
const { config } = loadConfig(root);
|
|
297
|
+
const provider = flag(args, 'provider');
|
|
298
|
+
if (provider !== undefined && provider !== 'configured') { out(format, `context review: --provider only accepts 'configured' (the endpoint set through the existing LLM configuration); got '${provider}'`, { ok: false, reason: 'bad-provider' }); return 1; }
|
|
299
|
+
const wanted = flag(args, 'question') && flag(args, 'question') !== true ? String(flag(args, 'question')).split(',').map(s => s.trim()).filter(Boolean) : config.review.questionPacks;
|
|
300
|
+
const packs = wanted.map(packById);
|
|
301
|
+
if (packs.some(p => !p)) { out(format, `context review: unknown question pack '${wanted[packs.findIndex(p => !p)]}'. Known: ${QUESTION_PACKS.map(p => p.id).join(', ')}`, { ok: false, reason: 'unknown-pack' }); return 1; }
|
|
302
|
+
|
|
303
|
+
// --local-only is an explicit statement of intent: it is enforced by the existing egress policy, which denies any
|
|
304
|
+
// non-loopback endpoint, and a denial skips the jobs. There is no fallback to another provider.
|
|
305
|
+
if (truthy(flag(args, 'local-only'))) process.env.AGENTIC_SECURITY_EGRESS_MODE = 'local-only';
|
|
306
|
+
|
|
307
|
+
const r = await doBuild(root, args, { write: true });
|
|
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; }
|
|
309
|
+
const { built } = r;
|
|
310
|
+
const model = describeModel({ allowRemote: truthy(flag(args, 'allow-remote')) || r.config.review.allowRemote === true });
|
|
311
|
+
const { jobs, droppedByJobLimit } = planJobs({ bundles: built.bundles, packs, model, maxJobs: Math.min(HARD_LIMITS.maxJobs, num(flag(args, 'max-jobs')) || HARD_LIMITS.maxJobs) });
|
|
312
|
+
const loadJob = loadJobFrom(built, packs);
|
|
313
|
+
const ctxBase = { root: built.root, fileContents: built.fileContents, model };
|
|
314
|
+
|
|
315
|
+
if (truthy(flag(args, 'dry-run'))) {
|
|
316
|
+
const obj = { ok: true, dryRun: true, eligibleJobs: jobs.length, droppedByJobLimit, model: { provider: model.provider, model: model.model }, packs: packs.map(p => `${p.id}/${p.version}`) };
|
|
317
|
+
out(format, `context review (dry run): ${jobs.length} job(s) would run over ${built.summary.bundlesBuilt} bundle(s); model ${model.provider || '(none configured)'}/${model.model || '-'}`, obj);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (truthy(flag(args, 'export-batch'))) {
|
|
322
|
+
const e = exportBatch(built.root, jobs, loadJob, { model, review: { buildReviewPrompt, redactPackageText, checkEgressForFiles } });
|
|
323
|
+
if (!e.ok) { out(format, `context review: batch export failed (${e.code})`, e); return 1; }
|
|
324
|
+
out(format, `context review: batch ${e.batchId} written (${e.requests} request(s), ${e.skipped.length} skipped by policy or error). Upload is the operator's step; import results with --import-batch <file> --batch-id ${e.batchId}.`, { ok: true, ...e });
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (flag(args, 'import-batch') && flag(args, 'import-batch') !== true) {
|
|
329
|
+
const batchId = flag(args, 'batch-id');
|
|
330
|
+
if (!batchId || batchId === true) { out(format, 'context review: --batch-id is required with --import-batch', { ok: false }); return 1; }
|
|
331
|
+
let text;
|
|
332
|
+
try { text = fs.readFileSync(path.resolve(String(flag(args, 'import-batch'))), 'utf8'); } catch { out(format, 'context review: cannot read the results file', { ok: false }); return 1; }
|
|
333
|
+
const imp = importBatch(built.root, String(batchId), text, { jobs, loadJob, fileContents: built.fileContents });
|
|
334
|
+
out(format, imp.ok ? `context review: imported ${imp.accepted} accepted, ${imp.declined} declined, ${imp.rejected.length} rejected, ${imp.duplicates} duplicate(s)` : `context review: ${imp.reason}`, imp);
|
|
335
|
+
return imp.ok ? 0 : 1;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const invoker = resolveInvoker({ scanRoot: built.root });
|
|
339
|
+
const ac = new AbortController();
|
|
340
|
+
let interrupted = false;
|
|
341
|
+
const onSig = () => { interrupted = true; ac.abort(); };
|
|
342
|
+
process.on('SIGINT', onSig); process.on('SIGTERM', onSig);
|
|
343
|
+
const jsonl = format === 'jsonl';
|
|
344
|
+
let result;
|
|
345
|
+
try {
|
|
346
|
+
result = await runQueue(jobs, {
|
|
347
|
+
...ctxBase, invoker, loadJob, signal: ac.signal, retryFailed: truthy(flag(args, 'retry-failed')),
|
|
348
|
+
onEvent: jsonl ? (e) => say(JSON.stringify(e)) : undefined,
|
|
349
|
+
limits: {
|
|
350
|
+
concurrency: num(flag(args, 'concurrency')) || config.review.concurrency,
|
|
351
|
+
maxCalls: num(flag(args, 'max-calls')) || config.review.maxCalls,
|
|
352
|
+
maxWallSeconds: num(flag(args, 'max-wall-seconds')) || config.review.maxWallSeconds,
|
|
353
|
+
maxTokensPerJob: config.review.maxTokensPerJob,
|
|
354
|
+
maxRetries: config.review.maxRetries,
|
|
355
|
+
jobTimeoutMs: (num(flag(args, 'job-timeout-seconds')) || 120) * 1000,
|
|
356
|
+
maxCostUsd: num(flag(args, 'max-cost-usd')) ?? config.review.maxCostUsd,
|
|
357
|
+
costPerCallUsd: num(flag(args, 'cost-per-call-usd')),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
} finally { process.off('SIGINT', onSig); process.off('SIGTERM', onSig); }
|
|
361
|
+
|
|
362
|
+
// verified results -> candidates -> the existing adjudication
|
|
363
|
+
const records = [];
|
|
364
|
+
for (const [, res] of result.results) {
|
|
365
|
+
if (res.state === 'succeeded' && res.review) {
|
|
366
|
+
const job = res.job;
|
|
367
|
+
const lj = loadJob(job);
|
|
368
|
+
if (lj.error) continue;
|
|
369
|
+
records.push({ review: res.review, entrypoints: lj.entrypoints.map(e => ({ id: e.id, label: e.label })), pack: lj.pack });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const candidates = reviewsToCandidates(records);
|
|
373
|
+
let priorScan = null, triageFeedback = null;
|
|
374
|
+
try { priorScan = JSON.parse(fs.readFileSync(statePath(built.root, 'last-scan.json'), 'utf8')); } catch { /* none */ }
|
|
375
|
+
try { triageFeedback = JSON.parse(fs.readFileSync(statePath(built.root, 'triage-feedback.json'), 'utf8')); } catch { /* none */ }
|
|
376
|
+
const { makeTaintProbe } = await import('../discovery/index.js');
|
|
377
|
+
const adj = await adjudicate(candidates, { taintProbe: makeTaintProbe(built.ir.perFile, built.ir.callGraph), priorScan, triageFeedback, scanRoot: built.root, refute: truthy(flag(args, 'refute')) ? {} : null });
|
|
378
|
+
|
|
379
|
+
const cov = result.coverage;
|
|
380
|
+
const report = { ok: true, runId: result.runId, interrupted, model: { provider: model.provider, model: model.model }, buildSummary: built.summary, coverage: cov, candidates: candidates.length, findings: adj.findings.map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, vuln: f.vuln, cwe: f.cwe, confirmation: f.discovery && f.discovery.confirmation && f.discovery.confirmation.tier, witnesses: f.contextIntelligence && f.contextIntelligence.witnessCount, contextIntelligence: f.contextIntelligence })), advisory: adj.advisory, refuted: adj.refuted, duplicates: adj.duplicates.length, suppressed: adj.suppressed.length };
|
|
381
|
+
writeStoreJson(built.root, `reports/review-${result.runId}.json`, report);
|
|
382
|
+
const text = [
|
|
383
|
+
'Deterministic Context Intelligence: review',
|
|
384
|
+
` model: ${model.provider || '(none)'}/${model.model || '-'} packs: ${packs.map(p => p.id).join(', ')}`,
|
|
385
|
+
` eligible ${cov.eligible} | attempted ${cov.attempted} | succeeded ${cov.succeeded} | partial ${cov.partial} | failed ${cov.failed} | timed out ${cov.timed_out} | skipped by policy ${cov.skipped_by_policy} | cancelled ${cov.cancelled}`,
|
|
386
|
+
` cached ${cov.cached}, resumed ${cov.resumed}, model calls ${cov.modelCalls}, wall ${Math.round(cov.wallMs / 1000)}s`,
|
|
387
|
+
` cost: ${cov.estimatedCostUsd === null ? 'not estimated (' + cov.costBasis + ')' : '$' + cov.estimatedCostUsd.toFixed(4) + ' (' + cov.costBasis + ')'}`,
|
|
388
|
+
...Object.entries(cov.reasons).slice(0, 8).map(([k, v]) => ` ! ${v}x ${k}`),
|
|
389
|
+
cov.missingCoverage > 0 ? ` ${cov.missingCoverage} eligible job(s) have NO verified result. That is missing coverage, not a clean result.` : ' every eligible job has a verified result (that is coverage, not proof of safety)',
|
|
390
|
+
cov.queueStateCorrupt ? ' note: the previous queue checkpoint failed its integrity check and was ignored' : null,
|
|
391
|
+
'',
|
|
392
|
+
adj.findings.length ? `${adj.findings.length} candidate finding(s) (advisory, unconfirmed unless stated; severity comes from the confirmation tier):` : 'No candidate findings survived verification.',
|
|
393
|
+
...adj.findings.map(f => ` [${f.severity}] ${f.file}:${f.line} ${f.vuln} confirmation=${(f.discovery && f.discovery.confirmation && f.discovery.confirmation.tier) || 'unknown'} routes=${f.contextIntelligence ? f.contextIntelligence.witnessCount : 1}`),
|
|
394
|
+
adj.advisory.length ? ` ${adj.advisory.length} advisory-only hypothesis(es) not promoted to findings` : null,
|
|
395
|
+
].filter(x => x !== null).join('\n');
|
|
396
|
+
out(format, text, report);
|
|
397
|
+
if (interrupted) return 130;
|
|
398
|
+
if (truthy(flag(args, 'strict')) && cov.missingCoverage > 0) return 3;
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---- dispatch -----------------------------------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
export async function cmdContext(args) {
|
|
405
|
+
const sub = args._[1];
|
|
406
|
+
const root = path.resolve(flag(args, 'root') && flag(args, 'root') !== true ? String(flag(args, 'root')) : '.');
|
|
407
|
+
const format = ['json', 'jsonl'].includes(flag(args, 'format')) ? flag(args, 'format') : 'text';
|
|
408
|
+
switch (sub) {
|
|
409
|
+
case 'build': return cmdBuild(root, args, format);
|
|
410
|
+
case 'inspect': return cmdInspect(root, args, format);
|
|
411
|
+
case 'diff': return cmdDiff(root, args, format);
|
|
412
|
+
case 'review': return cmdReview(root, args, format);
|
|
413
|
+
case 'status': return cmdStatus(root, args, format);
|
|
414
|
+
case 'verify': return cmdVerify(root, args, format);
|
|
415
|
+
case 'export': return cmdExport(root, args, format);
|
|
416
|
+
case 'cleanup': return cmdCleanup(root, args, format);
|
|
417
|
+
case 'eval': {
|
|
418
|
+
const { cmdEval } = await import('./evaluation/index.js');
|
|
419
|
+
return cmdEval(root, args, format);
|
|
420
|
+
}
|
|
421
|
+
default:
|
|
422
|
+
usage();
|
|
423
|
+
return sub === undefined ? 0 : 1;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
//
|
|
2
|
+
// DCI configuration: `.agentic-security/context-intelligence.json`, operator-authored, never written by the scanner.
|
|
3
|
+
//
|
|
4
|
+
// `enabled` is false by default and means exactly one thing: DCI is not woven into any other command. The explicit
|
|
5
|
+
// `context ...` commands are the opt-in and always work; building and inspecting need no model and no network.
|
|
6
|
+
// A model call needs an explicit `context review`, a configured endpoint, and a pass through the egress policy.
|
|
7
|
+
// `review.enabled` is reserved for a future automatic integration and is not read by the explicit command.
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import { statePath } from '../posture/state-dir.js';
|
|
10
|
+
import { PROFILES } from './packer.js';
|
|
11
|
+
import { ENTRYPOINT_KINDS } from './entrypoints.js';
|
|
12
|
+
|
|
13
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
14
|
+
export const CONFIG_FILE = 'context-intelligence.json';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
17
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
18
|
+
enabled: false,
|
|
19
|
+
entrypointKinds: ['http'],
|
|
20
|
+
exclude: [],
|
|
21
|
+
maxEntrypoints: 5000,
|
|
22
|
+
maxFiles: 3000,
|
|
23
|
+
semanticMetadata: { mode: 'off', haskellHieDir: null },
|
|
24
|
+
context: { profile: 'standard', maxTokens: null, pruneLowValue: true, maxSymbols: 60, maxDepth: 12 },
|
|
25
|
+
review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
|
|
26
|
+
replay: { contentAddressed: true, retention: 'inherit' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const clamp = (v, lo, hi, d) => (Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.trunc(v))) : d);
|
|
30
|
+
|
|
31
|
+
/** Validate and normalise a parsed config object. Returns { config, problems }; unknown keys are reported, never silently kept. */
|
|
32
|
+
export function normalizeConfig(raw) {
|
|
33
|
+
const problems = [];
|
|
34
|
+
const r = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
35
|
+
const d = DEFAULT_CONFIG;
|
|
36
|
+
if (raw !== undefined && raw !== null && r !== raw) problems.push('config-is-not-an-object');
|
|
37
|
+
if (r.schemaVersion !== undefined && r.schemaVersion !== CONFIG_SCHEMA_VERSION) problems.push(`unsupported-schemaVersion:${r.schemaVersion}`);
|
|
38
|
+
for (const k of Object.keys(r)) if (!Object.hasOwn(d, k)) problems.push(`unknown-key:${k}`);
|
|
39
|
+
|
|
40
|
+
const kinds = Array.isArray(r.entrypointKinds) ? r.entrypointKinds.filter(k => ENTRYPOINT_KINDS.includes(k)) : d.entrypointKinds;
|
|
41
|
+
if (Array.isArray(r.entrypointKinds) && kinds.length !== r.entrypointKinds.length) problems.push('unknown-entrypoint-kind');
|
|
42
|
+
const sm = r.semanticMetadata && typeof r.semanticMetadata === 'object' ? r.semanticMetadata : {};
|
|
43
|
+
const cx = r.context && typeof r.context === 'object' ? r.context : {};
|
|
44
|
+
const rv = r.review && typeof r.review === 'object' ? r.review : {};
|
|
45
|
+
const rp = r.replay && typeof r.replay === 'object' ? r.replay : {};
|
|
46
|
+
const mode = ['off', 'import'].includes(sm.mode) ? sm.mode : 'off';
|
|
47
|
+
if (sm.mode !== undefined && mode !== sm.mode) problems.push(`semanticMetadata.mode-not-supported:${sm.mode}`);
|
|
48
|
+
|
|
49
|
+
const config = {
|
|
50
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
51
|
+
enabled: r.enabled === true,
|
|
52
|
+
entrypointKinds: kinds.length ? [...new Set(kinds)] : d.entrypointKinds,
|
|
53
|
+
exclude: Array.isArray(r.exclude) ? r.exclude.filter(x => typeof x === 'string' && x.length < 300).slice(0, 200) : [],
|
|
54
|
+
maxEntrypoints: clamp(r.maxEntrypoints, 1, 50000, d.maxEntrypoints),
|
|
55
|
+
maxFiles: clamp(r.maxFiles, 1, 20000, d.maxFiles),
|
|
56
|
+
semanticMetadata: { mode, haskellHieDir: typeof sm.haskellHieDir === 'string' ? sm.haskellHieDir : null },
|
|
57
|
+
context: {
|
|
58
|
+
profile: Object.hasOwn(PROFILES, cx.profile) ? cx.profile : d.context.profile,
|
|
59
|
+
maxTokens: Number.isFinite(cx.maxTokens) ? clamp(cx.maxTokens, 1000, 400000, null) : null,
|
|
60
|
+
pruneLowValue: cx.pruneLowValue !== false,
|
|
61
|
+
maxSymbols: clamp(cx.maxSymbols, 5, 400, d.context.maxSymbols),
|
|
62
|
+
maxDepth: clamp(cx.maxDepth, 1, 40, d.context.maxDepth),
|
|
63
|
+
},
|
|
64
|
+
review: {
|
|
65
|
+
enabled: rv.enabled === true,
|
|
66
|
+
allowRemote: rv.allowRemote === true,
|
|
67
|
+
questionPacks: Array.isArray(rv.questionPacks) && rv.questionPacks.length ? rv.questionPacks.filter(x => typeof x === 'string') : d.review.questionPacks,
|
|
68
|
+
concurrency: clamp(rv.concurrency, 1, 16, d.review.concurrency),
|
|
69
|
+
maxCalls: clamp(rv.maxCalls, 1, 5000, d.review.maxCalls),
|
|
70
|
+
maxWallSeconds: clamp(rv.maxWallSeconds, 5, 86400, d.review.maxWallSeconds),
|
|
71
|
+
maxCostUsd: Number.isFinite(rv.maxCostUsd) && rv.maxCostUsd >= 0 ? rv.maxCostUsd : null,
|
|
72
|
+
maxTokensPerJob: clamp(rv.maxTokensPerJob, 1000, 400000, d.review.maxTokensPerJob),
|
|
73
|
+
maxRetries: clamp(rv.maxRetries, 0, 3, d.review.maxRetries),
|
|
74
|
+
},
|
|
75
|
+
replay: { contentAddressed: rp.contentAddressed !== false, retention: typeof rp.retention === 'string' ? rp.retention : 'inherit' },
|
|
76
|
+
};
|
|
77
|
+
return { config, problems };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Load the config for a project. A missing file is the defaults; a malformed one is reported, and the defaults are used. */
|
|
81
|
+
export function loadConfig(root) {
|
|
82
|
+
let raw = null;
|
|
83
|
+
const problems = [];
|
|
84
|
+
try {
|
|
85
|
+
raw = JSON.parse(fs.readFileSync(statePath(root, CONFIG_FILE), 'utf8'));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err && err.code !== 'ENOENT') problems.push('config-unreadable-or-invalid-json');
|
|
88
|
+
}
|
|
89
|
+
const n = normalizeConfig(raw);
|
|
90
|
+
return { config: n.config, problems: [...problems, ...n.problems] };
|
|
91
|
+
}
|