diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34fb02c1..d3d1e5ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: - name: zig build test run: zig build test --summary all + - name: zig build wasm + run: zig build wasm + - name: Local learning lifecycle and security regression run: python3 tests/learn_e2e.py --graff zig-out/bin/graff @@ -187,6 +190,9 @@ jobs: - name: zig build test run: zig build test --summary all + - name: zig build wasm + run: zig build wasm + - name: Local learning lifecycle and security regression (Windows) run: python3 tests/learn_e2e.py --graff zig-out/bin/graff.exe diff --git a/CHANGELOG.md b/CHANGELOG.md index 5933bc44..45aa46fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ The release workflow uses a tag's section here as its release notes (a hand-written `docs/releases/.md` wins if present), so keeping this file current is part of cutting a release. +## Unreleased + +- `zig build wasm` emits `graff-kernel.wasm`: the ToolCatalog cube and the + lexical path jail, callable from JS (`sdk/wasm/`). Not the agent — no + HTTP, bash, or TTY. Same predicates Lean exports (ADR 0012). + ## v0.0.267 (2026-08-19) - Background jobs wait like grok-build: `bash_output(wait_ms>0)` and diff --git a/README.md b/README.md index 9b71a586..c1651308 100644 --- a/README.md +++ b/README.md @@ -1265,6 +1265,7 @@ skips). Alternatively, run in place: ```sh zig build run # or: ./zig-out/bin/graff zig build test # the test suite (also run by CI, .github/workflows/ci.yml) +zig build wasm # graff-kernel.wasm — catalog + path cubes, not the agent ``` **Releases & verification.** Tagged releases ship a prebuilt **darwin-arm64** @@ -1452,6 +1453,7 @@ about 20 seconds warm: | `tests` | `zig build test`, and a suite count that may grow but never shrink | | `invariants` | the named goal/loop/todo tests actually ran, not just compiled | | `sdk` | the committed SDKs still match `graff --schema` | +| `wasm` | `zig build wasm` — `graff-kernel.wasm` still compiles | A push that only touches docs skips the whole thing. When a check fails it names the invariant, says which regression it guards, and prints the one-liner that diff --git a/build.zig b/build.zig index 7856dcb9..6a79a645 100644 --- a/build.zig +++ b/build.zig @@ -200,4 +200,27 @@ pub fn build(b: *std.Build) void { const tui_test_step = b.step("tui-test", "Run fullscreen TUI unit tests"); tui_test_step.dependOn(&b.addRunArtifact(tui_tests).step); + + // `zig build wasm` — the kernel cube as wasm32-freestanding. Not the + // agent (no HTTP, no bash, no TTY). A JS host loads graff-kernel.wasm + // and evaluates catalog/confined; see sdk/wasm/ and ADR 0012. + const wasm_target = b.resolveTargetQuery(.{ + .cpu_arch = .wasm32, + .os_tag = .freestanding, + }); + const wasm_optimize = if (optimize == .Debug) .ReleaseSmall else optimize; + const wasm_exe = b.addExecutable(.{ + .name = "graff-kernel", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/wasm_main.zig"), + .target = wasm_target, + .optimize = wasm_optimize, + .strip = true, + }), + }); + wasm_exe.entry = .disabled; + wasm_exe.rdynamic = true; + const install_wasm = b.addInstallArtifact(wasm_exe, .{}); + const wasm_step = b.step("wasm", "Build graff-kernel.wasm (catalog + path kernels)"); + wasm_step.dependOn(&install_wasm.step); } diff --git a/docs/adr/0012-wasm-is-the-kernel-cube.md b/docs/adr/0012-wasm-is-the-kernel-cube.md new file mode 100644 index 00000000..9b923cc0 --- /dev/null +++ b/docs/adr/0012-wasm-is-the-kernel-cube.md @@ -0,0 +1,34 @@ +# 0012. WebAssembly ships the kernel cube, not the agent + +Status: accepted 2026-08-20 + +## Context + +fx compiles two `wasm32-freestanding` artifacts (`fx-core.wasm`, `fx-term.wasm`) +and a JS host that supplies fetch, session storage, and (optionally) exec. The +WASM build has no native processes, OS sandbox, WASI filesystem, MCP, or +subagents; JSPI is required for the async host calls. + +graff's live loop (`std.http.Client`, `bash` jobs, MCP stdio, TTY restore) +does not compile to freestanding WASM as-is, and grafting a host layer onto +every I/O seam is a second product. The kernels already are total functions +over finite cubes (`{0,1}^6` catalogs, lexical paths) with no OS. + +## Decision + +`zig build wasm` produces `graff-kernel.wasm`: `catalog`, `advertised`, and +`confined` over the same fixtures Lean exports. The JS host is +`sdk/wasm/graff-kernel.js`. It does not use JSPI. + +A later `graff-core.wasm` / `graff-term.wasm` needs an explicit host +capability table (fetch, workspace, no implicit bash), the same way fx does. +Do not compile `src/main.zig` to WASM and hope. + +Do not target WASI so the module can load in a browser without a filesystem +polyfill. Do not emit `OSC 50` or claim the pager font is ours. + +## Consequences + +The wasm step is a compile-only tier-1 check (`zig build wasm`). Semantics +stay in the native suite against `spec/kernels/*.json`. Full-agent embed +stays `graff serve` + the remote SDK until a host layer exists. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5619f8a2..202a3ec7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ record only when you need the evidence or the edge cases. | [0009](0009-gpt-5-6-explicit-prompt-cache-boundary.md) | GPT-5.6 OpenAI Platform marks the stable prefix explicitly; Codex and xAI stay on their supported keyed automatic-cache paths. | | [0010](0010-background-jobs-wait-for-exit.md) | `bash_output`/`agent_output` `wait_ms>0` blocks until exit (10h cap); do not poll every 30s. | | [0011](0011-prompt-cache-max-is-visible.md) | Prompt-cache max is `/cache` posture, not a new default; `/btw` rides the parent prefix. | +| [0012](0012-wasm-is-the-kernel-cube.md) | WASM is `graff-kernel.wasm` (catalog + path cubes), not the agent; a later core/term host must supply I/O like fx. | ## When to write one diff --git a/scripts/eval-tier1.sh b/scripts/eval-tier1.sh index 6efb1fde..e3075bfa 100644 --- a/scripts/eval-tier1.sh +++ b/scripts/eval-tier1.sh @@ -25,7 +25,7 @@ cd "$repo_root" # processes discover their repo from their cwd like they expect. unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_PREFIX -CHECKS=(fmt lines spec reach build tests tui tuiguard invariants sdk) +CHECKS=(fmt lines spec reach build tests tui tuiguard invariants sdk wasm) usage() { cat <<'EOF' @@ -43,6 +43,7 @@ checks, in order: and virtual-screen checks (test-tui-screenstate.py) invariants the named goal/loop/todo tests actually ran, not just compiled sdk the committed SDKs match `graff --schema` + wasm zig build wasm (graff-kernel.wasm still compiles) EOF } @@ -440,6 +441,21 @@ if wanted sdk; then fi fi +# --- wasm -------------------------------------------------------------- +# Compile-only: the kernels must keep targeting wasm32-freestanding. Semantics +# stay in the native suite (kernel_catalog / kernel_path / wasm_abi). Does not +# depend on zig-out/bin/graff. +if wanted wasm; then + announce wasm "zig build wasm — graff-kernel.wasm still compiles" + if zig build wasm; then + printf ' zig-out/bin/graff-kernel.wasm is current\n' + else + printf ' the wasm32-freestanding kernel target failed to compile.\n' + printf ' fix: zig build wasm\n' + record_fail wasm + fi +fi + # --- verdict ----------------------------------------------------------- elapsed=$((SECONDS - started)) if ((${#warned[@]} > 0)); then diff --git a/sdk/README.md b/sdk/README.md index 893bdb76..fbf026d7 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -4,6 +4,10 @@ TypeScript (`ts/`) and Python (`py/`) clients that drive graff over its `--json` stdio protocol. **Both are auto-generated** — never hand-edit the generated files. +`wasm/` is a separate, hand-written host for `graff-kernel.wasm` (the +catalog + path cubes, not the agent). `generate.py` does not touch it. +See [wasm/README.md](wasm/README.md) and ADR 0012. + ## How it works The graff binary is the single source of truth. `graff --schema` emits its diff --git a/sdk/ts/remote.test.ts b/sdk/ts/remote.test.ts index 1f2a0485..4bf03790 100644 --- a/sdk/ts/remote.test.ts +++ b/sdk/ts/remote.test.ts @@ -171,12 +171,15 @@ describe("RemoteHarness transport", () => { test("constructor observes create rejection even when the caller never awaits it", async () => { let unhandled = 0; const listener = () => { unhandled += 1; }; - process.on("unhandledRejection", listener); + // bun-types narrows Process.on/off to memoryPressure; Node's + // unhandledRejection is the event this test actually needs. + const proc = process as unknown as NodeJS.EventEmitter; + proc.on("unhandledRejection", listener); globalThis.fetch = (async () => { throw new Error("create failed"); }) as unknown as typeof fetch; const h = new RemoteHarness({ url: "http://bridge.test" }); live.push(h); await Bun.sleep(30); - process.off("unhandledRejection", listener); + proc.off("unhandledRejection", listener); expect(unhandled).toBe(0); await expect(h.sessionId).rejects.toThrow("create failed"); }); diff --git a/sdk/wasm/README.md b/sdk/wasm/README.md new file mode 100644 index 00000000..7123ffe5 --- /dev/null +++ b/sdk/wasm/README.md @@ -0,0 +1,48 @@ +# graff-kernel.wasm + +The ToolCatalog cube and the lexical path jail, compiled to +`wasm32-freestanding`. This is **not** the agent. fx's `fx-core.wasm` is a +host-supplied ACP loop (JSPI fetch, no bash). graff's first wasm artifact is +the same finite functions Lean proves — 64 catalog cells, `confined(path)`. + +See [ADR 0012](../../docs/adr/0012-wasm-is-the-kernel-cube.md). + +## Build + +```sh +zig build wasm +# zig-out/bin/graff-kernel.wasm +``` + +ReleaseSmall is the default even on a Debug configure: the kernels have +nothing to debug in DWARF, and a Debug wasm is mostly unused panic +machinery. + +## Use + +```js +import { loadGraffKernel } from "./graff-kernel.js"; + +const k = await loadGraffKernel("./graff-kernel.wasm"); +k.cubeCells(); // 64 +k.catalog(); // ["bash", "bash_output", ...] +k.catalog({ lean: true }); // 8 names +k.advertised("subagent", { isSub: true }); // false +k.confined("/etc/passwd"); // false +k.confined("src/main.zig"); // true +``` + +Flag bits: `noLocal`, `lean`, `imagegen`, `clockSleep`, `learnLoaded`, `isSub`. + +No JSPI. A later `graff-core.wasm` that talks to a model will need a host +`fetch` (and must not pretend bash exists). Until then, embed the live +harness with `graff serve` and the remote SDK. + +## Demo + +After `zig build wasm`, from the repo root: + +```sh +python3 -m http.server 8080 +# open http://localhost:8080/sdk/wasm/demo.html +``` diff --git a/sdk/wasm/demo.html b/sdk/wasm/demo.html new file mode 100644 index 00000000..1e3c97c4 --- /dev/null +++ b/sdk/wasm/demo.html @@ -0,0 +1,37 @@ + + +graff-kernel.wasm + +

graff-kernel.wasm

+

The 64-cell catalog cube and the lexical path jail. Not the agent.

+
loading…
+ diff --git a/sdk/wasm/graff-kernel.js b/sdk/wasm/graff-kernel.js new file mode 100644 index 00000000..49364cd7 --- /dev/null +++ b/sdk/wasm/graff-kernel.js @@ -0,0 +1,76 @@ +// Host loader for graff-kernel.wasm (ADR 0012). +// +// The module is the finite kernels, not the agent. No JSPI. Flag bits +// match src/kernel_catalog.zig: no_local, lean, imagegen, clock_sleep, +// learn_loaded, is_sub. + +export const FLAG = Object.freeze({ + noLocal: 1 << 0, + lean: 1 << 1, + imagegen: 1 << 2, + clockSleep: 1 << 3, + learnLoaded: 1 << 4, + isSub: 1 << 5, +}); + +export function packFlags(flags = {}) { + let bits = 0; + if (flags.noLocal) bits |= FLAG.noLocal; + if (flags.lean) bits |= FLAG.lean; + if (flags.imagegen) bits |= FLAG.imagegen; + if (flags.clockSleep) bits |= FLAG.clockSleep; + if (flags.learnLoaded) bits |= FLAG.learnLoaded; + if (flags.isSub) bits |= FLAG.isSub; + return bits; +} + +function decoder() { + return new TextDecoder(); +} + +function encoder() { + return new TextEncoder(); +} + +export async function loadGraffKernel(wasm) { + const source = + wasm instanceof WebAssembly.Module + ? wasm + : wasm instanceof ArrayBuffer || ArrayBuffer.isView(wasm) + ? wasm + : await (await fetch(wasm)).arrayBuffer(); + const { instance } = await WebAssembly.instantiate(source, {}); + const ex = instance.exports; + if (ex.graff_abi_version() !== 1) { + throw new Error(`unsupported graff-kernel ABI ${ex.graff_abi_version()}`); + } + return { + instance, + abiVersion: () => ex.graff_abi_version(), + cubeCells: () => ex.graff_cube_cells(), + catalog(flags = {}) { + const n = ex.graff_catalog(packFlags(flags)); + if (n < 0) throw new Error("graff_catalog: scratch overflow"); + const mem = new Uint8Array(ex.memory.buffer); + const ptr = ex.graff_scratch_ptr(); + return JSON.parse(decoder().decode(mem.subarray(ptr, ptr + n))); + }, + advertised(name, flags = {}) { + const n = writeScratch(ex, name); + return ex.graff_advertised(packFlags(flags), n) === 1; + }, + confined(path) { + const n = writeScratch(ex, path); + return ex.graff_confined(n) === 1; + }, + }; +} + +function writeScratch(ex, text) { + const bytes = encoder().encode(text); + const cap = ex.graff_scratch_len(); + if (bytes.length > cap) throw new Error("scratch overflow"); + const mem = new Uint8Array(ex.memory.buffer); + mem.set(bytes, ex.graff_scratch_ptr()); + return bytes.length; +} diff --git a/sdk/wasm/package.json b/sdk/wasm/package.json new file mode 100644 index 00000000..8e9232f0 --- /dev/null +++ b/sdk/wasm/package.json @@ -0,0 +1,6 @@ +{ + "name": "graff-kernel", + "private": true, + "type": "module", + "description": "JS host for graff-kernel.wasm (catalog + path cubes, not the agent)" +} diff --git a/src/kernel_catalog.zig b/src/kernel_catalog.zig new file mode 100644 index 00000000..544129c5 --- /dev/null +++ b/src/kernel_catalog.zig @@ -0,0 +1,218 @@ +//! Name-list half of the ToolCatalog kernel. +//! +//! Freestanding: no process globals, no schema.zig, no I/O. Same function +//! Lean (`lean-proofs/Graff/ToolCatalog.lean`) and `spec/ref/tool_catalog.py` +//! export. The 64-cell flag cube is `{0,1}^6`; `catalog` is the ordered +//! name list at each point. Zig's live catalog (`schema.effectiveRootSpecs`) +//! is checked against the same fixtures in `spec_catalog_conformance.zig`. + +const std = @import("std"); + +pub const Flags = struct { + no_local: bool = false, + lean: bool = false, + imagegen: bool = false, + clock_sleep: bool = false, + learn_loaded: bool = false, + is_sub: bool = false, +}; + +pub const FLAG_NO_LOCAL: u32 = 1 << 0; +pub const FLAG_LEAN: u32 = 1 << 1; +pub const FLAG_IMAGEGEN: u32 = 1 << 2; +pub const FLAG_CLOCK_SLEEP: u32 = 1 << 3; +pub const FLAG_LEARN_LOADED: u32 = 1 << 4; +pub const FLAG_IS_SUB: u32 = 1 << 5; + +pub const cube_cells: usize = 64; +pub const max_names: usize = 32; + +pub const local_tools = [_][]const u8{ + "bash", "bash_output", "bash_kill", "read_file", + "edit_file", "write_file", "codedb", "imagegen", +}; +pub const lean_tools = [_][]const u8{ + "bash", "read_file", "edit_file", + "write_file", "codedb", "subagent", + "attempt_completion", "load_tool_schemas", +}; +pub const optional_tools = [_][]const u8{"imagegen"}; +pub const base_tools = [_][]const u8{ + "bash", "bash_output", "bash_kill", "read_file", + "edit_file", "write_file", "webfetch", "skill", + "codedb", +}; +pub const meta_tools = [_][]const u8{ + "todo_write", "todo_read", "eval", "note_constraint", + "ask_user", "attempt_completion", "load_tool_schemas", "clock_sleep", +}; +pub const root_extras = [_][]const u8{ + "subagent", "workflow", "agent_output", "learn_candidate", + "peer_message", "workspace", +}; + +pub fn unpack(bits: u32) Flags { + return .{ + .no_local = bits & FLAG_NO_LOCAL != 0, + .lean = bits & FLAG_LEAN != 0, + .imagegen = bits & FLAG_IMAGEGEN != 0, + .clock_sleep = bits & FLAG_CLOCK_SLEEP != 0, + .learn_loaded = bits & FLAG_LEARN_LOADED != 0, + .is_sub = bits & FLAG_IS_SUB != 0, + }; +} + +pub fn pack(f: Flags) u32 { + var bits: u32 = 0; + if (f.no_local) bits |= FLAG_NO_LOCAL; + if (f.lean) bits |= FLAG_LEAN; + if (f.imagegen) bits |= FLAG_IMAGEGEN; + if (f.clock_sleep) bits |= FLAG_CLOCK_SLEEP; + if (f.learn_loaded) bits |= FLAG_LEARN_LOADED; + if (f.is_sub) bits |= FLAG_IS_SUB; + return bits; +} + +fn mem(name: []const u8, list: []const []const u8) bool { + for (list) |n| { + if (std.mem.eql(u8, n, name)) return true; + } + return false; +} + +pub fn isLocal(name: []const u8) bool { + return mem(name, &local_tools); +} + +pub fn isOptional(name: []const u8) bool { + return mem(name, &optional_tools); +} + +pub fn isLeanKeep(name: []const u8) bool { + return mem(name, &lean_tools); +} + +fn keepRoot(f: Flags, name: []const u8) bool { + if (!f.clock_sleep and std.mem.eql(u8, name, "clock_sleep")) return false; + if (!f.learn_loaded and std.mem.eql(u8, name, "learn_candidate")) return false; + return true; +} + +/// Writes the advertised names into `out` (must hold `max_names`) and +/// returns how many were written. Order matches the Lean/Python kernel. +pub fn catalog(f: Flags, out: [][]const u8) usize { + var n: usize = 0; + const append = struct { + fn go(dest: [][]const u8, i: *usize, name: []const u8, flags: Flags) void { + // chosenSub is base only; imagegen still appends when flagged. + if (!flags.is_sub and !keepRoot(flags, name)) return; + if (flags.no_local and isLocal(name)) return; + if (flags.lean and !flags.is_sub and !isLeanKeep(name)) return; + dest[i.*] = name; + i.* += 1; + } + }.go; + + if (f.is_sub) { + for (base_tools) |name| append(out, &n, name, f); + } else { + for (base_tools) |name| append(out, &n, name, f); + for (meta_tools) |name| append(out, &n, name, f); + for (root_extras) |name| append(out, &n, name, f); + } + if (f.imagegen) append(out, &n, "imagegen", f); + return n; +} + +pub fn advertised(f: Flags, name: []const u8) bool { + var buf: [max_names][]const u8 = undefined; + const names = buf[0..catalog(f, &buf)]; + return mem(name, names); +} + +pub fn blocked(f: Flags, name: []const u8) bool { + return (f.no_local and isLocal(name)) or (isOptional(name) and !f.imagegen); +} + +pub fn unique(names: []const []const u8) bool { + for (names, 0..) |n, i| { + if (mem(n, names[i + 1 ..])) return false; + } + return true; +} + +/// Compact JSON array of advertised names into `out`. Returns bytes written, +/// or error.NoSpace when the buffer is too small. +pub fn catalogJson(f: Flags, out: []u8) error{NoSpace}!usize { + var names: [max_names][]const u8 = undefined; + const n = catalog(f, &names); + var i: usize = 0; + if (out.len == 0) return error.NoSpace; + out[0] = '['; + i = 1; + for (names[0..n], 0..) |name, k| { + if (k > 0) { + if (i >= out.len) return error.NoSpace; + out[i] = ','; + i += 1; + } + if (i + 2 + name.len > out.len) return error.NoSpace; + out[i] = '"'; + i += 1; + @memcpy(out[i .. i + name.len], name); + i += name.len; + out[i] = '"'; + i += 1; + } + if (i >= out.len) return error.NoSpace; + out[i] = ']'; + return i + 1; +} + +fn allFlags() [cube_cells]Flags { + var acc: [cube_cells]Flags = undefined; + var i: usize = 0; + var bits: u32 = 0; + while (bits < cube_cells) : (bits += 1) { + acc[i] = unpack(bits); + i += 1; + } + return acc; +} + +test "catalog cube is 64 cells" { + try std.testing.expectEqual(cube_cells, allFlags().len); +} + +test "cube: imagegen stays off when the flag is off" { + for (allFlags()) |f| { + if (!f.imagegen) try std.testing.expect(!advertised(f, "imagegen")); + } +} + +test "cube: no_local drops bash" { + for (allFlags()) |f| { + if (f.no_local) try std.testing.expect(!advertised(f, "bash")); + } +} + +test "cube: a child never sees subagent" { + for (allFlags()) |f| { + if (f.is_sub) try std.testing.expect(!advertised(f, "subagent")); + } +} + +test "cube: webfetch survives no_local unless lean-on-root" { + for (allFlags()) |f| { + if (f.no_local and !(f.lean and !f.is_sub)) { + try std.testing.expect(advertised(f, "webfetch")); + } + } +} + +test "cube: every cell's name list is unique" { + for (allFlags()) |f| { + var buf: [max_names][]const u8 = undefined; + try std.testing.expect(unique(buf[0..catalog(f, &buf)])); + } +} diff --git a/src/kernel_conformance.zig b/src/kernel_conformance.zig new file mode 100644 index 00000000..84161a7a --- /dev/null +++ b/src/kernel_conformance.zig @@ -0,0 +1,62 @@ +//! Fixture half of the wasm kernels. Lives outside kernel_catalog / +//! kernel_path so those stay freestanding (no `@embedFile` of spec/). + +const std = @import("std"); +const catalog = @import("kernel_catalog.zig"); +const path = @import("kernel_path.zig"); + +test "kernel catalog matches exported tool_catalog fixtures" { + const fixtures = @embedFile("spec_tool_catalog"); + var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, fixtures, .{}); + defer parsed.deinit(); + const cases = parsed.value.object.get("cases").?.array.items; + try std.testing.expectEqual(catalog.cube_cells, cases.len); + + for (cases) |case_v| { + const case = case_v.object; + const flags = case.get("flags").?.object; + const f = catalog.Flags{ + .no_local = flags.get("no_local").?.bool, + .lean = flags.get("lean").?.bool, + .imagegen = flags.get("imagegen").?.bool, + .clock_sleep = flags.get("clock_sleep").?.bool, + .learn_loaded = flags.get("learn_loaded").?.bool, + .is_sub = std.mem.eql(u8, flags.get("seat").?.string, "sub"), + }; + var buf: [catalog.max_names][]const u8 = undefined; + const got = buf[0..catalog.catalog(f, &buf)]; + const want = case.get("advertised").?.array.items; + if (got.len != want.len) { + std.debug.print("\ncounterexample {s}: len want={d} got={d}\n", .{ + case.get("id").?.string, want.len, got.len, + }); + return error.CatalogMismatch; + } + for (want, got) |w, g| { + if (!std.mem.eql(u8, w.string, g)) { + std.debug.print("\ncounterexample {s}: name want={s} got={s}\n", .{ + case.get("id").?.string, w.string, g, + }); + return error.CatalogMismatch; + } + } + } +} + +test "kernel confined matches exported path_confine fixtures" { + const fixtures = @embedFile("spec_path_confine"); + var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, fixtures, .{}); + defer parsed.deinit(); + const paths = parsed.value.object.get("paths").?.array.items; + try std.testing.expect(paths.len > 10); + for (paths) |row_v| { + const row = row_v.object; + const p = row.get("path").?.string; + const want = row.get("confined").?.bool; + const got = path.confined(p); + if (want != got) { + std.debug.print("\ncounterexample confined {s}: want={} got={}\n", .{ p, want, got }); + return error.PathMismatch; + } + } +} diff --git a/src/kernel_path.zig b/src/kernel_path.zig new file mode 100644 index 00000000..86d6bc14 --- /dev/null +++ b/src/kernel_path.zig @@ -0,0 +1,31 @@ +//! Lexical half of the PathConfine kernel. +//! +//! Freestanding: no `Io`, no live `/proc`, no symlink probe. Same predicate +//! Lean (`lean-proofs/Graff/PathConfine.lean`) and `spec/ref/path_confine.py` +//! export. The Zig file-tool path (`harness_policy.confinedPath`) is the +//! same function plus a separate symlink walk that stays off this surface. + +const std = @import("std"); + +/// True when `path` is a relative path with no `..` component. Empty, +/// absolute (`/`-prefixed), and any `..` segment are jail-breaks. Backslash +/// is treated as a separator so `foo\..\bar` fails the same way as `foo/../bar`. +pub fn confined(path: []const u8) bool { + if (path.len == 0) return false; + if (path[0] == '/') return false; + var it = std.mem.tokenizeAny(u8, path, "/\\"); + while (it.next()) |comp| { + if (std.mem.eql(u8, comp, "..")) return false; + } + return true; +} + +test "confined: empty, absolute, and parent segments fail" { + try std.testing.expect(!confined("")); + try std.testing.expect(!confined("/etc/passwd")); + try std.testing.expect(!confined("../outside")); + try std.testing.expect(!confined("a/../../b")); + try std.testing.expect(confined("src/main.zig")); + try std.testing.expect(confined("a/./b")); + try std.testing.expect(confined("..hidden")); +} diff --git a/src/test_hooks.zig b/src/test_hooks.zig index 1c32c2a2..d663e6a3 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -213,6 +213,13 @@ const sandbox_docker = @import("sandbox_docker.zig"); const commands_sandbox = @import("commands_sandbox.zig"); const sandbox_tests = @import("sandbox_tests.zig"); +// wasm32 kernel surface (ADR 0012). wasm_main.zig is the freestanding +// entry and must stay out of this graph — it overrides panic. +const kernel_catalog = @import("kernel_catalog.zig"); +const kernel_path = @import("kernel_path.zig"); +const kernel_conformance = @import("kernel_conformance.zig"); +const wasm_abi = @import("wasm_abi.zig"); + test { _ = learn_holdout; _ = learn_receipt; @@ -298,4 +305,8 @@ test { _ = sandbox_docker; _ = commands_sandbox; _ = sandbox_tests; + _ = kernel_catalog; + _ = kernel_path; + _ = kernel_conformance; + _ = wasm_abi; } diff --git a/src/wasm_abi.zig b/src/wasm_abi.zig new file mode 100644 index 00000000..176a3180 --- /dev/null +++ b/src/wasm_abi.zig @@ -0,0 +1,71 @@ +//! C ABI shared by `graff-kernel.wasm` and the native tests. +//! +//! The wasm surface is the kernel cube, not the agent: `catalog` / `advertised` +//! / `confined`. A JS host writes a UTF-8 string into the scratch buffer, then +//! calls the matching export. No JSPI — these functions are total and sync. + +const std = @import("std"); +const catalog = @import("kernel_catalog.zig"); +const path = @import("kernel_path.zig"); + +pub const abi_version: u32 = 1; +pub const scratch_len: usize = 4096; + +pub const FLAG_NO_LOCAL = catalog.FLAG_NO_LOCAL; +pub const FLAG_LEAN = catalog.FLAG_LEAN; +pub const FLAG_IMAGEGEN = catalog.FLAG_IMAGEGEN; +pub const FLAG_CLOCK_SLEEP = catalog.FLAG_CLOCK_SLEEP; +pub const FLAG_LEARN_LOADED = catalog.FLAG_LEARN_LOADED; +pub const FLAG_IS_SUB = catalog.FLAG_IS_SUB; + +/// Writes `catalog(flags)` as a compact JSON string array into `out`. +/// Returns the byte count, or `-1` if `out` is too small. +pub fn writeCatalog(flags: u32, out: []u8) i32 { + const n = catalog.catalogJson(catalog.unpack(flags), out) catch return -1; + return @intCast(n); +} + +pub fn advertised(flags: u32, name: []const u8) bool { + return catalog.advertised(catalog.unpack(flags), name); +} + +pub fn confined(p: []const u8) bool { + return path.confined(p); +} + +test "abi version is 1 and the cube is 64" { + try std.testing.expectEqual(@as(u32, 1), abi_version); + try std.testing.expectEqual(@as(usize, 64), catalog.cube_cells); +} + +test "writeCatalog: default root is a JSON array that starts with bash" { + var buf: [scratch_len]u8 = undefined; + const n = writeCatalog(0, &buf); + try std.testing.expect(n > 0); + const json = buf[0..@intCast(n)]; + try std.testing.expect(std.mem.startsWith(u8, json, "[\"bash\"")); + try std.testing.expect(std.mem.endsWith(u8, json, "]")); + try std.testing.expect(std.mem.indexOf(u8, json, "\"subagent\"") != null); +} + +test "writeCatalog: lean root drops webfetch and keeps load_tool_schemas" { + var buf: [scratch_len]u8 = undefined; + const n = writeCatalog(FLAG_LEAN, &buf); + try std.testing.expect(n > 0); + const json = buf[0..@intCast(n)]; + try std.testing.expect(std.mem.indexOf(u8, json, "\"webfetch\"") == null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"load_tool_schemas\"") != null); +} + +test "advertised and confined are the same predicates as the kernels" { + try std.testing.expect(advertised(0, "bash")); + try std.testing.expect(!advertised(FLAG_NO_LOCAL, "bash")); + try std.testing.expect(!advertised(FLAG_IS_SUB, "subagent")); + try std.testing.expect(confined("src/main.zig")); + try std.testing.expect(!confined("/etc/passwd")); +} + +test "writeCatalog returns -1 when the buffer cannot hold the array" { + var tiny: [2]u8 = undefined; + try std.testing.expectEqual(@as(i32, -1), writeCatalog(0, &tiny)); +} diff --git a/src/wasm_main.zig b/src/wasm_main.zig new file mode 100644 index 00000000..43b6327b --- /dev/null +++ b/src/wasm_main.zig @@ -0,0 +1,52 @@ +//! wasm32-freestanding entry for `graff-kernel.wasm`. +//! +//! Not the agent. fx's `fx-core.wasm` is a host-supplied ACP loop (JSPI +//! fetch, no bash). This artifact is the finite kernels — the 64-cell +//! catalog cube and the lexical path jail — so a JS host can evaluate the +//! same function Lean proves, without spawning `graff`. +//! +//! Build: `zig build wasm`. Do not import this file from the native test +//! root: it overrides `panic` and exports the C ABI. + +const abi = @import("wasm_abi.zig"); + +var scratch: [abi.scratch_len]u8 = undefined; + +pub fn panic(msg: []const u8, _: ?*@import("std").builtin.StackTrace, _: ?usize) noreturn { + _ = msg; + while (true) {} +} + +export fn graff_abi_version() u32 { + return abi.abi_version; +} + +export fn graff_cube_cells() u32 { + return @intCast(@import("kernel_catalog.zig").cube_cells); +} + +export fn graff_scratch_ptr() [*]u8 { + return &scratch; +} + +export fn graff_scratch_len() u32 { + return scratch.len; +} + +/// `catalog(flags)` → JSON array in the scratch buffer. Returns byte count +/// or -1 if the buffer is too small (it is not, for this kernel). +export fn graff_catalog(flags: u32) i32 { + return abi.writeCatalog(flags, &scratch); +} + +/// Reads a UTF-8 name from scratch[0..name_len]. 1 = advertised, 0 = not. +export fn graff_advertised(flags: u32, name_len: u32) i32 { + if (name_len > scratch.len) return 0; + return if (abi.advertised(flags, scratch[0..name_len])) 1 else 0; +} + +/// Reads a UTF-8 path from scratch[0..path_len]. 1 = confined, 0 = jail-break. +export fn graff_confined(path_len: u32) i32 { + if (path_len > scratch.len) return 0; + return if (abi.confined(scratch[0..path_len])) 1 else 0; +}