Repository navigation
v1.11.2
A security release over 1.11.1, with four advisories:
- GHSA-3pv7-p7w6-q93q (high): a web page could reach the browser desktop through VNC on port 5900,
bypassing noVNC's Origin check. - GHSA-9wxr-2vcc-9x6c (moderate): governed approvals could be forged for a tool call, reused after their
action started, or skipped by the cron tools. - GHSA-j5hf-3vw7-g4cp (moderate): content of a page outside
ALLOWED_HOSTSreached the agent through
popups, logs, downloads, cookies and the pages it chose. - GHSA-xq9j-p4rw-q2cq (moderate): a page could stop a session's witness receipts; each later action ran,
then reported an error.
Two more security fixes, to witness verification and to staged skill candidates, have no advisory of
their own, and Fixed lists the rest, among them three that keep operators under named credentials out
of each other's MCP sessions, session ids and files.
To deploy the fixes:
- update the controller: its image, or your source checkout followed by
pip install -r controller/requirements.txt, which now pinsidna; - update the browser-node image, which carries the VNC fix, and take the new
docker-compose.yml,
which no longer publishes 5900; - if
ISOLATED_BROWSER_IMAGEnames an image explicitly, point it at the 1.11.2 browser-node image; - close isolated sessions started before the upgrade, and recreate the containers.
The client, LangChain and MCP bridge packages change only in version; updating them alone does not
deploy the fixes.
Upgrade notes:
- A VNC viewer pointed at port 5900 no longer connects. Open noVNC on 6080 instead
(http://127.0.0.1:6080/vnc.html?autoconnect=true&resize=scale, or the session'stakeover_url),
which asks forVNC_PASSWORDwhen you set one.VNC_PORTis removed from.env.example, the compose
file andmake doctor, and a new isolated session'sruntimeobject has novnc_port(the stored
record of an earlier session keeps it). Seedocs/deployment.md, "Gateway recommendations". - Ask again for a governed tool call that was pending or approved when you upgraded. An approval
stored before 1.11.2 counts as a browser action's, which no tool call can spend: the agent repeats the
call without its oldapproval_id(an approved one once you know it did not already take effect), and
an operator approves the new request. A pending one left from before shows in the queue as a
request_human_takeoveraction: reject it. WithAUTONOMOUS_APPROVALS=true, that rejection stops the
agent approving its repeated call itself only for a call that names no session: a session's rejection
binds only within that session, which the upgrade's restart ends, so the agent approves the repeat in
its new session unless you turnAUTONOMOUS_APPROVALSoff. - An approval is spent when a call claims it, before its action runs. If the action then fails,
retryable refusals such aselement_not_stableincluded, the sameapproval_idis refused, where
1.11.1 let it run the action again: "was spent when its action started (...), so the action may have
taken effect: observe the page, or check the outcome, before acting again. Only to do it again, repeat
the call without approval_id, which asks an operator for a new approval". A spent approval does not
mean its action failed: check what it did before you approve a repeat. Only a browser action that ends
before it begins gives its approval back (its session closing, or an upload's file going, after the
claim included), and the answer then carriesapproval_given_back: retry anapprovedone with the
sameapproval_idonce the cause is gone, except when the note says its session has closed: nothing
can run it then, so repeat the call in a live session withoutapproval_id. Which endings give back,
and the REST status of each refusal, are indocs/mcp-clients.md, "When an approval is spent, and
when it is given back". - A reject sent after a browser action claimed its approval, but before the action began, stops
the action (approval_rejected) and answers 200 with the record, stillexecuted; the approval then
becomesrejected. Once the action has begun, a reject is refused (a 409 over REST). A decision
sent to another worker or replica while an action holds the approval is refused with a 409 ("has
already been spent"), where 1.11.1 accepted a reject. Seedocs/mcp-clients.md, "Rejecting or
approving while an action holds its approval", which also covers Windows hosts with the default file
store. - A governed upload's file is checked before anyone is asked to approve it, in
browser.execute_actionand the agent loop: a missing file is refused (not_foundover MCP) and
queues nothing.browser.execute_approvalandPOST /approvals/{id}/executecheck it before they
claim the approval, so a file gone since the approval leaves it approved. - An agent can no longer ask for approval of a
request_human_takeoverordoneaction, or run
one: it is refused with a 400 before anything is queued.POST /approvals/{id}/executeanswers a
409, without spending the approval, for a tool call's approval and for adoneor a takeover approved
before 1.11.2, each of which 1.11.1 answered there with a 400 ("Unsupported action" over MCP). - Approval webhooks and SSE approval events carry
origin, a server-writtenaction_summaryand,
for a tool call,arguments_size, and theirreasongoes out on one line: a newline as\n, a
tab or another control as its code point ([U+0009]). A sensitivetypereads "text hidden at the
agent's request (sensitive)" in the queue, the confirm dialog, the webhook and the SSE summary, where
the dashboard read "(sensitive text hidden)": the agent can mark any typed text sensitive. - A governed tool call's arguments are kept whole beside its approval (
<id>.argumentsin
APPROVAL_ROOT, credentials left out), forGET /approvals/{id}/argumentsand the queue's "View all
N characters". The file goes when the call that spends the approval ends, or at the next start if the
controller stopped during that call. A rejected one's goes once the rejection is older than
APPROVAL_TTL_MINUTES(at the next approval request or start), and until then an operator can still
approve it. One left pending, or approved and never run, keeps it until a call spends it, and nothing
else sweeps it. Seedocs/mcp-clients.md, "What the operator is shown". - An approval an earlier version stored whose action 1.11.2 refuses is left out (a navigate to a URL
whose host a browser reads otherwise, below): listings leave it out, and fetching or executing it
answers as an unknown id, so nothing can execute it, and the agent asks again. Fetching or listing it,
from either store, logs a warning that names it and where it fails, never what it holds (a navigate
URL's query can carry a token). - The cron tools ask for approval under governed approvals. An agent that creates, triggers or
deletes cron jobs withworkflow_profile=governed, or on a controller with
FORCE_GOVERNED_APPROVALS=true, now getsapproval_required, and repeats the call with the
approval_idonce an operator approves; RESTPOST /cronsandPOST /crons/{id}/trigger, which need
the bearer token, never ask. A job created by an earlier version keeps running asfast, and jobs
cannot be edited: to govern one, delete it and create it again from a governed caller, or through
POST /cronswith"workflow_profile": "governed", or turn onFORCE_GOVERNED_APPROVALS, which
forces every run togoverned. A governed job's run cannot continue past an action that needs an
operator's approval: it stops there, and its session closes with the run, so that approval, once
approved, can no longer be executed: it staysapproved, and triggering the job again asks for a new
approval in a new session. WithAUTONOMOUS_APPROVALS=truethe agent approves such an action itself
and the run carries on. Run work that needs an operator's approval in an interactive session, or keep
the jobfastunder a reviewed script. Seedocs/mcp-clients.md, "Cron jobs and
FORCE_GOVERNED_APPROVALS". - A page
ALLOWED_HOSTSrefuses is read again only after the agent navigates it to an allowed URL, or
a blocked action's rollback brings it back. Until then observe, screenshots, the page-reading MCP
tools and actions other than navigation refuse it (REST: 403), also after the controller has sent it
back toabout:blank, where they used to read the blank page, and after it has loaded an allowed URL
on its own. A popup or blank window a refused page opened is refused with it.go_backleaves a
refused page refused, where 1.10.0 through 1.11.1 let it leave a disallowed page;reloadand
go_forwardare refused there, as before. A refused tab that is not the active one cannot be
activated: close it, or open a new tab. Seedocs/deployment.md, "Reading a refused page again". - While a tab of the session can run a refused host's code, and for 2 seconds after it closes, the
other tabs' own navigations are refused too. Such a tab was refused for where it went, a refused
host's redirect or its opener, is navigating through a refused host, or, refused only because of
another tab or still waiting for its opener, is on ablob:ordata:page or a blank one the
controller did not send it to. A tab that loads a page on its own meanwhile (a link the agent clicks
while the refused tab is open, a redirect, a script, a reload) is sent back toabout:blank, and
whatever it had loaded, a form in progress included, is lost. Close the refused tab if it is still
open, then navigate the other one: the refusal says so. Each of the agent's actions on the session (a
navigate, a click, typing, a key press), and opening a tab onto a URL, first waits out what is left of
those 2 seconds, for at most 2 seconds from the call, and not while a refused tab is still open; the
wait comes before the action begins, so a call cancelled meanwhile gives a claimed approval back. A
tab refused this way that is not the active one cannot be activated, also once the refused tab is
closed ("That tab loaded while another tab of this session was refused, or within 2 seconds after a
refused tab closed, ..."): close it, or open a new tab. Seedocs/deployment.md, "Other tabs while
one is refused". - A navigation to a URL that names another host to a browser than it shows is refused, in every
profile: anavigateaction,POST /sessions/{id}/actions/navigate,POST /sessions/{id}/tabs/open,
and a new session's or a fork'sstart_url. That is a backslash in its authority
(https://evil.example\@bank.example/), a URL written without the//after its scheme or with extra
slashes (https:u:p@host/), a percent-encoded host, an IPv4 address not written as a dotted quad
(http://127.1/), a host a browser maps to another name beyond case folding and composition (one in
full-width digits, say), or a control character (U+0000 to U+001F, a tab or a newline among them) in
its scheme or host, which a browser drops. A backslash or a control in the path or the query is
accepted. Each run of a cron job an earlier version stored with such astart_urlfails before a
browser opens, and the controller logs why: delete the job and create it again. See
docs/mcp-clients.md, "What the operator is shown". - Downloads and
browser.get_cookiesfollowALLOWED_HOSTS. A download must come from a host on
the list, through no off-list host on its redirects, and from a page on one, or it is cancelled and
recorded asblocked. The host checked is the file's after its redirects, so a file an allowed host
redirects to (a CDN, a file host) is refused unless that host is listed too, and listing it also lets
pages navigate there; a link that passes an unlisted redirector (a tracking link, say) is refused even
when the file's host is listed. A file a page generates (blob:,data:) is judged by the page that
reports it.browser.get_cookiesreturns only cookies a listed host would receive; with
ALLOWED_HOSTSempty or*, it filters nothing. Seedocs/deployment.md, "Refused downloads" and
"Cookies an allowed host would receive". - The page-reading MCP tools and the REST CDP routes take the session lock, and are bounded.
browser.eval_js,browser.get_html,browser.find_elements, the storage tools,browser.drag_drop
and thebrowser://<session>/domresource wait up to 10 seconds for a session another call holds and
then fail, and give up on a page that does not answer within 30 seconds (abrowser.find_elements
text query within 10,query_timeout;browser.eval_jstakes atimeout_msof up to 60000 instead;
a drag addsACTION_TIMEOUT_MSfor each end named by a selector orelement_id, then holds the
session up to 7 seconds more to remove its scroll watch and release the mouse; giving up does not stop
the page's script).browser.wait_for_selectordoes not take the lock, and is refused if its page
became refused, closed or not the active tab while it waited. The REST CDP routes (GET /sessions/{id}/cdp/element,POST /sessions/{id}/cdp/raw) answer 409 when they cannot get the
session within 10 seconds, 403 for a pageALLOWED_HOSTSrefuses, 503 when they cannot attach to the
active tab, where 1.11.1 used the tab they were attached to before, and 504 when the browser does not
answer, attach or detach within 10 seconds. Seedocs/deployment.md, "Reads and the page tools". - Witness verification fails on missing evidence, where it used to pass it. The first start after
upgrading anchors every chain that has no anchor yet, as the chain stands, and marks the witness root
(.anchors-migrated); no later start does. That includes a chain whose anchor was deleted on 1.11.1
or earlier, so if its evidence matters, check the chains without a<id>.jsonl.anchor.jsonbefore
that start. WithWITNESS_ENABLED=false, a session with no receipts now verifiesfalse("no
receipts recorded for this scope"), and theanchor.statusvalueunanchoredis gone. See
docs/deployment.md, "Witness receipts: what a signature proves". - Skill candidates staged by an earlier version are refused when read or registered ("... induce it
again"), andharness.list_candidatesleaves them out, logging a warning for each: run the
convergence again to stage them afresh. This only matters on a controller with a mesh identity, where
candidates are signed. - Under named credentials, a subfolder of
UPLOAD_ROOTnamed like a session id (12 lowercase
hexadecimal characters, so a 12-digit timestamp too) is treated as that session's directory, even if
you made it yourself. An upload refused for that reason says so: rename the folder.
Security
- Raw VNC is gone: x11vnc listens on no TCP port, and noVNC on 6080 is the only way to the desktop.
x11vnc served the browser-node display on TCP 5900, with no password unlessVNC_PASSWORDwas set,
and its VNC library accepts WebSocket connections there, checking only that anOriginheader is
present. So a web page in a browser on the same host could reach the desktop around noVNC's Origin
check, through the base compose file's127.0.0.1:5900publish or the host port each isolated session
container published atISOLATED_BROWSER_BIND_HOST, wherever that browser lets pages connect to local
addresses without asking. The agent's own Chromium shares the container's network and needed no
publish. In images from 1.5.1 on, which install it with Playwright 1.62, it is expected to ask before
a public page connects to a loopback address (not tested); older images install an older Chromium,
which may not ask. A page that got through had full view and control of a browser holding stored
logins. Every release through 1.11.1 is affected. x11vnc now serves RFB only on a unix socket, in a
directory only the browser user can enter, and the IPv6 port its library opened on 5900 by itself is
closed too; websockify forwards noVNC to that socket, and nothing exposes or publishes 5900.
Unchanged: noVNC has no password by default, and a program that is not a browser can send any Origin
it likes, so anything on the host or the Docker network that can reach 6080 can still take over the
desktop. SetVNC_PASSWORDwherever that matters.
Governed approvals:
- An approval for a governed tool call can no longer be minted as a browser action, or spent as the
wrong one. A governed tool call that is not a browser action (browser.eval_js,
browser.set_cookies, ...) is approved through a stand-inrequest_human_takeoveraction whose text
names the tool and a digest of its arguments, and approvals were matched on field values alone. An
agent could ask for that stand-in throughbrowser.execute_action, with a reason that previewed other
arguments, which the dashboard showed as that tool call, and once an operator approved it, spend it on
the arguments the digest named. Every release from 1.0.5 through 1.11.1 is affected (before 1.8.0 the
stand-in carried no digest, so such an approval covered any governed tool call with the same risk
category in its session). An approval now records its origin,actionortool_call, set only by the
gateway: one origin's approval is refused for the other, a pending one is reused only within its
origin, and the dashboard names a tool call only fororigin: tool_call. An operator's rejection
counts whatever the origin, so one made before the upgrade still holds where it binds (upgrade notes),
unless it was made before 1.8.0, on a stand-in with no digest, which matches no current call. Still
the case: the agent writes an approval's reason, so read the action as well as the reason. - An approval that let its action start is spent, whatever fails after. An approval was consumed
only once its action had returned, so an action that went through and then failed (the settle after a
click, the observation, a captcha), or a controller restart while it ran, left it approved, and the
sameapproval_idran the action again: a second payment click or upload. So did a governed upload
withREQUIRE_APPROVAL_FOR_UPLOADS=false, and an upload whose file went in while Playwright's call
raised. Every release through 1.11.1 is affected. The claim now spends it, only a browser action that
ends before it began gives it back, and an action approval covers one operation. WithSTATE_DB_PATH
set, an approval spent while SQLite was unavailable could also run again once SQLite was back. At a
start on SQLite, SQLite's spent record now always wins over the file store's, and an operator's
rejection over a file record not updated after it; SQLite takes the rest of what it missed, and the
record it keeps is written back to the file store, so a later start whose SQLite fails cannot claim
the approval again. If that write fails, SQLite stays primary, and if SQLite then cannot take the rest
either, the controller refuses to start rather than run on a file store that could claim a spent
approval. Every record write lands whole under the store's lock, and one whose SQLite half fails puts
the file record back before it reports the failure, so the next start does not revive what the caller
was told failed. A give-back reported as failed stays failed even when putting the file back fails
too, since SQLite's spend wins. Not covered: two controller processes sharing one approval store (file
or SQLite) can still both claim an approval, since the claim is atomic only within a process; several
workers or replicas are not supported. The details, the approval guard's error codes and witness
receipts included, are indocs/mcp-clients.md, "When an approval is spent, and when it is given
back". - An approval whose session has no record follows the operator who asked for it. Who may use an
approval follows its session's owner, and a session with no record (lost while the approval stayed)
read as unowned, so under named credentials any operator could read, list, decide and execute another
operator's approval, its reason included, which previews a tool call's arguments (credential fields
left out). Such an approval now belongs to the operator its record names, and one that names nobody is
refused under named credentials, as such a session id is (Fixed, below); a known session that nobody
owns stays open to every operator. - What an operator is shown for an approval says what it covers. The webhook and the SSE event
carried neither the origin nor the action, so a coordinate click whose reason copied a tool call's
read the same as that tool call's approval. The queue showed a coordinate click as a bareclick, an
option pick without its value, and a selector where an element id beside it was what ran, and cut
typed text at 200 characters without saying so. A tool call's preview hid every value under a key
namedvalue,password,tokenorsecret, which hid a harness run's start URL and conditions,
and its URL fields were accepted even where a browser reads another host in them than the controller
does. Now the queue, the summary, the webhook and the SSE event list every field an action approval
binds and mark one that does not act, nothing is cut without saying so, and characters that hide or
reorder text (bidi overrides, zero-width marks, controls) are shown as their code point ([U+202E]).
A tool call's preview leaves out credentials by tool and path, among them the userinfo and
credential-named query values of five URL fields:browser.cdp_attach's URL, a cookie's URL, a
harness run's start URL and mock final page URL, and a cron job'sstart_url. Those five are refused
where a browser could read another host in them than the controller: a backslash or a control
character (U+0000 to U+001F) anywhere, a percent-encoded host, one UTS 46 maps to another name beyond
case folding and composition, an IPv4 address not written as a dotted quad, or a URL written without
the//after its scheme or with extra slashes. A navigate'surlis refused by its host alone
(upgrade notes), and the queue and the summary show it with its host as a browser sends it (xn--),
the host as written beside it. Witness receipts and audit entries carry an approval's origin and, for
a tool call, the tool, where they named only the stand-in; the audit log stays operator-wide, so under
named credentials every operator reads these. Still the case: letters that look alike across scripts,
and right-to-left letters, are shown as they are; a credential in a navigate's URL is shown, and so is
one in a URL's fragment, in a query parameter of another name, or anywhere but those paths; and with
AUTONOMOUS_APPROVALS=truethe agent approves its own calls, and an operator's rejection stops it
approving the same request only within that session (in controller scope, for the same operator). The
full rules are indocs/mcp-clients.md, "What the operator is shown" and "Controller-scope approvals
and the audit log". - The cron tools now ask for approval under governed approvals.
browser.create_cron_job,
browser.trigger_cron_jobandbrowser.delete_cron_jobwere never gated, and every run of a job
started asfastunlessFORCE_GOVERNED_APPROVALS(1.10.0 and later) was on, so a governed agent
could plant a job that ran its writes without approval. Every release from 1.0.5 through 1.11.1 is
affected, for an agent served the cron tools (only thefullprofile has them, unless
MCP_TOOL_PACKSadds thecronpack). The first two are nowwriteand the thirddestructive, and
every run of a job starts under theworkflow_profileof the call that created it. A run or step
queued by a governedbrowser.queue_agent_runorbrowser.queue_agent_stepcall now runs governed
too, whatever its request names, where it ran under the request's own profile,fastincluded.POST /cronsstores theworkflow_profileits body names (fastby default). The full tool profile now
has 19 tools with a governed kind, not 16. Still the case: tools without one are not governed, the
proxy persona tools among them, andbrowser.create_session,browser.fork_sessionand each run of a
cron job, scheduled or triggered, open astart_urlwith no approval, though creating or triggering a
job through the MCP cron tools asks for one (RESTPOST /cronsandPOST /crons/{id}/triggernever
do). Seedocs/mcp-clients.md, "Cron jobs and FORCE_GOVERNED_APPROVALS".
ALLOWED_HOSTS:
- A popup onto a host off
ALLOWED_HOSTSis sent back toabout:blank, and a page is refused for
where it came from. Playwright reports a popup only once its first navigation has committed, so the
listener that holds a page to the list came too late. A popup opened straight onto a disallowed host
(bywindow.open, atarget=_blanklink, or a page timer) stayed there, kept running, wrote no
navigation_blockedevent, and fed its console messages and page errors into every observation and
the console tools; shadow-browse sessions attached no listeners to their popups until their tabs were
next listed. A blank window a refused page opened is same-origin with it and scriptable by it, so
whatever the page had it log, fetch, title or download passed every URL check, and the agent could
switch to it and observe it. Every release through 1.11.1 is affected: the guard that missed these
arrived in 1.10.0, and before it no popup was sent back at all. The controller now checks the URL a
page is already on when it attaches its listeners, to every popup in shadow-browse too, and drops a
refused page's console messages and page errors. A page is also refused while it is tainted: it
committed a refused URL, came through a refused host's redirect, loaded a page on its own while
another page of the session was refused or within 2 seconds of its closing, or is a popup of a refused
page. The TOTP autofill skips a refused page, also on a host intotp_hosts. The full rule is in
docs/deployment.md, "When a page is refused". - The network log and request failures withhold what a refused page asks for. A request made by a
pageALLOWED_HOSTSrefuses, or one that navigates a top-level page to a refused host, was logged
whole, the page's HTML included, and so were a service worker's requests after the refused page that
registered it was blanked;request_failureskept the full URL of each failed request such a page
made. Such requests are now recorded as"withheld": "page off ALLOWED_HOSTS", cut to their origin,
with no headers or bodies, and so are a popup's first navigation, a top-level navigation that
starts while a page of the session is refused, and a request the controller cannot attribute. A
refused page's failed requests are left out ofrequest_failures. Iframes and subresources an
allowed page loads from other hosts are logged as before. Seedocs/deployment.md, "Logs of a
refused page". - A download from a host off
ALLOWED_HOSTS, or one a refused page starts, is cancelled, and
browser.get_cookiesreturns only what a listed host would receive. Such a download, a file a
refused page generated itself included, was saved and readable throughbrowser.read_downloadand its
artifact URL, andbrowser.get_cookiesreturned the cookies a page off the list set before it was
blanked. A download's final URL, any off-list host its navigation passed, and the page it is reported
on are now checked; nothing of a refused one is saved, and it is recorded asblocked, with its
source's origin only, and adownload_blockedaudit event.browser.get_cookiesjudges host-only,
domain and partitioned cookies as the browser sends them. Seedocs/deployment.md, "Refused
downloads" and "Cookies an allowed host would receive". - A read is refused when its page went refused, closed or stopped being the active tab while it was
read, and a refused page's URL is shown by its origin only. Reads checked the page's URL before
they read it, and some again after, so a page that went to a refused host and came back, or an active
tab that closed and handed the session to another, passed both checks and was read. The REST CDP
routes read the active page whatever its host, and tab listings, session summaries, the snapshots an
action returns, approval requests and thenavigation_blockedevent showed a refused page's URL
whole. Each read now notes the active page and its count of refused commits first, and refuses
afterwards if either changed, the page closed, or the page is refused now; the CDP routes also take
the session lock and answer 403 as observe does, and those places now show a refused page's URL
asscheme://host:port. The 1.10.0 note that raw CDP passthrough is unchanged no longer holds. See
docs/deployment.md, "Reads and the page tools". - What a page
ALLOWED_HOSTSrefuses still does. Its request has already gone out when it is
refused, with whatever the page put in its URL, and the page runs, sets cookies and shows in the
takeover (noVNC) view until it is blanked, which waits for any call in flight on the session. A
service worker it registered keeps running after that. What an allowed origin's own scripts do with
what a refused redirect or a refused opener put in its URL is not tracked. A refused page can set a
cookie on a parent domain that an allowed host receives, andbrowser.get_cookiesreturns it. A
withheld network entry keeps the host's labels, which the page chooses. Playwright traces
(ENABLE_TRACING, off by default) record the refused page too, and a storage-state export holds every
host's cookies. Iframes and subresources from other hosts on an allowed page are not filtered, by
design. As before, an internationalized host matchesALLOWED_HOSTSonly in itsxn--form: list it
that way and navigate to itsxn--URL (the Unicode form fails closed: the page is refused, not
read). The full list is indocs/deployment.md, "What a refused page still does".
Witness receipts and skill candidates:
- A page can no longer stop a session's witness chain from recording. Two faults ended recording,
both since 1.7.0. The tail read that finds the last receipt decoded bytes from an offset as text, so
once a chain passed 64 KiB and that window began inside a multi-byte character (a Japanese page, a
euro sign), every later append failed. And a receipt holding U+2028, U+2029 or U+0085, which a page
can put in its title, was cut in two by the line splitting, so every later append failed at any chain
size, and verify, the receipt list and the bundle export failed too. Each later action ran, then
reported an error, and its receipt was never recorded, so a caller that retried could repeat a click
or a submit. The tail is now read as bytes and chains are split on newlines only; a chain stuck this
way verifies, lists and exports again with no repair, but the upgrade's restart ends its session, and
the receipts that were never written cannot be recovered. - Witness verification no longer reports missing evidence as valid. Anyone who could write the
witness directory could remove receipts and still readvalid: true: a missing or corrupt anchor
read asunanchored, which passed, so cutting a chain's tail and deleting its anchor hid the cut,
and so did the next receipt, which moved the anchor to the shortened chain. Nowvalidisfalse
when the chain is gone or empty or its anchor is missing or unreadable, the chain must match the
anchor exactly, and a later receipt no longer moves the anchor over a chain it does not describe.
Still the case: someone with write access to the witness directory can make a cut chain verify, by
rewriting the anchor, by deleting the chain with its anchor (the next receipt starts a new chain that
verifies), or by deleting.anchors-migratedbefore a restart (which anchors a cut chain whose
anchor is missing as it stands). For evidence that must survive that, pin a head hash somewhere the
controller host cannot write. Seedocs/deployment.md, "Witness receipts: what a signature proves". - A staged skill candidate can no longer be edited, or copied to another directory, and still read as
verified. On a controller with a mesh identity, where candidates are signed, someone who could write
to the staging directory could change fields ofcandidate.json(safety_class,verifier_backend,
name, ...), or copy a genuinecandidate.jsonnext to altered files, and the registry served it as
verified. The signature now covers every field ofcandidate.jsonbut the envelope itself and the
filesmap, which is built from the directory the candidate was read from; that directory must be
named for the candidate'sskill_idand stay inside the staging directory. Still the case: no check
covers the review files (contract.jsonandtrace.jsonare never compared with the contract and
trace hashes the envelope signs, andprovenance.jsonandenvelope.jsonare not hashed). See
docs/convergence-harness.md, "Mesh Boundary".
Fixed
- The session-limit error names only the sessions the caller may use. Under named credentials
(API_BEARER_TOKENS), the errorbrowser.create_session(and an observe or act tool that creates a
session) returns atMAX_SESSIONSlisted every live session id, other operators' included. It now
lists the caller's own and unowned sessions, and counts the rest. - An MCP session (
MCP-Session-Id) belongs to the operator who initialized it. Under named
credentials, another operator holding the id could ping it, subscribe it to resources and
DELETE /mcpit, though tool calls always ran as the caller's own operator. Any other operator now
gets-32001"Unknown MCP session". MCP session ids created before this release, or by a client
that initialized without a named credential, stay usable by any operator until they are deleted or
evicted. Thebrowser://sessionsupdate no longer reaches other operators' subscribers when an owned
session closes. The session table keeps 500 sessions and evicts the least recently used, so one
operator initializing many evicted another's idle session; an operator now keeps at most 100, and
past that loses its own least recently used first. Unowned sessions are bounded by the 500 alone. - Under named credentials, a session id with no live session and no record is refused like another
operator's. The routes and MCP tools that read a session's files by id (replay, audit, witness)
served an orphaned id's files and events to every operator, and other routes answered it in words that
told another operator's session from none. Routes and tools that name the session id now answer404
unknown_sessionbefore anything else. The chain of the auth-profile receipts (auth-profiles),
which has no session record, is refused the same way: its witness routes and tools answer404to
every operator. Its agent jobs, and its approvals that name no operator, are left out of listings and
answer as if they did not exist, and an upload from its directory underUPLOAD_ROOTis refused
(upgrade notes); other subfolders stay shared with every operator. Deployments without named
credentials are unchanged. - Tab listings, the
fastobserve preset and the bot-challenge check no longer wait without end for
a page stuck in a script. All three run under the session lock, so one tab whose renderer never
answered stalled every call on the session. A tab's title now waits at most 2 seconds (the tab is
listed without one), thefastpreset's title at most 30 seconds (observe fails), and the
bot-challenge probe that runs after every action at most 30 seconds (the page is then checked by its
URL alone). - An auth-profile save that fails before its files are in place leaves the profile as it was, unless
putting the previous state back fails too. A save wrote the state file first and then read the
page's title for the profile's metadata, so when that read or write failed the new state stayed in
place, on a first save with no metadata and so no owner. The title is now read first, and the new
state and metadata are put in place together; a failure after that (recording the save, say) is
reported as an error, and the new profile stays. A process killed during a save can still leave the
new state beside the previous metadata, or the new metadata beside the previous state, and hidden
files whose names start with.state.jsonor..state.json, holding the previous login and the new
one. A first save, and a named operator's re-save of a profile whose metadata names no owner (saved
under the shared token or before owners), now puts its metadata, which names the owner, in place
before its state: one killed between the two leaves at most that metadata beside no state or the
previous one, and one whose state cannot be put back keeps the new metadata beside the new state, so
neither leaves a new login with no owner that every operator could open under named credentials. See
docs/deployment.md, "Auth profiles". - A policy block after a
go_backor areloadis no longer rolled back with anothergo_back,
which went one page further back, or to the page before the reloaded one. The page stays where the
action left it, and oneALLOWED_HOSTSrefuses is sent toabout:blank. - The audit log's trim no longer drops an event whose text holds a Unicode line separator. It split
the log at U+2028, U+2029 and U+0085 as well as at newlines, so such an event came back as fragments
that no longer parse, and listings left it out for good. Text a page chooses reaches the log, a
download's file name among them. The trim now splits on newlines only, and so does the convergence
harness's trace, where an event holding one failed the run. - A navigate to a host
ALLOWED_HOSTSrefuses is refused before an approval is asked for. An
operator was asked to approve a navigation that could never run, sincenavigatechecked the list
only once it ran. A host that leaves the list after the approve is refused when the approval is
executed, and the approval is given back. - An action whose observation hits a permission error on a file reports it without the server's
path. The OS error, path included, was returned as the action'sobservation_error(EACCESor
EPERM: a screenshot in an artifact directory the controller may not write, say). It is now a fixed
text, and the OS error is logged.