Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions capa/ir/_capa_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,42 @@
# their method calls to host imports (Wasm) or to capability
# classes from ``capa.runtime`` (Python). The set must match
# ``capa.typesys.CAPABILITY_NAMES`` and ``capa.builtins``'s
# registered capability classes; cross-check fires the
# manifest-vs-runtime property test in
# [`tests/test_properties.py`](../../tests/test_properties.py).
# registered capability classes; the cross-check that keeps the
# copies in lockstep is ``TestCapabilityRegistry`` in
# [`tests/test_cap_handles.py`](../../tests/test_cap_handles.py).
# (This comment used to promise ``tests/test_properties.py``, but
# that module skips wholesale when Hypothesis is absent and a
# registry guard must never be skippable.)
BUILTIN_CAPS: frozenset[str] = frozenset({
"Stdio", "Fs", "Net", "Env", "Clock", "Random", "Proc", "Db", "Unsafe",
})


# The built-in caps that are UN-ERASED on the Wasm side: each one
# lowers to a real i32 handle into the host's per-instance cap
# table, so a restricted cap keeps its restriction across function
# boundaries (audit slices 25.2 - 25.6, 2026-05-30). A handle
# occupies a Wasm slot everywhere a value can live: params, locals,
# struct fields, closure environments, call args, return values,
# and ``main``'s exported signature.
#
# The complement (``ERASED_CAPS`` below) pushes no Wasm value at
# all: those caps have no attenuation surface to thread.
#
# Every built-in capability MUST appear in exactly one of the two
# sets; ``TestCapabilityRegistry`` in
# [`tests/test_cap_handles.py`](../../tests/test_cap_handles.py)
# fails if a new name lands in ``BUILTIN_CAPS`` without that
# decision being recorded here.
HANDLE_BEARING_CAPS: frozenset[str] = frozenset({
"Fs", "Net", "Db", "Proc", "Env", "Clock",
})


# The deliberately-erased complement. Spelled out rather than
# derived from ``BUILTIN_CAPS - HANDLE_BEARING_CAPS`` so that adding
# a capability forces an explicit choice instead of silently
# defaulting to "erased".
ERASED_CAPS: frozenset[str] = frozenset({
"Stdio", "Random", "Unsafe",
})
12 changes: 4 additions & 8 deletions capa/ir/_emit_wasm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
# ``_WIT_SIGNATURES`` is consumed in ``_caps.py`` (sig dispatch) and
# ``_discovery.py`` (early validation); not referenced directly
# from this module any more after the mixin extraction.
from .._capa_types import BUILTIN_CAPS
from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS
from .._walk import iter_functions, walk_module
from ._layout import (
WasmEmissionError,
Expand Down Expand Up @@ -1487,9 +1487,7 @@ def _emit_function(self, fn: Function) -> None:
param_clauses = []
for p in fn.params:
if p.ty in BUILTIN_CAPS:
if p.ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if p.ty in HANDLE_BEARING_CAPS:
param_clauses.append(f"(param ${p.name} i32)")
continue
if p.ty == "String":
Expand Down Expand Up @@ -1641,7 +1639,7 @@ def _push_call_args(self, args: list) -> None:
(``_emit_trait_method_call``) so the three never drift."""
for arg in args:
if arg.ty in BUILTIN_CAPS:
if arg.ty in ("Fs", "Net", "Db", "Proc", "Env", "Clock"):
if arg.ty in HANDLE_BEARING_CAPS:
self._push_value(arg)
continue
if arg.ty == "String":
Expand Down Expand Up @@ -2340,9 +2338,7 @@ def _emit_user_call(self, instr: Call) -> None:
if dst_ty == "String":
self._write(f"local.set ${instr.dst}_len")
self._write(f"local.set ${instr.dst}_ptr")
elif dst_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
elif dst_ty in HANDLE_BEARING_CAPS:
# Slices 25.2 - 25.6: Fs / Net / Db / Proc / Env /
# Clock return values carry the handle as i32.
self._write(f"local.set ${instr.dst}")
Expand Down
13 changes: 13 additions & 0 deletions capa/ir/_emit_wasm/_caps.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,13 @@ def _emit_cap_method_call(self, instr: MethodCall) -> None:
# enforcement (the old guest-side lexical prefix check could
# not realpath). ``Clock.allows`` was already host-side (it
# takes no string arg) - see the branch below.
#
# Registry note: this is ``HANDLE_BEARING_CAPS`` minus Clock,
# and it legitimately differs - the set here is "handle-
# bearing caps whose ``allows`` takes a STRING argument", so
# Clock (whose ``allows`` is nullary) needs its own emitter.
# Kept as a literal so a future cap must be classified by
# hand rather than inherited by set arithmetic.
if method == "allows" and cap in ("Fs", "Env", "Db", "Proc", "Net"):
self._emit_cap_allows_with_handle(instr, cap)
return
Expand Down Expand Up @@ -518,6 +525,12 @@ def _emit_cap_method_call(self, instr: MethodCall) -> None:
# passing helper. The host bridge looks up the receiver cap
# from the table and enforces ``cap.allows(arg)`` before
# the syscall, closing audit slice 25 F1 for these caps.
#
# Registry note: legitimately NOT ``HANDLE_BEARING_CAPS``.
# This is the GENERIC indirect-return path; the other three
# handle-bearing caps are dispatched before reaching here -
# Fs and Net by dedicated emitters above (Net also has WASI
# variants), Clock by the primitive-return branch below.
if cap in ("Db", "Proc", "Env") and indirect is not None:
self._emit_indirect_with_cap_handle(instr, cap, method, indirect)
return
Expand Down
30 changes: 8 additions & 22 deletions capa/ir/_emit_wasm/_closures.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
If, While, For, Match, FormatStr,
Pattern, PatIdent, PatVariant,
)
from .._capa_types import BUILTIN_CAPS
from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS
from .._walk import walk_instrs
from ._layout import (
WasmEmissionError,
Expand Down Expand Up @@ -128,9 +128,7 @@ def visit_value(v) -> None:
p.ty for p in target.params
if (
p.ty not in BUILTIN_CAPS
or p.ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
)
or p.ty in HANDLE_BEARING_CAPS
)
]
ret_capa_ty = target.return_type or "Unit"
Expand Down Expand Up @@ -274,9 +272,7 @@ def _emit_fn_ref_thunk(self, thunk: dict) -> None:
# Clock are un-erased - the closure ABI must thread
# their i32 handles the same way it threads any
# other scalar.
if p.ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if p.ty in HANDLE_BEARING_CAPS:
param_clauses.append(f"(param ${p.name} i32)")
continue
if p.ty == "String":
Expand All @@ -298,9 +294,7 @@ def _emit_fn_ref_thunk(self, thunk: dict) -> None:
# is intentionally ignored: thunks have no captured state.
for p in params:
if p.ty in BUILTIN_CAPS:
if p.ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if p.ty in HANDLE_BEARING_CAPS:
self._write(f"local.get ${p.name}")
continue
if p.ty == "String":
Expand Down Expand Up @@ -663,15 +657,11 @@ def nested_defs(ins: list[Instr]) -> None:
# see whatever default the host bridge invented,
# defeating the cross-function soundness these
# slices deliver).
if capa_ty not in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if capa_ty not in HANDLE_BEARING_CAPS:
continue
size = (
self._size_of(capa_ty)
if capa_ty not in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
) else 4
if capa_ty not in HANDLE_BEARING_CAPS else 4
)
offset = _align_up(offset, size)
env_layout[name] = (offset, capa_ty)
Expand Down Expand Up @@ -952,9 +942,7 @@ def _emit_closure_call(self, instr: Call, callee_ty: str) -> None:
# handles; other built-in caps stay erased.
for arg in instr.args:
if arg.ty in BUILTIN_CAPS:
if arg.ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if arg.ty in HANDLE_BEARING_CAPS:
self._push_value(arg)
continue
if arg.ty == "String":
Expand All @@ -973,9 +961,7 @@ def _emit_closure_call(self, instr: Call, callee_ty: str) -> None:
dst_ty
and (
dst_ty not in BUILTIN_CAPS
or dst_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
)
or dst_ty in HANDLE_BEARING_CAPS
)
and dst_ty not in ("Unit",)
):
Expand Down
13 changes: 13 additions & 0 deletions capa/ir/_emit_wasm/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,13 @@ def _discover_instrs(self, instrs: list[Instr]) -> None:
# and return a fresh i32 handle bound to a narrower
# restriction. Register the imports so the linker
# resolves the host callbacks.
#
# Registry note: the three branches below TOGETHER
# cover exactly ``HANDLE_BEARING_CAPS``; the split is
# by ATTENUATOR METHOD NAME, not by cap class - Env
# narrows via ``restrict_to_keys`` and Clock via
# ``restrict_to_after``, so only the remaining four
# share the ``restrict_to`` spelling.
if (cap in ("Fs", "Net", "Db", "Proc")
and instr.method == "restrict_to"):
self._used_caps.add((cap, "restrict_to"))
Expand All @@ -621,6 +628,12 @@ def _discover_instrs(self, instrs: list[Instr]) -> None:
pass
elif (cap in ("Fs", "Env", "Db", "Proc", "Net")
and instr.method == "allows"):
# Registry note: ``HANDLE_BEARING_CAPS`` minus
# Clock, mirroring the emitter split in
# ``_caps._emit_cap_method_call`` - these five
# take a string arg, ``Clock.allows`` is nullary
# and already had its own host route.
#
# GAP-2b (2026-06-21): Fs.allows / Env.allows /
# Db.allows / Proc.allows / Net.allows route
# through the authoritative host function
Expand Down
10 changes: 3 additions & 7 deletions capa/ir/_emit_wasm/_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from __future__ import annotations

from .._capa_types import BUILTIN_CAPS
from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS
from .._nodes import (
AssignConst, Reassign, BinOp, UnaryOp, Call, MethodCall,
If, While, Break, Continue, Return, TryUnwrap,
Expand All @@ -37,9 +37,7 @@ def _emit_instr(self, instr: Instr) -> None:
# (slices 25.2 - 25.6) which carry i32 handles so a
# restricted cap survives crossing function
# boundaries.
if dst_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if dst_ty in HANDLE_BEARING_CAPS:
self._push_value(instr.src)
self._write(f"local.set ${instr.dst}")
return
Expand All @@ -59,9 +57,7 @@ def _emit_instr(self, instr: Instr) -> None:
if isinstance(instr, Reassign):
dst_ty = self._dst_capa_ty(instr.dst)
if dst_ty in BUILTIN_CAPS:
if dst_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if dst_ty in HANDLE_BEARING_CAPS:
self._push_value(instr.src)
self._write(f"local.set ${instr.dst}")
return
Expand Down
10 changes: 3 additions & 7 deletions capa/ir/_emit_wasm/_locals.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
PatIdent, PatVariant, PatTuple,
)
from .._lower_pattern import PatStruct
from .._capa_types import BUILTIN_CAPS
from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS
from ._layout import (
_element_type_of_list, _element_type_of_set, _map_key_type,
WasmEmissionError,
Expand Down Expand Up @@ -746,9 +746,7 @@ def visit(instrs: list[Instr]) -> None:
# so a restricted cap survives crossing function
# boundaries.
if capa_ty in BUILTIN_CAPS:
if capa_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if capa_ty in HANDLE_BEARING_CAPS:
out[dst] = "i32"
continue
# String locals expand to a (ptr, len) pair so
Expand Down Expand Up @@ -796,9 +794,7 @@ def visit(instrs: list[Instr]) -> None:
# Slices 25.2 - 25.6 (2026-05-30): Fs / Net / Db /
# Proc / Env / Clock are un-erased; every other cap
# stays as a no-Wasm-value.
if capa_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if capa_ty in HANDLE_BEARING_CAPS:
out[name] = "i32"
continue
if capa_ty == "String":
Expand Down
12 changes: 4 additions & 8 deletions capa/ir/_emit_wasm/_structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from .._nodes import (
Call, FieldAccess, FieldStore, MakeStruct, Value,
)
from .._capa_types import BUILTIN_CAPS
from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS
from ._layout import (
WasmEmissionError, _store_op_for_size, _load_op_for_size,
_strip_type_qualifiers, _TYPE_ID_OFFSET,
Expand Down Expand Up @@ -325,9 +325,7 @@ def _emit_make_struct(self, instr: MakeStruct) -> None:
# Proc / Env / Clock become i32 handles the struct
# must carry so a restricted cap stashed in a record
# survives across function boundaries.
if field_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if field_ty in HANDLE_BEARING_CAPS:
self._write(f"local.get ${instr.dst}")
self._push_value(fval)
self._write(f"i32.store offset={offset}")
Expand Down Expand Up @@ -388,9 +386,7 @@ def _emit_field_access(self, instr: FieldAccess) -> None:
# as the receiver of subsequent privileged calls
# (closing audit slice 25 F1 for cap values stashed in
# records).
if field_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
if field_ty in HANDLE_BEARING_CAPS:
self._push_value(instr.receiver)
self._write(f"i32.load offset={offset}")
self._write(f"local.set ${instr.dst}")
Expand Down Expand Up @@ -442,7 +438,7 @@ def _emit_field_store(self, instr: FieldStore) -> None:
# carry i32 handles (slices 25.2 - 25.6); store the
# handle. Every other cap is erased and has no Wasm value,
# so the store is a no-op (matching _emit_make_struct).
if field_ty in ("Fs", "Net", "Db", "Proc", "Env", "Clock"):
if field_ty in HANDLE_BEARING_CAPS:
self._push_value(instr.receiver)
self._push_value(instr.src)
self._write(f"i32.store offset={offset}")
Expand Down
6 changes: 2 additions & 4 deletions capa/ir/_emit_wasm/_traits.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from __future__ import annotations

from .._nodes import Function, MethodCall
from .._capa_types import BUILTIN_CAPS
from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS
from ._layout import (
WasmEmissionError, _strip_type_qualifiers, _TYPE_ID_OFFSET,
)
Expand Down Expand Up @@ -401,9 +401,7 @@ def _store_trait_call_result(self, instr: MethodCall) -> None:
# Multi-value (i32 i32) return -> dst pair.
self._write(f"local.set ${instr.dst}_len")
self._write(f"local.set ${instr.dst}_ptr")
elif dst_ty in (
"Fs", "Net", "Db", "Proc", "Env", "Clock",
):
elif dst_ty in HANDLE_BEARING_CAPS:
# Slices 25.2 - 25.6: Fs / Net / Db / Proc / Env /
# Clock return the handle as i32.
self._write(f"local.set ${instr.dst}")
Expand Down
6 changes: 3 additions & 3 deletions capa/ir/_emit_wasm/_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from typing import Optional

from .._nodes import Value
from .._capa_types import BUILTIN_CAPS
from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS
from ._layout import (
WasmEmissionError, _TYPE_SIZE,
_size_of, _store_op_for_size, _load_op_for_size,
Expand Down Expand Up @@ -118,7 +118,7 @@ def _call_result_slot_count(self, ret_ty: str) -> int:
return 0
if head == "String":
return 2
if head in ("Fs", "Net", "Db", "Proc", "Env", "Clock"):
if head in HANDLE_BEARING_CAPS:
return 1
if head in BUILTIN_CAPS:
# Non-handle capabilities (e.g. Stdio) are erased at the
Expand Down Expand Up @@ -357,7 +357,7 @@ def _wasm_type(self, capa_ty: str) -> str:
# by routing through ``urlparse(url).hostname`` rather than
# ``$str_contains``). Random / Unsafe / Stdio stay erased
# (no attenuation surface to wire).
if head in ("Fs", "Net", "Db", "Proc", "Env", "Clock"):
if head in HANDLE_BEARING_CAPS:
return "i32"
# ``()`` is Capa's empty-tuple / Unit alias from the type
# printer; treat it the same as Unit (no Wasm result).
Expand Down
Loading