Skip to main content

cfg-guard

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.

python dependencies license


A config that several hands write is a config that silently loses edits. Two agents read the same file, each changes its own copy, the second write lands on top of the first — no exception, no conflict marker, just a key that is quietly gone. The agent whose change vanished will not notice until something downstream reads a config it never wrote.

cfg-guard is two tools for that one problem. A lock and a promise (flock plus a content-hash compare-and-swap) stop you from clobbering others and refuse a write whose base bytes moved. A witness (inotify plus /proc) names whoever else writes the file while you are not looking. It grew out of running a live cliproxy config that several agents and a few cron jobs edit at once, and learning the hard way how many "the config is wrong again" incidents were really a race nobody could see.

The two failures

Both are silent. Both are the reason this exists.

  1. Clobber. Two writers, one file, last write wins — and the loser never hears about it. cfgguard.lockedit prevents it two ways: flock serializes the writers polite enough to take the lock, and a content-hash promise (compare-and-swap) refuses a write whose base bytes moved, catching the ones that never heard of the lock. A refusal is not a failure; it is the clobber being prevented. Exit 3 means the tool did its job.

  2. Invisible writers. A vendor agent, a hot-reload, a cron mutates the file and nobody can say when or by whom. cfgguard.watch is the witness: it sits on inotify and attributes every real change to a live /proc fd holder, separating content edits from open-and-close no-ops by sha256. inotify never hands you a PID, so the watcher snapshots /proc/*/fd first on every event — the fd trail fades in milliseconds.

Install

pip install cfg-guard

No PyPI release yet? Install straight from the checkout:

git clone https://gitlab.com/ameobius-ai/cfg-guard
cd cfg-guard && pip install .

The engine has zero runtime dependencies — pure stdlib. flock is POSIX and inotify + /proc are Linux, so the witness is Linux-only; everywhere else the lock degrades to a no-op (the hash promise still holds) and watch says so instead of pretending. See Platform.

Quickstart

# 1. Snapshot the promise you will hand back as --expect
H=$(cfg-guard hash gateway.yaml)

# 2. Edit under lock + promise: a stdin->stdout filter, refused if the base moved
cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'

# 3. Or replace the whole file from stdin, still under the same guard
cfg-guard edit gateway.yaml --expect "$H" --stdin < new.yaml

# 4. Or open $EDITOR, serialized by flock so a second editor waits
cfg-guard edit gateway.yaml

# 5. Witness who else writes it, live, with the writer named from /proc
cfg-guard watch gateway.yaml

edit is the only command that writes. Everything after the first bare -- is the filter argv — cfg-guard splits it off before argparse sees it, so the filter's own flags are never mistaken for ours.

What a guarded write looks like

$ H=$(cfg-guard hash gateway.yaml)
$ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
gateway.yaml: wrote 3f2a9c1d4e5b -> 8c1f0a2b3d4e
cfg-guard: previous copy kept at gateway.yaml.bak

One honest line: what moved. The pre-write bytes are copied to .bak unless you pass --no-backup. An identical write is detected and skipped, so a caller can re-assert the whole desired config every run without churning backups:

$ cfg-guard edit gateway.yaml --stdin < same.yaml
cfg-guard: no-op, gateway.yaml already 8c1f0a2b3d4e

What a prevented clobber looks like

Someone else wrote the file between your hash and your edit. The promise no longer matches, so the write is refused and nothing on disk is touched:

$ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
cfg-guard: error: gateway.yaml: expected hash 3f2a9c1d4e5b but on-disk is 8c1f0a2b3d4e — another writer moved it; re-read and redo the edit
$ echo $?
3

Re-read, redo the edit against the new bytes. That loop is the whole point — better a refused write than a vanished one.

What the witness sees

$ cfg-guard watch ~/.config/gateway.yaml
cfg-guard: witnessing /home/ops/.config/gateway.yaml (Ctrl-C to stop)
watching /home/ops/.config/gateway.yaml (baseline 3f2a9c1d4e5b)
no-op x1: ATTRIB content unchanged (3f2a9c1d4e5b)
CONTENT CHANGED MODIFY|CLOSE_WRITE: 3f2a9c1d4e5b -> 8c1f0a2b3d4e
    fd-holder file pid=4821 comm=yq yq .timeout = 30 gateway.yaml
    inotify-watcher pid=3390 comm=hermes

The no-op line is a metadata touch that did not change a single byte — the sha256 gate keeps it from flooding the log. The CONTENT CHANGED block names the process caught holding the file open (fd-holder) and anyone else watching it (inotify-watcher). When a writer is fast enough to close its descriptor before the snapshot, the witness says so rather than guessing:

    ATTRIBUTION-MISS: writer closed its fd before the snapshot; last known: 4821 yq

Commands

Command What it does Writes?
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
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
watch PATH Witness the file: attribute every real change to a live /proc fd holder, suppress no-ops by sha256. Linux-only. no
selfcheck Offline assert suite — lockedit, watch, the exit-code contract, parser wiring, colour policy, editor resolution. This is what CI runs. no

Common options — edit: --expect HASH, --stdin, --editor ARGV, --no-backup, --timeout S, --color {auto,always,never}, -q/--quiet. watch: --heartbeat S, --max-events N, --color {auto,always,never}.

--expect MISSING turns a write into a create-only guard: it lands only if nobody made the file first.

Exit codes

A cron contract. 3 is a finding, not noise.

Code Meaning
0 Done. A refused stale write is not this — see 3.
2 Usage error, unreadable file, a failed filter command, or watch off Linux.
3 Stale promise: the bytes moved since you read them. Nothing was written — the clobber was prevented.
4 Lock busy: another writer held the flock past --timeout.
130 Interrupted.

How it holds

lockedit — the lock and the promise. flock on a sidecar <file>.lock serializes every writer polite enough to take the lock. A content-hash compare-and-swap catches the impolite ones: you snapshot the hash at read time and hand it back as --expect; if the bytes moved in between, the write is refused and nothing is touched. Writes are atomic (a temp file plus os.replace). Correctness never rests on the lock alone — on a platform without flock the lock is a no-op and the promise still holds.

watch — the witness. inotify hands you a mask and a filename but never a PID, so on each event the watcher snapshots /proc/*/fd first — before hashing, before logging — because a fast writer closes its descriptor in milliseconds and any delay loses the trail. It then separates real content changes from no-ops by sha256, and reads /proc/*/fdinfo to name the other inotify watchers sharing the file (an agent that auto-saves a config usually watches it too).

Three sidecar files, all next to the target:

Suffix What it is
<file>.lock The flock sidecar. Not the config itself, so an atomic temp+rename that replaces the inode never drops the lock.
<file>.bak A copy of the pre-write bytes, kept unless --no-backup.
<file>.tmp The atomic-write scratch, renamed over the target then unlinked.

Platform

flock is POSIX; inotify + /proc are Linux. The two cores degrade independently and honestly:

  • Linux — everything: serialized writes, the hash promise, and a witness that names the writer.
  • Other POSIX (macOS/BSD)hash and edit work in full; the lock is a no-op, so the content-hash promise is what prevents the clobber. watch reports itself unavailable.
  • Windows — same: hash/edit ride on the promise, watch exits 2 with a plain "this platform has neither inotify nor /proc" rather than a fake watch.

Library use

from cfgguard import file_hash, guarded_write, StaleConfig

seen = file_hash("gateway.yaml")          # snapshot at read time
try:
    guarded_write("gateway.yaml", new_bytes, expect=seen)
except StaleConfig:
    ...                                    # someone moved it; re-read and redo

The witness is a library too: Witness, fd_holders, inotify_watchers, and watch_available are all re-exported from the package root. transform() runs a stdin→stdout filter under the same lock + promise; edit_inplace() serializes an interactive editor.

Development

python -m cfgguard selfcheck   # the whole offline contract; CI runs exactly this

selfcheck needs no network, no config file and no state on disk. On Linux it drives a real inotify round-trip — write a file, catch the attributed event, assert CONTENT CHANGED. If it passes, the lock, the promise, the witness, the exit-code contract and the parser wiring are all behaving.

License

MIT — see LICENSE.

Release files for cfg-guard 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cfg-guard 1.0.0
File Size Uploaded
cfg_guard-1.0.0.tar.gz 20.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cfg-guard 1.0.0
File Interpreter ABI Platform
cfg_guard-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 44.6 kB

Release files / cfg_guard-1.0.0.tar.gz

Download URL cfg_guard-1.0.0.tar.gz
Size 20.5 kB
Tags Source
SHA-256 checksum
How to use checksums
0c933c8157cdf70182061097885dcdc0279737f527c3b828bc8cf3fce7146261
BLAKE2b-256 checksum
How to use checksums
3de527ffae179f9f312a232ce3735f9c7d4c2927343a7b8874ce62bd0390afca
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.10.12

Release files / cfg_guard-1.0.0-py3-none-any.whl

Download URL cfg_guard-1.0.0-py3-none-any.whl
Size 24.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0ea29ca62d9c49094b0e6536632e456897a60ba91fd5137b03483458c3958d3a
BLAKE2b-256 checksum
How to use checksums
58c3df678aead0b6a2497a741fbd5c4e4e9f933512501c37ad73c0e44a3c56e0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.10.12

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page