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/CHANGELOG.md CHANGED
@@ -7,6 +7,261 @@ The format is based on [Keep a Changelog], and this project adheres to
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.1.0] - 2026-09-29
11
+
12
+ ### Added
13
+
14
+ - **A root [`action.yml`](action.yml), so the Action can be listed on the GitHub
15
+ Marketplace.** GitHub only lists an action whose metadata file sits at a public
16
+ repository's root; Flecto's Actions live in `.github/actions/`, which is why
17
+ they were never listable. The listed action is `flecto-pr-risk` — the pull
18
+ request risk comment — with branding and the wedge description.
19
+
20
+ `.github/actions/flecto-pr-risk/action.yml` **stays exactly where it is**, so
21
+ nothing referencing that path changes. The two files' `runs:` blocks are
22
+ byte-identical and a test enforces it, so a fix to one is a CI failure until it
23
+ lands in both.
24
+
25
+ Docs continue to reference
26
+ `myselfsiddharth/Flecto/.github/actions/flecto-pr-risk@v4.0.0` until a release
27
+ carrying the root file exists. The shorter `myselfsiddharth/Flecto@vX.Y.Z` form
28
+ becomes correct at that point; see [RELEASE.md](RELEASE.md) step 5.
29
+
30
+ - **`flecto-pr-risk` takes a Terraform plan directly.** A new `terraform-plan`
31
+ input points at `terraform show -json` output and switches the Action to
32
+ `flecto plan`, so reviewing a plan on every pull request is two steps:
33
+
34
+ ```yaml
35
+ - run: terraform plan -out=tf.plan && terraform show -json tf.plan > plan.json
36
+ - uses: myselfsiddharth/Flecto/.github/actions/flecto-pr-risk@v4.0.0
37
+ with:
38
+ terraform-plan: plan.json
39
+ fail-on: error
40
+ ```
41
+
42
+ Because a plan JSON carries its own before and after, **no baseline is resolved
43
+ and no git history is needed** in this mode — the `fetch-depth: 0` that config
44
+ mode wants does not apply, and the Action runs on events with no pull request
45
+ base commit. A missing plan file fails the step rather than letting Flecto
46
+ report nothing. `targets` is ignored; add a second step without
47
+ `terraform-plan` to also check config files.
48
+
49
+ Config mode is unchanged, including its fail-closed behaviour when no baseline
50
+ can be resolved. A complete workflow is in
51
+ [`examples/github-action/flecto-terraform-plan.yml`](examples/github-action/flecto-terraform-plan.yml).
52
+
53
+ - [docs/stability.md](docs/stability.md): what the public contract covers (the
54
+ `schema_version: "2.0"` envelope, exit codes, `.flectorc`, the CLI surface),
55
+ what it deliberately does not, and the deprecation sequence — one minor release
56
+ carrying a warning before any removal, with security fixes the stated
57
+ exception.
58
+
59
+ ### Fixed
60
+
61
+ - **The bundled GitHub Actions installed the pre-4.0 CLI.** `flecto-ci`
62
+ hardcoded `npx --yes flecto@3` and `flecto-pr-risk` defaulted
63
+ `flecto-version: "3"`, so both shipped Actions ran the 3.x line after 4.0.0
64
+ released. Two consequences: `flecto-ci`'s advertised `snapshot-file:` input
65
+ passed a flag that does not exist before 4.0, and its default
66
+ `snapshot-ref: HEAD~1` against a 3.x CLI is the baseline-shadowing bypass 4.0
67
+ closed — a pull request commits a file named `HEAD~1`, it is read instead of
68
+ the revision, the diff comes back empty and no `--fail-on` value catches it.
69
+
70
+ Both now default to `4`. `flecto-ci` gains a `flecto-version` input so the CLI
71
+ can be pinned without forking, matching `flecto-pr-risk`. A test asserts the
72
+ floor across both Actions, including hardcoded installs that would bypass the
73
+ input.
74
+
75
+ **If you copied an earlier README example you are affected**: the examples
76
+ referenced the Actions `@main`, which resolved to a 3.x install. Re-pin to
77
+ `@v4.0.0` — every example in the README and [docs/ci.md](docs/ci.md) now does,
78
+ with SHA pinning documented for security-sensitive users.
79
+
80
+ ## [4.0.0] - 2026-09-23
81
+
82
+ **A security release.** Every breaking change below exists because a pull
83
+ request could otherwise make `flecto ci` report a clean run on a change that was
84
+ not clean. If you run Flecto on untrusted pull requests, upgrading is not
85
+ optional.
86
+
87
+ See **[docs/migrating-to-4.md](docs/migrating-to-4.md)** for what to change, and
88
+ the [security advisories](https://github.com/myselfsiddharth/Flecto/security/advisories)
89
+ for what was wrong.
90
+
91
+ ### Breaking
92
+
93
+ - `snapshotRef` and `snapshotFile` declared in `.flectorc` are refused
94
+ (`FLECTO_ALLOW_RC_BASELINE=1` opts back in).
95
+ - `--snapshot-ref` takes a git revision. A bare snapshot filename needs
96
+ `--snapshot-file`, or a `./` prefix. The bundled `flecto-ci` Action gains a
97
+ `snapshot-file:` input.
98
+ - Policy-pack regular expressions outside `src/packs/` are compiled with RE2:
99
+ lookaround, backreferences, `\uXXXX` escapes, and `v`-flag set subtraction now
100
+ fail at load, and a few constructs match differently.
101
+ - `.flecto-queue/` is keyed by destination. A 3.x backlog is kept but not
102
+ auto-delivered.
103
+ - The `--command` spill file is deleted when the command exits, so a script must
104
+ read `FLECTO_CHANGES_FILE` while the command is still running.
105
+ - Flecto now requires git 2.24 or newer.
106
+
107
+ ### Added
108
+
109
+ - **`flecto explain` and `ci --explain`: opt-in, advisory narration of a diff by
110
+ a model you configure** ([#143]). `pool_size: 5 → 20` is mechanical; "this
111
+ quadruples connections per replica, so check `max_connections`" is judgment,
112
+ and it is what a reviewer wants at 2 AM. Bring your own key: `anthropic`
113
+ (Messages API, default model `claude-opus-5`) or `openai`, which covers any
114
+ OpenAI-compatible server, including a local one. Each is a single `fetch`, so
115
+ no vendor SDK and no new dependency.
116
+
117
+ The constraints are the feature. **Only the masked semantic diff is sent**,
118
+ masked unconditionally while the payload is built, never file contents, with
119
+ encrypted values as sentinels. `--dry-run` / `--explain-dry-run` print the exact
120
+ request and send nothing. **It never touches an exit code**: `ci` decides the
121
+ gate first, and every failure (no provider, over budget, timeout, HTTP error,
122
+ refusal) is a warning. `ci`'s stdout is byte-identical except under
123
+ `--format pr-comment`, where the narration goes in its own labeled section,
124
+ fenced so links, images, mentions, and HTML render as text. **The operator
125
+ configures it and the repository cannot**: `explain*` options in `.flectorc`
126
+ are refused, provider, endpoint, and key come only from the CLI and
127
+ `FLECTO_EXPLAIN_*` variables, redirects are refused rather than forwarding
128
+ `x-api-key`, and `FLECTO_EXPLAIN=0` is a runner-wide kill switch. **Cost is
129
+ stated before the call**, with estimated input tokens and the output cap.
130
+ Diffs over an input budget are skipped, not truncated. Identical requests are
131
+ served from a cache outside the repository, keyed with an HMAC of the API key
132
+ so a pull request cannot plant an entry. See [docs/explain.md](docs/explain.md).
133
+
134
+ - **`flecto mcp` — a read-only Model Context Protocol server over stdio**
135
+ ([#140]). An agent asked to debug a config incident no longer has to read a
136
+ two-thousand-line manifest into its context to learn that `pool_size` doubled;
137
+ Flecto already computes that small answer and now hands it over as a structured
138
+ tool result. Three read-only tools — `flecto_diff`, `flecto_check`, and
139
+ `flecto_explain` — each run the same `ci` path a pull request triggers and
140
+ return the JSON envelope.
141
+
142
+ The security posture is inherited from `ci` by construction: the tools can only
143
+ reach what `ci` reaches (no `--command`, no writes, no webhooks, no plugins —
144
+ the last enforced regardless of `FLECTO_ALLOW_RC_PLUGINS`), no tool argument is
145
+ ever parsed as a CLI option, tool-argument paths — including a `ref` that names
146
+ a snapshot file — are contained before anything spawns, and results are
147
+ bounded. **Secrets are masked by default here, inverted from the CLI**,
148
+ because the consumer is a model context that is transmitted and often logged;
149
+ `mask: false` is an explicit, documented opt-out. Adds no runtime dependency —
150
+ the stdio JSON-RPC framing is spoken directly. See [docs/mcp.md](docs/mcp.md).
151
+
152
+ - **`flecto lsp`: findings and semantic changes as editor diagnostics while a
153
+ config file is being edited** ([#142]). It's a Language Server Protocol server
154
+ over stdio, compared against `HEAD` by default (`--snapshot-ref`,
155
+ `--snapshot-store`), and it **agrees with the merge gate**: it uses the same
156
+ `.flectorc`, packs, `severityRemap`, scope (`files`/`include`/`exclude`), inline
157
+ suppressions, and `--baseline` file as `ci`. A suppression missing its reason
158
+ is the error CI fails on. The hard part the issue named, positions, is a new
159
+ module (`src/positions.js`) that maps a diff path back into the source text for
160
+ YAML (including anchors, merge keys, and multi-document manifests), JSON/JSONC,
161
+ dotenv, INI, and TOML. A position is used only where an independent scan of
162
+ the text agrees with the parsed tree. Anything else anchors at the nearest
163
+ verified ancestor, never at a guess. Across every fixture and example in the
164
+ repository, no exact position lands on the wrong key. Analyses run in a worker
165
+ thread, debounced, cancelled by a newer edit, and stopped at `--timeout`, so a
166
+ catastrophic pack regex costs one warning instead of a wedged server.
167
+ **Plugins declared in `.flectorc` are never loaded**, even with
168
+ `FLECTO_ALLOW_RC_PLUGINS=1`, since opening a repository in an editor is the
169
+ untrusted-PR threat model. `--plugins` must be absolute paths. See
170
+ [docs/editor.md](docs/editor.md).
171
+
172
+ ### Security
173
+
174
+ - **BREAKING: `snapshotRef` declared in `.flectorc` is refused** ([#121]). The
175
+ baseline decides what counts as a change, so a pull request that sets it
176
+ decides the verdict: a committed `{"defaults": {"snapshotRef": "HEAD"}}`
177
+ compared every file against the pull request's own tip and exited 0 on a
178
+ config that disabled TLS. Pass `--snapshot-ref` on the command line — the form
179
+ every example and the shipped Action already use — or set
180
+ `FLECTO_ALLOW_RC_BASELINE=1` if the rc file is trusted.
181
+ - **`--snapshot-file <path>` is added, and `--snapshot-ref` is a git revision**
182
+ ([#121]). Overloading one flag with both is what let an attacker-committed
183
+ file stand in for the operator's baseline. **This is breaking**: only a value
184
+ that is unambiguously a path — absolute, or starting `./` or `../`, shapes
185
+ git's ref format cannot produce — is still read as a file by
186
+ `--snapshot-ref`. A bare `--snapshot-ref snapshots/base.json` now fails and
187
+ says to use `--snapshot-file`. The bundled `flecto-ci` Action gains a
188
+ `snapshot-file:` input for the same reason.
189
+ When git is missing, too old, or not looking at a repository, Flecto refuses
190
+ rather than falling back to a file.
191
+ - **A baseline ref can no longer be crafted into a file write, a shadowed
192
+ baseline, or an empty diff** ([#121]). Three shapes, one property:
193
+ `--output=pwned` was read by git as an *option* and wrote a file while the
194
+ emptied read made every key look `added` so the default `--fail-on` never
195
+ fired; a committed file named after the operator's ref (`HEAD~1`, the shipped
196
+ Action's default) shadowed the baseline with one the attacker wrote; and a
197
+ commit range such as `HEAD:..` succeeded while printing nothing, for the same
198
+ silent pass. Refs now resolve through `git rev-parse --verify <ref>^{commit}`,
199
+ revision before file, and `git show` receives the resolved SHA.
200
+ - **BREAKING: pack-supplied regular expressions are compiled with RE2**
201
+ ([#121]). A policy pack is attacker input on an untrusted pull request --
202
+ `policies/*.json` is committed and `.flectorc` selects which packs run -- and
203
+ Node's engine backtracks, so `^(a+)+$` took **97 seconds** against a 44-character
204
+ value and grew exponentially. No in-process timeout could help: the
205
+ backtracking happens inside one uninterruptible call into the engine. Packs
206
+ outside `src/packs/` now use a linear-time engine (`re2js`, pure JS, no native
207
+ build), which answers the same pattern in 3 ms. The packs Flecto ships keep
208
+ the native engine. RE2 does not support lookaround, backreferences, `\uXXXX`
209
+ escapes, or `v`-flag set subtraction, so a pack using them now fails to load
210
+ with a message naming the rule; a few constructs also *match* differently, and
211
+ [docs/policy-packs.md](docs/policy-packs.md#regular-expressions-in-packs)
212
+ tables both sets.
213
+ - **A pack regex with the `g` flag no longer fires on alternate files.** Packs
214
+ are cached and shared across every file in a run, and a `g` regex carries a
215
+ mutable `lastIndex` that `.test()` advances, so such a rule matched every
216
+ other value it saw.
217
+ ### Added
218
+
219
+ - **`flecto-drift`: compare a declared config file against what is actually
220
+ running** ([#144]). A **separate binary**, deliberately: every other Flecto
221
+ command authenticates to nothing, and reading live state cannot keep that
222
+ promise, so it does not share an entry point with the tool that can. `flecto
223
+ ci` cannot reach it and installing Flecto does not enable it.
224
+ It holds **no credentials** — Kubernetes and SSM are read through `kubectl`
225
+ and `aws`, which you have already authenticated, so Flecto inherits exactly
226
+ what those are entitled to. Read-only is structural: argv is built from a
227
+ fixed verb allowlist and nothing from the URI can reach it as a flag. Values
228
+ from a secret store are compared **by shape** (length and digest), never by
229
+ value, with no flag to change that; SSM is read without `--with-decryption`.
230
+ Terraform state exposes only `outputs`. See [docs/drift.md](docs/drift.md).
231
+
232
+ ### Fixed
233
+
234
+ - **The shared snapshot store now refuses a Windows target on another drive or
235
+ a UNC share** ([#141], [#121]). The store keys a snapshot by its repo-relative
236
+ path and refuses a file outside the repository, but recognised "outside" only
237
+ as a `..`-prefixed path. On Windows, `path.relative` cannot reach another drive
238
+ or a share and returns the target absolute instead, which was accepted as a
239
+ key: a cross-drive write failed on a raw `ENOENT`, and a UNC one was written
240
+ under a meaningless `server/share/…` key. Neither left `.flecto/snapshots/`.
241
+ Both are now refused with the same message as any other outside target.
242
+
243
+ - **`watch --command`/`--webhook`/`--webhook-header` declared in `.flectorc`
244
+ are refused, not honored** ([#121]). All three merge through the ordinary
245
+ options path with no other gate, unlike
246
+ `--plugins`/`--output`/`--baseline`/`--update-baseline`, which were already
247
+ refused there. `command` spawns a shell command on every change;
248
+ `.flectorc` is attacker-controlled on an untrusted pull request, so a
249
+ `.flectorc` naming one got arbitrary shell execution on the next `flecto
250
+ watch` — no `--command` flag required. Confirmed end to end: a hostile
251
+ `.flectorc` alone, with nothing passed on the command line, ran a command that
252
+ wrote a marker file outside anything the run otherwise touched. `webhook` is
253
+ the same shape one step down — it sends the change payload to a URL the
254
+ pull request chose. `webhook-header` is reachable even when `webhook` itself
255
+ is the operator's own flag: an rc-declared header rides along on that
256
+ already-approved request and can override it. All three are refused with the
257
+ message the plugin and write guards already use, `FLECTO_ALLOW_RC_ALERTS=1`
258
+ opts out for a repository that configures one in `.flectorc` on purpose, and
259
+ any of the three named on the command line is untouched, because that is the
260
+ operator. `--delivery-mode`/`--on-alert-failure` are untouched either way —
261
+ they only tune failure handling for an alert the operator already chose, the
262
+ same "operator delegates a setting" shape `--fail-on` already has, and
263
+ `flecto init` writes both into the config it generates.
264
+
10
265
  ## [3.1.0] - 2026-09-15
11
266
 
12
267
  ### Added
@@ -1037,7 +1292,9 @@ fixed — those runs were never actually gated — but the failure is new.
1037
1292
  - Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
1038
1293
  continuing with no policies.
1039
1294
 
1040
- [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...HEAD
1295
+ [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.0...HEAD
1296
+ [4.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.0.0...v4.1.0
1297
+ [4.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...v4.0.0
1041
1298
  [3.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.2...v3.1.0
1042
1299
  [3.0.2]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.1...v3.0.2
1043
1300
  [3.0.1]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...v3.0.1
@@ -1125,8 +1382,12 @@ fixed — those runs were never actually gated — but the failure is new.
1125
1382
  [#159]: https://github.com/myselfsiddharth/Flecto/issues/159
1126
1383
  [#150]: https://github.com/myselfsiddharth/Flecto/issues/150
1127
1384
  [#125]: https://github.com/myselfsiddharth/Flecto/issues/125
1385
+ [#143]: https://github.com/myselfsiddharth/Flecto/issues/143
1128
1386
  [#141]: https://github.com/myselfsiddharth/Flecto/issues/141
1387
+ [#140]: https://github.com/myselfsiddharth/Flecto/issues/140
1129
1388
  [Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
1130
1389
  [#138]: https://github.com/myselfsiddharth/Flecto/issues/138
1131
1390
  [Semantic Versioning]: https://semver.org/spec/v2.0.0.html
1132
1391
  [GHSA-wq8m-fc3q-8m5x]: https://github.com/myselfsiddharth/Flecto/security/advisories/GHSA-wq8m-fc3q-8m5x
1392
+ [#142]: https://github.com/myselfsiddharth/Flecto/issues/142
1393
+ [#144]: https://github.com/myselfsiddharth/Flecto/issues/144
package/README.md CHANGED
@@ -63,7 +63,7 @@ flecto doctor
63
63
  ```
64
64
 
65
65
  Prefer not to install globally? Every example below works with
66
- `npx --yes flecto@3` instead of `flecto`.
66
+ `npx --yes flecto@4` instead of `flecto`.
67
67
 
68
68
  ---
69
69
 
@@ -173,7 +173,7 @@ steps:
173
173
  - uses: actions/checkout@v7
174
174
  with:
175
175
  fetch-depth: 2
176
- - uses: myselfsiddharth/Flecto/.github/actions/flecto-ci@main
176
+ - uses: myselfsiddharth/Flecto/.github/actions/flecto-ci@v4.0.0
177
177
  with:
178
178
  targets: config/**/*.{yaml,yml,json,toml,ini}
179
179
  snapshot-ref: HEAD~1
@@ -197,7 +197,7 @@ steps:
197
197
  - uses: actions/checkout@v7
198
198
  with:
199
199
  fetch-depth: 0
200
- - uses: myselfsiddharth/Flecto/.github/actions/flecto-pr-risk@main
200
+ - uses: myselfsiddharth/Flecto/.github/actions/flecto-pr-risk@v4.0.0
201
201
  ```
202
202
 
203
203
  GitLab and Bitbucket work the same way — Flecto detects the host from CI
@@ -501,6 +501,7 @@ Explicit CLI flags win over profiles, which win over `defaults`.
501
501
  | `flecto watch --snapshot` | Save the current state as a baseline |
502
502
  | `flecto watch --diff` | Compare against the baseline and exit |
503
503
  | `flecto ci [files...]` | One-shot check with a gate-able exit code |
504
+ | `flecto explain [files...]` | Advisory, model-generated narration of the masked diff (opt-in, your own key) |
504
505
  | `flecto compare <fileA> <fileB>` | Diff two files against each other (`fileA` is the baseline) |
505
506
  | `flecto plan <planFiles...>` | Review `terraform show -json` output and gate on it |
506
507
  | `flecto history [files...]` | Summarize drift across local snapshots |
@@ -509,6 +510,8 @@ Explicit CLI flags win over profiles, which win over `defaults`.
509
510
  | `flecto policies list` | List available policy packs |
510
511
  | `flecto policies test <dir>` | Assert pack and plugin findings from fixtures |
511
512
  | `flecto init` | Create a `.flectorc` from detected stack signals |
513
+ | `flecto mcp` | Serve read-only diff/check/explain tools to an agent over MCP |
514
+ | `flecto lsp` | Show findings and changes as diagnostics while a config file is edited |
512
515
  | `flecto doctor` | Check setup, config, and environment |
513
516
 
514
517
  → **[Every flag, every command](docs/cli-reference.md)**
@@ -521,15 +524,21 @@ Explicit CLI flags win over profiles, which win over `defaults`.
521
524
  |---|---|
522
525
  | **[CLI reference](docs/cli-reference.md)** | Every command, flag, and exit code |
523
526
  | **[Configuration](docs/configuration.md)** | `.flectorc`, profiles, ignore patterns, array identity, masking |
527
+ | **[Editor diagnostics](docs/editor.md)** | `flecto lsp` setup for Neovim, Helix, Emacs, and where diagnostics land |
524
528
  | **[Encrypted files](docs/encrypted-files.md)** | SOPS and age: what is detected, what is reported, why nothing is decrypted |
525
529
  | **[CI](docs/ci.md)** | Baselines, fail triggers, output formats, the bundled GitHub Actions |
526
530
  | **[Performance](docs/performance.md)** | Where time goes at scale, and how much smaller a diff is than the config |
527
531
  | **[Kubernetes](docs/kubernetes.md)** | Diffing rendered Helm/Kustomize manifests before they reach a cluster |
528
532
  | **[Terraform plans](docs/terraform.md)** | Reviewing `terraform show -json` output and the `terraform` pack |
529
533
  | **[Webhooks and commands](docs/webhooks.md)** | Envelope shape, delivery modes, command environment |
534
+ | **[MCP server](docs/mcp.md)** | Read-only diff/check/explain tools for agents, and the security posture |
535
+ | **[Explain](docs/explain.md)** | Opt-in model narration of a diff: what is sent, what it can never do, cost |
530
536
  | **[Policy packs](docs/policy-packs.md)** | Writing declarative rules |
531
537
  | **[Plugins](docs/plugins.md)** · **[Cookbook](docs/plugin-cookbook.md)** | Rules that need real code |
538
+ | **[Live drift](docs/drift.md)** | `flecto-drift`: comparing a declared config against what is actually running |
532
539
  | **[Troubleshooting](docs/troubleshooting.md)** | When something doesn't behave |
540
+ | **[Stability](docs/stability.md)** | What you can build against, what you cannot, and the deprecation sequence |
541
+ | **[Migrating to 4.0](docs/migrating-to-4.md)** | The five breaking changes, and how to tell whether they affect you |
533
542
  | **[Changelog](CHANGELOG.md)** | Release history and migration notes |
534
543
 
535
544
  ---
@@ -548,6 +557,40 @@ leaves the process unless you configure a webhook or command.
548
557
 
549
558
  ---
550
559
 
560
+ ## Stability
561
+
562
+ Flecto runs inside your merge path, so here is what you can build against.
563
+ These follow [semver](https://semver.org/) and are covered by the deprecation
564
+ sequence below:
565
+
566
+ - **The JSON envelope** (`schema_version: "2.0"`) — existing fields keep their
567
+ name, type, and meaning; new fields are additive. Schemas in [`schemas/`](schemas).
568
+ - **Exit codes** — `0` clean, `1` a fail trigger matched or the run could not
569
+ complete. That is the whole set, and Flecto fails closed.
570
+ - **`.flectorc`** — documented keys keep their name, meaning, and default.
571
+ - **Command and flag names**, and what a flag accepts.
572
+
573
+ **No breaking change to those ships without a minor release that warns first**,
574
+ names the replacement, and says which version removes the old form. The one
575
+ exception is a security fix: if a surface can make `flecto ci` report a clean run
576
+ on a change that is not clean, it gets closed in the next release with an
577
+ advisory. 4.0 was exactly that — five breaking changes, every one a bypass.
578
+
579
+ Deliberately **not** stable: terminal and `pr-comment` output (presentation —
580
+ parse `--format json` instead), message wording, anything under `src/`, and
581
+ snapshot file internals. Built-in packs gain rules in minor releases; rule IDs
582
+ never change meaning.
583
+
584
+ Flecto reached 4.0 in four months, which is fast. That churn was front-loaded
585
+ into a period with no real users, and 4.0 was forced by a
586
+ [security review](docs/security-review.md) finding real bypasses. The intent now
587
+ is minor releases only — anything needing a 5.0 waits in
588
+ [`docs/v5-proposals.md`](docs/v5-proposals.md).
589
+
590
+ → **[Full stability policy](docs/stability.md)**
591
+
592
+ ---
593
+
551
594
  ## Project
552
595
 
553
596
  - **Questions and ideas** — [Discussions](https://github.com/myselfsiddharth/Flecto/discussions)
package/drift.js ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `flecto-drift` — compare what a repository declares against what is running.
4
+ *
5
+ * Flecto answers "what changed in this file". The question underneath it is
6
+ * usually "does what we declared still match what is running" — the config
7
+ * committed six months ago, against the value somebody hotfixed into the
8
+ * cluster at 3 AM and never backported (#144).
9
+ *
10
+ * **This is a separate binary on purpose.** Core Flecto authenticates to
11
+ * nothing, reads no key material, and shells out to nothing but `git`; every
12
+ * other command in this package keeps that promise. Reading live state cannot,
13
+ * so it does not share an entry point with the tool that can. `flecto ci`
14
+ * cannot reach this file, nothing in `src/` outside `drift-sources.js` imports
15
+ * it, and installing Flecto does not enable it. The intent is for it to become
16
+ * its own package with its own security review and release cadence; it lives
17
+ * here now for the same reason the MCP server did, which is that one repository
18
+ * is easier to review than two while the shape is still settling.
19
+ *
20
+ * It holds no credentials. Every source delegates to a tool the operator has
21
+ * already installed and authenticated (`kubectl`, `aws`), so Flecto inherits
22
+ * exactly what that tool is entitled to and nothing else — which is also what
23
+ * makes "give it a read-only role" advice an operator can enforce in their own
24
+ * IAM rather than a promise this code makes about itself.
25
+ *
26
+ * Values from a secret store are compared by **shape**, never by value, and
27
+ * there is no flag to change that.
28
+ */
29
+
30
+ import { program } from 'commander';
31
+ import { readFileSync } from 'fs';
32
+ import { createRequire } from 'module';
33
+ import { resolve } from 'path';
34
+ import { diffTrees } from './src/differ.js';
35
+ import { assertTargetContained } from './src/config.js';
36
+ import { documentKeysOf } from './src/documents.js';
37
+ import { parseFile } from './src/parser.js';
38
+ import { maskChangeEvent, renderDiff, renderError, renderInfo, renderNote, renderWarn } from './src/renderer.js';
39
+ import { readLiveState, shapeOf, SHAPE_RE } from './src/drift-sources.js';
40
+
41
+ const require = createRequire(import.meta.url);
42
+ const { version } = require('./package.json');
43
+
44
+ /**
45
+ * Keys a declared file carries that a live store never does.
46
+ *
47
+ * A Kubernetes ConfigMap's `data` block is what corresponds to a config file;
48
+ * the manifest around it (`apiVersion`, `metadata`, …) has no counterpart in
49
+ * the live read, and reporting all of it as "removed" would bury the one line
50
+ * that actually drifted. Descending into `data` when it is there is the whole
51
+ * of the normalization — anything cleverer would be guessing.
52
+ * @param {unknown} declared
53
+ * @returns {unknown}
54
+ */
55
+ function declaredComparable(declared) {
56
+ if (!declared || typeof declared !== 'object' || Array.isArray(declared)) return declared;
57
+ let record = /** @type {Record<string, unknown>} */ (declared);
58
+
59
+ // A manifest carrying apiVersion + kind + metadata.name is wrapped by the
60
+ // parser under a synthetic `Kind/ns/name` document key, so the `data` block
61
+ // sits one level down. Looking only at the top level found nothing, and the
62
+ // documented headline case -- a committed ConfigMap against an identical live
63
+ // one -- reported the whole manifest as drift and exited 1 forever.
64
+ const documents = documentKeysOf(declared) ?? [];
65
+ // Refused *before* looking for `data`. A document's identity falls back to a
66
+ // top-level `id`/`name`, so a document can be keyed literally `data` -- and
67
+ // checking `record.data` first then matched that wrapper, compared the wrong
68
+ // subtree, and silently dropped every other document in the file.
69
+ if (documents.length > 1) {
70
+ throw new Error(
71
+ `drift: ${documents.length} documents in this file, and a live source is one object.`
72
+ + ' Point drift at a file holding a single manifest.',
73
+ );
74
+ }
75
+ if (documents.length === 1 && typeof record[documents[0]] === 'object' && record[documents[0]] !== null) {
76
+ record = /** @type {Record<string, unknown>} */ (record[documents[0]]);
77
+ }
78
+
79
+ // `stringData` as well as `data`: readKubernetes merges both, and a plaintext
80
+ // Secret manifest uses `stringData`, so looking only at `data` reported the
81
+ // whole manifest as drift -- the same failure, on the other key.
82
+ const blocks = ['data', 'stringData']
83
+ .filter((key) => record[key] && typeof record[key] === 'object' && !Array.isArray(record[key]));
84
+ if (blocks.length > 0) {
85
+ const merged = Object.assign({}, ...blocks.map((key) => record[key]));
86
+ const dropped = Object.keys(record).filter((key) => !blocks.includes(key));
87
+ if (dropped.length > 0 && !documents.length) {
88
+ // For a manifest the surrounding keys have no live counterpart, which is
89
+ // the point. For an ordinary config file that happens to carry `data`,
90
+ // they are real settings -- say so rather than quietly comparing a third
91
+ // of the file.
92
+ renderWarn(
93
+ `Comparing only the ${blocks.join(' and ')} block; `
94
+ + `${dropped.length} other top-level key(s) in this file were not compared.`,
95
+ );
96
+ }
97
+ return merged;
98
+ }
99
+ return record;
100
+ }
101
+
102
+ /**
103
+ * Compare declared values against live ones, shaping the declared side for
104
+ * exactly the keys the live side shaped.
105
+ *
106
+ * Comparing a plaintext declared value against a live *shape* would report that
107
+ * key as changed on every run, which is noise that trains people to ignore the
108
+ * tool. So a shaped key is shaped on both sides, and every other key is
109
+ * compared by value.
110
+ * @param {unknown} declared
111
+ * @param {Record<string, unknown>} live
112
+ * @param {Set<string>} shapedKeys the live keys compared by shape
113
+ * @param {(v: string) => string} shape
114
+ * @returns {{ before: unknown, after: unknown }}
115
+ */
116
+ function alignForComparison(declared, live, shapedKeys, shape) {
117
+ const comparable = declaredComparable(declared);
118
+ if (shapedKeys.size === 0 || !comparable || typeof comparable !== 'object' || Array.isArray(comparable)) {
119
+ return { before: comparable, after: live };
120
+ }
121
+ // Per key, not per source. Shaping the whole declared side because *one*
122
+ // value was sensitive compared a shaped declared value against a raw live
123
+ // one, so every non-secret key drifted on every run -- the "trains people to
124
+ // ignore the tool" failure this function exists to prevent.
125
+ const shaped = Object.fromEntries(
126
+ Object.entries(/** @type {Record<string, unknown>} */ (comparable))
127
+ .map(([key, value]) => [key, shapedKeys.has(key) ? shape(stableString(value)) : value]),
128
+ );
129
+ return { before: shaped, after: live };
130
+ }
131
+
132
+ /**
133
+ * A value as a string, matching how the live side stringifies before hashing.
134
+ * @param {unknown} value
135
+ * @returns {string}
136
+ */
137
+ function stableString(value) {
138
+ if (typeof value === 'string') return value;
139
+ if (value === null || value === undefined) return '';
140
+ return JSON.stringify(value);
141
+ }
142
+
143
+ program
144
+ .name('flecto-drift')
145
+ .description(
146
+ 'Compare a declared config file against what is actually running.\n'
147
+ + 'Reads live state through a CLI you have already authenticated; holds no credentials.',
148
+ )
149
+ .version(version)
150
+ .argument('<file>', 'the declared configuration file')
151
+ .requiredOption('--against <uri>', 'live source: k8s://<ns>/configmap/<name>, k8s://<ns>/secret/<name>, ssm://<path>, tfstate://<path>')
152
+ .option('--format <type>', 'human or json', 'human')
153
+ .option('--fail-on-drift', 'exit 1 when the declared file and the live state differ', false)
154
+ .action(async (file, opts) => {
155
+ try {
156
+ const format = String(opts.format);
157
+ if (!['human', 'json'].includes(format)) {
158
+ throw new Error('--format must be human or json');
159
+ }
160
+
161
+ const filepath = resolve(file);
162
+ // The same containment every other read in Flecto has: file names and
163
+ // links are attacker-controlled on an untrusted pull request.
164
+ assertTargetContained(filepath, process.cwd());
165
+ const declared = parseFile(filepath);
166
+
167
+ const { state: live, meta } = readLiveState(opts.against);
168
+ const { before, after } = alignForComparison(declared, live, meta.shapedKeys, shapeOf);
169
+
170
+ // Declared is `before`, live is `after`, so the verbs read the way the
171
+ // question is asked: what has the running system done to what we wrote.
172
+ // Masked on both paths, not only the human one: a machine-readable report
173
+ // of live state is the likelier thing to be archived as a CI artifact, so
174
+ // leaving it raw would put those values somewhere they outlive the run.
175
+ //
176
+ // A shape is skipped, because it is already the safe form -- a keyed
177
+ // digest of a value this process never prints -- and masking it again
178
+ // would replace it with `***` on both sides, throwing away the one thing
179
+ // it exists to show: that the credential rotated.
180
+ //
181
+ // The test is on the **value**, not on the key it sits under. Keying it
182
+ // on `shapedKeys` trusted metadata that can fall out of step with the
183
+ // value beside it, and when it did, a live plaintext printed unmasked
184
+ // because its key was still marked as shaped. Every present side must be
185
+ // a shape, so a shape-to-plaintext change is masked rather than exempted.
186
+ const changes = diffTrees(before, after, {}).map((event) => {
187
+ const sides = [event.before, event.after].filter((value) => value !== undefined);
188
+ const allShaped = sides.length > 0
189
+ && sides.every((value) => typeof value === 'string' && SHAPE_RE.test(value));
190
+ return allShaped ? event : maskChangeEvent(event);
191
+ });
192
+
193
+ if (format === 'json') {
194
+ process.stdout.write(`${JSON.stringify({
195
+ file: filepath,
196
+ against: opts.against,
197
+ source: meta.label,
198
+ // Named, so a consumer never has to guess whether a value in here is
199
+ // a real value or a digest of one.
200
+ comparison: meta.sensitive ? 'shape-only' : 'values',
201
+ drifted: changes.length > 0,
202
+ changes,
203
+ }, null, 2)}\n`);
204
+ } else if (changes.length === 0) {
205
+ renderInfo(`No drift: ${filepath} matches ${meta.label}.`);
206
+ } else {
207
+ // Masked, like every other render path in Flecto. Drift prints values
208
+ // read out of a live system into a CI log, so it needs this more than
209
+ // the others, not less -- a ConfigMap value under a secret-shaped key
210
+ // is still a secret.
211
+ renderDiff(filepath, changes, { baseline: meta.label });
212
+ if (meta.sensitive) {
213
+ renderNote(
214
+ 'Values from a secret store are compared by shape (length and digest), never by value.',
215
+ );
216
+ }
217
+ }
218
+
219
+ if (changes.length > 0 && opts.failOnDrift) process.exitCode = 1;
220
+ } catch (err) {
221
+ renderError(err.message);
222
+ process.exitCode = 1;
223
+ }
224
+ });
225
+
226
+ program.parseAsync(process.argv);