feat(deploy): L5 journey suite, fleet substrate, CI gates (owner-gated merge) - #346
feat(deploy): L5 journey suite, fleet substrate, CI gates (owner-gated merge)#346Yambr wants to merge 132 commits into
Conversation
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
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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
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>
|
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: Ran semgrep with the exact CI rule set — 14 code findings. They split into two groups that need different answers:
The production one is worth reading before deciding: 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. |
… 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>
|
All eleven gates are green;
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:
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>
|
Correction to my earlier comment on this PR. It listed #433 as fixing the The waiver claimed the visualiser was "reachable from the host loopback alone — never from the fleet network." The publish is loopback, but What was reachable that way: #434 moves g7 to a dedicated bridge with Still open and out of scope for these corrections: Merge remains the owner's, per the title. |
#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>
|
Refreshed again. The branch was 17 commits behind What landed on
None of that touches 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: |
Forked from next/v1 at a6b48bd. No changes under
docs/architecture/orcontracts/— canon untouched (the three-dot PR diff under those paths is empty; agit merge-treeagainst 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, followdeploy/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/contractslint-path gap.Draft: merge is owner-gated.
🤖 Generated with Claude Code