Releases: Celmis-labs/Celmis
Release list
v0.1.29
Fixed
-
A paused session reconnected every two seconds, for as long as the tab was
open. Measured on production: 36 event-stream connections in 75
seconds, under an amber "Reconnecting…" badge, on a conversation that was
perfectly healthy and simply waiting for somebody to type.Two causes that compound. The retry loop stopped only on
final— "cannot
continue at all" — while a paused session reportsresumable, which
continues when a person sends a turn and never on its own. And the backoff
reset on any frame,stream_endincluded, so every reconnect that found
nothing reset the counter it was supposed to grow.What still reconnects is the state between the two: a RUNNING session whose
API restarted under it, which reports neither flag. That is the deploy case
the server comment describes, and stopping on anystream_endwould strand
it. Sending a turn now wakes the stream, because stopping the loop is only
safe if typing restarts it.
v0.1.28
Fixed
-
A notification with a relative link arrived as an empty card. Google
Chat validatesopenLink.url;/claude/<session-id>fails that validation
and the whole card is dropped, so what lands in the room is the bot's name
with nothing under it — while the log recordsnotif_delivered … delivered=1.
Seen on a phone, as a hole in the feed between a firing alert and its
recovery, at the exact minute an agent session finished.The alerts path already knew this rule and wrote it down; the agent path did
not. It now lives in one place —dispatch.public_link, absolute or nothing
— and a guard reads everynotify(link_url=…)call site withast. -
**0** critical · **4** errorreached a chat card with the asterisks in
it.body_mdis markdown and every other adapter is right to treat it as
such — Slack takes mrkdwn, Discord takes markdown — but a Google Chat
textParagraphrenders a small HTML subset and prints the rest verbatim.
Bold, italic, inline code and links are converted now, and the text is
escaped first: a notification body carries an alert title from somebody
else's monitoring, and that must not be able to put markup into a card sent
under this product's branding.
Changed
- The agent's own notification was titled "Claude Code finished a step". The
page it links to is called Agent in all sixteen locales.
v0.1.27
Fixed
-
A recovery paged exactly as hard as the outage did. Grafana sends the
same labels when an alert resolves as when it fires — the labels identify
the rule, not the event — and onlystatussays which happened. The parser
read the labels and ignoredstatus.Reproduced end to end on production, not deduced: a test gateway came back
up at 14:45 and the workspace was pagedcriticalwith the title
5xx rate 100% over 1m, byte for byte the card it had sent at 14:30 when
the service actually broke. A false page is noise; a false page
indistinguishable from the real one teaches people that a critical card
might mean nothing. A resolved alert now says so in its title and arrives as
info. It still arrives — coming back is worth knowing — it just stops
impersonating an incident.
v0.1.26
Changed
-
The agent page was headed "Claude Code" while the sidebar entry that
opens it said "Agent". Both were on screen at once — sidebar, breadcrumb
and H1 in one frame — and that frame is the screenshot published in the
guide. Two things were wrong and only one is about a trademark: a page whose
title contradicts the entry you clicked to reach it is a bug at any name.
claude.titlenow equalsnav.agentin all sixteen locales.Every descriptive mention stays, because the terms allow saying a product
runs Claude Code: the connection card, the token walkthrough, the engine
pickers, the MCP client list,AI report: Claude Codebesideoffand
API. The guard that was supposed to protect the permitted half had picked
claude.titleas its specimen — a heading, not a sentence — so it asserted
the one string that had to change. It now watchesclaude.helpTitle, on the
same page, and two new guards hold the heading: it must agree with its nav
entry, and neither may carry the engine's name.
v0.1.25
Security
-
The sandbox firewall covered one of the two ways in. The INPUT rule was
in place and the deploy logged "sandbox→host blocked"; probed from inside
the running container,host:22timed out andhost:80answered. A
container port published on the host is destination-NAT'd and then
forwarded, so INPUT never sees it.Nothing was exposed by it — the only
0.0.0.0port is Caddy, which the
internet reaches anyway, and postgres, the api and the web app are bound to
127.0.0.1; cross-network isolation was probed too and holds. The rule is
here because the next published port is the one nobody re-checks. A second
rule inDOCKER-USERmatches--ctstate DNAT: exactly the packets that
arrived through a published port, leaving the sandbox's internet egress and
the api on the sandbox network alone. Verified on production after applying
it — 22 blocked, 80 blocked, internet open, api open.
Fixed
- CI could not see the history it was asked about.
actions/checkout
fetches one commit and no tags. The guard that recomputes PROVENANCE.md's
numbers failed with "the record pins v0.1.23, which is not a tag in this
repository" — a true sentence about the clone and a false accusation against
the file — and the guard that no commit ever weakened that record and its
own test together walked a log of one commit and passed on nothing. Proven
on a--depth 1clone.fetch-depth: 0, and both now fail loudly on a
shallow clone rather than answering a question they cannot see.
v0.1.24
Security
-
The sandbox firewall step said it was not optional and continued without
it. Both failure branches logged aWARNING:and carried on, five lines
after a realfailon a missing.env— so a host without iptables shipped
a container that runs a tenant's own build commands with a route to the host.
It now tries iptables, then nft, then stops, with
CELMIS_ALLOW_UNFIREWALLED_SANDBOX=1as a typed decision for a host isolated
another way.And it did not survive a reboot. Measured on the production box: the rule was
in place,iptables-persistentwas absent and crontab was empty — so the
gap after a restart was not until the next scheduled deploy, it was until
somebody deployed by hand. A systemd unit orderedBefore=docker.service
reinstates it at boot.
Added
-
Publishing is a workflow now, not a person with a token.
publish-verifier.ymluses PyPI Trusted Publishing, so upload rights belong
to this workflow in this repository for the length of a run. The tag prefix
ispypi-, and the first letter is the reason:release.ymltriggers on the
globv*, whichverifier-0.2.1matches, because "verifier" starts with a
v — that tag would have built three container images. -
Images carry a signed build attestation.
gh attestation verifynow
answers "built from which commit, by which workflow" for somebody holding
only the image; before this the OCIrevisionlabel was the only claim, and
a label is text anybody can write. Buildkit's ownprovenance: falsestays —
different mechanism, and the comment there is right. -
packaging/pypi/DIGESTS.mdrecords the sha256 of every published file in
the git repository, because PyPI is the same channel that serves them. It
says plainly what it is: the 0.1.0 and 0.2.0 digests were read from PyPI and
written down, which is a trust-on-first-use anchor and not independent
attestation. From the first workflow-published release the digests are
printed before upload and the artefacts carry a PyPI attestation.
Fixed
-
The lint gate everybody believed in enforced nothing.
ci.ymlsaid the
rules-of-hooks rule was blocking as a test in the python job; those tests
carryskipif(not _eslint_available())and that job never installs
web/node_modules, so they reported SKIPPED in green — while this step ended
in|| echo. Measured for the first time: 42 findings, 31 errors, 24 of them
in the react-hooks family whose breach took every authenticated page down.
scripts/eslint_gate.pymakes rules-of-hooks fatal at any count and ratchets
the rest from 42. -
The verifier read whatever an archive told it to. A zip declares each
member's size before you read it, and neither copy checked. Measured against
the publishedcelmis 0.2.0: an archive of 200 KB on disk declaring 200 MB
verified asOKwith a 215 MB peak, and the number was the sender's to
choose. The person running that got the file from the party they are
checking, on their own laptop, precisely because they do not trust them.Sizes are checked before anything is read — per member, and in total, which
is what a spread of medium files defeats — and hashing streams a megabyte at
a time, counting what actually arrives so a header that lies in the other
direction is refused too. Limits are three orders of magnitude above a real
pack, and identical in both copies, held so by a test. -
A manifest that was valid JSON and not an object crashed the verifier.
manifest.get(...)on a list raised AttributeError: plain output reported it
as a problem found (exit 1) and--jsonprinted a traceback, also exit 1.
Neither was true — nothing had been checked, which is exit 2. Both modes now
agree, and the refusal says what the manifest actually was.
v0.1.23
Security
-
/healthzhanded out the installation's configuration, to anybody, now.
main.pycopies the webhook sub-app's routes into the main app; the sub-app
declares its own detailed/healthz, and a copied route lands ahead of the
plain one declared below — first match wins.middleware.pyexempts the path
from authentication and Caddy proxies/backend/*, so the public answer
carried every model name, deadline, budget, the cache size and which backends
are configured. Observed answering exactly that on production.The route is no longer copied. The reason the block existed is real and is
kept:env_filepoints at a.envthat does not exist in the container, so
a setting the compose file does not forward silently takes the code default
and nothing outside could tell which had happened. It is served from
/api/ops/review-settings, behind an admin. -
/readyzand/metricswere public by the same route./readyz
returned per-dependency detail including a user count andstr(exc)[:200]
from a failed connection — for a bad DSN, a fragment of the DSN. It now
answers{"ok": …}and the status code, which is a probe's whole contract;
the detail is at/api/ops/readyzbehind an admin./metricsreturned the entire Prometheus slice — queue depths, spend, review
counts, error rates. It now needsCELMIS_METRICS_TOKENwhen the request
passed a proxy, and is served unconditionally when it did not: the bundled
Prometheus scrapesapi:8000/metricsdirectly over the compose network and
keeps working. A caller can addX-Forwarded-Forand lose access; they
cannot remove Caddy's, so the public path fails closed.
v0.1.22
Added
-
A finding now says whether your own code names the package. A findings
list is hundreds of rows and every one reads the same, so a direct dependency
the service imports on its hot path and a transitive package pulled in four
levels down by a build tool looked identical to whoever had to triage them.
named_in_codecarries an import-position answer with up to five
file:linesites.Three states, and the third is the point.
imported,not_found, and
unknown— because a package name does not determine its module name.
beautifulsoup4imports asbs4,pillowasPIL; reporting those as "not
imported" would be the silent zero this subsystem is built to refuse. The
rule is exact for npm, Go and crates.io, so absence means something there;
for PyPI it is not, and the answer says so.This is not reachability, and nothing in it is named as though it were.
Reachability would need the dependency's own source in the index — excluded
on purpose — advisories that name the vulnerable symbol, which OSV carries
for a small minority, and a notion of where execution starts, which does not
exist here. A test fails the build if any identifier in the feature is
calledreachable, because a column name reaches an API response and
eventually a filing, where nobody reads the docstring that qualified it.
Fixed
-
Four caps bounded a dependency audit in silence. Twenty lock files, four
thousand entries per lock file, forty manifests, six hundred transitive
candidates — every one a plain slice orbreak, with nothing logged and
nothing recorded. A monorepo with twenty-five lock files produced an SBOM
missing five, and the evidence pack built from it read as a complete
inventory. The caps themselves are right; an audit has to end. Not saying
they bit is what turned a bounded read into a false statement about what is
installed — anddocument.pyalready writes the rule down: "count of what
was dropped is printed rather than silently truncated".Each now logs what it dropped and records it, and the run summary carries
truncatedper repository so the SBOM and the pack can say the list was
short. A test asserts the caps still bound the work, because lifting them
would turn a warning into an audit that never finishes. -
Two texts still promised verification without trust. The README
scenarios table andhome.htmlwere missed when the evidence-pack claim was
corrected, and the site build reads README — so the old promise was live on
the published docs page.
v0.1.21
Security
-
The evidence pack's hashes prove consistency, not authenticity — and the
product said otherwise.MANIFEST.jsonrecords a sha256 for every other
file and none for itself, because a file cannot contain its own hash. So
anyone who opens the zip, edits a file and rewrites that file's entry in the
manifest passes verification. Demonstrated against a real production pack:
the edited archive verified asOKand exited 0.README, the dependencies page and the onboarding tour all said a third party
could "check nothing was edited afterwards without trusting us". That is
false of an unsigned manifest that does not hash itself, and it is the
sentence a CRA filing would lean on.The fix is the manifest's own hash, obtained from somewhere the sender does
not control.GET /api/deps/{run_id}/evidencenow returns it in
X-Celmis-Manifest-SHA256and logs it;celmis verify --manifest-sha256 <hex>checks against it; and every text now says which of the two things it
establishes. A malformed hash is a usage error (exit 2), not a mismatch
(exit 1) — losing characters to a line wrap must not read as tampering.
v0.1.20
Added
-
celmisis on PyPI, and the pack now says so. Published 0.1.0 and
verified from the live index: installed on Python 3.9.25 in a clean
container, all five commands run against a real production pack, one flipped
bit reported asfindings.json: sha256 mismatchwith exit 1, a missing file
with exit 2.summary.mdinside every pack said "recompute them and
compare", which was true and had no executable form; it now names
pip install celmisandcelmis verify, and says in the same breath that
you are not required to use it — the manifest is plain JSON and sha256 is
sha256. A tool that made checking us require trusting us would defeat the
point of the pack. -
packaging/pypi/— the evidence-pack verifier, as a standalone package.
summary.mdinside every pack tells a third party they can check it without
trusting the tool that made it, and until now the only way to reach
verify_pack()was to clone an AGPL repository and install forty
dependencies under Python 3.13. The claim was true and unexecutable. The
verifier is a few hundred lines ofhashlib,io,jsonandzipfile, so
it lifts out with no dependencies at all and runs from Python 3.9 — which is
where a locked-down audit box usually is. Two commands,verifyandshow,
with exit codes an auditor's CI can read: 0 intact, 1 problems found, 2 could
not check.A fixture pack is committed in both trees and a drift test builds a fresh
pack frombuild_evidence_pack()and runs the packaged verifier over it.
Without it, the day somebody adds a file to the producer every installed
celmis verifystarts reportingpresent but not in the manifest— a false
accusation of tampering, produced by our own tooling, against the operator.
Changed
-
The platform's distribution is
celmis-platform.celmison PyPI is the
verifier above, and while both were called the same thing two failures were
one command away:python -m buildfrom the repository root would publish
the entire server under the verifier's name, and in a shared environment
importlib.metadata.version("celmis")would answer with the verifier's
version — whichsrc/vault/provenance.pystamps into every generated
document and/api/capabilitiesreports as the running platform. Both
DISTRIBUTIONStuples now ask forcelmis-platformfirst, and the root
carries aPrivate :: Do Not Uploadclassifier, which is not a real
classifier and which PyPI therefore rejects — a hard stop rather than a note. -
Two sentences kept the mark after the labels above them lost it. The
navigation entry and the tour heading were renamed to "Agent"; the subtitle
and the tour's own description still said "Dispatch a bug straight to the
Claude agent" and "Claude agent: code-editing sessions on your repos". A scan
for "Claude agent" found seven locales of sixteen — the other nine inflect it
("agentovi Claude", "à l'agent Claude", "al agente de Claude"), which is why
the first pass missed them. All thirty-two strings are fixed, and the guard
now covers the keys that name a section of ours, not only the button.Naming Anthropic's product is untouched: "Claude Code agent — researches the
code" is an engine the operator picks between, and erasing the mark there
would leave them guessing what they chose.