widgets: W5 fix round 1 — wasm by magic, CORS on module refusals, host-owned prefixes

Closes the accepted findings of the Phase 5 backend review (ledger 8.6).

W5-7 / W5-11 (codex, scope): `_read_skill_file` decoded UTF-8 first and
chose text-vs-descriptor from that, so a WebAssembly module whose bytes
happen to decode — the canonical 8-byte module, or a functional module
built only of ASCII bytes — entered the review pack as text, contradicting
"the reviewer does not read the WebAssembly code". The descriptor route is
now chosen by the magic: content starting with `WASM_MAGIC` (`\x00asm`, a
new SSOT constant beside `_EXECUTABLE_MAGICS`) becomes a
`binary_file_descriptor` after the native-magic hard-block check and
before the text branch. The pack label reads "binary file — descriptor
only" because "non-UTF-8 file" is no longer true of every descriptor.
skill_review.py stays at exactly 1600 lines (two comments compacted pay
for the import and docstring lines). Tests: the canonical module and the
validated all-ASCII `(func (export "f") (result i32) i32.const 42)`
module both yield descriptors and never appear inline; an ELF renamed
`.wasm` still hard-blocks; the existing non-UTF-8 wasm test stays.

W5-9 / W5-14 (grok, scope): the module endpoint's 400/404/409 refusals
now carry `Cache-Control: no-store` and `Access-Control-Allow-Origin: *`
like a 200, so an opaque-origin `import()` reads the 4xx instead of an
unreadable CORS failure. Local `refuse` helper; `json_error` is unchanged
for every other caller. The widgets test asserts the headers on every
refusal, and pins that a root dot-file (`.hidden.js`) is not served.

W5-1 (fable): CREATING_SKILLS' own-route recipe and assets section state
the out-of-process cap — a child handler's body is capped at 512 KiB
(`_RESULT_CAP`), so a larger module or media asset needs an in-process
skill or the runtime-download path.

W5-2 / W5-8 / W5-12 (fable, codex, grok, scope): the host-owned GET/HEAD
prefixes under `/api/extensions/<skill>/` — `manifest`, `module/…` (any
depth), `settings_section` — are documented for authors in CREATING_SKILLS
(PluginAPI example comment and "Loading more than one file") and in the
`register_route` docstring of contracts/plugin_api.py (docstring only;
PLUGIN_API_VERSION and code untouched). No registration guard: zero
installed or bundled skills register there (disclose, do not forbid).

W5-4 (fable, grok): "dot-directories" → "dot-prefixed paths (directories
and files)" in CREATING_SKILLS, both ARCHITECTURE mentions, and the
endpoint docstring; ARCHITECTURE also records the headers on refusals.

W5-13 (scope; protected docs/CHECKLISTS.md, pre-disclosed): one hunk in
the loader-magic paragraph adds the two clarifying clauses — a `.wasm`
module instantiated by widget JavaScript inside the browser-sandboxed
frame is the deliberate exception to "flag code that loads a
descriptor-only file" (it never runs in the host process), and the
"future out-of-process / WASM sandbox" means a host-side WebAssembly
runtime, a separate future topic. Nothing else in the file changed.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-02 19:29:00 +03:00
parent afe1fc4fee
commit 4bd148a3c2
9 changed files with 127 additions and 55 deletions

View file

@ -807,7 +807,7 @@ Widgets is a separate page because extension UI is an execution surface, not cat
Declarative widgets support forms and actions, status/data/text/code/markdown, tables, tabs, charts, polls, jobs, streams, subscriptions, progress, media, files, maps, calendars, kanban, and composition through `group`, `metric`, and `callout`. One recursive validator limits the tree to depth 8 and 256 nodes and reports the exact failing path. Nested interactive components use an explicit id or stable tree path as identity; `subscription.render` remains transitively passive so an incoming event cannot smuggle a new active control tree past validation. Text, attributes, links, media routes, and field values are escaped or constrained for their actual sink.
Module widgets receive a narrow parent-mediated fetch bridge. The iframe's policy denies ambient network and origin authority; the parent accepts requests only to the exact owning extension prefix under `/api/extensions/<skill>/...` and returns the response through a nonce-bound message exchange. The host-generated module bootstrap observes the content-sized `#root` edge with `ResizeObserver` plus a load measurement, integer-deduplicates and clamps resize messages, and receives a nonce-bound dispose message that rejects pending child fetch promises and disconnects the observer. Module source loading is also bounded and aborted when a mount becomes stale. The module source endpoint (`/api/extensions/{skill}/module/{path}`) authorizes purely against the live loader registration — no skill discovery on the read path — and serves any reviewed `.js`/`.mjs` file of the skill directory, the declared entry or a sibling such as `lib/x.js`, from the texts captured when the bundle's module tab registered rather than re-reading the skill directory (the bytes served are the bytes the reviewed bundle loaded from; an edit after load is not served until the skill reloads; dependency, cache, and dot directories are never captured, and traversal-shaped paths are refused before any lookup). It answers with `Access-Control-Allow-Origin: *`, because the requesting `srcdoc` frame has an opaque origin and its script fetches are cross-origin and anonymous; the declared entry still executes as a classic script, and sibling files load through `<script src>` or `import()` once the frame CSP admits the skill's module prefix. This preserves useful route I/O without giving reviewed skill JavaScript the SPA's cookies, DOM, or broad API authority. Chart.js is bundled locally, and module/declarative rendering must not depend on a third-party CDN.
Module widgets receive a narrow parent-mediated fetch bridge. The iframe's policy denies ambient network and origin authority; the parent accepts requests only to the exact owning extension prefix under `/api/extensions/<skill>/...` and returns the response through a nonce-bound message exchange. The host-generated module bootstrap observes the content-sized `#root` edge with `ResizeObserver` plus a load measurement, integer-deduplicates and clamps resize messages, and receives a nonce-bound dispose message that rejects pending child fetch promises and disconnects the observer. Module source loading is also bounded and aborted when a mount becomes stale. The module source endpoint (`/api/extensions/{skill}/module/{path}`) authorizes purely against the live loader registration — no skill discovery on the read path — and serves any reviewed `.js`/`.mjs` file of the skill directory, the declared entry or a sibling such as `lib/x.js`, from the texts captured when the bundle's module tab registered rather than re-reading the skill directory (the bytes served are the bytes the reviewed bundle loaded from; an edit after load is not served until the skill reloads; dependency and cache directories and dot-prefixed paths (directories and files) are never captured, and traversal-shaped paths are refused before any lookup). It answers every response, refusals included, with `Access-Control-Allow-Origin: *`, because the requesting `srcdoc` frame has an opaque origin and its script fetches are cross-origin and anonymous (a 4xx without the header would reach `import()` only as an unreadable CORS failure); the declared entry still executes as a classic script, and sibling files load through `<script src>` or `import()` once the frame CSP admits the skill's module prefix. This preserves useful route I/O without giving reviewed skill JavaScript the SPA's cookies, DOM, or broad API authority. Chart.js is bundled locally, and module/declarative rendering must not depend on a third-party CDN.
A mounted widget owns its timers, abort controllers, chart objects, event streams, polls, jobs, and WebSocket message handlers through one disposer. Leaving Widgets or forcing a refresh disposes the mounted work, removes framed iframes, aborts host bridge requests, and ignores late resize/fetch messages; an async mount that finishes after the page generation changed disposes itself instead of registering a hidden frame. A later visit may repaint the last good extension payload and restore bounded widget session state without leaving the hidden copy running. Job polling keeps its `job_id` across bounded retryable transport/408/429/5xx failures and request timeouts, while explicit terminal job states remain terminal. A missing or malformed status envelope fails immediately; a non-empty producer-specific in-progress status remains pending but is still bounded by `max_ticks`. The existing interval and tick bounds remain the scheduler rather than a second polling service. Poll and WebSocket writers use monotonic progress for one job so an older response cannot rewind a newer event. Transient refresh failure preserves the last good widgets instead of blanking the page. Card order is keyboard- and drag-adjustable owner UI state stored through `/api/ui/preferences`, and the owner's per-card launch-policy override (`widget_start_mode`) lives beside it while the author's default is the validated `render.start` (`auto` / `manual` / `retain`, filled per kind by `extension_ui_validation.py`); neither rewrites the extension manifest or changes the review boundary.
@ -876,7 +876,7 @@ Every `/api/files/*` operation resolves its requested path and refuses the opera
| GET | `/api/widgets` | `gateway.widgets.api_widgets` (passive projection of the loader's live UI tabs via `extension_loader.live_widget_projection` — tabs and their owners' revisions read under ONE loader lock; each card carries the owning skill's live payload `content_hash` as `revision` — a change-signature fact for the page, not an ETag; `Cache-Control: no-store`) |
| POST | `/api/skills/{skill}/publish-preflight` | `gateway.skill_publish.api_skill_publish_preflight` |
| GET | `/api/extensions/{skill}/manifest` | `gateway.extensions.api_extension_manifest` |
| GET | `/api/extensions/{skill}/module/{entry:path}` | `gateway.extensions.api_extension_module` (authorizes against the live loader registration only — no skill discovery — and serves any reviewed `.js`/`.mjs` file of the skill directory, keyed by POSIX path relative to it: the declared entry or a sibling such as `lib/x.js`, from the texts `extension_loader.live_module_sources` captured when the bundle's module tab registered, so a file edited afterwards is not served until the skill reloads; dependency, cache, and dot directories are never captured; 400 for a traversal-shaped path (backslash, NUL, an empty/`.`/`..` segment after percent-decoding) or a non-JavaScript suffix, 409 for a skill with no live bundle (including enabled-but-unloaded), 404 for a path the bundle did not capture; answers `Cache-Control: no-store` plus `Access-Control-Allow-Origin: *` because the requesting module frame has an opaque origin and fetches anonymously cross-origin) |
| GET | `/api/extensions/{skill}/module/{entry:path}` | `gateway.extensions.api_extension_module` (authorizes against the live loader registration only — no skill discovery — and serves any reviewed `.js`/`.mjs` file of the skill directory, keyed by POSIX path relative to it: the declared entry or a sibling such as `lib/x.js`, from the texts `extension_loader.live_module_sources` captured when the bundle's module tab registered, so a file edited afterwards is not served until the skill reloads; dependency and cache directories and dot-prefixed paths (directories and files) are never captured; 400 for a traversal-shaped path (backslash, NUL, an empty/`.`/`..` segment after percent-decoding) or a non-JavaScript suffix, 409 for a skill with no live bundle (including enabled-but-unloaded), 404 for a path the bundle did not capture; every response, refusals included, answers `Cache-Control: no-store` plus `Access-Control-Allow-Origin: *` because the requesting module frame has an opaque origin and fetches anonymously cross-origin, and an opaque-origin `import()` can read a 4xx only through CORS) |
| GET | `/api/extensions/{skill}/settings_section` | `gateway.extensions.api_extension_settings_section` |
| ANY | `/api/extensions/{skill}/{rest:path}` | `gateway.extensions.api_extension_dispatch` |
| GET | `/api/skills/daemons` | `gateway.extensions.api_skill_daemons` |

View file

@ -436,14 +436,18 @@ are exact. An opaque blob is only dangerous through loader code the
review CAN see (``ctypes.CDLL`` / ``zipimport`` / ``require`` /
``sys.path`` insertion) — flag any code path that loads or executes a
descriptor-only file, and treat an unexplained opaque blob in an
executable position as a finding. ``skill_exec`` independently refuses
to execute a declared script that is not valid UTF-8 text, so a blob
renamed into the scripts list cannot run. Media-carrying skills should
still prefer fetching binary assets on demand from a reviewable HTTPS
source over vendoring opaque bytes. This content-judged posture is the
current owner decision, revisitable explicitly; a future sandbox
project (out-of-process / WASM) remains the prerequisite for trusting
opaque bytes as EXECUTABLE payload inside the skill tree.
executable position as a finding (the deliberate exception: a `.wasm`
module that widget JavaScript instantiates inside the browser-sandboxed
widget frame, which never runs in the host process). ``skill_exec``
independently refuses to execute a declared script that is not valid
UTF-8 text, so a blob renamed into the scripts list cannot run.
Media-carrying skills should still prefer fetching binary assets on
demand from a reviewable HTTPS source over vendoring opaque bytes. This
content-judged posture is the current owner decision, revisitable
explicitly; a future sandbox project (out-of-process / WASM) remains
the prerequisite for trusting opaque bytes as EXECUTABLE payload inside
the skill tree — the WASM there is a host-side WebAssembly runtime, a
separate future topic from the browser-sandboxed `.wasm` above.
Skills default to **disabled** and cannot be executed by `skill_exec`
until review produces a fresh executable verdict. Skill review output is persisted

View file

@ -677,7 +677,9 @@ def register(api):
timeout_sec=60,
)
# HTTP routes — mounted at /api/extensions/<skill>/<path>.
# HTTP routes — mounted at /api/extensions/<skill>/<path>. GET/HEAD under
# manifest, module/... and settings_section are host-owned (see "Loading
# more than one file").
api.register_route("search", handler=http_search, methods=("POST",))
# WebSocket message handlers (inbound) and broadcasts (outbound).
@ -857,8 +859,11 @@ and the module's provenance instead of its bytes.
Ship and load it through your own route: register a route that returns the
module bytes (an in-process handler may return a Starlette `Response` or
`FileResponse`; an out-of-process handler's body is buffered by the host), then
in the widget:
`FileResponse` of any size; an out-of-process handler's body is buffered by the
host and capped at 512 KiB — `_RESULT_CAP` in
`ouroboros/extension_process_runner.py` — so a larger module needs an in-process
skill or the runtime-download path described under assets below), then in the
widget:
```js
const bytes = await (await OuroborosWidget.fetch('/api/extensions/<skill>/core.wasm')).arrayBuffer();
@ -875,8 +880,9 @@ slices.
#### Assets: fonts, audio, video, images
Widget assets are ordinary payload files and travel the same way as
WebAssembly: your own routes serve them (`register_route` returning the bytes),
the widget references them by `/api/extensions/<skill>/...` URL, and review
WebAssembly: your own routes serve them (`register_route` returning the bytes;
an out-of-process handler answers at most 512 KiB per response, as above), the
widget references them by `/api/extensions/<skill>/...` URL, and review
sees each non-text asset as a content-hash-bound descriptor. The module
endpoint stays JavaScript-only. Hub packages admit `.png .jpg .jpeg .gif .webp
.svg`, `.mp3 .ogg .wav`, `.mp4 .webm`, `.woff .woff2 .ttf .otf`, and `.wasm`.
@ -898,10 +904,14 @@ endpoint, keyed by its path relative to the skill directory:
`GET /api/extensions/<skill>/module/lib/x.js`. The host captures all of them
when the module tab registers (the same moment it reads the entry), so the frame
always receives the bytes the reviewed bundle loaded from; files under
`node_modules`, `.ouroboros_env`, other cache directories, and dot-directories
are never served, and only UTF-8 text is admitted — a non-UTF-8 `.js` fails the
load exactly like a broken entry. Load a sibling either as a classic script or
as an ES module:
`node_modules`, `.ouroboros_env`, other cache directories, and dot-prefixed
paths (directories and files) are never served, and only UTF-8 text is
admitted — a non-UTF-8 `.js` fails the load exactly like a broken entry. The
host owns GET/HEAD under three prefixes of `/api/extensions/<skill>/` —
`manifest`, `module/…` (any depth), and `settings_section` — so do not register
skill routes there: a route registered under them is shadowed for GET/HEAD,
while POST and the other methods are unaffected. Load a sibling either as a
classic script or as an ES module:
```html
<script src="/api/extensions/<skill>/module/lib/x.js"></script>

View file

@ -124,7 +124,13 @@ class PluginAPI(Protocol):
*,
methods: Sequence[str] = ("GET",),
) -> None:
"""Register ``/api/extensions/<skill>/<path>`` for allowed methods."""
"""Register ``/api/extensions/<skill>/<path>`` for allowed methods.
The host owns GET/HEAD under three prefixes of that namespace —
``manifest``, ``module/...`` (any depth) and ``settings_section`` — so
a skill route registered there is shadowed for GET/HEAD (POST and the
other methods still reach the skill); register under another path.
"""
...
def register_ws_handler(

View file

@ -528,16 +528,22 @@ async def api_extension_module(request: Request) -> Response:
(``lib/x.js``). Authorization and content are one loader read under one
lock: 409 when the skill has no live bundle; 404 when the path is not among
the files captured when its module tab registered (dependency, cache, and
dot directories are never captured); 400 for a path with a backslash or
dot-prefixed paths are never captured); 400 for a path with a backslash or
NUL, an empty/``.``/``..`` segment (the ASGI server already decoded
``%2e%2e`` and ``%2F``), or a non-``.js``/``.mjs`` suffix. The body is the
text captured at load — no per-request disk read, so an edit after load is
not served until the skill reloads (DEVELOPMENT "Passive GET"). The
requesting ``srcdoc`` frame has an opaque origin and fetches anonymously
cross-origin, hence ``Access-Control-Allow-Origin: *`` (no credentials).
cross-origin, hence ``Access-Control-Allow-Origin: *`` (no credentials) on
every response, refusals included — else ``import()`` sees a CORS failure.
"""
from ouroboros.extension_loader import live_module_sources
headers = {"Cache-Control": "no-store", "Access-Control-Allow-Origin": "*"}
def refuse(message: str, status: int) -> Response:
return JSONResponse({"error": message}, status_code=status, headers=headers)
skill_name = str(request.path_params.get("skill") or "").strip()
path = str(request.path_params.get("entry") or "")
if (
@ -545,18 +551,14 @@ async def api_extension_module(request: Request) -> Response:
or any(part in {"", ".", ".."} for part in path.split("/"))
or not path.endswith((".js", ".mjs"))
):
return json_error("invalid module path", 400)
return refuse("invalid module path", 400)
sources = live_module_sources(skill_name)
if sources is None:
return json_error(f"extension {skill_name!r} not live", 409)
return refuse(f"extension {skill_name!r} not live", 409)
source = sources.get(path)
if source is None:
return json_error("module path is not a reviewed JavaScript file of a live widget", 404)
return Response(
source,
media_type="application/javascript; charset=utf-8",
headers={"Cache-Control": "no-store", "Access-Control-Allow-Origin": "*"},
)
return refuse("module path is not a reviewed JavaScript file of a live widget", 404)
return Response(source, media_type="application/javascript; charset=utf-8", headers=headers)
async def api_extension_settings_section(request: Request) -> JSONResponse:

View file

@ -16,6 +16,7 @@ from typing import Any, Dict, List, Optional
from ouroboros.config import adaptive_quorum, get_auto_grant_enabled
from ouroboros.reviewer_slot_config import commit_triad_delivery, reviewer_slot_config_error
from ouroboros.skill_review_passes import (
WASM_MAGIC,
SkillBinaryPayload as _SkillBinaryPayload,
binary_file_descriptor,
executable_magic_kind,
@ -67,12 +68,11 @@ from ouroboros.utils import (
)
log = logging.getLogger(__name__)
# The reviewable skill payload is bound by ONE pack-level token budget (reusing the
# review stack's SSOT REVIEW_PROMPT_TOKEN_BUDGET) instead of arbitrary per-file /
# file-count BYTE caps: a 76 KB data file or a 41-file skill is fully reviewable when
# the whole pack fits a 1M-context reviewer. Loadable executables / unreadable files
# are still refused (safety, not size). Headroom reserves the rest of the reviewer
# prompt (governance docs + checklist + framing) so the SKILL pack alone is bounded.
# The reviewable skill payload is bound by ONE pack-level token budget (the review
# stack's SSOT REVIEW_PROMPT_TOKEN_BUDGET), not per-file / file-count BYTE caps: a 76 KB
# data file or a 41-file skill is fully reviewable when the whole pack fits a 1M-context
# reviewer. Loadable executables / unreadable files are still refused (safety, not size).
# Headroom reserves the rest of the reviewer prompt (governance docs + checklist + framing).
_SKILL_PACK_TOKEN_HEADROOM = 120_000
def _skill_pack_token_budget() -> int:
@ -82,11 +82,10 @@ def _skill_pack_token_budget() -> int:
_SKILL_CHECKLIST_SECTION = "Skill Review Checklist"
# Lexical download filter retained ONLY for the marketplace fetcher's coarse
# pre-gate (ouroboros/marketplace/fetcher.py). Skill REVIEW itself judges file
# CONTENT — loader magic bytes, see ``skill_review_passes.executable_magic_kind``
# — never filenames (X4/В21): a renamed ELF is still blocked, while a text file
# with a scary extension stays reviewable.
# Lexical download filter retained ONLY for the marketplace fetcher's coarse pre-gate
# (ouroboros/marketplace/fetcher.py). Skill REVIEW itself judges file CONTENT — loader
# magic bytes, see ``skill_review_passes.executable_magic_kind`` — never filenames
# (X4/В21): a renamed ELF is still blocked; a text file with a scary extension stays reviewable.
_LOADABLE_BINARY_EXTENSIONS = frozenset(
{".so", ".dylib", ".dll", ".pyc", ".pyo", ".node", ".exe", ".bin"}
)
@ -204,7 +203,8 @@ def _read_skill_file(
) -> tuple[Optional[str], bytes, Optional[Dict[str, Any]]]:
"""Read one skill file: ``(text, sha256_digest, descriptor)`` — exactly one set.
Loadable executables (CONTENT magic bytes, never filename) hard-block review;
other non-UTF-8 files yield a typed descriptor instead of raw bytes."""
WebAssembly (``WASM_MAGIC``, even when its bytes decode as UTF-8) and other
non-UTF-8 files yield a typed descriptor instead of raw bytes."""
try:
data = path.read_bytes()
except OSError as exc:
@ -219,7 +219,7 @@ def _read_skill_file(
if kind:
raise _SkillBinaryPayload(rel, len(data), kind)
digest = hashlib.sha256(data).digest()
if text is not None:
if text is not None and not data.startswith(WASM_MAGIC):
return text, digest, None
return None, digest, binary_file_descriptor(rel, data, filename=path.name)
@ -266,7 +266,7 @@ def _build_skill_file_packs(
file_digests.append((rel, file_digest))
if descriptor is not None: # typed descriptor, never raw non-UTF-8 bytes
body = json.dumps(descriptor, indent=2, sort_keys=True)
rel_head = f"{rel} (non-UTF-8 file — descriptor only, content not inlined)"
rel_head = f"{rel} (binary file — descriptor only, content not inlined)"
block = f"### {rel_head}\n\n```json\n{body}\n```"
else:
block = f"### {rel}\n\n```\n{body}\n```"

View file

@ -19,10 +19,12 @@ from typing import Any, Callable, Dict, List, Tuple
# LLMs: files whose CONTENT starts with a loader magic number are hard-blocked
# from the skill payload surface — judged by magic bytes, never by filename.
# These prefixes are unambiguous (never legitimate text), so they block
# regardless of UTF-8 validity. WebAssembly (``\x00asm``) is deliberately NOT
# regardless of UTF-8 validity. WebAssembly (``WASM_MAGIC``) is deliberately NOT
# here: the host never loads it natively — it runs only inside the browser's
# sandboxed widget frame — so a ``.wasm`` file is admitted like any other
# non-UTF-8 asset, as a content-hash-bound descriptor the reviewer sees.
# sandboxed widget frame — so a module is admitted as a content-hash-bound
# descriptor the reviewer sees, routed there by its magic even when its bytes
# happen to decode as UTF-8 (the canonical 8-byte module does).
WASM_MAGIC = b"\x00asm"
_EXECUTABLE_MAGICS: Tuple[Tuple[bytes, str], ...] = (
(b"\x7fELF", "ELF executable / shared object"),
(b"\xfe\xed\xfa\xce", "Mach-O executable (32-bit)"),

View file

@ -129,6 +129,7 @@ def test_api_extension_module_serves_reviewed_js_files_without_discovery(tmp_pat
"lib/y.mjs": "export const y = 2;\n",
"node_modules/dep/index.js": "module.exports = 1;\n", # review-opaque: never captured
".hidden/h.js": "window.__hidden = 1;\n", # dot directory: never captured
".hidden.js": "window.__dotfile = 1;\n", # dot-prefixed file: never captured
"notes.txt": "not javascript\n",
}
for rel, body in files.items():
@ -172,21 +173,28 @@ def test_api_extension_module_serves_reviewed_js_files_without_discovery(tmp_pat
# The sources live on the loader bundle, never in a browser-facing projection.
assert "window.__" not in json.dumps(extension_loader.snapshot())
assert "window.__" not in json.dumps(client.get("/api/widgets").json())
# Not captured: review-opaque and dot directories, files the payload lacks.
for rel in ("node_modules/dep/index.js", ".hidden/h.js", "missing.js", "lib/missing.mjs"):
assert get(rel).status_code == 404, rel
# A refusal carries the same no-store/ACAO headers as a 200: the opaque-origin
# frame's ``import()`` then reads the 4xx instead of an unreadable CORS failure.
def refused(response, status: int, label: str) -> None:
assert response.status_code == status, (label, response.status_code, response.text)
assert response.headers["cache-control"] == "no-store", label
assert response.headers["access-control-allow-origin"] == "*", label
# Not captured: review-opaque and dot-prefixed paths, files the payload lacks.
for rel in ("node_modules/dep/index.js", ".hidden/h.js", ".hidden.js", "missing.js", "lib/missing.mjs"):
refused(get(rel), 404, rel)
# Shape-rejected before any lookup; percent-escapes arrive decoded, so an
# encoded traversal is the same ``..`` segment as a literal one.
for rel in (
"%2e%2e/widget.js", "..%2Fwidget.js", "lib%2F..%2Fwidget.js", "%2e/widget.js", "lib%5Cx.js",
"%2Flib/x.js", "lib//x.js", "%00.js", "widget.js%00", "notes.txt", "plugin.py", "lib/", "",
):
assert get(rel).status_code == 400, rel
refused(get(rel), 400, rel)
# Cross-skill and unloaded: a live skill never serves another's files.
assert client.get("/api/extensions/ext_other/module/widget.js").status_code == 404
assert client.get("/api/extensions/nope/module/widget.js").status_code == 409
refused(client.get("/api/extensions/ext_other/module/widget.js"), 404, "ext_other")
refused(client.get("/api/extensions/nope/module/widget.js"), 409, "nope")
extension_loader.unload_extension("ext_module")
assert get("widget.js").status_code == 409
refused(get("widget.js"), 409, "unloaded")
assert all(count == 0 for count in calls.values()), calls

View file

@ -1243,7 +1243,7 @@ def test_skill_review_admits_wasm_as_content_hash_bound_descriptor(tmp_path):
assert descriptor is not None and descriptor.pop("mime_from_name")
assert descriptor == {"path": "core.wasm", "size": len(wasm), "sha256": _hashlib.sha256(wasm).hexdigest()}
joined = "\n".join(_build_skill_file_packs(skill_dir))
assert "core.wasm (non-UTF-8 file — descriptor only" in joined
assert "core.wasm (binary file — descriptor only" in joined
assert _hashlib.sha256(wasm).hexdigest() in joined
# Content-hash-bound: one changed byte is a different payload (the stored
# review goes stale), exactly like every other payload file.
@ -1252,6 +1252,46 @@ def test_skill_review_admits_wasm_as_content_hash_bound_descriptor(tmp_path):
assert compute_content_hash(skill_dir) != before
def test_skill_review_routes_utf8_decodable_wasm_to_descriptor(tmp_path):
"""W5-7/W5-11: the descriptor route is chosen by the WebAssembly magic, not by a
failed UTF-8 decode. The canonical 8-byte module and a functional module made
only of ASCII bytes both decode as UTF-8, yet neither may be inlined as text —
"the reviewer does not read the WebAssembly code" must hold for every module.
A native loader magic renamed ``.wasm`` still hard-blocks by content."""
import hashlib as _hashlib
from ouroboros.skill_review import _build_skill_file_packs, _read_skill_file, _SkillBinaryPayload
from ouroboros.skill_review_passes import WASM_MAGIC
skill_dir = tmp_path / "skills" / "wasmtext"
skill_dir.mkdir(parents=True)
(skill_dir / "skill.json").write_text('{"name": "wasmtext"}', encoding="utf-8")
canonical = WASM_MAGIC + b"\x01\x00\x00\x00"
# (func (export "f") (result i32) i32.const 42) — type, function, export and code
# sections; every byte < 0x80 (validated with WebAssembly.validate, f() == 42).
functional = (
canonical
+ b"\x01\x05\x01\x60\x00\x01\x7f"
+ b"\x03\x02\x01\x00"
+ b"\x07\x05\x01\x01f\x00\x00"
+ b"\x0a\x06\x01\x04\x00\x41\x2a\x0b"
)
for name, module in (("empty.wasm", canonical), ("answer.wasm", functional)):
module.decode("utf-8") # the precondition the decode-first branch mistook for text
(skill_dir / name).write_bytes(module)
text, digest, descriptor = _read_skill_file(skill_dir / name, relpath=name)
assert text is None and digest == _hashlib.sha256(module).digest(), name
assert descriptor is not None and descriptor["sha256"] == _hashlib.sha256(module).hexdigest()
assert descriptor["path"] == name and descriptor["size"] == len(module)
joined = "\n".join(_build_skill_file_packs(skill_dir))
assert "empty.wasm (binary file — descriptor only" in joined
assert "answer.wasm (binary file — descriptor only" in joined
assert "\x00asm" not in joined # module bytes never inlined, not even as "text"
(skill_dir / "core.wasm").write_bytes(b"\x7fELF" + b"\x00" * 32) # ELF disguised as wasm
with pytest.raises(_SkillBinaryPayload):
_read_skill_file(skill_dir / "core.wasm", relpath="core.wasm")
def test_skill_review_does_not_block_text_by_scary_filename(tmp_path):
"""Capability preservation: the block is content-judged, so a valid UTF-8
text file survives review even with a formerly-blocking extension or an