flecto 3.0.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +257 -1
- package/README.md +12 -1
- package/drift.js +226 -0
- package/index.js +672 -152
- package/package.json +10 -7
- package/src/alerter.js +272 -24
- package/src/config.js +369 -3
- package/src/differ.js +76 -12
- 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 +1057 -0
- package/src/pr-comment.js +75 -1
- package/src/pr-providers.js +8 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +27 -15
- package/src/report.js +4 -2
- package/src/secrets.js +27 -0
- package/src/snapshot-store.js +672 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,256 @@ The format is based on [Keep a Changelog], and this project adheres to
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.0.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
**A security release.** Every breaking change below exists because a pull
|
|
13
|
+
request could otherwise make `flecto ci` report a clean run on a change that was
|
|
14
|
+
not clean. If you run Flecto on untrusted pull requests, upgrading is not
|
|
15
|
+
optional.
|
|
16
|
+
|
|
17
|
+
See **[docs/migrating-to-4.md](docs/migrating-to-4.md)** for what to change, and
|
|
18
|
+
the [security advisories](https://github.com/myselfsiddharth/Flecto/security/advisories)
|
|
19
|
+
for what was wrong.
|
|
20
|
+
|
|
21
|
+
### Breaking
|
|
22
|
+
|
|
23
|
+
- `snapshotRef` and `snapshotFile` declared in `.flectorc` are refused
|
|
24
|
+
(`FLECTO_ALLOW_RC_BASELINE=1` opts back in).
|
|
25
|
+
- `--snapshot-ref` takes a git revision. A bare snapshot filename needs
|
|
26
|
+
`--snapshot-file`, or a `./` prefix. The bundled `flecto-ci` Action gains a
|
|
27
|
+
`snapshot-file:` input.
|
|
28
|
+
- Policy-pack regular expressions outside `src/packs/` are compiled with RE2:
|
|
29
|
+
lookaround, backreferences, `\uXXXX` escapes, and `v`-flag set subtraction now
|
|
30
|
+
fail at load, and a few constructs match differently.
|
|
31
|
+
- `.flecto-queue/` is keyed by destination. A 3.x backlog is kept but not
|
|
32
|
+
auto-delivered.
|
|
33
|
+
- The `--command` spill file is deleted when the command exits, so a script must
|
|
34
|
+
read `FLECTO_CHANGES_FILE` while the command is still running.
|
|
35
|
+
- Flecto now requires git 2.24 or newer.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`flecto explain` and `ci --explain`: opt-in, advisory narration of a diff by
|
|
40
|
+
a model you configure** ([#143]). `pool_size: 5 → 20` is mechanical; "this
|
|
41
|
+
quadruples connections per replica, so check `max_connections`" is judgment,
|
|
42
|
+
and it is what a reviewer wants at 2 AM. Bring your own key: `anthropic`
|
|
43
|
+
(Messages API, default model `claude-opus-5`) or `openai`, which covers any
|
|
44
|
+
OpenAI-compatible server, including a local one. Each is a single `fetch`, so
|
|
45
|
+
no vendor SDK and no new dependency.
|
|
46
|
+
|
|
47
|
+
The constraints are the feature. **Only the masked semantic diff is sent**,
|
|
48
|
+
masked unconditionally while the payload is built, never file contents, with
|
|
49
|
+
encrypted values as sentinels. `--dry-run` / `--explain-dry-run` print the exact
|
|
50
|
+
request and send nothing. **It never touches an exit code**: `ci` decides the
|
|
51
|
+
gate first, and every failure (no provider, over budget, timeout, HTTP error,
|
|
52
|
+
refusal) is a warning. `ci`'s stdout is byte-identical except under
|
|
53
|
+
`--format pr-comment`, where the narration goes in its own labeled section,
|
|
54
|
+
fenced so links, images, mentions, and HTML render as text. **The operator
|
|
55
|
+
configures it and the repository cannot**: `explain*` options in `.flectorc`
|
|
56
|
+
are refused, provider, endpoint, and key come only from the CLI and
|
|
57
|
+
`FLECTO_EXPLAIN_*` variables, redirects are refused rather than forwarding
|
|
58
|
+
`x-api-key`, and `FLECTO_EXPLAIN=0` is a runner-wide kill switch. **Cost is
|
|
59
|
+
stated before the call**, with estimated input tokens and the output cap.
|
|
60
|
+
Diffs over an input budget are skipped, not truncated. Identical requests are
|
|
61
|
+
served from a cache outside the repository, keyed with an HMAC of the API key
|
|
62
|
+
so a pull request cannot plant an entry. See [docs/explain.md](docs/explain.md).
|
|
63
|
+
|
|
64
|
+
- **`flecto mcp` — a read-only Model Context Protocol server over stdio**
|
|
65
|
+
([#140]). An agent asked to debug a config incident no longer has to read a
|
|
66
|
+
two-thousand-line manifest into its context to learn that `pool_size` doubled;
|
|
67
|
+
Flecto already computes that small answer and now hands it over as a structured
|
|
68
|
+
tool result. Three read-only tools — `flecto_diff`, `flecto_check`, and
|
|
69
|
+
`flecto_explain` — each run the same `ci` path a pull request triggers and
|
|
70
|
+
return the JSON envelope.
|
|
71
|
+
|
|
72
|
+
The security posture is inherited from `ci` by construction: the tools can only
|
|
73
|
+
reach what `ci` reaches (no `--command`, no writes, no webhooks, no plugins —
|
|
74
|
+
the last enforced regardless of `FLECTO_ALLOW_RC_PLUGINS`), no tool argument is
|
|
75
|
+
ever parsed as a CLI option, tool-argument paths — including a `ref` that names
|
|
76
|
+
a snapshot file — are contained before anything spawns, and results are
|
|
77
|
+
bounded. **Secrets are masked by default here, inverted from the CLI**,
|
|
78
|
+
because the consumer is a model context that is transmitted and often logged;
|
|
79
|
+
`mask: false` is an explicit, documented opt-out. Adds no runtime dependency —
|
|
80
|
+
the stdio JSON-RPC framing is spoken directly. See [docs/mcp.md](docs/mcp.md).
|
|
81
|
+
|
|
82
|
+
- **`flecto lsp`: findings and semantic changes as editor diagnostics while a
|
|
83
|
+
config file is being edited** ([#142]). It's a Language Server Protocol server
|
|
84
|
+
over stdio, compared against `HEAD` by default (`--snapshot-ref`,
|
|
85
|
+
`--snapshot-store`), and it **agrees with the merge gate**: it uses the same
|
|
86
|
+
`.flectorc`, packs, `severityRemap`, scope (`files`/`include`/`exclude`), inline
|
|
87
|
+
suppressions, and `--baseline` file as `ci`. A suppression missing its reason
|
|
88
|
+
is the error CI fails on. The hard part the issue named, positions, is a new
|
|
89
|
+
module (`src/positions.js`) that maps a diff path back into the source text for
|
|
90
|
+
YAML (including anchors, merge keys, and multi-document manifests), JSON/JSONC,
|
|
91
|
+
dotenv, INI, and TOML. A position is used only where an independent scan of
|
|
92
|
+
the text agrees with the parsed tree. Anything else anchors at the nearest
|
|
93
|
+
verified ancestor, never at a guess. Across every fixture and example in the
|
|
94
|
+
repository, no exact position lands on the wrong key. Analyses run in a worker
|
|
95
|
+
thread, debounced, cancelled by a newer edit, and stopped at `--timeout`, so a
|
|
96
|
+
catastrophic pack regex costs one warning instead of a wedged server.
|
|
97
|
+
**Plugins declared in `.flectorc` are never loaded**, even with
|
|
98
|
+
`FLECTO_ALLOW_RC_PLUGINS=1`, since opening a repository in an editor is the
|
|
99
|
+
untrusted-PR threat model. `--plugins` must be absolute paths. See
|
|
100
|
+
[docs/editor.md](docs/editor.md).
|
|
101
|
+
|
|
102
|
+
### Security
|
|
103
|
+
|
|
104
|
+
- **BREAKING: `snapshotRef` declared in `.flectorc` is refused** ([#121]). The
|
|
105
|
+
baseline decides what counts as a change, so a pull request that sets it
|
|
106
|
+
decides the verdict: a committed `{"defaults": {"snapshotRef": "HEAD"}}`
|
|
107
|
+
compared every file against the pull request's own tip and exited 0 on a
|
|
108
|
+
config that disabled TLS. Pass `--snapshot-ref` on the command line — the form
|
|
109
|
+
every example and the shipped Action already use — or set
|
|
110
|
+
`FLECTO_ALLOW_RC_BASELINE=1` if the rc file is trusted.
|
|
111
|
+
- **`--snapshot-file <path>` is added, and `--snapshot-ref` is a git revision**
|
|
112
|
+
([#121]). Overloading one flag with both is what let an attacker-committed
|
|
113
|
+
file stand in for the operator's baseline. **This is breaking**: only a value
|
|
114
|
+
that is unambiguously a path — absolute, or starting `./` or `../`, shapes
|
|
115
|
+
git's ref format cannot produce — is still read as a file by
|
|
116
|
+
`--snapshot-ref`. A bare `--snapshot-ref snapshots/base.json` now fails and
|
|
117
|
+
says to use `--snapshot-file`. The bundled `flecto-ci` Action gains a
|
|
118
|
+
`snapshot-file:` input for the same reason.
|
|
119
|
+
When git is missing, too old, or not looking at a repository, Flecto refuses
|
|
120
|
+
rather than falling back to a file.
|
|
121
|
+
- **A baseline ref can no longer be crafted into a file write, a shadowed
|
|
122
|
+
baseline, or an empty diff** ([#121]). Three shapes, one property:
|
|
123
|
+
`--output=pwned` was read by git as an *option* and wrote a file while the
|
|
124
|
+
emptied read made every key look `added` so the default `--fail-on` never
|
|
125
|
+
fired; a committed file named after the operator's ref (`HEAD~1`, the shipped
|
|
126
|
+
Action's default) shadowed the baseline with one the attacker wrote; and a
|
|
127
|
+
commit range such as `HEAD:..` succeeded while printing nothing, for the same
|
|
128
|
+
silent pass. Refs now resolve through `git rev-parse --verify <ref>^{commit}`,
|
|
129
|
+
revision before file, and `git show` receives the resolved SHA.
|
|
130
|
+
- **BREAKING: pack-supplied regular expressions are compiled with RE2**
|
|
131
|
+
([#121]). A policy pack is attacker input on an untrusted pull request --
|
|
132
|
+
`policies/*.json` is committed and `.flectorc` selects which packs run -- and
|
|
133
|
+
Node's engine backtracks, so `^(a+)+$` took **97 seconds** against a 44-character
|
|
134
|
+
value and grew exponentially. No in-process timeout could help: the
|
|
135
|
+
backtracking happens inside one uninterruptible call into the engine. Packs
|
|
136
|
+
outside `src/packs/` now use a linear-time engine (`re2js`, pure JS, no native
|
|
137
|
+
build), which answers the same pattern in 3 ms. The packs Flecto ships keep
|
|
138
|
+
the native engine. RE2 does not support lookaround, backreferences, `\uXXXX`
|
|
139
|
+
escapes, or `v`-flag set subtraction, so a pack using them now fails to load
|
|
140
|
+
with a message naming the rule; a few constructs also *match* differently, and
|
|
141
|
+
[docs/policy-packs.md](docs/policy-packs.md#regular-expressions-in-packs)
|
|
142
|
+
tables both sets.
|
|
143
|
+
- **A pack regex with the `g` flag no longer fires on alternate files.** Packs
|
|
144
|
+
are cached and shared across every file in a run, and a `g` regex carries a
|
|
145
|
+
mutable `lastIndex` that `.test()` advances, so such a rule matched every
|
|
146
|
+
other value it saw.
|
|
147
|
+
### Added
|
|
148
|
+
|
|
149
|
+
- **`flecto-drift`: compare a declared config file against what is actually
|
|
150
|
+
running** ([#144]). A **separate binary**, deliberately: every other Flecto
|
|
151
|
+
command authenticates to nothing, and reading live state cannot keep that
|
|
152
|
+
promise, so it does not share an entry point with the tool that can. `flecto
|
|
153
|
+
ci` cannot reach it and installing Flecto does not enable it.
|
|
154
|
+
It holds **no credentials** — Kubernetes and SSM are read through `kubectl`
|
|
155
|
+
and `aws`, which you have already authenticated, so Flecto inherits exactly
|
|
156
|
+
what those are entitled to. Read-only is structural: argv is built from a
|
|
157
|
+
fixed verb allowlist and nothing from the URI can reach it as a flag. Values
|
|
158
|
+
from a secret store are compared **by shape** (length and digest), never by
|
|
159
|
+
value, with no flag to change that; SSM is read without `--with-decryption`.
|
|
160
|
+
Terraform state exposes only `outputs`. See [docs/drift.md](docs/drift.md).
|
|
161
|
+
|
|
162
|
+
### Fixed
|
|
163
|
+
|
|
164
|
+
- **The shared snapshot store now refuses a Windows target on another drive or
|
|
165
|
+
a UNC share** ([#141], [#121]). The store keys a snapshot by its repo-relative
|
|
166
|
+
path and refuses a file outside the repository, but recognised "outside" only
|
|
167
|
+
as a `..`-prefixed path. On Windows, `path.relative` cannot reach another drive
|
|
168
|
+
or a share and returns the target absolute instead, which was accepted as a
|
|
169
|
+
key: a cross-drive write failed on a raw `ENOENT`, and a UNC one was written
|
|
170
|
+
under a meaningless `server/share/…` key. Neither left `.flecto/snapshots/`.
|
|
171
|
+
Both are now refused with the same message as any other outside target.
|
|
172
|
+
|
|
173
|
+
- **`watch --command`/`--webhook`/`--webhook-header` declared in `.flectorc`
|
|
174
|
+
are refused, not honored** ([#121]). All three merge through the ordinary
|
|
175
|
+
options path with no other gate, unlike
|
|
176
|
+
`--plugins`/`--output`/`--baseline`/`--update-baseline`, which were already
|
|
177
|
+
refused there. `command` spawns a shell command on every change;
|
|
178
|
+
`.flectorc` is attacker-controlled on an untrusted pull request, so a
|
|
179
|
+
`.flectorc` naming one got arbitrary shell execution on the next `flecto
|
|
180
|
+
watch` — no `--command` flag required. Confirmed end to end: a hostile
|
|
181
|
+
`.flectorc` alone, with nothing passed on the command line, ran a command that
|
|
182
|
+
wrote a marker file outside anything the run otherwise touched. `webhook` is
|
|
183
|
+
the same shape one step down — it sends the change payload to a URL the
|
|
184
|
+
pull request chose. `webhook-header` is reachable even when `webhook` itself
|
|
185
|
+
is the operator's own flag: an rc-declared header rides along on that
|
|
186
|
+
already-approved request and can override it. All three are refused with the
|
|
187
|
+
message the plugin and write guards already use, `FLECTO_ALLOW_RC_ALERTS=1`
|
|
188
|
+
opts out for a repository that configures one in `.flectorc` on purpose, and
|
|
189
|
+
any of the three named on the command line is untouched, because that is the
|
|
190
|
+
operator. `--delivery-mode`/`--on-alert-failure` are untouched either way —
|
|
191
|
+
they only tune failure handling for an alert the operator already chose, the
|
|
192
|
+
same "operator delegates a setting" shape `--fail-on` already has, and
|
|
193
|
+
`flecto init` writes both into the config it generates.
|
|
194
|
+
|
|
195
|
+
## [3.1.0] - 2026-09-15
|
|
196
|
+
|
|
197
|
+
### Added
|
|
198
|
+
|
|
199
|
+
- **A shared, git-tracked snapshot store** ([#141]). Snapshot history lived in
|
|
200
|
+
`.flecto-snapshots/`, keyed by each file's *absolute* path — right for a laptop
|
|
201
|
+
and meaningless anywhere else. An ephemeral runner starts with that directory
|
|
202
|
+
empty on every run, so `history` and `report` were local-only by construction
|
|
203
|
+
and `ci` had to be handed `--snapshot-ref`.
|
|
204
|
+
|
|
205
|
+
`--snapshot-store shared` (or `"snapshotStore": "shared"` in `.flectorc`, which
|
|
206
|
+
is the better place for it) writes `.flecto/snapshots/` instead: keyed by
|
|
207
|
+
repo-relative path, one file per config file with its history inside, keys
|
|
208
|
+
sorted at every level. Commit it and every runner reads the baseline the author
|
|
209
|
+
saved, with no cache, no ref, and no setup step. `watch`, `ci`, `history`, and
|
|
210
|
+
`report` all read whichever store is selected, and every "nothing found"
|
|
211
|
+
message names the store it looked in.
|
|
212
|
+
|
|
213
|
+
**Committing snapshots commits config values into git history permanently**, so
|
|
214
|
+
the shared store masks by default: values that trip Flecto's secret detection —
|
|
215
|
+
by shape *or* by key name, the same names `--mask-secrets` recognizes — are
|
|
216
|
+
stored as `flecto:sha256:<digest>`. The digest is a change detector, not a
|
|
217
|
+
vault — a rotated credential still reports as drift, because a store that
|
|
218
|
+
silently missed one would be worse than no store, and the live side of a diff is
|
|
219
|
+
masked the same way so an untouched secret produces no change.
|
|
220
|
+
`--snapshot-mask none` opts out and says so.
|
|
221
|
+
|
|
222
|
+
Retention (`--snapshot-retention`, 20 per file in the shared store) prunes
|
|
223
|
+
oldest-first, because an append-forever store inside a repository becomes its
|
|
224
|
+
own problem. The `local` store is untouched: same filenames, same JSON, same
|
|
225
|
+
unbounded history, still the default.
|
|
226
|
+
|
|
227
|
+
### Security
|
|
228
|
+
|
|
229
|
+
- **The merge gate could be turned green from `.flectorc`** ([#121]).
|
|
230
|
+
`--update-baseline` accepts every finding of the current run, and it resolved
|
|
231
|
+
through the ordinary options merge — so a pull request that added four lines of
|
|
232
|
+
`.flectorc` turned a failing `flecto ci --fail-on error` into a passing one,
|
|
233
|
+
overriding a `--fail-on` given on the command line. `updateBaseline` is now
|
|
234
|
+
refused from `.flectorc` (and from a profile) rather than honored: it is an
|
|
235
|
+
action, not a setting. `--update-baseline` on the command line is unchanged.
|
|
236
|
+
|
|
237
|
+
- **Write destinations could be redirected out of the repository** ([#121]).
|
|
238
|
+
`--output` (`flecto report`) and `--baseline` (`flecto ci`) can both be declared
|
|
239
|
+
in `.flectorc`, so a pull request could point them at any file the job could
|
|
240
|
+
reach — through `..`, or through a symlink — and both files carry content that
|
|
241
|
+
pull request partly wrote. A destination declared in `.flectorc` must now
|
|
242
|
+
resolve inside the project (`FLECTO_ALLOW_RC_WRITES=1` opts out), and a
|
|
243
|
+
destination that leaves the project through a symlink is refused whoever named
|
|
244
|
+
it (`FLECTO_ALLOW_SYMLINK_TARGETS=1` opts out) — including a link whose target
|
|
245
|
+
does not exist yet, which `existsSync` reports as absent and which would have
|
|
246
|
+
Flecto *create* a file outside the repository rather than overwrite one. A
|
|
247
|
+
destination named on the command line is operator intent and is unchanged.
|
|
248
|
+
|
|
249
|
+
- **The GitLab token followed redirects** ([#121]). `fetch` strips
|
|
250
|
+
`Authorization` when a redirect crosses origins and strips only that header;
|
|
251
|
+
GitLab authenticates with `PRIVATE-TOKEN`, which was forwarded to the redirect
|
|
252
|
+
target in full — verified against a local server. Provider API requests are now
|
|
253
|
+
issued with `redirect: 'manual'` and refuse a 3xx, naming the origin it pointed
|
|
254
|
+
at. Bitbucket workspace and repository segments are URL-encoded alongside,
|
|
255
|
+
matching GitLab's project id.
|
|
256
|
+
|
|
257
|
+
The API host comes from runner environment rather than pull request content, so
|
|
258
|
+
this needed a hostile or misconfigured API host to reach.
|
|
259
|
+
|
|
10
260
|
## [3.0.2] - 2026-09-06
|
|
11
261
|
|
|
12
262
|
### Security
|
|
@@ -972,7 +1222,9 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
972
1222
|
- Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
|
|
973
1223
|
continuing with no policies.
|
|
974
1224
|
|
|
975
|
-
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.0
|
|
1225
|
+
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...HEAD
|
|
1226
|
+
[4.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...v4.0.0
|
|
1227
|
+
[3.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.2...v3.1.0
|
|
976
1228
|
[3.0.2]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.1...v3.0.2
|
|
977
1229
|
[3.0.1]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...v3.0.1
|
|
978
1230
|
[3.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.1.0...v3.0.0
|
|
@@ -1059,8 +1311,12 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
1059
1311
|
[#159]: https://github.com/myselfsiddharth/Flecto/issues/159
|
|
1060
1312
|
[#150]: https://github.com/myselfsiddharth/Flecto/issues/150
|
|
1061
1313
|
[#125]: https://github.com/myselfsiddharth/Flecto/issues/125
|
|
1314
|
+
[#143]: https://github.com/myselfsiddharth/Flecto/issues/143
|
|
1062
1315
|
[#141]: https://github.com/myselfsiddharth/Flecto/issues/141
|
|
1316
|
+
[#140]: https://github.com/myselfsiddharth/Flecto/issues/140
|
|
1063
1317
|
[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
|
|
1064
1318
|
[#138]: https://github.com/myselfsiddharth/Flecto/issues/138
|
|
1065
1319
|
[Semantic Versioning]: https://semver.org/spec/v2.0.0.html
|
|
1066
1320
|
[GHSA-wq8m-fc3q-8m5x]: https://github.com/myselfsiddharth/Flecto/security/advisories/GHSA-wq8m-fc3q-8m5x
|
|
1321
|
+
[#142]: https://github.com/myselfsiddharth/Flecto/issues/142
|
|
1322
|
+
[#144]: https://github.com/myselfsiddharth/Flecto/issues/144
|
package/README.md
CHANGED
|
@@ -263,7 +263,10 @@ flecto history config/prod.yaml --limit 10
|
|
|
263
263
|
```
|
|
264
264
|
|
|
265
265
|
Snapshots stay on your machine in `.flecto-snapshots/`. Nothing is uploaded and
|
|
266
|
-
no account is required.
|
|
266
|
+
no account is required. To read the same history on a CI runner, save it to the
|
|
267
|
+
git-tracked store instead — `--snapshot-store shared` writes a committable
|
|
268
|
+
`.flecto/snapshots/`, masking secret-like values into digests as it goes.
|
|
269
|
+
→ **[CLI reference](docs/cli-reference.md#flecto-history-files)**
|
|
267
270
|
|
|
268
271
|
### Share what changed before the incident
|
|
269
272
|
|
|
@@ -498,6 +501,7 @@ Explicit CLI flags win over profiles, which win over `defaults`.
|
|
|
498
501
|
| `flecto watch --snapshot` | Save the current state as a baseline |
|
|
499
502
|
| `flecto watch --diff` | Compare against the baseline and exit |
|
|
500
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) |
|
|
501
505
|
| `flecto compare <fileA> <fileB>` | Diff two files against each other (`fileA` is the baseline) |
|
|
502
506
|
| `flecto plan <planFiles...>` | Review `terraform show -json` output and gate on it |
|
|
503
507
|
| `flecto history [files...]` | Summarize drift across local snapshots |
|
|
@@ -506,6 +510,8 @@ Explicit CLI flags win over profiles, which win over `defaults`.
|
|
|
506
510
|
| `flecto policies list` | List available policy packs |
|
|
507
511
|
| `flecto policies test <dir>` | Assert pack and plugin findings from fixtures |
|
|
508
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 |
|
|
509
515
|
| `flecto doctor` | Check setup, config, and environment |
|
|
510
516
|
|
|
511
517
|
→ **[Every flag, every command](docs/cli-reference.md)**
|
|
@@ -518,15 +524,20 @@ Explicit CLI flags win over profiles, which win over `defaults`.
|
|
|
518
524
|
|---|---|
|
|
519
525
|
| **[CLI reference](docs/cli-reference.md)** | Every command, flag, and exit code |
|
|
520
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 |
|
|
521
528
|
| **[Encrypted files](docs/encrypted-files.md)** | SOPS and age: what is detected, what is reported, why nothing is decrypted |
|
|
522
529
|
| **[CI](docs/ci.md)** | Baselines, fail triggers, output formats, the bundled GitHub Actions |
|
|
523
530
|
| **[Performance](docs/performance.md)** | Where time goes at scale, and how much smaller a diff is than the config |
|
|
524
531
|
| **[Kubernetes](docs/kubernetes.md)** | Diffing rendered Helm/Kustomize manifests before they reach a cluster |
|
|
525
532
|
| **[Terraform plans](docs/terraform.md)** | Reviewing `terraform show -json` output and the `terraform` pack |
|
|
526
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 |
|
|
527
536
|
| **[Policy packs](docs/policy-packs.md)** | Writing declarative rules |
|
|
528
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 |
|
|
529
539
|
| **[Troubleshooting](docs/troubleshooting.md)** | When something doesn't behave |
|
|
540
|
+
| **[Migrating to 4.0](docs/migrating-to-4.md)** | The five breaking changes, and how to tell whether they affect you |
|
|
530
541
|
| **[Changelog](CHANGELOG.md)** | Release history and migration notes |
|
|
531
542
|
|
|
532
543
|
---
|
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);
|