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 +262 -1
- package/README.md +46 -3
- package/drift.js +226 -0
- package/index.js +527 -31
- package/package.json +24 -14
- package/src/alerter.js +272 -24
- package/src/config.js +208 -2
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1063 -0
- package/src/pr-comment.js +33 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +24 -0
- package/src/snapshot-store.js +6 -2
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/
|
|
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@
|
|
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@
|
|
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@
|
|
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);
|