@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.
- package/CHANGELOG.md +26 -0
- package/dist/3596.index.js +1 -1
- package/dist/550.index.js +9 -8
- package/dist/5620.index.js +59 -10
- package/dist/5937.index.js +88 -16
- package/dist/7838.index.js +32 -5
- package/dist/9441.index.js +50 -7
- package/dist/9824.index.js +19 -13
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{c1e282247b06d51c8ffb.json → daefda0819bb18309b75.json} +3 -3
- package/package.json +3 -3
- package/src/discovery/detector-spec-synthesis.js +3 -3
- package/src/discovery/llm-invoke.js +3 -3
- package/src/egress/transport.js +148 -0
- package/src/lineage/CLAUDE.md +12 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +23 -0
- package/src/lineage/driver.js +19 -2
- package/src/lineage/engine.js +24 -1
- package/src/lineage/graph-builder.js +3 -1
- package/src/lineage/lineage-source-catalog.js +143 -0
- package/src/lineage/source-seeding.js +142 -16
- package/src/lineage/summaries.js +21 -5
- package/src/llm-validator/index.js +4 -2
- package/src/llm-validator/local-endpoint.js +4 -1
- package/src/llm-validator/ollama-provider.js +8 -6
- package/src/posture/adversary-agent.js +27 -10
- package/src/posture/custom-rules.js +13 -1
- package/src/posture/fleet/cli.js +2 -4
- package/src/posture/fleet/fleet-manifest.js +4 -1
- package/src/posture/fleet/job-runner.js +28 -4
- package/src/posture/fleet/org-health.js +23 -0
- package/src/posture/fleet/scan-cache.js +1 -1
- package/src/posture/flow-narration.js +10 -8
- package/src/posture/llm-redteam.js +3 -1
- package/src/posture/provenance/coordinator.js +8 -0
- package/src/posture/provenance/origin-resolver.js +17 -4
- package/src/posture/rule-compiler/cli.js +56 -8
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/ir.js +10 -1
- package/src/posture/rule-compiler/promotion.js +12 -2
- package/src/posture/rule-compiler/regression.js +1 -1
- package/src/posture/rule-compiler/store.js +27 -7
- package/src/posture/strategic-metrics.js +50 -7
- 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
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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) {
|