flecto 3.0.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { program } from 'commander';
4
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, realpathSync } from 'fs';
5
- import { resolve, relative, dirname, join } from 'path';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
5
+ import { resolve, relative, dirname, basename, join, isAbsolute } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
- import { createHash } from 'crypto';
8
7
  import { execFileSync } from 'child_process';
9
8
  import chalk from 'chalk';
10
9
 
11
10
  import { parseFile, isSupported, parseContent } from './src/parser.js';
12
- import { diffTrees, secretMatchPath } from './src/differ.js';
11
+ import { diffTrees } from './src/differ.js';
13
12
  import { documentKeysOf, withDocumentKeys } from './src/documents.js';
14
13
  import { startWatcher } from './src/watcher.js';
15
14
  import {
@@ -21,7 +20,7 @@ import {
21
20
  renderWarn,
22
21
  renderPolicyFindings,
23
22
  maskChangeEvent,
24
- maskSensitiveValue,
23
+ maskFindings,
25
24
  } from './src/renderer.js';
26
25
  import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
27
26
  import { PR_PROVIDER_IDS } from './src/pr-providers.js';
@@ -31,11 +30,17 @@ import {
31
30
  readTerraformPlanFile,
32
31
  } from './src/terraform.js';
33
32
  import { renderReportHtml } from './src/report.js';
34
- import { redactSecretString } from './src/secrets.js';
35
33
  import { fireAlerts } from './src/alerter.js';
36
34
  import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
37
35
  import { createEnvelope } from './src/envelope.js';
36
+ import { startLanguageServer } from './src/lsp.js';
38
37
  import { buildSarif } from './src/sarif.js';
38
+ import {
39
+ maskState,
40
+ resolveSnapshotStore,
41
+ SNAPSHOT_MASK_MODES,
42
+ SNAPSHOT_STORE_IDS,
43
+ } from './src/snapshot-store.js';
39
44
  import {
40
45
  loadBaseline,
41
46
  applyBaseline,
@@ -55,6 +60,18 @@ import {
55
60
  addPolicyPackFromPackage,
56
61
  } from './src/policy.js';
57
62
  import { testPolicyFixture } from './src/policy-test.js';
63
+ import { makeCliRunner, runStdioServer } from './src/mcp.js';
64
+ import {
65
+ assertExplainNotFromRc,
66
+ buildExplainPayload,
67
+ buildExplainRequest,
68
+ describeRequest,
69
+ estimateInputTokens,
70
+ EXPLAIN_PROVIDERS,
71
+ formatNarration,
72
+ narrate,
73
+ resolveExplainConfig,
74
+ } from './src/explain.js';
58
75
  import {
59
76
  loadRcConfig,
60
77
  resolveEffectiveOptions,
@@ -63,13 +80,16 @@ import {
63
80
  resolveProfileName,
64
81
  resolvePolicyOptions,
65
82
  assertTargetContained,
83
+ assertWriteDestinationContained,
84
+ assertAlertActionsFromCli,
85
+ assertSnapshotRefFromCli,
86
+ assertSafeGitRef,
66
87
  } from './src/config.js';
67
88
 
68
89
  const PKG = JSON.parse(
69
90
  readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
70
91
  );
71
92
 
72
- const SNAPSHOT_DIR = '.flecto-snapshots';
73
93
  const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn'];
74
94
 
75
95
  /**
@@ -82,85 +102,42 @@ const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn
82
102
  const PLAN_DEFAULT_FAIL_ON = 'error';
83
103
  const PLAN_DEFAULT_POLICIES = 'terraform';
84
104
 
85
- function snapshotIdForPath(absPath) {
86
- const normalized = absPath.replaceAll('\\', '/');
87
- return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
88
- }
89
-
90
- function snapshotPathForFile(absPath) {
91
- const id = snapshotIdForPath(absPath);
92
- return resolve(`${SNAPSHOT_DIR}/${id}.json`);
93
- }
94
-
95
- function snapshotHistoryPathForFile(absPath) {
96
- const id = snapshotIdForPath(absPath);
97
- let timestamp = Date.now();
98
- let path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
99
- while (existsSync(path)) {
100
- timestamp += 1;
101
- path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
102
- }
103
- return path;
104
- }
105
-
106
105
  /**
107
- * Snapshot ids that already have at least one timestamped history entry.
106
+ * Put the live side of a diff in the same form the store recorded the baseline
107
+ * in.
108
108
  *
109
- * Listed once per run and threaded through the snapshot loop: probing the
110
- * directory per file made writing N baselines cost N listings of O(N) entries
111
- * each, which is quadratic in the number of tracked files.
112
- * @returns {Set<string>}
109
+ * A masked store holds `flecto:sha256:…` where a secret was. Diffing that
110
+ * against the plaintext on disk would report every secret in the file as changed
111
+ * on every single run — noise that would train a team to ignore the tool, from a
112
+ * store whose whole purpose is to be trusted. Masking both sides compares digest
113
+ * to digest, so a rotated credential still reports as changed and an untouched
114
+ * one reports nothing.
115
+ * @template T
116
+ * @param {T} state
117
+ * @param {import('./src/snapshot-store.js').SnapshotStore} store
118
+ * @returns {T}
113
119
  */
114
- function snapshotIdsWithHistory() {
115
- /** @type {Set<string>} */
116
- const ids = new Set();
117
- if (!existsSync(SNAPSHOT_DIR)) return ids;
118
- for (const name of readdirSync(SNAPSHOT_DIR)) {
119
- const match = /^([a-f0-9]{16})\.\d+\.json$/.exec(name);
120
- if (match) ids.add(match[1]);
121
- }
122
- return ids;
123
- }
124
-
125
- function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
126
- if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
127
-
128
- const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
129
- writeFileSync(
130
- snapshotHistoryPathForFile(absPath),
131
- JSON.stringify({
132
- file: legacy.file ?? absPath,
133
- state: legacy.state ?? legacy,
134
- ...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
135
- createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
136
- }, null, 2),
137
- 'utf8',
138
- );
120
+ function alignStateWithStore(state, store) {
121
+ return store.maskMode === 'hash' ? /** @type {T} */ (maskState(state)) : state;
139
122
  }
140
123
 
141
- function readLocalSnapshotHistory() {
142
- if (!existsSync(SNAPSHOT_DIR)) return [];
143
-
144
- const entries = readdirSync(SNAPSHOT_DIR, { withFileTypes: true })
145
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
146
- const historyEntries = entries.filter((entry) => /^[a-f0-9]{16}\.\d+\.json$/.test(entry.name));
147
- const historyIds = new Set(historyEntries.map((entry) => entry.name.slice(0, 16)));
148
- const legacyEntries = entries.filter((entry) =>
149
- /^[a-f0-9]{16}\.json$/.test(entry.name) && !historyIds.has(entry.name.slice(0, 16)));
150
- const snapshotEntries = [...historyEntries, ...legacyEntries];
151
-
152
- return snapshotEntries.map((entry) => {
153
- const path = resolve(SNAPSHOT_DIR, entry.name);
154
- const snapshot = JSON.parse(readFileSync(path, 'utf8'));
155
- const state = restoreSnapshotDocumentKeys(snapshot?.state ?? snapshot, snapshot);
156
- if (typeof snapshot?.file !== 'string') {
157
- throw new Error(`Invalid snapshot file: ${path}`);
158
- }
159
- return {
160
- file: snapshot.file,
161
- state,
162
- createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
163
- };
124
+ /**
125
+ * Resolve the snapshot store a command should read and write (#141).
126
+ *
127
+ * Every snapshot consumer goes through this, so `--snapshot-store shared` in
128
+ * `.flectorc` means the same thing to `watch`, `ci`, `history`, and `report` —
129
+ * a store the editor of the config and the runner gating it disagree about
130
+ * would be worse than having only the local one.
131
+ * @param {Record<string, unknown>} effective
132
+ * @returns {import('./src/snapshot-store.js').SnapshotStore}
133
+ */
134
+ function snapshotStoreFromEffective(effective) {
135
+ return resolveSnapshotStore({
136
+ store: effective.snapshotStore,
137
+ dir: effective.snapshotDir,
138
+ mask: effective.snapshotMask,
139
+ retention: effective.snapshotRetention,
140
+ cwd: process.cwd(),
164
141
  });
165
142
  }
166
143
 
@@ -265,11 +242,6 @@ function maybeMaskChanges(events, maskSecrets) {
265
242
  }
266
243
 
267
244
  /**
268
- * Redact secret-shaped text from policy messages. A rule using
269
- * `messageTemplate` can interpolate `{before}` / `{after}`, so a finding can
270
- * carry a credential even when the change events beside it are masked. Replace
271
- * exact interpolated values using the same path-aware masking as change events,
272
- * then catch any other recognizable secret fragments in free-form messages.
273
245
  * @param {import('./src/policy.js').PolicyFinding[]} findings
274
246
  * @param {import('./src/differ.js').ChangeEvent[]} changes
275
247
  * @param {boolean} maskSecrets
@@ -277,17 +249,7 @@ function maybeMaskChanges(events, maskSecrets) {
277
249
  */
278
250
  function maybeMaskFindings(findings, changes, maskSecrets) {
279
251
  if (!maskSecrets) return findings;
280
- return findings.map((finding) => {
281
- let message = String(finding.message ?? '');
282
- for (const change of changes.filter((event) => event.path === finding.path)) {
283
- for (const value of [change.before, change.after]) {
284
- const original = String(value);
285
- const masked = String(maskSensitiveValue(value, secretMatchPath(change)));
286
- if (original && original !== masked) message = message.replaceAll(original, masked);
287
- }
288
- }
289
- return { ...finding, message: redactSecretString(message) };
290
- });
252
+ return maskFindings(findings, changes);
291
253
  }
292
254
 
293
255
  /**
@@ -347,6 +309,37 @@ function restoreSnapshotDocumentKeys(state, snapshot) {
347
309
  return withDocumentKeys(state, documents.map(String));
348
310
  }
349
311
 
312
+ /**
313
+ * Read a snapshot named as a path, with the containment every other read in
314
+ * Flecto already has.
315
+ *
316
+ * A baseline path is attacker-reachable in the shape that matters: the
317
+ * operator names an in-repo snapshot such as `.flecto/baseline.json`, and the
318
+ * pull request replaces that file with a symlink. Without this check the
319
+ * contents of any JSON file on the runner became the "baseline" and were
320
+ * printed as `removed` changes -- the same read-outside-the-repo leak that
321
+ * `assertTargetContained` was added for, and that `src/mcp.js` already refuses
322
+ * for a ref naming a file. This was the one remaining surface without the rule.
323
+ *
324
+ * `assertTargetContained` is about escape, not location, so an operator's
325
+ * deliberate absolute path to a snapshot outside the project still works.
326
+ * @param {string} given
327
+ * @param {string} option the flag to name in an error
328
+ * @returns {unknown}
329
+ */
330
+ function readSnapshotFile(given, option) {
331
+ if (given === '') {
332
+ // An unset CI variable expands to this. Everything else here refuses
333
+ // loudly; silently falling back to the local store would be the one path
334
+ // that quietly compares against something the operator did not choose.
335
+ throw new Error(`${option} was given an empty value`);
336
+ }
337
+ const resolved = resolve(given);
338
+ assertTargetContained(resolved, process.cwd());
339
+ if (!existsSync(resolved)) throw new Error(`no snapshot file at "${given}"`);
340
+ return readSnapshotStateFromFile(resolved);
341
+ }
342
+
350
343
  function readSnapshotStateFromFile(snapshotPath) {
351
344
  const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
352
345
  return restoreSnapshotDocumentKeys(snap?.state ?? snap, snap);
@@ -367,7 +360,41 @@ function gitRepoRelativePath(filePath) {
367
360
  const top = execFileSync('git', ['-C', dirname(filePath), 'rev-parse', '--show-toplevel'], {
368
361
  encoding: 'utf8',
369
362
  }).trim();
370
- return relative(canonicalPath(top), canonicalPath(filePath)).replaceAll('\\', '/');
363
+ // Canonicalize the *directory* and keep the name as written. Canonicalizing
364
+ // the file itself resolved its final symlink, so the path handed to
365
+ // `git show` was the link's destination rather than the path the operator
366
+ // gated -- and a pull request that replaced the gated file with a link to any
367
+ // other file unchanged in the baseline got `before == after`, a genuinely
368
+ // empty diff that no --fail-on value catches. Every reason the comment above
369
+ // gives for canonicalizing (Windows 8.3 and case, the macOS /tmp and /var
370
+ // links) is a property of directories, so none of it is lost.
371
+ const nominal = join(canonicalPath(dirname(filePath)), basename(filePath));
372
+ return relative(canonicalPath(top), nominal).replaceAll('\\', '/');
373
+ }
374
+
375
+ /**
376
+ * Is the entry at this path, in this commit, a symbolic link?
377
+ *
378
+ * Git stores a link as mode 120000 whose blob is the *target path*, so reading
379
+ * one as a config file yields the target string rather than any configuration.
380
+ * That is a baseline Flecto cannot honestly compare against, and saying so
381
+ * beats emitting a diff between a filename and a document.
382
+ * @param {string} commit
383
+ * @param {string} rel
384
+ * @param {string} dir
385
+ * @returns {boolean}
386
+ */
387
+ function baselineEntryIsSymlink(commit, rel, dir) {
388
+ try {
389
+ const entry = execFileSync(
390
+ 'git',
391
+ ['-C', dir, 'ls-tree', '--format=%(objectmode)', '--end-of-options', commit, '--', rel],
392
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
393
+ ).trim();
394
+ return entry === '120000';
395
+ } catch {
396
+ return false;
397
+ }
371
398
  }
372
399
 
373
400
  /**
@@ -402,29 +429,169 @@ function canonicalPath(path) {
402
429
  }
403
430
  }
404
431
 
405
- function readSnapshotStateFromRef(filePath, snapshotRef) {
432
+ function gitFailureReason(err) {
433
+ if (err?.code === 'ENOENT') return 'git is not installed or not on PATH';
434
+ if (err?.status === 128) return 'this is not a git repository';
435
+ // The most likely remaining cause by far: `--end-of-options` arrived in git
436
+ // 2.24 (2019), and an older git rejects it as an unknown option. Naming a
437
+ // version someone can check beats printing the raw spawn failure.
438
+ return 'git rejected the command -- Flecto needs git 2.24 or newer'
439
+ + ` (${String(err?.message ?? 'unknown error').split('\n')[0]})`;
440
+ }
441
+
442
+ /**
443
+ * Resolve a ref to the single commit it names, or null when it names none.
444
+ *
445
+ * `git show` accepts far more than a commit: `A..B` is a *range*, and asking
446
+ * it to show one succeeds while printing nothing. An empty read then parses as
447
+ * an empty document, every key looks `added` rather than `changed`, and the
448
+ * default `--fail-on changed,policy,error` never fires -- the same silent-pass
449
+ * chain as the `--output=` injection, reached without a single dash.
450
+ * `rev-parse --verify <ref>^{commit}` refuses anything that is not exactly one
451
+ * commit, which closes the whole shape rather than the one spelling of it.
452
+ *
453
+ * Resolving also means the value handed to `git show` afterwards is a hex SHA
454
+ * this function produced, not a string the attacker wrote.
455
+ * @param {string} ref
456
+ * @param {string} dir a directory inside the repository
457
+ * @returns {string | null} the commit SHA, or null when `ref` is not a revision
458
+ */
459
+ function resolveGitCommit(ref, dir) {
460
+ try {
461
+ const out = execFileSync(
462
+ 'git',
463
+ ['-C', dir, 'rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`],
464
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
465
+ ).trim();
466
+ return { usable: true, commit: out || null };
467
+ } catch (err) {
468
+ // Exactly one failure means "git works, that revision is not here": exit 1
469
+ // from `--verify --quiet`. Everything else -- git missing (ENOENT), not a
470
+ // repository (128), an option this git does not know (pre-2.24) -- means
471
+ // Flecto cannot tell whether the ref exists. Collapsing those into "not a
472
+ // revision" is what let a missing git turn a baseline into whatever file
473
+ // happened to share the ref's name.
474
+ if (err?.status === 1) return { usable: true, commit: null };
475
+ return { usable: false, commit: null, reason: gitFailureReason(err) };
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Is this value written the way a *path* is written, rather than a revision?
481
+ *
482
+ * `--snapshot-ref` has always accepted either a git revision or a snapshot
483
+ * file, and that ambiguity is the vulnerability: the file is resolved against
484
+ * the checkout root, whose file names an untrusted pull request controls. A
485
+ * pull request that commits a file named after the operator's ref replaces the
486
+ * baseline with one the attacker wrote, and since they can make it match their
487
+ * own tip exactly, the diff is genuinely empty -- no `--fail-on` setting
488
+ * catches it.
489
+ *
490
+ * The first attempt at this asked "does the value look like a revision?" and
491
+ * refused the file branch when it did. That is a denylist over a value space
492
+ * identical to the one it is trying to exclude: branch and tag names are
493
+ * ordinary words, so `origin/main`, `main`, and `v1.2.3` all fell through it
494
+ * and stayed exploitable -- and `origin/main` is the form this project's own
495
+ * documentation puts in a workflow.
496
+ *
497
+ * So the polarity is inverted. The *file* branch must prove itself, and the
498
+ * proof is a shape the git ref grammar cannot produce: an absolute path, or an
499
+ * explicit `./` or `../`. Everything else must resolve as a revision or the run
500
+ * fails, pointing at `--snapshot-file`. The failure mode is now "a snapshot
501
+ * file named like a ref is refused, and says which flag to use" rather than
502
+ * "an attacker's file is read as the baseline".
503
+ * @param {string} value
504
+ * @returns {boolean}
505
+ */
506
+ function isExplicitPath(value) {
507
+ // git-check-ref-format forbids a ref whose component begins with `.` and a
508
+ // ref beginning with `/`, so none of these can name a revision. An earlier
509
+ // version of this also accepted a `.json`/`.yaml` suffix, which was the same
510
+ // mistake a third time: those ARE legal in a ref name, so `release/v1.json`
511
+ // skipped git entirely and read an attacker's committed file in preference to
512
+ // a real, resolvable tag of that name. A suffix is a convention; these are a
513
+ // grammar.
514
+ return isAbsolute(value)
515
+ || value.startsWith('./')
516
+ || value.startsWith('../')
517
+ || value.startsWith('.\\')
518
+ || value.startsWith('..\\');
519
+ }
520
+
521
+ function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile) {
522
+ if (snapshotRef === '') {
523
+ // The same unset-CI-variable case readSnapshotFile refuses. Falling back to
524
+ // the store here would compare against something the operator did not
525
+ // choose -- and on an untrusted pull request a committed shared store is
526
+ // something the change under review wrote.
527
+ throw new Error('--snapshot-ref was given an empty value');
528
+ }
529
+ // `--snapshot-file` is the unambiguous form: a path, never a revision. It
530
+ // exists because overloading one flag with both is what made an
531
+ // attacker-committed file able to stand in for the operator's baseline.
532
+ if (snapshotFile !== undefined) {
533
+ return readSnapshotFile(String(snapshotFile), '--snapshot-file');
534
+ }
406
535
  if (!snapshotRef) {
407
- const snapshotPath = snapshotPathForFile(filePath);
408
536
  // Failing closed here is right — a diff with no baseline is not a clean
409
- // diff — but an ENOENT on a hashed filename explains nothing. Snapshot
410
- // history is local to the working directory, so this is what an ephemeral
411
- // CI runner hits on every run (#141).
412
- if (!existsSync(snapshotPath)) {
413
- throw new Error(
414
- `no local snapshot has been saved for this file (${SNAPSHOT_DIR}/ holds none).`
415
- + ' Save one with "flecto watch <file> --snapshot", or pass --snapshot-ref'
416
- + ' <git-ref> to diff against a committed revision instead',
417
- );
537
+ // diff — but an ENOENT on a hashed filename explains nothing. The default
538
+ // store is local to the working directory, so this is what an ephemeral CI
539
+ // runner hits on every run; the message names the store it looked in and
540
+ // the two ways to give it one (#141).
541
+ const record = store.readLatest(filePath);
542
+ if (!record) {
543
+ throw new Error(`no snapshot has been saved for this file (${store.emptyHint})`);
418
544
  }
419
- return readSnapshotStateFromFile(snapshotPath);
545
+ return record.state;
420
546
  }
421
- const maybePath = resolve(snapshotRef);
422
- if (existsSync(maybePath)) {
423
- return readSnapshotStateFromFile(maybePath);
547
+ // The path decision comes before assertSafeGitRef, which refuses `..` -- a
548
+ // legitimate `../snapshots/base.json` is a path, not a commit range.
549
+ if (isExplicitPath(String(snapshotRef))) {
550
+ return readSnapshotFile(String(snapshotRef), '--snapshot-ref');
551
+ }
552
+
553
+ const ref = assertSafeGitRef(snapshotRef);
554
+ const repoDir = dirname(resolve(filePath));
555
+ const { usable, commit, reason } = resolveGitCommit(ref, repoDir);
556
+ if (!usable) {
557
+ // Never fall through to a file here. Not knowing whether the revision
558
+ // exists is exactly when reading a same-named file is most dangerous.
559
+ throw new Error(
560
+ `cannot resolve "${ref}" as a git revision: ${reason}.`
561
+ + ' Pass --snapshot-file <path> if you meant a snapshot file.',
562
+ );
563
+ }
564
+ if (!commit) {
565
+ const shadow = resolve(ref);
566
+ throw new Error(
567
+ `"${ref}" does not resolve to a git revision here`
568
+ + ' (a shallow clone is the usual reason -- on a pull request, actions/checkout does not'
569
+ + ' create origin/<base> unless you fetch it; try fetch-depth: 0).'
570
+ + (existsSync(shadow)
571
+ ? ` A file named "${ref}" exists and was NOT read as the baseline: a pull request can`
572
+ + ' commit such a file, and reading it would let the change under review choose what it'
573
+ + ' is compared against. Pass --snapshot-file if you genuinely meant that file.'
574
+ : ' Pass --snapshot-file <path> if you meant a snapshot file.'),
575
+ );
424
576
  }
425
577
 
426
578
  const rel = gitRepoRelativePath(filePath);
427
- const raw = execFileSync('git', ['show', `${snapshotRef}:${rel}`], { encoding: 'utf8' });
579
+ if (baselineEntryIsSymlink(commit, rel, repoDir)) {
580
+ throw new Error(
581
+ `"${rel}" is a symbolic link in ${ref}, so the baseline there is a path, not a configuration.`
582
+ + ' Point --snapshot-ref at a revision where it is a regular file, or gate the file the link'
583
+ + ' resolves to.',
584
+ );
585
+ }
586
+ // `-C repoDir` matches where the revision was resolved, and the operand is a
587
+ // SHA this process produced rather than any string the attacker wrote.
588
+ // maxBuffer matches the LSP's reader: a megabyte-scale baseline blob is
589
+ // ordinary, and the default 1 MB dies with a raw ENOBUFS.
590
+ const raw = execFileSync(
591
+ 'git',
592
+ ['-C', repoDir, 'show', '--end-of-options', `${commit}:${rel}`],
593
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 },
594
+ );
428
595
  return parseContent(filePath, raw);
429
596
  }
430
597
 
@@ -651,6 +818,50 @@ async function deliverPrCommentSafely(body, enabled, provider) {
651
818
  }
652
819
  }
653
820
 
821
+ /**
822
+ * `ci --explain` (#143): narration for the run, or null.
823
+ *
824
+ * Called only after the gate is decided, and it cannot change it: every failure
825
+ * here — no provider configured, over budget, network, refusal — is a warning
826
+ * and a null, never a thrown error. Status goes to stderr, so the machine
827
+ * output on stdout is byte-for-byte what it is without the flag.
828
+ * @param {import('./src/explain.js').ExplainInputFile[]} files unmasked; the payload builder masks
829
+ * @param {{ dryRun: boolean, cwd: string }} options
830
+ * @returns {Promise<{ text: string, provider: string, model: string, cached: boolean, truncated: boolean } | null>}
831
+ */
832
+ async function narrateForCi(files, { dryRun, cwd }) {
833
+ try {
834
+ const payload = buildExplainPayload(files, { cwd });
835
+ if (payload.files.length === 0) {
836
+ renderNote('flecto explain: nothing to narrate — no changes and no findings.');
837
+ return null;
838
+ }
839
+ const resolved = resolveExplainConfig({}, process.env, { dryRun });
840
+ if (!resolved.ok) {
841
+ renderWarn(`No narration: ${resolved.reason}.`);
842
+ return null;
843
+ }
844
+ if (dryRun) {
845
+ const request = describeRequest(buildExplainRequest(payload, resolved.config), resolved.config);
846
+ renderNote(
847
+ `flecto explain --explain-dry-run: nothing was sent. ~${estimateInputTokens(request.body)} input`
848
+ + ` tokens (estimated), output capped at ${resolved.config.maxTokens}. The request:`,
849
+ );
850
+ process.stderr.write(`${JSON.stringify(request, null, 2)}\n`);
851
+ return null;
852
+ }
853
+ const result = await narrate(payload, resolved.config, { onNote: renderNote });
854
+ if (!result.ok) {
855
+ renderWarn(`No narration: ${result.reason}.`);
856
+ return null;
857
+ }
858
+ return result;
859
+ } catch (err) {
860
+ renderWarn(`No narration: ${err.message}.`);
861
+ return null;
862
+ }
863
+ }
864
+
654
865
  program
655
866
  .name('flecto')
656
867
  .description('Flecto — semantic config watcher for meaningful structured file changes')
@@ -684,6 +895,10 @@ program
684
895
  .option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
685
896
  .option('--snapshot', 'Save current state as baseline instead of watching')
686
897
  .option('--diff', 'Diff current file against saved baseline and exit')
898
+ .option('--snapshot-store <id>', `Snapshot store: ${SNAPSHOT_STORE_IDS.join(' | ')} (shared is repo-relative and meant to be committed)`)
899
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
900
+ .option('--snapshot-mask <mode>', `How the store records secret-like values: ${SNAPSHOT_MASK_MODES.join(' | ')} (default: hash for shared, none for local)`)
901
+ .option('--snapshot-retention <n>', 'Snapshots kept per file, 0 keeps every one (default: 20 for shared, unlimited for local)')
687
902
  .option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
688
903
  .action(async (files, opts, command) => {
689
904
  try {
@@ -691,6 +906,7 @@ program
691
906
  const profile = resolveProfileName(opts.profile);
692
907
  const cliOverrides = stripUnsetCliOverrides(opts, command);
693
908
  const effective = resolveEffectiveOptions(config, profile, cliOverrides);
909
+ assertAlertActionsFromCli(effective, cliOverrides);
694
910
  const { policies, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
695
911
  const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
696
912
  if (targets.length === 0) {
@@ -707,16 +923,18 @@ program
707
923
  const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
708
924
  const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
709
925
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
926
+ const snapshotStore = snapshotStoreFromEffective(effective);
710
927
 
711
928
  if (effective.snapshot) {
712
- mkdirSync(SNAPSHOT_DIR, { recursive: true });
713
- // Snapshots carry config values, so a .flecto-snapshots/ that is itself a
929
+ mkdirSync(snapshotStore.root, { recursive: true });
930
+ // Snapshots carry config values, so a store directory that is itself a
714
931
  // link out of the project would write them somewhere the repository does
715
932
  // not control. Same rule as a target, checked after mkdir so an existing
716
933
  // link is seen rather than a path that does not exist yet.
717
- assertTargetContained(resolve(SNAPSHOT_DIR), process.cwd());
718
- const idsWithHistory = snapshotIdsWithHistory();
934
+ assertTargetContained(snapshotStore.root, process.cwd());
719
935
  let written = 0;
936
+ let pruned = 0;
937
+ const warnings = new Set();
720
938
  for (const filepath of targets) {
721
939
  if (!existsSync(filepath)) {
722
940
  renderWarn(`Skipping missing file: ${filepath}`);
@@ -727,25 +945,32 @@ program
727
945
  continue;
728
946
  }
729
947
  const state = parseFile(filepath);
730
- const snapshotPath = snapshotPathForFile(filepath);
731
- preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
732
948
  // Only a multi-document file records `documents`, so an ordinary
733
949
  // snapshot is byte-for-byte what it was before this field existed.
734
- const documents = documentKeysOf(state) ?? [];
735
- const snapshot = {
736
- file: filepath,
950
+ const result = snapshotStore.write(filepath, {
737
951
  state,
738
- ...(documents.length > 0 ? { documents: [...documents] } : {}),
739
- createdAt: new Date().toISOString(),
740
- };
741
- writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
742
- writeFileSync(snapshotHistoryPathForFile(filepath), JSON.stringify(snapshot, null, 2), 'utf8');
743
- // Keep the set in step with what this run has written, so a repeated
744
- // target behaves exactly as it did when the check hit the disk.
745
- idsWithHistory.add(snapshotIdForPath(filepath));
746
- console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
952
+ documents: documentKeysOf(state) ?? [],
953
+ });
954
+ if (result.warning) warnings.add(result.warning);
955
+ pruned += result.pruned;
956
+ console.log(chalk.green(`✓ Snapshot saved: ${result.path}`));
747
957
  written += 1;
748
958
  }
959
+ for (const warning of warnings) renderWarn(warning);
960
+ if (pruned > 0) {
961
+ renderNote(
962
+ `Pruned ${pruned} snapshot${pruned === 1 ? '' : 's'} beyond the`
963
+ + ` ${snapshotStore.retention}-entry retention of the ${snapshotStore.id} store.`,
964
+ );
965
+ }
966
+ if (written > 0 && snapshotStore.id === 'shared') {
967
+ renderNote(
968
+ `Shared store: commit ${snapshotStore.label} so every runner reads the same history.`
969
+ + (snapshotStore.maskMode === 'none'
970
+ ? ' Masking is off, so these files carry config values verbatim into git history.'
971
+ : ' Secret-like values are stored as digests, not plaintext.'),
972
+ );
973
+ }
749
974
  if (written === 0 && !effective.allowEmpty) {
750
975
  throw new Error(
751
976
  'No snapshots written — all targets were missing or unsupported.' +
@@ -760,14 +985,14 @@ program
760
985
  let compared = 0;
761
986
  let missing = 0;
762
987
  for (const filepath of targets) {
763
- const snapshotPath = snapshotPathForFile(filepath);
764
- if (!existsSync(snapshotPath)) {
765
- renderWarn(`No snapshot found for "${filepath}"`);
988
+ const record = snapshotStore.readLatest(filepath);
989
+ if (!record) {
990
+ renderWarn(`No snapshot found for "${filepath}" in ${snapshotStore.label}`);
766
991
  missing += 1;
767
992
  continue;
768
993
  }
769
- const before = readSnapshotStateFromFile(snapshotPath);
770
- const after = parseFile(filepath);
994
+ const before = record.state;
995
+ const after = alignStateWithStore(parseFile(filepath), snapshotStore);
771
996
  const events = diffTrees(before, after, dOpts);
772
997
  renderDiff(filepath, events, { maskSecrets });
773
998
  compared += 1;
@@ -779,7 +1004,7 @@ program
779
1004
  // is the normal state of a fresh CI runner.
780
1005
  if (compared === 0) {
781
1006
  throw new Error(
782
- 'No snapshot found for any target, so nothing was compared.'
1007
+ `No snapshot found for any target in ${snapshotStore.label}, so nothing was compared.`
783
1008
  + ' Run "flecto watch <file> --snapshot" first — no history is not no drift.',
784
1009
  );
785
1010
  }
@@ -897,8 +1122,10 @@ program
897
1122
 
898
1123
  program
899
1124
  .command('history [files...]')
900
- .description('Summarize drift across local snapshots')
1125
+ .description('Summarize drift across saved snapshots')
901
1126
  .option('-l, --limit <n>', 'Number of recent snapshots to show', '10')
1127
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
1128
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
902
1129
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
903
1130
  .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
904
1131
  .option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
@@ -917,7 +1144,8 @@ program
917
1144
  const ignorePaths = parseCsv(effective.ignore);
918
1145
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
919
1146
 
920
- const allSnapshots = readLocalSnapshotHistory();
1147
+ const snapshotStore = snapshotStoreFromEffective(effective);
1148
+ const allSnapshots = snapshotStore.readHistory();
921
1149
  let snapshots = allSnapshots;
922
1150
  if (files.length > 0) {
923
1151
  const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
@@ -928,13 +1156,20 @@ program
928
1156
  if (summaries.length === 0) {
929
1157
  if (files.length > 0 && allSnapshots.length > 0) {
930
1158
  throw new Error(
931
- 'No local snapshots matched the given files. Omit files to view all saved snapshot history.',
1159
+ `No snapshots in ${snapshotStore.label} matched the given files.`
1160
+ + ' Omit files to view all saved snapshot history.',
932
1161
  );
933
1162
  }
934
- throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
1163
+ throw new Error(
1164
+ `No snapshots found in ${snapshotStore.label}.`
1165
+ + ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
1166
+ + ' — no history is not no drift.',
1167
+ );
935
1168
  }
936
1169
 
937
- console.log(`Local snapshot history (${summaries.length} snapshots)`);
1170
+ console.log(
1171
+ `Snapshot history from ${snapshotStore.label} (${summaries.length} snapshots, ${snapshotStore.id} store)`,
1172
+ );
938
1173
  let baselines = 0;
939
1174
  for (const snapshot of summaries) {
940
1175
  const file = relative(process.cwd(), snapshot.file) || snapshot.file;
@@ -965,8 +1200,10 @@ program
965
1200
 
966
1201
  program
967
1202
  .command('report [files...]')
968
- .description('Render local snapshot history as a self-contained HTML report')
1203
+ .description('Render saved snapshot history as a self-contained HTML report')
969
1204
  .option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
1205
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
1206
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
970
1207
  .option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
971
1208
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
972
1209
  .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
@@ -992,10 +1229,15 @@ program
992
1229
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
993
1230
  const maskSecrets = Boolean(effective.maskSecrets);
994
1231
  const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
1232
+ assertWriteDestinationContained(outputPath, {
1233
+ option: '--output',
1234
+ fromCli: cliOverrides.output !== undefined,
1235
+ });
995
1236
 
996
1237
  // Same snapshot source, filtering, and errors as `flecto history` — this
997
1238
  // command only changes how that history is rendered.
998
- const allSnapshots = readLocalSnapshotHistory();
1239
+ const snapshotStore = snapshotStoreFromEffective(effective);
1240
+ const allSnapshots = snapshotStore.readHistory();
999
1241
  let snapshots = allSnapshots;
1000
1242
  if (files.length > 0) {
1001
1243
  const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
@@ -1006,10 +1248,15 @@ program
1006
1248
  if (summaries.length === 0) {
1007
1249
  if (files.length > 0 && allSnapshots.length > 0) {
1008
1250
  throw new Error(
1009
- 'No local snapshots matched the given files. Omit files to report on all saved snapshot history.',
1251
+ `No snapshots in ${snapshotStore.label} matched the given files.`
1252
+ + ' Omit files to report on all saved snapshot history.',
1010
1253
  );
1011
1254
  }
1012
- throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
1255
+ throw new Error(
1256
+ `No snapshots found in ${snapshotStore.label}.`
1257
+ + ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
1258
+ + ' — no history is not no drift.',
1259
+ );
1013
1260
  }
1014
1261
 
1015
1262
  const reportSnapshots = [];
@@ -1043,6 +1290,7 @@ program
1043
1290
  version: PKG.version,
1044
1291
  limit,
1045
1292
  maskSecrets,
1293
+ store: snapshotStore.label,
1046
1294
  });
1047
1295
  mkdirSync(dirname(outputPath), { recursive: true });
1048
1296
  writeFileSync(outputPath, html, 'utf8');
@@ -1059,7 +1307,10 @@ program
1059
1307
  .command('ci [files...]')
1060
1308
  .description('Run semantic diff in CI mode')
1061
1309
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
1062
- .option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
1310
+ .option('--snapshot-ref <ref>', 'Baseline git revision (a snapshot path also works if it is path-shaped)')
1311
+ .option('--snapshot-file <path>', 'Baseline snapshot file, never consulted as a git revision')
1312
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
1313
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
1063
1314
  .option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
1064
1315
  .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
1065
1316
  .option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
@@ -1076,12 +1327,18 @@ program
1076
1327
  .option('--show-suppressed', 'List inline-suppressed findings instead of only counting them', false)
1077
1328
  .option('--changed-only', 'With --format json|ndjson, replace envelopes for unchanged files with one scanned manifest', false)
1078
1329
  .option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
1330
+ .option('--explain', 'Add model-generated narration of the masked diff (advisory; provider set by FLECTO_EXPLAIN_* env)', false)
1331
+ .option('--explain-dry-run', 'Print the narration request that --explain would send, and send nothing', false)
1079
1332
  .action(async (files, opts, command) => {
1080
1333
  try {
1081
1334
  const { config } = loadRcConfig(process.cwd());
1082
1335
  const profile = resolveProfileName(opts.profile);
1083
1336
  const cliOverrides = stripUnsetCliOverrides(opts, command);
1084
1337
  const effective = resolveEffectiveOptions(config, profile, cliOverrides);
1338
+ assertExplainNotFromRc(effective, cliOverrides);
1339
+ assertSnapshotRefFromCli(effective, cliOverrides);
1340
+ const explainDryRun = Boolean(cliOverrides.explainDryRun);
1341
+ const explainRequested = Boolean(cliOverrides.explain) || explainDryRun;
1085
1342
  const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
1086
1343
  const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
1087
1344
  if (targets.length === 0) {
@@ -1112,14 +1369,37 @@ program
1112
1369
  }
1113
1370
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
1114
1371
 
1372
+ const snapshotStore = snapshotStoreFromEffective(effective);
1373
+
1115
1374
  const cwd = process.cwd();
1116
1375
  const baselinePath = effective.baseline ? resolve(cwd, String(effective.baseline)) : null;
1376
+ if (baselinePath) {
1377
+ assertWriteDestinationContained(baselinePath, {
1378
+ option: '--baseline',
1379
+ fromCli: cliOverrides.baseline !== undefined,
1380
+ cwd,
1381
+ });
1382
+ }
1383
+ // `--update-baseline` accepts every finding this run produced, so honoring
1384
+ // it from `.flectorc` would let a pull request turn its own failing gate
1385
+ // green — including one whose `--fail-on` was set explicitly on the command
1386
+ // line. It is an action, not a setting: the CLI is the only place it can
1387
+ // come from, and a declaration in the rc file is refused rather than
1388
+ // ignored, so a repository that meant it finds out.
1389
+ if (effective.updateBaseline && cliOverrides.updateBaseline === undefined) {
1390
+ throw new Error(
1391
+ 'updateBaseline is declared in .flectorc, and it is refused there: it accepts every'
1392
+ + ' current finding, which would turn a failing gate green from a file a pull request'
1393
+ + ' can edit. Pass --update-baseline on the command line when you mean to record a'
1394
+ + ' baseline.',
1395
+ );
1396
+ }
1117
1397
  const updateBaseline = Boolean(effective.updateBaseline);
1118
1398
  if (updateBaseline && !baselinePath) {
1119
1399
  throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
1120
1400
  }
1121
1401
 
1122
- /** @type {Array<{ filepath: string, relFile: string, outboundChanges: any[], outboundFindings: any[], changesFail: boolean }>} */
1402
+ /** @type {Array<{ filepath: string, relFile: string, events: any[], outboundChanges: any[], outboundFindings: any[], changesFail: boolean }>} */
1123
1403
  const perFile = [];
1124
1404
  /** @type {Array<{ file: string, finding: any, reason: string }>} */
1125
1405
  const allSuppressed = [];
@@ -1134,10 +1414,14 @@ program
1134
1414
  renderWarn(`Skipping unsupported file: ${filepath}`);
1135
1415
  continue;
1136
1416
  }
1137
- const after = parseFile(filepath);
1417
+ // A `--snapshot-ref` baseline is read straight from git, so it is never
1418
+ // masked; only a store-provided baseline needs the live side aligned.
1419
+ const after = (effective.snapshotRef || effective.snapshotFile)
1420
+ ? parseFile(filepath)
1421
+ : alignStateWithStore(parseFile(filepath), snapshotStore);
1138
1422
  let before;
1139
1423
  try {
1140
- before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
1424
+ before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore, effective.snapshotFile);
1141
1425
  } catch (err) {
1142
1426
  throw new Error(
1143
1427
  `Failed to resolve snapshot baseline for "${filepath}"` +
@@ -1167,6 +1451,7 @@ program
1167
1451
  perFile.push({
1168
1452
  filepath,
1169
1453
  relFile: baselineRelativePath(filepath, cwd),
1454
+ events,
1170
1455
  outboundChanges: maybeMaskChanges(events, maskSecrets),
1171
1456
  outboundFindings: maybeMaskFindings(policyFindings, events, maskSecrets),
1172
1457
  changesFail: shouldFailFromChanges(events, failOn),
@@ -1268,13 +1553,24 @@ program
1268
1553
  }
1269
1554
  }
1270
1555
 
1556
+ // Asked for only once `shouldFail` is settled, and handed nothing that
1557
+ // could change it. Narration covers the findings the gate sees — active
1558
+ // ones, after baseline and suppressions.
1559
+ const narration = explainRequested
1560
+ ? await narrateForCi(
1561
+ perFile.map((f) => ({ file: f.filepath, changes: f.events, findings: activeByFile.get(f.relFile) ?? [] })),
1562
+ { dryRun: explainDryRun, cwd },
1563
+ )
1564
+ : null;
1565
+
1271
1566
  if (format === 'pr-comment') {
1272
- const body = renderPrComment(results, { cwd, failed: shouldFail });
1567
+ const body = renderPrComment(results, { cwd, failed: shouldFail, narration });
1273
1568
  await writeStdout(body);
1274
1569
  await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
1275
1570
  } else {
1276
1571
  const collapsible = changedOnly && (format === 'json' || format === 'ndjson');
1277
1572
  await printCiOutput(collapsible ? collapseUnchangedResults(results) : results, format);
1573
+ if (narration) process.stderr.write(`\n${formatNarration(narration)}\n`);
1278
1574
  }
1279
1575
  process.exit(shouldFail ? 1 : 0);
1280
1576
  } catch (err) {
@@ -1283,6 +1579,161 @@ program
1283
1579
  }
1284
1580
  });
1285
1581
 
1582
+ program
1583
+ .command('explain [files...]')
1584
+ .description('Narrate the masked semantic diff with a model you configure — advisory, opt-in, bring your own key (#143)')
1585
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
1586
+ .option('--snapshot-ref <ref>', 'Baseline git revision (a snapshot path also works if it is path-shaped)')
1587
+ .option('--snapshot-file <path>', 'Baseline snapshot file, never consulted as a git revision')
1588
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
1589
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
1590
+ .option('--provider <id>', `Model provider: ${EXPLAIN_PROVIDERS.join(' | ')} (else FLECTO_EXPLAIN_PROVIDER)`)
1591
+ .option('--model <name>', 'Model id (else FLECTO_EXPLAIN_MODEL; anthropic defaults to claude-opus-5)')
1592
+ .option('--max-tokens <n>', 'Output token cap (else FLECTO_EXPLAIN_MAX_TOKENS; default 16000)')
1593
+ .option('--format <type>', 'Output format: human | json', 'human')
1594
+ .option('--dry-run', 'Print the exact request that would be sent, and send nothing', false)
1595
+ .option('--no-cache', 'Neither read nor write the narration cache')
1596
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore')
1597
+ .option('--policies <ids>', 'Comma-separated policy pack ids')
1598
+ .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
1599
+ .option('--array-id-key <key>', 'Diff arrays by this object identity key')
1600
+ .option('--no-array-id', 'Diff arrays by index instead of object identity')
1601
+ .option('--array-ignore-order', 'Treat array order as insignificant', false)
1602
+ .action(async (files, opts, command) => {
1603
+ try {
1604
+ const { config } = loadRcConfig(process.cwd());
1605
+ const profile = resolveProfileName(opts.profile);
1606
+ const cliOverrides = stripUnsetCliOverrides(opts, command);
1607
+ const effective = resolveEffectiveOptions(config, profile, cliOverrides);
1608
+ assertExplainNotFromRc(effective, cliOverrides);
1609
+ assertSnapshotRefFromCli(effective, cliOverrides);
1610
+ // Everything that shapes the request is read from the command line, never
1611
+ // from the merged options: `.flectorc` can name a `format` or `model` for
1612
+ // other commands, and none of it may steer where the diff goes.
1613
+ const format = String(cliOverrides.format ?? 'human');
1614
+ if (!['human', 'json'].includes(format)) {
1615
+ throw new Error('--format must be human or json');
1616
+ }
1617
+ const dryRun = Boolean(cliOverrides.dryRun);
1618
+ const snapshotRef = cliOverrides.snapshotRef;
1619
+ const snapshotFile = cliOverrides.snapshotFile;
1620
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
1621
+ const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
1622
+ if (targets.length === 0) {
1623
+ throw new Error('No files matched. Provide files or configure .flectorc files/include.');
1624
+ }
1625
+ const dOpts = diffOptionsFromEffective(effective, parseCsv(effective.ignore));
1626
+ const snapshotStore = snapshotStoreFromEffective(effective);
1627
+ const cwd = process.cwd();
1628
+
1629
+ /** @type {import('./src/explain.js').ExplainInputFile[]} */
1630
+ const diffed = [];
1631
+ for (const filepath of targets) {
1632
+ if (!existsSync(filepath)) {
1633
+ renderWarn(`Skipping missing file: ${filepath}`);
1634
+ continue;
1635
+ }
1636
+ if (!isSupported(filepath)) {
1637
+ renderWarn(`Skipping unsupported file: ${filepath}`);
1638
+ continue;
1639
+ }
1640
+ const after = (snapshotRef || snapshotFile)
1641
+ ? parseFile(filepath)
1642
+ : alignStateWithStore(parseFile(filepath), snapshotStore);
1643
+ let before;
1644
+ try {
1645
+ before = readSnapshotStateFromRef(filepath, snapshotRef, snapshotStore, snapshotFile);
1646
+ } catch (err) {
1647
+ throw new Error(
1648
+ `Failed to resolve snapshot baseline for "${filepath}"`
1649
+ + `${snapshotRef ? ` (ref: ${snapshotRef})` : ''}: ${err.message}`,
1650
+ );
1651
+ }
1652
+ const events = diffTrees(before, after, dOpts);
1653
+ const rawFindings = await evaluatePolicies(events, {
1654
+ cwd,
1655
+ file: filepath,
1656
+ profile: profile ?? null,
1657
+ source: 'diff',
1658
+ policies: packIds,
1659
+ plugins,
1660
+ severityRemap,
1661
+ });
1662
+ const { active } = resolveSuppressed(filepath, rawFindings);
1663
+ diffed.push({ file: filepath, changes: events, findings: active });
1664
+ }
1665
+ if (diffed.length === 0) {
1666
+ throw new Error('No files were diffed — all targets were missing or unsupported.');
1667
+ }
1668
+
1669
+ // The operator sees what is narrated, masked the way it is sent.
1670
+ if (format === 'human') {
1671
+ for (const { file, changes, findings } of diffed) {
1672
+ renderDiff(file, changes, { maskSecrets: true, baseline: snapshotRef ?? 'snapshot' });
1673
+ renderPolicyFindings(maskFindings(findings, changes));
1674
+ }
1675
+ }
1676
+
1677
+ const payload = buildExplainPayload(diffed, { cwd });
1678
+ if (payload.files.length === 0) {
1679
+ if (format === 'json') {
1680
+ await writeStdout(JSON.stringify({ advisory: true, narration: null, reason: 'no changes and no findings', payload }, null, 2));
1681
+ } else {
1682
+ renderInfo('Nothing to narrate: no semantic changes and no policy findings.');
1683
+ }
1684
+ process.exit(0);
1685
+ }
1686
+
1687
+ const resolved = resolveExplainConfig(
1688
+ {
1689
+ provider: cliOverrides.provider,
1690
+ model: cliOverrides.model,
1691
+ maxTokens: cliOverrides.maxTokens,
1692
+ cache: cliOverrides.cache,
1693
+ },
1694
+ process.env,
1695
+ { dryRun },
1696
+ );
1697
+ if (!resolved.ok) throw new Error(`Cannot narrate: ${resolved.reason}.`);
1698
+
1699
+ if (dryRun) {
1700
+ const request = describeRequest(buildExplainRequest(payload, resolved.config), resolved.config);
1701
+ renderNote(
1702
+ `flecto explain --dry-run: nothing was sent. ~${estimateInputTokens(request.body)} input tokens`
1703
+ + ` (estimated), output capped at ${resolved.config.maxTokens}.`,
1704
+ );
1705
+ await writeStdout(JSON.stringify(request, null, 2));
1706
+ process.exit(0);
1707
+ }
1708
+
1709
+ const result = await narrate(payload, resolved.config, { onNote: renderNote });
1710
+ if (format === 'json') {
1711
+ await writeStdout(JSON.stringify({
1712
+ advisory: true,
1713
+ narration: result.ok
1714
+ ? {
1715
+ model_generated: true,
1716
+ provider: result.provider,
1717
+ model: result.model,
1718
+ cached: result.cached,
1719
+ truncated: result.truncated,
1720
+ text: result.text,
1721
+ }
1722
+ : null,
1723
+ reason: result.ok ? undefined : result.reason,
1724
+ payload,
1725
+ }, null, 2));
1726
+ } else if (result.ok) {
1727
+ await writeStdout(`\n${formatNarration(result)}`);
1728
+ }
1729
+ if (!result.ok) renderWarn(`No narration: ${result.reason}.`);
1730
+ process.exit(0);
1731
+ } catch (err) {
1732
+ renderError(err.message);
1733
+ process.exit(1);
1734
+ }
1735
+ });
1736
+
1286
1737
  program
1287
1738
  .command('plan <planFiles...>')
1288
1739
  .description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
@@ -1583,6 +2034,75 @@ program
1583
2034
  renderInfo(`Policy packs: ${detection.packs.join(', ')}`);
1584
2035
  });
1585
2036
 
2037
+ program
2038
+ .command('mcp')
2039
+ .description('Run Flecto as a read-only Model Context Protocol server over stdio (#140)')
2040
+ .action(async () => {
2041
+ // The server writes JSON-RPC to stdout and nothing else, so diagnostics go
2042
+ // to stderr. Tools invoke the read-only `ci` path as a subprocess of this
2043
+ // same CLI; the seam is the JSON envelope, so this lifts cleanly into a
2044
+ // standalone flecto-mcp package later without touching the protocol code.
2045
+ const cliPath = fileURLToPath(import.meta.url);
2046
+ const runFlecto = makeCliRunner({ nodeExec: process.execPath, cliPath, cwd: process.cwd() });
2047
+ // Readiness goes to stderr — stdout must carry JSON-RPC and nothing else.
2048
+ renderNote('flecto mcp: read-only server ready (stdio). Secrets masked by default.');
2049
+ await runStdioServer({ version: PKG.version, cwd: process.cwd(), runFlecto });
2050
+ });
2051
+
2052
+ program
2053
+ .command('lsp')
2054
+ .description('Run a Language Server Protocol server over stdio: findings and changes as editor diagnostics (#142)')
2055
+ .option('--stdio', 'Talk LSP over stdin/stdout (the only transport; accepted because editors pass it)')
2056
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
2057
+ .option('--snapshot-ref <ref>', 'Git ref to diff open files against', 'HEAD')
2058
+ .option('--snapshot-store <id>', `Diff against a snapshot store instead of git: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
2059
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store')
2060
+ .option('--plugins <paths>', 'Comma-separated absolute paths of ESM policy plugins to load (never read from .flectorc)')
2061
+ .option('--changes <level>', 'How semantic changes appear: hint | info | none', 'hint')
2062
+ .option('--debounce <ms>', 'Wait this long after the last edit before analyzing', '250')
2063
+ .option('--timeout <ms>', 'Stop an analysis that runs longer than this', '10000')
2064
+ .action(async (opts) => {
2065
+ try {
2066
+ const changes = String(opts.changes);
2067
+ if (!['hint', 'info', 'none'].includes(changes)) {
2068
+ throw new Error('--changes must be hint, info, or none');
2069
+ }
2070
+ const debounceMs = Number(opts.debounce);
2071
+ const timeoutMs = Number(opts.timeout);
2072
+ if (!Number.isInteger(debounceMs) || debounceMs < 0) throw new Error('--debounce must be a whole number of milliseconds');
2073
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100) throw new Error('--timeout must be at least 100 milliseconds');
2074
+ const plugins = opts.plugins === undefined ? undefined : parseCsv(opts.plugins);
2075
+ // A relative path would resolve against whichever repository the editor
2076
+ // opens — including one that ships a file at exactly that path.
2077
+ for (const plugin of plugins ?? []) {
2078
+ if (!isAbsolute(plugin)) {
2079
+ throw new Error(`--plugins must be absolute paths in the language server (got "${plugin}"): a relative path would resolve inside whatever repository is open.`);
2080
+ }
2081
+ }
2082
+ renderNote('flecto lsp: ready (stdio). Plugins from .flectorc are never loaded here.');
2083
+ const { done } = startLanguageServer({
2084
+ input: process.stdin,
2085
+ output: process.stdout,
2086
+ version: PKG.version,
2087
+ cwd: process.cwd(),
2088
+ debounceMs,
2089
+ timeoutMs,
2090
+ settings: {
2091
+ profile: resolveProfileName(opts.profile),
2092
+ plugins,
2093
+ snapshotRef: opts.snapshotRef,
2094
+ snapshotStore: opts.snapshotStore,
2095
+ snapshotDir: opts.snapshotDir,
2096
+ changes: /** @type {'hint' | 'info' | 'none'} */ (changes),
2097
+ },
2098
+ });
2099
+ process.exit(await done);
2100
+ } catch (err) {
2101
+ renderError(err.message);
2102
+ process.exit(1);
2103
+ }
2104
+ });
2105
+
1586
2106
  program
1587
2107
  .command('doctor')
1588
2108
  .description('Check Flecto setup, config, and environment')