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.
Files changed (83) hide show
  1. ceiling_guard-0.5.3/CHANGELOG.md +533 -0
  2. ceiling_guard-0.5.3/CONTRIBUTING.md +58 -0
  3. ceiling_guard-0.5.3/Dockerfile +47 -0
  4. ceiling_guard-0.5.3/LICENSE +21 -0
  5. ceiling_guard-0.5.3/MANIFEST.in +17 -0
  6. ceiling_guard-0.5.3/PKG-INFO +810 -0
  7. ceiling_guard-0.5.3/README.md +764 -0
  8. ceiling_guard-0.5.3/SECURITY.md +95 -0
  9. ceiling_guard-0.5.3/TESTING.md +512 -0
  10. ceiling_guard-0.5.3/ceiling_guard.egg-info/PKG-INFO +810 -0
  11. ceiling_guard-0.5.3/ceiling_guard.egg-info/SOURCES.txt +81 -0
  12. ceiling_guard-0.5.3/ceiling_guard.egg-info/dependency_links.txt +1 -0
  13. ceiling_guard-0.5.3/ceiling_guard.egg-info/entry_points.txt +5 -0
  14. ceiling_guard-0.5.3/ceiling_guard.egg-info/top_level.txt +1 -0
  15. ceiling_guard-0.5.3/config.example.toml +227 -0
  16. ceiling_guard-0.5.3/continuity_guard/__init__.py +14 -0
  17. ceiling_guard-0.5.3/continuity_guard/actions.py +260 -0
  18. ceiling_guard-0.5.3/continuity_guard/cli.py +672 -0
  19. ceiling_guard-0.5.3/continuity_guard/client.py +312 -0
  20. ceiling_guard-0.5.3/continuity_guard/config.example.toml +227 -0
  21. ceiling_guard-0.5.3/continuity_guard/config.py +299 -0
  22. ceiling_guard-0.5.3/continuity_guard/daemon.py +1115 -0
  23. ceiling_guard-0.5.3/continuity_guard/detectors.py +553 -0
  24. ceiling_guard-0.5.3/continuity_guard/handoff.py +195 -0
  25. ceiling_guard-0.5.3/continuity_guard/integrations.py +259 -0
  26. ceiling_guard-0.5.3/continuity_guard/protocol.py +212 -0
  27. ceiling_guard-0.5.3/continuity_guard/proxy.py +414 -0
  28. ceiling_guard-0.5.3/continuity_guard/quota.py +794 -0
  29. ceiling_guard-0.5.3/continuity_guard/state.py +227 -0
  30. ceiling_guard-0.5.3/continuity_guard/supervise.py +590 -0
  31. ceiling_guard-0.5.3/continuity_guard/transport.py +133 -0
  32. ceiling_guard-0.5.3/docs/README.md +23 -0
  33. ceiling_guard-0.5.3/docs/account-window.md +150 -0
  34. ceiling_guard-0.5.3/docs/calibrate.md +124 -0
  35. ceiling_guard-0.5.3/docs/confirmation-window.md +133 -0
  36. ceiling_guard-0.5.3/docs/failure-mode-evidence.md +220 -0
  37. ceiling_guard-0.5.3/docs/integrating.md +148 -0
  38. ceiling_guard-0.5.3/docs/launch-post.md +138 -0
  39. ceiling_guard-0.5.3/docs/next-window-test.md +132 -0
  40. ceiling_guard-0.5.3/docs/platform-support.md +86 -0
  41. ceiling_guard-0.5.3/docs/quota-accuracy.md +337 -0
  42. ceiling_guard-0.5.3/docs/quota-adapters.md +145 -0
  43. ceiling_guard-0.5.3/docs/v0.4.2-hardening-and-backstop.md +163 -0
  44. ceiling_guard-0.5.3/docs/wrapup-signal.md +262 -0
  45. ceiling_guard-0.5.3/pyproject.toml +47 -0
  46. ceiling_guard-0.5.3/setup.cfg +4 -0
  47. ceiling_guard-0.5.3/systemd/continuity-guard.service +30 -0
  48. ceiling_guard-0.5.3/tests/armed_real_agent.sh +110 -0
  49. ceiling_guard-0.5.3/tests/bench_accuracy.py +575 -0
  50. ceiling_guard-0.5.3/tests/campaign/agent.py +259 -0
  51. ceiling_guard-0.5.3/tests/campaign/burn_claude.py +166 -0
  52. ceiling_guard-0.5.3/tests/campaign/burn_review.py +225 -0
  53. ceiling_guard-0.5.3/tests/campaign/run_burn.sh +101 -0
  54. ceiling_guard-0.5.3/tests/campaign/run_phase.py +237 -0
  55. ceiling_guard-0.5.3/tests/fake_cli_agent.sh +47 -0
  56. ceiling_guard-0.5.3/tests/fuzz_protocol.py +423 -0
  57. ceiling_guard-0.5.3/tests/mock_openai_server.py +143 -0
  58. ceiling_guard-0.5.3/tests/pi_verify.sh +155 -0
  59. ceiling_guard-0.5.3/tests/real_faults.sh +159 -0
  60. ceiling_guard-0.5.3/tests/run_matrix.sh +414 -0
  61. ceiling_guard-0.5.3/tests/soak_stability.py +429 -0
  62. ceiling_guard-0.5.3/tests/test_authority.py +407 -0
  63. ceiling_guard-0.5.3/tests/test_ceiling_signal.py +193 -0
  64. ceiling_guard-0.5.3/tests/test_env_knobs.py +177 -0
  65. ceiling_guard-0.5.3/tests/test_packaging.py +139 -0
  66. ceiling_guard-0.5.3/tests/test_protocol.py +102 -0
  67. ceiling_guard-0.5.3/tests/test_quota.py +752 -0
  68. ceiling_guard-0.5.3/tests/test_quota_confirm.py +203 -0
  69. ceiling_guard-0.5.3/tests/test_statusline.py +173 -0
  70. ceiling_guard-0.5.3/tests/test_wrap_output.py +257 -0
  71. ceiling_guard-0.5.3/tests/test_wrap_signals.py +170 -0
  72. ceiling_guard-0.5.3/tests/test_wrapup_race.py +151 -0
  73. ceiling_guard-0.5.3/tests/verify_quota_tracking.py +163 -0
  74. ceiling_guard-0.5.3/tools/cg_proxy.py +19 -0
  75. ceiling_guard-0.5.3/tools/cg_supervise.py +19 -0
  76. ceiling_guard-0.5.3/tools/install_phase1_units.sh +162 -0
  77. ceiling_guard-0.5.3/tools/langgraph_agent_loop.py +261 -0
  78. ceiling_guard-0.5.3/tools/make_windows_proof.py +172 -0
  79. ceiling_guard-0.5.3/tools/phase1.py +466 -0
  80. ceiling_guard-0.5.3/tools/real_agent_loop.py +466 -0
  81. ceiling_guard-0.5.3/tools/real_session_loop.py +352 -0
  82. ceiling_guard-0.5.3/tools/setup_nucbox.sh +152 -0
  83. 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.