From 7b8fd1366a4b2ac5fd13bcd902bcb18b59e8ddc3 Mon Sep 17 00:00:00 2001 From: mandiau-matthias Date: Thu, 12 Nov 2026 13:58:03 +0100 Subject: [PATCH] Audit trace verification and prepare practical adoption Reject malformed trace records and invalid network options. Add a pinned read-handler consumer, packed-install smoke checks, current pinned CI actions and the distribution plan. Preserve the unresolved license and no-MCP-server boundary. --- .github/workflows/test.yml | 12 +++-- CHANGELOG.md | 22 +++++++++ CONTRIBUTING.md | 7 ++- README.md | 9 +++- docs/API.md | 10 ++-- docs/AUDIT-2026-11-12.md | 17 +++++++ docs/DISTRIBUTION.md | 97 ++++++++++++++++++++++++++++++++++++++ docs/GETTING-STARTED.md | 6 +++ docs/INTEGRATION.md | 13 ++++- docs/ROADMAP.md | 10 +++- docs/SPEC.md | 5 +- examples/README.md | 4 ++ examples/consumer.mjs | 65 +++++++++++++++++++++++++ package.json | 6 ++- scripts/check-package.mjs | 33 +++++++++++++ src/infer.mjs | 4 +- src/trace.mjs | 40 ++++++++++++---- test/consumer.test.mjs | 29 ++++++++++++ test/infer.test.mjs | 10 ++++ test/trace.test.mjs | 43 ++++++++++++++++- 20 files changed, 413 insertions(+), 29 deletions(-) create mode 100644 docs/DISTRIBUTION.md create mode 100644 examples/consumer.mjs create mode 100644 scripts/check-package.mjs create mode 100644 test/consumer.test.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ca5e59..262ae71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,13 +10,15 @@ permissions: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - node-version: "24" + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: ".nvmrc" + package-manager-cache: false # No install step: the package has no dependencies by design. - run: npm run check - - run: npm pack --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index be08e22..af612d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.4.1 — 2026-11-12 + +Final use-readiness audit and runnable consumer pattern. + +- Trace verification now rejects malformed outcome metadata and unknown record + fields even if the digest was recomputed. With a panel it also checks argument + shape and declared redaction. It still does not establish authenticity. +- Explicit invalid inference network options are no longer silently defaulted. +- `npm run consumer` invokes a pinned, allowlisted demo read handler, checks its + result and records a success trace. It is not a general executor or MCP server. +- `npm run test:package` packs and installs the actual artifact offline, testing + ESM exports, the installed CLI binary and the included consumer example. +- CI runs these checks on the exact supported Node version, uses current pinned + actions, disables credential persistence and fixes the Ubuntu image version. +- Distribution guide distinguishes an owned-app pilot, licensed npm release and + a future tested MCP adapter. Package name/scope availability is unverified. + +Valid 0.4.0 panels and trace bytes are unchanged. Malformed traces previously +accepted by the partial verifier now fail. Extra outcome fields supplied to +`buildTrace` are rejected rather than silently discarded. License and npm +publication remain unchanged. + ## 0.4.0 — 2026-11-12 Experimental foundation audit and onboarding pass. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ccdd95..8699426 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,8 +11,11 @@ npm run check ``` This runs unit/CLI tests, real loopback HTTP integration tests, panel validation -and the offline API walkthrough. CI runs the same command plus `npm pack ---dry-run`. All checks must pass before merging. The standalone `npm run corpus` +and the offline API walkthrough, runnable consumer and package smoke check. The +package check builds a tarball, installs it offline in a temporary project, then +checks its ESM exports, CLI binary and included consumer example. CI runs the +same command on the exact `.nvmrc` version. All checks must pass before merging. +The standalone `npm run corpus` command intentionally exits 1 on the current baseline; its expected scores and failure modes are asserted inside the passing test suite. diff --git a/README.md b/README.md index d7c2a9c..4eef912 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Hardwire describes an application's operations in a small JSON file called a `read` or `payment`, and a declared confirmation requirement. An agent integration can inspect that contract instead of guessing which UI to click. -**Status: 0.4.0 experimental foundation.** This Node.js library validates panels +**Status: 0.4.1 experimental foundation.** This Node.js library validates panels and arguments, compiles MCP tool descriptions, proposes panels from static HTML, and creates verifiable trace digests. It does **not** execute operations, provide an MCP server, log anyone in, or enforce human approval. No React dependency. @@ -24,6 +24,7 @@ git clone https://github.com/Automatron-workflows/hardwire.git cd hardwire node --version npm run walkthrough +npm run consumer npm test ``` @@ -32,8 +33,14 @@ password redaction, and scores a sample inference. `npm test` also starts a short-lived server on the loopback interface. In a sandbox that forbids listening sockets, run `npm run test:unit`; the integration suite must still pass in CI. +`npm run consumer` goes one step further: it maps `search_catalogue` to a known +application function, returns a matching demo product, checks the result and +records a successful trace. It runs offline on fake data. This is a runnable +integration pattern, not an MCP server or an authenticated deployment. + **Next:** [step-by-step experiments](docs/GETTING-STARTED.md), [API reference](docs/API.md), or [adding a panel to your app](docs/INTEGRATION.md). +For adoption and packaging, see [distribution and first deployment](docs/DISTRIBUTION.md). ## What a panel looks like diff --git a/docs/API.md b/docs/API.md index dcc0295..ef42ed0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -45,8 +45,8 @@ const trace = buildTrace({ outcome: { status: 'unknown' }, actor: 'local-experiment', at: '2026-11-12T10:00:00.000Z' }); -verifyTrace(trace); // digest and basic record checks -verifyTrace(trace, { panel }); // also exact panel revision and port binding +verifyTrace(trace); // digest and strict record-shape checks +verifyTrace(trace, { panel }); // also panel binding, argument shape and redaction ``` `buildTrace` accepts a raw or normalized panel and a port ID or port object. It @@ -60,7 +60,11 @@ SHA-256 digest string. No response body is recorded. All outcomes are assertions by the caller; none are independently observed by this package. `verifyTrace` returns false for a changed record or a mismatch with the supplied -panel. It does not establish identity, approval, execution, or authenticity. +panel. With a panel it validates the recorded argument shape, including required +fields, and requires `[redacted]` at declared write-only positions. Secret enums +are replaced by that marker for this check; original secrets cannot be recovered +or validated from the record. Unknown trace/outcome/port fields are rejected. +It does not establish identity, approval, execution, or authenticity. It cannot reconstruct redacted values. Two otherwise identical records that differ only in redacted secrets intentionally have the same digest. diff --git a/docs/AUDIT-2026-11-12.md b/docs/AUDIT-2026-11-12.md index eec5356..0a72dbd 100644 --- a/docs/AUDIT-2026-11-12.md +++ b/docs/AUDIT-2026-11-12.md @@ -38,3 +38,20 @@ npm publication stays disabled. Compatibility: normalized defaults and digests change from 0.3.0. Preserve old panels with old traces; see CHANGELOG.md before comparing revisions. + +## Follow-up: use readiness, 0.4.1 + +The final pass reproduced four false-positive verification cases: recomputed +traces with a malformed result digest, numeric outcome code, unknown record field +or wrong argument type. The verifier now enforces the record shape and, when a +panel is supplied, argument types/required fields and secret-redaction markers. +Tests also cover accessor-bearing inputs without invoking their getters. + +Explicit zero/null/false inference options no longer select silent defaults. +The new consumer example performs one actual application-function read against +fake data and checks the result. A packed-install smoke check runs in CI, which +also uses current SHA-pinned actions and the exact supported Node version. + +The remaining adoption blockers are product boundaries, not failing tests: +license selection, an owned npm identity, and a real client adapter with scoped +application access. See DISTRIBUTION.md. None is claimed to be released already. diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md new file mode 100644 index 0000000..8466d30 --- /dev/null +++ b/docs/DISTRIBUTION.md @@ -0,0 +1,97 @@ +# Distribution and first deployment + +## What is ready now + +0.4.1 is usable by the owner as a Node.js contract, validation, inference and +trace library. `npm run consumer` proves one fixed read operation can execute +through an application-owned handler. `npm run check` also installs the actual +packed artifact in a clean temporary project and exercises its exports and CLI. + +It is not yet a product that can be connected to an agent client and operate +arbitrary websites. There is no transport, identity service or general executor. +Public GitHub visibility does not provide a reuse license: `UNLICENSED` and +`private: true` are deliberate release blockers, not installation bugs. + +## First working deployment + +Use one application we control and an existing backend operation. Do not start +by tagging every UI element, handling third-party credentials or building a registry. + +1. Publish a reviewed panel for three useful read operations: search, fetch one + record, and read status. Keep auth tokens and private data out of the panel. +2. Copy the consumer pattern into the app's trusted backend. Map each stable port + ID to an existing service function; scope every read to the authenticated user. + Bind the accepted panel revision to deployment configuration. +3. Add a small MCP adapter using a pinned official SDK. Implement initialization, + `tools/list` with Hardwire descriptions, and `tools/call` with known handlers. + Use stdio for the first local client; introduce remote hosting only when needed. + Remote authentication and authorization are additional deployment work. +4. Connect one supported MCP client and complete actual user tasks. Record the + client, adapter/SDK version, app revision, failures and observed results. +5. Add exactly one reversible write after the read path works. Require explicit + authorization and approval bound to the user, port, arguments and panel digest. + Implement idempotency, a checked result and read-back in the application. +6. Test denied access, cross-user access, a changed panel, invalid input, timeout, + repeated calls and refused approval. Treat untrusted results as data. + +Acceptance target for this owned-app pilot: at least 20 recorded read tasks with +checked results, all denial cases refused, and no duplicate effect under a retried +write. These are proposed targets, not measurements already achieved. This does +not replace the separate real-page inference evaluation in ROADMAP.md. + +Login should use the app's established authentication flow and scoped session. +A declared login port is not a reason to collect passwords in prompts or traces. + +## Release the library + +1. **Choose the license.** The owner must choose the permissions granted to outside + users. Add the matching LICENSE file and package metadata. This document does + not select a license or grant those rights. +2. **Choose an owned npm scope.** Candidate: `@automatron-workflows/hardwire`. + Availability and ownership are unverified; the GitHub organization does not + automatically own that npm scope. Do not advertise `npm install hardwire`. +3. Update the package name and install examples, remove `private: true` only for + the release, run `npm run check`, and inspect `npm pack --dry-run` for unintended + files. Freeze canonicalization test vectors before interoperability claims. +4. Publish a versioned experimental release and tag it on GitHub. Verify installing + that exact version from npm in a clean project; do not rely only on the checkout. +5. For ongoing releases, configure npm trusted publishing from the specific + GitHub repository/workflow, with provenance and release checks. Account/scope + ownership and first-package setup must be resolved before this can work. + Keep release credentials out of the repository. + +No publishing workflow is enabled by this audit. Publishing still requires the +license, package identity, account access and an explicit release action. + +## Distribution to developers and agent users + +| Audience | Deliverable | Evidence needed before promoting it | +| --- | --- | --- | +| Application developers | Licensed npm library, GitHub README and one small integration example | Fresh install and the owned-app pilot work end to end. | +| Agent users | Runnable MCP adapter with client setup instructions | Tested `tools/list` and `tools/call` in one real client. | +| Additional integrators | A concise case study and a reproducible failure report | Three independent teams can integrate without bespoke patches. | + +The MCP Registry is a discovery layer for actual MCP servers; it does not host +the package or convert a JSON panel into a server. List the adapter only after +it exists and its artifact or remote endpoint is available. Do not list the +current core library as an operational MCP server. + +Start with direct onboarding of three teams that own both an app and its agent +integration. Measure time to first successful task, integration code required, +task success, denied unauthorized calls and repeated use after one week. Publish +the measured before/after workflow and a short recording. Broader developer +announcements should link to that evidence and an installable artifact. Stars, +registry presence and a synthetic score are not adoption. + +The initial promise should be narrow: **expose a few real application operations +as validated agent tools without teaching the agent to navigate the UI**. Avoid +claims of universal compatibility, automatic login or unmeasured speed gains. + +## Sources checked on 2026-11-12 + +- [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/): OIDC-based + release authentication and provider configuration. +- [MCP Registry publishing quickstart](https://modelcontextprotocol.io/registry/quickstart): + metadata, underlying artifacts, identity verification and server publication. +- [MCP tools specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools): + tool discovery and calls. Core Hardwire implements descriptions only. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 024bb38..43c977f 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -4,6 +4,12 @@ Run every command below from the repository root with Node 24.16+. `npm run walkthrough` is the fastest complete offline tour. It makes no network requests and needs no model or credentials. +To see a tool actually call an application function, run `npm run consumer`. +It calls `search_catalogue` with `{ "query": "lamp" }`, returns the fake +`Desk lamp` product and records a checked success. Read `examples/consumer.mjs` +to replace the fixed handler with your own authorized service. A model client +still needs a transport adapter; merely publishing a panel cannot make calls. + ## 1. Inspect an explicit contract ```sh diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 8414ae6..c1a4ce0 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -27,6 +27,13 @@ method, headers, CSRF handling, body serialization or session requirements. ## Consumer setup +Start with `npm run consumer` and [the runnable consumer](../examples/consumer.mjs). +It pins a reviewed panel digest, refuses unknown tools or changed panels, +validates arguments, invokes an allowlisted read handler, checks the application's +result and binds its digest into a trace. It handles only fake public catalogue +data and grants no account access. Handler failures propagate to the caller; +production error reporting and refusal/failure traces remain integration work. + A trusted Node consumer can use this explicit sequence: 1. Fetch the panel under your own access and network policy. Validate it. @@ -38,7 +45,8 @@ A trusted Node consumer can use this explicit sequence: 7. Observe the result, build a trace, and retain the exact panel for verification. The repository implements steps 2–4's format helpers and step 7's record helper. -It does not implement a generic runtime for steps 1, 5 or 6. Do not implement a +The consumer example demonstrates a single fixed handler for step 6. It does not +implement a generic runtime for steps 1, 5 or 6. Do not implement a consumer as “fetch whatever endpoint the untrusted panel says.” Absolute HTTPS endpoints can be cross-origin; a real consumer needs an explicit destination allowlist and must never forward credentials automatically. @@ -63,3 +71,6 @@ The WebMCP document explicitly identifies itself as a Community Group draft, not a W3C Standard or a standards-track specification. Hardwire is an independent experiment, not a competing web standard or an official extension of any of the above. Its own discovery file and metadata vocabulary may change. + +See [distribution and first deployment](DISTRIBUTION.md) for the concrete path +from this example to a client-connected pilot and an installable release. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 127d92e..9bbacdf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Foundation and next gates -## Foundation: implemented in 0.4.0 +## Foundation: implemented in 0.4.1 - Strict panel and schema validation, argument checks and normalized digests. - MCP tool descriptions with the same closed-object semantics as validation. @@ -9,6 +9,7 @@ - Fixed-path discovery diagnostics with bounded network responses. - Trace creation, redaction, verification and exact panel binding. - Offline walkthrough, local HTTP demo, CLI/API guides and automated checks. +- An application-owned read-handler example and automated packed-install checks. The library is ready for controlled experiments by the owner. It is not a production executor or an open-source release. License selection is still open. @@ -48,7 +49,9 @@ read-back without UI navigation. Every action must have a known handler, authorization, explicit approval policy, idempotency behavior, a checked result and a trace bound to the reviewed panel. Inject failures and retry scenarios. -The local HTTP demo currently proves discovery, inference and a read response. +The offline consumer proves one fixed read-handler mapping, result check and +trace. It has no authentication or mutation support and does not satisfy this +gate. The local HTTP demo proves discovery, inference and a read response. It deliberately refuses POST. It does not satisfy the traversal gate. Before adding a generic executor, define method/serialization/credential rules and bind approval to exact arguments and panel revision. Prefer an explicit @@ -63,6 +66,9 @@ external implementation contributions or promoting reusable packages. ## Release decisions still open +Follow [distribution and first deployment](DISTRIBUTION.md) for the ordered pilot, +package and client-adapter rollout. Publishing the panel alone cannot connect a client. + - Choose a license; `UNLICENSED` and `private: true` remain unchanged. - Decide whether npm distribution is useful and check/reserve an appropriate name. - Design browser/WebMCP integration only against a pinned, reviewed API version. diff --git a/docs/SPEC.md b/docs/SPEC.md index 68f8842..8ca698d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -152,7 +152,10 @@ hash. Verification does not prove identity, execution or approval. real UTC timestamp in the shown format (milliseconds optional). `actor` and optional outcome `code` are non-empty strings of at most 128 characters. An optional result digest must be `sha256:` plus 64 lowercase hex digits. -`verifyTrace(trace, { panel })` additionally checks exact panel and port binding. +`verifyTrace` rejects unknown record, port and outcome fields, malformed optional +outcome metadata and non-JSON records. `verifyTrace(trace, { panel })` additionally +checks exact panel and port binding, argument shape and `[redacted]` markers at +declared write-only positions. Original secret values cannot be verified. Write-only array items are redacted too; unmarked secrets are not detected. ## 7. Canonical JSON diff --git a/examples/README.md b/examples/README.md index 8b9e700..689e91d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,9 @@ # Examples +Run `npm run consumer` for an application-owned read-handler integration. It +returns a fake catalogue product and records its checked result; see +`consumer.mjs`. `npm run walkthrough` demonstrates the format helpers offline. + | Path | What it is | Try it with | | --- | --- | --- | | `example-panel.json` | A complete panel for a fictional shop, including every effect class. | `validate`, `audit`, `compile`, `trace` | diff --git a/examples/consumer.mjs b/examples/consumer.mjs new file mode 100644 index 0000000..a1e1942 --- /dev/null +++ b/examples/consumer.mjs @@ -0,0 +1,65 @@ +// Application-owned consumer example. No arbitrary URL execution or credentials. +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + buildTrace, canonicalJson, compilePanel, findPort, normalizePanel, + panelDigest, validateParams, verifyTrace, +} from '../src/index.mjs'; + +export const cataloguePanel = { + panel: 'hardwire.panel/1', origin: 'https://catalogue.example.com', revision: 'demo/1', + ports: [{ + id: 'search-catalogue', kind: 'read', summary: 'Search the demo catalogue.', + parameters: { properties: { query: { type: 'string' } }, required: ['query'] }, + expects: { type: 'array', items: { type: 'object', properties: { + id: { type: 'string' }, name: { type: 'string' }, + }, required: ['id', 'name'] } }, + }], +}; +const reviewedDigest = panelDigest(cataloguePanel); + +// Replace this application function with an existing, authorized read service. +// The model chooses arguments, never source code, URLs, headers or credentials. +const catalogue = [ + { id: 'lamp-1', name: 'Desk lamp' }, + { id: 'chair-1', name: 'Reading chair' }, +]; +const handlers = new Map([ + ['search-catalogue', ({ query }) => catalogue + .filter(item => item.name.toLowerCase().includes(query.toLowerCase())) + .map(item => ({ ...item }))], +]); + +export async function callReadTool(name, args, { panel: input = cataloguePanel } = {}) { + const panel = normalizePanel(input); + if (panelDigest(panel) !== reviewedDigest) throw new Error('panel_revision_not_reviewed'); + const resolved = findPort(panel, name); + if (!resolved) throw new Error('tool_not_allowed'); + const { port } = resolved; + if (port.kind !== 'read' || port.confirmation !== 'none' || port.preconditions.length) { + throw new Error('policy_not_supported'); + } + const handler = handlers.get(port.id); + if (!handler) throw new Error('handler_not_registered'); + const params = validateParams(port, args); + const result = await handler(params); + // Application-specific result check. `expects` alone does not check results. + if (!Array.isArray(result) || result.some(item => + typeof item.id !== 'string' || typeof item.name !== 'string' || Object.keys(item).length !== 2)) { + throw new Error('catalogue_result_invalid'); + } + const resultDigest = `sha256:${createHash('sha256').update(canonicalJson(result)).digest('hex')}`; + const trace = buildTrace({ panel, port, params, actor: 'demo-consumer', + outcome: { status: 'succeeded', resultDigest } }); + if (!verifyTrace(trace, { panel })) throw new Error('trace_verification_failed'); + return { result, trace }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const response = await callReadTool('search_catalogue', { query: 'lamp' }); + console.log(JSON.stringify({ + tools: compilePanel(cataloguePanel).map(tool => tool.name), + ...response, + }, null, 2)); +} diff --git a/package.json b/package.json index f6e28bb..a1ade8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hardwire", - "version": "0.4.0", + "version": "0.4.1", "description": "Typed action panels, static inference, MCP tool descriptions and verifiable trace digests. Experimental; no executor.", "type": "module", "private": true, @@ -47,9 +47,11 @@ "corpus": "node src/cli.mjs corpus examples/corpus", "demo": "node examples/demo/server.mjs", "walkthrough": "node examples/walkthrough.mjs", + "consumer": "node examples/consumer.mjs", + "test:package": "node scripts/check-package.mjs", "test:unit": "node --test test/*.test.mjs", "test:integration": "node --test test/integration/*.test.mjs", - "check": "npm test && npm run validate && npm run walkthrough" + "check": "npm test && npm run validate && npm run walkthrough && npm run consumer && npm run test:package" }, "keywords": [ "agents", diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs new file mode 100644 index 0000000..7654b14 --- /dev/null +++ b/scripts/check-package.mjs @@ -0,0 +1,33 @@ +// Exercise the tarball a consumer would receive, not imports from the source tree. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const metadata = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); +const npm = process.env.npm_execpath; +if (!npm) throw new Error('Run this check with npm run test:package'); +const temp = await mkdtemp(join(tmpdir(), 'hardwire-package-')); +const env = { ...process.env, npm_config_cache: join(temp, 'cache') }; +const runNpm = (args, cwd) => execFileSync(process.execPath, [npm, ...args], { cwd, env, encoding: 'utf8', timeout: 60000 }); +try { + const packed = JSON.parse(runNpm(['pack', '--json', '--ignore-scripts', '--pack-destination', temp], root)); + const consumer = join(temp, 'consumer'); + await mkdir(consumer); + await writeFile(join(consumer, 'package.json'), JSON.stringify({ name: 'hardwire-package-check', private: true, type: 'module' })); + runNpm(['install', join(temp, packed[0].filename), '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false'], consumer); + const imported = execFileSync(process.execPath, ['--input-type=module', '-e', + `import {normalizePanel,compilePanel} from ${JSON.stringify(metadata.name)}; console.log(compilePanel(normalizePanel({panel:'hardwire.panel/1',origin:'https://example.com',ports:[{id:'read-status',kind:'read',summary:'Read status.'}]}))[0].name);`], + { cwd: consumer, encoding: 'utf8', timeout: 10000 }); + assert.equal(imported.trim(), 'read_status'); + const installed = join(consumer, 'node_modules', ...metadata.name.split('/')); + const version = runNpm(['exec', '--offline', '--', 'hardwire', '--version'], consumer); + assert.equal(version.trim(), metadata.version); + execFileSync(process.execPath, [join(installed, 'examples/consumer.mjs')], { cwd: consumer, stdio: 'pipe', timeout: 10000 }); + console.log(`Package smoke check passed: ${metadata.name}@${metadata.version}; installed ESM, CLI and consumer example.`); +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/src/infer.mjs b/src/infer.mjs index 928fd79..c323fe4 100644 --- a/src/infer.mjs +++ b/src/infer.mjs @@ -243,8 +243,8 @@ export function inferFromHtml(html, { origin }) { export async function inferFromUrl(origin, options = {}) { const base = assertPublicOrigin(origin, { allowPrivate: options.allowPrivate }); const page = await fetchPage(base, { - ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), - ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), }); if (page.status !== 200 || !/text\/html|application\/xhtml\+xml/i.test(page.contentType)) { throw new HardwireError("hardwire_infer_failed", "origin", `page returned ${page.status || page.error}`); diff --git a/src/trace.mjs b/src/trace.mjs index 4d82706..97a75b5 100644 --- a/src/trace.mjs +++ b/src/trace.mjs @@ -6,12 +6,18 @@ import { createHash } from "node:crypto"; import { canonicalJson } from "./canonical.mjs"; -import { HardwireError, normalizePanel, panelDigest, validateParams } from "./panel.mjs"; +import { HardwireError, normalizeOrigin, normalizePanel, panelDigest, validateParams } from "./panel.mjs"; export const TRACE_VERSION = "hardwire.trace/1"; const OUTCOMES = new Set(["succeeded", "failed", "unknown", "refused"]); const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; const DIGEST = /^sha256:[a-f0-9]{64}$/; +const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +const onlyKeys = (value, keys) => record(value) && Object.keys(value).every((key) => keys.includes(key)); +const shortString = (value) => typeof value === "string" && value.length > 0 && value.length <= 128; +const validOutcome = (value) => onlyKeys(value, ["status", "code", "resultDigest"]) && OUTCOMES.has(value.status) && + (value.code === undefined || shortString(value.code)) && + (value.resultDigest === undefined || (typeof value.resultDigest === "string" && DIGEST.test(value.resultDigest))); const validTime = (at) => typeof at === "string" && TIMESTAMP.test(at) && Number.isFinite(Date.parse(at)) && new Date(at).toISOString() === (at.length === 20 ? at.replace("Z", ".000Z") : at); @@ -69,7 +75,7 @@ export function buildTrace({ if (typeof bound.kind !== "string" || typeof bound.confirmation !== "string") { throw new HardwireError("hardwire_panel_invalid", "port", `${id} is not normalised`); } - if (!outcome || !OUTCOMES.has(outcome.status)) { + if (!validOutcome(outcome)) { throw new HardwireError("hardwire_trace_invalid", "outcome.status", `expected one of ${[...OUTCOMES].join(", ")}`); } if (!validTime(at)) throw new HardwireError("hardwire_trace_invalid", "at", "expected a real ISO-8601 UTC timestamp"); @@ -113,21 +119,37 @@ export function traceDigest(document) { * truncation and edits; it does not catch forgery, which needs an issuer. */ export function verifyTrace(trace, { panel } = {}) { - if (!trace || typeof trace !== "object") return false; - if (trace.trace !== TRACE_VERSION) return false; - if (!DIGEST.test(trace.digest) || !DIGEST.test(trace.panelDigest)) return false; - if (!validTime(trace.at) || !OUTCOMES.has(trace.outcome?.status)) return false; - if (typeof trace.actor !== "string" || !trace.actor.length || trace.actor.length > 128) return false; - if (typeof trace.port?.id !== "string" || !["read", "write", "payment", "destructive"].includes(trace.port?.kind) || !["none", "user", "owner"].includes(trace.port?.confirmation)) return false; - if (!trace.params || typeof trace.params !== "object" || Array.isArray(trace.params)) return false; try { + // Reject accessors/non-JSON values before reading caller-controlled fields. + trace = JSON.parse(canonicalJson(trace)); + if (!onlyKeys(trace, ["trace", "panel", "panelDigest", "revision", "port", "actor", "at", "params", "outcome", "digest"])) return false; + if (trace.trace !== TRACE_VERSION || normalizeOrigin(trace.panel) !== trace.panel) return false; + if (!DIGEST.test(trace.digest) || !DIGEST.test(trace.panelDigest)) return false; + if (!validTime(trace.at) || !validOutcome(trace.outcome) || !shortString(trace.actor)) return false; + if (trace.revision !== undefined && !shortString(trace.revision)) return false; + if (!onlyKeys(trace.port, ["id", "kind", "confirmation"]) || typeof trace.port.id !== "string" || trace.port.id.length > 64 || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(trace.port.id)) return false; + if (!["read", "write", "payment", "destructive"].includes(trace.port.kind) || !["none", "user", "owner"].includes(trace.port.confirmation)) return false; + if (["payment", "destructive"].includes(trace.port.kind) && trace.port.confirmation !== "owner") return false; + if (!record(trace.params)) return false; if (panel !== undefined) { const normalized = normalizePanel(panel); const port = normalized.ports.find((entry) => entry.id === trace.port.id); if (!port || trace.panel !== normalized.origin || trace.panelDigest !== panelDigest(normalized) || trace.revision !== normalized.revision || trace.port.kind !== port.kind || trace.port.confirmation !== port.confirmation) return false; + // Validate the recorded shape, allowing only the redaction marker where + // the original secret was removed. The secret itself cannot be verified. + validateParams({ ...port, parameters: recordedSchema(port.parameters) }, trace.params); } return traceDigest(trace) === trace.digest; } catch { return false; } } + +function recordedSchema(schema) { + if (schema.writeOnly) return { type: "string", enum: ["[redacted]"] }; + return { + ...schema, + ...(schema.properties ? { properties: Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, recordedSchema(value)])) } : {}), + ...(schema.items ? { items: recordedSchema(schema.items) } : {}), + }; +} diff --git a/test/consumer.test.mjs b/test/consumer.test.mjs new file mode 100644 index 0000000..88c3362 --- /dev/null +++ b/test/consumer.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { callReadTool, cataloguePanel } from '../examples/consumer.mjs'; +import { verifyTrace } from '../src/index.mjs'; + +test('a compiled tool invokes the known read handler and records its observed result', async () => { + const { result, trace } = await callReadTool('search_catalogue', { query: 'lamp' }); + assert.deepEqual(result, [{ id: 'lamp-1', name: 'Desk lamp' }]); + assert.equal(trace.outcome.status, 'succeeded'); + assert.match(trace.outcome.resultDigest, /^sha256:[a-f0-9]{64}$/); + assert.equal(verifyTrace(trace, { panel: cataloguePanel }), true); + result[0].name = 'Changed by caller'; + assert.equal((await callReadTool('search_catalogue', { query: 'lamp' })).result[0].name, 'Desk lamp'); +}); + +test('the consumer refuses unknown tools, invalid arguments and changed panel policy', async () => { + await assert.rejects(callReadTool('delete_catalogue', {}), /tool_not_allowed/); + await assert.rejects(callReadTool('search_catalogue', { query: 42 }), { code: 'hardwire_params_invalid' }); + await assert.rejects(callReadTool('search_catalogue', { query: 'lamp', url: 'https://other.example' }), { code: 'hardwire_params_invalid' }); + for (const change of [ + p => { p.revision = 'unreviewed'; }, + p => { p.ports[0].confirmation = 'owner'; }, + p => { p.ports[0].endpoint = 'https://other.example/read'; }, + ]) { + const panel = structuredClone(cataloguePanel); + change(panel); + await assert.rejects(callReadTool('search_catalogue', { query: 'lamp' }, { panel }), /panel_revision_not_reviewed/); + } +}); diff --git a/test/infer.test.mjs b/test/infer.test.mjs index 18a67ee..67066ac 100644 --- a/test/infer.test.mjs +++ b/test/infer.test.mjs @@ -123,3 +123,13 @@ test("inferFromUrl fetches once and reports a non-200 page", async () => { (error) => error.code === "hardwire_infer_failed", ); }); + +test("invalid explicit network options are refused instead of replaced by defaults", async () => { + let calls = 0; + const fetchImpl = async () => { calls++; return new Response(PAGE, { headers: { 'content-type': 'text/html' } }); }; + for (const timeoutMs of [0, null, false, NaN]) { + await assert.rejects(inferFromUrl('https://shop.example.com', { timeoutMs, fetchImpl }), { code: 'hardwire_probe_invalid' }); + } + await assert.rejects(inferFromUrl('https://shop.example.com', { fetchImpl: null }), { code: 'hardwire_panel_invalid' }); + assert.equal(calls, 0); +}); diff --git a/test/trace.test.mjs b/test/trace.test.mjs index 3da4a76..6a131c9 100644 --- a/test/trace.test.mjs +++ b/test/trace.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { normalizePanel, panelDigest } from "../src/panel.mjs"; -import { buildTrace, verifyTrace } from "../src/trace.mjs"; +import { buildTrace, traceDigest, verifyTrace } from "../src/trace.mjs"; import { clone, example } from "./fixtures.mjs"; const AT = "2026-11-11T10:00:00.000Z"; @@ -100,3 +100,44 @@ test("write-only parameters never reach the trace", () => { assert.equal(JSON.stringify(trace).includes("hunter2"), false); assert.equal(verifyTrace(trace), true); }); + +test("recomputed digests do not make malformed records valid", () => { + const panel = normalizePanel(example); + const original = buildTrace({ panel, port: "search-catalogue", params: { query: "lamp" }, at: AT }); + for (const mutate of [ + t => { t.outcome.resultDigest = "broken"; }, + t => { t.outcome.code = 42; }, + t => { t.outcome.extra = true; }, + t => { t.extra = true; }, + t => { t.port.extra = true; }, + t => { t.panel = "not-an-origin"; }, + t => { t.revision = 12; }, + ]) { + const trace = clone(original); + mutate(trace); + trace.digest = traceDigest(trace); + assert.equal(verifyTrace(trace), false); + } + assert.throws(() => buildTrace({ panel, port: "search-catalogue", params: { query: "lamp" }, outcome: { status: "succeeded", extra: true } }), { code: "hardwire_trace_invalid" }); +}); + +test("panel verification checks argument shape and required redaction", () => { + const panel = normalizePanel(example); + for (const params of [{ query: 42 }, {}, { query: "lamp", extra: true }]) { + const trace = buildTrace({ panel, port: "search-catalogue", params: { query: "lamp" }, at: AT }); + trace.params = params; + trace.digest = traceDigest(trace); + assert.equal(verifyTrace(trace), true); // No schema was supplied. + assert.equal(verifyTrace(trace, { panel }), false); + } + const login = buildTrace({ panel, port: "sign-in", params: { email: "demo@example.com", password: "secret" }, at: AT }); + assert.equal(verifyTrace(login, { panel }), true); + login.params.password = "unredacted-secret"; + login.digest = traceDigest(login); + assert.equal(verifyTrace(login, { panel }), false); +}); + +test("trace verification rejects non-JSON input without invoking getters", () => { + assert.equal(verifyTrace({ get trace() { throw new Error("must not run"); } }), false); + assert.equal(verifyTrace(null), false); +});