flecto 3.1.0 → 4.1.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
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { program } from 'commander';
4
4
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
5
- import { resolve, relative, dirname, join } from 'path';
5
+ import { resolve, relative, dirname, basename, join, isAbsolute } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execFileSync } from 'child_process';
8
8
  import chalk from 'chalk';
9
9
 
10
10
  import { parseFile, isSupported, parseContent } from './src/parser.js';
11
- import { diffTrees, secretMatchPath } from './src/differ.js';
11
+ import { diffTrees } from './src/differ.js';
12
12
  import { documentKeysOf, withDocumentKeys } from './src/documents.js';
13
13
  import { startWatcher } from './src/watcher.js';
14
14
  import {
@@ -20,7 +20,7 @@ import {
20
20
  renderWarn,
21
21
  renderPolicyFindings,
22
22
  maskChangeEvent,
23
- maskSensitiveValue,
23
+ maskFindings,
24
24
  } from './src/renderer.js';
25
25
  import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
26
26
  import { PR_PROVIDER_IDS } from './src/pr-providers.js';
@@ -30,10 +30,10 @@ import {
30
30
  readTerraformPlanFile,
31
31
  } from './src/terraform.js';
32
32
  import { renderReportHtml } from './src/report.js';
33
- import { redactSecretString } from './src/secrets.js';
34
33
  import { fireAlerts } from './src/alerter.js';
35
34
  import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
36
35
  import { createEnvelope } from './src/envelope.js';
36
+ import { startLanguageServer } from './src/lsp.js';
37
37
  import { buildSarif } from './src/sarif.js';
38
38
  import {
39
39
  maskState,
@@ -60,6 +60,18 @@ import {
60
60
  addPolicyPackFromPackage,
61
61
  } from './src/policy.js';
62
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';
63
75
  import {
64
76
  loadRcConfig,
65
77
  resolveEffectiveOptions,
@@ -69,6 +81,9 @@ import {
69
81
  resolvePolicyOptions,
70
82
  assertTargetContained,
71
83
  assertWriteDestinationContained,
84
+ assertAlertActionsFromCli,
85
+ assertSnapshotRefFromCli,
86
+ assertSafeGitRef,
72
87
  } from './src/config.js';
73
88
 
74
89
  const PKG = JSON.parse(
@@ -227,11 +242,6 @@ function maybeMaskChanges(events, maskSecrets) {
227
242
  }
228
243
 
229
244
  /**
230
- * Redact secret-shaped text from policy messages. A rule using
231
- * `messageTemplate` can interpolate `{before}` / `{after}`, so a finding can
232
- * carry a credential even when the change events beside it are masked. Replace
233
- * exact interpolated values using the same path-aware masking as change events,
234
- * then catch any other recognizable secret fragments in free-form messages.
235
245
  * @param {import('./src/policy.js').PolicyFinding[]} findings
236
246
  * @param {import('./src/differ.js').ChangeEvent[]} changes
237
247
  * @param {boolean} maskSecrets
@@ -239,17 +249,7 @@ function maybeMaskChanges(events, maskSecrets) {
239
249
  */
240
250
  function maybeMaskFindings(findings, changes, maskSecrets) {
241
251
  if (!maskSecrets) return findings;
242
- return findings.map((finding) => {
243
- let message = String(finding.message ?? '');
244
- for (const change of changes.filter((event) => event.path === finding.path)) {
245
- for (const value of [change.before, change.after]) {
246
- const original = String(value);
247
- const masked = String(maskSensitiveValue(value, secretMatchPath(change)));
248
- if (original && original !== masked) message = message.replaceAll(original, masked);
249
- }
250
- }
251
- return { ...finding, message: redactSecretString(message) };
252
- });
252
+ return maskFindings(findings, changes);
253
253
  }
254
254
 
255
255
  /**
@@ -309,6 +309,37 @@ function restoreSnapshotDocumentKeys(state, snapshot) {
309
309
  return withDocumentKeys(state, documents.map(String));
310
310
  }
311
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
+
312
343
  function readSnapshotStateFromFile(snapshotPath) {
313
344
  const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
314
345
  return restoreSnapshotDocumentKeys(snap?.state ?? snap, snap);
@@ -329,7 +360,41 @@ function gitRepoRelativePath(filePath) {
329
360
  const top = execFileSync('git', ['-C', dirname(filePath), 'rev-parse', '--show-toplevel'], {
330
361
  encoding: 'utf8',
331
362
  }).trim();
332
- 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
+ }
333
398
  }
334
399
 
335
400
  /**
@@ -364,7 +429,109 @@ function canonicalPath(path) {
364
429
  }
365
430
  }
366
431
 
367
- function readSnapshotStateFromRef(filePath, snapshotRef, store) {
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
+ }
368
535
  if (!snapshotRef) {
369
536
  // Failing closed here is right — a diff with no baseline is not a clean
370
537
  // diff — but an ENOENT on a hashed filename explains nothing. The default
@@ -377,13 +544,54 @@ function readSnapshotStateFromRef(filePath, snapshotRef, store) {
377
544
  }
378
545
  return record.state;
379
546
  }
380
- const maybePath = resolve(snapshotRef);
381
- if (existsSync(maybePath)) {
382
- 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
+ );
383
576
  }
384
577
 
385
578
  const rel = gitRepoRelativePath(filePath);
386
- 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
+ );
387
595
  return parseContent(filePath, raw);
388
596
  }
389
597
 
@@ -610,6 +818,50 @@ async function deliverPrCommentSafely(body, enabled, provider) {
610
818
  }
611
819
  }
612
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
+
613
865
  program
614
866
  .name('flecto')
615
867
  .description('Flecto — semantic config watcher for meaningful structured file changes')
@@ -654,6 +906,7 @@ program
654
906
  const profile = resolveProfileName(opts.profile);
655
907
  const cliOverrides = stripUnsetCliOverrides(opts, command);
656
908
  const effective = resolveEffectiveOptions(config, profile, cliOverrides);
909
+ assertAlertActionsFromCli(effective, cliOverrides);
657
910
  const { policies, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
658
911
  const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
659
912
  if (targets.length === 0) {
@@ -1054,7 +1307,8 @@ program
1054
1307
  .command('ci [files...]')
1055
1308
  .description('Run semantic diff in CI mode')
1056
1309
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
1057
- .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')
1058
1312
  .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
1059
1313
  .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
1060
1314
  .option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
@@ -1073,12 +1327,18 @@ program
1073
1327
  .option('--show-suppressed', 'List inline-suppressed findings instead of only counting them', false)
1074
1328
  .option('--changed-only', 'With --format json|ndjson, replace envelopes for unchanged files with one scanned manifest', false)
1075
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)
1076
1332
  .action(async (files, opts, command) => {
1077
1333
  try {
1078
1334
  const { config } = loadRcConfig(process.cwd());
1079
1335
  const profile = resolveProfileName(opts.profile);
1080
1336
  const cliOverrides = stripUnsetCliOverrides(opts, command);
1081
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;
1082
1342
  const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
1083
1343
  const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
1084
1344
  if (targets.length === 0) {
@@ -1139,7 +1399,7 @@ program
1139
1399
  throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
1140
1400
  }
1141
1401
 
1142
- /** @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 }>} */
1143
1403
  const perFile = [];
1144
1404
  /** @type {Array<{ file: string, finding: any, reason: string }>} */
1145
1405
  const allSuppressed = [];
@@ -1156,12 +1416,12 @@ program
1156
1416
  }
1157
1417
  // A `--snapshot-ref` baseline is read straight from git, so it is never
1158
1418
  // masked; only a store-provided baseline needs the live side aligned.
1159
- const after = effective.snapshotRef
1419
+ const after = (effective.snapshotRef || effective.snapshotFile)
1160
1420
  ? parseFile(filepath)
1161
1421
  : alignStateWithStore(parseFile(filepath), snapshotStore);
1162
1422
  let before;
1163
1423
  try {
1164
- before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore);
1424
+ before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore, effective.snapshotFile);
1165
1425
  } catch (err) {
1166
1426
  throw new Error(
1167
1427
  `Failed to resolve snapshot baseline for "${filepath}"` +
@@ -1191,6 +1451,7 @@ program
1191
1451
  perFile.push({
1192
1452
  filepath,
1193
1453
  relFile: baselineRelativePath(filepath, cwd),
1454
+ events,
1194
1455
  outboundChanges: maybeMaskChanges(events, maskSecrets),
1195
1456
  outboundFindings: maybeMaskFindings(policyFindings, events, maskSecrets),
1196
1457
  changesFail: shouldFailFromChanges(events, failOn),
@@ -1292,13 +1553,24 @@ program
1292
1553
  }
1293
1554
  }
1294
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
+
1295
1566
  if (format === 'pr-comment') {
1296
- const body = renderPrComment(results, { cwd, failed: shouldFail });
1567
+ const body = renderPrComment(results, { cwd, failed: shouldFail, narration });
1297
1568
  await writeStdout(body);
1298
1569
  await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
1299
1570
  } else {
1300
1571
  const collapsible = changedOnly && (format === 'json' || format === 'ndjson');
1301
1572
  await printCiOutput(collapsible ? collapseUnchangedResults(results) : results, format);
1573
+ if (narration) process.stderr.write(`\n${formatNarration(narration)}\n`);
1302
1574
  }
1303
1575
  process.exit(shouldFail ? 1 : 0);
1304
1576
  } catch (err) {
@@ -1307,6 +1579,161 @@ program
1307
1579
  }
1308
1580
  });
1309
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
+
1310
1737
  program
1311
1738
  .command('plan <planFiles...>')
1312
1739
  .description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
@@ -1607,6 +2034,75 @@ program
1607
2034
  renderInfo(`Policy packs: ${detection.packs.join(', ')}`);
1608
2035
  });
1609
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
+
1610
2106
  program
1611
2107
  .command('doctor')
1612
2108
  .description('Check Flecto setup, config, and environment')