Skip to content

feat(deploy): L5 journey suite, fleet substrate, CI gates (owner-gated merge) - #346

Draft
Yambr wants to merge 132 commits into
next/v1from
docs/demo-walkthrough
Draft

feat(deploy): L5 journey suite, fleet substrate, CI gates (owner-gated merge)#346
Yambr wants to merge 132 commits into
next/v1from
docs/demo-walkthrough

Conversation

@Yambr

@Yambr Yambr commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Forked from next/v1 at a6b48bd. No changes under docs/architecture/ or contracts/ — canon untouched (the three-dot PR diff under those paths is empty; a git merge-tree against next/v1 HEAD keeps ADR-0028/0029 and both openapi contracts intact). Merge simulates clean against current next/v1 HEAD.

What lands

The L5 user-journey e2e suite + the fleet deploy substrate + tier-1 CI gates. The three MCP tool-surface output-losing defects the suite found are already merged to their component mains and live-verified — this PR carries only the deploy/test tooling that proves them end-to-end:

  • deploy/tests/journeys/ — PoC-vs-fleet journey groups A-I; groups H (gateway auth-edge) + I (MCP tool-surface) close the sk-ocu keyed-create + exit/truncation/timeout/EACCES/persistence coverage below the exec contract.
  • deploy/fleet/ — the assembled 8-component stand: guest image, embed-portal IdP stand-in, gateway PKI, the vendored boot-set minter + its key-set schema, .env.example, README run-book.
  • .github/workflows/journeys-collect.yml — tier-1 gate: import/collection health + a mint-smoke step (red-probed on schema delete/corrupt/drift).

Proof (firsthand, live Lima stand)

From a truly clean in-Lima clone that renders its OWN boot-set via the vendored minter (no symlinked secrets): H (5) + I (8) = 13 passed, 0 skipped. Full A-I: 23 passed, 92 loud-skip (A-G loud-skip when the backend is absent — never mocked green).

Verify it yourself (zero trust in the author)

In Lima ocu-linux, follow deploy/fleet/README.md -> "Running the journey suite": clean clone, mint the boot-set, .venv/bin/pytest deploy/tests/journeys, compare against the pinned expected counts (13/0 with a python3 guest; 10 passed + 3 skipped on the stripped default).

Merged component fixes (already on their mains)

Tracked non-blocking follow-ups

#344 canon exec-reply schema (owner-gated next/v1), #345 demo default python3 guest, tier-2 live-CI gate, JOURNEYS_REQUIRE_FLEET fail-loud knob, deploy/fleet/contracts lint-path gap.

Draft: merge is owner-gated.

🤖 Generated with Claude Code

widemoat-ai and others added 25 commits June 28, 2026 16:00
A runnable walkthrough of each architectural security and data-flow guarantee,
one step at a time, with the command and the expected observable. Aspects 2, 3,
and 4 carry a firsthand run: a green baseline and a neutered counter-case that
reds (the IaC gate's own --self-test, a red-probe for the cross-tenant and F9
guards), against the merged/shipped code. Front-matter and curl examples use
env-var placeholders so no secret-shaped literal enters the history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
45 end-user-journey scenarios (7 groups: auth/bootstrap, docx create+download,
upload/edit/download, authz boundary, auto-disconnect/lifecycle, agentic load,
adversarial). Each scenario is paired: it runs against the PoC (Open WebUI +
computer-use-server) and the fleet (gateway mTLS -> control -> gVisor guest ->
FUSE -> egress edge -> filestore -> MinIO), and asserts the per-backend end
state plus a keystone that stays reproducibly reddable.

A pytest spine drives real Backend verbs; scenarios.yaml is the single source
of truth and renders CONTRAST.md. The fleet leg runs live only under Lima +
runsc and loud-skips elsewhere (never a mocked green); the exec_sh chokepoint
busybox-prefixes every in-guest command, guarded by a meta-test that greps the
suite for bare sh/python3 argv. Two watchable bash demos narrate the two
journeys.

Run firsthand against the live fleet, the suite exposed three real defects,
recorded in FINDINGS.md as strict issue-linked xfails (keystones not weakened):
the storage-write plane does not round-trip (mount Put omits the required
declared_size_bytes; the stand-in read plane is 501), the concurrency counter
leaks under the kill-switch and wedges the deployment at the tier cap, and the
mount-facing network is internal:false so a guest reaches the public internet.

Two deploy fixes land alongside: control now depends_on harness-init (it writes
the CA the control plane latches at boot, so a clean down -v + up self-heals)
and allow-lists the busybox-carrying demo guest image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZogNpHHSvgo41RsoBmsxH
The fleet compose healthcheck called /ocu-mcp-gatewayd but the gateway
image entrypoint is /usr/local/bin/ocu-mcp-gatewayd, so the check failed
its stat and the container never went healthy though the process served
MCP on :8080. Point the healthcheck at the entrypoint path (canonical and
permanent, mirrors control).

G3 and E8 issued a guest exec immediately after create, before the guest
boot-child brings up the FUSE mount and exec plane, so the op was denied
(exit -1) and the test read a boot race as a failure. Wait for the exec
plane with await_fleet_exec_ready before the audited/keystone exec, the
same gate the B group already uses; both now record their remaining gaps
as honest xfails rather than a boot-race red.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZogNpHHSvgo41RsoBmsxH
Add the OpenWebUI client leg to the fleet compose, repointed at OUR MCP
gateway instead of the old monolithic Computer Use Server. The old tool
already speaks MCP tools/call; here it targets mcp-gateway:8080 with an
sk-ocu- boot-set key and MCP-Protocol-Version. A tool-call flows
OpenWebUI -> gateway (auth + validate) -> control (create) -> gVisor
guest, and executes behind the gateway; the control plane owns the
session lifecycle (the tool never destroys).

- open-webui + its OWN openwebui-db (never control-db) on ocu-frontend,
  host port 3001 (3000 is the fleet webui BFF).
- mcp-gateway now runs the G4+G6+G7 combined image with the G7 durable
  audit sink (-audit-sink) so a valid keyed create reaches 201 instead
  of the fail-closed 500, plus the G6 readiness healthcheck.
- mcp-gateway-audit-init chowns the one writable audit-journal mount to
  the gateway uid, mirroring webui-audit-init.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZogNpHHSvgo41RsoBmsxH
The exec-forward runs a bash_tool command as an argv in the guest. The demo
guest shipped only /bin/busybox with no /bin/sh, so a tool command spawned via a
shell path failed ENOENT and the exec fail-closed with a 409. An image that
supports bash_tool must guarantee a POSIX /bin/sh so the gateway need not know
the image internals; symlink /bin/sh onto the static busybox to provide it.

Pairs with the gateway sending /bin/sh -c (not bash -lc: -l is non-POSIX and a
login shell is not needed for a tool-call). Follow-up: the prod assembled image
must also ship a /bin/sh or it hits the same ENOENT.
The File Pane (component-08) is an embeddable SPA that never self-issues its
bootstrap credential: it trusts exactly one parent origin
(NEXT_PUBLIC_OCU_PARENT_ORIGIN, strict equality) and waits for the parent to
postMessage a peer-minted embed token. The fleet had no such parent, and the
build-arg was unset, so the pane could never bootstrap in a browser — it hung
on "Loading files…" forever.

embed-portal is that parent: a tiny Go service that iframes the pane, mints a
short-lived HS256 embed token server-side (aud ocu-webui, exp 60s under the
120s ceiling, sub/filesystem_id/intent claims the BFF requires), and
postMessages it into the iframe at the pane's literal origin. It holds the
embed-verify secret in a separate origin/process, preserving the invariant
that the webui origin never mints its own bootstrap credential — the same role
a customer portal/IdP fills in production.

Compose wiring: portal published on :3003; webui gets
NEXT_PUBLIC_OCU_PARENT_ORIGIN as a build-arg (NEXT_PUBLIC_* inlines at build,
not runtime) and the portal origin in its frame-ancestors allowlist. Open the
demo at http://localhost:3003 (localhost, not 127.0.0.1 — strict origin
equality plus the SameSite=None;Secure session cookie requires a localhost
http origin).

Proven live in a real browser: bootstrap -> list -> upload round-trips
end to end; the download 403 on an uploaded file is the NFR-SEC-73 control
(downloadable is a read-time egress tag, default false), not a defect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZogNpHHSvgo41RsoBmsxH
…gateway requires

The tool's manual `/mcp` initialize preflight sent protocolVersion
"2024-11-05" in the JSON-RPC body and omitted the MCP-Protocol-Version
HTTP header. The next/v1 MCP gateway negotiates the version through that
header and rejects a request that carries the wrong version, or none,
with -32602 "unsupported or missing protocol version" (HTTP 400) before
auth. The preflight treats anything other than 401/403 as a broken
server, so every tool call surfaced [CONFIG ERROR] and never reached the
real MCP SDK call (which sets the header itself).

Pin the version as `_MCP_PROTOCOL_VERSION = "2025-06-18"` and send it in
both the header and the initialize body. Verified live against the fleet
stand: the browser bash tool now runs into the guest and returns output
(no [CONFIG ERROR]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…etworks

The PoC guest image installs ~24 npm globals with binary postinstalls
(phantomjs via markdown-pdf, sharp, playwright). Under an emulated (qemu
amd64) or otherwise slow network, npm's default 300s idle timeout aborts
mid-fetch (EIDLETIMEOUT), failing the build. Seed the assistant user's
npmrc once after useradd so every later global install retries with a
600s ceiling. No effect when the network is fast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
Running `glab config set check_update false --global` at build time crashes
under qemu-user emulation: glab is a pure-Go binary, and Go's lock-free
stack hits its 48-bit pointer-packing assertion (lfstack.push) when
qemu-user returns higher addresses. Building the x86_64 image with
--platform linux/amd64 on an arm64 host runs that step emulated.

Replace the execution with a direct write of the config.yml/aliases.yml
artifacts glab would have produced (check_update:false is the only
non-default line the step set). Shipping the artifact instead of running
the producer is byte-identical on the emulated and native paths — no
image drift, and no guard that could mask a real native failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
The image was x86_64-hardcoded (node linux-x64, ttyd.x86_64, glab_amd64,
JAVA_HOME .../-amd64, a vendored x86_64 extract-text ELF), so it built
only under --platform linux/amd64 — and on an arm64 host that means qemu
emulation, where Go binaries crash at build time and every download is
slower. Parameterize each arch-specific site on the buildx-provided
TARGETARCH so one Dockerfile builds both amd64 (x86_64-faithful) and arm64
(native on aarch64 hosts, incl. gVisor):

- node: linux-${x64|arm64}
- ttyd: ttyd.${x86_64|aarch64}
- glab: glab_..._linux_${amd64|arm64}
- JAVA_HOME: a stable symlink to java-21-openjdk-${TARGETARCH}
- extract-text: the vendored binary is x86_64-only; install it on amd64,
  omit it on arm64 with an explicit build note (never ship a broken ELF
  that pretends to be present). Ship an arm64 build to close that gap.

bun and codex resolve their own arch at install; playwright installs
per-arch — no change. Verified firsthand under linux/arm64 buildx: node
v22.11.0, glab and ttyd extract as ARM aarch64 ELFs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…grants

Seed a workspace-model record per surviving catalog model with
base_model_id=null so meta.toolIds surfaces into the resolved /api/models
(Open WebUI's get_all_models applies a null-base record as a direct
override; a self-referential base_model_id lands on the skip path and the
tool never reaches the model). The catalog is trimmed to Qwen + DeepSeek
flash, native function-calling is set, and the fresh-chat default is
deepseek-flash — so any chat a user opens has the Computer Use tool live
without a manual toggle.

Also seed public read access_grants on each model record: a seeded record
with empty grants is dropped by get_filtered_models for non-admin users,
who would otherwise see zero models.

Verified firsthand in the real browser on the default deepseek-flash chat:
bash_tool runs and returns real guest output (echo 7*7 -> 49, pwd -> /),
and create_file surfaces the gateway's clean "unimplemented tool" error in
the UI (no hang) then falls back to bash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…nly)

markdown-pdf pulls phantomjs-prebuilt, which ships x86_64 binaries only
(abandoned, predates arm64 Linux); its postinstall hard-fails on a native
arm64 build. Filter it out of the global install when TARGETARCH=arm64, so
the arm64 image builds clean. The markdown->PDF skill helper is then
unavailable on arm64 — a documented host-ISA gap, symmetric to the
x86_64-only extract-text helper; amd64 keeps it. Verified the filter:
amd64 installs 21 globals (markdown-pdf included), arm64 installs 20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
Cover the MCP tool surface (bash_tool, str_replace, create_file, view)
end-to-end against the live fleet, derived from the PoC behavior in
computer-use-server/mcp_tools.py. mcp_tool_surface.feature is the Gherkin
source of truth; @L4 scenarios are proven in the gateway forward e2e, @l5
here as live journeys through the real gateway on 127.0.0.1:8080.

Group I proves what only a live guest can settle:
  I1  a real non-zero exit transports to isError, empty-output non-zero
      carries the synthesized "[Exit code: N]" marker (verified live)
  I2b a moderate output returns whole; I2 (oversize) is a bounded result
  I3  a command past the exec-timeout is KILLED (enforcement holds)
  I4  create_file EACCES is a guest-identity contrast + writable keystone
  I5  one chat maps to one persistent workspace; a different chat is
      isolated (per-session, not a shared global fs)
  I6  the four tools compose over one workspace (create->view->str_replace
      ->bash = ALPHA EDITED), gated on a python3-bearing guest

I2 and I3b are xfail(strict) pending DEFECT #127: oversized output and a
timed-out command surface as a 502 forward-refusal that loses the whole
result, where the PoC returns a bounded/timeout-noted tool result. The
strict marker XPASSes -> reds the suite the moment the fix lands, forcing
the marker's removal in the verifying PR. Root cause: gateway
maxReplyBytes=64KiB read-cap vs control 8MiB stream-cap — a cross-component
sizing-invariant the two repos never cross-checked.

Firsthand on the live Lima stand: 6 passed, 2 xfailed. I5 persistence is
red-probed non-vacuous (break the session -> the journal reds).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…t to #129

#127 step 1 (gateway raise maxReplyBytes) kills the 502 for outputs up to
the read-cap but does not deliver the 64KiB caller ceiling (that is step 2,
control-side, #128). Per the multi-step-xfail rule, split so each xfail
tracks exactly one un-landed step and none sits red-hidden across PRs:

  I2  -> plain green: the step-1 "nothing lost" contract (a 120k output that
         used to 502 now returns HTTP 200 with the output intact, len>=120000).
         Strict whole-return is a designed paired-flip with I2c: when step 2
         lands, I2 reds as I2c strict-xpasses, forcing the step-2 PR to merge
         them into one bounded-contract test.
  I2c -> new strict-xfail (#128): the 64KiB caller ceiling + truncation marker.
  i2b -> unchanged 30k whole-return keystone; stale base64 NOTE dropped.
  I3b -> re-pointed from "#127 same class" to #129: the timeout 502 is a
         SHAPING defect, not the size class. Firsthand on the live stand:
         control returns HTTP 409 on a timed-out exec -> gateway 502; a
         "echo MARKER; sleep 600" loses the partial output (the PoC preserves
         it + a timeout notice). sleep 600 emits zero stdout, so #127's
         maxReplyBytes raise could not have touched this path.

Firsthand on the live Lima stand (gateway rebuilt from PR #43): 7 passed,
2 xfailed. The 502 for oversized output is dead; the timeout 502 (#129) and
the 64KiB ceiling (#128) remain, each tracked by exactly one strict-xfail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
#129 fixed)

Control PR #63 shapes a host exec-timeout into a valid exit-124 reply with
the pre-kill partial output + a "[Command timed out after Ns]" notice in the
stream the gateway relays on isError — so the killed command is a usable
Tier-2 tool result, not a 502 that loses everything.

I3b was strict-xfail(#129); the fix made it XPASS (strict -> reds the suite),
so per the multi-step-xfail rule it flips to a plain green assert: a timed-out
command returns HTTP 200 + isError, carrying its PARTIAL output AND the notice.
Firsthand-verified on the live Lima stand (control rebuilt from PR #63):
"echo MARKER; sleep 600" -> HTTP 200, isError:true,
content "MARKER\n\n[Command timed out after 30s]\n" (was 502, MARKER lost).

Group I on the live stand: 8 passed, 1 xfailed (I2c still tracks #128). I3
still proves the KILL; I3b now proves the RESULT SHAPE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…nded)

Control PR #64 bounds each F5 exec-reply stream at 64KiB at the source
(stdioCap 8MiB->64<<10) + a truncation marker, so an oversized output is a
bounded tool result — not a 502 (#127) and not relayed whole (#128).

The designed paired flip fired: I2's whole-return-of-120k assert reds AND
I2c's 64KiB-ceiling strict-xfail xpasses at the same moment #128 lands, so
per the multi-step-xfail rule they merge into ONE plain-green bounded-contract
test: HTTP 200 + isError:false + DATA truncated to <=64KiB + a truncation
marker + a keystone that a small output comes back whole and un-truncated.

Firsthand-verified on the live Lima stand (control rebuilt from PR #64,
FAT-guest override re-applied): "yes X | head -c 120000" -> 65535 bytes of
data + "[output truncated at 65536 bytes]" (was 502, then whole at 8MiB).
Group I: 8 passed, 0 xfailed — the tool-surface arc's three defects
([Exit code:N], large-output 502, timeout 502) are all closed end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…un journeys

The fleet stand was runnable only on one laptop: the compose bind-mounts
./fixtures/guest-config.json, the guest-image Dockerfile, the demo scripts,
gen-gateway-pki.sh, README, .env.example, and the group-H journeys were all
UNTRACKED, and the compose diff carried a security default (downloadable
prefixes -> /outputs, NFR-SEC-73) that lived nowhere in git. A clone could
not build or run anything.

This commits the deployable substrate:
- docker-compose.fleet.yml: the -downloadable-prefixes /outputs exfil default
  + OCU_DOWNLOADABLE_PREFIXES / OCU_GUEST_IMAGE env seams.
- .env.example: documents OCU_GUEST_IMAGE (the file tools need a python3-bearing
  FAT guest; the stripped default runs bash-only — see #122) + the prefixes.
- fixtures/guest-config.json: the mount config the guest reads (placeholders
  rendered at bring-up; no secrets).
- guest-image/Dockerfile, gen-gateway-pki.sh, exec-demo.sh, storage-chain-demo.sh,
  g7-visualizer/, README.md: the bring-up + demo surface.
- test_h_gateway.py: the group-H MCP gateway auth-edge journeys.

Scrub: process_api kept only where load-bearing (the real guest-agent binary
COPY/ENTRYPOINT + the real image tag in executable build/create commands);
every prose/comment mention genericized to "the guest agent" (identifier-vs-
provenance rule). The H1 forged-key fixture uses the allowlisted sk-ocu-wrong
placeholder. Secrets (gateway-pki/, secrets/, .env) stay gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…le comment

The fleet journey suite (deploy/tests/journeys/) ran only when a human
remembered to, on a live Lima stand — no CI ever imported it, so import rot
or fixture drift would land silently. journeys-collect.yml adds a tier-1 gate
on every PR touching deploy/: compileall + `pytest --collect-only` (115 tests
collected firsthand, 0.18s), no live run. The tier-2 live-stand gate (needs a
real fleet a GH runner lacks) is a separate follow-up.

Also correct a stale compose comment: the mount root_path fix it called "task
tracked" was in fact REJECTED (ocu-control #113) in favor of storage-engine
scope resolution from the token intent claim (ADR-0029, shipped #116/#117/#118).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…cap invariant

Every mcp_tool_surface.feature scenario now maps to a named covering test — the
13 @L4 to ocu-mcp-gateway forward-e2e (TestL4*), the 10 @l5 to group-I journeys
(test_i/test_h). 23 scenarios, 0 unmapped (the "18" I'd claimed miscounted the
Scenario Outline). Pins the large-output cap invariant in one committed place:
gateway.maxReplyBytes >= 2*ceil(control.replyCeiling*4/3)+envelope, control 64KiB
< gateway 256KiB; the F5 exec-reply schema follow-up is tracked as #344.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
… a clean clone

The final-proof run from a fresh in-Lima clone @bc8faa3 caught it: the journey
suite needs pytest + pyyaml, undocumented, so a clean clone stops at
`ModuleNotFoundError: yaml` before any test runs. requirements.txt pins both;
README adds the venv + install + pytest steps and notes the file-tool legs need
the OCU_GUEST_IMAGE=poc-fat-arm64 override (#345).

Verified: from the clean clone with these deps installed, the full A-I suite is
23 passed, 92 skipped, 0 failed against the live stand (gateway f7b6e5c, control
37d6492) — the arc's coverage runs from pushed state, not just this laptop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…eview

A fresh-clone review (Fable) found the arc's own tests were not reproducible
from pushed state. Four fixes:

1. Vendor scripts/mint_boot_set.py into deploy/fleet/scripts/ — the H/I journeys
   need a minted boot-set + bearer, but the minter lived only in ocu-mcp-gateway,
   so a clean clone of THIS repo could never render it and every gateway journey
   skipped (green-by-skip on missing substrate). It is standalone stdlib; the
   vendor note points at the gateway as the formula owner.
2. journeys-collect.yml installed only pytest while conftest imports yaml — the
   tier-1 gate would ImportError on its first run. Install -r requirements.txt.
3. Dead ticket refs in pushed files: FINDINGS cited "ocu-mcp-gateway #131" (the
   real anchor is PR #44/f7b6e5c); the compose comment cited a bare "ocu-control
   #113" + an ADR path absent from this branch — both de-numbered to
   repo-neutral descriptions a reader here can actually resolve.
4. README documents the mint step before the pytest run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…int runbook

mint_boot_set.py validates its rendered boot-set against
contracts/mcp/mcp-key-set.schema.json resolved next to the script, but that
schema was never committed here — it lived only in the gateway/control repos.
A clean clone therefore crashed the minter with FileNotFoundError before it
could render a boot-set, so the H (gateway auth-edge) and I (tool-surface)
journeys skipped for lack of a boot-set: green-by-skip, not a pass.

Vendor the schema alongside the minter (its doc-comment already promised it
sits there) and fix two runbook gaps in the README that would leave a clean
clone unable to run H/I:
  - --deployment is required and MUST equal the gateway's -deployment
    (fleet-local); a foreign-deployment record 401s (ADR-0027).
  - the minter prints the bearer to stdout; the tests read it from bearer.txt,
    so the runbook now captures the printed line there and recreates the
    gateway to reload the boot-set.

Verified from a truly clean in-Lima clone that renders its own boot-set: the
minter exits 0, the live gateway binary accepts the clean-clone-minted key
(200, not 401; a forged key still 401s), and H (5) + I (8) run green against
the live stand instead of skipping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
… order)

The prior runbook redirected the bearer into
deploy/fleet/secrets/gateway/bearer.txt, but the shell opens that redirect
target before the minter creates --out-dir, so a clean clone failed with
"No such file or directory" and a BrokenPipeError. mkdir -p the out-dir first,
write the minter's stdout to a temp file, then take the last line as the
bearer — so a mint failure leaves a traceback in the temp file, never a
half-written bearer.txt.

Verified verbatim from a fresh in-Lima clone of the pushed tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…at-guest build

Two closeout gaps a final review surfaced:

1. Nothing exercised the minter, so the vendored contracts/mcp schema could be
   deleted or drift and CI would stay green while a clean clone crashed with the
   same FileNotFoundError the vendoring just fixed. Add a mint-smoke step to
   journeys-collect.yml: it runs mint_boot_set.py against a throwaway deployment
   + out-dir (stdlib-only, no live stand, no pip) and reds if a boot-set cannot
   render. Red-probed: hiding the schema makes the step fail; restoring it passes.

2. The group-I file-tool legs need a python3-bearing guest, but no build recipe
   for one lived in the tree — the "13 passed" outcome leaned on a Lima-local
   image tag. Document the build (layer deploy/guest-image/Dockerfile over the
   repo-root PoC userland base) and state the expected counts so a third party
   can tell honest-green (13 passed / 0 skipped with a python3 guest) from
   green-by-skip (10 passed / 3 skipped on the stripped default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…d .env.example

Follow-on cleanup after the fat-guest recipe landed: .env.example cited
ocu-guest:poc-fat-arm64 (task #122), but the README build recipe produces
ocu-guest:poc-fat (task #345) — a reader following the .env.example comment
would set a tag the recipe never builds. Point the comment at the README
recipe and the poc-fat tag. Add --platform linux/amd64 to the base-build step
per repo policy, with the Lima-native drop note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3bc9729-07d8-47ee-8ca6-b3875eebfcba

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/demo-walkthrough

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

widemoat-ai and others added 4 commits July 10, 2026 14:02
The first tool call in a fresh Open WebUI chat wrote to /home/assistant and
died on the read-only rootfs: the model gets zero path guidance. The old
filter's /system-prompt fetch points at the MCP gateway, which fronts
tools/call only, so the injection degrades to nothing. Bind a fleet-true
system prompt (filesystem map, session semantics, verify-your-work) onto
every seeded model via params.system in init.sh.

Raise the default -session-idle-ttl 1m -> 15m (the NFR-SEC-40 ceiling): a 1m
window reaped the guest while the user was still reading the reply, wiping
/tmp and the scratch home mid-conversation. Short values stay one env var
away for reaper demos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
A re-minted boot-set updates what the gateway accepts, but the chat leg keeps
presenting the OLD bearer from its tool Valve (seeded from MCP_API_KEY on
first boot). Every chat tool-call then dies 401 wrapped in the MCP SDK's
opaque cancel-scope transport error while the journey suite - which reads
bearer.txt directly - stays green. Add the .env update + Valve re-seed steps
to the runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…122)

The compose default was the distroless assembled tag: no shell, no python3,
so the first chat input in a fresh deploy hit a dead guest (file tools
project onto python3). Default to ocu-guest:poc-fat (build recipe already in
the README journey-suite section) and keep the stripped tags one env var
away for the minimal rung and the storage-chain demo scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
… semantics

The per-model seeding loop walks base-catalog ids only, so a legacy derived
record (unique id + base_model_id set, e.g. an ocu-* alias of a kept base)
kept stale params forever. When such a record is the fresh-chat default,
every new chat runs with NO system prompt while directly-seeded models carry
one. Step 2b now walks workspace records and re-binds tool + native FC +
prompt onto every record whose base points into the kept set.

The /mnt/user-data prompt bullet now states the exchange semantics the guest
actually observes: user uploads are readable in place, saved deliverables
write through to the user's Files panel and may leave the guest's listing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g5EttcR8mqYDn8EByShUL
Nick and others added 8 commits August 3, 2026 14:34
Four files conflicted. Each was resolved by reading both parents against
the merge base rather than the markers alone, and each resolution was then
checked by an independent pass whose job was to find behaviour that had
been dropped. None was found.

- .github/workflows/build.yml: next/v1's digest-then-tag sign/promote
  architecture is kept whole; this branch's per-image SLSA provenance is
  folded into its sign matrix at the same action SHA, and the
  attestations: write permission next/v1 lacked comes along. The
  system-prompt/skills contract test joins the test job.
- openwebui/functions/computer_link_filter.py: both branches landed the
  same scope-segment fix independently, so this branch is a strict
  superset. Kept whole. The new RESOLVE_SCOPE_URL valve defaults empty,
  which reduces the scope expression to exactly next/v1's.
- openwebui/functions/test_computer_link_filter_download.py: next/v1's
  174 lines are a byte-exact prefix of this branch's 269. All ten of its
  tests survive at the same line numbers.
- openwebui/init.sh: next/v1's clearer comment on the download base wins;
  the valve payload keeps all five of its keys at identical values plus
  this branch's five.

Nothing executable changed for the three runtime files this branch's live
measurements were taken against: two are byte-identical, init.sh differs
only in comment prose.

Co-Authored-By: Claude <noreply@anthropic.com>
Every other M test gates on the browser or on a configured model and skips
where neither exists. M13 talks to the gateway directly and had no gate, so
on a runner with no fleet it raised "curl transport failure rc=7" and the
suite reported a failure where the truth was that nothing had been deployed
to test. That is what the collect job hit.

The I-suite's equivalent is an autouse fixture, which pytest refuses to let
another module call, so its three conditions are mirrored. The asymmetry is
kept: an absent gateway skips, a gateway answering 401 fails -- that means
the bearer and the running boot-set are from different trees, and skipping
there once let a stale bearer pass as a clean capability skip.

Both sides measured: with the stand up M13 passes; with the boot-set path
pointed at nothing it skips with that reason instead of raising.

Every M test that reaches the stand is now gated -- checked by walking each
test body for a stand call and for a gate, not by fixing the one that failed.

Co-Authored-By: Claude <noreply@anthropic.com>
Both callers reached for shlex.quote, which is the tell that a shell was in
the loop at all: with shell=True the quoting was the only thing standing
between an image name or a compose path and a second command. Passing a list
hands the words to execve, so a value carrying a quote, a semicolon or a
newline stays one argument by construction rather than by escaping.

docker_cmd is split rather than concatenated because it is a command line,
not a word: deployments set it to "sudo docker".

Measured both ways with the same hostile input "probe; touch <marker>":
through the list form /bin/echo printed it verbatim and no marker appeared;
through the old shell form the shell cut it in two and touch ran.

Closes the only semgrep subprocess-shell-true finding on this branch. The
remaining subprocess findings are the harness driving docker and curl with
argv it builds itself, which is a separate call.

Co-Authored-By: Claude <noreply@anthropic.com>
The SCA gate runs two passes: CRITICAL including unfixed, then HIGH
fixable-only. The second is what reds this branch -- a fixable HIGH has a
bump answer, so the gate enforces bump-not-ignore. It printed no findings
because format: sarif sends them to a file, which is why the log reads as
an unexplained exit 1.

  pillow    12.2.0 -> 12.3.0   10 CVE
  mcp       1.27.0 -> 1.28.1    3 CVE
  pypdf     5.9.0  -> 6.14.2    2 CVE
  httplib2  0.20.4 -> 0.32.0    1 CVE

pypdf 5 -> 6 is a major, so its consumers were checked rather than assumed:
the four skill scripts and the server import PdfReader, PdfWriter,
generic.DictionaryObject, constants.FieldDictionaryAttributes and
annotations.FreeText -- no removed camelCase shim. All five resolve against
an installed 6.14.2.

Measured with the gate's own flags: fixable HIGH 16 -> 0, CRITICAL 0.

The same pillow bump merged separately on main. It does not reach here:
main and this lineage share no ancestor, and this branch still pinned
12.2.0 after that merge.

Co-Authored-By: Claude <noreply@anthropic.com>
Control answers every unclassified refusal with a bare 409 and an EMPTY
body on purpose: the reason lives in the audit stream, never in the
response. A guest image the daemon does not have fails at materialize and
surfaces here identical to an exhausted quota.

The bare "denied:409" sent this investigation through the quota source,
the counter schema, the control database and the audit volume before the
audit named it -- and the counters could never have answered, because a
refused charge is refunded to zero by design.

Measured on the live stand, one variable changed:

  FLEET_GUEST_IMAGE=ocu-guest:assembled-demo  ->  32 failed / 57 passed
  FLEET_GUEST_IMAGE=ocu-guest:poc-fat         ->   2 failed / 81 passed

The stand's control runs -guest-image ocu-guest:poc-fat and merely
allow-lists assembled-demo, which was never built. Thirty of the
thirty-two failures were that one absent image.

Co-Authored-By: Claude <noreply@anthropic.com>
Both opened the pane with no chat -- which binds the BASE storage tree --
and then named a fresh chat for the guest, whose mount is that chat's own
subtree. The bytes land correctly and the guest looks somewhere else, so
j2 reads "No such file or directory" and j5b fails on its own precondition
instead of on the tamper property it exists to guard.

Same defect m2 carried this morning: under per-chat isolation both halves
must name the same chat or the test silently measures two trees.

Red-probed one side each: pointing the guest at another chat reds j2;
binding the pane to another chat reds j5b.

Co-Authored-By: Claude <noreply@anthropic.com>
Folding the image name into SessionRef.status broke every test that
compares that field for equality -- test_e_lifecycle asserts
status == "denied:409" exactly, and the appended "(image=...)" made it
never match. The whole e-module went red on a change meant to make one
message clearer.

status is a contract value, not a message. The diagnosis moves to a new
`detail` field nothing compares, so a refused create still names the image
without redefining the code beside it.

Measured after the change with no other run contending: test_e_lifecycle
7 passed, 0 failed. The 6 failures seen while a full suite ran in parallel
were session contention, not this fix.

Co-Authored-By: Claude <noreply@anthropic.com>
Comment thread requirements.txt Fixed
Comment thread requirements.txt Fixed
@Yambr

Yambr commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Refreshed the base (the last run was from 3 August, before several gate changes) and ran the scanners locally to see what is actually red rather than reading week-old logs. Recording it so whoever picks this up does not start from zero.

Three gates fail: SAST — semgrep, Trivy, SCA — trivy (filesystem).

Ran semgrep with the exact CI rule set — .semgrep/ plus p/security-audit, p/owasp-top-ten, p/python, p/javascript, p/dockerfile, p/github-actions, with --exclude '*.fixture.py' — over the 45 scannable files this PR adds:

14 code findings. They split into two groups that need different answers:

where finding disposition
deploy/tests/journeys/* (conftest, backends/fleet.py, test_e_lifecycle, test_k_admin) dangerous-subprocess-use-tainted-env-args test harness driving docker/compose from env — argue or annotate per finding
openwebui/tools/computer_use_tools.py dynamic-urllib-use-detectedBlocking production path
openwebui/functions/computer_link_filter.py same class production path

The production one is worth reading before deciding: _http_probe_get builds urllib.request.Request(url) where url is a health-probe target. The rule's concern is real in general — urllib honours file://, so a caller-controlled URL reads arbitrary files — and whether it is reachable here depends on where that URL originates, which is the thing to establish rather than assume.

What this PR is blocked on is triage, not a rebase. 20,090 lines, 14 findings, two classes, one of them on a production path. That is its own piece of work with its own review, not a tail on a queue-clearing pass — so I am neither merging nor closing it.

Related: #345 describes the demo default as if it were on mainline. It is not — the stand substrate lives only on this PR and its sibling branches, which is why that issue reads as a mainline defect and is not one.

Yambr and others added 4 commits August 11, 2026 12:24
… blocks on (#431)

`SCA — trivy (filesystem)` blocks on fixable HIGH, and the whole of what it
finds in tracked files is two CVEs in one package: CVE-2026-69249 and
CVE-2026-69247 against cryptography 48.0.1. 50.0.0 clears both, and re-running
the gate's own invocation over requirements.txt reports zero.

Scanning the working tree reports 496 findings, but 494 of them sit in local
untracked directories that no CI checkout contains. Filtering to `git ls-files`
is what makes the number match what the gate sees.

PyJWT is the only dependent here. Verified on the image's Python (3.12, per
ubuntu:24.04) rather than the host: cryptography 50.0.0 installs alongside
PyJWT 2.13.0 and an RS256 sign/verify round-trip through a generated RSA key
completes.

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…reason (#430)

* test(journeys): waive each harness subprocess site, then enforce the reason

The semgrep tainted-env-args findings in the journeys harness are all the same
shape: a stand-config value from the operator's own env reaches a list argv.
Each site now carries a `# nosemgrep` with the reason that holds THERE, rather
than a path exclusion — a subprocess call added later fires fresh instead of
being silently pre-exempted.

The `limactl shell $FLEET_LIMA_INSTANCE -- argv` leg is called out explicitly:
it rides ssh semantics, which join argv into a command line the VM's shell
re-parses, so "list argv, never a shell" is not true end to end there.

Every waiver states "list argv, no shell", which is an assumption about code
nobody re-reads. test_z_meta_guard now makes it a property: an AST scan over
the whole journeys tree reds on `shell=True`, `os.system`/`os.popen`, or a
command built by f-string / `%` / `.format` / concatenation. Interpolation
inside ONE argv element stays legal — `f"name={cname}"` reaches the program as
a single argument — and the planted-violation test pins both sides of that
boundary so the guard can neither go vacuous nor force the waivers off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): bind the shell hazard to the property, not to its spelling

The first cut of the guard matched a syntactic silhouette, so nine genuine
host-side hazards walked past it. An adversarial pass planted each one and the
detector returned nothing:

  subprocess.getoutput("docker rm " + x)      # /bin/sh by construction
  subprocess.getstatusoutput(f"...")          # likewise; semgrep misses it too
  from subprocess import run; run(f"...")      # callee not spelled subprocess.run
  cmd = f"..."; subprocess.run(cmd)            # command hoisted into a local
  from os import system; system("..." + x)
  subprocess.run(a, shell=sh)                  # non-literal shell=
  subprocess.run(a, shell=1)
  subprocess.run(a, shell=bool(os.getenv(..)))
  sh = subprocess.run; sh("docker rm " + x)    # alias

getoutput/getstatusoutput matter most: they take a command string and hand it
to /bin/sh, no list-argv form exists, and semgrep's python bundle does not flag
them either — this guard is their only backstop.

The detector now resolves the callee (from-imports and simple aliases) instead
of matching its spelling, tracks locals holding a built command string, treats
any non-False `shell=` as a hit, and carries a shell-by-construction callee set.
Interpolation inside ONE argv element stays legal, and the clean half of the
planted test pins that so the guard cannot force the waivers off.

The planted test previously exercised only the shapes the detector already
caught, which made it green by construction; it now plants every evasion above.

Two waiver reasons overstated their case and are corrected: test_k_admin's
`body` carries env-derived credentials via `_login` rather than literals, and
`_psql` takes one f-string (interpolating `int(value)` and a module constant).
Both remain safe because each value is one argv element, not because the values
are constants — which is the reason the comment should have given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): scope the string locals, or the guard reds on the safe form

A second adversarial pass found a false positive, which is worse than any of
the misses it also found: `string_locals` was a flat, file-global map, so a
local holding a LIST argv was flagged whenever another function in the same
file bound the same name to a built string.

    def list_probe(name):
        cmd = ["docker", "ps", "--filter", f"name={name}"]
        return subprocess.run(cmd)          # flagged, and clean

    def other(x):
        cmd = f"echo {x}"
        return subprocess.getoutput(cmd)    # the actual hazard

`cmd`, `args` and `argv` are what this harness names its list argv, so the next
helper added to an already-waived file would have reddened the safe form — and
a guard that reds on the safe form forces the waivers off instead of keeping
them honest, the exact failure the negative test claims to prevent. The tree is
clean today, so this was latent rather than visible.

String locals are now indexed per enclosing function (module level included),
and the lookup consults the scope of the call being inspected.

Two genuine hazards the pass also found are closed: `**{"shell": True}`, whose
keyword carries `arg is None` and so was never examined by the shell= loop, and
`import os as o; o.system(...)`, which `ast.Import` never bound (only
`ImportFrom` and attribute assignments were tracked).

The planted test pins all three, including the scope case as a line-number
assertion so a regression names the clean call it wrongly reds. 11/11 evasions
caught, harness green, semgrep tree still 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): prune the subtree, not the node, when indexing a scope

Per-function scoping did not close the false positive; it moved it one level
down. `_index_scope` skipped a nested function with `ast.walk` + `continue`,
which prunes that one NODE and still descends into its body, so an inner
helper's built string landed in its parent's locals and reddened the parent's
clean list argv:

    def outer(name):
        cmd = ["docker", "ps"]
        subprocess.run(cmd)        # flagged, and clean
        def inner(x):
            cmd = f"docker rm {x}"
            subprocess.run(cmd)    # the actual hazard

Not hypothetical: conftest.py and test_f_agentic_load.py already nest helpers
inside waiver-bearing functions, so the first inner `cmd`/`args` holding a
string would have reddened the outer call.

Indexing now descends through direct children and stops at each nested scope,
carrying a nested definition's decorators and argument defaults with the
enclosing scope, where they actually evaluate.

Comprehensions and lambdas get their own scope for the same reason — a loop
target or a lambda argument binds inside and shadows the enclosing name — and
inherit the enclosing scope minus what they bind, so a comprehension that
merely READS an outer built string is still caught.

The planted test pins both as line-number assertions, so a regression names
the clean call it wrongly reds rather than just going red somewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): resolve a name outward to the nearest scope, not the first one

A comprehension inside a function resolved its names through the MODULE, so a
command string built in the function was invisible:

    def t(x):
        cmd = f"rm {x}"
        return [subprocess.run(cmd) for _ in y]   # missed

Two causes, both fixed. The parent map recorded whichever scope a walk reached
first and refused to overwrite it, which put every nested comprehension under
the module; it now records the nearest enclosing scope by descending from each
scope to its own children. And inheritance was precomputed in walk order, so a
comprehension could inherit before its enclosing function had been indexed; the
chain is now resolved at lookup, walking outward and stopping at any name the
scope binds itself.

A walrus binds in the enclosing scope rather than the comprehension (PEP 572),
so its assignment is collected there.

10/10 scope cases correct, including a comprehension shadowing its own loop
target, a lambda reading a function local, and a two-level nested def. All 12
evasions still caught, semgrep tree still 0.

The planted test pins the three shapes by line number. Its assertion sorts the
hits: they are not emitted in line order, and asserting the unsorted list would
have made the test depend on traversal order rather than on the finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): every lambda parameter kind shadows, not just the ordinary ones

The inheritance lookup popped `args.args` alone, so a lambda whose own
positional-only, keyword-only, `*args` or `**kwargs` parameter shadowed an
enclosing built-string local still inherited that string, and the lambda's
clean call reddened:

    cmd = f"echo {X}"
    posonly = lambda cmd, /: subprocess.run(cmd)   # flagged, and clean

All five parameter kinds now shadow. A lambda that READS an enclosing built
string is still caught, so the fix narrows nothing.

Two gaps stay open and are now written down in the detector rather than left
implied: a name rebound through `global`/`nonlocal` in another scope, and a
closure reading an enclosing function's local. Both need cross-scope name
resolution, and neither is a shell hazard — `subprocess.run("<str>")` without
`shell=` is a program-name lookup that fails, not a command line. The shell
surface is `shell=` and the always-shell callees, which resolve regardless of
scope.

Mutation-checked: reverting the pop to `args.args` reds the planted test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… does (#429)

* fix(tools): refuse a non-http orchestrator URL, as the sibling filter does

semgrep flags `dynamic-urllib-use-detected` on this file as Blocking, and
tracing it out is what makes the finding real rather than noise: the URL
comes from `self.valves.ORCHESTRATOR_URL`, an Open WebUI admin setting,
so it is not caller-controlled — but urllib honours file://, ftp:// and
data://, and there is no guard here at all.

The sibling function file already refuses exactly this, with a comment
naming the same risk: "a misconfigured Valve could read arbitrary local
files through urlopen (ruff S310)". So one of the two paths that consume
the same Valve was protected and the other was not. That asymmetry is the
defect, not the rule firing.

The check goes in the constructor rather than at each urlopen: the URL
enters once and fans out to three call sites (health probe, MCP
preflight, and the chat-scope read), so guarding the entry cannot be
partially applied later.

Probed both directions: http and https accepted; file://, ftp://, data:
and a bare path refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCVG3p5zphDdsnCptx4S7J

* test(tools): bind the orchestrator URL scheme check to its own assertions

The refusal shipped without a test, so an implementation that accepted every
scheme — or refused every one, including http — would have passed.

Four cases: http and https construct, `file:///etc/passwd` raises (the scheme
that turns a network read into a local-file read and reports it as if it had
come from the orchestrator), ftp/data/gopher raise, and a bare `host:port`
with no scheme raises here rather than deep inside urlopen with a message
naming neither the Valve nor the URL.

Mutation-checked: neutering the check to `if False` reds five of them, so the
positive and negative halves are both bound to the guard rather than to
whatever the constructor happens to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): guard the resolve path too, which does not go through the client

The constructor check closed three urlopen sites and left a fourth open:
`_resolve_chat_scope_sync` builds its own Request straight from
ORCHESTRATOR_URL and never constructs an `_MCPClient`. So the PR's own claim —
that the check sits where the URL enters — was not true of every entry.

That path is the worse one. It catches every exception and degrades to the
base scope, so a `file://` Valve would have read a local file and reported the
result as a scope, and any failure would have looked like an ordinary resolve
miss. The check is now a module-level helper both entry points call, and the
async `_resolve_chat_scope` reaches it by delegating to the sync method.

The first version of these tests was VACUOUS: without the guard, urlopen
raises on the bad scheme, the method degrades, and the return value is the
base scope either way — so asserting the return value proved nothing. They now
assert whether urlopen was REACHED, which is what the guard changes, and carry
the control that an http URL still is attempted — otherwise a guard refusing
everything would pass.

Mutation-checked both directions: removing the guard reds three cases,
refusing every scheme reds the control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(tools): name the reason urlparse actually refuses a bare host:port

The comment said urlparse gives `orchestrator:8000/mcp` an empty scheme. It
reads `orchestrator` as the scheme, so the case is refused for the same reason
ftp:// is. The assertion was right and its stated mechanism was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… subtree (#432)

The walrus collector swept the entire comprehension with `ast.walk`, so one
written inside a NESTED lambda was attributed to the enclosing function and
reddened that function's clean list argv:

    def f(x):
        cmd = ["docker", "ps"]
        fns = [lambda: (cmd := f"rm {x}") for _ in range(1)]
        subprocess.run(cmd)          # flagged, and clean

PEP 572 binds a walrus in the scope that contains it — inside a lambda that is
the lambda, not the function. The collector now recurses through direct
children and stops at every nested scope, the same pruning the nested-def fix
applied. A walrus written in the comprehension itself still binds outward and
is still caught, in both list and generator forms.

Latent rather than live: the harness contains no walrus today, so the tree was
0-hit either way. It is the same reds-on-the-safe-form failure the earlier
scoping work drove out.

Mutation-checked: restoring the subtree walk reds 14 cases. The first probe of
this was invalid — it failed to parse, so its green measured a build error
rather than the guard.

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…back-only (#433)

* fix(fleet): waive the visualiser's plain-HTTP listener, which is loopback-only

`SAST — semgrep` blocks on one finding: `use-tls` against the g7 visualiser's
`http.ListenAndServe`. The rule cannot see the deployment, and the deployment
is what makes it safe — the compose service publishes the port as
`127.0.0.1:8099:8099`, so the listener is reachable from the host loopback
alone, never from the fleet network and never off-box.

It serves a read-only visualiser over data the operator already has locally,
and terminating TLS on a loopback port means shipping a cert nobody can
validate. The waiver states that reason at the line, and says what would
invalidate it: the published port losing its 127.0.0.1 prefix.

Per-line, not a rule disable — a second plain listener added later fires fresh.
Verified: the finding goes 1 -> 0 with the waiver and returns when it is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fleet): say what the loopback port actually fronts

The waiver called this a read-only visualiser. It is not: the handler POSTs to
the gateway over mTLS with a client cert from /pki, so the plain-HTTP port is
an unauthenticated front to an authenticated channel.

That does not make the waiver false — loopback is still loopback, and reaching
the port already means host access — but the reason a reader checks has to be
the true one. The comment now states it, and names the second condition that
would invalidate it: a route here that is not safe for whoever holds a shell on
the host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yambr

Yambr commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

All eleven gates are green; mergeStateStatus is CLEAN. The three that were red are fixed and each landed as its own reviewed PR against this branch:

was red fixed by what it was
SAST — semgrep #430, #433 13 tainted-env-args findings in the journeys harness, and one use-tls on the visualiser's loopback listener
SCA — trivy (filesystem) #431 two fixable HIGH in cryptography 48.0.1
Trivy #431 same package

Also merged into this branch while clearing them: #429 (orchestrator URL scheme guard, both urlopen paths) and #432 (walrus scope boundary in the meta-guard).

Two corrections worth carrying forward, because both were wrong in a PR body before they were right in the code:

  • The scheme guard originally covered only _MCPClient; _resolve_chat_scope_sync builds its own request and was left open — the same file:// hole on the upload preflight. Closed in fix(tools): refuse a non-http orchestrator URL, as the sibling filter does #429, degrading rather than raising to match that path's no-raise contract.
  • The visualiser waiver called the service a read-only visualiser. It POSTs to the gateway over mTLS with a client cert from /pki, so the plain-HTTP loopback port is an unauthenticated front to an authenticated channel. The waiver now states that, and names both conditions that would invalidate it.

Leaving the merge itself to the owner, per the title.

…tend bridge (#434)

* fix(fleet): take the unauthenticated exec surface off the shared frontend bridge

The waiver merged in #433 rested on a false premise. It said the visualiser was
"reachable from the host loopback alone — never from the fleet network". The
`ports:` publish is loopback, but the service sat on `ocu-frontend`, a plain
bridge it shared with open-webui, webui, embed-portal, admin and mcp-gateway. A
published port says nothing about in-network callers, so every one of those
could reach it.

What they could reach matters: `/api/create`, `/api/exec`, `/api/tool` and
`/api/destroy` take NO inbound credential, and the process holds the gateway
mTLS client cert. Any co-tenant could POST an arbitrary argv in cleartext and
have it executed in a live guest under that cert — with open-webui, which
processes untrusted input, among the co-tenants.

g7 now shares a dedicated bridge with control alone, which is the only hop it
needs. The waiver stays, because the rule still cannot see a deployment, but it
now states the containment that is real instead of the one that was not, and
says that the exposure here is authorisation rather than eavesdropping.

`deploy/tests/test_fleet_g7_isolation.py` turns all of it into properties: the
loopback prefix, no shared bridge with the web tier, and — so the isolation
cannot pass by breaking the service — that g7 still shares a network with
control. Mutation-checked: each of the three reds on its own violation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(fleet): allowlist g7's co-tenants instead of denylisting the web tier

A hard-coded list of web-tier names passes silently the moment a service is
renamed or a new one is added — the assertion would still be green while the
containment was gone. The test now derives the actual co-tenant set from the
compose file and requires it to be exactly `{control}`, so any unlisted service
joining that bridge reds and is named in the failure.

Mutation-checked: adding an arbitrary service to `ocu-g7` reds and prints which
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yambr

Yambr commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my earlier comment on this PR.

It listed #433 as fixing the SAST — semgrep red. That waiver was merged on a false premise and has now been corrected by #434.

The waiver claimed the visualiser was "reachable from the host loopback alone — never from the fleet network." The publish is loopback, but ports: governs host exposure, not in-network reachability: the service sat on ocu-frontend, a plain bridge shared with open-webui, webui, embed-portal, admin and mcp-gateway.

What was reachable that way: /api/create, /api/exec, /api/tool, /api/destroy — no inbound credential of any kind, while the process holds the gateway mTLS client cert. Any co-tenant could POST an arbitrary argv in cleartext and have it executed in a live guest under that cert. open-webui processes untrusted input and was one of them.

#434 moves g7 to a dedicated bridge with control alone, replaces the waiver's reason with the containment that is real, and ships deploy/tests/test_fleet_g7_isolation.py — the loopback prefix, an allowlist of co-tenants, and that g7 still reaches control, so isolation cannot pass by breaking the service. Each is mutation-checked against its own violation.

Still open and out of scope for these corrections: /api/* has no inbound authentication. Network isolation contains it for this deployment; it is not a substitute for a credential.

Merge remains the owner's, per the title.

Yambr and others added 6 commits August 12, 2026 21:02
#438)

* test(journeys): red-probe every egress channel out of the render frame

Group M proves the render works. Nothing proved it leaks nothing, and those are
independent claims — a frame that renders all thirteen formats and quietly ships
bytes to an attacker passes every M test.

Ten channels, each attempted for real inside the frame: fetch, XHR, sendBeacon,
image beacon, form post, window.open, `<base href>` injection, same-frame
self-navigation, Worker, prefetch. They do not fall to one directive: `base-uri`
and `form-action` never inherit from `default-src`, and a self-navigation is not
a fetch at all, so no fetch directive touches it.

Each probe asserts on what an attacker-controlled sink SAW, not on whether the
JS threw. A fetch rejects for a blocked request and an unreachable host alike;
only the sink separates "policy refused it" from "it never got there".

Two anti-vacuity controls, because a suite that reports "no leak" for a channel
it never exercised is worse than no suite. n0 proves the sink records at all —
without it a mis-bound sink reads as ten clean channels. n2 drives every channel
from an unpoliced about:blank and reds on any that cannot reach the sink even
there, which is what catches a payload that silently stopped working.

jsdom was measured and rejected as the venue: a blocked fetch and an unreachable
host are the same `TypeError: fetch failed`, and the result is byte-identical
with a `default-src 'none'` meta present and absent. Probes there would be green
against no policy at all, so these run in real Chromium under the existing
OCU_BROWSER_E2E gate, where gate-set-but-no-chromium fails rather than skips.

The sink binds without a reverse lookup. `HTTPServer.server_bind` calls
`getfqdn()`, which cost 35s per sink on this host — measured, and enough for
every probe to time out and report a leak-free channel it never reached.

Mutation-checked: a sink that stops recording reds n0, and so does a marker
match that always returns False.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): the self-navigation probe destroys its own context by design

`location.href = attacker` is the attempt, and it tears down the execution
context Playwright is evaluating in. `evaluate` then raises, which failed the
whole run instead of recording one channel's outcome — and in n2 it would have
aborted the loop before the channels that follow it were ever exercised.

The raise carries no information here: the verdict comes from what the sink saw,
not from whether the call returned. `_attempt` swallows exactly the
context-destroyed message and re-raises everything else, so a typo in a payload
or a dead browser still fails loudly rather than reading as a clean channel.

Verified by construction: a navigation error is swallowed, a ReferenceError in
the payload and a closed-browser error both re-raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): probe the render frame, not the pane that hosts it

`_render_frame` matched any frame whose URL contained the pane's host, which
today is the pane itself — the SPA origin. The render frame does not exist yet;
it arrives with the ADR-0026 substrate. So every probe would have run in the SPA
origin and asserted the sandbox is closed while measuring a different context
entirely.

The failure mode is the dangerous direction: the SPA origin may well refuse
these channels under its own policy, so the suite would have gone green without
a sandbox existing at all. That is the exact shape of green this file was
written to prevent, in the file itself.

The lookup now descends to a CHILD of the pane and matches the renderer-document
route (or about:srcdoc / blob:, depending on how it ends up served). When no
such child exists the probe FAILS and prints the frames it did see, rather than
passing on the wrong context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): n2 reports what the sink recorded, not only what it missed

A dead-channel list names which payloads failed; it does not say why. The sink's
actual hits distinguish 'the payload never fired' from 'it fired at the wrong
URL and the marker did not match' — two different fixes, and the second reads as
the first without this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): probe the read side of the claim, not only the send side

ADR-0026 says the frame "cannot read a cookie, a token, another artifact, or
anything in the embedder". That is a different property from "cannot send", and
nothing was checking it either — a frame with a cookie jar and a same-origin
handle on its parent passes every egress probe in this file.

Eight surfaces: document.cookie, localStorage, sessionStorage, indexedDB, the
parent's DOM, parent location, top location, and the frame's own origin.

The origin probe is the load-bearing one. It asserts the frame reports
`origin === "null"`, because an opaque origin is what closes all seven others.
If someone adds `allow-same-origin`, every probe in this file quietly starts
measuring an ordinary same-origin iframe instead of a sandbox — and the seven
read probes would go RED there, but the ten egress probes would not, since a
same-origin frame under the same CSP still cannot fetch. That single assertion
is what keeps the rest honest.

Absence of the render frame FAILS rather than passing: in the SPA origin every
one of these reads succeeds by design, so a probe that landed there would report
the opposite of the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): the control cannot run from about:blank, and says what it misses

Three fixes from an adversarial pass that ran the payloads in a real Chromium.

The control was inoperative. n2 fired every channel from `about:blank` — a null
origin in an insecure context — at a sink on 127.0.0.1, and Chromium's Private
Network Access refuses that hop wholesale:

  blocked by CORS policy: The request client is not a secure context and the
  resource is in more-private address space `loopback`

So every channel landed in `dead[]` and the sole anti-vacuity control failed for
a reason unrelated to the payloads. The sink now serves a `/__control__` page,
and the control runs from that real http origin at the same address-space
privilege. From there all ten payloads were confirmed live — including the three
I doubted: an input-less form does POST, prefetch does fire headless, and a
blob: Worker constructs and its inner fetch lands.

Fixed sleeps are gone. Both families now poll the sink against one shared
budget: a hit ends the wait immediately, absence is only declared at the
deadline, and a channel is never given less time to leak than the control gave
it to prove it can.

The suite now states what it does NOT test. `frame.evaluate` runs in a CDP
isolated world, where code executes even in a frame whose `script-src 'none'`
would stop a page script starting. These probes measure the network layer given
running code; they do not exercise the earlier leg. A frame that lost that leg
entirely would still pass every probe here, and the docstring says so rather
than letting a green read as the whole invariant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): probe the leg the isolated world cannot reach

I claimed testing "a page script cannot start" needed the render substrate and
documented it as out of reach. That was wrong: the isolation primitives
reproduce standalone, and an adversarial pass built and ran the probe to show
it. The script arrives as BODY CONTENT the way a hostile artifact would, in a
sandboxed srcdoc frame with no `allow-scripts`, and the browser refuses to
execute it:

  Blocked script execution in 'about:srcdoc' because the document's frame is
  sandboxed and the 'allow-scripts' permission is not set.

n1 cannot see this leg at all — `frame.evaluate` runs in a CDP isolated world,
so it executes where a page script could not start, and n1 therefore measures
the network layer given running code.

The control is the same body in an UNSANDBOXED frame, which must leak. Without
it a green could equally mean the payload never worked, the sink was deaf, or
srcdoc escaping mangled the script — none distinguishable from "the sandbox
held".

What this still does not cover, and the code says so: the product's own wiring.
If the shipped frame carries the CSP or the sandbox attribute wrong, only a
probe against that frame catches it. This closes "the primitives block
script-execution egress"; n1's frame lookup closes "our frame is built from
those primitives".

Also waives the semgrep `dynamic-urllib-use-detected` finding at the control's
urlopen, per-line: the URL is built from a port this process just bound on
127.0.0.1, and the file:// hazard the rule warns about needs an
attacker-supplied scheme, for which there is no path here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er it (#440)

* test(journeys): self-navigation contaminates every channel probed after it

Found by running the channels against a live render route rather than reasoning
about them. In declaration order the suite reported worker and prefetch as
LEAKED. They do not leak: a successful self-navigation moves the frame to the
attacker's own origin, where no policy applies, so every channel after it runs
in THAT document and reaches the sink trivially.

Re-run with self-navigation last: 9 of 10 blocked, and the console shows CSP
refusing worker and prefetch by name. Only self-navigation reaches the sink,
which is the residual ADR-0026 accepts and states.

This is a false POSITIVE, the rarer direction and the more confusing one — the
suite reported a breach that was its own probe order. A reader would have gone
looking for a hole in the policy that was never there.

Both families now order the channels the same way, so n1 and n2 cannot disagree
about which ones are live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): the base-href channel left its <base> in the document

Ordering self-navigation last fixed one contaminant and left another. The
base-href channel appends a <base> element and never removes it, so every
relative URL built afterwards resolves against the attacker's sink instead of
the document.

Measured before the fix: the element survives the probe, and
`new URL('x', document.baseURI)` resolves to `http://sink/base-.../x`. After:
zero elements left, and the same expression resolves against the document again.

No current payload is relative, so no verdict was wrong today. The removal is
what keeps that true when the first relative payload is added — at which point
the failure would read as a leak rather than as this channel's residue, which is
the same phantom-red the self-navigation ordering just cost a debugging round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): the cookie probe scored the regression it exists to catch backwards

`("document.cookie", "document.cookie")` was the only read entry without a
try/catch. In an opaque origin `document.cookie` RAISES SecurityError rather
than returning "", so the bare expression made `frame.evaluate` throw and the
probe ERROR on a correct frame — the assertion body never ran.

And on a frame carrying `allow-same-origin` — the exact regression this file
exists to catch — cookie access succeeds, the probe got a string, and it PASSED.
Backwards in both directions at once.

Wrapped like every other entry. Verified against a real http origin with a
cookie set, because a srcdoc frame denies cookies either way and would have
shown both cases passing:

  correct (allow-scripts)          -> 'THREW:SecurityError'  PASS
  defective (+allow-same-origin)   -> 'READ:probe=1'         FAIL

The `own origin` probe independently catches the same regression, so the suite
was not blind — but this probe was worse than absent, since an erroring test
reads as infrastructure trouble rather than as a defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(journeys): n4 proved a primitive production does not rely on

The probe blocked script with `sandbox=""` — no allow-scripts at all. Production
keeps allow-scripts, because the renderer needs it, and stops a hostile body's
script with the BODY's own `script-src 'none'`. So n4 certified a stricter
mechanism than the one shipped, and would have stayed green if the shipped body
lost its CSP — precisely the misconfiguration that lets an artifact's script
run.

Both cases now carry `allow-scripts`; the blocked case adds the body policy and
the control omits it. Mutation-checked: dropping the body CSP reds the blocked
case, so the probe is bound to the control that actually holds.

The n1 failure message also stopped misdirecting. `form post` and `window.open`
are blocked by the sandbox token list, not by CSP — measured, they stay blocked
with form-action removed — while the fetch-class channels are blocked by the
renderer policy. The message named CSP for all of them, which would send a
reader hunting through directives for a channel no directive governs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…441)

* ci(journeys): run the browser probes, so their green stops decaying

`OCU_BROWSER_E2E` was set nowhere — no workflow, no stand script. So the twenty-
two frame-egress probes only ever ran when I ran them by hand, and "proven by
running probes" was decaying into "proven the day it merged".

Twelve of them need a live stand (a pane, a portal, a render frame) and stay
opt-in. Four do not: the sink control, the unpoliced-channel control, and both
halves of the script-execution probe reproduce the isolation primitives
standalone. Measured — they pass on a bare runner with only chromium.

The gate is SET in this job on purpose. `_require_browser` fails rather than
skips when the gate is set and chromium is missing, so a broken install cannot
read as a pass.

The vacuity guard is an equality, not a floor, and it is the same failure this
file already guards for the journeys: a suite where everything skipped renders
green. Four is the measured count; a floor would hide a probe silently dropping
out of the selection, which is the regression worth catching. Mutation-checked
against a real junit report: with the gate unset, 3 of 4 skip and the guard
reds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(journeys): the comment misstated the gate's own verification properties

Fable's ruling, and he was right on both counts. The comment claimed the
stand-bound probes "are not deselected silently: they SKIP with their own
reason, visible in -rs". Measured: `-k` DESELECTS them, deselected tests never
reach `-rs` and never enter the junit, and `-rs` reports zero SKIPPED lines
here. It also said twelve; the arithmetic is 22 - 4 = eighteen test ids.

A comment that misstates a gate's verification properties is exactly the decay
this job exists to stop, so it says the true thing now: the eighteen are
deselected, their absence is detectable only through the equality guard, and
that guard is the load-bearing half of the job. Also states plainly that this
covers 4 of 22 and is not "the N group covered".

Two follow-ons he named, both cheap enough to do here rather than queue:

A weekly schedule plus workflow_dispatch. Chromium installs at run time, so the
probes can rot from upstream drift with no repo change — and that rot would
surface as a red on some unrelated deploy PR. Per-PR alone was still "proven the
day it merged", only with a longer day.

Pinned playwright and pytest. A floating toolchain changes what the probe MEANS
between runs. Both pins are the versions the probes were actually measured on
(1.62.0 / 9.1.1) — I first wrote 1.58.0 and 8.3.4 from memory, which would have
pinned a contract I never ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(journeys): install the suite's own requirements before the probes

The job died at collection with ModuleNotFoundError: no module named 'yaml' —
the journeys conftest imports it, and I had installed only playwright and
pytest. My local venv already had it, which is the whole reason the CI run is
the one that counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nick <developer@widemoat.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yambr

Yambr commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Refreshed again. The branch was 17 commits behind next/v1 — everything merged since 11 August — and is now level. All eleven gates green, mergeStateStatus: CLEAN.

What landed on next/v1 under it since the last note here, because it changes what this PR sits on rather than what it contains:

area change
enforcement NFR-SEC-89 now judges all three repositories, not just this one (#451); the applier writes a ruleset as well as classic protection (#452), and requires nfr-gates (#469)
measurement an NFR-coverage ratchet at 11 of 190 with the blocked remainder recorded per-row (#453, #468), and it can no longer be lowered silently (#470)
new gates pin policy (#455), persist-credentials (#456), compliance front-matter (#458), schema closure (#463), vendor-SDK imports (#467)
meta every checker now fails its own self-test when stubbed — seven of seven, proven rather than assumed (#481)
hygiene the NFR gates moved out of the AsyncAPI job, where a network flake would have skipped all ten silently (#459)

None of that touches docs/architecture/ or contracts/, so the canon statement in the description still holds.

Still owner-gated and still draft — I have not changed either, and I am not asking for that to change here. This note exists so the decision is made against the current base rather than the 11 August one.

One thing worth knowing before a merge: open-computer-use remains the only repository of the three with no branch protection (issue #408, admin=false for me). ocu-sandbox and ocu-webui both report NFR-SEC-89 holds; this repo reports VIOLATION. Whatever lands here, lands unprotected until that flip happens.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants