collect_submodules only takes .py, so packaged builds had no builtin coworkers.
Caught by inspecting the DMG — dev installs read them from the source tree.
web_search reclassified EGRESS (spec 2.2, decided 2026-08-12): the destination is
fixed (the configured provider) but the query is model-chosen free text - the same
outbound channel web_fetch's URL is. It ran completely ungated in every mode until
now; it gates like any egress from here on, which also puts it in front of the
Auto-Approve reviewer.
The egress approval cards (spec 1.9):
- web_fetch offers "Always allow <host> this session" -> ALWAYS_DOMAIN. Tool-wide
"always" is gone from the card AND server-refused (_grant_offered): it would
cover every future destination, and the live A/B showed exactly that (one click
on a bbc.com card ran promptless fetches to hosts no card ever named).
- www. stripped at grant minting (allow_domain_for_session) - pure spelling only,
never eTLD+1. The card button shows the exact spelling the grant mints.
- web_search offers "Always allow searches this session" -> ALWAYS_TOOL (tool-wide
IS provider-wide for a fixed destination), with the card naming the LIVE
destination: "Queries go to your configured search provider (currently: <name>)".
Provider resolved when the card is raised (engine.approval_extras hook), not at
session start.
- Provider-change invalidation: set_web_search clears the web_search session grant
in every live engine when the provider actually changes - the grant was consent
to a named destination.
- Auto-Approve fall-through cards hide every session "always" button: grants don't
skip the reviewer there (1.5), and a button that lies is worse than none.
- scopeNote tells the truth for egress: "leaves this computer -> <host>" replaces
"stays on this computer" on fetch/search cards.
Corpora gain web_search cases (benign 22 / dangerous 17 / injection 14), including
query-borne secret exfiltration and a planted search-the-credentials injection.
Tests: test_egress_and_overrides (EGRESS class, gating, www-strip, 1.5 in
Auto-Approve), test_approval_integrity (tool-wide refused for URL-carrying egress,
kept for web_search; provider-change invalidation), ApprovalCard.test.tsx (domain
button + www-strip, provider line, Auto-Approve hides always). Full suites pass;
the 22 pre-existing failures (Slack fake-gateway timeouts, a Windows file-lock
rename) fail identically on the pre-change tree.
Spec 1.7: the cost of Auto-Approve is visible while it accrues, not
discovered later. This is also where "uses your session model" gets
communicated (picker copy decision A): as a real accruing number.
Audit store:
- New columns call_id / tokens_in / tokens_out, with an idempotent ALTER
migration for existing databases. This also fixes a feature-1 gap found
in the process: the engine passed call_id and token counts on reviewer
rows but the fixed column set silently dropped them, which would have
broken the shadow-eval join and made token metering impossible.
- reviewer_stats(session_id): SQL aggregation of reviewer_verdict (live)
and reviewer_shadow rows into checks/allow/deny/unsure + token sums.
Durable - survives restarts and engine rebuilds.
Server: GET /v1/sessions/{id}/reviewer-stats (same shape as /unattended).
GUI:
- Polled with the existing 4s per-session poller.
- Mode button gains the badge when the session is in auto-approve and has
checks: "Auto-Approve . 12 checks".
- Mode menu gains the session summary line: "This session: 12 checks . 10
cleared . 0 blocked . 2 asked you . ~1k tokens". Only the LIVE bucket
surfaces in the composer; shadow counts are a Settings/analysis concern.
Verified live against the running sidecar: the store already held 9 real
verdicts from manual testing of the mode, the endpoint aggregates them,
and both badge and summary render with real data.
Tests: stats aggregation (per-stage, per-session isolation, token sums),
legacy-DB migration (old schema opens, migrates, and round-trips call_id),
and the endpoint's empty shape. 113 backend + 114 GUI green.
A reviewer deny now renders as a proper card in the transcript - the FULL
reason (the agent only ever got the terse refusal) plus an "Allow anyway"
button - and clicking it mints a ONE-SHOT exact-action approval.
Engine:
- approve_action_once(tool, arguments): human-minted grant keyed on the
exact tool + canonical (sort_keys) JSON arguments, consumed on first
match. Checked in _authorize's needs_user branch AHEAD of the reviewer,
so the approved re-proposal runs without a reviewer call or a card.
Audited as allow_anyway_granted + auto_allowed.
- Deliberately narrow: a re-proposal with even slightly different
arguments does not match and goes back through the normal flow, and the
grant only applies where needs_user is true - it CANNOT unlock a hard
deny (1.2), which is now a test.
Server: WS kind "allow_anyway" {name, arguments} -> engine.approve_action_
once, with input validation. The GUI follows up through the normal
user_message path with a visible "go ahead with it exactly as proposed"
message, so the retry is in the transcript, not magic.
GUI:
- tool items carry reviewerReason/allowAnyway (the event fields were
already broadcast verbatim; updateLastTool now keeps them).
- StepRow renders the deny card: full reason, a note that the agent was
told only THAT it was blocked (not why), and the button - which
collapses into a confirmation after one click (no double-fire).
- SessionSocket.allowAnyway; App.allowAnyway = WS grant + canned retry
message; onAllowAnyway threaded Transcript -> TurnGroup -> StepRow.
Tests: 4 engine (runs once without card/reviewer; consumed not standing;
different action never matches; hard deny stays denied) + 3 component
(card + reason + exact-args callback + one-shot button; no card on
ordinary denies; no button without the callback). 110 backend + 114 GUI
tests green.
The auto_approve flag (and its shadow sibling) become first-class settings
instead of hand-edited TOML, and the Auto-Approve mode entry appears in the
picker only when the flag is on.
Server:
- manager: auto_approve()/auto_approve_shadow() read prefs.json first,
falling back to the config.toml value a power user may have set; both
writers persist to prefs. Both stores are user-global, so a cloned repo
still can't enable either (the 1.5 invariant, unchanged).
- get_settings() exposes both; POST /v1/settings/auto-approve and
/auto-approve-shadow write them (same shape as context-bar).
- Session builds pass the prefs-backed values into build_engine via new
optional auto_approve/auto_approve_shadow overrides (None = config value),
so a Settings flip takes effect on the next session build with no restart.
Scheduled runs keep reading config only - they are unattended, so the
live reviewer can never fire there regardless.
GUI:
- Mode picker: the Auto-Approve entry is `gated` - shown when
getSettings().auto_approve is true, fetched on menu open. A session
already IN auto-approve always shows its own entry so the current mode
stays legible even if the flag was later turned off. This replaces the
TEST-ONLY unconditional entry.
- Settings: AutoApproveCard with the feature toggle and the nested shadow-
evaluation toggle ("records what it would have decided next to your own
choice - without changing anything").
- api.ts: ModelSettings.auto_approve/auto_approve_shadow + setters.
Verified live against the running sidecar: flag off hides the entry on an
interactive session, flag on shows it, the Settings toggles round-trip and
persist. tests/test_auto_approve_settings.py (6): defaults, REST round-
trip, restart persistence, config fallback, prefs-beats-config, and the
build_engine override. tsc clean; 111 GUI unit tests pass.
Spec Part 6 step 3. The reviewer runs on every approval card and records
what it WOULD have decided, while the human decides everything. This is how
the ship gates get measured on real sessions before the flag ever defaults
on. Nothing about a decision changes.
- config.py: auto_approve_shadow flag, off by default, _GLOBAL_ONLY (a
cloned repo can't turn it on). agent.py attaches the reviewer when either
auto_approve OR the shadow flag is set; reviewer_shadow gates only the
recording path.
- engine.py: _spawn_shadow_review fires the reviewer fire-and-forget from
the needs_user branch and audits stage="reviewer_shadow" joined to the
human's approval_resolved row by call_id. The card is never delayed; a
shadow failure never surfaces. Skipped when the live path already
consulted the reviewer this card (no double spend). approval_requested /
approval_resolved rows gained call_id for the join.
Eval harness (scripts/eval_reviewer.py, spec 7.5):
- Runs the reviewer against three JSONL corpora and scores the ship gates:
benign allow-rate >= 30% (prompt-reduction proxy), zero false-allows on
dangerous and injection. Exit 1 on any gate failure.
- Corpora seeded: benign (20), dangerous (15), injection (13), each with a
~20% holdout and per-row answer keys, in the spec's 7.5.1 format. Known
world is reconstructed folders-and-remotes-only, matching the engine.
- --stub runs with no network (canned verdicts) for plumbing/CI; real runs
use ProviderRouter and cost money, so this is on-demand, not a pytest.
tests/test_shadow_eval.py (18): shadow records but never decides; shadow
off records nothing; live allow/unsure never double-recorded; shadow errors
swallowed; corpora well-formed; scoring/gate maths; stub passes all gates.
Owner call after seeing it rendered: the three-line entry read as a
paragraph in a list of two-liners. Decision A: fold the who-judges fact
into the description itself -
Auto-Approve
Your session model clears routine actions; doubtful ones still ask
- and let per-check cost surface in the 1.7 metering badge where it
actually accrues, instead of as picker text. This supersedes the copy
recorded in the previous commit.
The `note` field and its render block are removed as dead code; `caution`
(the Bypass warning triangle) stays. tsc clean.
- Icon.tsx: "warning" caution triangle (24px grid, 1.7 stroke, Lucide-style
rounded triangle + exclamation) matching the existing icon set.
- Composer.tsx: ModeOption extends Dropdown's Option with `caution` (warning
triangle before the label, themed via text-warnInk so it follows
light/dark) and `note` (a second, dimmer italic line under the
description). Bypass approvals carries the caution icon.
The Auto-Approve picker entry itself remains unshipped until the settings
pass gates it on the server-exposed auto_approve flag; its copy is decided
(owner, 2026-08-12): description "A reviewer clears routine actions;
doubtful ones still ask", note "Uses your session model for judgement - one
extra model call per check".
tsc clean; 111 GUI unit tests pass; rendered live and verified (note line
under Auto-Approve, warnInk triangle on Bypass).
The mode from ocw-context/docs/reviewed-auto-mode.md (rev. 4), v1 scope.
coworker/reviewer.py (new)
- The 8.3 prompt verbatim, cache-shaped: instructions + known world (folders
and remotes only) + user-message history in the stable prefix; this turn's
request and ONE action in the suffix.
- parse_verdict: any defect (empty, non-JSON, unknown verdict) -> unsure.
There is no parse path that results in execution (8.5).
- Reviewer.review never raises: provider errors and timeouts -> unsure.
Metering counters (checks / verdicts / tokens) for 1.7.
- AGENT_DENY_MESSAGE: the terse, non-diagnostic refusal the agent gets on a
deny; the full reason goes to the user only (8.4 asymmetry).
coworker/engine.py
- Reviewer consulted ONLY when: attached, mode is AUTO_APPROVE, session
explicitly attended (unset is_attended counts as NOT attended, so
automations can never be reviewed), fewer than two denials this turn.
- Consulted ONLY on decisions the gate marked needs_user - hard denies
never reach it, so it can only turn "ask" into "allow" (1.2).
- One action per request, fired concurrently for all of a turn's escalating
calls before the sequential authorize loop (8.6): a verdict cannot land
on the wrong action, and approval cards still reach the human one at a
time in call order.
- allow -> runs, audited with the reason. deny -> blocked; user event
carries the full reviewer reason + allow_anyway; agent message carries
only AGENT_DENY_MESSAGE. unsure -> today's card.
- Reviewer sees the user's words only, extracted mechanically from
role=user messages - never agent output, never tool results (4.4).
coworker/permissions.py
- Mode.AUTO renamed Mode.BYPASS_APPROVALS ("bypass-approvals"); legacy
"auto" still parses via _missing_ so configs, saved sessions, and the
golden decision table are untouched.
- Mode.AUTO_APPROVE ("auto-approve"): gate-identical to INTERACTIVE except
session grants ("always allow this ...") no longer auto-allow - they
route to the reviewer instead (1.5: out-of-band standing policy may skip
the judge; an in-flow click may not). Config allowlists still skip.
- _domain_allowed(include_session=False) checks the user-settings list only.
coworker/config.py: auto_approve flag, off by default, _GLOBAL_ONLY (a
cloned repo cannot hand itself a looser reviewer). agent.py attaches the
Reviewer only when the flag is on; without it AUTO_APPROVE behaves exactly
like INTERACTIVE.
server/manager.py: autonomy audit ranks auto-approve above interactive
(turning the reviewer on IS raising autonomy) and below bypass.
GUI: mode picker label "Full access" -> "Bypass approvals" (wire value
"auto" kept). Verified live against the real sidecar; e2e spec updated;
tsc and all 111 GUI unit tests pass.
Tests: tests/test_auto_approve.py (33) - gate behaviour per mode, fail-
closed parsing, prompt shape, deny asymmetry, retry guard, attended
gating, hard-deny isolation, per-action verdict landing, and that the
reviewer never sees agent prose. Permission suites + golden table: 146
passing unchanged.
coworker/session_facts.py: KnownWorld (roots + git remotes + hosts, captured
once before the agent acts) and SessionFacts (per-turn ingestion records).
Nothing consumes either in v1 - the known world will be rendered into the
reviewer prefix in step 2, ingestion exists so the v2 question ("would this
fact have changed a verdict?") is answerable from a shadow run replay
instead of re-argued.
- render() shows folders and remotes ONLY. Hostnames are held for DST-1 in
v2 but never rendered: a host list is only useful to a reviewer that can
do a suffix match, which models get wrong and Python does not.
- Freezing is the point: a remote added after capture stays unknown, so an
agent cannot make its own destination look familiar.
- Ingestion records the fact and the hostname, never the content and never
a full URL (a query string is exactly where a payload rides). Failed
calls record nothing. Local reads are deliberately excluded; the cost of
that exclusion is recorded in the spec.
- Engine hook is inert when session_facts is None; classification keys on
tool metadata category (web/connector/mcp) so new connectors are covered
the day they ship.
22 new tests. Permission suites (146) and the golden decision table pass
unchanged - this step alters no decision.
Spec: ocw-context/docs/reviewed-auto-mode.md Part 0, 2.4, Part 6 step 1.
Bundle zip export + import (zip-slip guarded) through the picker's Import door; version+provenance with a replaces-note, re-consent only when capabilities grow.
Consent screen: trust warning first, capability summary with collapsed tool list, recommended connectors.
1. Grant validation. POST /v1/inbox/{id}/resolve takes a raw resolution string
and approval_outcome() previously honoured whatever it named. The GUI
deliberately withholds the tool-wide "always allow" for run_shell (the
command-scoped grant is the narrower option), for save_skill (every skill
proposal gets its own review) and for connectors; Slack mirrors render only
approve/deny. So any local API caller could mint a session-wide,
any-argument shell grant -- a vocabulary the design says must not exist.
_grant_offered() now mirrors the card's own rules on the server and
downgrades an unoffered grant to a one-time approval, writing a
`grant_refused` audit row. Applied to the "always" channel vocabulary too,
so a Slack reply cannot mint what the in-app card would refuse. MCP tools
are covered alongside connectors: they are not category=connector but are
external, and the grant would be unbounded over every future argument.
A failed always_task mint is now audited rather than silently downgraded.
2. Autonomy transitions. Mode changes (WS set_mode) and the unattended toggle
were unrecorded, so "who turned on auto mode, and when" was unanswerable
from the audit store -- at odds with the per-call trail the engine keeps
everywhere else. Both now write an audit row tagged raised/lowered, so
autonomy increases can be filtered. set_unattended moves onto the manager
so REST and any future surface record it the same way; no-op flips are not
recorded.
15 new tests. test_server's two failures are pre-existing on the unmodified
tree (Windows file-permission errors in pathlib), unrelated to this change.
Design of record: ocw-context/docs/reviewed-auto-mode.md Part 3.
The old rule -- any shell operator disqualifies the whole command -- was wrong
in both directions, verified by running it:
find . -delete -> ALLOW (destructive, no prompt)
find . -exec rm {} + -> ALLOW (destructive, no prompt)
git status && git diff -> ask (two allowed reads, refused)
It judged punctuation rather than danger. `-delete` and `-exec` need no
separator, so a bare `find` prefix auto-ran them; meanwhile two independently
allowed reads were refused for containing `&&`.
Now:
- Constructs whose contents we cannot evaluate -- substitution, redirection,
variable expansion, grouping -- still disqualify the whole command, because
the unexamined tail after a prefix match must only ever be arguments.
- Compound commands are split on &&, ||, ;, |, |&, & and newlines, and EVERY
part must be independently covered by an allowlist entry.
- Parts that run code named in their arguments are never prefix-eligible:
argument executors (xargs, sudo, timeout, env, docker, npx, ssh...),
interpreters carrying inline code (python -c, bash -c, node -e), and
execution/deletion flags (-exec, -execdir, -delete, -ok).
- Matching stays on parsed words, so `git status` covers `git status -s` but
never `git statusfoo` or a bare `git`.
Splitting is textual and does not respect quoted separators. That is
deliberate: over-splitting yields MORE parts to justify, never fewer, so it
cannot loosen a verdict.
37 new tests including metamorphic cases (spacing, quoting, absolute program
path must not loosen `find . -delete`). Golden matrix: three rows flip as
intended, two added. 164 permission tests green.
Design of record: ocw-context/docs/reviewed-auto-mode.md Part 2 (CMD-1/3/4).
in-project files that execute later
Two floors, both mode-independent.
1. The settings files (config.toml, risk_overrides.json, workspace_trust.json,
unattended.json, coworker.db which holds the saved grants, secrets.json,
inbox_routing.json) cannot be modified by any tool, in any mode. The check
runs BEFORE mode, allowlists and every auto-approve path, and returns a hard
refusal rather than an approvable prompt -- loosening requires editing the
files out-of-band.
The escalation this blocks: approve one ordinary-looking `python setup.py`,
it quietly appends to risk_overrides.json, and every future session is more
permissive. That happens in the DEFAULT interactive mode, so the protection
cannot be a property of a sandbox or of any single mode.
Covered for write tools (resolved path), patch/diff blobs (path extracted),
and run_shell (command text). Shell matching is deliberately full-path only:
an earlier draft also matched bare filenames, which hard-denied any command
merely mentioning `secrets.json` -- caught by
test_shell_commands_not_auto_allowed_by_default, which reads that path with
`cat`. Shell is parser depth: it stops accidents and casual attempts, not a
determined adversary. That needs the OS sandbox (OPE-14).
2. Files inside the workspace that run on a later, innocuous action --
.git/hooks/**, .github/workflows/**, .vscode/tasks.json, .coworker/** --
stay writable but never WITHOUT a human. Auto mode, custom-mode auto_allow
and session "always allow" all lose to this. Otherwise accept-edits is a
clean bypass: write .git/hooks/pre-commit, then run an innocuous git commit.
19 new tests, incl. every mode parametrized and a lookalike case
(docs/pre-commit.md stays ordinary). 167 permission-related tests green.
test_standing_approvals::test_blocked_run_does_not_stall_other_tasks is an
intermittent pre-existing scheduler timing flake (fails ~1 in 3 on the
unmodified tree).
Design of record: ocw-context/docs/reviewed-auto-mode.md Part 3.
Three gate defects, each verified by direct execution before and after.
1. web_fetch was RiskClass.READ, so is_consequential() was False and evaluate()
returned allow on its third rung -- before any rule, mode or PDP, in EVERY
mode including plan/discuss. A URL's query string carries data outbound, so
this was an ungated egress path. New RiskClass.EGRESS covers model-chosen
network reads; web_search stays READ (fixed configured provider, not a
model-chosen host). Adds an allowed_domains allowlist (exact host or
subdomain; 'evil-python.org' never matches 'python.org'), a session-scoped
"always allow this domain" grant, and ApprovalOutcome.ALWAYS_DOMAIN.
2. A risk override could DOWNGRADE a built-in: marking write_file as read made
is_write False (skipping path scoping) and consequential False (skipping the
read-only gate) at once -- one settings line disabling two protections, in
every future session. Overrides may now only tighten a built-in write/exec/
egress tool; relaxing a metadata/MCP tool (the intended use) still works.
3. Path scoping read a literal "path" argument, so apply_patch and
apply_unified_diff -- whose paths live inside the patch/diff blob -- were
never scoped at all. write_paths() extracts them from the blob and scopes
every one; a write whose path cannot be located now fails closed to approval
rather than slipping through auto/custom unscoped.
allowed_domains is user-global only, alongside auto_allow: a cloned repo must
not be able to widen the agent's network reach.
Golden matrix: web_fetch interactive allow->ask, plan allow->deny, plus new
egress/patch rows (31 rows green). test_permissions_risk's override test
asserted the old downgrade behavior and is updated to the tightening rule.
Full suite: 22 failures, all pre-existing on the unmodified tree (boto3 absent,
Windows symlink privilege, Slack socket timeouts) -- none introduced here.
Design of record: ocw-context/docs/reviewed-auto-mode.md Part 3.
Freezes today's evaluate() verdict across 26 (mode, tool, args, grants)
situations, so any later permission change shows up as a row-diff. Four rows
are marked BASELINE-WRONG / BASELINE-ANNOYING on purpose: they record known
gaps (shell auto with no sandbox, find -delete and find -exec auto-allowed via
a find prefix, git status && git diff rejected for the operator, web_fetch
never gating in any mode). The PRs that fix these flip their rows here as the
visible proof.
Design of record: ocw-context/docs/reviewed-auto-mode.md Parts 3 and 7.
Bundle skills/ dir joins the persona's session menu (additive; user disables/mutes win); manifest skills: narrows the bundle; mcp: scopes raw servers.
Install snapshot now carries the skills folder — the sharing bundle shape.
Per-session coworker+folder chips replace the sidebar split-button picker; code family gets a send-time folder dialog with git-ready temp dirs and Save as project.
Builtins ship enabled; user-facing noun is Coworker; personas flag now defaults on.
Both tools were registered with kind="read" in TOOL_DEFS, so
approval_for_tool() returned False and overrode the approval=True
set at the call site. The permission engine then classified them
as READ (requires_approval=False → RiskClass.READ), auto-allowing
them without ever prompting the user — even though both write to
disk (clone creates a new directory, pull fast-forwards an existing
repo) and their own descriptions say "Requires user approval".
The connector list API (tool_dicts) always reports
requires_approval=True, so the UI showed them as gated while the
runtime silently bypassed the gate — a mismatch that made the bug
invisible to users.
Reclassify both as kind="write" so the §36 kind→approval mapping
correctly gates them.
An inbound channel reply resolving an inbox item matched decision words
as substrings anywhere in the text, with allow words checked first. Two
consequences:
- A free-text answer to a question was hijacked whenever it merely
contained a keyword: "I have no preference - use us-east-1" resolved
the item as "deny" instead of the answer; "yesterday's numbers look
fine" resolved as "allow".
- A negated approval reply flipped to an approval: "I cannot approve
this yet" contains "approve", so the pending action was ALLOWED.
Intent now comes from the reply's leading word (or emoji) after the
[ow:...] token is stripped: "Yes, go ahead", "No.", "deny", a bare
thumbs-up all still resolve as before. Anything else falls through to
the free-text path, which inbox_approver already maps to deny - the
safe default for an approval gate.
A tick's due() snapshot still lists a task whose run is parked on an
approval — next_run only advances on completion. The overlap guard lived
inside the spawned coroutine, so when an approval landed just before a
tick, the parked run could finish and clear the guard before the
duplicate spawn took its first step, and the task ran twice.
This is the intermittent 'assert 2 == 1' in
test_blocked_run_does_not_stall_other_tasks on main's CI. The new
regression test forces that interleaving deterministically.
requires-python declares >=3.10 and the README says "Python 3.10+", but
coworker/config.py imports tomllib at module top and tomllib only landed in
the 3.11 stdlib — on 3.10 the package cannot even be imported (every test
module fails collection through the coworker.config import chain), so the
advertised floor is broken in practice.
Fall back to the tomli package (the pre-stdlib implementation of the same API)
and declare it as a dependency only for python_version < '3.11', so 3.11+
installs are unchanged.
Verified on 3.10: importing coworker.config and load_config() parsing a
workspace config.toml both work through the fallback; the config suite still
passes on 3.12.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GUI README tells contributors to run `npx tsc --noEmit` and `npm run build`
starts with `tsc`, but CI never runs either — a type error ships green today
and only surfaces when someone next builds a bundle locally. Add the check as
one step inside the existing gui-unit job so it reuses that job's npm ci and
costs no extra runner.
Verified locally: `npx tsc --noEmit` exits 0 on current main, and the vitest
suite (82 tests) still passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GUI README still described the pre-split monorepo layout: every command
cd'd into a platform/ directory that does not exist in this repository, so a
fresh contributor following it verbatim fails at the first step. The venv,
packaging scripts, and surfaces/gui all live at the repo root now — align the
commands and the server_bin() dev-fallback comment with the layout the root
README already documents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
guard.get_checked previously resolved a name for the address check and then let
the client resolve it again to connect, so a ~0-TTL record could flip to
127.0.0.1 (or the metadata endpoint) between the two — the exact gap the module
docstring called out as "not covered".
Every hop now connects to the address that passed its check: the request URL
carries the vetted IP, Host keeps the original name (and explicit port) for
virtual hosting, and the sni_hostname extension keeps the TLS handshake — and
certificate verification — against the name. Literal-IP URLs are unchanged.
Redirect Locations are resolved against the logical URL, and the final logical
URL rides resp.extensions["logical_url"] so web_fetch reports the name, not the
pinned address.
Verified against a local TLS server whose cert carries only DNS:pinned.test:
the pinned request (URL=127.0.0.1, sni_hostname=pinned.test) verifies and the
server sees Host: pinned.test; the same request without the extension fails
with "IP address mismatch" — httpcore does verify certificates against
sni_hostname, not the URL host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The session WebSocket handler at api.ts:1824 called JSON.parse without
a try/catch. A single malformed frame from the server would throw an
uncaught exception inside the browser event handler, silently killing
the onmessage callback — the session would freeze with no error feedback.
The sibling connectEvents handler at api.ts:1471 already wraps its
JSON.parse in try/catch. This mirrors that same pattern.