diff --git a/.gitattributes b/.gitattributes index 8f07f0a..20a6ce1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,8 @@ rootfs/** text eol=lf # Shell scripts run under WSL/Linux and die on CRLF ("$'\r': command not # found"); keep them LF everywhere. scripts/*.sh text eol=lf + +# Frozen contract goldens (ADR-0020) are strict-text-compared byte streams: +# a CRLF checkout on Windows must never fake a gate diff (the extractors +# normalize \r\n on read, but the committed bytes stay LF regardless). +contracts/** text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 965221e..514b2dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -677,6 +677,13 @@ jobs: # against the committed contracts/abi/v1.golden. Pure-static (gcc + binutils, # no boot, no pip), so it stays in the fast lane. The selftest runs FIRST so a # broken generator cannot leave the gate vacuously green. + # + # The WIRE freeze (ADR-0020 lane C) rides the same fast static lane: the + # guest probe (user/wire_probe.c) is compiler-measured the same way, the + # host ring is imported live from stdlib-only scripts/qos_bridge.py (no + # pip), and the twin cross-check keeps emitter and verifier agreeing. + # Its selftest also runs FIRST, and asserts rc == 1 exactly (rc 2 is + # operational, never a caught mutation). steps: - name: Checkout code uses: actions/checkout@v4 @@ -684,6 +691,37 @@ jobs: run: make ci-smoke-abi-golden-selftest - name: ABI golden gate (extracted ABI == committed golden) run: make ci-smoke-abi-golden-gate + - name: Wire golden teeth-check (mutation + twin-teeth must redden the gate) + run: make ci-smoke-wire-golden-selftest + - name: Wire golden gate (extracted wire contract == committed golden) + run: make ci-smoke-wire-golden-gate + + mcp-schema: + name: v1 MCP tool-surface freeze gate (golden diff + teeth-check — ADR-0020) + runs-on: ubuntu-latest + # The MCP tool schemas (contract b of ADR-0020) need `mcp` + pydantic, which + # the stdlib-only integration lane forbids — so this is its own pinned-pip + # lane (the quantum-gateway shape). requirements-mcp-gate.txt pins the exact + # schema GENERATORS (mcp/pydantic/pydantic-core) and the golden's _meta + # records the same pins; the extractor self-checks them (a resolver bump is + # rc 2 GENERATOR SKEW, never a fake diff). The selftest runs FIRST and + # asserts rc == 1 exactly, so a skewed or broken generator cannot pass as a + # caught mutation. MCP_PY=python3: the venv indirection is for local runs. + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: requirements-mcp-gate.txt + - name: Install pinned MCP schema generators + run: pip install --retries 5 -r requirements-mcp-gate.txt + - name: MCP schema teeth-check (a mutated schema must redden the gate) + run: make ci-smoke-mcp-schema-selftest MCP_PY=python3 + - name: MCP schema gate (extracted tool surface == committed golden) + run: make ci-smoke-mcp-schema-gate MCP_PY=python3 quantum-com2: name: COM2 Quantum-Submit (host → broker → qpud over the wire — epic #149 B1) diff --git a/Makefile b/Makefile index bca3d87..cb44f09 100644 --- a/Makefile +++ b/Makefile @@ -2551,3 +2551,107 @@ ci-smoke-abi-golden-selftest: $(ABI_PROBE_KERN) @mkdir -p $(BUILD_DIR)/abi $(CC) $(USER_CFLAGS) -DSYS_QPU_OVERRIDE=36 -c $(USER_DIR)/abi_probe.c -o $(BUILD_DIR)/abi/abi_probe_user_mut.o @if timeout -k 5 $(ABI_GOLDEN_GATE_TIMEOUT) python3 scripts/extract-abi.py check --golden $(ABI_GOLDEN) $(BUILD_DIR)/abi/abi_probe_user_mut.o $(ABI_PROBE_KERN) >/dev/null 2>&1; then echo "TEETH-CHECK FAILED: a mutated SYS_QPU did NOT redden the gate"; exit 1; else echo "teeth-check OK: the mutation reddened the gate"; fi + +# --------------------------------------------------------------------------- +# v1 WIRE freeze gate (ADR-0020 lane C). One probe TU (user/wire_probe.c) +# emits the COM2 swarm-bridge + FSYN/FSYP wire contract into .abi_ents under +# the REAL user build flags; scripts/extract-wire.py reads it back with +# objcopy, builds the HOST ring live from scripts/qos_bridge.py (the one host +# wire implementation), cross-checks the twins, and diffs the merged table +# against contracts/wire/v1.golden. The gate NEVER regenerates the golden +# (regen-wire-golden is human-only). Exit-code discipline: rc 1 = contract +# signal (diff / twin / must-twin) ONLY; rc 2 = operational — so the selftest +# below asserts rc == 1 EXACTLY, and a broken extractor can never pass as a +# caught mutation. Timeout in ONE place (the #93 desync lesson); no kernel +# prereq (fast static lane). +# --------------------------------------------------------------------------- +WIRE_GOLDEN_GATE_TIMEOUT ?= 60s +WIRE_GOLDEN := contracts/wire/v1.golden +WIRE_PROBE_USER := $(BUILD_DIR)/abi/wire_probe_user.o + +$(WIRE_PROBE_USER): $(USER_DIR)/wire_probe.c $(USER_DIR)/swarm.h $(USER_DIR)/fsyn.h $(USER_DIR)/ghost.h $(USER_DIR)/usys.h + @mkdir -p $(dir $@) + $(CC) $(USER_CFLAGS) -c $< -o $@ + +.PHONY: regen-wire-golden ci-smoke-wire-golden-gate ci-smoke-wire-golden-selftest + +# HUMAN-ONLY: regenerate the committed golden after an intended wire change. +regen-wire-golden: $(WIRE_PROBE_USER) + python3 scripts/extract-wire.py emit --out $(WIRE_GOLDEN) $(WIRE_PROBE_USER) + +# CI: diff extracted-vs-committed; NEVER writes the golden. +ci-smoke-wire-golden-gate: $(WIRE_PROBE_USER) + timeout -k 5 $(WIRE_GOLDEN_GATE_TIMEOUT) python3 scripts/extract-wire.py check --golden $(WIRE_GOLDEN) $(WIRE_PROBE_USER) + +# CI teeth-check, two legs, each asserting rc == 1 EXACTLY plus the specific +# mutated marker: (a) a guest probe recompiled with a mutated SWARM_MAGIC must +# redden the gate NAMING the mutated entry; (b) QOS_WIRE_TEETH=1 (the host +# ring perturbed inside the EXTRACTOR — teeth never live in production code) +# must trip the TWIN MISMATCH path specifically. +ci-smoke-wire-golden-selftest: $(WIRE_PROBE_USER) + @mkdir -p $(BUILD_DIR)/abi + $(CC) $(USER_CFLAGS) -DSWARM_MAGIC_OVERRIDE=0xA6 -c $(USER_DIR)/wire_probe.c -o $(BUILD_DIR)/abi/wire_probe_user_mut.o + @out=$$(timeout -k 5 $(WIRE_GOLDEN_GATE_TIMEOUT) python3 scripts/extract-wire.py check --golden $(WIRE_GOLDEN) $(BUILD_DIR)/abi/wire_probe_user_mut.o 2>&1); rc=$$?; \ + if [ $$rc -ne 1 ]; then echo "TEETH-CHECK FAILED: mutated SWARM_MAGIC gave rc=$$rc (want exactly 1)"; echo "$$out"; exit 1; fi; \ + if ! echo "$$out" | grep -q "guest:wire:SWARM_MAGIC"; then echo "TEETH-CHECK FAILED: the diff does not name the mutated guest:wire:SWARM_MAGIC entry"; echo "$$out"; exit 1; fi; \ + echo "teeth-check OK (a): a mutated guest SWARM_MAGIC reddened the gate (rc=1, entry named)" + @out=$$(QOS_WIRE_TEETH=1 timeout -k 5 $(WIRE_GOLDEN_GATE_TIMEOUT) python3 scripts/extract-wire.py check --golden $(WIRE_GOLDEN) $(WIRE_PROBE_USER) 2>&1); rc=$$?; \ + if [ $$rc -ne 1 ]; then echo "TEETH-CHECK FAILED: QOS_WIRE_TEETH=1 gave rc=$$rc (want exactly 1)"; echo "$$out"; exit 1; fi; \ + if ! echo "$$out" | grep -q "TWIN MISMATCH"; then echo "TEETH-CHECK FAILED: teeth did not trip the TWIN MISMATCH path"; echo "$$out"; exit 1; fi; \ + echo "teeth-check OK (b): QOS_WIRE_TEETH tripped TWIN MISMATCH (rc=1)" + +# --------------------------------------------------------------------------- +# v1 MCP tool-surface freeze gate (ADR-0020 lane B). scripts/extract-mcp- +# schema.py imports qos_mcp, lists the FastMCP tools, normalizes each +# inputSchema (title/description stripped — pydantic re-derives those from +# docstrings, which are NOT frozen) and diffs {name, inputSchema} against +# contracts/mcp/v1-tools.json. Frozen = name + inputSchema; docstrings and +# result-dict shapes are documented, not gated. Needs the `mcp` package, which +# the stdlib-only lanes forbid — so it runs out of a pinned venv +# (requirements-mcp-gate.txt) locally, or with MCP_PY=python3 in the dedicated +# pip-provisioned mcp-schema CI job. The check self-verifies the generator +# versions against the golden's _meta pins (mismatch = rc 2 GENERATOR SKEW, +# operational — never a fake diff). rc 1 = schema diff ONLY. +# --------------------------------------------------------------------------- +MCP_SCHEMA_GATE_TIMEOUT ?= 120s +MCP_GOLDEN := contracts/mcp/v1-tools.json +MCP_VENV := $(BUILD_DIR)/mcp-venv +MCP_PY ?= $(MCP_VENV)/bin/python + +# The venv is a prerequisite only while MCP_PY still points into it (CI passes +# MCP_PY=python3 after its own pinned `pip install`). +ifeq ($(MCP_PY),$(MCP_VENV)/bin/python) +MCP_VENV_DEP := $(MCP_VENV)/.stamp +else +MCP_VENV_DEP := +endif + +$(MCP_VENV)/.stamp: requirements-mcp-gate.txt + python3 -m venv $(MCP_VENV) + $(MCP_VENV)/bin/pip install -r requirements-mcp-gate.txt + touch $@ + +.PHONY: regen-mcp-golden ci-smoke-mcp-schema-gate ci-smoke-mcp-schema-selftest + +# HUMAN-ONLY: regenerate the committed golden after an intended tool change. +regen-mcp-golden: $(MCP_VENV_DEP) + $(MCP_PY) scripts/extract-mcp-schema.py emit --out $(MCP_GOLDEN) + +# CI: diff extracted-vs-committed; NEVER writes the golden. +ci-smoke-mcp-schema-gate: $(MCP_VENV_DEP) + timeout -k 5 $(MCP_SCHEMA_GATE_TIMEOUT) $(MCP_PY) scripts/extract-mcp-schema.py check --golden $(MCP_GOLDEN) + +# CI teeth-check, two legs, each asserting rc == 1 EXACTLY (rc 2 is GENERATOR +# SKEW / operational and must NOT pass as a caught mutation) plus the specific +# mutated marker: QOS_MCP_TEETH=del deletes a property from the qos_qpu_submit +# schema in-memory (a changed-VALUE mutation, not just an added/removed name); +# QOS_MCP_TEETH=add appends a bogus tool. Teeth live in the EXTRACTOR only. +ci-smoke-mcp-schema-selftest: $(MCP_VENV_DEP) + @out=$$(QOS_MCP_TEETH=del timeout -k 5 $(MCP_SCHEMA_GATE_TIMEOUT) $(MCP_PY) scripts/extract-mcp-schema.py check --golden $(MCP_GOLDEN) 2>&1); rc=$$?; \ + if [ $$rc -ne 1 ]; then echo "TEETH-CHECK FAILED: QOS_MCP_TEETH=del gave rc=$$rc (want exactly 1; 2 = skew/operational is a selftest FAILURE)"; echo "$$out"; exit 1; fi; \ + if ! echo "$$out" | grep -q "TOOL SCHEMA DRIFT: qos_qpu_submit"; then echo "TEETH-CHECK FAILED: the diff does not name the mutated qos_qpu_submit schema"; echo "$$out"; exit 1; fi; \ + echo "teeth-check OK (del): a deleted qos_qpu_submit property reddened the gate (rc=1)" + @out=$$(QOS_MCP_TEETH=add timeout -k 5 $(MCP_SCHEMA_GATE_TIMEOUT) $(MCP_PY) scripts/extract-mcp-schema.py check --golden $(MCP_GOLDEN) 2>&1); rc=$$?; \ + if [ $$rc -ne 1 ]; then echo "TEETH-CHECK FAILED: QOS_MCP_TEETH=add gave rc=$$rc (want exactly 1)"; echo "$$out"; exit 1; fi; \ + if ! echo "$$out" | grep -q "TOOL ADDED: qos_teeth_bogus_tool"; then echo "TEETH-CHECK FAILED: the diff does not name the bogus added tool"; echo "$$out"; exit 1; fi; \ + echo "teeth-check OK (add): a bogus added tool reddened the gate (rc=1)" diff --git a/contracts/mcp/v1-tools.json b/contracts/mcp/v1-tools.json new file mode 100644 index 0000000..53d37e5 --- /dev/null +++ b/contracts/mcp/v1-tools.json @@ -0,0 +1,315 @@ +{ + "_meta": { + "contract": "qos-mcp-tools", + "frozen": "name+inputSchema; descriptions and result-dict shapes excluded (documented in the machine-readable agent docs, not gated)", + "mcp": "1.28.1", + "pydantic": "2.13.4", + "pydantic-core": "2.46.4", + "regen": "make regen-mcp-golden", + "version": 1 + }, + "tools": [ + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_audit" + }, + { + "inputSchema": { + "properties": { + "kernel": { + "default": "", + "type": "string" + }, + "qseed": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "name": "qos_boot" + }, + { + "inputSchema": { + "properties": { + "allow_private": { + "default": false, + "type": "boolean" + }, + "host": { + "type": "string" + }, + "port": { + "default": 80, + "type": "integer" + } + }, + "required": [ + "host" + ], + "type": "object" + }, + "name": "qos_fetch" + }, + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_field_status" + }, + { + "inputSchema": { + "properties": { + "op": { + "type": "string" + }, + "path": { + "default": "", + "type": "string" + }, + "text": { + "default": "", + "type": "string" + } + }, + "required": [ + "op" + ], + "type": "object" + }, + "name": "qos_fs" + }, + { + "inputSchema": { + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "name": "qos_imprint" + }, + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_manifest" + }, + { + "inputSchema": { + "properties": { + "query": { + "type": "string" + }, + "top_k": { + "default": 3, + "type": "integer" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "qos_memory_bridged_recall" + }, + { + "inputSchema": { + "properties": { + "allow_write": { + "default": false, + "type": "boolean" + }, + "importance": { + "default": 0.6, + "type": "number" + }, + "probe": { + "type": "string" + } + }, + "required": [ + "probe" + ], + "type": "object" + }, + "name": "qos_memory_export" + }, + { + "inputSchema": { + "properties": { + "query": { + "type": "string" + }, + "top_k": { + "default": 5, + "type": "integer" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "qos_memory_import" + }, + { + "inputSchema": { + "properties": { + "iters": { + "default": 1, + "type": "integer" + }, + "kind": { + "default": "bell", + "type": "string" + }, + "n_qubits": { + "default": 3, + "type": "integer" + }, + "probe": { + "default": 0, + "type": "integer" + }, + "target": { + "default": 0, + "type": "integer" + } + }, + "type": "object" + }, + "name": "qos_qpu_submit" + }, + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_qrand" + }, + { + "inputSchema": { + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "name": "qos_recall" + }, + { + "inputSchema": { + "properties": { + "args": { + "default": "", + "type": "string" + }, + "program": { + "type": "string" + } + }, + "required": [ + "program" + ], + "type": "object" + }, + "name": "qos_run" + }, + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_shutdown" + }, + { + "inputSchema": { + "properties": { + "threshold": { + "default": 0.8, + "type": "number" + } + }, + "type": "object" + }, + "name": "qos_society_await_sync" + }, + { + "inputSchema": { + "properties": { + "qseed_a": { + "type": "string" + }, + "qseed_b": { + "type": "string" + } + }, + "required": [ + "qseed_a", + "qseed_b" + ], + "type": "object" + }, + "name": "qos_society_boot" + }, + { + "inputSchema": { + "properties": { + "qseeds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "qseeds" + ], + "type": "object" + }, + "name": "qos_society_boot_n" + }, + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_society_shutdown" + }, + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_society_status" + }, + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_status" + }, + { + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "qos_sysinfo" + } + ] +} diff --git a/contracts/wire/v1.golden b/contracts/wire/v1.golden new file mode 100644 index 0000000..bbbbe78 --- /dev/null +++ b/contracts/wire/v1.golden @@ -0,0 +1,88 @@ +# QuantumOS v1 WIRE contract (ADR-0020 lane C): COM2 swarm-bridge framing, +# reply-auth geometry, Lamport attestation parameters, FSYN/FSYP coupling +# frames, and attestation-string KATs — guest ring (user/wire_probe.c, +# compiler-measured) twinned against the host ring (scripts/qos_bridge.py). +# Regenerate ONLY for an INTENDED wire change (human-only, never in CI): +# make regen-wire-golden +# then commit the diff. A v1 wire change is a contract break: follow the +# semver procedure in docs/adr/0020-v1-contract-freeze.md (bump the frozen +# contract version; a silent regen without the version call is a break). +guest:kat:WIRE_ATTEST_HEAD_0 = 6075161567992958801 +guest:kat:WIRE_ATTEST_HEAD_1 = 17280360235889020 +guest:kat:WIRE_ATTEST_TICKS_0 = 17296878645900412 +guest:off:fsyn.magic = 0 +guest:off:fsyn.phase = 8 +guest:off:fsyn.seq = 4 +guest:off:fsyn.tag = 264 +guest:off:fsyp.aggregate = 4 +guest:off:fsyp.magic = 0 +guest:off:fsyp.reserved0 = 8 +guest:size:fsyn_frame_t = 296 +guest:size:fsyp_frame_t = 16 +guest:wire:FRAME_ATTEST = 17 +guest:wire:FRAME_DATA = 2 +guest:wire:FRAME_DISCONNECT = 5 +guest:wire:FRAME_HANDSHAKE = 1 +guest:wire:FRAME_PING = 3 +guest:wire:FRAME_PKDIGEST = 16 +guest:wire:FRAME_PONG = 4 +guest:wire:FRAME_SIG = 18 +guest:wire:FSYN_MAC_COVERED = 264 +guest:wire:FSYN_MAGIC = 1314476870 +guest:wire:FSYN_PORT = 4747 +guest:wire:FSYP_MAGIC = 1348031302 +guest:wire:LAMPORT_BITS = 256 +guest:wire:LAMPORT_HASH_LEN = 32 +guest:wire:LAMPORT_SEED_LEN = 32 +guest:wire:LAMPORT_SIG_ELEM = 64 +guest:wire:LAMPORT_SIG_LEN = 16384 +guest:wire:SWARM_CRC8_INIT = 0 +guest:wire:SWARM_CRC8_POLY = 7 +guest:wire:SWARM_HDR_LEN = 4 +guest:wire:SWARM_KEY_LEN = 32 +guest:wire:SWARM_MAGIC = 165 +guest:wire:SWARM_MAX_PAYLOAD = 512 +guest:wire:SWARM_OP_KEY = 4 +guest:wire:SWARM_OP_QSUBMIT = 3 +guest:wire:SWARM_OP_RECALL = 2 +guest:wire:SWARM_OP_STATUS = 1 +guest:wire:SWARM_QSUBMIT_BODY_ERR = 2 +guest:wire:SWARM_QSUBMIT_BODY_OK = 22 +guest:wire:SWARM_RECALL_BODY_LEN = 6 +guest:wire:SWARM_REPLYAUTH_NONCE_LEN = 16 +guest:wire:SWARM_REPLYAUTH_TAG_LEN = 32 +guest:wire:SWARM_STATUS_BODY_LEN = 6 +guest:wire:SWARM_STATUS_R_SCALE = 65536 +host:kat:WIRE_ATTEST_HEAD_0 = 6075161567992958801 +host:kat:WIRE_ATTEST_HEAD_1 = 17280360235889020 +host:kat:WIRE_ATTEST_TICKS_0 = 17296878645900412 +host:kat:ping_frame = 811748819877 +host:wire:FRAME_ATTEST = 17 +host:wire:FRAME_DATA = 2 +host:wire:FRAME_DISCONNECT = 5 +host:wire:FRAME_HANDSHAKE = 1 +host:wire:FRAME_PING = 3 +host:wire:FRAME_PKDIGEST = 16 +host:wire:FRAME_PONG = 4 +host:wire:FRAME_SIG = 18 +host:wire:LAMPORT_BITS = 256 +host:wire:LAMPORT_HASH_LEN = 32 +host:wire:LAMPORT_SIG_ELEM = 64 +host:wire:LAMPORT_SIG_LEN = 16384 +host:wire:SWARM_CRC8_INIT = 0 +host:wire:SWARM_CRC8_POLY = 7 +host:wire:SWARM_HDR_LEN = 4 +host:wire:SWARM_KEY_LEN = 32 +host:wire:SWARM_MAGIC = 165 +host:wire:SWARM_MAX_PAYLOAD = 512 +host:wire:SWARM_OP_KEY = 4 +host:wire:SWARM_OP_QSUBMIT = 3 +host:wire:SWARM_OP_RECALL = 2 +host:wire:SWARM_OP_STATUS = 1 +host:wire:SWARM_QSUBMIT_BODY_ERR = 2 +host:wire:SWARM_QSUBMIT_BODY_OK = 22 +host:wire:SWARM_RECALL_BODY_LEN = 6 +host:wire:SWARM_REPLYAUTH_NONCE_LEN = 16 +host:wire:SWARM_REPLYAUTH_TAG_LEN = 32 +host:wire:SWARM_STATUS_BODY_LEN = 6 +host:wire:SWARM_STATUS_R_SCALE = 65536 diff --git a/docs/adr/0020-v1-contract-freeze.md b/docs/adr/0020-v1-contract-freeze.md index c889cdb..c5fe01d 100644 --- a/docs/adr/0020-v1-contract-freeze.md +++ b/docs/adr/0020-v1-contract-freeze.md @@ -1,7 +1,7 @@ # 20. Freeze the v1 Agent-Surface Contracts Date: 2026-07-11 -Status: Accepted (guest syscall ABI frozen 2026-07-13, PRs #206–#211; MCP-schema and COM2/attestation lanes deferred) +Status: Accepted (all three lanes complete 2026-07-15: guest syscall ABI frozen 2026-07-13 PRs #206–#211; MCP tool surface + COM2/attestation wire frozen 2026-07-15) ## Context @@ -88,6 +88,46 @@ scoped as follow-ups, not part of this closure: durable-format space speculatively for an unbuilt syscall) is left open pending an explicit call. +## Update (2026-07-15) — lanes B and C frozen; all three contracts gated + +The two deferred lanes shipped as one increment (branch `feat/adr-0020-freeze-lanes`): + +- **Wire golden (contract c, `contracts/wire/v1.golden`).** A guest probe TU + (`user/wire_probe.c`, compiler-measured under real `USER_CFLAGS` — the + abi-golden pattern verbatim) plus a **live host ring** imported from + `scripts/qos_bridge.py` by `scripts/extract-wire.py`. The literals both sides + used are now shared named constants (`user/swarm.h` `SWARM_CRC8_*`/ + `SWARM_ATTEST_*`/reply-auth body lens; the FSYN/FSYP frames moved to + `user/fsyn.h`; `qos_bridge` module constants), and the extractor + **twin-cross-checks** guest vs host — plus a MUST-TWIN set so a one-sided + deletion is a named failure, not a hole. Attestation-string KATs are packed + from the SHARED macros on the guest and REBUILT from the parser's pieces on + the host, so emitter and verifier cannot drift apart silently. +- **MCP tool-surface golden (contract b, `contracts/mcp/v1-tools.json`).** + `scripts/extract-mcp-schema.py` freezes `name + inputSchema` per tool, + **normalized** (docstring-derived `title`/`description` stripped — pydantic + re-words those without wire consequence). The generators are **pinned** + (`requirements-mcp-gate.txt`: mcp 1.28.1, pydantic 2.13.4, pydantic-core + 2.46.4) and recorded in the golden's `_meta`; the check self-verifies the + environment against those pins first (mismatch = exit 2 `GENERATOR SKEW`, + operational, never a fake diff). Runs in its own pinned-pip CI job + (`mcp-schema`, the quantum-gateway shape) — the integration lane stays + stdlib-only. Pre-freeze fix: `qos_society_boot_n(qseeds: list[str])` so the + frozen schema has a typed items schema. +- **Shared gate discipline.** Both extractors: exit 1 = contract signal ONLY + (diff/twin/must-twin), exit 2 = operational; selftests run FIRST in CI and + assert rc == 1 **exactly** plus the specific mutated marker; teeth live in + the extractors (never production code) and **emit refuses to run under + teeth**; regen targets are human-only; goldens are LF-pinned + (`.gitattributes contracts/**`). +- **Documented-not-gated:** tool descriptions and result-dict shapes are + excluded from the freeze — they belong to the machine-readable agent-docs + item (the Decision's last bullet), which remains the follow-up that + documents them. +- **Follow-ups for the maintainer:** retrofit the same `#` header comment onto + `contracts/abi/v1.golden` (its extractor does not strip comments yet), and + add the two new gates to branch-protection required checks. + ## Consequences ### Positive diff --git a/requirements-mcp-gate.txt b/requirements-mcp-gate.txt new file mode 100644 index 0000000..054a75b --- /dev/null +++ b/requirements-mcp-gate.txt @@ -0,0 +1,10 @@ +# Pinned schema GENERATORS for the v1 MCP tool-surface freeze gate (ADR-0020 +# lane B). pydantic/mcp derive the JSON inputSchemas, so a resolver bump can +# re-shape a schema with no source change — the golden's _meta records these +# same pins and scripts/extract-mcp-schema.py self-checks them (a mismatch is +# rc 2 GENERATOR SKEW, operational). Re-pin only together with an intentional +# `make regen-mcp-golden`. Used by the mcp-schema CI job and the local +# build venv (make ci-smoke-mcp-schema-gate). +mcp==1.28.1 +pydantic==2.13.4 +pydantic-core==2.46.4 diff --git a/scripts/extract-mcp-schema.py b/scripts/extract-mcp-schema.py new file mode 100644 index 0000000..ddb698b --- /dev/null +++ b/scripts/extract-mcp-schema.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Extract + freeze the QuantumOS v1 MCP tool surface (ADR-0020 lane B). + +Imports scripts/qos_mcp.py (the FastMCP server), lists its tools, and either +emits or checks the committed golden contracts/mcp/v1-tools.json. What is +FROZEN is each tool's `name` + normalized `inputSchema` — the surface an +agent's tool-call marshalling depends on. Docstring-derived `title` and +`description` keys are STRIPPED recursively before the compare (pydantic +re-words those without any wire consequence), and tool RESULT-dict shapes are +documented-not-gated (they are dynamic dicts; the machine-readable agent-docs +item owns them). + +The golden is generator-version-pinned: pydantic/mcp derive JSON schemas, and +a resolver bump can re-shape a schema with no source change. check therefore +self-verifies importlib.metadata versions against the golden's _meta pins +FIRST — a mismatch is 'GENERATOR SKEW', exit 2 (operational: fix the +environment via requirements-mcp-gate.txt, or intentionally re-pin), never a +fake contract diff. + +Exit-code discipline (the CI selftest asserts rc == 1, not merely != 0): + 1 contract diff ONLY (tool added/removed/schema drift vs the golden) + 2 operational (missing/unparseable golden, import failure, version skew) + +QOS_MCP_TEETH (teeth live in THIS extractor, never in qos_mcp.py): + del delete one property from the qos_qpu_submit inputSchema in-memory — + a changed-VALUE mutation (the tool LIST is unchanged), proving the + gate catches more than an added/removed name. + add append a bogus tool. +emit REFUSES to run under teeth (exit 2), so a poisoned golden cannot be +written. Modes: extract | emit --out GOLDEN | check --golden GOLDEN. + +SPDX-License-Identifier: GPL-2.0-only +""" +import argparse +import asyncio +import importlib.metadata +import json +import os +import sys + +CONTRACT = "qos-mcp-tools" +CONTRACT_VERSION = 1 +REGEN = "make regen-mcp-golden" +FROZEN = ("name+inputSchema; descriptions and result-dict shapes excluded " + "(documented in the machine-readable agent docs, not gated)") +# The schema GENERATORS whose versions are pinned in requirements-mcp-gate.txt +# and self-checked against the golden's _meta on every run. +PINNED_DISTS = ("mcp", "pydantic", "pydantic-core") + +BOGUS_TOOL_NAME = "qos_teeth_bogus_tool" + + +def die(msg): + """Operational failure: rc 2, never confusable with a contract diff.""" + sys.stderr.write(msg.rstrip("\n") + "\n") + sys.exit(2) + + +def strip_meta(node): + """Recursively drop 'title' and 'description' keys: pydantic derives them + from docstrings/field names, which are documentation, not wire contract.""" + if isinstance(node, dict): + return {k: strip_meta(v) for k, v in node.items() + if k not in ("title", "description")} + if isinstance(node, list): + return [strip_meta(v) for v in node] + return node + + +def measured_versions(): + out = {} + for dist in PINNED_DISTS: + try: + out[dist] = importlib.metadata.version(dist) + except importlib.metadata.PackageNotFoundError: + die("generator package %r is not installed — pip install -r " + "requirements-mcp-gate.txt (or use the make venv)" % dist) + return out + + +def load_tools(): + """Import qos_mcp and return the sorted frozen-surface entries, with the + QOS_MCP_TEETH mutation applied in-memory when requested.""" + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + try: + import qos_mcp + except Exception as exc: # noqa: BLE001 — any import failure is operational + die("cannot import qos_mcp (mcp package missing?): %s" % exc) + try: + tools = asyncio.run(qos_mcp.mcp.list_tools()) + except Exception as exc: # noqa: BLE001 + die("mcp.list_tools() failed: %s" % exc) + entries = [{"name": t.name, "inputSchema": strip_meta(t.inputSchema)} + for t in tools] + if not entries: + die("qos_mcp exposes zero tools (extractor or server broken)") + + teeth = os.environ.get("QOS_MCP_TEETH") + if teeth == "del": + for e in entries: + if e["name"] == "qos_qpu_submit": + props = e["inputSchema"].get("properties") or {} + if not props: + die("teeth=del: qos_qpu_submit has no properties to delete") + victim = "probe" if "probe" in props else sorted(props)[0] + del props[victim] + break + else: + die("teeth=del: qos_qpu_submit tool not found") + elif teeth == "add": + entries.append({"name": BOGUS_TOOL_NAME, + "inputSchema": {"properties": {}, "type": "object"}}) + elif teeth: + die("unknown QOS_MCP_TEETH mode %r (want del|add)" % teeth) + + entries.sort(key=lambda e: e["name"]) + return entries + + +def render(entries, versions): + doc = { + "_meta": { + "contract": CONTRACT, + "version": CONTRACT_VERSION, + "mcp": versions["mcp"], + "pydantic": versions["pydantic"], + "pydantic-core": versions["pydantic-core"], + "regen": REGEN, + "frozen": FROZEN, + }, + "tools": entries, + } + return json.dumps(doc, sort_keys=True, indent=2) + "\n" + + +def tool_map(doc): + return {t["name"]: t.get("inputSchema") for t in doc.get("tools", [])} + + +def main(): + ap = argparse.ArgumentParser( + description="QuantumOS v1 MCP tool-surface freeze gate (ADR-0020 lane B)") + ap.add_argument("mode", choices=["extract", "emit", "check"]) + ap.add_argument("--golden", help="committed golden file (check mode)") + ap.add_argument("--out", help="golden file to write (emit mode)") + args = ap.parse_args() + + versions = measured_versions() + + if args.mode == "emit": + if not args.out: + ap.error("emit needs --out") + if os.environ.get("QOS_MCP_TEETH"): + # Refuse to mint a golden from a teeth-mutated surface: an emit + # run under the selftest environment would freeze the mutation. + die("refusing to emit a golden with QOS_MCP_TEETH set") + entries = load_tools() + outdir = os.path.dirname(args.out) + if outdir: + os.makedirs(outdir, exist_ok=True) + with open(args.out, "w", newline="\n") as f: + f.write(render(entries, versions)) + sys.stderr.write("wrote %d tools to %s (mcp %s, pydantic %s)\n" + % (len(entries), args.out, versions["mcp"], versions["pydantic"])) + return 0 + + entries = load_tools() + text = render(entries, versions) + + if args.mode == "extract": + sys.stdout.write(text) + return 0 + + # check + if not args.golden: + ap.error("check needs --golden") + if not os.path.exists(args.golden): + die("FROZEN MCP GATE: golden %s is missing -- run `%s` " + "(operational, not a diff)" % (args.golden, REGEN)) + with open(args.golden, "r", newline="") as f: + want = f.read().replace("\r\n", "\n") + try: + want_doc = json.loads(want) + except json.JSONDecodeError as exc: + die("FROZEN MCP GATE: golden %s is not valid JSON: %s" % (args.golden, exc)) + + # Generator self-check FIRST: a pydantic/mcp resolver bump re-shapes + # schemas with no source change, which must surface as SKEW (rc 2, fix the + # pin), never as a contract diff the teeth selftest could mistake for a + # caught mutation. + meta = want_doc.get("_meta", {}) + for dist in PINNED_DISTS: + pinned = meta.get(dist) + if pinned != versions[dist]: + die("FROZEN MCP GATE: GENERATOR SKEW: %s golden pins %r but this " + "environment has %r — align requirements-mcp-gate.txt / the CI " + "pip install with the golden's _meta (or intentionally re-pin " + "via `%s`)" % (dist, pinned, versions[dist], REGEN)) + + if text == want: + return 0 + + # Structured per-tool report (named markers), then the strict verdict. + sys.stderr.write("FROZEN MCP GATE: extracted tool surface != %s\n" % args.golden) + got_tools = tool_map({"tools": entries}) + want_tools = tool_map(want_doc) + for name in sorted(set(got_tools) - set(want_tools)): + sys.stderr.write(" TOOL ADDED: %s\n" % name) + for name in sorted(set(want_tools) - set(got_tools)): + sys.stderr.write(" TOOL REMOVED: %s\n" % name) + for name in sorted(set(got_tools) & set(want_tools)): + if got_tools[name] != want_tools[name]: + sys.stderr.write(" TOOL SCHEMA DRIFT: %s\n" % name) + want_lines = set(want.splitlines()) + got_lines = set(text.splitlines()) + for line in sorted(got_lines - want_lines): + sys.stderr.write(" + %s\n" % line) + for line in sorted(want_lines - got_lines): + sys.stderr.write(" - %s\n" % line) + sys.stderr.write("If this change is intended, run `%s` and commit the " + "golden diff.\n" % REGEN) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/extract-wire.py b/scripts/extract-wire.py new file mode 100644 index 0000000..8ebecbf --- /dev/null +++ b/scripts/extract-wire.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""Extract + freeze the QuantumOS v1 WIRE contract (ADR-0020 lane C). + +Two rings, one golden. The GUEST ring is compiler-measured: the probe TU +(user/wire_probe.c) emits the COM2 swarm-bridge framing, the DATA reply-body +geometry, the Lamport attestation parameters, the FSYN/FSYP coupling frame +layouts, and packed attestation-string known-answer entries into a .abi_ents +section under the REAL user build flags; we read it back with `objcopy -O +binary` (the extract-abi.py unpacker, verbatim). The HOST ring is imported +live from scripts/qos_bridge.py — the ONE host implementation of the same +wire — so the golden diff catches EITHER side drifting, and the twin +cross-check catches the two sides disagreeing with each other even when both +moved together away from the golden. + +Modes: + extract OBJ... print the canonical table to stdout + emit --out GOLDEN OBJ... write the canonical table to GOLDEN + check --golden GOLDEN OBJ... diff extracted vs GOLDEN + +Exit-code discipline (the CI selftest asserts rc == 1, not merely != 0): + 1 contract signal ONLY: a golden diff, a guest/host TWIN MISMATCH, or a + MUST-TWIN name missing from either ring. + 2 operational failure: missing golden, objcopy/probe trouble, a qos_bridge + import error, or a mapped host attribute gone missing. An operational + failure must never be mistakable for (or masked by) a contract diff. + +QOS_WIRE_TEETH=1 (teeth live in THIS extractor, never in production code): +perturbs the host ring's SWARM_MAGIC by +1 in-memory, which MUST surface as a +TWIN MISMATCH (rc 1) — proving the twin cross-check actually bites. emit +REFUSES to run under teeth (rc 2), so a poisoned golden cannot be written. + +`check` strips leading '# ' header-comment lines from the golden before the +strict text compare, so the committed file can carry its own regen/semver +instructions without them being contract bytes. + +Stdlib-only by contract (the CI integration lane forbids pip). +SPDX-License-Identifier: GPL-2.0-only +""" +import argparse +import os +import re +import struct +import subprocess +import sys +import tempfile + +REC = 64 # bytes per record +NAMELEN = 56 # NUL-padded ASCII name; the remaining 8 bytes are the LE u64 value + +GOLDEN_HEADER = """\ +# QuantumOS v1 WIRE contract (ADR-0020 lane C): COM2 swarm-bridge framing, +# reply-auth geometry, Lamport attestation parameters, FSYN/FSYP coupling +# frames, and attestation-string KATs — guest ring (user/wire_probe.c, +# compiler-measured) twinned against the host ring (scripts/qos_bridge.py). +# Regenerate ONLY for an INTENDED wire change (human-only, never in CI): +# make regen-wire-golden +# then commit the diff. A v1 wire change is a contract break: follow the +# semver procedure in docs/adr/0020-v1-contract-freeze.md (bump the frozen +# contract version; a silent regen without the version call is a break). +""" + +# The wire names that MUST exist on BOTH rings (logical names, ring prefix +# stripped). A refactor that drops one side's constant would otherwise leave a +# HOLE the plain diff reports only as a removed line on one ring — this makes +# it an explicit MUST-TWIN failure instead. +MUST_TWIN = frozenset([ + "wire:SWARM_MAGIC", + "wire:SWARM_MAX_PAYLOAD", + "wire:SWARM_HDR_LEN", + "wire:FRAME_HANDSHAKE", + "wire:FRAME_DATA", + "wire:FRAME_PING", + "wire:FRAME_PONG", + "wire:FRAME_DISCONNECT", + "wire:FRAME_PKDIGEST", + "wire:FRAME_ATTEST", + "wire:FRAME_SIG", + "wire:SWARM_OP_STATUS", + "wire:SWARM_OP_RECALL", + "wire:SWARM_OP_QSUBMIT", + "wire:SWARM_OP_KEY", + "wire:LAMPORT_BITS", + "wire:LAMPORT_HASH_LEN", + "wire:LAMPORT_SIG_ELEM", + "wire:LAMPORT_SIG_LEN", + "wire:SWARM_CRC8_POLY", + "wire:SWARM_CRC8_INIT", + "wire:SWARM_REPLYAUTH_NONCE_LEN", + "wire:SWARM_REPLYAUTH_TAG_LEN", + "wire:SWARM_KEY_LEN", + "wire:SWARM_STATUS_BODY_LEN", + "wire:SWARM_RECALL_BODY_LEN", + "wire:SWARM_QSUBMIT_BODY_ERR", + "wire:SWARM_QSUBMIT_BODY_OK", + "wire:SWARM_STATUS_R_SCALE", + "kat:WIRE_ATTEST_HEAD_0", + "kat:WIRE_ATTEST_HEAD_1", + "kat:WIRE_ATTEST_TICKS_0", +]) + +# qos_bridge attribute -> frozen contract name, for host constants whose +# python spelling differs from the guest macro. Every mapped attribute MUST +# exist (a missing one is an operational failure, rc 2 — the extractor is +# broken, not the contract). +ALIAS = { + "MAGIC": "SWARM_MAGIC", + "SWARM_MAX_PAYLOAD": "SWARM_MAX_PAYLOAD", + "HDR_LEN": "SWARM_HDR_LEN", + "CRC8_POLY": "SWARM_CRC8_POLY", + "CRC8_INIT": "SWARM_CRC8_INIT", + "REPLYAUTH_NONCE_LEN": "SWARM_REPLYAUTH_NONCE_LEN", + "REPLYAUTH_TAG_LEN": "SWARM_REPLYAUTH_TAG_LEN", + "KEY_LEN": "SWARM_KEY_LEN", + "STATUS_BODY_LEN": "SWARM_STATUS_BODY_LEN", + "RECALL_BODY_LEN": "SWARM_RECALL_BODY_LEN", + "QSUBMIT_BODY_ERR": "SWARM_QSUBMIT_BODY_ERR", + "QSUBMIT_BODY_OK": "SWARM_QSUBMIT_BODY_OK", + "STATUS_R_SCALE": "SWARM_STATUS_R_SCALE", + "LAMPORT_BITS": "LAMPORT_BITS", + "HASH_LEN": "LAMPORT_HASH_LEN", + "SIG_ELEM": "LAMPORT_SIG_ELEM", + "SIG_LEN": "LAMPORT_SIG_LEN", +} + +# A host module constant matching this pattern joins the host ring even when +# unmapped: a NEW host wire constant then shows up as a golden '+' diff (a +# reviewable addition), never as an invisible hole. +AUTO_DISCOVER = re.compile( + r"^(FRAME_|SWARM_OP_|LAMPORT_|CRC8_|REPLYAUTH_|STATUS_|RECALL_|QSUBMIT_" + r"|KEY_LEN|HDR_LEN|MAGIC)") + + +def die(msg): + """Operational failure: rc 2, never confusable with a contract diff.""" + sys.stderr.write(msg.rstrip("\n") + "\n") + sys.exit(2) + + +def read_section(obj): + """Return {name: unsigned_u64} for every record in obj's .abi_ents section.""" + fd, tmp = tempfile.mkstemp(suffix=".wiresec") + os.close(fd) + try: + r = subprocess.run( + ["objcopy", "-O", "binary", "--only-section=.abi_ents", obj, tmp], + capture_output=True, text=True) + if r.returncode != 0: + die("objcopy failed on %s: %s" % (obj, r.stderr.strip())) + with open(tmp, "rb") as f: + raw = f.read() + finally: + try: + os.remove(tmp) + except OSError: + pass + if len(raw) == 0: + die("%s: .abi_ents is empty (probe failed to compile?)" % obj) + if len(raw) % REC != 0: + die("%s: .abi_ents is %d bytes, not a multiple of %d" % (obj, len(raw), REC)) + ents = {} + for i in range(0, len(raw), REC): + rec = raw[i:i + REC] + name = rec[:NAMELEN].split(b"\x00", 1)[0].decode("ascii") + (value,) = struct.unpack(" %s)" % (attr, contract)) + v = getattr(qos_bridge, attr) + if not isinstance(v, int) or isinstance(v, bool): + die("qos_bridge.%s is not an int (host ring broken)" % attr) + ents["host:wire:" + contract] = v & ((1 << 64) - 1) + + # Attestation KATs, REBUILT the way parse_attestation consumes the string: + # head = ATTEST_MAGIC + "|" + ATTEST_QSEED_KEY must byte-equal the guest's + # SWARM_ATTEST_HEAD "QOS-BOOT|qseed=". Packing the reconstruction (never a + # re-typed literal) is what twins the host PARSER against the guest EMITTER. + for attr in ("ATTEST_MAGIC", "ATTEST_QSEED_KEY", "ATTEST_TICKS_KEY"): + if not hasattr(qos_bridge, attr): + die("qos_bridge is missing attestation piece %s" % attr) + head = qos_bridge.ATTEST_MAGIC + "|" + qos_bridge.ATTEST_QSEED_KEY + ticks = "|" + qos_bridge.ATTEST_TICKS_KEY + if len(head) > 15 or len(ticks) > 7: + die("attestation piece grew past its KAT window (head %d > 15 or " + "ticks %d > 7) — extend the KAT entries on BOTH rings" % (len(head), len(ticks))) + head_b = head.encode("ascii") + ents["host:kat:WIRE_ATTEST_HEAD_0"] = pack8(head_b, 0) + ents["host:kat:WIRE_ATTEST_HEAD_1"] = pack8(head_b, 8) + ents["host:kat:WIRE_ATTEST_TICKS_0"] = pack8(ticks.encode("ascii"), 0) + + # Behavioural KAT, host-only (documented UNTWINNED: the guest builds + # frames imperatively, so there is no guest expression to twin against): + # the exact 5 wire bytes of an empty PING — magic+type+len exercise the + # header layout and the CRC8 parameters end to end through frame()/crc8(). + ents["host:kat:ping_frame"] = int.from_bytes( + qos_bridge.frame(qos_bridge.FRAME_PING, b""), "little") + + if os.environ.get("QOS_WIRE_TEETH") == "1": + # Teeth live HERE, in the extractor, never in qos_bridge itself: a + # production-code hook could be shipped enabled. Perturbing a TWINNED + # value exercises the TWIN MISMATCH path specifically. + ents["host:wire:SWARM_MAGIC"] += 1 + return ents + + +def collect(objs): + merged = host_ring() + for o in objs: + for name, value in read_section(o).items(): + if name in merged and merged[name] != value: + die("duplicate name %s across rings with differing values" % name) + merged[name] = value + if not merged: + die("no wire entries extracted from %r" % (objs,)) + return merged + + +def render(v): + """u64 -> stable decimal; values with the sign bit set render as int64 so + errnos read as -1, -11 rather than a giant unsigned constant.""" + return str(v - (1 << 64)) if v >= (1 << 63) else str(v) + + +def canonical(ents): + return "".join("%s = %s\n" % (n, render(ents[n])) for n in sorted(ents)) + + +def strip_header(text): + """Drop the leading '#' comment block (the emit-time header) so the strict + compare sees only contract lines.""" + lines = text.split("\n") + i = 0 + while i < len(lines) and lines[i].startswith("#"): + i += 1 + return "\n".join(lines[i:]) + + +def twin_errors(ents): + """A logical name (ring prefix stripped) present on both rings must carry + the same value.""" + by_logical = {} + for name, v in ents.items(): + ring, rest = name.split(":", 1) + by_logical.setdefault(rest, {})[ring] = v + errs = [] + for rest, rings in sorted(by_logical.items()): + if "guest" in rings and "host" in rings and rings["guest"] != rings["host"]: + errs.append("TWIN MISMATCH %s: guest=%s host=%s" + % (rest, render(rings["guest"]), render(rings["host"]))) + return errs + + +def must_twin_errors(ents): + """Every MUST_TWIN logical name must exist on BOTH rings — a one-sided + deletion is a contract hole, not a benign diff.""" + have = {} + for name in ents: + ring, rest = name.split(":", 1) + have.setdefault(rest, set()).add(ring) + errs = [] + for rest in sorted(MUST_TWIN): + rings = have.get(rest, set()) + for ring in ("guest", "host"): + if ring not in rings: + errs.append("MUST-TWIN MISSING %s: no %s-ring entry" % (rest, ring)) + return errs + + +def main(): + ap = argparse.ArgumentParser(description="QuantumOS v1 WIRE freeze gate (ADR-0020 lane C)") + ap.add_argument("mode", choices=["extract", "emit", "check"]) + ap.add_argument("objs", nargs="+", help="compiled wire-probe object files") + ap.add_argument("--golden", help="committed golden file (check mode)") + ap.add_argument("--out", help="golden file to write (emit mode)") + args = ap.parse_args() + + ents = collect(args.objs) + terrs = twin_errors(ents) + text = canonical(ents) + + if args.mode == "extract": + sys.stdout.write(text) + if terrs: + sys.stderr.write("\n".join(terrs) + "\n") + return 1 + return 0 + + if args.mode == "emit": + if not args.out: + ap.error("emit needs --out") + if os.environ.get("QOS_WIRE_TEETH"): + # Refuse to mint a golden from a teeth-perturbed ring: an emit run + # under the selftest environment would freeze the mutation. + die("refusing to emit a golden with QOS_WIRE_TEETH set") + if terrs: + sys.stderr.write("refusing to emit a golden with twin mismatches:\n") + sys.stderr.write("\n".join(terrs) + "\n") + return 1 + outdir = os.path.dirname(args.out) + if outdir: + os.makedirs(outdir, exist_ok=True) + with open(args.out, "w", newline="\n") as f: + f.write(GOLDEN_HEADER) + f.write(text) + sys.stderr.write("wrote %d entries to %s\n" % (len(ents), args.out)) + return 0 + + # check + if not args.golden: + ap.error("check needs --golden") + if not os.path.exists(args.golden): + die("FROZEN WIRE GATE: golden %s is missing -- run " + "`make regen-wire-golden` (operational, not a diff)" % args.golden) + with open(args.golden, "r", newline="") as f: + want = strip_header(f.read().replace("\r\n", "\n")) + rc = 0 + mterrs = must_twin_errors(ents) + if mterrs: + sys.stderr.write("FROZEN WIRE GATE: must-twin names missing:\n") + sys.stderr.write("\n".join(mterrs) + "\n") + rc = 1 + if terrs: + sys.stderr.write("FROZEN WIRE GATE: guest/host twins disagree:\n") + sys.stderr.write("\n".join(terrs) + "\n") + rc = 1 + if text != want: + rc = 1 + sys.stderr.write("FROZEN WIRE GATE: extracted wire contract != %s\n" % args.golden) + want_lines = set(want.splitlines()) + got_lines = set(text.splitlines()) + for line in sorted(got_lines - want_lines): + sys.stderr.write(" + %s\n" % line) + for line in sorted(want_lines - got_lines): + sys.stderr.write(" - %s\n" % line) + sys.stderr.write("If this change is intended, run `make regen-wire-golden` " + "and commit the golden diff.\n") + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/qos_bridge.py b/scripts/qos_bridge.py index 1009964..1d2ed89 100644 --- a/scripts/qos_bridge.py +++ b/scripts/qos_bridge.py @@ -42,7 +42,12 @@ import uuid # ---- wire constants (user/swarm.h) ---------------------------------------- +# These module constants ARE the host ring of the ADR-0020 wire freeze: +# scripts/extract-wire.py imports this module and twins each value against the +# compiler-measured guest probe (user/wire_probe.c), so an edit on either side +# is a contracts/wire/v1.golden diff, never a silent protocol split. MAGIC = 0xA5 +HDR_LEN = 4 # magic + type + len(2) (SWARM_HDR_LEN) SWARM_MAX_PAYLOAD = 512 FRAME_HANDSHAKE = 0x01 @@ -59,6 +64,25 @@ SWARM_OP_QSUBMIT = 0x03 # host submits an opaque circuit to the SYS_QPU broker (epic #149 B1) SWARM_OP_KEY = 0x04 # host admits the swarm-plane group session key (ADR-0019) +CRC8_POLY = 0x07 # CRC-8/CCITT polynomial (SWARM_CRC8_POLY) +CRC8_INIT = 0x00 # CRC-8/CCITT init value (SWARM_CRC8_INIT) + +# ---- attestation string pieces (user/swarm.h SWARM_ATTEST_*) --------------- +# parse_attestation() splits on these; the guest composes with the same bytes. +ATTEST_MAGIC = "QOS-BOOT" +ATTEST_QSEED_KEY = "qseed=" +ATTEST_TICKS_KEY = "ticks=" + +# ---- reply-auth + DATA reply body geometry (user/swarm.h, ADR-0019) -------- +REPLYAUTH_NONCE_LEN = 16 # per-request host nonce, echoed in the reply +REPLYAUTH_TAG_LEN = 32 # HMAC-SHA256 tag after the nonce echo +KEY_LEN = 32 # swarm-plane group session key (SWARM_OP_KEY) +STATUS_BODY_LEN = 6 # op + r_q16(4) + live +RECALL_BODY_LEN = 6 # op + match + r_q16(4) +QSUBMIT_BODY_ERR = 2 # op + status (error reply) +QSUBMIT_BODY_OK = 22 # op + status + QC_RESULT_LEN(20) result +STATUS_R_SCALE = 65536 # r_q16 is a Q16 fraction + # ---- Lamport parameters (user/swarm.h) ------------------------------------ LAMPORT_BITS = 256 HASH_LEN = 32 @@ -71,11 +95,11 @@ # ============================================================================ def crc8(data: bytes) -> int: """CRC-8/CCITT (poly 0x07, init 0x00, MSB-first) — matches swarm_crc8().""" - crc = 0x00 + crc = CRC8_INIT for byte in data: crc ^= byte for _ in range(8): - crc = ((crc << 1) ^ 0x07) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF + crc = ((crc << 1) ^ CRC8_POLY) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF return crc @@ -111,18 +135,18 @@ def feed(self, data: bytes): while True: while self.buf and self.buf[0] != MAGIC: del self.buf[0] - if len(self.buf) < 4: + if len(self.buf) < HDR_LEN: return out length = self.buf[2] | (self.buf[3] << 8) if length > SWARM_MAX_PAYLOAD: self.bad_len += 1 del self.buf[0] # impossible length: resync past this magic continue - total = 4 + length + 1 + total = HDR_LEN + length + 1 if len(self.buf) < total: return out # frame not fully arrived yet - if crc8(bytes(self.buf[1:4 + length])) == self.buf[4 + length]: - out.append((self.buf[1], bytes(self.buf[4:4 + length]))) + if crc8(bytes(self.buf[1:HDR_LEN + length])) == self.buf[HDR_LEN + length]: + out.append((self.buf[1], bytes(self.buf[HDR_LEN:HDR_LEN + length]))) del self.buf[:total] else: self.bad_crc += 1 @@ -174,12 +198,12 @@ def verify_lamport(message: bytes, pkdigest: bytes, signature: bytes) -> bool: def parse_attestation(msg: str): """Parse 'QOS-BOOT|qseed=|ticks=' -> (qseed_or_None, ticks).""" parts = msg.split("|") - if len(parts) != 3 or parts[0] != "QOS-BOOT": + if len(parts) != 3 or parts[0] != ATTEST_MAGIC: raise ValueError(f"malformed attestation: {msg!r}") - if not parts[1].startswith("qseed=") or not parts[2].startswith("ticks="): + if not parts[1].startswith(ATTEST_QSEED_KEY) or not parts[2].startswith(ATTEST_TICKS_KEY): raise ValueError(f"malformed attestation fields: {msg!r}") - qseed = parts[1][len("qseed="):] - ticks = parts[2][len("ticks="):] + qseed = parts[1][len(ATTEST_QSEED_KEY):] + ticks = parts[2][len(ATTEST_TICKS_KEY):] qseed_val = None if qseed == "none" else int(qseed, 16) return qseed_val, int(ticks) @@ -862,8 +886,8 @@ def admit_key(self, key): control of this COM2 channel.""" self._ensure_verified() key = bytes(key) - if len(key) != 32: - raise QosError(f"session key must be 32 bytes, got {len(key)}") + if len(key) != KEY_LEN: + raise QosError(f"session key must be {KEY_LEN} bytes, got {len(key)}") req = frame(FRAME_DATA, bytes([SWARM_OP_KEY]) + key) with self._io_lock: self._com2.sendall(req) @@ -884,7 +908,7 @@ def attest(self, deadline_s=12.0): Fail-CLOSED: raises if attestation cannot go live in time (it never silently leaves the session on the plain path while claiming attested).""" self._ensure_verified() - self.admit_key(os.urandom(32)) + self.admit_key(os.urandom(KEY_LEN)) deadline = time.time() + deadline_s last = None while time.time() < deadline: @@ -915,7 +939,7 @@ def status_authenticated(self, deadline_s=5.0): self._ensure_verified() if self._session_key is None: raise QosError("no session key admitted — call admit_key() first") - nonce = os.urandom(16) + nonce = os.urandom(REPLYAUTH_NONCE_LEN) payload = self._transact(frame(FRAME_DATA, bytes([SWARM_OP_STATUS]) + nonce), SWARM_OP_STATUS, time.time() + deadline_s) return self._verify_status_reply(payload, nonce) @@ -933,17 +957,18 @@ def _verify_reply_auth(self, payload, expect_nonce, op, valid_body_lens): if self._session_key is None: raise QosError("no session key admitted") total = len(payload) - floor = min(valid_body_lens) + 16 + 32 + floor = min(valid_body_lens) + REPLYAUTH_NONCE_LEN + REPLYAUTH_TAG_LEN if total < floor: raise QosRefused( f"op {op} reply too short to authenticate ({total} < {floor}) — " "tag stripped or unkeyed guest") - body_len = total - 16 - 32 + body_len = total - REPLYAUTH_NONCE_LEN - REPLYAUTH_TAG_LEN if body_len not in valid_body_lens: raise QosRefused(f"op {op} reply body_len {body_len} not in {valid_body_lens}") body = bytes(payload[:body_len]) - echo = bytes(payload[body_len:body_len + 16]) - tag = bytes(payload[body_len + 16:body_len + 48]) + echo = bytes(payload[body_len:body_len + REPLYAUTH_NONCE_LEN]) + tag = bytes(payload[body_len + REPLYAUTH_NONCE_LEN: + body_len + REPLYAUTH_NONCE_LEN + REPLYAUTH_TAG_LEN]) if body[0] != op: raise QosRefused(f"op {op} reply op mismatch (0x{body[0]:02x}) — cross-op frame") if echo != bytes(expect_nonce): @@ -956,9 +981,10 @@ def _verify_reply_auth(self, payload, expect_nonce, op, valid_body_lens): def _verify_status_reply(self, payload, expect_nonce): """Verify an authenticated STATUS reply (body op|r_q16(4)|live = 6 bytes).""" - body = self._verify_reply_auth(payload, expect_nonce, SWARM_OP_STATUS, (6,)) + body = self._verify_reply_auth(payload, expect_nonce, SWARM_OP_STATUS, + (STATUS_BODY_LEN,)) r_q16 = int.from_bytes(body[1:5], "little") - return {"r": r_q16 / 65536.0, "live": body[5], "authenticated": True, + return {"r": r_q16 / STATUS_R_SCALE, "live": body[5], "authenticated": True, "identity": self.identity()} def status(self, deadline_s=5.0): @@ -969,7 +995,7 @@ def status(self, deadline_s=5.0): SWARM_OP_STATUS, time.time() + deadline_s) r_q16 = int.from_bytes(payload[1:5], "little") if len(payload) >= 5 else 0 live = payload[5] if len(payload) > 5 else 0 - return {"r": r_q16 / 65536.0, "live": live, "identity": self.identity()} + return {"r": r_q16 / STATUS_R_SCALE, "live": live, "identity": self.identity()} def qsubmit(self, circuit, deadline_s=10.0): """Submit an OPAQUE circuit (qpu_circuit.h bytes) to the in-OS QPU broker @@ -991,7 +1017,7 @@ def qsubmit(self, circuit, deadline_s=10.0): Unkeyed submits keep the legacy op|circuit wire and plain reply.""" self._ensure_verified() if self._session_key is not None: - nonce = os.urandom(16) + nonce = os.urandom(REPLYAUTH_NONCE_LEN) payload = self._transact( frame(FRAME_DATA, bytes([SWARM_OP_QSUBMIT]) + nonce + bytes(circuit)), SWARM_OP_QSUBMIT, time.time() + deadline_s) @@ -1005,7 +1031,8 @@ def qsubmit(self, circuit, deadline_s=10.0): def _verify_qsubmit_reply(self, payload, expect_nonce): """Verify an authenticated QSUBMIT reply. Body is op|status(1) for an error or op|status(1)|result(20) for DONE, so body_len is 2 or 22.""" - body = self._verify_reply_auth(payload, expect_nonce, SWARM_OP_QSUBMIT, (2, 22)) + body = self._verify_reply_auth(payload, expect_nonce, SWARM_OP_QSUBMIT, + (QSUBMIT_BODY_ERR, QSUBMIT_BODY_OK)) return {"status": body[1], "result": body[2:] if len(body) > 2 else b"", "authenticated": True} diff --git a/scripts/qos_mcp.py b/scripts/qos_mcp.py index bc325dc..b370b68 100644 --- a/scripts/qos_mcp.py +++ b/scripts/qos_mcp.py @@ -322,7 +322,7 @@ def qos_society_boot(qseed_a: str, qseed_b: str) -> dict: @mcp.tool() -def qos_society_boot_n(qseeds: list) -> dict: +def qos_society_boot_n(qseeds: list[str]) -> dict: """Boot an N-WAY society (3..4 members) into ONE mean-field coupled over a shared multicast L2 (epic #139). Every qseed must be DISTINCT — each member seeds its ghostd field divergently, so the cross-node order parameter starts diff --git a/user/fieldsyncd.c b/user/fieldsyncd.c index 1ec6745..803ee2c 100644 --- a/user/fieldsyncd.c +++ b/user/fieldsyncd.c @@ -25,40 +25,7 @@ #include "ghost.h" /* ghost_wide_t, GHOST_SNAPSHOT/COUPLE, string builders */ #include "sha256.h" /* hmac_sha256 + constant-time compare (ADR-0019) */ - -#define FSYN_PORT 4747 -#define FSYN_MAGIC 0x4E595346u /* 'FSYN' little-endian */ -#define FSYP_MAGIC 0x50595346u /* 'FSYP' little-endian — society aggregate (epic #178) */ - -/* One UDP datagram (ADR-0019 authenticated form): magic + a monotonic transmit - * sequence + this node's 256 phase bytes + an HMAC-SHA256 tag over the leading - * 264 bytes (magic||seq||phase). 296 bytes. A KEYED node fills seq+tag and a - * keyed receiver verifies them; an UNKEYED node sends seq=0 + a zero tag and an - * unkeyed receiver ignores them (backward-compatible coupling — see the recv - * path). The layout has no padding: 4 + 4 + 256 + 32. */ -typedef struct { - uint32_t magic; - uint32_t seq; - uint8_t phase[GHOST_N]; - uint8_t tag[32]; -} fsyn_frame_t; -_Static_assert(sizeof(fsyn_frame_t) == 296, "FSYN wire frame must be 296 bytes"); -/* The HMAC covers everything BEFORE the tag: magic(4)+seq(4)+phase(256). */ -#define FSYN_MAC_COVERED 264 - -/* Society-aggregate datagram (epic #178): fixed 16 bytes, sent alongside the - * phase frame each cycle once the local agentd has handed us its aggregate - * (continuous idempotent RESEND — the peer VM may boot later, and UDP may - * drop frames; receivers dedup by value). NEVER imprinted into the field: - * the design review rejected field-content replication (unauthenticated wire - * data must not become recallable/persistable field content); received - * values are only PRINTED for host-side verification. */ -typedef struct { - uint32_t magic; - uint32_t aggregate; - uint64_t reserved0; -} fsyp_frame_t; -_Static_assert(sizeof(fsyp_frame_t) == 16, "FSYP wire frame must be 16 bytes"); +#include "fsyn.h" /* fsyn_frame_t/fsyp_frame_t + FSYN_* (frozen wire, ADR-0020) */ /* The local society's aggregate (from agentd via IPC, "A<8hex>") and the * last few DISTINCT peer aggregates already printed (print-once dedup so the diff --git a/user/fsyn.h b/user/fsyn.h new file mode 100644 index 0000000..95dc368 --- /dev/null +++ b/user/fsyn.h @@ -0,0 +1,56 @@ +/** + * QuantumOS fieldsyncd wire format — the FSYN/FSYP UDP coupling frames + * (epic #97 two-node coupling; epic #178 society aggregate; ADR-0019 + * authenticated form; frozen as v1 wire contract by ADR-0020 lane C). + * + * Moved out of fieldsyncd.c so the wire freeze probe (user/wire_probe.c) can + * measure the SAME struct layouts the daemon sends — the golden gate diffs + * compiler-measured sizeof/offsetof, never a size-claim comment. + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef FSYN_H +#define FSYN_H + +#include "ghost.h" /* GHOST_N (frame geometry); pulls stdint + usys.h */ + +#define FSYN_PORT 4747 +#define FSYN_MAGIC 0x4E595346u /* 'FSYN' little-endian */ +#define FSYP_MAGIC 0x50595346u /* 'FSYP' little-endian — society aggregate (epic #178) */ + +/* One UDP datagram (ADR-0019 authenticated form): magic + a monotonic transmit + * sequence + this node's 256 phase bytes + an HMAC-SHA256 tag over the leading + * 264 bytes (magic||seq||phase). 296 bytes. A KEYED node fills seq+tag and a + * keyed receiver verifies them; an UNKEYED node sends seq=0 + a zero tag and an + * unkeyed receiver ignores them (backward-compatible coupling — see the recv + * path). The layout has no padding: 4 + 4 + 256 + 32. */ +typedef struct { + uint32_t magic; + uint32_t seq; + uint8_t phase[GHOST_N]; + uint8_t tag[32]; +} fsyn_frame_t; +_Static_assert(sizeof(fsyn_frame_t) == 296, "FSYN wire frame must be 296 bytes"); +/* The HMAC covers everything BEFORE the tag: magic(4)+seq(4)+phase(256). */ +#define FSYN_MAC_COVERED 264 +/* FSYN_MAC_COVERED IS the tag offset by construction — an inserted field that + * moves the tag without updating the MAC span fails here, in this TU. */ +_Static_assert(FSYN_MAC_COVERED == __builtin_offsetof(fsyn_frame_t, tag), + "HMAC must cover exactly the bytes before the tag"); + +/* Society-aggregate datagram (epic #178): fixed 16 bytes, sent alongside the + * phase frame each cycle once the local agentd has handed us its aggregate + * (continuous idempotent RESEND — the peer VM may boot later, and UDP may + * drop frames; receivers dedup by value). NEVER imprinted into the field: + * the design review rejected field-content replication (unauthenticated wire + * data must not become recallable/persistable field content); received + * values are only PRINTED for host-side verification. */ +typedef struct { + uint32_t magic; + uint32_t aggregate; + uint64_t reserved0; +} fsyp_frame_t; +_Static_assert(sizeof(fsyp_frame_t) == 16, "FSYP wire frame must be 16 bytes"); + +#endif /* FSYN_H */ diff --git a/user/swarm.h b/user/swarm.h index 8506aee..5c23cc1 100644 --- a/user/swarm.h +++ b/user/swarm.h @@ -51,6 +51,12 @@ #define SWARM_HDR_LEN 4 /* magic + type + len(2) */ #define SWARM_MAX_PAYLOAD 512 /* keep frames small for the polled UART */ +/* CRC-8/CCITT parameters (ADR-0020 lane C): named so the wire freeze probe + * (user/wire_probe.c) measures the SAME expressions swarm_crc8() computes + * with — a drive-by polynomial edit reddens contracts/wire/v1.golden. */ +#define SWARM_CRC8_POLY 0x07u +#define SWARM_CRC8_INIT 0x00u + /* Frame types */ #define FRAME_HANDSHAKE 0x01u #define FRAME_DATA 0x02u @@ -77,6 +83,34 @@ * the field-coupling wire can be HMAC-authenticated. No reply. Trust * reduces to control of the host's COM2 channel (the key root). */ +/* ---- attestation string pieces (frozen v1 wire, ADR-0020 lane C) ---- + * build_attestation() composes SWARM_ATTEST_HEAD SWARM_ATTEST_TICKS + * ; the host (scripts/qos_bridge.py parse_attestation) splits on the same + * literals. The freeze probe packs these bytes into KAT entries so either side + * drifting is a golden diff, not a silent verify failure. */ +#define SWARM_ATTEST_HEAD "QOS-BOOT|qseed=" +#define SWARM_ATTEST_TICKS "|ticks=" + +/* ---- reply-auth + DATA reply body geometry (ADR-0019, frozen ADR-0020) ---- + * The host verifier (qos_bridge._verify_reply_auth) slices replies with the + * SAME numbers: body | nonce_echo(16) | tag(32). */ +/* per-request host nonce echoed in an authenticated reply */ +#define SWARM_REPLYAUTH_NONCE_LEN 16 +/* HMAC-SHA256 tag bytes appended after the nonce echo */ +#define SWARM_REPLYAUTH_TAG_LEN 32 +/* swarm-plane group session key admitted via SWARM_OP_KEY */ +#define SWARM_KEY_LEN 32 +/* STATUS reply body: op + r_q16(4) + live */ +#define SWARM_STATUS_BODY_LEN 6 +/* RECALL reply body: op + match + r_q16(4) */ +#define SWARM_RECALL_BODY_LEN 6 +/* QSUBMIT error reply body: op + status */ +#define SWARM_QSUBMIT_BODY_ERR 2 +/* QSUBMIT DONE reply body: op + status + QC_RESULT_LEN(20) result */ +#define SWARM_QSUBMIT_BODY_OK 22 +/* r_q16 is a Q16 fraction: host divides by this scale */ +#define SWARM_STATUS_R_SCALE 65536 + /* ---- Lamport parameters ---- */ #define LAMPORT_BITS 256 /* SHA-256 digest width */ #define LAMPORT_HASH_LEN 32 /* SHA-256 output bytes */ @@ -86,11 +120,11 @@ /* CRC-8/CCITT (poly 0x07, init 0x00), MSB-first — matches verify_attestation.py. */ static inline uint8_t swarm_crc8(const uint8_t *data, uint32_t len) { - uint8_t crc = 0x00u; + uint8_t crc = SWARM_CRC8_INIT; for (uint32_t i = 0; i < len; i++) { crc ^= data[i]; for (int b = 0; b < 8; b++) { - crc = (crc & 0x80u) ? (uint8_t)((crc << 1) ^ 0x07u) : (uint8_t)(crc << 1); + crc = (crc & 0x80u) ? (uint8_t)((crc << 1) ^ SWARM_CRC8_POLY) : (uint8_t)(crc << 1); } } return crc; diff --git a/user/swarm_svc.c b/user/swarm_svc.c index 8c3d1e1..403d41d 100644 --- a/user/swarm_svc.c +++ b/user/swarm_svc.c @@ -31,6 +31,12 @@ #include "ghost.h" /* ghost_req_t/ghost_rep_t, GHOST_*, usys.h, string builders */ #include "qpu_circuit.h" /* QC_RESULT_LEN, QC_STATUS_* (epic #149 B1) */ +/* The frozen QSUBMIT DONE body (swarm.h, ADR-0020 lane C) must stay derived + * from the executor's result length — a QC_RESULT_LEN change that forgets the + * wire contract fails HERE, in the emitter's TU, not silently on the host. */ +_Static_assert(SWARM_QSUBMIT_BODY_OK == 2 + QC_RESULT_LEN, + "QSUBMIT DONE body = op + status + QC result"); + /* ---- state (zeroed .bss) ---- */ static uint8_t master_seed[LAMPORT_SEED_LEN]; static uint8_t framebuf[SWARM_HDR_LEN + SWARM_MAX_PAYLOAD + 1]; @@ -47,7 +53,7 @@ static long ghostd_pid = 0; * ADR-0023's declarative re-mint restores only the DELIVERY PATH: a reborn * fieldsyncd with restored ghostd wiring but no key would emit seq=0 zero-tag * frames that keyed peers silently reject (a keyless-but-wired partition). */ -static uint8_t session_key[32]; +static uint8_t session_key[SWARM_KEY_LEN]; static int have_key = 0; /* The fieldsyncd pid the key was last successfully forwarded to (0 = never). */ static long key_fwd_pid = 0; @@ -71,7 +77,7 @@ static long qsub_jid = 0; * MAC'd when the job completes. qsub_authed snapshots have_key at accept (NOT * read live at DONE-time) so a key admitted mid-flight cannot desync the reply * framing from what the host is awaiting. Scrubbed on completion. */ -static uint8_t qsub_nonce[16]; +static uint8_t qsub_nonce[SWARM_REPLYAUTH_NONCE_LEN]; static int qsub_authed = 0; static void logline(const char *s) { @@ -188,12 +194,12 @@ static void emit_signature(const uint8_t md[LAMPORT_HASH_LEN]) { /* Compose the attestation string into `msg`, returning its length. */ static int build_attestation(char *msg, int seed_present, uint64_t qseed, uint64_t tk) { int o = 0; - o = ghost_put(msg, o, "QOS-BOOT|qseed="); + o = ghost_put(msg, o, SWARM_ATTEST_HEAD); if (seed_present) o = put_hex_u64(msg, o, qseed); else o = ghost_put(msg, o, "none"); - o = ghost_put(msg, o, "|ticks="); + o = ghost_put(msg, o, SWARM_ATTEST_TICKS); o = ghost_put_u(msg, o, (unsigned)tk); return o; } @@ -309,24 +315,26 @@ static void emit_authed_reply(const uint8_t *body, uint32_t body_len, const uint emit_frame(FRAME_DATA, body, body_len); return; } - uint8_t out[1 + 1 + QC_RESULT_LEN + 16 + 32]; /* max body(22) + nonce(16) + tag(32) */ + /* max body(22) + nonce(16) + tag(32) */ + uint8_t out[SWARM_QSUBMIT_BODY_OK + SWARM_REPLYAUTH_NONCE_LEN + SWARM_REPLYAUTH_TAG_LEN]; for (uint32_t i = 0; i < body_len; i++) { out[i] = body[i]; } - for (int i = 0; i < 16; i++) { + for (int i = 0; i < SWARM_REPLYAUTH_NONCE_LEN; i++) { out[body_len + i] = nonce[i]; /* nonce echo */ } /* MAC input: op(1) || nonce(16) || body[1..] — identical form to STATUS. */ - uint8_t macin[1 + 16 + 1 + QC_RESULT_LEN]; + uint8_t macin[1 + SWARM_REPLYAUTH_NONCE_LEN + 1 + QC_RESULT_LEN]; macin[0] = body[0]; - for (int i = 0; i < 16; i++) { + for (int i = 0; i < SWARM_REPLYAUTH_NONCE_LEN; i++) { macin[1 + i] = nonce[i]; } for (uint32_t i = 1; i < body_len; i++) { - macin[17 + (i - 1)] = body[i]; + macin[1 + SWARM_REPLYAUTH_NONCE_LEN + (i - 1)] = body[i]; } - hmac_sha256(session_key, 32, macin, 17 + (body_len - 1), &out[body_len + 16]); - emit_frame(FRAME_DATA, out, body_len + 16 + 32); + hmac_sha256(session_key, SWARM_KEY_LEN, macin, 1 + SWARM_REPLYAUTH_NONCE_LEN + (body_len - 1), + &out[body_len + SWARM_REPLYAUTH_NONCE_LEN]); + emit_frame(FRAME_DATA, out, body_len + SWARM_REPLYAUTH_NONCE_LEN + SWARM_REPLYAUTH_TAG_LEN); } /* ---- session-key delivery to fieldsyncd (ADR-0019 + ADR-0023) ---- */ @@ -358,12 +366,12 @@ static long find_fieldsyncd_pid(void) { * swarm_svc->fieldsyncd IPC cap. Records the delivered-to pid on success so * the main-loop watch can detect a fieldsyncd REBIRTH (new pid) and re-send. */ static int forward_key_to(long fs_pid) { - char km[33]; + char km[1 + SWARM_KEY_LEN]; km[0] = 'K'; - for (int i = 0; i < 32; i++) { + for (int i = 0; i < SWARM_KEY_LEN; i++) { km[i + 1] = (char)session_key[i]; } - if (send_to(fs_pid, km, 33) == 0) { + if (send_to(fs_pid, km, 1 + SWARM_KEY_LEN) == 0) { key_fwd_pid = fs_pid; return 1; } @@ -414,7 +422,7 @@ static void handle_data(const uint8_t *payload, uint32_t len) { return; /* reply body (leading, so legacy 6-byte parsers still work): op, * r_q16 (LE u32), live (u8). */ - uint8_t body[6]; + uint8_t body[SWARM_STATUS_BODY_LEN]; body[0] = SWARM_OP_STATUS; body[1] = (uint8_t)(rep.r_q16); body[2] = (uint8_t)(rep.r_q16 >> 8); @@ -425,7 +433,8 @@ static void handle_data(const uint8_t *payload, uint32_t len) { * (OPTIONAL for STATUS: a plain 1-byte request keeps the legacy 6-byte * reply, so an unkeyed host / ci-smoke-mcp is unaffected). Same emit path * as QSUBMIT — one audited nonce-echo + HMAC(op||nonce||body[1..]). */ - const uint8_t *nonce = (have_key && len >= 1 + 16) ? &payload[1] : NULL; + const uint8_t *nonce = + (have_key && len >= 1 + SWARM_REPLYAUTH_NONCE_LEN) ? &payload[1] : NULL; emit_authed_reply(body, sizeof(body), nonce); } else if (op == SWARM_OP_RECALL) { if (len < 1 + GHOST_PW * 4) @@ -439,7 +448,7 @@ static void handle_data(const uint8_t *payload, uint32_t len) { if (!ghost_query(&req, &rep)) return; /* reply: op, match (s8), r_q16 (LE u32) */ - uint8_t out[6]; + uint8_t out[SWARM_RECALL_BODY_LEN]; out[0] = SWARM_OP_RECALL; out[1] = (uint8_t)rep.match; out[2] = (uint8_t)(rep.r_q16); @@ -466,18 +475,19 @@ static void handle_data(const uint8_t *payload, uint32_t len) { /* Too short to carry a nonce -> we could not authenticate any reply, * so drop SILENTLY (no OOB read, no plaintext downgrade oracle); the * keyed host fail-closes on the transaction timeout. */ - if (len < 1 + 16) { + if (len < 1 + SWARM_REPLYAUTH_NONCE_LEN) { return; } nonce = &payload[1]; - circuit = &payload[1 + 16]; - clen = len - 1 - 16; + circuit = &payload[1 + SWARM_REPLYAUTH_NONCE_LEN]; + clen = len - 1 - SWARM_REPLYAUTH_NONCE_LEN; } else { circuit = &payload[1]; clen = len - 1; } if (clen == 0 || clen > QPU_CIRCUIT_MAX) { - uint8_t err[2] = {SWARM_OP_QSUBMIT, 2}; /* malformed → broker/EINVAL */ + /* malformed → broker/EINVAL */ + uint8_t err[SWARM_QSUBMIT_BODY_ERR] = {SWARM_OP_QSUBMIT, 2}; emit_authed_reply(err, sizeof(err), nonce); return; } @@ -485,7 +495,7 @@ static void handle_data(const uint8_t *payload, uint32_t len) { /* A job is already outstanding (unreachable under a synchronous * host that waits for each reply; defensive). Report refused with * the IN-HAND nonce — a synchronous error NEVER touches the stash. */ - uint8_t busy[2] = {SWARM_OP_QSUBMIT, 1}; + uint8_t busy[SWARM_QSUBMIT_BODY_ERR] = {SWARM_OP_QSUBMIT, 1}; emit_authed_reply(busy, sizeof(busy), nonce); return; } @@ -502,14 +512,14 @@ static void handle_data(const uint8_t *payload, uint32_t len) { * snapshot (not live have_key). */ qsub_authed = (nonce != NULL); if (nonce) { - for (int i = 0; i < 16; i++) { + for (int i = 0; i < SWARM_REPLYAUTH_NONCE_LEN; i++) { qsub_nonce[i] = nonce[i]; } } } else { /* -4 EPERM/quota → refused(1); other broker errors → 2. Synchronous * error → in-hand nonce, no stash write. */ - uint8_t err[2] = {SWARM_OP_QSUBMIT, (uint8_t)(r == -4 ? 1 : 2)}; + uint8_t err[SWARM_QSUBMIT_BODY_ERR] = {SWARM_OP_QSUBMIT, (uint8_t)(r == -4 ? 1 : 2)}; emit_authed_reply(err, sizeof(err), nonce); } } else if (op == SWARM_OP_KEY) { @@ -519,13 +529,13 @@ static void handle_data(const uint8_t *payload, uint32_t len) { * HMAC-authenticated. fieldsyncd's pid comes from the uncapped * SYSINFO_PS text (the agentd/qtop pattern). No reply — the host does * not depend on a guest ack for the key. */ - if (len < 1 + 32) { + if (len < 1 + SWARM_KEY_LEN) { return; } /* Store the key for reply-auth FIRST — independent of the fieldsyncd * forward below, so a lone MCP VM (or one whose fieldsyncd hasn't yet * appeared in SYSINFO_PS) can still authenticate its COM2 replies. */ - for (int i = 0; i < 32; i++) { + for (int i = 0; i < SWARM_KEY_LEN; i++) { session_key[i] = payload[1 + i]; } have_key = 1; @@ -560,7 +570,7 @@ static void qsub_poll_step(void) { * this same single-threaded loop (no ISR), so the pointer stays valid until * the scrub below; a future move to interrupt-driven RX re-triggers review. */ const uint8_t *nonce = qsub_authed ? qsub_nonce : NULL; - uint8_t reply[1 + 1 + QC_RESULT_LEN]; + uint8_t reply[SWARM_QSUBMIT_BODY_OK]; reply[0] = SWARM_OP_QSUBMIT; if (st == QPU_POLL_DONE && out.status == QPU_STATUS_OK && out.result_len == QC_RESULT_LEN) { /* status keys on the EXECUTOR's circuit-level result[0], not just the @@ -573,14 +583,14 @@ static void qsub_poll_step(void) { } else { /* EXECFAIL, a short/oversized result, or a poll error → broker error. */ reply[1] = 2; - emit_authed_reply(reply, 2, nonce); + emit_authed_reply(reply, SWARM_QSUBMIT_BODY_ERR, nonce); } /* Clear-on-completion: free the broker slot AND scrub the auth context so no * later path can emit with a stale nonce (a replayed DONE re-poll is already * blocked by qsub_jid==0, this is defense-in-depth). */ qsub_jid = 0; qsub_authed = 0; - for (int i = 0; i < 16; i++) { + for (int i = 0; i < SWARM_REPLYAUTH_NONCE_LEN; i++) { qsub_nonce[i] = 0; } } diff --git a/user/wire_probe.c b/user/wire_probe.c new file mode 100644 index 0000000..1083c35 --- /dev/null +++ b/user/wire_probe.c @@ -0,0 +1,127 @@ +/** + * QuantumOS v1 WIRE freeze probe - GUEST ring (ADR-0020 lane C). + * + * This translation unit is NEVER linked or run. It is compiled to an object + * file under the REAL user build flags (USER_CFLAGS); scripts/extract-wire.py + * reads the compiler-measured values back out of the .abi_ents section with + * objcopy and diffs them against contracts/wire/v1.golden. Emitting + * compiler-measured values (macro expansions, sizeof, offsetof, packed + * attestation bytes) - rather than parsing comments or _Static_assert + * literals - is the whole point: a developer editing a frame struct and its + * size-claim comment together cannot fool the golden. + * + * The HOST ring twin lives in scripts/qos_bridge.py (extract-wire.py imports + * it); every twinned logical name must carry the same value on both rings, so + * the guest emitter and the host verifier can never silently disagree on the + * COM2/FSYN wire. + * + * Each entry is a fixed 64-byte record: a 56-char NUL-padded name plus a + * little-endian u64 value, ring-namespaced (guest: here, host: in python). + * + * SPDX-License-Identifier: GPL-2.0-only + */ +#include "swarm.h" +#include "fsyn.h" + +/* Teeth-check hook (inert in a normal build): the wire gate's selftest + * recompiles this probe with -DSWARM_MAGIC_OVERRIDE= and asserts the gate + * reddens, proving a CHANGED wire value is actually caught (not just an added + * or removed name). #undef-then-#define avoids a redefinition diagnostic. */ +#ifdef SWARM_MAGIC_OVERRIDE +#undef SWARM_MAGIC +#define SWARM_MAGIC SWARM_MAGIC_OVERRIDE +#endif + +/* A wrong data model (ILP32/LLP64) would silently mis-measure every size; fail + * the compile loudly instead of emitting a plausible-but-wrong layout. */ +_Static_assert(sizeof(long) == 8 && sizeof(void *) == 8, "wrong data model / ABI"); + +/* Attestation known-answer entries: the EXACT bytes build_attestation() emits, + * packed 8 at a time as little-endian u64s from the SHARED swarm.h macros (not + * from a re-typed copy of the string). The sizeof guards pin the string + * lengths the [0..8)/[8..16) windows assume, so a lengthened literal cannot + * silently truncate out of the KAT. */ +_Static_assert(sizeof(SWARM_ATTEST_HEAD) == 16, "attest head is 15 chars + NUL"); +_Static_assert(sizeof(SWARM_ATTEST_TICKS) == 8, "attest ticks key is 7 chars + NUL"); + +/* Pack 8 consecutive bytes of a string literal, starting at offset `o`, as an + * LE u64 (NUL padding included). Direct subscripting — not pointer arithmetic + * — keeps every element a compile-time constant under -Werror. */ +#define PACK8_AT(s, o) \ + ((uint64_t)(uint8_t)(s)[(o) + 0] | ((uint64_t)(uint8_t)(s)[(o) + 1] << 8) | \ + ((uint64_t)(uint8_t)(s)[(o) + 2] << 16) | ((uint64_t)(uint8_t)(s)[(o) + 3] << 24) | \ + ((uint64_t)(uint8_t)(s)[(o) + 4] << 32) | ((uint64_t)(uint8_t)(s)[(o) + 5] << 40) | \ + ((uint64_t)(uint8_t)(s)[(o) + 6] << 48) | ((uint64_t)(uint8_t)(s)[(o) + 7] << 56)) +#define PACK8(s) PACK8_AT(s, 0) + +struct abi_ent { + char name[56]; + unsigned long long value; +}; + +#define ABI(n, v) \ + { n, (unsigned long long)(v) } + +__attribute__((used, section(".abi_ents"))) const struct abi_ent wire_guest[] = { + /* --- COM2 framing --- */ + ABI("guest:wire:SWARM_MAGIC", SWARM_MAGIC), + ABI("guest:wire:SWARM_HDR_LEN", SWARM_HDR_LEN), + ABI("guest:wire:SWARM_MAX_PAYLOAD", SWARM_MAX_PAYLOAD), + + /* --- frame types --- */ + ABI("guest:wire:FRAME_HANDSHAKE", FRAME_HANDSHAKE), + ABI("guest:wire:FRAME_DATA", FRAME_DATA), + ABI("guest:wire:FRAME_PING", FRAME_PING), + ABI("guest:wire:FRAME_PONG", FRAME_PONG), + ABI("guest:wire:FRAME_DISCONNECT", FRAME_DISCONNECT), + ABI("guest:wire:FRAME_PKDIGEST", FRAME_PKDIGEST), + ABI("guest:wire:FRAME_ATTEST", FRAME_ATTEST), + ABI("guest:wire:FRAME_SIG", FRAME_SIG), + + /* --- DATA routing opcodes --- */ + ABI("guest:wire:SWARM_OP_STATUS", SWARM_OP_STATUS), + ABI("guest:wire:SWARM_OP_RECALL", SWARM_OP_RECALL), + ABI("guest:wire:SWARM_OP_QSUBMIT", SWARM_OP_QSUBMIT), + ABI("guest:wire:SWARM_OP_KEY", SWARM_OP_KEY), + + /* --- Lamport attestation parameters --- */ + ABI("guest:wire:LAMPORT_BITS", LAMPORT_BITS), + ABI("guest:wire:LAMPORT_HASH_LEN", LAMPORT_HASH_LEN), + ABI("guest:wire:LAMPORT_SEED_LEN", LAMPORT_SEED_LEN), + ABI("guest:wire:LAMPORT_SIG_ELEM", LAMPORT_SIG_ELEM), + ABI("guest:wire:LAMPORT_SIG_LEN", LAMPORT_SIG_LEN), + + /* --- CRC-8/CCITT parameters --- */ + ABI("guest:wire:SWARM_CRC8_POLY", SWARM_CRC8_POLY), + ABI("guest:wire:SWARM_CRC8_INIT", SWARM_CRC8_INIT), + + /* --- reply-auth + DATA reply body geometry (ADR-0019) --- */ + ABI("guest:wire:SWARM_REPLYAUTH_NONCE_LEN", SWARM_REPLYAUTH_NONCE_LEN), + ABI("guest:wire:SWARM_REPLYAUTH_TAG_LEN", SWARM_REPLYAUTH_TAG_LEN), + ABI("guest:wire:SWARM_KEY_LEN", SWARM_KEY_LEN), + ABI("guest:wire:SWARM_STATUS_BODY_LEN", SWARM_STATUS_BODY_LEN), + ABI("guest:wire:SWARM_RECALL_BODY_LEN", SWARM_RECALL_BODY_LEN), + ABI("guest:wire:SWARM_QSUBMIT_BODY_ERR", SWARM_QSUBMIT_BODY_ERR), + ABI("guest:wire:SWARM_QSUBMIT_BODY_OK", SWARM_QSUBMIT_BODY_OK), + ABI("guest:wire:SWARM_STATUS_R_SCALE", SWARM_STATUS_R_SCALE), + + /* --- FSYN/FSYP field-coupling UDP frames (fsyn.h) --- */ + ABI("guest:wire:FSYN_MAGIC", FSYN_MAGIC), + ABI("guest:wire:FSYN_PORT", FSYN_PORT), + ABI("guest:wire:FSYN_MAC_COVERED", FSYN_MAC_COVERED), + ABI("guest:size:fsyn_frame_t", sizeof(fsyn_frame_t)), + ABI("guest:off:fsyn.magic", __builtin_offsetof(fsyn_frame_t, magic)), + ABI("guest:off:fsyn.seq", __builtin_offsetof(fsyn_frame_t, seq)), + ABI("guest:off:fsyn.phase", __builtin_offsetof(fsyn_frame_t, phase)), + ABI("guest:off:fsyn.tag", __builtin_offsetof(fsyn_frame_t, tag)), + ABI("guest:wire:FSYP_MAGIC", FSYP_MAGIC), + ABI("guest:size:fsyp_frame_t", sizeof(fsyp_frame_t)), + ABI("guest:off:fsyp.magic", __builtin_offsetof(fsyp_frame_t, magic)), + ABI("guest:off:fsyp.aggregate", __builtin_offsetof(fsyp_frame_t, aggregate)), + ABI("guest:off:fsyp.reserved0", __builtin_offsetof(fsyp_frame_t, reserved0)), + + /* --- attestation string KATs (packed from the SHARED macros) --- */ + ABI("guest:kat:WIRE_ATTEST_HEAD_0", PACK8(SWARM_ATTEST_HEAD)), + ABI("guest:kat:WIRE_ATTEST_HEAD_1", PACK8_AT(SWARM_ATTEST_HEAD, 8)), + ABI("guest:kat:WIRE_ATTEST_TICKS_0", PACK8(SWARM_ATTEST_TICKS)), +};