ceiling-guard 0.5.3__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.
- ceiling_guard-0.5.3/CHANGELOG.md +533 -0
- ceiling_guard-0.5.3/CONTRIBUTING.md +58 -0
- ceiling_guard-0.5.3/Dockerfile +47 -0
- ceiling_guard-0.5.3/LICENSE +21 -0
- ceiling_guard-0.5.3/MANIFEST.in +17 -0
- ceiling_guard-0.5.3/PKG-INFO +810 -0
- ceiling_guard-0.5.3/README.md +764 -0
- ceiling_guard-0.5.3/SECURITY.md +95 -0
- ceiling_guard-0.5.3/TESTING.md +512 -0
- ceiling_guard-0.5.3/ceiling_guard.egg-info/PKG-INFO +810 -0
- ceiling_guard-0.5.3/ceiling_guard.egg-info/SOURCES.txt +81 -0
- ceiling_guard-0.5.3/ceiling_guard.egg-info/dependency_links.txt +1 -0
- ceiling_guard-0.5.3/ceiling_guard.egg-info/entry_points.txt +5 -0
- ceiling_guard-0.5.3/ceiling_guard.egg-info/top_level.txt +1 -0
- ceiling_guard-0.5.3/config.example.toml +227 -0
- ceiling_guard-0.5.3/continuity_guard/__init__.py +14 -0
- ceiling_guard-0.5.3/continuity_guard/actions.py +260 -0
- ceiling_guard-0.5.3/continuity_guard/cli.py +672 -0
- ceiling_guard-0.5.3/continuity_guard/client.py +312 -0
- ceiling_guard-0.5.3/continuity_guard/config.example.toml +227 -0
- ceiling_guard-0.5.3/continuity_guard/config.py +299 -0
- ceiling_guard-0.5.3/continuity_guard/daemon.py +1115 -0
- ceiling_guard-0.5.3/continuity_guard/detectors.py +553 -0
- ceiling_guard-0.5.3/continuity_guard/handoff.py +195 -0
- ceiling_guard-0.5.3/continuity_guard/integrations.py +259 -0
- ceiling_guard-0.5.3/continuity_guard/protocol.py +212 -0
- ceiling_guard-0.5.3/continuity_guard/proxy.py +414 -0
- ceiling_guard-0.5.3/continuity_guard/quota.py +794 -0
- ceiling_guard-0.5.3/continuity_guard/state.py +227 -0
- ceiling_guard-0.5.3/continuity_guard/supervise.py +590 -0
- ceiling_guard-0.5.3/continuity_guard/transport.py +133 -0
- ceiling_guard-0.5.3/docs/README.md +23 -0
- ceiling_guard-0.5.3/docs/account-window.md +150 -0
- ceiling_guard-0.5.3/docs/calibrate.md +124 -0
- ceiling_guard-0.5.3/docs/confirmation-window.md +133 -0
- ceiling_guard-0.5.3/docs/failure-mode-evidence.md +220 -0
- ceiling_guard-0.5.3/docs/integrating.md +148 -0
- ceiling_guard-0.5.3/docs/launch-post.md +138 -0
- ceiling_guard-0.5.3/docs/next-window-test.md +132 -0
- ceiling_guard-0.5.3/docs/platform-support.md +86 -0
- ceiling_guard-0.5.3/docs/quota-accuracy.md +337 -0
- ceiling_guard-0.5.3/docs/quota-adapters.md +145 -0
- ceiling_guard-0.5.3/docs/v0.4.2-hardening-and-backstop.md +163 -0
- ceiling_guard-0.5.3/docs/wrapup-signal.md +262 -0
- ceiling_guard-0.5.3/pyproject.toml +47 -0
- ceiling_guard-0.5.3/setup.cfg +4 -0
- ceiling_guard-0.5.3/systemd/continuity-guard.service +30 -0
- ceiling_guard-0.5.3/tests/armed_real_agent.sh +110 -0
- ceiling_guard-0.5.3/tests/bench_accuracy.py +575 -0
- ceiling_guard-0.5.3/tests/campaign/agent.py +259 -0
- ceiling_guard-0.5.3/tests/campaign/burn_claude.py +166 -0
- ceiling_guard-0.5.3/tests/campaign/burn_review.py +225 -0
- ceiling_guard-0.5.3/tests/campaign/run_burn.sh +101 -0
- ceiling_guard-0.5.3/tests/campaign/run_phase.py +237 -0
- ceiling_guard-0.5.3/tests/fake_cli_agent.sh +47 -0
- ceiling_guard-0.5.3/tests/fuzz_protocol.py +423 -0
- ceiling_guard-0.5.3/tests/mock_openai_server.py +143 -0
- ceiling_guard-0.5.3/tests/pi_verify.sh +155 -0
- ceiling_guard-0.5.3/tests/real_faults.sh +159 -0
- ceiling_guard-0.5.3/tests/run_matrix.sh +414 -0
- ceiling_guard-0.5.3/tests/soak_stability.py +429 -0
- ceiling_guard-0.5.3/tests/test_authority.py +407 -0
- ceiling_guard-0.5.3/tests/test_ceiling_signal.py +193 -0
- ceiling_guard-0.5.3/tests/test_env_knobs.py +177 -0
- ceiling_guard-0.5.3/tests/test_packaging.py +139 -0
- ceiling_guard-0.5.3/tests/test_protocol.py +102 -0
- ceiling_guard-0.5.3/tests/test_quota.py +752 -0
- ceiling_guard-0.5.3/tests/test_quota_confirm.py +203 -0
- ceiling_guard-0.5.3/tests/test_statusline.py +173 -0
- ceiling_guard-0.5.3/tests/test_wrap_output.py +257 -0
- ceiling_guard-0.5.3/tests/test_wrap_signals.py +170 -0
- ceiling_guard-0.5.3/tests/test_wrapup_race.py +151 -0
- ceiling_guard-0.5.3/tests/verify_quota_tracking.py +163 -0
- ceiling_guard-0.5.3/tools/cg_proxy.py +19 -0
- ceiling_guard-0.5.3/tools/cg_supervise.py +19 -0
- ceiling_guard-0.5.3/tools/install_phase1_units.sh +162 -0
- ceiling_guard-0.5.3/tools/langgraph_agent_loop.py +261 -0
- ceiling_guard-0.5.3/tools/make_windows_proof.py +172 -0
- ceiling_guard-0.5.3/tools/phase1.py +466 -0
- ceiling_guard-0.5.3/tools/real_agent_loop.py +466 -0
- ceiling_guard-0.5.3/tools/real_session_loop.py +352 -0
- ceiling_guard-0.5.3/tools/setup_nucbox.sh +152 -0
- ceiling_guard-0.5.3/tools/soak.py +390 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Ceiling Guard are recorded here. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses a
|
|
5
|
+
simple `MAJOR.MINOR.PATCH` scheme.
|
|
6
|
+
|
|
7
|
+
## [0.5.3] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
Found by running the command lines a user actually types, from a clean install,
|
|
10
|
+
on two machines. Every item below was reachable by an ordinary mistake or by
|
|
11
|
+
ordinary use; none needed a hostile input.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **`cg wrap` silently truncated the wrapped agent's output.** The wrapper's
|
|
15
|
+
first duty is to relay what the child wrote, and it was dropping most of it.
|
|
16
|
+
`proc.wait()` returns the moment the child exits, but the output can still be
|
|
17
|
+
sitting in the pipe; the code then set a `stop` flag that made the reader
|
|
18
|
+
thread `break` **mid-drain**, discarding whatever had not been read.
|
|
19
|
+
|
|
20
|
+
Measured on x86/WSL2, same machine, same minute: a child emitting 500 lines
|
|
21
|
+
came back as **45, then 30, then 59**. On the ARM dev box it was
|
|
22
|
+
intermittent -- 500, 395, 500 -- which is exactly why a single manual
|
|
23
|
+
spot-check "confirmed" it worked and the defect survived 0.5.0, 0.5.1 and
|
|
24
|
+
0.5.2. The faster the machine, the further the child runs ahead of the
|
|
25
|
+
reader, so better hardware lost more. For `cg wrap -- claude -p` the lost
|
|
26
|
+
bytes are the agent's answer.
|
|
27
|
+
|
|
28
|
+
The reader now drains to EOF on its own; `stop` reverts to being a bounded
|
|
29
|
+
abort for the case where a surviving grandchild holds the pipe open
|
|
30
|
+
(`CG_DRAIN_TIMEOUT`, default 10s), and says so on stderr rather than
|
|
31
|
+
truncating quietly. A pump exception no longer disappears into
|
|
32
|
+
`except Exception: pass` either -- that was a second, silent path to the same
|
|
33
|
+
data loss. Pinned by matrix row 24 / `tests/test_wrap_output.py` (12 checks),
|
|
34
|
+
which counts lines rather than grepping for a substring, because the broken
|
|
35
|
+
build reliably produced the FIRST lines.
|
|
36
|
+
|
|
37
|
+
- **OpenAI/Codex reported a confident `0.0% ok` while the account was at 96%.**
|
|
38
|
+
Codex writes `used_percent: 0.0` as a *placeholder* as well as a
|
|
39
|
+
measurement: a short session emits one `token_count` event before the server
|
|
40
|
+
has sent real numbers. Taking the newest record blindly turned that into a
|
|
41
|
+
fresh, wrong, reassuring reading -- a false *low* on the exact signal this
|
|
42
|
+
tool exists to watch. The staleness gate could not help: the reading really
|
|
43
|
+
was fresh.
|
|
44
|
+
|
|
45
|
+
Measured on a live Plus account: 96%/62% at 18:47, then `0.0/0.0` at 23:39;
|
|
46
|
+
and 100%/47% -> `0.0/0.0` **36 seconds apart**. A 7-day rolling window cannot
|
|
47
|
+
shed 47 points in 36 seconds. 197 of 208 session files never report a zero.
|
|
48
|
+
|
|
49
|
+
The rule is physical, not heuristic: usage cannot fall to zero before its
|
|
50
|
+
window has reset, so an all-zero record is refused while an older record
|
|
51
|
+
still shows usage in a window whose own `resets_at` has not passed. The
|
|
52
|
+
reading falls back to the last credible one, which is then correctly old and
|
|
53
|
+
correctly `IGNORED` -- fail closed rather than falsely comfortable. A genuine
|
|
54
|
+
post-reset zero is still believed. Both directions pinned in
|
|
55
|
+
`tests/test_quota.py`.
|
|
56
|
+
|
|
57
|
+
- **Ctrl-C never reached the wrapped agent under nohup, systemd or CI.** The
|
|
58
|
+
signal handlers were installed *after* the child was spawned, which is fine
|
|
59
|
+
interactively and wrong everywhere else. A shell starting a job in the
|
|
60
|
+
background without job control sets SIGINT to `SIG_IGN`, and while handled
|
|
61
|
+
signals reset to `SIG_DFL` across exec, **ignored ones stay ignored** -- so
|
|
62
|
+
the child inherited `SIG_IGN`, and a POSIX shell cannot un-ignore a signal
|
|
63
|
+
that was ignored at startup. The agent's own `trap ... INT` became a no-op.
|
|
64
|
+
|
|
65
|
+
Measured: SIGTERM to `cg` reached the child (exit 98); SIGINT did not, and
|
|
66
|
+
the child ran to completion while `cg` waited out the whole run. From a
|
|
67
|
+
terminal it always looked right, because there SIGINT starts `SIG_DFL` --
|
|
68
|
+
the failure was confined to the unattended context the tool exists for.
|
|
69
|
+
Handlers now go on before `Popen`. `tests/test_wrap_signals.py` launches
|
|
70
|
+
`cg` with SIGINT explicitly ignored, and fails 3/8 against the previous
|
|
71
|
+
commit.
|
|
72
|
+
|
|
73
|
+
- **`cg install` put site-packages on `PYTHONPATH`** in the generated systemd
|
|
74
|
+
unit. Meaningless for an installed copy and leaky, since `PYTHONPATH` is
|
|
75
|
+
inherited by every child and outranks its own paths. Emitted now only from a
|
|
76
|
+
source checkout.
|
|
77
|
+
|
|
78
|
+
- **`tests/test_quota.py --live` crashed on any machine that had Codex.** It
|
|
79
|
+
formatted every consumption value with a thousands separator, but Codex's
|
|
80
|
+
consumption carries strings, so `f"{v:,}"` raised. The docs point users at
|
|
81
|
+
that exact command to verify their own setup, and it could only fail on the
|
|
82
|
+
machines it was written for.
|
|
83
|
+
|
|
84
|
+
- **A typo in any numeric environment knob was fatal on import.** All eight are
|
|
85
|
+
read at module import, so `CG_MAX_CONNS=abc` took the daemon down with a
|
|
86
|
+
traceback naming a line in `config.py` rather than the variable just set.
|
|
87
|
+
They now fall back to the default and say which variable was wrong.
|
|
88
|
+
|
|
89
|
+
The quieter half mattered more: out-of-range values were accepted silently.
|
|
90
|
+
`CG_QUOTA_CONFIRM_POLLS=0` made the `_over_polls < QUOTA_CONFIRM_POLLS` gate
|
|
91
|
+
vacuously false, so **one** measured reading became enough to terminate a
|
|
92
|
+
session -- the confirm-the-crossing property switched off by a
|
|
93
|
+
plausible-looking number. `CG_MAX_CONNS=0` left a daemon that looked alive
|
|
94
|
+
and served nobody. Knobs that guard a property now carry a floor
|
|
95
|
+
(`config.env_num`). Matrix row 23 / `tests/test_env_knobs.py` (56 checks).
|
|
96
|
+
|
|
97
|
+
- **A corrupt or wrong file at `db =` crashed both `cg history` and the
|
|
98
|
+
daemon** with a raw `sqlite3.DatabaseError`. Both now explain what was found
|
|
99
|
+
and what to do. Deliberately **not** auto-recovered: the common way to reach
|
|
100
|
+
this is a `db =` aimed at the wrong path, and recreating it would turn a typo
|
|
101
|
+
into data loss.
|
|
102
|
+
|
|
103
|
+
- **`cg wrap -- <typo>` printed a `FileNotFoundError` traceback.** Now follows
|
|
104
|
+
shell convention: `command not found` / exit **127**, `permission denied` /
|
|
105
|
+
exit **126**.
|
|
106
|
+
|
|
107
|
+
- **A malformed `config.toml` printed a `tomllib` traceback.** Now names the
|
|
108
|
+
file and the line, and exits 2.
|
|
109
|
+
|
|
110
|
+
- **A corrupt quota cache crashed `cg quota`** with `TypeError: float - str`
|
|
111
|
+
from `stale_seconds`. Unknown age is now treated as stale, which is the
|
|
112
|
+
fail-closed answer used everywhere else in that module.
|
|
113
|
+
|
|
114
|
+
- **Burn could hard-fire on healthy batched token reporting.** The rate's
|
|
115
|
+
denominator was the spread of the samples in the window, floored at 30s:
|
|
116
|
+
`max(30.0, now - tokens[0][0])`. For the normal shape of a client that
|
|
117
|
+
accounts usage at the END of a call -- one frame carrying the whole batch --
|
|
118
|
+
`tokens[0][0]` IS now, so the span collapsed to 30s and the entire batch was
|
|
119
|
+
attributed to thirty seconds. A healthy 60-second call reporting 20,001
|
|
120
|
+
tokens read as **40,002 tok/min**, over the 40,000 default, and `burn` has no
|
|
121
|
+
confirmation gate: a hard-level false positive on a session doing nothing
|
|
122
|
+
wrong. The denominator is now the period actually observed, capped by the
|
|
123
|
+
window being summed. Verified: 20,001 over 60s reads 20,001/min; a genuine
|
|
124
|
+
120k/min runaway is unchanged; burn recall stays 100% at 62s to hard **on
|
|
125
|
+
the nine-archetype release corpus** (the figure is not claimed for
|
|
126
|
+
`phase_change`, which is measured separately under `--known-fail`).
|
|
127
|
+
|
|
128
|
+
- **A peer could clear `shadow` for its own session, and a reload promoted
|
|
129
|
+
everything it asked for.** `UNTRUSTED_KEYS` stripped `action` but not
|
|
130
|
+
`shadow` -- so a socket peer that could not ask to be terminated could still
|
|
131
|
+
take itself out of observe-only, which is the switch deciding whether any
|
|
132
|
+
verdict is carried out. Separately, HELLO resolved peer overrides with
|
|
133
|
+
`trusted=False`, but `Sess` kept the RAW overrides and SIGHUP re-resolved
|
|
134
|
+
them with the default `trusted=True`: an operator running `cg reload`
|
|
135
|
+
silently granted the peer `action="terminate"` and `shadow=false`. Both
|
|
136
|
+
closed; both pinned in `tests/test_authority.py`.
|
|
137
|
+
|
|
138
|
+
- **The confirm-the-crossing floor was one short of confirming anything.** The
|
|
139
|
+
gate reads `_over_polls < QUOTA_CONFIRM_POLLS`, and `_over_polls` is already
|
|
140
|
+
1 on the first over-threshold reading -- so the `minimum=1` added earlier
|
|
141
|
+
today made the test `1 < 1`, false, and enforcement proceeded on a single
|
|
142
|
+
measurement. The floor that actually preserves the property is 2.
|
|
143
|
+
|
|
144
|
+
### Added
|
|
145
|
+
- **`cg statusline`** -- one short plan-window reading for a shell prompt or
|
|
146
|
+
tmux bar. Loads no config, contacts no daemon, exits 0 on every path, and
|
|
147
|
+
prints `-` rather than a percentage that was true an hour ago.
|
|
148
|
+
|
|
149
|
+
### Known
|
|
150
|
+
- **`cg install` is shaped for a source checkout under systemd.** It embeds
|
|
151
|
+
whichever Python ran it, detects only that `systemctl` exists rather than
|
|
152
|
+
that a user session works, discards the result of `loginctl enable-linger`,
|
|
153
|
+
declares success without running `cg doctor`, has no uninstall or repair,
|
|
154
|
+
and on Windows prints instructions and returns success having installed
|
|
155
|
+
nothing. **`pipx install ceiling-guard` then `cg quota` / `cg wrap` is the
|
|
156
|
+
supported path for anyone who is not already in the repo**; the daemon and
|
|
157
|
+
its unit are for operators who want persistent supervision. Installer work is
|
|
158
|
+
0.5.4 and is deliberately not in this tag — the wheels currently on PyPI drop
|
|
159
|
+
the agent's answer and report `0.0% ok` at 96%, and getting those off the
|
|
160
|
+
index matters more than installer polish.
|
|
161
|
+
|
|
162
|
+
### Known open (measured, not fixed)
|
|
163
|
+
- **A retransmitted PROGRESS frame double-counts spend.** `add_call`,
|
|
164
|
+
`add_tokens`, `add_outcome` and depth are applied on every frame, not only
|
|
165
|
+
when `seq` advances, so the same frame applied twice adds its tokens twice.
|
|
166
|
+
`client._send` retries a frame after a reconnect, so this is reachable, and a
|
|
167
|
+
daemon restart makes it worse: the durable token carry is restored but
|
|
168
|
+
`sess.seq` starts at zero.
|
|
169
|
+
|
|
170
|
+
The obvious fix -- ignore any frame whose `seq` does not advance -- is
|
|
171
|
+
**wrong for this codebase** and was not applied. A frozen `seq` carrying
|
|
172
|
+
fresh tokens is exactly what "burn with no progress" means; `demo_agent.py
|
|
173
|
+
burn` sends precisely that, and matrix row 7 depends on it. Content-hash
|
|
174
|
+
dedupe fails for the same reason: those frames are byte-identical by design.
|
|
175
|
+
|
|
176
|
+
Telling a retransmission from a genuine repeat needs a frame identity the
|
|
177
|
+
protocol does not currently carry. The fix is a monotonic frame id, ignored
|
|
178
|
+
by the daemon when already applied and treated as always-new when absent, so
|
|
179
|
+
older clients keep working. That is a protocol addition and is deliberately
|
|
180
|
+
not being rushed.
|
|
181
|
+
|
|
182
|
+
- **`decay` fires on a healthy phase change, and the corpus could not see it.**
|
|
183
|
+
Every healthy archetype in `tests/bench_accuracy.py` held roughly one pace
|
|
184
|
+
throughout, so the frozen baseline always resembled the work being judged.
|
|
185
|
+
Real sessions do not behave that way: an agent reads a dozen files at a
|
|
186
|
+
second each, then runs five test suites at three minutes each. That freezes a
|
|
187
|
+
~1s baseline and makes every legitimate 200s call a 200x "collapse".
|
|
188
|
+
|
|
189
|
+
A `phase_change` archetype has been added, and with it the bench now reports
|
|
190
|
+
what was always true: **decay fires on 100% of those sessions** (warn level;
|
|
191
|
+
a direct probe reaches HARD with steady 200s+ gaps), and measured decay
|
|
192
|
+
recall drops from 100% to **90%** because the fault generator now also runs
|
|
193
|
+
against a slow-paced archetype. `./tests/bench_accuracy.py` therefore FAILS
|
|
194
|
+
its gate on this commit.
|
|
195
|
+
|
|
196
|
+
This is deliberate. The archetype is realistic, the numbers are real, and the
|
|
197
|
+
previous 100%/0.0% was partly an artefact of a corpus that did not contain
|
|
198
|
+
this shape. A loosen-only baseline re-learn was tried and made both numbers
|
|
199
|
+
worse, so it was reverted rather than shipped. The honest position is that
|
|
200
|
+
the zero-false-positive claim holds for the archetypes measured, and this
|
|
201
|
+
archetype is not yet among the ones it holds for.
|
|
202
|
+
|
|
203
|
+
### Changed
|
|
204
|
+
- `docs/quota-accuracy.md`: the two OpenAI rows are **withdrawn as evidence**.
|
|
205
|
+
They recorded `0.00%` agreeing with a UI at 0% and called it *exact* -- but
|
|
206
|
+
`0.00%` is what the adapter produced whether or not it worked, so the check
|
|
207
|
+
passed identically for a working and a broken adapter. An unfalsifiable check
|
|
208
|
+
is worse than none; it occupied the row where real evidence should have been.
|
|
209
|
+
- `docs/quota-adapters.md`: the Codex path is verified against a real install
|
|
210
|
+
(NucBox, 208 session files, 1233 rate-limit events) for **parsing**, and
|
|
211
|
+
explicitly not for accuracy.
|
|
212
|
+
|
|
213
|
+
### Added
|
|
214
|
+
- `tests/test_wrap_output.py`, `tests/test_env_knobs.py`,
|
|
215
|
+
`tests/test_wrap_signals.py` -- matrix rows 23 and 24. The matrix is now 24
|
|
216
|
+
rows reporting 25 checks (row 24 covers wrapper output and signals
|
|
217
|
+
separately), passing on aarch64/Python 3.13.5 and x86-64/Python 3.12.3.
|
|
218
|
+
|
|
219
|
+
## [0.5.2] - 2026-08-29
|
|
220
|
+
|
|
221
|
+
Third patch in a day. The cadence is ugly; the reason is that 0.5.1's leading
|
|
222
|
+
Claude command could not capture Anthropic, so the published page documented an
|
|
223
|
+
on-ramp that did not work.
|
|
224
|
+
|
|
225
|
+
### Fixed
|
|
226
|
+
- **The documented Claude on-ramp captured nothing.** The README said to run
|
|
227
|
+
`cg wrap -- claude -p "whatever you were going to run anyway"` and then
|
|
228
|
+
`cg quota` would show the Anthropic rows. It did not. Claude publishes its
|
|
229
|
+
plan window only as a `rate_limit_event` in `--output-format stream-json`;
|
|
230
|
+
plain `claude -p` prints text, so nothing carrying `unifiedWindows` ever
|
|
231
|
+
crossed the wire and `cg quota` stayed empty forever.
|
|
232
|
+
|
|
233
|
+
Every earlier test of this path passed those flags itself, so the gap never
|
|
234
|
+
appeared. It took installing from PyPI into a clean venv, pointing
|
|
235
|
+
`CG_QUOTA_CACHE` at an empty directory, and running the README's literal
|
|
236
|
+
command to find it.
|
|
237
|
+
|
|
238
|
+
`cg wrap` now adds `--output-format stream-json --verbose` when the child is
|
|
239
|
+
`claude`, is running with `-p`, and the user has not chosen a format
|
|
240
|
+
themselves. It renders the stream back to plain text, so the answer arrives
|
|
241
|
+
looking exactly as it would have, and it says on stderr that it did this
|
|
242
|
+
rather than silently rewriting the command line. An explicit
|
|
243
|
+
`--output-format`, an interactive session with no `-p`, and any non-Claude
|
|
244
|
+
child are all left untouched.
|
|
245
|
+
|
|
246
|
+
This makes `cg wrap` a shim for `claude -p` rather than a transparent sleeve
|
|
247
|
+
like `timeout` or `nice`, and the docs now say so instead of claiming the
|
|
248
|
+
command runs unchanged. The alternative was documenting the flags, which
|
|
249
|
+
makes the on-ramp longer than the job it wraps. Known gaps, deliberately not
|
|
250
|
+
guessed at: a `claude` invoked under another name is not recognised, and
|
|
251
|
+
print-mode reached by anything other than `-p`/`--print` is not detected.
|
|
252
|
+
- **A renderer miss would have been silent.** We ask Claude for a format the
|
|
253
|
+
user did not, so if its event shape moves -- `result` renamed,
|
|
254
|
+
`message.content` restructured -- the run still succeeds while the answer
|
|
255
|
+
stops appearing. On child exit, if the injected stream produced nothing
|
|
256
|
+
printable, the raw events are dumped to stderr with an explanation. The
|
|
257
|
+
child's exit code is untouched: the job may have worked, and the renderer is
|
|
258
|
+
our problem rather than its.
|
|
259
|
+
- **The rendered answer printed twice.** The text appears both as the
|
|
260
|
+
assistant's `text` block and again in the terminal `result` event. The
|
|
261
|
+
renderer now prefers the streamed text and falls back to `result` only when
|
|
262
|
+
no text block was seen.
|
|
263
|
+
|
|
264
|
+
## [0.5.1] - 2026-08-29
|
|
265
|
+
|
|
266
|
+
Documentation only. No behaviour change.
|
|
267
|
+
|
|
268
|
+
### Fixed
|
|
269
|
+
- **The README told people to install from a private repository.** The first
|
|
270
|
+
code block offered `pipx install git+https://github.com/ak2tx/ceiling-guard`
|
|
271
|
+
as an alternative; that URL 404s until the repo is public. Removed, so the
|
|
272
|
+
only advertised path is the one that works.
|
|
273
|
+
- **The opening named ChatGPT as an audience the next section excludes.** A
|
|
274
|
+
browser-only subscription leaves no local record for this tool to read, and
|
|
275
|
+
saying "Claude, ChatGPT or Grok" in the first sentence promised a reader
|
|
276
|
+
something the provider table then took away. It now names the CLIs.
|
|
277
|
+
- **Restored the three sentences that carry the claim.** "Thirty seconds is
|
|
278
|
+
Codex and Grok. Claude is wrap the job you were going to run. A browser-only
|
|
279
|
+
subscription is not a user of this tool." A rewrite dropped them; the table
|
|
280
|
+
underneath is only the evidence, and without the claim a Claude user infers
|
|
281
|
+
they should see numbers straight after `pipx install`.
|
|
282
|
+
- The plan-window section re-pitched the opening, with a second copy of the
|
|
283
|
+
situation and a second sample table. It now carries only the mechanics.
|
|
284
|
+
- The status footer still said rows #20 and #21 had not run on the Pi. #22
|
|
285
|
+
exists too.
|
|
286
|
+
|
|
287
|
+
## [0.5.0] - 2026-08-29
|
|
288
|
+
|
|
289
|
+
### Fixed
|
|
290
|
+
- **`pipx install` could not run the documented integration paths.** The wheel
|
|
291
|
+
ships `packages = ["continuity_guard"]`, so `tools/` was never in it, while
|
|
292
|
+
README and `docs/integrating.md` told users to run `python -m tools.cg_proxy`
|
|
293
|
+
and `tools/cg_supervise.py`. Both raised `ModuleNotFoundError` on every
|
|
294
|
+
installed copy. Worse, `cg_supervise` holds the ONLY `write_cache("anthropic")`
|
|
295
|
+
call, so `cg quota` could never show a Claude percentage after a clean
|
|
296
|
+
install -- the README's own opening example was unreachable for the user it
|
|
297
|
+
addressed. Every test and campaign run executes from a checkout where
|
|
298
|
+
`tools/` is simply on the path, which is why nothing caught it.
|
|
299
|
+
|
|
300
|
+
The proxy and supervisor now live in the package as `continuity_guard.proxy`
|
|
301
|
+
and `continuity_guard.supervise`, with `cg-proxy` and `cg-supervise` console
|
|
302
|
+
entry points. `tools/*.py` remain as thin shims so existing invocations by
|
|
303
|
+
path -- systemd units, running phase-1 loops -- keep working unchanged.
|
|
304
|
+
|
|
305
|
+
### Added
|
|
306
|
+
- **`cg wrap`** -- run a CLI agent under the watchdog with no code and no
|
|
307
|
+
config: `cg wrap -- claude -p "..."`. Observe-only regardless of profile,
|
|
308
|
+
and it says so on the first line before it does anything. Arming stays a
|
|
309
|
+
separate deliberate act in the daemon's configuration.
|
|
310
|
+
|
|
311
|
+
### Changed
|
|
312
|
+
- Version 0.5.0 rather than 0.4.3. Wrap-up that actually reaches the agent,
|
|
313
|
+
three working ceilings, named plan windows, a socket that is not a kill
|
|
314
|
+
oracle, spend that survives a restart, and confirm-polls on quota are not a
|
|
315
|
+
patch release, and shipping them under 0.4.2 would confuse anyone who
|
|
316
|
+
installed last week.
|
|
317
|
+
- Docs reconciled with HEAD: `HANDOFF.md` tree pointer, its 19/19 vs 21/21
|
|
318
|
+
contradiction, and its claim that the socket is `0666` "on purpose" (false
|
|
319
|
+
since the authority fix, and dangerous as guidance); README's matrix listing
|
|
320
|
+
stopped at row 18 under a "21 checks" heading; `docs/quota-adapters.md` still
|
|
321
|
+
said adapters were unimplemented, Codex unverified, and Claude published no
|
|
322
|
+
percentage -- all three now settled and marked as the research record.
|
|
323
|
+
|
|
324
|
+
## [Unreleased]
|
|
325
|
+
|
|
326
|
+
### Changed
|
|
327
|
+
- **The handoff claim is narrowed to what it can actually deliver.** The docs
|
|
328
|
+
described "every long session ends with its state written down" and "the thing
|
|
329
|
+
that means I never lose a run". Neither survives contact with the mechanism:
|
|
330
|
+
what is preserved is the run's observed reach and its *enumerable* progress,
|
|
331
|
+
not the agent's understanding — the unarticulated hypothesis, the discarded
|
|
332
|
+
approach and why. That is context, it dies with the context, and summarising
|
|
333
|
+
it is lossy in a way the reader cannot audit. The claim is now "a warm start
|
|
334
|
+
on enumerable work, and a guarantee that the run's reach is never unknown",
|
|
335
|
+
with the two limits that follow from it stated: task shape dominates the
|
|
336
|
+
value, and findings decay (measured — two of four findings in one handoff were
|
|
337
|
+
already fixed hours later, and a third was wrong on its own terms). See
|
|
338
|
+
README "What the handoff is and is not" and `docs/wrapup-signal.md` "The
|
|
339
|
+
claim, narrowed to what holds".
|
|
340
|
+
- **Handoffs carry findings, not an index.** The agent-written document listed
|
|
341
|
+
which areas were touched and nothing about what was found, which is a
|
|
342
|
+
bookmark: a successor could not continue from it without redoing the work.
|
|
343
|
+
It now carries the task, condensed findings with observed-at stamps, what was
|
|
344
|
+
ruled out, what remains, and the exact command that resumes past the work
|
|
345
|
+
already done. Ceiling-aware — a budget does not "reset", and saying so sent a
|
|
346
|
+
reader to wait for something that never comes.
|
|
347
|
+
|
|
348
|
+
### Security
|
|
349
|
+
- **An armed daemon could be made to terminate arbitrary processes.** Four
|
|
350
|
+
facts combined: the Unix socket was created world-writable (`0o666`);
|
|
351
|
+
`protocol.sanitize()` type-checked HELLO fields but did not authorise them,
|
|
352
|
+
so peer-supplied `overrides` became effective policy *including* `action`;
|
|
353
|
+
and `actions._identity_ok()` fell back to "is anything alive with this PID"
|
|
354
|
+
whenever `start_time` was absent. Any local account could announce another
|
|
355
|
+
process's PID with `start_time=0` and a policy that terminated on the first
|
|
356
|
+
frame, and the daemon would signal that process and its group — a kill
|
|
357
|
+
primitive for everything the daemon's user could signal. Found by a
|
|
358
|
+
high-reasoning review of `daemon.py`/`detectors.py`, then confirmed
|
|
359
|
+
end-to-end before it was believed.
|
|
360
|
+
|
|
361
|
+
Four independent fixes, because an operator will eventually widen one of them
|
|
362
|
+
for a good reason: the socket defaults to `0o600` (`CG_SOCKET_MODE` widens it
|
|
363
|
+
deliberately); operator-only keys are stripped from peer overrides and the
|
|
364
|
+
removal is logged; termination requires a verified `start_time` where the
|
|
365
|
+
platform can supply one, and refuses rather than assuming where it cannot;
|
|
366
|
+
and the announced PID must be the connecting peer or a descendant of it
|
|
367
|
+
(`SO_PEERCRED`), failing which termination is disarmed for that session while
|
|
368
|
+
observation continues. `tests/test_authority.py` reproduces the original
|
|
369
|
+
exploit — the victim process dies against the pre-fix code and survives
|
|
370
|
+
after.
|
|
371
|
+
|
|
372
|
+
### Fixed
|
|
373
|
+
- **The hard backstop could be widened by a learned baseline, breaking
|
|
374
|
+
invariant 4.** `_adaptive_floor` applied its `is_backstop` guard only to the
|
|
375
|
+
flat learning margin, so `max(configured, base * adaptive_k)` widened the
|
|
376
|
+
hard ceiling anyway — both while learning and after the baseline froze. A
|
|
377
|
+
30-minute hard silence ceiling with `adaptive_k = 6` and one learned
|
|
378
|
+
20-minute gap became **120 minutes**: a frozen process sat undetected for two
|
|
379
|
+
hours under a configuration that asked for thirty. The guard is now a single
|
|
380
|
+
unconditional return, since the bug was a branch that forgot it.
|
|
381
|
+
- **Ceilings reset when the daemon restarted.** Every HELLO built a fresh
|
|
382
|
+
session with `total_tokens = 0` and a new clock, so a client reconnecting
|
|
383
|
+
after a restart (systemd `Restart=always`, an OOM) got a full budget and a
|
|
384
|
+
fresh deadline and could spend the whole allowance twice. Accumulated spend
|
|
385
|
+
and the original start time are now persisted (`session_carry`) and restored
|
|
386
|
+
on reconnect, bounded by age so a session id reused days later starts clean.
|
|
387
|
+
- **EOF was recorded as death without checking the process.** A supervisor
|
|
388
|
+
exiting or a TCP reset produced EOF while the agent ran on; that was written
|
|
389
|
+
to the ledger as a death and could drive `flap`. Where the PID is checkable
|
|
390
|
+
and still alive, the daemon now says what it actually observed.
|
|
391
|
+
- **Unbounded idle connections.** A peer could open connections, never send a
|
|
392
|
+
HELLO, and hold them; there was no cap and no handshake timeout, so fds and
|
|
393
|
+
buffers grew until real agents could not connect. The fuzzer closes its
|
|
394
|
+
sockets, so its churn never built this up. Now capped (`CG_MAX_CONNS`, 512)
|
|
395
|
+
with a handshake timeout (`CG_HANDSHAKE_TIMEOUT`, 60s).
|
|
396
|
+
- **Short writes were treated as delivered.** `conn.send()` on a non-blocking
|
|
397
|
+
socket returns a byte count that was discarded, so under output-buffer
|
|
398
|
+
pressure a truncated WRAPUP was logged as sent — the client drops the
|
|
399
|
+
malformed line and never learns a ceiling is near.
|
|
400
|
+
- **Silent `status.json` write failures** left `cg status` stale while looking
|
|
401
|
+
authoritative. Reported once, and again when it changes.
|
|
402
|
+
- **Three stale claims, found by the tool reviewing itself.** A ceiling-stopped
|
|
403
|
+
accuracy run handed over four reviews; three carried findings and all three
|
|
404
|
+
were real: `TESTING.md:24` unpacked a `v0.4.1` tarball, `README.md:393` quoted
|
|
405
|
+
`any-level FP 0.6%` where the measured figure is 0.1%, and
|
|
406
|
+
`tests/run_matrix.sh:2` still described "19 realtime checks" after row #20
|
|
407
|
+
landed. The last was introduced by the commit that added that row.
|
|
408
|
+
- **The ceiling wrap-up signal never reached the agent.** `progress()` only
|
|
409
|
+
writes to the socket, so the sole reader was the process-wide link watcher,
|
|
410
|
+
whose loop sleeps `WATCH_INTERVAL` (5s) before its first pass.
|
|
411
|
+
`should_wrap_up()` — the call the documentation tells agents to make —
|
|
412
|
+
answered from a field that had not been populated yet, so an agent that
|
|
413
|
+
checked the signal after its work and exited raced the timer and lost. It
|
|
414
|
+
only appeared to work when a unit of work happened to be long enough for a
|
|
415
|
+
tick to land inside it. `should_wrap_up()` and `headroom()` now drain the
|
|
416
|
+
socket first. Found against a real Claude plan window; see
|
|
417
|
+
`docs/wrapup-signal.md` for the timeline and the before/after table.
|
|
418
|
+
- **Handoffs printed the window reset time as a raw epoch** (`resets
|
|
419
|
+
1787993400`) — the single field a reader most needs off that document. Now
|
|
420
|
+
rendered as a local timestamp. The wire frame still carries the integer.
|
|
421
|
+
- **`[account]` was ignored on SIGHUP.** `_reload()` rebuilt profiles and
|
|
422
|
+
defaults but never re-read the block, so a reload logged success and changed
|
|
423
|
+
neither the threshold, the grace, the poll interval, nor the background
|
|
424
|
+
classification.
|
|
425
|
+
- **`_quota_sent` held one value per session name and was never cleaned up.**
|
|
426
|
+
Broken twice over. The plan-window signal and the configured-ceiling signal
|
|
427
|
+
wrote the same key, so one silently unblocked the other — measured live, a
|
|
428
|
+
budget wrap-up stayed suppressed until the quota poll happened to overwrite
|
|
429
|
+
it, arriving at 5,839 tokens against a 1,700 cap instead of at 2,014. And
|
|
430
|
+
because nothing removed an entry, a session reusing a name that had already
|
|
431
|
+
been warned could never be warned again for the daemon's lifetime: a fail-open
|
|
432
|
+
on the one signal that exists to save work. Now a set per session, cleared on
|
|
433
|
+
close.
|
|
434
|
+
- **The account block was logged only when a value changed**, making silence
|
|
435
|
+
mean both "reloaded, nothing moved" and "never reloaded at all" — the
|
|
436
|
+
ambiguity that hid the bug above. The effective settings are now stated on
|
|
437
|
+
every reload and once at startup.
|
|
438
|
+
|
|
439
|
+
### Added
|
|
440
|
+
- **`[account] windows`** — name which plan windows may bind. The daemon has
|
|
441
|
+
always used the *highest* window, which is right when you want protection from
|
|
442
|
+
whichever allowance runs out first, but it made "warn me at 15% of my session"
|
|
443
|
+
inexpressible: a 7d weekly sitting at 66% binds first, so every threshold below
|
|
444
|
+
that fires on the first poll having done no work, and writes an empty handoff.
|
|
445
|
+
Measured twice before this existed. An unmatched name binds nothing and the
|
|
446
|
+
daemon says so once per provider — silently protecting nobody looks exactly
|
|
447
|
+
like nothing being near a ceiling.
|
|
448
|
+
- **`budget` and `deadline` now signal.** `docs/wrapup-signal.md` has named
|
|
449
|
+
them the signallable ceilings since the feature was designed — "fired when a
|
|
450
|
+
ceiling detector returns warn" — but the only path that ever sent a WRAPUP
|
|
451
|
+
frame was the plan-window poll. A session with a token cap or a wall-clock cap
|
|
452
|
+
got a log line and then, at `hard`, a termination, with no chance in between
|
|
453
|
+
to save its work: two of the three ceilings the document claimed to cover were
|
|
454
|
+
silent. `daemon._ceiling_wrapup()`, reported with real headroom (tokens
|
|
455
|
+
remaining to `hard`, or seconds).
|
|
456
|
+
- **Matrix check #20 — the ceiling signal reaches the agent.** Two tests, both
|
|
457
|
+
regression-proven against the broken code rather than merely passing against
|
|
458
|
+
the fixed code. `tests/test_wrapup_race.py` (client visibility, link watcher
|
|
459
|
+
disabled entirely so the drain does the work rather than a lucky tick): 6 of 9
|
|
460
|
+
fail before the fix, 9/9 after. `tests/test_ceiling_signal.py` (real daemon;
|
|
461
|
+
`budget` and `deadline` delivery, and suppression not outliving a session):
|
|
462
|
+
0 of 9 before, 9/9 after. The matrix is now 20 checks.
|
|
463
|
+
|
|
464
|
+
## [0.4.2] - 2026-08-27
|
|
465
|
+
|
|
466
|
+
First release published to PyPI. `pipx install ceiling-guard`.
|
|
467
|
+
|
|
468
|
+
### Added
|
|
469
|
+
- **Cross-platform transport.** The daemon now listens on a Unix domain socket
|
|
470
|
+
on Linux/macOS and on `127.0.0.1:9099` on Windows; `socket = "tcp://HOST:PORT"`
|
|
471
|
+
(or `":PORT"`, or `CG_TCP_PORT`) forces TCP anywhere. `continuity_guard/transport.py`
|
|
472
|
+
is the single resolver used by the daemon, the client, the CLI, and the fuzzer.
|
|
473
|
+
- **`cg` command-line interface** — `init`, `install`, `run`, `status`, `watch`,
|
|
474
|
+
`history`, `profiles`, `reload`, `doctor`. `cg install` writes a systemd
|
|
475
|
+
`--user` unit on Linux, a launchd agent on macOS, and prints NSSM instructions
|
|
476
|
+
on Windows.
|
|
477
|
+
- **Packaging.** `pyproject.toml` with zero runtime dependencies, `cg` and
|
|
478
|
+
`cg-daemon` entry points, wheel + sdist, and a `Dockerfile` (python:3.12-slim,
|
|
479
|
+
non-root, baked default config).
|
|
480
|
+
- **`protocol.sanitize()`** — every decoded frame is normalised to well-typed
|
|
481
|
+
fields or `None` before it reaches a detector.
|
|
482
|
+
- **`decay.hard_floor`** — an absolute backstop (default 200s) under the
|
|
483
|
+
adaptive decay detector, so the terminate level can never be pulled down by a
|
|
484
|
+
learned baseline.
|
|
485
|
+
- **`Store.prune`** — history tables are bounded and checkpointed hourly from
|
|
486
|
+
the daemon; `PRAGMA wal_checkpoint(TRUNCATE)` runs every prune, not only when
|
|
487
|
+
rows were deleted.
|
|
488
|
+
- **Client reconnect.** A session whose daemon restarts under it (systemd
|
|
489
|
+
`Restart=always`, an OOM kill) re-connects and re-HELLOs with the same
|
|
490
|
+
identity, throttled and silent.
|
|
491
|
+
- **`docs/integrating.md`** — the four integration paths (proxy, library,
|
|
492
|
+
supervisor, callbacks), per-agent supervisor recipes, and the three wiring
|
|
493
|
+
footguns that cause a healthy agent to be killed.
|
|
494
|
+
- **`tests/soak_stability.py`** — a multi-day leak *assertion*: drives heavy
|
|
495
|
+
connection churn at a throwaway daemon, samples RSS / fds / threads / WAL, and
|
|
496
|
+
exits non-zero on a memory slope, an fd leak, thread growth, or a stalled
|
|
497
|
+
checkpoint.
|
|
498
|
+
- **`tests/fuzz_protocol.py`** — hostile-input fuzzing folded into the matrix as
|
|
499
|
+
check 19; runs against both Unix and TCP transports.
|
|
500
|
+
|
|
501
|
+
### Fixed
|
|
502
|
+
- **`decay` hard-level false positives** went from 1.2% to 0.0% at high sample
|
|
503
|
+
counts (recall unchanged) after adding `hard_floor`.
|
|
504
|
+
- **Daemon crashes on hostile input** — a non-integer `pid`, deeply nested
|
|
505
|
+
JSON, and binary garbage each previously reached an unhandled exception in the
|
|
506
|
+
selector loop. `decode()` now catches `RecursionError`; framing is wrapped;
|
|
507
|
+
`sanitize()` runs first.
|
|
508
|
+
- **Accept backlog** under a connection flood — `_accept` now drains the whole
|
|
509
|
+
backlog per wakeup and the listen queue is 256.
|
|
510
|
+
- **`cg_proxy` gzip bug** — the proxy forwarded `Accept-Encoding: gzip` but
|
|
511
|
+
stripped `Content-Encoding`, producing a client-side decode error. It now
|
|
512
|
+
forces `Accept-Encoding: identity` upstream.
|
|
513
|
+
- **CLI f-string** used a PEP 701 nested-same-quote form that fails on the
|
|
514
|
+
minimum supported Python (3.11).
|
|
515
|
+
- **`config.example.toml`** is now shipped inside the package, so `cg init`
|
|
516
|
+
works from a `pip`/`pipx` install.
|
|
517
|
+
|
|
518
|
+
### Evidence
|
|
519
|
+
- Matrix **19/19** (18 realtime checks + protocol fuzz) on Raspberry Pi 5
|
|
520
|
+
(aarch64) and x86-64 Linux, Python 3.12/3.13.
|
|
521
|
+
- Adversarial corpus (default profile, 3600 healthy + 4000 faulty):
|
|
522
|
+
**hard-level false positives 0.0%**, recall 100% on all ten fault classes.
|
|
523
|
+
- Phase-1 shadow-mode soak across two hosts, ~13 models and 5 integration
|
|
524
|
+
paths: **zero genuine healthy sessions would have been terminated**
|
|
525
|
+
(one harness artifact, annotated, ledger kept append-only).
|
|
526
|
+
|
|
527
|
+
## [0.4.1] and earlier
|
|
528
|
+
|
|
529
|
+
Pre-PyPI reference builds, distributed as tarballs. 18-check matrix, adaptive
|
|
530
|
+
`decay` without `hard_floor`, Unix-socket transport only. See `git log` for the
|
|
531
|
+
full history.
|
|
532
|
+
|
|
533
|
+
[0.4.2]: https://github.com/ak2tx/ceiling-guard/releases/tag/v0.4.2
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Bug reports and small fixes are welcome. Before a larger change, open an issue
|
|
4
|
+
— the answer may be that it was tried and measured and did not work, and
|
|
5
|
+
`docs/` will say which measurement.
|
|
6
|
+
|
|
7
|
+
## The one rule
|
|
8
|
+
|
|
9
|
+
**A change to detector or protocol semantics is not done until it is
|
|
10
|
+
measured.** This project is a watchdog: it can end someone's work. An
|
|
11
|
+
improvement that is only argued for is a regression waiting to be found by a
|
|
12
|
+
user at three in the morning.
|
|
13
|
+
|
|
14
|
+
Definition of done:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
./tests/run_matrix.sh # 22 checks against real processes, ~14 min
|
|
18
|
+
python3 tests/bench_accuracy.py # hard-level FP 0.0%, recall 100%
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
plus a short note in the repo saying what changed and **which measurement
|
|
22
|
+
justified it**. If a measurement contradicts a design intuition, trust the
|
|
23
|
+
measurement and write down which intuition was wrong. `docs/` is full of
|
|
24
|
+
those; they are the most useful pages in it.
|
|
25
|
+
|
|
26
|
+
## Two instruments, and which is authoritative for what
|
|
27
|
+
|
|
28
|
+
| | use it for | it cannot see |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `tests/bench_accuracy.py` | tuning. Thousands of sessions in seconds against a simulated clock, on nine deliberately adversarial *healthy* workloads | a real process, a real `SIGSTOP`, a real kill |
|
|
31
|
+
| `tests/run_matrix.sh` | enforcement. 22 checks against real processes | high-N false-positive rates |
|
|
32
|
+
|
|
33
|
+
Run **both** after any detector change. Shipping something validated only by
|
|
34
|
+
the benchmark once let a stopped process go undetected; real hardware caught
|
|
35
|
+
it, not the model.
|
|
36
|
+
|
|
37
|
+
Row #22 installs the built wheel into a clean venv and runs the commands the
|
|
38
|
+
README names, from outside the checkout. It exists because for the whole life
|
|
39
|
+
of 0.4.2 the two documented integration paths were missing from the wheel and
|
|
40
|
+
every test passed anyway — they all ran from a checkout where `tools/` was
|
|
41
|
+
simply on the path.
|
|
42
|
+
|
|
43
|
+
## Invariants
|
|
44
|
+
|
|
45
|
+
`HANDOFF.md` lists seven. Each was learned by breaking it. Do not regress
|
|
46
|
+
them, and if you think one is wrong, bring the measurement.
|
|
47
|
+
|
|
48
|
+
The first is the one that matters most: **hard-level false positives must be
|
|
49
|
+
zero.** Warn-level noise is survivable; spurious termination is not.
|
|
50
|
+
|
|
51
|
+
## Style
|
|
52
|
+
|
|
53
|
+
Match the surrounding code. Comments explain *why*, especially why something
|
|
54
|
+
is not the obvious approach — a comment that says what the line does is noise,
|
|
55
|
+
one that records the failure that shaped it is the reason the file is
|
|
56
|
+
maintainable.
|
|
57
|
+
|
|
58
|
+
Zero runtime dependencies. Python 3.11+. Keep it that way.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Ceiling Guard -- the daemon (and, with a command override, the proxy).
|
|
2
|
+
# Zero Python dependencies, so this stays small.
|
|
3
|
+
#
|
|
4
|
+
# docker build -t continuity-guard .
|
|
5
|
+
#
|
|
6
|
+
# # daemon: mount a config and a dir for the socket + state
|
|
7
|
+
# docker run -d --name cg \
|
|
8
|
+
# -v "$PWD/config.toml:/etc/continuity-guard/config.toml:ro" \
|
|
9
|
+
# -v cg-state:/var/lib/continuity-guard \
|
|
10
|
+
# continuity-guard
|
|
11
|
+
#
|
|
12
|
+
# # proxy in the same network namespace as the daemon container
|
|
13
|
+
# docker run -d --name cg-proxy --network container:cg \
|
|
14
|
+
# -v cg-state:/var/lib/continuity-guard \
|
|
15
|
+
# continuity-guard \
|
|
16
|
+
# python -m tools.cg_proxy --upstream http://host.docker.internal:11434 --port 8111
|
|
17
|
+
#
|
|
18
|
+
# The daemon writes its socket where the config's `socket =` points; keep that
|
|
19
|
+
# on the shared volume (e.g. /var/lib/continuity-guard/cg.sock) so the proxy,
|
|
20
|
+
# supervisor, or library in another container can reach it.
|
|
21
|
+
|
|
22
|
+
FROM python:3.12-slim AS base
|
|
23
|
+
|
|
24
|
+
RUN useradd --system --create-home --home-dir /home/cg cg
|
|
25
|
+
WORKDIR /app
|
|
26
|
+
|
|
27
|
+
COPY pyproject.toml README.md ./
|
|
28
|
+
COPY continuity_guard ./continuity_guard
|
|
29
|
+
COPY tools ./tools
|
|
30
|
+
|
|
31
|
+
RUN pip install --no-cache-dir . \
|
|
32
|
+
&& mkdir -p /etc/continuity-guard /var/lib/continuity-guard \
|
|
33
|
+
&& chown -R cg /var/lib/continuity-guard
|
|
34
|
+
|
|
35
|
+
# default config location; override by mounting your own or setting CG_CONFIG
|
|
36
|
+
ENV CG_CONFIG=/etc/continuity-guard/config.toml \
|
|
37
|
+
PYTHONUNBUFFERED=1 \
|
|
38
|
+
PYTHONPATH=/app
|
|
39
|
+
USER cg
|
|
40
|
+
|
|
41
|
+
# a sane default config baked in (shadow mode, socket on the shared volume)
|
|
42
|
+
RUN mkdir -p /home/cg/.config && \
|
|
43
|
+
printf 'socket = "/var/lib/continuity-guard/cg.sock"\ndb = "/var/lib/continuity-guard/state.db"\n[defaults]\nshadow = true\n' \
|
|
44
|
+
> /home/cg/default-config.toml
|
|
45
|
+
|
|
46
|
+
# if no config is mounted, fall back to the baked default
|
|
47
|
+
ENTRYPOINT ["sh", "-c", "exec cg-daemon -c \"${CG_CONFIG}\" 2>/dev/null || exec cg-daemon -c /home/cg/default-config.toml"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ak2tx LLC
|
|
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.
|