@clear-capabilities/agentic-security-scanner 0.195.26 → 0.195.27

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.
@@ -0,0 +1,936 @@
1
+ export const id = 396;
2
+ export const ids = [396];
3
+ export const modules = {
4
+
5
+ /***/ 12090:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ KF: () => (/* binding */ toProofTier),
10
+ /* harmony export */ g3: () => (/* binding */ stateRank),
11
+ /* harmony export */ jn: () => (/* binding */ fromProofTier),
12
+ /* harmony export */ np: () => (/* binding */ advance)
13
+ /* harmony export */ });
14
+ /* unused harmony exports EVIDENCE_STATES, COMPLETENESS */
15
+ // Evidence state ladder for the Universal Exploit Proof Fabric.
16
+ //
17
+ // Evidence state is SEPARATE from severity and confidence. It extends, never replaces, the vocabulary that already
18
+ // exists: `proof-tier.js` (execution-proven / proof-failed / taint-proven / unproven) and the fix-verify legs
19
+ // (`verifiedFull`, `degradedLegs`). `toProofTier` / `fromProofTier` are the explicit bridges.
20
+ //
21
+ // Transitions are guarded: a state can only be reached from the state directly before it (or by dropping back), so no
22
+ // caller can jump a finding to EXPLOIT_CONFIRMED without passing REPRODUCED, or to FIX_VERIFIED without
23
+ // FIX_REPLAY_BLOCKED. A degraded or incomplete run NEVER advances a state.
24
+ const EVIDENCE_STATES = Object.freeze([
25
+ 'CANDIDATE',
26
+ 'STATICALLY_CORROBORATED',
27
+ 'REPRODUCER_GENERATED',
28
+ 'REPRODUCED',
29
+ 'EXPLOIT_CONFIRMED',
30
+ 'FIX_REPLAY_BLOCKED',
31
+ 'FIX_VERIFIED',
32
+ ]);
33
+
34
+ const stateRank = (s) => EVIDENCE_STATES.indexOf(s);
35
+
36
+ const REQUIRES_PREDECESSOR = Object.freeze(Object.fromEntries(EVIDENCE_STATES.map((s, i) => [s, i === 0 ? null : EVIDENCE_STATES[i - 1]])));
37
+
38
+ /**
39
+ * Advance `from` to `to`. Returns { ok, state, reason }. Moving up requires `to` to be exactly the next rung; moving
40
+ * down or staying is always allowed (evidence can be lost, never invented). Unknown states are rejected.
41
+ */
42
+ function advance(from, to) {
43
+ if (!EVIDENCE_STATES.includes(from)) return { ok: false, state: 'CANDIDATE', reason: `unknown current state: ${String(from)}` };
44
+ if (!EVIDENCE_STATES.includes(to)) return { ok: false, state: from, reason: `unknown target state: ${String(to)}` };
45
+ if (stateRank(to) <= stateRank(from)) return { ok: true, state: to, reason: null };
46
+ if (REQUIRES_PREDECESSOR[to] !== from) return { ok: false, state: from, reason: `cannot reach ${to} from ${from}: ${REQUIRES_PREDECESSOR[to]} is required first` };
47
+ return { ok: true, state: to, reason: null };
48
+ }
49
+
50
+ // Bridges to the older vocabulary so existing consumers keep working.
51
+ function toProofTier(state) {
52
+ const r = stateRank(state);
53
+ if (r >= stateRank('EXPLOIT_CONFIRMED')) return 'execution-proven';
54
+ if (r >= stateRank('STATICALLY_CORROBORATED')) return 'taint-proven';
55
+ return 'unproven';
56
+ }
57
+
58
+ function fromProofTier(tier) {
59
+ if (tier === 'taint-proven') return 'STATICALLY_CORROBORATED';
60
+ // execution-proven under the older single-run marker path is one run, not the 3/3 + independent replay the fabric
61
+ // requires, so it maps to REPRODUCED and never to EXPLOIT_CONFIRMED.
62
+ if (tier === 'execution-proven') return 'REPRODUCED';
63
+ return 'CANDIDATE';
64
+ }
65
+
66
+ // Completeness vocabulary shared with fix-verify: FULL only when nothing was skipped.
67
+ const COMPLETENESS = Object.freeze(['FULL', 'DEGRADED', 'INCOMPLETE']);
68
+
69
+
70
+ /***/ }),
71
+
72
+ /***/ 30396:
73
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
74
+
75
+ // ESM COMPAT FLAG
76
+ __webpack_require__.r(__webpack_exports__);
77
+
78
+ // EXPORTS
79
+ __webpack_require__.d(__webpack_exports__, {
80
+ cmdRules: () => (/* binding */ cmdRules)
81
+ });
82
+
83
+ // EXTERNAL MODULE: external "node:fs"
84
+ var external_node_fs_ = __webpack_require__(73024);
85
+ // EXTERNAL MODULE: external "node:path"
86
+ var external_node_path_ = __webpack_require__(76760);
87
+ // EXTERNAL MODULE: external "node:child_process"
88
+ var external_node_child_process_ = __webpack_require__(31421);
89
+ // EXTERNAL MODULE: ./src/posture/state-dir.js
90
+ var state_dir = __webpack_require__(31174);
91
+ // EXTERNAL MODULE: external "node:crypto"
92
+ var external_node_crypto_ = __webpack_require__(77598);
93
+ // EXTERNAL MODULE: ./src/posture/rule-compiler/ir.js
94
+ var rule_compiler_ir = __webpack_require__(87539);
95
+ ;// CONCATENATED MODULE: ./src/posture/rule-compiler/lifecycle.js
96
+ // Rule lifecycle ledger: DRAFT -> CANDIDATE -> VALIDATED -> PROMOTED -> DEPRECATED/ROLLED_BACK.
97
+ //
98
+ // Append-only, hash-chained (same idiom as remediation-ledger.js / invariants/lifecycle.js): each entry links the
99
+ // previous entry's hash, so a dropped or edited middle record breaks the chain and `verifyLedger` catches it. A
100
+ // rule cannot promote itself: `transition` only records a transition a caller already decided; `advance`-style
101
+ // guarding on VALID edges lives here so no caller can jump DRAFT straight to PROMOTED.
102
+
103
+
104
+
105
+ const LEDGER_SCHEMA = 'agentic-security/rule-lifecycle-ledger';
106
+ const EDGES = Object.freeze({
107
+ DRAFT: ['CANDIDATE'], CANDIDATE: ['VALIDATED', 'DRAFT'], VALIDATED: ['PROMOTED', 'DRAFT'],
108
+ PROMOTED: ['DEPRECATED', 'ROLLED_BACK'], DEPRECATED: [], ROLLED_BACK: ['CANDIDATE'],
109
+ });
110
+
111
+ const hashOf = (entry) => (0,external_node_crypto_.createHash)('sha256').update(JSON.stringify(entry)).digest('hex');
112
+
113
+ function newLedger() { return { schema: LEDGER_SCHEMA, entries: [] }; }
114
+
115
+ function currentState(ledger, ruleId) {
116
+ for (let i = ledger.entries.length - 1; i >= 0; i--) if (ledger.entries[i].ruleId === ruleId) return ledger.entries[i].to;
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * Record a transition. Refuses an edge not in EDGES, and refuses DRAFT->DRAFT re-entry for a rule that has never
122
+ * existed (the first entry for a rule id must be `from: null, to: 'DRAFT'`).
123
+ */
124
+ function recordTransition(ledger, { ruleId, from, to, reason, evidence = null, actor }) {
125
+ if (!rule_compiler_ir/* RULE_STATES */.UQ.includes(to)) return { ok: false, reason: `unknown target state: ${to}` };
126
+ const cur = currentState(ledger, ruleId);
127
+ if (cur === null) {
128
+ if (from !== null || to !== 'DRAFT') return { ok: false, reason: 'first transition for a rule must be null -> DRAFT' };
129
+ } else {
130
+ if (from !== cur) return { ok: false, reason: `stale transition: current state is ${cur}, not ${from}` };
131
+ if (!EDGES[cur].includes(to)) return { ok: false, reason: `cannot go from ${cur} to ${to}` };
132
+ }
133
+ const prevHash = ledger.entries.length ? ledger.entries[ledger.entries.length - 1].entryHash : null;
134
+ const entry = { ruleId, from: cur, to, reason: reason ?? null, evidence, actor: actor ?? 'unknown', at: new Date().toISOString(), prevHash };
135
+ entry.entryHash = hashOf({ ...entry, entryHash: undefined });
136
+ ledger.entries.push(entry);
137
+ return { ok: true, entry };
138
+ }
139
+
140
+ function verifyLedger(ledger) {
141
+ let prevHash = null;
142
+ for (const e of ledger.entries) {
143
+ if (e.prevHash !== prevHash) return { ok: false, reason: 'chain broken: prevHash mismatch' };
144
+ const recomputed = hashOf({ ...e, entryHash: undefined });
145
+ if (recomputed !== e.entryHash) return { ok: false, reason: `entry hash mismatch for ${e.ruleId} at ${e.at}` };
146
+ prevHash = e.entryHash;
147
+ }
148
+ return { ok: true };
149
+ }
150
+
151
+ function historyFor(ledger, ruleId) { return ledger.entries.filter((e) => e.ruleId === ruleId); }
152
+ function stateOf(ledger, ruleId) { return currentState(ledger, ruleId); }
153
+
154
+ // EXTERNAL MODULE: ./src/posture/integrity.js
155
+ var integrity = __webpack_require__(71130);
156
+ // EXTERNAL MODULE: ./src/posture/rule-pack-signing.js
157
+ var rule_pack_signing = __webpack_require__(99527);
158
+ ;// CONCATENATED MODULE: ./src/posture/rule-compiler/store.js
159
+ // Persistence for the rule compiler: one directory per rule under `.agentic-security/rule-compiler/<id>/`
160
+ // (`ir.json`, `ledger.json`, `evaluation.json`), the promoted custom-rule document at
161
+ // `.agentic-security/rules/<id>.yml` (so the EXISTING `loadCustomRules`/`rule test` machinery picks it up with no
162
+ // change), and a regression fixture pair at `.agentic-security/rule-compiler/fixtures/<id>/{vulnerable,clean}/`
163
+ // using the SAME filename convention `runRuleTests` already reads (`vulnerable`/`clean` in the path).
164
+
165
+
166
+
167
+
168
+
169
+
170
+
171
+
172
+
173
+ const DIR = 'rule-compiler';
174
+ const EXT = { javascript: 'js', typescript: 'ts', python: 'py', go: 'go', ruby: 'rb', java: 'java', rust: 'rs', csharp: 'cs', php: 'php', any: 'txt' };
175
+
176
+ function ruleDir(scanRoot, id) { return (0,state_dir.statePath)(scanRoot, DIR, id); }
177
+
178
+ /**
179
+ * The lifecycle ledger is hash-chained (lifecycle.js) AND signed with the per-install HMAC key (the same key as last-scan.json),
180
+ * 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,
181
+ * or that cannot be parsed, is returned WITH `tampered: true` and its reason, and `saveLedger` refuses to write it, so a bad ledger
182
+ * is never quietly replaced by a fresh one and its history lost. An unsigned ledger with entries (written before 0.195.23) is
183
+ * accepted only with AGENTIC_SECURITY_RULES_UNSIGNED=1, the same switch that accepts an unsigned rules override, and is signed on
184
+ * its next save.
185
+ */
186
+ function loadLedger(scanRoot, env = process.env) {
187
+ const f = (0,state_dir.statePath)(scanRoot, DIR, 'ledger.json');
188
+ if (!external_node_fs_.existsSync(f)) return newLedger();
189
+ let text;
190
+ try { text = external_node_fs_.readFileSync(f, 'utf8'); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `cannot read the ledger: ${e.message}` }; }
191
+ let ledger;
192
+ try { ledger = JSON.parse(text); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `the ledger is not valid JSON: ${e.message}` }; }
193
+ if (!ledger || !Array.isArray(ledger.entries)) return { ...newLedger(), tampered: true, tamperReason: 'the ledger has no entries array' };
194
+ const v = verifyLedger(ledger);
195
+ if (!v.ok) return { ...ledger, tampered: true, tamperReason: v.reason };
196
+ const sig = (0,integrity/* verifyLastScan */.Ef)(text, `${f}.sig`);
197
+ if (sig === true) return { ...ledger, verifiedBy: 'install-key' };
198
+ // GA-07 §3 (0.195.27): the install key never leaves the machine that promoted the rule, so on a CI runner (or any other
199
+ // clone) the HMAC cannot verify. A ledger that `promote --sign-key` also signed with the operator's Ed25519 key is accepted
200
+ // when that signature verifies against a key the environment trusts: AGENTIC_SECURITY_RULES_PUBLIC_KEY (set outside the
201
+ // repository, a CI variable) or the trusted-keys store the rule packs already use. The chain check above still applies, and
202
+ // the signature covers the whole file, so a dropped last entry fails it like any other edit. A project-local key file alone
203
+ // is not trust (an author who can write .agentic-security/ could add one), which is why the env variable is the CI route.
204
+ const author = verifyAuthorSignature(text, `${f}${AUTHOR_SIG_SUFFIX}`, authorKeysFrom(scanRoot, env));
205
+ if (author.ok) return { ...ledger, verifiedBy: 'author-key', authorKeyId: author.keyId };
206
+ if (sig === false) return { ...ledger, tampered: true, tamperReason: `the ledger signature does not verify under this install key${author.present ? ` and its author signature does not verify against any trusted key (${author.reason})` : ' and it carries no author signature (promote with --sign-key to make it portable)'}` };
207
+ if (sig === null && ledger.entries.length && env.AGENTIC_SECURITY_RULES_UNSIGNED !== '1') {
208
+ 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' };
209
+ }
210
+ return { ...ledger, verifiedBy: sig === null ? 'unsigned-accepted' : 'install-key' };
211
+ }
212
+
213
+ const AUTHOR_SIG_SUFFIX = '.author.sig';
214
+ const PUBLIC_KEY_ENV = 'AGENTIC_SECURITY_RULES_PUBLIC_KEY';
215
+
216
+ /** The Ed25519 public keys a ledger's author signature may verify against: the environment variable (one or more base64 raw keys,
217
+ * comma separated) first, then the rule-pack trust store (bundled keys, and the project file only when the operator honours it). */
218
+ function authorKeysFrom(scanRoot, env) {
219
+ const keys = [];
220
+ for (const k of String(env[PUBLIC_KEY_ENV] || '').split(',').map((s) => s.trim()).filter(Boolean)) keys.push({ id: 'env', publicKey: k, alg: 'ed25519' });
221
+ const origErr = console.error; console.error = () => {};
222
+ try { keys.push(...(0,rule_pack_signing/* loadTrustedKeys */.CW)(scanRoot)); } catch { /* no trust store */ } finally { console.error = origErr; }
223
+ return keys;
224
+ }
225
+
226
+ function verifyAuthorSignature(text, sigPath, keys) {
227
+ let sig;
228
+ try { sig = external_node_fs_.readFileSync(sigPath); } catch { return { ok: false, present: false, reason: 'no author signature' }; }
229
+ if (!keys.length) return { ok: false, present: true, reason: `no trusted key (set ${PUBLIC_KEY_ENV})` };
230
+ const body = Buffer.from(text, 'utf8');
231
+ for (const k of keys) {
232
+ try {
233
+ const keyBytes = Buffer.from(k.publicKey, 'base64');
234
+ if (keyBytes.length !== 32) continue;
235
+ const keyObj = external_node_crypto_.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: keyBytes.toString('base64url') }, format: 'jwk' });
236
+ if (external_node_crypto_.verify(null, body, keyObj, sig)) return { ok: true, present: true, keyId: k.id || '(unnamed)' };
237
+ } catch { /* next key */ }
238
+ }
239
+ return { ok: false, present: true, reason: 'bad signature' };
240
+ }
241
+
242
+ function signWithAuthorKey(text, privateKeyB64) {
243
+ const privBytes = Buffer.from(privateKeyB64, 'base64');
244
+ if (privBytes.length !== 32) throw new Error('private key must be 32 bytes (raw ed25519)');
245
+ const keyObj = external_node_crypto_.createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), privBytes]), format: 'der', type: 'pkcs8' });
246
+ return external_node_crypto_.sign(null, Buffer.from(text, 'utf8'), keyObj);
247
+ }
248
+
249
+ /** The public key (base64 raw) that pairs with a raw Ed25519 private key; printed by `promote` so the operator can set it in CI. */
250
+ function publicKeyOf(privateKeyB64) {
251
+ const privBytes = Buffer.from(privateKeyB64, 'base64');
252
+ if (privBytes.length !== 32) return null;
253
+ const keyObj = external_node_crypto_.createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), privBytes]), format: 'der', type: 'pkcs8' });
254
+ return Buffer.from(external_node_crypto_.createPublicKey(keyObj).export({ format: 'jwk' }).x, 'base64url').toString('base64');
255
+ }
256
+
257
+ /**
258
+ * Write the ledger with its install-key signature and, when the operator's Ed25519 key is given, an author signature beside it
259
+ * (`ledger.json.author.sig`) that another machine can verify (see loadLedger). Without a key an existing author signature is
260
+ * removed rather than left stale: a signature that no longer matches the file would read as tampering on the next clone.
261
+ */
262
+ function saveLedger(scanRoot, ledger, { authorKey = null } = {}) {
263
+ if (ledger?.tampered) return { ok: false, reason: `refusing to overwrite a ledger that failed its integrity check (${ledger.tamperReason})` };
264
+ if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
265
+ const f = (0,state_dir.statePath)(scanRoot, DIR, 'ledger.json');
266
+ const { verifiedBy, authorKeyId, ...body } = ledger;
267
+ const text = JSON.stringify(body, null, 2);
268
+ let ok = (0,state_dir.safeWriteState)(f, text) && (0,state_dir.safeWriteState)(`${f}.sig`, (0,integrity/* signLastScan */.lU)(text));
269
+ let authorSigned = false;
270
+ if (ok && authorKey) {
271
+ try { ok = (0,state_dir.safeWriteState)(`${f}${AUTHOR_SIG_SUFFIX}`, signWithAuthorKey(text, authorKey)); authorSigned = ok; } catch (e) { return { ok: false, reason: `cannot author-sign the ledger: ${String(e.message).slice(0, 120)}` }; }
272
+ } else if (ok) {
273
+ try { external_node_fs_.unlinkSync(`${f}${AUTHOR_SIG_SUFFIX}`); } catch { /* none to remove */ }
274
+ }
275
+ return { ok, authorSigned };
276
+ }
277
+
278
+ function saveIR(scanRoot, ir) {
279
+ if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
280
+ const f = external_node_path_.join(ruleDir(scanRoot, ir.id), 'ir.json');
281
+ return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(ir, null, 2)), file: f };
282
+ }
283
+
284
+ function loadIR(scanRoot, id) {
285
+ const f = external_node_path_.join(ruleDir(scanRoot, id), 'ir.json');
286
+ try { return JSON.parse(external_node_fs_.readFileSync(f, 'utf8')); } catch { return null; }
287
+ }
288
+
289
+ function saveJson(scanRoot, id, name, doc) {
290
+ if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
291
+ const f = external_node_path_.join(ruleDir(scanRoot, id), name);
292
+ return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(doc, null, 2)), file: f };
293
+ }
294
+ function loadJson(scanRoot, id, name) { try { return JSON.parse(external_node_fs_.readFileSync(external_node_path_.join(ruleDir(scanRoot, id), name), 'utf8')); } catch { return null; } }
295
+ /** The corpus a rule was synthesized on, kept so `rules evaluate <id>` needs no flag and the evidence is the same corpus. */
296
+ const saveCorpus = (r, id, spec) => saveJson(r, id, 'corpus.json', spec);
297
+ const loadCorpus = (r, id) => loadJson(r, id, 'corpus.json');
298
+ /** Every synthesis attempt: strategy, match, score and why it did not pass. */
299
+ const saveSynthesis = (r, id, syn) => saveJson(r, id, 'synthesis.json', syn);
300
+ const loadSynthesis = (r, id) => loadJson(r, id, 'synthesis.json');
301
+ /** The rule's provenance record (PRD 3 requirement 7). */
302
+ const saveProvenance = (r, id, doc) => saveJson(r, id, 'provenance.json', doc);
303
+ const loadProvenance = (r, id) => loadJson(r, id, 'provenance.json');
304
+
305
+ function saveEvaluation(scanRoot, id, evaluation) {
306
+ if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
307
+ const f = external_node_path_.join(ruleDir(scanRoot, id), 'evaluation.json');
308
+ return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(evaluation, null, 2)) };
309
+ }
310
+
311
+ /** Write the regression fixtures a promoted rule needs: the SAME vulnerable/clean convention `runRuleTests`
312
+ * already reads, scored against the exact corpus the rule was promoted on. */
313
+ function writeFixtures(scanRoot, ir, corpus) {
314
+ const ext = EXT[ir.languages[0]] || 'txt';
315
+ const base = external_node_path_.join(ruleDir(scanRoot, ir.id), 'fixtures');
316
+ if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
317
+ const written = [];
318
+ for (const p of corpus.positives) {
319
+ const f = external_node_path_.join(base, 'vulnerable', `${p.label.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`);
320
+ if ((0,state_dir.safeWriteState)(f, p.code)) written.push(f);
321
+ }
322
+ for (const n of corpus.negatives) {
323
+ const f = external_node_path_.join(base, 'clean', `${n.label.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`);
324
+ if ((0,state_dir.safeWriteState)(f, n.code)) written.push(f);
325
+ }
326
+ return { ok: true, written, glob: external_node_path_.join(base, '**', '*') };
327
+ }
328
+
329
+ /** Promote: write the compiled custom-rule document to the live rules directory. Does not touch the ledger —
330
+ * the caller records the PROMOTED transition itself, so a failed write here can never produce an inconsistent
331
+ * "promoted but not live" or "live but not recorded" state silently. */
332
+ function publishCustomRule(scanRoot, ir) {
333
+ const { ok, doc, errs } = (0,rule_compiler_ir/* compileToCustomRule */.Mo)(ir);
334
+ if (!ok) return { ok: false, reason: `invalid rule IR: ${errs.join('; ')}` };
335
+ if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
336
+ const f = (0,state_dir.statePath)(scanRoot, 'rules', `${ir.id}.yml`);
337
+ const yamlText = [
338
+ `# Compiled by the rule compiler from finding ${ir.provenance.originatingFindingId} (${ir.provenance.evidenceState}).`,
339
+ `- id: ${doc.id}`,
340
+ ` title: ${JSON.stringify(doc.title)}`,
341
+ ` severity: ${doc.severity}`,
342
+ ` cwe: ${doc.cwe}`,
343
+ ` languages: [${doc.languages.join(', ')}]`,
344
+ ` match:`,
345
+ ...(doc.match.pattern ? [` pattern: ${JSON.stringify(doc.match.pattern)}`] : []),
346
+ ...(doc.match.allOf ? [` allOf:`, ...doc.match.allOf.map((p) => ` - ${JSON.stringify(p)}`), ` window: ${doc.match.window}`] : []),
347
+ ...(doc.match.notMatch ? [` notMatch: ${JSON.stringify(doc.match.notMatch)}`] : []),
348
+ ...(doc.match.fileRequires ? [` fileRequires:`, ...doc.match.fileRequires.map((p) => ` - ${JSON.stringify(p)}`)] : []),
349
+ ...(doc.match.guardedBy ? [` guardedBy: ${JSON.stringify(doc.match.guardedBy)}`, ` guardWindow: ${doc.match.guardWindow}`] : []),
350
+ ...(doc.match.excludeLine ? [` excludeLine:`, ...doc.match.excludeLine.map((p) => ` - ${JSON.stringify(p)}`)] : []),
351
+ ` message: ${JSON.stringify(doc.message)}`,
352
+ '',
353
+ ].join('\n');
354
+ return { ok: (0,state_dir.safeWriteState)(f, yamlText), file: f };
355
+ }
356
+
357
+ /** Sign a published rule with the operator's key (the same Ed25519 scheme `agentic-security-rule sign` uses). The key is never stored here. */
358
+ function signPublishedRule(file, privateKeyB64) {
359
+ try { (0,rule_pack_signing/* signRulePack */.pV)(file, privateKeyB64); return { ok: true }; } catch (e) { return { ok: false, reason: String(e.message).slice(0, 120) }; }
360
+ }
361
+
362
+ /**
363
+ * Will the scanner actually LOAD this promoted rule? Publishing a file is not the same as the scanner trusting it: rule packs are
364
+ * signature-checked (posture/rule-pack-signing.js) so that a rule dropped into a repository cannot run in someone's CI. A promoted rule
365
+ * the scanner would refuse is not a capability yet, so this is reported next to the lifecycle state instead of being assumed.
366
+ */
367
+ function packTrust(scanRoot, id) {
368
+ const file = (0,state_dir.statePath)(scanRoot, 'rules', `${id}.yml`);
369
+ if (!external_node_fs_.existsSync(file)) return { live: false, reason: 'not published', remediation: null };
370
+ let r;
371
+ const origErr = console.error; console.error = () => {};
372
+ try { r = (0,rule_pack_signing/* verifyRulePack */.Ig)(file, (0,rule_pack_signing/* loadTrustedKeys */.CW)(scanRoot)); } finally { console.error = origErr; }
373
+ if (r.ok) return { live: true, via: r.passThrough ? 'signed, accepted in pass-through mode (no trusted keys are configured)' : `signed by ${r.keyId}`, reason: null, remediation: null };
374
+ if (r.reason === 'unsigned' && r.allowUnsigned) return { live: true, via: 'unsigned, accepted because AGENTIC_SECURITY_ALLOW_UNSIGNED_PACKS=1', reason: null, remediation: null };
375
+ const remediation = r.reason === 'unsigned'
376
+ ? `sign it (agentic-security-rule sign ${external_node_path_.relative(process.cwd(), file) || file} with your key, or re-run promote with --sign-key <base64>), or opt in for this environment with AGENTIC_SECURITY_ALLOW_UNSIGNED_PACKS=1`
377
+ : (r.remediation ?? 'see the signing setup in bin/agentic-security-rule.js');
378
+ return { live: false, reason: r.reason, remediation };
379
+ }
380
+
381
+ function unpublishCustomRule(scanRoot, id) {
382
+ const f = (0,state_dir.statePath)(scanRoot, 'rules', `${id}.yml`);
383
+ try { for (const x of [f, `${f}.sig`]) if (external_node_fs_.existsSync(x)) external_node_fs_.unlinkSync(x); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; }
384
+ }
385
+
386
+ // EXTERNAL MODULE: ./src/posture/rule-compiler/corpus.js
387
+ var rule_compiler_corpus = __webpack_require__(43556);
388
+ // EXTERNAL MODULE: ./src/posture/rule-compiler/promotion.js
389
+ var promotion = __webpack_require__(57711);
390
+ // EXTERNAL MODULE: ./src/posture/custom-rules.js
391
+ var custom_rules = __webpack_require__(69400);
392
+ ;// CONCATENATED MODULE: ./src/posture/rule-compiler/negative-corpus.js
393
+ // The shared negative corpus shipped with the scanner (GA-07 §3, 0.195.27): safe, idiomatic code that no promoted rule may fire
394
+ // on, the same on every machine and in every repository. `regression.js` puts it in front of the shared false-positive corpus, so
395
+ // `rules evaluate`, `rules promote` and `rules regress` all hold a rule to it; a rule that fires on any entry is refused at
396
+ // evaluation and reported as regressed later, wherever it runs.
397
+ //
398
+ // Each entry is the SAFE side of a pattern the compiler is likely to be asked to detect: a parameterised query beside the
399
+ // concatenated one, an argument-vector process spawn beside the shell string, a constant-time comparison beside the plain one.
400
+ // They are plain strings in a module (not files on disk) so the bundle carries them without a build step and the corpus is
401
+ // identical wherever the scanner runs. Keep every entry free of the unsafe form: a vulnerable snippet here would make a correct
402
+ // rule fail its gate.
403
+ const SHIPPED_NEGATIVE_CORPUS = Object.freeze([
404
+ { name: 'js-parameterised-query', language: 'javascript', code: 'const rows = await db.query("SELECT * FROM orders WHERE owner = $1", [ownerId]);\n' },
405
+ { name: 'js-placeholder-query', language: 'javascript', code: 'const [row] = await conn.execute("SELECT id FROM users WHERE email = ?", [email]);\n' },
406
+ { name: 'js-orm-where-object', language: 'javascript', code: 'const user = await User.findOne({ where: { id: req.params.id, tenantId: req.user.tenantId } });\n' },
407
+ { name: 'js-execfile-argv', language: 'javascript', code: 'const out = execFileSync("git", ["log", "--format=%H", "-1", ref], { encoding: "utf8" });\n' },
408
+ { name: 'js-spawn-argv', language: 'javascript', code: 'const child = spawn("convert", [inputPath, "-resize", "200x200", outputPath], { stdio: "ignore" });\n' },
409
+ { name: 'js-path-confined', language: 'javascript', code: 'const target = path.resolve(root, name);\nif (!target.startsWith(root + path.sep)) throw new Error("outside root");\nconst text = fs.readFileSync(target, "utf8");\n' },
410
+ { name: 'js-constant-time-compare', language: 'javascript', code: 'const ok = a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));\n' },
411
+ { name: 'js-password-hash', language: 'javascript', code: 'const hash = await bcrypt.hash(password, 12);\nawait store.save({ email, hash });\n' },
412
+ { name: 'js-random-token', language: 'javascript', code: 'const token = crypto.randomBytes(32).toString("hex");\n' },
413
+ { name: 'js-json-parse-validated', language: 'javascript', code: 'const body = schema.parse(JSON.parse(raw));\nres.json({ id: body.id });\n' },
414
+ { name: 'js-template-escaped', language: 'javascript', code: 'res.send(template.render({ name: escapeHtml(req.query.name) }));\n' },
415
+ { name: 'js-fetch-allowlist', language: 'javascript', code: 'if (!ALLOWED_HOSTS.has(new URL(target).host)) return res.status(400).end();\nconst r = await fetch(target, { signal: AbortSignal.timeout(5000) });\n' },
416
+ { name: 'ts-parameterised-query', language: 'typescript', code: 'const rows: Row[] = await db.query("SELECT * FROM t WHERE id = $1", [id]);\n' },
417
+ { name: 'py-parameterised-query', language: 'python', code: 'cur.execute("SELECT * FROM users WHERE email = %s", (email,))\n' },
418
+ { name: 'py-subprocess-argv', language: 'python', code: 'subprocess.run(["ls", "-l", directory], check=True, capture_output=True)\n' },
419
+ { name: 'py-safe-yaml', language: 'python', code: 'config = yaml.safe_load(text)\n' },
420
+ { name: 'py-path-confined', language: 'python', code: 'target = os.path.realpath(os.path.join(root, name))\nif not target.startswith(root + os.sep):\n raise ValueError("outside root")\n' },
421
+ { name: 'py-password-hash', language: 'python', code: 'digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 200_000)\n' },
422
+ { name: 'go-parameterised-query', language: 'go', code: 'row := db.QueryRow("SELECT name FROM users WHERE id = $1", id)\n' },
423
+ { name: 'go-exec-argv', language: 'go', code: 'out, err := exec.Command("git", "rev-parse", ref).Output()\n' },
424
+ { name: 'rb-parameterised-query', language: 'ruby', code: 'User.where("email = ?", params[:email]).first\n' },
425
+ { name: 'rb-system-argv', language: 'ruby', code: 'system("convert", input_path, "-resize", "200x200", output_path)\n' },
426
+ { name: 'java-prepared-statement', language: 'java', code: 'PreparedStatement ps = conn.prepareStatement("SELECT * FROM t WHERE id = ?");\nps.setInt(1, id);\nResultSet rs = ps.executeQuery();\n' },
427
+ { name: 'java-processbuilder-argv', language: 'java', code: 'Process p = new ProcessBuilder("git", "log", "-1", ref).start();\n' },
428
+ { name: 'php-prepared-statement', language: 'php', code: '$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");\n$stmt->execute([":id" => $id]);\n' },
429
+ { name: 'cs-parameterised-query', language: 'csharp', code: 'var cmd = new SqlCommand("SELECT * FROM t WHERE id = @id", conn);\ncmd.Parameters.AddWithValue("@id", id);\n' },
430
+ { name: 'rs-parameterised-query', language: 'rust', code: 'let row = client.query_one("SELECT name FROM users WHERE id = $1", &[&id]).await?;\n' },
431
+ ]);
432
+
433
+ ;// CONCATENATED MODULE: ./src/posture/rule-compiler/regression.js
434
+ // Regression integration for promoted rules (PRD 3, requirement 8): a promoted rule joins a corpus every later rule and every later
435
+ // run is held to, instead of being checked once at promotion and forgotten.
436
+ //
437
+ // shared false-positive corpus the SAFE code of every promoted rule (its patched, nearby-clean and protected variants) plus any file
438
+ // the operator drops in `.agentic-security/rule-compiler/fp-corpus/`. A new candidate must not fire
439
+ // on any of it, so rule N+1 cannot reintroduce the false positive rule N was built to avoid.
440
+ // regression run `rules regress` re-scores every PROMOTED rule against its own corpus, then against the shared false
441
+ // positive corpus, and reports any rule that no longer clears its gate (the matcher, the loader or
442
+ // another rule changed underneath it).
443
+ // The rule-specific unit tests are the fixtures written at evaluation (the existing `rule test` convention); this module adds the cross-rule
444
+ // part, which a per-rule check cannot see.
445
+
446
+
447
+
448
+
449
+
450
+
451
+
452
+
453
+
454
+
455
+
456
+
457
+ const FP_DIR = 'rule-compiler/fp-corpus';
458
+ const MAX_FP_FILE = 256 * 1024;
459
+ const MAX_FP_FILES = 500;
460
+ const EXT_OF = { '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.ts': 'typescript', '.py': 'python', '.go': 'go', '.rb': 'ruby', '.java': 'java', '.rs': 'rust', '.cs': 'csharp', '.php': 'php' };
461
+
462
+ // GA-07 §3 (0.195.27): one or more directories of safe code shared ACROSS repositories (a fleet's common false-positive corpus),
463
+ // named by path-separator-joined entries in this variable. Same rules as the project directory: plain files, size and count caps.
464
+ const SHARED_FP_ENV = 'AGENTIC_SECURITY_RULES_FP_CORPUS';
465
+
466
+ function falsePositiveFiles(dir, prefix) {
467
+ const out = [];
468
+ let names = [];
469
+ try { names = external_node_fs_.readdirSync(dir).sort(); } catch { return out; }
470
+ for (const n of names.slice(0, MAX_FP_FILES)) {
471
+ const f = external_node_path_.join(dir, n);
472
+ try {
473
+ const st = external_node_fs_.lstatSync(f);
474
+ if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_FP_FILE) continue;
475
+ out.push({ label: `${prefix}:${n}`, file: n, language: EXT_OF[external_node_path_.extname(n).toLowerCase()] ?? 'any', code: external_node_fs_.readFileSync(f, 'utf8') });
476
+ } catch { /* unreadable: skipped */ }
477
+ }
478
+ return out;
479
+ }
480
+
481
+ /** Operator-supplied false positives (real code a rule should never flag): the project's own directory, then every shared directory. */
482
+ function operatorFalsePositives(root, env = process.env) {
483
+ const out = falsePositiveFiles((0,state_dir.statePath)(root, ...FP_DIR.split('/')), 'operator-fp');
484
+ for (const dir of String(env[SHARED_FP_ENV] || '').split(external_node_path_.delimiter).map((s) => s.trim()).filter(Boolean)) {
485
+ out.push(...falsePositiveFiles(external_node_path_.resolve(dir), `shared-fp:${external_node_path_.basename(dir)}`));
486
+ }
487
+ return out;
488
+ }
489
+
490
+ function promotedRuleIds(ledger) {
491
+ return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
492
+ }
493
+
494
+ /**
495
+ * The shared false-positive corpus as seen by `exceptId`: the safe code shipped with the scanner (`negative-corpus.js`, the same on
496
+ * every machine and in every repository), the safe code of every OTHER promoted rule, the operator's project files, and any
497
+ * directories shared across repositories through AGENTIC_SECURITY_RULES_FP_CORPUS.
498
+ */
499
+ function sharedFalsePositiveCorpus(root, ledger, exceptId = null, env = process.env) {
500
+ const items = SHIPPED_NEGATIVE_CORPUS.map((s) => ({ label: `shipped-fp:${s.name}`, language: s.language, code: s.code }));
501
+ for (const id of promotedRuleIds(ledger)) {
502
+ if (id === exceptId) continue;
503
+ const ir = loadIR(root, id); const spec = loadCorpus(root, id);
504
+ if (!ir || !spec || typeof spec.originalCode !== 'string') continue;
505
+ let corpus; try { corpus = (0,rule_compiler_corpus/* buildCorpus */.F)(ir, spec); } catch { continue; }
506
+ for (const n of corpus.negatives) items.push({ label: `rule:${id}:${n.label}`, language: ir.languages[0] ?? 'any', code: n.code });
507
+ }
508
+ return [...items, ...operatorFalsePositives(root, env)];
509
+ }
510
+
511
+ /** The digest of a corpus half, as `rules evaluate` records it in the provenance record and `rules regress` re-derives it. */
512
+ function corpusHash(list) {
513
+ return (0,external_node_crypto_.createHash)('sha256').update(JSON.stringify(list.map((x) => [x.label, x.code]).sort())).digest('hex');
514
+ }
515
+
516
+ const FILE_FOR_LANG = { 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' };
517
+
518
+ /** Which shared false-positive items does this rule fire on? A rule is only run against items written in a language it applies to. */
519
+ function falsePositiveHits(ir, items) {
520
+ const { ok, doc } = (0,rule_compiler_ir/* compileToCustomRule */.Mo)(ir);
521
+ if (!ok) return [{ label: '(rule does not compile)' }];
522
+ const rule = (0,custom_rules/* normalizeCustomRule */.Lj)(doc);
523
+ if (!rule) return [{ label: '(rule would be rejected by the loader)' }];
524
+ const hits = [];
525
+ for (const it of items) {
526
+ const lang = it.language && it.language !== 'any' ? it.language : (ir.languages[0] ?? 'any');
527
+ if (!ir.languages.includes('any') && !ir.languages.includes(lang)) continue;
528
+ if ((0,custom_rules/* runRule */.OA)(rule, FILE_FOR_LANG[lang] ?? 'x.txt', it.code).length > 0) hits.push({ label: it.label });
529
+ }
530
+ return hits;
531
+ }
532
+
533
+ /** Re-score every promoted rule (or one) against its own corpus and the shared false-positive corpus. */
534
+ function regressRules(root, ledger, { only = null, thresholds } = {}) {
535
+ const results = [];
536
+ for (const id of promotedRuleIds(ledger)) {
537
+ if (only && id !== only) continue;
538
+ const ir = loadIR(root, id); const spec = loadCorpus(root, id);
539
+ if (!ir || !spec || typeof spec.originalCode !== 'string') { results.push({ id, status: 'unverifiable', reasons: [!ir ? 'the rule IR is missing' : 'no evaluated corpus was kept for this rule (run rules evaluate --corpus again)'] }); continue; }
540
+ let ev; let corpus;
541
+ try { corpus = (0,rule_compiler_corpus/* buildCorpus */.F)(ir, spec); ev = (0,promotion/* evaluateRule */.kx)(ir, corpus, thresholds); } catch (e) { results.push({ id, status: 'broken', reasons: [`cannot be scored: ${String(e.message).slice(0, 120)}`] }); continue; }
542
+ const fp = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
543
+ const prov = loadProvenance(root, id);
544
+ const baseline = prov?.benchmarkResults ?? null;
545
+ const reasons = [...ev.reasons];
546
+ // GA-07 §3 (0.195.27): the promotion was a review of ONE rule against ONE corpus, both digested into the promotion event. A
547
+ // corpus edited after the review (an independent example dropped, a protected variant softened) can still clear the gate, so
548
+ // the gate alone would not notice; the digests do. Such a rule is `review-stale`: not regressed, but no longer the reviewed one.
549
+ const reviewed = prov?.promotionEvent?.evidence ?? null;
550
+ if (reviewed) {
551
+ if (reviewed.positiveCorpusHash !== corpusHash(corpus.positives) || reviewed.negativeCorpusHash !== corpusHash(corpus.negatives)) reasons.push('the corpus changed since the reviewed promotion (its digests no longer match the promotion event); re-evaluate and promote again');
552
+ }
553
+ if (fp.length) reasons.push(`fires on ${fp.length} item(s) of the shared false-positive corpus: ${fp.slice(0, 3).map((h) => h.label).join(', ')}${fp.length > 3 ? ', ...' : ''}`);
554
+ if (baseline && ev.score.recall != null && baseline.recall != null && ev.score.recall < baseline.recall) reasons.push(`recall fell from ${(baseline.recall * 100).toFixed(1)}% to ${(ev.score.recall * 100).toFixed(1)}% since promotion`);
555
+ results.push({ id, status: reasons.length ? 'regressed' : 'ok', reasons, precision: ev.score.precision, recall: ev.score.recall, protectedFailures: ev.score.protectedFailures, fpCorpusHits: fp.length });
556
+ }
557
+ return results;
558
+ }
559
+
560
+ // EXTERNAL MODULE: ./src/posture/proof-fabric/states.js
561
+ var states = __webpack_require__(12090);
562
+ ;// CONCATENATED MODULE: ./src/posture/rule-compiler/eligibility.js
563
+ // X-eligibility (PRD 3 "Eligibility"): a finding may enter synthesis only with a stable id, a vulnerable span, a
564
+ // CWE/family, code-flow evidence, and deterministic corroboration OR exploit proof. Reuses the existing
565
+ // vocabularies rather than inventing a third: proof-fabric's evidence-state ladder when present, else the older
566
+ // proof-tier (`taint-proven`/`execution-proven`), else the finding's own `parser` (an IR-TAINT/MULTI-SINK parser
567
+ // origin is code-flow evidence even with no execution).
568
+
569
+
570
+ const ANALYSED_PARSERS = new Set(['IR-TAINT', 'MULTI-SINK']);
571
+
572
+ function checkEligibility(finding, { proofState = null } = {}) {
573
+ const reasons = [];
574
+ if (!finding?.stableId && !finding?.id) reasons.push('no stable id');
575
+ if (finding?.file == null || finding?.line == null) reasons.push('no vulnerable span (file:line)');
576
+ if (!finding?.cwe && !finding?.family) reasons.push('no CWE or family');
577
+ const hasFlowEvidence = ANALYSED_PARSERS.has(finding?.parser) || !!finding?.snippet;
578
+ if (!hasFlowEvidence) reasons.push('no code-flow evidence (parser not IR-TAINT/MULTI-SINK, no snippet)');
579
+
580
+ let evidenceState = 'none';
581
+ if (proofState && (0,states/* stateRank */.g3)(proofState) >= (0,states/* stateRank */.g3)('EXPLOIT_CONFIRMED')) evidenceState = 'execution-proven';
582
+ else if (finding?.proofTier === 'execution-proven') evidenceState = 'execution-proven';
583
+ else if (finding?.proofTier === 'taint-proven' || ANALYSED_PARSERS.has(finding?.parser)) evidenceState = 'taint-proven';
584
+ if (evidenceState === 'none') reasons.push('no deterministic corroboration or exploit proof');
585
+
586
+ return { eligible: reasons.length === 0, reasons, evidenceState };
587
+ }
588
+
589
+ // EXTERNAL MODULE: ./src/posture/rule-compiler/proposal.js
590
+ var proposal = __webpack_require__(47838);
591
+ // EXTERNAL MODULE: ./src/posture/rule-compiler/mutation.js
592
+ var mutation = __webpack_require__(85127);
593
+ // EXTERNAL MODULE: ./src/posture/proof-fabric/reproducer.js
594
+ var reproducer = __webpack_require__(37055);
595
+ ;// CONCATENATED MODULE: ./src/posture/rule-compiler/cli.js
596
+ // CLI surface: `agentic-security rules synthesize|evaluate|promote|status|rollback|cost|regress`.
597
+ //
598
+ // Distinct from the pre-existing `labs synthesize-rule` (posture/rule-synthesis.js), which proposes SUPPRESSION
599
+ // rules from repeated false-positive triage verdicts — a different input, a different output and a different
600
+ // command group on purpose, so the two are never confused at the CLI.
601
+ //
602
+ // Non-interactive by construction: no command reads stdin, every corpus evaluation is bounded (no network, no
603
+ // model call — this is the deterministic half; a model may PROPOSE the candidate pattern this CLI is handed, but
604
+ // it never decides promotion).
605
+
606
+
607
+
608
+
609
+
610
+
611
+
612
+
613
+
614
+
615
+
616
+
617
+
618
+
619
+
620
+
621
+ function loadFinding(root, id) {
622
+ const p = (0,state_dir.statePath)(root, 'last-scan.json');
623
+ if (!external_node_fs_.existsSync(p)) return null;
624
+ const last = JSON.parse(external_node_fs_.readFileSync(p, 'utf8'));
625
+ return (last.findings || []).find((f) => f.id === id || f.stableId === id) ?? null;
626
+ }
627
+
628
+ function readJsonArg(pathOrInline) {
629
+ if (!pathOrInline) return null;
630
+ try { return JSON.parse(external_node_fs_.existsSync(pathOrInline) ? external_node_fs_.readFileSync(pathOrInline, 'utf8') : pathOrInline); } catch { return null; }
631
+ }
632
+
633
+ async function cmdRules(args, root) {
634
+ const sub = args._[1];
635
+ // GA-07: a ledger that failed its integrity check (broken chain, bad or missing signature) refuses EVERY command, not only the
636
+ // ones that change state; nothing below can read a tampered history as fact or write over it.
637
+ if (['synthesize', 'evaluate', 'promote', 'status', 'rollback', 'deprecate', 'cost', 'regress'].includes(sub)) {
638
+ const guard = loadLedger(root);
639
+ if (guard.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${guard.tamperReason}).`); return 4; }
640
+ }
641
+ if (sub === 'synthesize') return cmdSynthesize(args, root);
642
+ if (sub === 'evaluate') return cmdEvaluate(args, root);
643
+ if (sub === 'promote') return cmdPromote(args, root);
644
+ if (sub === 'status') return cmdStatus(args, root);
645
+ if (sub === 'rollback') return cmdRollback(args, root);
646
+ if (sub === 'deprecate') return cmdDeprecate(args, root);
647
+ if (sub === 'cost') return cmdCost(args, root);
648
+ if (sub === 'regress') return cmdRegress(args, root);
649
+ 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> [--sign-key <base64>] | status [rule-id] | rollback <rule-id> | deprecate <rule-id> | cost <rule-id> | regress [rule-id]');
650
+ return 4;
651
+ }
652
+
653
+ /** The operator's Ed25519 key, when given: it signs the published rule AND the ledger, so a clone without this install's HMAC key can
654
+ * still verify the lifecycle history (store.js loadLedger). Never stored. */
655
+ function authorKeyFrom(args) { return args.flags['sign-key'] ?? process.env.AGENTIC_SECURITY_PRIVATE_KEY ?? null; }
656
+
657
+ /** Every command that changes the ledger saves it through here, so an author signature made at promotion is renewed by a later
658
+ * rollback or evaluation run with the same key instead of going stale (a stale one would read as tampering on the next clone). */
659
+ function persistLedger(root, ledger, args) {
660
+ const r = saveLedger(root, ledger, { authorKey: authorKeyFrom(args) });
661
+ if (!r.ok && r.reason) console.error(r.reason);
662
+ return r;
663
+ }
664
+
665
+ // `--spec` carries exactly what a model or a human proposed: id/title/severity/cwe/family/languages/match/
666
+ // remediation plus the corpus inputs (originalCode/patchedCode/nearbyCleanCode/protectedVariantsCode). This
667
+ // command never invents a pattern; it only drafts the IR, checks eligibility and records DRAFT->CANDIDATE.
668
+ async function cmdSynthesize(args, root) {
669
+ const findingId = args._[2];
670
+ const spec = readJsonArg(args.flags.spec);
671
+ if (!findingId || !spec) { console.error('usage: rules synthesize <finding-id> --spec <file.json>'); return 4; }
672
+ const finding = loadFinding(root, findingId);
673
+ if (!finding) { console.error(`Finding ${findingId} not found in last scan.`); return 4; }
674
+ const elig = checkEligibility(finding, { proofState: args.flags['proof-state'] || null });
675
+ if (!elig.eligible) { console.error(`Not eligible for rule synthesis: ${elig.reasons.join('; ')}`); return 4; }
676
+
677
+ // The candidate pattern is the model's or the person's, when they gave one. Without one, or with --auto, the synthesis loop proposes
678
+ // from the vulnerable and patched code, scores each attempt on the real corpus and its mutations, and revises (see proposal.js).
679
+ let finalSpec = spec; let synthesis = null;
680
+ const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [], independentVulnerableCode: spec.independentVulnerableCode ?? [], independentCleanCode: spec.independentCleanCode ?? [] };
681
+ const leak = (0,promotion/* checkLeakage */.r_)(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
682
+ 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; }
683
+ if (args.flags.auto || (!spec.match && !spec.sink)) {
684
+ if (!isStr(spec.originalCode)) { console.error('Automatic proposal needs originalCode (the vulnerable snippet) in the spec.'); return 4; }
685
+ const { id, title, severity, cwe, family, languages, remediation } = spec;
686
+ synthesis = (0,proposal/* synthesizeCandidate */._7)({ base: { id, title, severity, cwe, family, languages, remediation, originatingFindingId: finding.stableId ?? finding.id, evidenceState: elig.evidenceState }, corpusSpec, proposed: spec.match ?? null, maxAttempts: Number(args.flags['max-attempts']) || 6, leakageOk: true });
687
+ for (const a of synthesis.attempts) console.log(` attempt ${a.n}: ${a.strategy.padEnd(34)} ${a.valid ? `precision ${fmtPct(a.score.precision)} recall ${fmtPct(a.score.recall)} protected-failures ${a.score.protectedFailures}${a.pass ? ' PASS' : ''}` : `invalid (${a.errors[0]})`}`);
688
+ if (!synthesis.best) { console.error('No valid candidate could be proposed from this material.'); return 1; }
689
+ finalSpec = { ...spec, ...(synthesis.best.match ? { match: synthesis.best.match } : {}), ...(synthesis.best.structure ?? {}) };
690
+ console.log(synthesis.best.pass ? ` candidate meets the promotion gate on this corpus (not yet recorded as VALIDATED).` : ` no attempt met the gate; the best is kept as a CANDIDATE only: ${synthesis.best.reasons.join('; ')}`);
691
+ }
692
+ const ir = (0,rule_compiler_ir/* draftRuleIR */.pc)({ ...finalSpec, originatingFindingId: finding.stableId ?? finding.id, originatingCommit: spec.originatingCommit ?? null, evidenceState: elig.evidenceState });
693
+ const errs = (0,rule_compiler_ir/* validateRuleIR */.g)(ir);
694
+ if (errs.length) { console.error(`Invalid rule: ${errs.join('; ')}`); return 4; }
695
+
696
+ const ledger = loadLedger(root);
697
+ // GA-07 (0.195.27): a rule that was rolled back re-enters as a CANDIDATE (the ledger's ROLLED_BACK -> CANDIDATE edge), keeping its
698
+ // history; any other existing state refuses, so a promoted or validated rule cannot be silently re-drafted over.
699
+ const current = stateOf(ledger, ir.id);
700
+ if (current !== null && current !== 'ROLLED_BACK') { console.error(`Rule ${ir.id} already exists in state ${current}; roll it back first, or choose another id.`); return 4; }
701
+ if (current === null) {
702
+ const draft = recordTransition(ledger, { ruleId: ir.id, from: null, to: 'DRAFT', reason: `drafted from ${findingId}`, actor: args.flags.actor || 'agent' });
703
+ if (!draft.ok) { console.error(draft.reason); return 4; }
704
+ }
705
+ const cand = recordTransition(ledger, { ruleId: ir.id, from: current === null ? 'DRAFT' : 'ROLLED_BACK', to: 'CANDIDATE', reason: current === null ? 'eligibility passed' : `re-entered after rollback; eligibility passed (drafted again from ${findingId})`, evidence: { eligibility: elig }, actor: args.flags.actor || 'agent' });
706
+ persistLedger(root, ledger, args);
707
+ saveIR(root, { ...ir, state: 'CANDIDATE' });
708
+ saveCorpus(root, ir.id, { ...corpusSpec, fileNames: spec.fileNames ?? [], discoveryCost: spec.discoveryCost ?? (args.flags['discovery-cost-usd'] ? { usd: Number(args.flags['discovery-cost-usd']), tokens: Number(args.flags['discovery-tokens']) || null, model: spec.model ?? null } : null) });
709
+ if (synthesis) saveSynthesis(root, ir.id, { attempts: synthesis.attempts, best: synthesis.best });
710
+ console.log(`Rule ${ir.id}: DRAFT -> CANDIDATE (digest ${(0,rule_compiler_ir/* ruleIRDigest */.GE)(ir).slice(0, 12)})`);
711
+ if (!cand.ok) console.error(cand.reason);
712
+ return 0;
713
+ }
714
+
715
+ async function cmdEvaluate(args, root) {
716
+ const id = args._[2];
717
+ if (!id) { console.error('usage: rules evaluate <rule-id> --corpus <file.json>'); return 4; }
718
+ const ir = loadIR(root, id);
719
+ if (!ir) { console.error(`No rule IR found for ${id}. Run rules synthesize first.`); return 4; }
720
+ const corpusSpec = readJsonArg(args.flags.corpus) ?? loadCorpus(root, id);
721
+ if (!corpusSpec) { console.error('No corpus supplied. Pass --corpus <file.json> with originalCode (and optionally patchedCode/nearbyCleanCode/protectedVariantsCode).'); return 4; }
722
+ const corpus = (0,rule_compiler_corpus/* buildCorpus */.F)(ir, corpusSpec);
723
+ // the corpus a rule is evaluated on is the evidence for it: keep it, so `rules regress` and the shared false-positive corpus use the SAME code
724
+ if (args.flags.corpus) saveCorpus(root, id, { ...(loadCorpus(root, id) ?? {}), ...corpusSpec });
725
+ const leakage = (0,promotion/* checkLeakage */.r_)(JSON.stringify({ ...corpusSpec, fileNames: undefined, discoveryCost: undefined }), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: corpusSpec.fileNames ?? [] });
726
+ const gate = (0,promotion/* gatePromotion */.z)({ ir, corpus, leakageCheck: leakage, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : promotion/* PROMOTION_DEFAULTS */.V2 });
727
+
728
+ const ledger = loadLedger(root);
729
+ // the shared false-positive corpus: the safe code of every other promoted rule, plus the operator's own files. Part of the gate.
730
+ const fpItems = sharedFalsePositiveCorpus(root, ledger, id);
731
+ const fpHits = falsePositiveHits(ir, fpItems);
732
+ gate.fpCorpus = { items: fpItems.length, hits: fpHits.map((h) => h.label) };
733
+ if (fpHits.length) { gate.pass = false; gate.reasons.push(`fires on ${fpHits.length} of ${fpItems.length} item(s) in the shared false-positive corpus: ${fpHits.slice(0, 3).map((h) => h.label).join(', ')}${fpHits.length > 3 ? ', ...' : ''}`); }
734
+ const cur = stateOf(ledger, id);
735
+ if (gate.pass && cur === 'CANDIDATE') recordTransition(ledger, { ruleId: id, from: 'CANDIDATE', to: 'VALIDATED', reason: 'promotion gate passed', evidence: { score: gate.evaluation.score }, actor: args.flags.actor || 'agent' });
736
+ persistLedger(root, ledger, args);
737
+ saveEvaluation(root, id, gate);
738
+ const prev = loadProvenance(root, id);
739
+ saveProvenance(root, id, {
740
+ schema: 'agentic-security/rule-provenance', version: 1, ruleId: id, ruleVersion: 1, ruleHash: (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir),
741
+ originatingFinding: ir.provenance.originatingFindingId, originatingCommit: ir.provenance.originatingCommit, evidenceState: ir.provenance.evidenceState,
742
+ generatingModel: ir.provenance.model, promptConfigVersion: ir.provenance.promptConfigVersion,
743
+ positiveCorpusHash: corpusHash(corpus.positives), negativeCorpusHash: corpusHash(corpus.negatives),
744
+ 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 },
745
+ leakageCheck: leakage, mutatorCoverage: (0,mutation/* mutatorCoverage */.xv)(corpusSpec.originalCode), discoveryCost: corpusSpec.discoveryCost ?? prev?.discoveryCost ?? null,
746
+ promotionEvent: prev?.promotionEvent ?? null, evaluatedAt: new Date().toISOString(),
747
+ });
748
+ if (args.flags.json) console.log(JSON.stringify(gate, null, 2));
749
+ else {
750
+ console.log(`Rule ${id}: ${gate.pass ? 'VALIDATED' : 'gate FAILED'}`);
751
+ console.log(` precision ${fmtPct(gate.evaluation.score.precision)} recall ${fmtPct(gate.evaluation.score.recall)} protected-failures ${gate.evaluation.score.protectedFailures}`);
752
+ for (const r of gate.reasons) console.log(` - ${r}`);
753
+ }
754
+ if (gate.pass) { const f = writeFixtures(root, ir, corpus); if (f.ok) console.log(` regression fixtures written: ${f.written.length} file(s) under .agentic-security/rule-compiler/${id}/fixtures/`); }
755
+ return gate.pass ? 0 : 1;
756
+ }
757
+ function isStr(v) { return typeof v === 'string' && v.length > 0; }
758
+ const fmtPct = (n) => (n == null ? 'unmeasured' : `${(n * 100).toFixed(1)}%`);
759
+
760
+ /** Does this commit exist in the project's history? `true`, `false`, or `null` when the project is not a git repository. */
761
+ function commitExists(root, sha) {
762
+ if (!external_node_fs_.existsSync(external_node_path_.join(root, '.git'))) return null;
763
+ try { (0,external_node_child_process_.execFileSync)('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: root, stdio: 'ignore' }); return true; } catch { return false; }
764
+ }
765
+
766
+ const NOT_A_REVIEWER = /^(?:agent|model|auto|bot|llm|ai)$/i;
767
+
768
+ // 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
769
+ // provenance that checks out, and no conflict with a rule that already exists; a model's recommendation on its own promotes nothing.
770
+ async function cmdPromote(args, root) {
771
+ const id = args._[2];
772
+ if (!id) { console.error('usage: rules promote <rule-id> --reviewed-by <person> [--scope <text>] [--reason <text>]'); return 4; }
773
+ const reviewer = args.flags['reviewed-by'];
774
+ if (!isStr(reviewer) || NOT_A_REVIEWER.test(reviewer.trim())) {
775
+ console.error('usage: rules promote <rule-id> --reviewed-by <person> --scope <text>. 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.');
776
+ return 4;
777
+ }
778
+ // GA-07 §3 (0.195.27): the reviewed SCOPE (what the rule is for, where it applies, what it must not flag) is part of the review
779
+ // record, not an optional note; a promotion without one records nothing a later reader could hold the rule to.
780
+ if (!isStr(args.flags.scope)) {
781
+ console.error('usage: rules promote <rule-id> --reviewed-by <person> --scope <text>. The reviewed scope is recorded with the promotion; state what the rule covers and where it applies.');
782
+ return 4;
783
+ }
784
+ const ledger = loadLedger(root);
785
+ if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
786
+ if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
787
+ const ir = loadIR(root, id);
788
+ if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
789
+ // the rule promoted must be the rule evaluated: an IR edited after `rules evaluate` has no evidence
790
+ const prov = loadProvenance(root, id);
791
+ if (!prov) { console.error(`Promotion refused: no evaluation record for ${id}; run rules evaluate first.`); return 4; }
792
+ if (prov.ruleHash !== (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir)) { console.error(`Promotion refused: the rule ${id} changed after it was evaluated (evaluated ${String(prov.ruleHash).slice(0, 12)}, now ${(0,rule_compiler_ir/* ruleIRDigest */.GE)(ir).slice(0, 12)}); run rules evaluate again.`); return 4; }
793
+ // a named originating commit must be real history, not a typed-in string
794
+ const commit = ir.provenance?.originatingCommit;
795
+ if (commit) {
796
+ const exists = commitExists(root, commit);
797
+ if (exists === false) { console.error(`Promotion refused: originating commit ${commit} is not in this repository's history (invalid provenance).`); return 4; }
798
+ if (exists === null) { console.error(`Promotion refused: originating commit ${commit} cannot be verified because this project is not a git repository.`); return 4; }
799
+ }
800
+ // conflicts: a hand-written rule file with the same id, or a promoted rule with the identical match
801
+ const live = (0,state_dir.statePath)(root, 'rules', `${id}.yml`);
802
+ let firstLine = null; // null: no file there (or unreadable, which is treated as absent below only when the read fails with ENOENT)
803
+ try { firstLine = external_node_fs_.readFileSync(live, 'utf8').split('\n')[0]; } catch (e) { if (e.code !== 'ENOENT') firstLine = ''; }
804
+ 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; }
805
+ for (const other of promotedRuleIds(ledger)) {
806
+ if (other === id) continue;
807
+ const oir = loadIR(root, other);
808
+ if (oir && JSON.stringify(oir.match) === JSON.stringify(ir.match) && JSON.stringify(oir.languages) === JSON.stringify(ir.languages)) {
809
+ console.error(`Promotion refused: ${id} duplicates the promoted rule ${other} (identical match on the same languages); roll one back or narrow this one.`);
810
+ return 4;
811
+ }
812
+ }
813
+ // other rules may have been promoted since this one was evaluated: hold it to the false-positive corpus as it is NOW
814
+ const fpNow = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
815
+ 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; }
816
+ const pub = publishCustomRule(root, ir);
817
+ if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
818
+ const scope = args.flags.scope.trim();
819
+ // GA-07 §3 (0.195.27): the evidence the reviewer looked at, digested into the promotion: the evaluation and provenance records
820
+ // as they stood, and the two corpus halves. `rules regress` re-derives the corpus digests and reports a rule whose corpus moved
821
+ // after the review as no longer the reviewed one.
822
+ const corpusNow = (0,rule_compiler_corpus/* buildCorpus */.F)(ir, loadCorpus(root, id) ?? {});
823
+ const evidence = {
824
+ reviewer: reviewer.trim(), scope, independent: prov.benchmarkResults?.independent ?? null, ruleHash: (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir),
825
+ evaluationDigest: (0,reproducer/* sha256 */.sc)(JSON.stringify(readJsonArg(external_node_path_.join((0,state_dir.statePath)(root, 'rule-compiler', id), 'evaluation.json')))),
826
+ provenanceDigest: (0,reproducer/* sha256 */.sc)(JSON.stringify({ ...prov, promotionEvent: undefined })),
827
+ positiveCorpusHash: corpusHash(corpusNow.positives), negativeCorpusHash: corpusHash(corpusNow.negatives),
828
+ sharedFalsePositiveItems: sharedFalsePositiveCorpus(root, ledger, id).length,
829
+ };
830
+ const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', evidence, actor: args.flags.actor || reviewer.trim() });
831
+ const saved = persistLedger(root, ledger, args);
832
+ 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: (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir), evidence } });
833
+ console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
834
+ // Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
835
+ // one with the operator's key when there is one, and say plainly whether the scanner will load it. The same key author-signs the
836
+ // ledger, which is what lets `rules regress` on another machine (CI) verify the history without this install's HMAC key.
837
+ const key = authorKeyFrom(args);
838
+ if (key) {
839
+ const sg = signPublishedRule(pub.file, key); console.log(sg.ok ? ` signed: ${pub.file}.sig` : ` could not sign (${sg.reason}); the rule is published unsigned`);
840
+ console.log(saved.authorSigned ? ` ledger author-signed; another machine verifies it with AGENTIC_SECURITY_RULES_PUBLIC_KEY=${publicKeyOf(key)}` : ' ledger NOT author-signed; rules regress on another machine will refuse it');
841
+ } else {
842
+ console.log(' no --sign-key: the ledger carries only this install\'s signature, so rules regress on another machine (CI) will refuse it; promote with --sign-key <base64> to make it portable');
843
+ }
844
+ const trust = packTrust(root, id);
845
+ console.log(trust.live ? ` The scanner will load it (${trust.via}).` : ` NOT LIVE YET: the scanner will refuse this rule (${trust.reason}). To make it live: ${trust.remediation}.`);
846
+ if (!t.ok) console.error(t.reason);
847
+ return 0;
848
+ }
849
+
850
+ /** `rules regress [rule-id]`: re-score every PROMOTED rule against its own corpus and the shared false-positive corpus. Exit 1 on any regression. */
851
+ async function cmdRegress(args, root) {
852
+ const ledger = loadLedger(root);
853
+ if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
854
+ const only = args._[2] ?? null;
855
+ const results = regressRules(root, ledger, { only, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : undefined });
856
+ if (args.flags.json) console.log(JSON.stringify({ ledgerVerifiedBy: ledger.verifiedBy ?? null, results }, null, 2));
857
+ else if (!results.length) console.log(only ? `No promoted rule ${only}.` : 'No rule is PROMOTED; nothing to regress.');
858
+ else for (const r of results) { console.log(`${r.id}: ${r.status.toUpperCase()}${r.precision != null ? ` precision ${fmtPct(r.precision)} recall ${fmtPct(r.recall)} false-positive corpus hits ${r.fpCorpusHits}` : ''}`); for (const m of r.reasons) console.log(` - ${m}`); }
859
+ if (!args.flags.json && results.length) console.log(`ledger verified by ${ledger.verifiedBy === 'author-key' ? `the author key (${ledger.authorKeyId})` : ledger.verifiedBy === 'install-key' ? 'this install\'s key' : 'nothing (unsigned, accepted by AGENTIC_SECURITY_RULES_UNSIGNED=1)'}`);
860
+ if (only && !results.length) return 4;
861
+ return results.every((r) => r.status === 'ok') ? 0 : 1;
862
+ }
863
+
864
+ async function cmdRollback(args, root) {
865
+ const id = args._[2];
866
+ if (!id) { console.error('usage: rules rollback <rule-id> [--reason text]'); return 4; }
867
+ const ledger = loadLedger(root);
868
+ const cur = stateOf(ledger, id);
869
+ if (cur !== 'PROMOTED') { console.error(`Rule ${id} is not PROMOTED (current: ${cur ?? 'unknown'}); nothing to roll back.`); return 4; }
870
+ const un = unpublishCustomRule(root, id);
871
+ const t = recordTransition(ledger, { ruleId: id, from: 'PROMOTED', to: 'ROLLED_BACK', reason: args.flags.reason || 'operator rollback', actor: args.flags.actor || 'operator' });
872
+ persistLedger(root, ledger, args);
873
+ console.log(`Rule ${id}: PROMOTED -> ROLLED_BACK.${un.ok ? '' : ` (warning: could not remove published rule file: ${un.reason})`}`);
874
+ return t.ok ? 0 : 1;
875
+ }
876
+
877
+ /** `rules deprecate <rule-id>`: retire a promoted rule for good (PROMOTED -> DEPRECATED, a terminal state); the published file is removed. */
878
+ async function cmdDeprecate(args, root) {
879
+ const id = args._[2];
880
+ if (!id) { console.error('usage: rules deprecate <rule-id> [--reason text]'); return 4; }
881
+ const ledger = loadLedger(root);
882
+ const cur = stateOf(ledger, id);
883
+ if (cur !== 'PROMOTED') { console.error(`Rule ${id} is not PROMOTED (current: ${cur ?? 'unknown'}); only a promoted rule can be deprecated (roll back a candidate instead).`); return 4; }
884
+ const un = unpublishCustomRule(root, id);
885
+ const t = recordTransition(ledger, { ruleId: id, from: 'PROMOTED', to: 'DEPRECATED', reason: args.flags.reason || 'operator deprecation', actor: args.flags.actor || 'operator' });
886
+ persistLedger(root, ledger, args);
887
+ console.log(`Rule ${id}: PROMOTED -> DEPRECATED (terminal).${un.ok ? '' : ` (warning: could not remove published rule file: ${un.reason})`}`);
888
+ return t.ok ? 0 : 1;
889
+ }
890
+
891
+ async function cmdStatus(args, root) {
892
+ const ledger = loadLedger(root);
893
+ const id = args._[2];
894
+ if (id) {
895
+ const h = historyFor(ledger, id);
896
+ if (!h.length) { console.error(`No history for rule ${id}.`); return 4; }
897
+ if (args.flags.json) console.log(JSON.stringify(h, null, 2));
898
+ else { console.log(`Rule ${id}: ${stateOf(ledger, id)}`);
899
+ if (stateOf(ledger, id) === 'PROMOTED') { const tr = packTrust(root, id); console.log(tr.live ? ` live: yes (${tr.via})` : ` live: NO, the scanner refuses it (${tr.reason}). ${tr.remediation}.`); }
900
+ for (const e of h) console.log(` ${e.at} ${e.from ?? '(new)'} -> ${e.to} (${e.reason || ''})`);
901
+ const syn = loadSynthesis(root, id);
902
+ if (syn?.attempts?.length) { console.log(` synthesis: ${syn.attempts.length} attempt(s), best ${syn.best ? `${syn.best.strategy}${syn.best.pass ? ' (met the gate)' : ' (did not meet the gate)'}` : 'none'}`); }
903
+ }
904
+ return 0;
905
+ }
906
+ const ids = [...new Set(ledger.entries.map((e) => e.ruleId))];
907
+ if (args.flags.json) { console.log(JSON.stringify(ids.map((rid) => ({ id: rid, state: stateOf(ledger, rid), ...(stateOf(ledger, rid) === 'PROMOTED' ? { live: packTrust(root, rid).live } : {}) })), null, 2)); return 0; }
908
+ if (!ids.length) { console.log('No rules in the lifecycle ledger yet.'); return 0; }
909
+ for (const rid of ids) console.log(` ${rid.padEnd(40)} ${stateOf(ledger, rid)}${stateOf(ledger, rid) === 'PROMOTED' && !packTrust(root, rid).live ? ' (NOT LIVE: the scanner refuses it)' : ''}`);
910
+ return 0;
911
+ }
912
+
913
+ // PRD 3 business metric: what the first, model-assisted discovery cost, against what every later deterministic detection costs.
914
+ // The discovery cost is whatever the agent run reported for it (recorded at synthesis); a deterministic detection makes no model
915
+ // call and is $0 of model spend. The count of later detections is read from the LAST scan only, so it is a floor, not a history.
916
+ async function cmdCost(args, root) {
917
+ const id = args._[2];
918
+ if (!id) { console.error('usage: rules cost <rule-id>'); return 4; }
919
+ const prov = loadProvenance(root, id);
920
+ if (!prov) { console.error(`No provenance for ${id}. Run rules evaluate first.`); return 4; }
921
+ let detections = null;
922
+ try { const last = JSON.parse(external_node_fs_.readFileSync((0,state_dir.statePath)(root, 'last-scan.json'), 'utf8')); detections = (last.findings || []).filter((f) => String(f.id || '').startsWith(`custom:rule-compiler/${id}:`)).length; } catch { /* no scan */ }
923
+ const first = prov.discoveryCost;
924
+ const out = { ruleId: id, firstDiscovery: first ? { usd: first.usd ?? null, tokens: first.tokens ?? null, model: first.model ?? null } : null, laterDeterministicDetections: detections, laterDeterministicModelUsd: 0, state: prov.promotionEvent ? 'promoted' : 'not promoted' };
925
+ if (args.flags.json) { console.log(JSON.stringify(out, null, 2)); return 0; }
926
+ console.log(`Rule ${id} (${out.state})`);
927
+ console.log(first ? ` first discovery: ${first.usd != null ? `$${first.usd}` : 'cost not recorded'}${first.tokens ? `, ${first.tokens} tokens` : ''}${first.model ? `, ${first.model}` : ''}` : ' first discovery: cost was not recorded for this rule');
928
+ console.log(` later detections by this rule in the last scan: ${detections ?? 'no scan available'}, each at $0 of model spend`);
929
+ if (first?.usd != null && detections) console.log(` discovery cost spread over ${detections + 1} detection(s): $${(first.usd / (detections + 1)).toFixed(4)} each, falling with every further one`);
930
+ return 0;
931
+ }
932
+
933
+
934
+ /***/ })
935
+
936
+ };