cfg-guard 1.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ameobius
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,291 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfg-guard
3
+ Version: 1.0.0
4
+ Summary: A lock and a witness for config files too many hands edit: flock + a content-hash promise to stop the clobber, inotify + /proc to name the writer.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 ameobius
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://gitlab.com/ameobius-ai/cfg-guard
28
+ Project-URL: Source, https://gitlab.com/ameobius-ai/cfg-guard
29
+ Project-URL: Issues, https://gitlab.com/ameobius-ai/cfg-guard/-/issues
30
+ Keywords: config,configuration,flock,inotify,concurrency,race-condition,ops,sysadmin
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Environment :: Console
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: Intended Audience :: System Administrators
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: POSIX
37
+ Classifier: Operating System :: POSIX :: Linux
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Programming Language :: Python :: 3.13
43
+ Classifier: Topic :: System :: Filesystems
44
+ Classifier: Topic :: System :: Systems Administration
45
+ Requires-Python: >=3.10
46
+ Description-Content-Type: text/markdown
47
+ License-File: LICENSE
48
+ Dynamic: license-file
49
+
50
+ <div align="center">
51
+
52
+ # cfg-guard
53
+
54
+ **A lock and a witness for config files too many hands edit.**
55
+ `flock` + a content-hash promise to stop the clobber, `inotify` + `/proc` to name the writer.
56
+
57
+ [![python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
58
+ [![dependencies](https://img.shields.io/badge/dependencies-stdlib%20only-brightgreen.svg)](#install)
59
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
60
+
61
+ </div>
62
+
63
+ ---
64
+
65
+ A config that several hands write is a config that silently loses edits. Two
66
+ agents read the same file, each changes its own copy, the second write lands on
67
+ top of the first — no exception, no conflict marker, just a key that is quietly
68
+ gone. The agent whose change vanished will not notice until something downstream
69
+ reads a config it never wrote.
70
+
71
+ `cfg-guard` is two tools for that one problem. A **lock and a promise** (`flock`
72
+ plus a content-hash compare-and-swap) stop *you* from clobbering others and
73
+ refuse a write whose base bytes moved. A **witness** (`inotify` plus `/proc`)
74
+ names whoever else writes the file while you are not looking. It grew out of
75
+ running a live [cliproxy] config that several agents and a few cron jobs edit at
76
+ once, and learning the hard way how many "the config is wrong again" incidents
77
+ were really a race nobody could see.
78
+
79
+ ## The two failures
80
+
81
+ Both are silent. Both are the reason this exists.
82
+
83
+ 1. **Clobber.** Two writers, one file, last write wins — and the loser never
84
+ hears about it. `cfgguard.lockedit` prevents it two ways: `flock` serializes
85
+ the writers polite enough to take the lock, and a content-hash promise
86
+ (compare-and-swap) refuses a write whose base bytes moved, catching the ones
87
+ that never heard of the lock. A refusal is not a failure; it is the clobber
88
+ being *prevented*. Exit `3` means the tool did its job.
89
+
90
+ 2. **Invisible writers.** A vendor agent, a hot-reload, a cron mutates the file
91
+ and nobody can say when or by whom. `cfgguard.watch` is the witness: it sits
92
+ on `inotify` and attributes every real change to a live `/proc` fd holder,
93
+ separating content edits from open-and-close no-ops by sha256. `inotify` never
94
+ hands you a PID, so the watcher snapshots `/proc/*/fd` *first* on every event
95
+ — the fd trail fades in milliseconds.
96
+
97
+ ## Install
98
+
99
+ ```bash
100
+ pip install cfg-guard
101
+ ```
102
+
103
+ No PyPI release yet? Install straight from the checkout:
104
+
105
+ ```bash
106
+ git clone https://gitlab.com/ameobius-ai/cfg-guard
107
+ cd cfg-guard && pip install .
108
+ ```
109
+
110
+ The engine has **zero runtime dependencies** — pure stdlib. `flock` is POSIX and
111
+ `inotify` + `/proc` are Linux, so the witness is Linux-only; everywhere else the
112
+ lock degrades to a no-op (the hash promise still holds) and `watch` says so
113
+ instead of pretending. See [Platform](#platform).
114
+
115
+ ## Quickstart
116
+
117
+ ```bash
118
+ # 1. Snapshot the promise you will hand back as --expect
119
+ H=$(cfg-guard hash gateway.yaml)
120
+
121
+ # 2. Edit under lock + promise: a stdin->stdout filter, refused if the base moved
122
+ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
123
+
124
+ # 3. Or replace the whole file from stdin, still under the same guard
125
+ cfg-guard edit gateway.yaml --expect "$H" --stdin < new.yaml
126
+
127
+ # 4. Or open $EDITOR, serialized by flock so a second editor waits
128
+ cfg-guard edit gateway.yaml
129
+
130
+ # 5. Witness who else writes it, live, with the writer named from /proc
131
+ cfg-guard watch gateway.yaml
132
+ ```
133
+
134
+ `edit` is the only command that writes. Everything after the first bare `--` is
135
+ the filter argv — `cfg-guard` splits it off before argparse sees it, so the
136
+ filter's own flags are never mistaken for ours.
137
+
138
+ ### What a guarded write looks like
139
+
140
+ ```console
141
+ $ H=$(cfg-guard hash gateway.yaml)
142
+ $ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
143
+ gateway.yaml: wrote 3f2a9c1d4e5b -> 8c1f0a2b3d4e
144
+ cfg-guard: previous copy kept at gateway.yaml.bak
145
+ ```
146
+
147
+ One honest line: what moved. The pre-write bytes are copied to `.bak` unless you
148
+ pass `--no-backup`. An identical write is detected and skipped, so a caller can
149
+ re-assert the whole desired config every run without churning backups:
150
+
151
+ ```console
152
+ $ cfg-guard edit gateway.yaml --stdin < same.yaml
153
+ cfg-guard: no-op, gateway.yaml already 8c1f0a2b3d4e
154
+ ```
155
+
156
+ ### What a prevented clobber looks like
157
+
158
+ Someone else wrote the file between your `hash` and your `edit`. The promise no
159
+ longer matches, so the write is refused and **nothing on disk is touched**:
160
+
161
+ ```console
162
+ $ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
163
+ cfg-guard: error: gateway.yaml: expected hash 3f2a9c1d4e5b but on-disk is 8c1f0a2b3d4e — another writer moved it; re-read and redo the edit
164
+ $ echo $?
165
+ 3
166
+ ```
167
+
168
+ Re-read, redo the edit against the new bytes. That loop is the whole point —
169
+ better a refused write than a vanished one.
170
+
171
+ ### What the witness sees
172
+
173
+ ```console
174
+ $ cfg-guard watch ~/.config/gateway.yaml
175
+ cfg-guard: witnessing /home/ops/.config/gateway.yaml (Ctrl-C to stop)
176
+ watching /home/ops/.config/gateway.yaml (baseline 3f2a9c1d4e5b)
177
+ no-op x1: ATTRIB content unchanged (3f2a9c1d4e5b)
178
+ CONTENT CHANGED MODIFY|CLOSE_WRITE: 3f2a9c1d4e5b -> 8c1f0a2b3d4e
179
+ fd-holder file pid=4821 comm=yq yq .timeout = 30 gateway.yaml
180
+ inotify-watcher pid=3390 comm=hermes
181
+ ```
182
+
183
+ The `no-op` line is a metadata touch that did not change a single byte — the
184
+ sha256 gate keeps it from flooding the log. The `CONTENT CHANGED` block names the
185
+ process caught holding the file open (`fd-holder`) and anyone else watching it
186
+ (`inotify-watcher`). When a writer is fast enough to close its descriptor before
187
+ the snapshot, the witness says so rather than guessing:
188
+
189
+ ```
190
+ ATTRIBUTION-MISS: writer closed its fd before the snapshot; last known: 4821 yq
191
+ ```
192
+
193
+ ## Commands
194
+
195
+ | Command | What it does | Writes? |
196
+ |---------|--------------|:-------:|
197
+ | `hash PATH` | Print the file's sha256 promise, or the `MISSING` sentinel. Machine-clean on stdout — no colour, no note — so `H=$(cfg-guard hash f)` is exact. | no |
198
+ | `edit PATH` | The only writer. A `-- cmd` filter, `--stdin`, or `$EDITOR`, all under `flock` + the hash promise. Refuses a base that moved (exit `3`). | yes |
199
+ | `watch PATH` | Witness the file: attribute every real change to a live `/proc` fd holder, suppress no-ops by sha256. Linux-only. | no |
200
+ | `selfcheck` | Offline assert suite — lockedit, watch, the exit-code contract, parser wiring, colour policy, editor resolution. **This is what CI runs.** | no |
201
+
202
+ Common options — `edit`: `--expect HASH`, `--stdin`, `--editor ARGV`,
203
+ `--no-backup`, `--timeout S`, `--color {auto,always,never}`, `-q/--quiet`.
204
+ `watch`: `--heartbeat S`, `--max-events N`, `--color {auto,always,never}`.
205
+
206
+ `--expect MISSING` turns a write into a create-only guard: it lands only if
207
+ nobody made the file first.
208
+
209
+ ## Exit codes
210
+
211
+ A cron contract. `3` is a finding, not noise.
212
+
213
+ | Code | Meaning |
214
+ |:----:|---------|
215
+ | `0` | Done. A refused stale write is **not** this — see `3`. |
216
+ | `2` | Usage error, unreadable file, a failed filter command, or `watch` off Linux. |
217
+ | `3` | Stale promise: the bytes moved since you read them. Nothing was written — the clobber was prevented. |
218
+ | `4` | Lock busy: another writer held the flock past `--timeout`. |
219
+ | `130` | Interrupted. |
220
+
221
+ ## How it holds
222
+
223
+ **`lockedit` — the lock and the promise.** `flock` on a sidecar `<file>.lock`
224
+ serializes every writer polite enough to take the lock. A content-hash
225
+ compare-and-swap catches the impolite ones: you snapshot the hash at read time
226
+ and hand it back as `--expect`; if the bytes moved in between, the write is
227
+ refused and nothing is touched. Writes are atomic (a temp file plus
228
+ `os.replace`). Correctness never rests on the lock alone — on a platform without
229
+ `flock` the lock is a no-op and the promise still holds.
230
+
231
+ **`watch` — the witness.** `inotify` hands you a mask and a filename but never a
232
+ PID, so on each event the watcher snapshots `/proc/*/fd` *first* — before hashing,
233
+ before logging — because a fast writer closes its descriptor in milliseconds and
234
+ any delay loses the trail. It then separates real content changes from no-ops by
235
+ sha256, and reads `/proc/*/fdinfo` to name the other inotify watchers sharing the
236
+ file (an agent that auto-saves a config usually watches it too).
237
+
238
+ Three sidecar files, all next to the target:
239
+
240
+ | Suffix | What it is |
241
+ |--------|------------|
242
+ | `<file>.lock` | The `flock` sidecar. Not the config itself, so an atomic temp+rename that replaces the inode never drops the lock. |
243
+ | `<file>.bak` | A copy of the pre-write bytes, kept unless `--no-backup`. |
244
+ | `<file>.tmp` | The atomic-write scratch, renamed over the target then unlinked. |
245
+
246
+ ## Platform
247
+
248
+ `flock` is POSIX; `inotify` + `/proc` are Linux. The two cores degrade
249
+ independently and honestly:
250
+
251
+ - **Linux** — everything: serialized writes, the hash promise, and a witness that
252
+ names the writer.
253
+ - **Other POSIX (macOS/BSD)** — `hash` and `edit` work in full; the lock is a
254
+ no-op, so the content-hash promise is what prevents the clobber. `watch`
255
+ reports itself unavailable.
256
+ - **Windows** — same: `hash`/`edit` ride on the promise, `watch` exits `2` with a
257
+ plain "this platform has neither inotify nor /proc" rather than a fake watch.
258
+
259
+ ## Library use
260
+
261
+ ```python
262
+ from cfgguard import file_hash, guarded_write, StaleConfig
263
+
264
+ seen = file_hash("gateway.yaml") # snapshot at read time
265
+ try:
266
+ guarded_write("gateway.yaml", new_bytes, expect=seen)
267
+ except StaleConfig:
268
+ ... # someone moved it; re-read and redo
269
+ ```
270
+
271
+ The witness is a library too: `Witness`, `fd_holders`, `inotify_watchers`, and
272
+ `watch_available` are all re-exported from the package root. `transform()` runs a
273
+ stdin→stdout filter under the same lock + promise; `edit_inplace()` serializes an
274
+ interactive editor.
275
+
276
+ ## Development
277
+
278
+ ```bash
279
+ python -m cfgguard selfcheck # the whole offline contract; CI runs exactly this
280
+ ```
281
+
282
+ `selfcheck` needs no network, no config file and no state on disk. On Linux it
283
+ drives a real inotify round-trip — write a file, catch the attributed event,
284
+ assert `CONTENT CHANGED`. If it passes, the lock, the promise, the witness, the
285
+ exit-code contract and the parser wiring are all behaving.
286
+
287
+ ## License
288
+
289
+ MIT — see [LICENSE](LICENSE).
290
+
291
+ [cliproxy]: https://github.com/router-for-me/CLIProxyAPI
@@ -0,0 +1,242 @@
1
+ <div align="center">
2
+
3
+ # cfg-guard
4
+
5
+ **A lock and a witness for config files too many hands edit.**
6
+ `flock` + a content-hash promise to stop the clobber, `inotify` + `/proc` to name the writer.
7
+
8
+ [![python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
9
+ [![dependencies](https://img.shields.io/badge/dependencies-stdlib%20only-brightgreen.svg)](#install)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ A config that several hands write is a config that silently loses edits. Two
17
+ agents read the same file, each changes its own copy, the second write lands on
18
+ top of the first — no exception, no conflict marker, just a key that is quietly
19
+ gone. The agent whose change vanished will not notice until something downstream
20
+ reads a config it never wrote.
21
+
22
+ `cfg-guard` is two tools for that one problem. A **lock and a promise** (`flock`
23
+ plus a content-hash compare-and-swap) stop *you* from clobbering others and
24
+ refuse a write whose base bytes moved. A **witness** (`inotify` plus `/proc`)
25
+ names whoever else writes the file while you are not looking. It grew out of
26
+ running a live [cliproxy] config that several agents and a few cron jobs edit at
27
+ once, and learning the hard way how many "the config is wrong again" incidents
28
+ were really a race nobody could see.
29
+
30
+ ## The two failures
31
+
32
+ Both are silent. Both are the reason this exists.
33
+
34
+ 1. **Clobber.** Two writers, one file, last write wins — and the loser never
35
+ hears about it. `cfgguard.lockedit` prevents it two ways: `flock` serializes
36
+ the writers polite enough to take the lock, and a content-hash promise
37
+ (compare-and-swap) refuses a write whose base bytes moved, catching the ones
38
+ that never heard of the lock. A refusal is not a failure; it is the clobber
39
+ being *prevented*. Exit `3` means the tool did its job.
40
+
41
+ 2. **Invisible writers.** A vendor agent, a hot-reload, a cron mutates the file
42
+ and nobody can say when or by whom. `cfgguard.watch` is the witness: it sits
43
+ on `inotify` and attributes every real change to a live `/proc` fd holder,
44
+ separating content edits from open-and-close no-ops by sha256. `inotify` never
45
+ hands you a PID, so the watcher snapshots `/proc/*/fd` *first* on every event
46
+ — the fd trail fades in milliseconds.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install cfg-guard
52
+ ```
53
+
54
+ No PyPI release yet? Install straight from the checkout:
55
+
56
+ ```bash
57
+ git clone https://gitlab.com/ameobius-ai/cfg-guard
58
+ cd cfg-guard && pip install .
59
+ ```
60
+
61
+ The engine has **zero runtime dependencies** — pure stdlib. `flock` is POSIX and
62
+ `inotify` + `/proc` are Linux, so the witness is Linux-only; everywhere else the
63
+ lock degrades to a no-op (the hash promise still holds) and `watch` says so
64
+ instead of pretending. See [Platform](#platform).
65
+
66
+ ## Quickstart
67
+
68
+ ```bash
69
+ # 1. Snapshot the promise you will hand back as --expect
70
+ H=$(cfg-guard hash gateway.yaml)
71
+
72
+ # 2. Edit under lock + promise: a stdin->stdout filter, refused if the base moved
73
+ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
74
+
75
+ # 3. Or replace the whole file from stdin, still under the same guard
76
+ cfg-guard edit gateway.yaml --expect "$H" --stdin < new.yaml
77
+
78
+ # 4. Or open $EDITOR, serialized by flock so a second editor waits
79
+ cfg-guard edit gateway.yaml
80
+
81
+ # 5. Witness who else writes it, live, with the writer named from /proc
82
+ cfg-guard watch gateway.yaml
83
+ ```
84
+
85
+ `edit` is the only command that writes. Everything after the first bare `--` is
86
+ the filter argv — `cfg-guard` splits it off before argparse sees it, so the
87
+ filter's own flags are never mistaken for ours.
88
+
89
+ ### What a guarded write looks like
90
+
91
+ ```console
92
+ $ H=$(cfg-guard hash gateway.yaml)
93
+ $ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
94
+ gateway.yaml: wrote 3f2a9c1d4e5b -> 8c1f0a2b3d4e
95
+ cfg-guard: previous copy kept at gateway.yaml.bak
96
+ ```
97
+
98
+ One honest line: what moved. The pre-write bytes are copied to `.bak` unless you
99
+ pass `--no-backup`. An identical write is detected and skipped, so a caller can
100
+ re-assert the whole desired config every run without churning backups:
101
+
102
+ ```console
103
+ $ cfg-guard edit gateway.yaml --stdin < same.yaml
104
+ cfg-guard: no-op, gateway.yaml already 8c1f0a2b3d4e
105
+ ```
106
+
107
+ ### What a prevented clobber looks like
108
+
109
+ Someone else wrote the file between your `hash` and your `edit`. The promise no
110
+ longer matches, so the write is refused and **nothing on disk is touched**:
111
+
112
+ ```console
113
+ $ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
114
+ cfg-guard: error: gateway.yaml: expected hash 3f2a9c1d4e5b but on-disk is 8c1f0a2b3d4e — another writer moved it; re-read and redo the edit
115
+ $ echo $?
116
+ 3
117
+ ```
118
+
119
+ Re-read, redo the edit against the new bytes. That loop is the whole point —
120
+ better a refused write than a vanished one.
121
+
122
+ ### What the witness sees
123
+
124
+ ```console
125
+ $ cfg-guard watch ~/.config/gateway.yaml
126
+ cfg-guard: witnessing /home/ops/.config/gateway.yaml (Ctrl-C to stop)
127
+ watching /home/ops/.config/gateway.yaml (baseline 3f2a9c1d4e5b)
128
+ no-op x1: ATTRIB content unchanged (3f2a9c1d4e5b)
129
+ CONTENT CHANGED MODIFY|CLOSE_WRITE: 3f2a9c1d4e5b -> 8c1f0a2b3d4e
130
+ fd-holder file pid=4821 comm=yq yq .timeout = 30 gateway.yaml
131
+ inotify-watcher pid=3390 comm=hermes
132
+ ```
133
+
134
+ The `no-op` line is a metadata touch that did not change a single byte — the
135
+ sha256 gate keeps it from flooding the log. The `CONTENT CHANGED` block names the
136
+ process caught holding the file open (`fd-holder`) and anyone else watching it
137
+ (`inotify-watcher`). When a writer is fast enough to close its descriptor before
138
+ the snapshot, the witness says so rather than guessing:
139
+
140
+ ```
141
+ ATTRIBUTION-MISS: writer closed its fd before the snapshot; last known: 4821 yq
142
+ ```
143
+
144
+ ## Commands
145
+
146
+ | Command | What it does | Writes? |
147
+ |---------|--------------|:-------:|
148
+ | `hash PATH` | Print the file's sha256 promise, or the `MISSING` sentinel. Machine-clean on stdout — no colour, no note — so `H=$(cfg-guard hash f)` is exact. | no |
149
+ | `edit PATH` | The only writer. A `-- cmd` filter, `--stdin`, or `$EDITOR`, all under `flock` + the hash promise. Refuses a base that moved (exit `3`). | yes |
150
+ | `watch PATH` | Witness the file: attribute every real change to a live `/proc` fd holder, suppress no-ops by sha256. Linux-only. | no |
151
+ | `selfcheck` | Offline assert suite — lockedit, watch, the exit-code contract, parser wiring, colour policy, editor resolution. **This is what CI runs.** | no |
152
+
153
+ Common options — `edit`: `--expect HASH`, `--stdin`, `--editor ARGV`,
154
+ `--no-backup`, `--timeout S`, `--color {auto,always,never}`, `-q/--quiet`.
155
+ `watch`: `--heartbeat S`, `--max-events N`, `--color {auto,always,never}`.
156
+
157
+ `--expect MISSING` turns a write into a create-only guard: it lands only if
158
+ nobody made the file first.
159
+
160
+ ## Exit codes
161
+
162
+ A cron contract. `3` is a finding, not noise.
163
+
164
+ | Code | Meaning |
165
+ |:----:|---------|
166
+ | `0` | Done. A refused stale write is **not** this — see `3`. |
167
+ | `2` | Usage error, unreadable file, a failed filter command, or `watch` off Linux. |
168
+ | `3` | Stale promise: the bytes moved since you read them. Nothing was written — the clobber was prevented. |
169
+ | `4` | Lock busy: another writer held the flock past `--timeout`. |
170
+ | `130` | Interrupted. |
171
+
172
+ ## How it holds
173
+
174
+ **`lockedit` — the lock and the promise.** `flock` on a sidecar `<file>.lock`
175
+ serializes every writer polite enough to take the lock. A content-hash
176
+ compare-and-swap catches the impolite ones: you snapshot the hash at read time
177
+ and hand it back as `--expect`; if the bytes moved in between, the write is
178
+ refused and nothing is touched. Writes are atomic (a temp file plus
179
+ `os.replace`). Correctness never rests on the lock alone — on a platform without
180
+ `flock` the lock is a no-op and the promise still holds.
181
+
182
+ **`watch` — the witness.** `inotify` hands you a mask and a filename but never a
183
+ PID, so on each event the watcher snapshots `/proc/*/fd` *first* — before hashing,
184
+ before logging — because a fast writer closes its descriptor in milliseconds and
185
+ any delay loses the trail. It then separates real content changes from no-ops by
186
+ sha256, and reads `/proc/*/fdinfo` to name the other inotify watchers sharing the
187
+ file (an agent that auto-saves a config usually watches it too).
188
+
189
+ Three sidecar files, all next to the target:
190
+
191
+ | Suffix | What it is |
192
+ |--------|------------|
193
+ | `<file>.lock` | The `flock` sidecar. Not the config itself, so an atomic temp+rename that replaces the inode never drops the lock. |
194
+ | `<file>.bak` | A copy of the pre-write bytes, kept unless `--no-backup`. |
195
+ | `<file>.tmp` | The atomic-write scratch, renamed over the target then unlinked. |
196
+
197
+ ## Platform
198
+
199
+ `flock` is POSIX; `inotify` + `/proc` are Linux. The two cores degrade
200
+ independently and honestly:
201
+
202
+ - **Linux** — everything: serialized writes, the hash promise, and a witness that
203
+ names the writer.
204
+ - **Other POSIX (macOS/BSD)** — `hash` and `edit` work in full; the lock is a
205
+ no-op, so the content-hash promise is what prevents the clobber. `watch`
206
+ reports itself unavailable.
207
+ - **Windows** — same: `hash`/`edit` ride on the promise, `watch` exits `2` with a
208
+ plain "this platform has neither inotify nor /proc" rather than a fake watch.
209
+
210
+ ## Library use
211
+
212
+ ```python
213
+ from cfgguard import file_hash, guarded_write, StaleConfig
214
+
215
+ seen = file_hash("gateway.yaml") # snapshot at read time
216
+ try:
217
+ guarded_write("gateway.yaml", new_bytes, expect=seen)
218
+ except StaleConfig:
219
+ ... # someone moved it; re-read and redo
220
+ ```
221
+
222
+ The witness is a library too: `Witness`, `fd_holders`, `inotify_watchers`, and
223
+ `watch_available` are all re-exported from the package root. `transform()` runs a
224
+ stdin→stdout filter under the same lock + promise; `edit_inplace()` serializes an
225
+ interactive editor.
226
+
227
+ ## Development
228
+
229
+ ```bash
230
+ python -m cfgguard selfcheck # the whole offline contract; CI runs exactly this
231
+ ```
232
+
233
+ `selfcheck` needs no network, no config file and no state on disk. On Linux it
234
+ drives a real inotify round-trip — write a file, catch the attributed event,
235
+ assert `CONTENT CHANGED`. If it passes, the lock, the promise, the witness, the
236
+ exit-code contract and the parser wiring are all behaving.
237
+
238
+ ## License
239
+
240
+ MIT — see [LICENSE](LICENSE).
241
+
242
+ [cliproxy]: https://github.com/router-for-me/CLIProxyAPI