Closes the design issues raised in
GHSA-xmh3-cw7j-9gp5
that 1.6.1 fixed the patchable half of and listed the rest of as Known and not
yet addressed, plus one item from the same report that list omitted. Two of the
three witness limits documented in 1.6.0 are fixed as well.
The thread running through all of it: a control that fails open is not a control.
Authentication that switched itself off when no token was set, an operator
identity anybody could assert, profiles with no owner, a signature nothing
checked, and a sandbox flag that disabled the sandbox.
If you publish the API anywhere other than loopback, read the first entry
before upgrading — it is the one change that can stop an existing deployment
from starting, deliberately.
Security
-
The API is no longer unauthenticated by default when it is reachable.
Three layers were supposed to answer "is this API authenticated?" and all
three failed open.API_BEARER_TOKENdefaulted to unset; the bearer
middleware returned early when it was unset, so the absence of a credential
disabled authentication instead of denying requests; and the only hard check
lived in startup validation, which downgraded to a warning unless
APP_ENV=production— while shipped compose set
APP_ENV: ${APP_ENV:-development}. The default was an open control plane
driving a browser that holds stored logins.The switch is now reachability rather than an environment name, because
reachability is what decides whether an open control plane is a local
convenience or an account takeover. New settingAPI_BIND_SCOPE
(loopback|exposed) declares it, and the controller refuses to start when
the scope isexposedwithout a token of at least 32 characters — in any
APP_ENV.It defaults to
exposed, so a deployment that declares nothing is assumed
reachable and fails closed. The basedocker-compose.ymldeclaresloopback
to match its127.0.0.1:8000:8000publish mapping, sodocker compose upon
a clean checkout still needs no token; the Codespaces overlay declares
exposed. A hand-rolled compose file that publishes on0.0.0.0without
saying so now refuses to start instead of silently serving an open API. If you
run the controller directly, a loopback bind host inAPI_BIND_HOST,
UVICORN_HOSTorHOSTis detected and treated asloopback.Authentication is decided in one place (
app/auth_policy.py) for both the
auth gate and the rate limiter, and the request path is what the tests assert:
a reachable, tokenless controller answers 401 to/sessionswhile/healthz
stays reachable for orchestrators.Upgrading: if you publish the API anywhere other than loopback and were
relying on it being open, setAPI_BEARER_TOKEN(32+ characters). If you
publish only on loopback with a compose file of your own, add
API_BIND_SCOPE=loopback. -
Two of the witness limits documented in 1.6.0 are now fixed.
Chain forking was a correctness bug rather than a limit. Appends were
serialised with anasyncio.Lock, which orders tasks on one event loop —
not threads in this process, and not other processes. Two workers
(AGENT_JOB_WORKER_COUNT > 1, several uvicorn workers, or two replicas on a
shared volume) could read the same head and write two receipts claiming the
same predecessor. Reading the head, signing, and appending are now one
critical section under an OS-level lock on the chain file, with an in-process
thread lock inside it.Tail truncation was undetectable: drop the last k receipts and what remains
is a shorter chain whose every signature still verifies. Each append now also
updates an anchor file beside the chain holding the head hash and receipt
count, andverify()compares them — the result gains ananchorblock, and
a truncated or rewritten chain reportsvalid: falsewith the reason.
Chains written before this release reportanchor: {"status": "unanchored"}
and keep verifying, because a missing anchor is not evidence of tampering.This needs no third party. An attacker who rewrites the anchor as well still
defeats it, which is what a genuinely external anchor is for; the anchor is a
separate artifact so one can be added later without changing this shape.
Key compromise remains a stated limit.Appends also stopped re-reading the whole chain to find its head.
-
Staged skill candidates are verified on read, not just signed on write.
Induction could sign a candidate envelope, but nothing on the read side ever
verified one — so the signature was an assertion the artifact made about
itself, the same shape as the pre-1.6.0 witness chain. Anyone able to write to
the staging root could edit a staged skill, or drop a new one in, and it was
served as a governed candidate carrying provenance.The registry now checks the signature before returning a candidate, and checks
that the envelope matches the artifact it sits in — a genuine signature lifted
from a different candidate is still a forgery. A candidate that fails is
refused;listomits what it cannot verify. Deployments with no signer
configured are unaffected: there is nothing to check, andsignedon the
candidate now records which case it was, so an unsigned envelope is no longer
a dict shaped exactly like a signed one.Candidates induced from a mock run are marked
simulated, inside the
provenance hash and the envelope, so a skill that no browser ever executed
cannot present itself as converged. The README's "signed provenance" claim is
reworded to say what actually holds. -
Codex no longer runs on the host with approvals and sandboxing disabled.
Both Codex paths — thecliprovider adapter and the host bridge — passed
--dangerously-bypass-approvals-and-sandbox, a flag whose own help text reads
"EXTREMELY DANGEROUS. Intended solely for running in environments that are
externally sandboxed". A model decision could therefore run any command on the
host.Nothing about the work needed it: both paths hand Codex a screenshot and a
prompt in an ephemeral temp directory and read back a JSON decision. Codex now
runs with-s read-onlyby default.CODEX_SANDBOX_MODEwidens that to
workspace-writeordanger-full-accessif you need it, and
CODEX_BRIDGE_ALLOW_HOST_EXEC=truerestores the bypass for the case its
help text actually describes — an environment that is itself sandboxed. That
opt-in logs a warning at startup and on the bridge's stderr. -
Auth profiles now belong to the operator who saved them. Profiles lived in
a flat root with no owner, so any caller who could reach the API could read
one, export its cookie archive, or — the takeover that matters — open a
session against it and drive a browser already logged in as somebody else.A profile saved by a caller with a proven identity records that operator as
its owner. Reading, exporting, overwriting on import, saving over it, and
opening a session from it then require authenticating as that operator, and
listing hides profiles you cannot access rather than advertising which sites
someone else holds logins for. Refusals are403, including on export, where
the route's catch-all previously turned any refusal into a500.Ownership keys on
source: "token", so claiming to be the owner in an
X-Operator-Idheader does not work. It also means ownership starts applying
exactly when named credentials do: a profile saved without a proven identity
records no owner, so shared-token deployments and every profile written before
this release keep working, with no migration step and no way to be locked out
of your own logins. -
Operator identity can now be proven instead of asserted.
X-Operator-Id
is a request header, and until now it was the only thing that ever set the
operator on an audit event — so the trail attributed actions to a string the
caller chose, which reads as identity while being a label.New setting
API_BEARER_TOKENStakes named credentials
(alice:token-a,bob:token-b). A request authenticating with one of those has
a verified identity recorded assource: "token", and aX-Operator-Id
header that disagrees no longer wins — the credential does, and the false
claim is kept on the audit record asasserted_idrather than dropped. A
named credential also satisfiesREQUIRE_OPERATOR_IDon its own.The shared
API_BEARER_TOKENstill works and still records
source: "header", because one shared credential genuinely cannot tell
operators apart. That distinction is now explicit in the data, so
authorization can requiresource: "token"— which is what the next release
needs in order to scope auth profiles to an owner.Every named token must also clear the 32-character floor; one weak entry in
API_BEARER_TOKENSis a way in. -
A privately reported advisory can no longer sit unnoticed.
GHSA-xmh3-cw7j-9gp5
was reported on 2026-06-17 and sat in triage for seven weeks, because a
private report appears in none of the views a maintainer opens day to day —
not issues, not pull requests — and was found only by listing advisories
through the API while publishing an unrelated one.SECURITY.mdpromises
reporters a quick acknowledgement.scripts/check_open_advisories.pynow fails the release audit while any
report is still waiting in triage. Accepted drafts are reported but do not
block, since accepted-and-being-fixed is a legitimate place to be mid-release.
The check attaches to cutting a release because that is the one process
guaranteed to run before users are affected by anything.The blocking path is what the tests cover: a gate exercised only against a
clean repo is indistinguishable from one that always reports "all clear".
Notes
The design items listed as Known and not yet addressed under 1.6.1 are the
scope of 1.7.0, along with one more from the same report that the 1.6.1 list
omitted: skill-candidate provenance is hashed but never signed or verified.