Metadata-Version: 2.4
Name: cfg-guard
Version: 1.1.0
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.
License: MIT License
        
        Copyright (c) 2026 ameobius
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://gitlab.com/ameobius-ai/cfg-guard
Project-URL: Source, https://gitlab.com/ameobius-ai/cfg-guard
Project-URL: Issues, https://gitlab.com/ameobius-ai/cfg-guard/-/issues
Keywords: config,configuration,flock,inotify,concurrency,race-condition,ops,sysadmin
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Filesystems
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

![cfg-guard — Guard the write. Trace the change.](assets/brand/readme-cover.svg)

# cfg-guard

**Guard the write. Trace the change.**

Guard config edits with a file lock and a content-hash precondition. Observe changes with a Linux inotify witness that looks for live file-descriptor holders.

[Install](#install) · [Quickstart](#quickstart) · [Commands](#commands) · [Platform](#platform) · [Contributing](CONTRIBUTING.md)

**Scope:** The witness is Linux-only and process attribution is best-effort. Lock behavior is platform-dependent; see [Platform](#platform). This tool does not replace file permissions or cooperation between writers.

**Checks:** run `python -m cfgguard selfcheck` before pushing.
This is the repository's offline contract; it needs no live service or credentials.

<details>
<summary>Package and license badges</summary>

[![python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![dependencies](https://img.shields.io/badge/dependencies-stdlib%20only-brightgreen.svg)](#install)
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

</details>

## Why it exists

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

Use **Python 3.10+**. From a checkout, install into a virtual environment you own
(no service config, credentials or privileged install needed). These commands use
a POSIX shell; see [Platform](#platform) before using another OS:

```bash
git clone https://gitlab.com/ameobius-ai/cfg-guard.git
cd cfg-guard
python3 -m venv .venv
. .venv/bin/activate
python -m pip install .
cfg-guard --version
```

The checkout reports `cfg-guard 1.1.0`. Installation can retrieve the build tooling
from [pyproject.toml](pyproject.toml); it is not a zero-network step. The installed
engine has **zero runtime dependencies** (pure stdlib), and the disposable example
below needs no network. For an available published release, the alternative is
`python -m pip install cfg-guard` in the same virtual environment; the checkout
route does not depend on a PyPI release being available.

`flock` is used where Python provides `fcntl`; without it the lock is a no-op,
while the hash precondition still checks the base bytes. The witness needs Linux
`inotify` + readable `/proc`; see [Platform](#platform). For contributor checks,
use the existing [contribution guide](CONTRIBUTING.md#run-the-existing-contract).

## Quickstart

### Disposable first run

With the virtual environment above active, copy this whole POSIX-shell block.
It creates **only a new temporary directory**, never opens your real config, and
removes that directory and its `.bak`/`.lock` sidecars on exit. It uses `--stdin`,
so no `yq`, editor or live watcher is needed:

```bash
(
set -eu
demo=$(mktemp -d "${TMPDIR:-/tmp}/cfg-guard-demo.XXXXXX")
trap 'rm -rf -- "$demo"' EXIT
cd "$demo"

printf 'timeout: 10\n' > gateway.yaml
H=$(cfg-guard hash gateway.yaml)
printf 'timeout: 30\n' | cfg-guard edit gateway.yaml --expect "$H" --stdin --color never
cat gateway.yaml
UPDATED=$(cfg-guard hash gateway.yaml)
printf '%s\n' "$UPDATED"

# H is deliberately stale now: expect refusal, not a second write.
if printf 'timeout: 60\n' | cfg-guard edit gateway.yaml --expect "$H" --stdin --color never; then
    printf 'Expected the stale edit to be refused\n' >&2
    exit 1
else
    rc=$?
    test "$rc" -eq 3
    printf 'stale edit exit: %s\n' "$rc"
fi

test "$(cfg-guard hash gateway.yaml)" = "$UPDATED"
test "$(cat gateway.yaml.bak)" = 'timeout: 10'
test ! -e gateway.yaml.tmp
cat gateway.yaml
)
```

Expected stdout (the input bytes and SHA-256 are exact):

```text
gateway.yaml: wrote a98fa1e72993 -> 6e4206a968e1
timeout: 30
6e4206a968e12622b1c42b272ee6faa88501e8522f3391be751753ff7d03e008
stale edit exit: 3
timeout: 30
```

Stderr also includes these two notices (stdout/stderr can interleave):

```text
cfg-guard: previous copy kept at gateway.yaml.bak
cfg-guard: error: gateway.yaml: expected hash a98fa1e72993 but on-disk is 6e4206a968e1 — another writer moved it; re-read and redo the edit
```

The first edit exits `0`; it writes `timeout: 30` and keeps the original
`timeout: 10` in `.bak`. The deliberately stale edit exits `3`, which the `if`
checks explicitly; the complete example then exits `0`. The final hash and bytes
stay unchanged after that refusal. A normal `.lock` sidecar is created/reused even
when checking a write that is refused; no `.tmp` scratch is left behind. The trap
removes **only the directory this example created**, including its backup and
lock file. Do not remove a lock file while real cooperating writers use it.

On POSIX with `fcntl`, edits also take the sidecar lock. Exit `4` means another
cooperating writer held it past `--timeout`; the existing
[`selfcheck`](#development) exercises contention and release on that path. Locks
are advisory, and this example is not a permission or isolation guarantee.

### Validate before the write, keep the inode

A config that parses for your editor can still kill the service that reads it.
`--validate CMD` runs `CMD <candidate-file>` on the new bytes before they land; a
non-zero exit refuses the write with exit `5` and leaves the file, its `.bak`, and the
directory untouched:

```bash
cfg-guard edit gateway.yaml --expect "$H" \
  --validate "python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))'" \
  -- yq '.timeout = 30'
```

`--keep-inode` rewrites the file in place instead of renaming a temp over it. Use it
when a consumer is pinned to the inode: a single-file bind mount into a container, or a
hot-reload watcher that holds a descriptor. After a rename that consumer keeps reading
the old bytes, so the edit looks applied and is not. In-place writes are not atomic for
concurrent readers, so this mode is opt-in. `--validate` works with a filter command or
`--stdin`; the interactive editor writes the file itself, so it is refused there.

### Other editing and witness modes

These are alternative patterns for an existing file you own, not a second
copy-paste sequence. Refresh `H` before each independent edit. The filter example
requires an external `yq`, which cfg-guard does not install; the disposable
`--stdin` example above needs no such tool. Interactive editors and the long-lived
Linux watcher are optional, not prerequisites for the first run:

```bash
# 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

```console
$ 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:

```console
$ 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 **the config bytes are not written**.
The normal `.lock` sidecar can still be created or reused while checking it:

```console
$ 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

```console
$ 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** — serialized cooperating writes, the hash precondition, and an
  inotify witness with best-effort process attribution.
- **Other POSIX (macOS/BSD)** — `hash` and `edit` use the `fcntl.flock` path when
  Python provides it; it is not automatically a no-op just because the OS is not
  Linux. Without `fcntl`, only the hash precondition remains. `watch` reports
  itself unavailable. A portable selfcheck result does not prove Linux witness
  attribution.
- **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

```python
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

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

`selfcheck` needs no network, credentials or pre-existing config. It creates and
cleans its own temporary files; it is not a no-disk-writes command. 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 on the exercised
platform. Run normal Python, not `python -O` or `PYTHONOPTIMIZE`, so the existing
assertions execute.

## License

MIT — see [LICENSE](LICENSE).

[cliproxy]: https://github.com/router-for-me/CLIProxyAPI
