From a9f42e14c3118f6e0e2e1c90be1c13c28dd73eff Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Thu, 8 Oct 2026 00:57:25 -0700 Subject: [PATCH] Include canonical hashing in restricted package execution --- restricted-execution/default.nix | 2 +- restricted-execution/service.test.mjs | 41 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/restricted-execution/default.nix b/restricted-execution/default.nix index 0d4a413..deca898 100644 --- a/restricted-execution/default.nix +++ b/restricted-execution/default.nix @@ -37,7 +37,7 @@ let mkdir -p "$out" cat > entry.mjs <<'JS' export {definePortableRegistry,QxReject} from '${core}/dist/portable.js'; - export {encodeArgument,decodeArgument,canonicalJson,parseJson} from '${core}/dist/value.js'; + export {encodeArgument,decodeArgument,canonicalJson,parseJson,hashJson} from '${core}/dist/value.js'; export {workspaceId,operationId,objectId,entryId,hashIdentity} from '${core}/dist/identity.js'; export {reference} from '${core}/dist/references.js'; export {CodecError,ExecutionError} from '${core}/dist/limits.js'; diff --git a/restricted-execution/service.test.mjs b/restricted-execution/service.test.mjs index b9c258d..b6087b4 100644 --- a/restricted-execution/service.test.mjs +++ b/restricted-execution/service.test.mjs @@ -307,6 +307,47 @@ test("the checked portable SDK keeps codecs and nested graph ports inside the fr await f.close(); } }); +test("checked handlers hash canonical values without ambient capabilities", async () => { + const sdk = process.env.QUIXOS_TEST_SDK; + const C = await import(sdk); + const workspace = C.workspaceId("workspace:restricted"); + const text = { kind: "scalar", name: "string" }; + const value = { + kind: "record", + fields: { + text, + options: { + kind: "record", + fields: { + level: { kind: "scalar", name: "int32" }, + enabled: { kind: "scalar", name: "bool" }, + }, + }, + }, + }; + const declarations = [ + { id: "test", name: "test", effect: "snapshot", receiver: null, input: value, output: text, methods: [] }, + ]; + const source = `import {definePortableRegistry,hashJson} from 'qx:core'; export const registry=definePortableRegistry(${JSON.stringify(declarations)},{test:ctx=>hashJson('test-content',ctx.input)});`; + const f = await fixture(source, { sdk: [{ name: "qx:core", path: sdk }] }); + try { + const input = { text: "雨后的书店", options: { level: 3, enabled: true } }; + let expected; + for (const candidate of [input, { options: input.options, text: input.text }, { ...input, text: "別の文" }]) { + f.start(C.encodeArgument(value, candidate, { workspace })); + const result = await f.receive(); + assert.equal(result.status, "returned"); + const actual = C.decodeArgument(text, result.output, { workspace }); + assert.equal(actual, await C.hashJson("test-content", candidate)); + if (candidate.text === input.text) { + expected ??= actual; + assert.equal(actual, expected, "Object key order must not invalidate content caches"); + } else assert.notEqual(actual, expected, "Content changes must invalidate the key"); + } + } finally { + await f.close(); + } +}); test("ambient clock, entropy, network and native imports are denied", async () => { for (const code of [ "Date.now()",