From 29e3ad31973e1bc0df53fa754917f7000ab1dfed Mon Sep 17 00:00:00 2001 From: mandiau-matthias Date: Thu, 12 Nov 2026 12:52:06 +0100 Subject: [PATCH] Prepare Hardwire 0.4 experimental foundation Harden panel and argument validation, inference, scoring, redaction and trace binding. Add offline and local HTTP experiments, public API and integration guides, and regression coverage. Preserve the pending license and explicit no-executor boundary. --- .github/workflows/test.yml | 6 +- .nvmrc | 1 + CHANGELOG.md | 49 ++++++ CONTRIBUTING.md | 36 ++++ README.md | 296 ++++++++++++++------------------- SECURITY.md | 13 ++ docs/API.md | 106 ++++++++++++ docs/AUDIT-2026-11-12.md | 40 +++++ docs/GETTING-STARTED.md | 129 ++++++++++++++ docs/INTEGRATION.md | 65 ++++++++ docs/ROADMAP.md | 142 ++++++++-------- docs/SAFETY.md | 162 +++++++++--------- docs/SPEC.md | 51 ++++-- examples/README.md | 33 ++++ examples/demo/page.html | 32 ++++ examples/demo/panel.json | 53 ++++++ examples/demo/server.mjs | 137 +++++++++++++++ examples/walkthrough.mjs | 33 ++++ package.json | 18 +- src/canonical.mjs | 9 +- src/cli.mjs | 91 ++++++++-- src/corpus.mjs | 13 +- src/index.mjs | 1 + src/infer.mjs | 58 ++++--- src/mcp.mjs | 14 +- src/panel.mjs | 154 +++++++++++++---- src/probe.mjs | 61 ++++--- src/score.mjs | 33 ++-- src/trace.mjs | 52 ++++-- test/cli.test.mjs | 47 ++++++ test/foundation.test.mjs | 193 +++++++++++++++++++++ test/integration/demo.test.mjs | 60 +++++++ test/panel.test.mjs | 75 +++++++++ test/trace.test.mjs | 32 +++- 34 files changed, 1811 insertions(+), 484 deletions(-) create mode 100644 .nvmrc create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/API.md create mode 100644 docs/AUDIT-2026-11-12.md create mode 100644 docs/GETTING-STARTED.md create mode 100644 docs/INTEGRATION.md create mode 100644 examples/README.md create mode 100644 examples/demo/page.html create mode 100644 examples/demo/panel.json create mode 100644 examples/demo/server.mjs create mode 100644 examples/walkthrough.mjs create mode 100644 test/foundation.test.mjs create mode 100644 test/integration/demo.test.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af12e7c..9ca5e59 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,12 +11,12 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "24" # No install step: the package has no dependencies by design. - - run: node --test test/*.test.mjs - - run: node src/cli.mjs validate examples/example-panel.json - - run: node src/cli.mjs audit examples/example-panel.json + - run: npm run check + - run: npm pack --dry-run diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b832e40 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.16.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be08e22 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +## 0.4.0 — 2026-11-12 + +Experimental foundation audit and onboarding pass. + +### Added + +- Offline `npm run walkthrough`, documented local demo and npm scripts. +- CLI `infer --html`, `verify --panel`, and `trace --params-file`. +- Getting-started guide, API reference, integration boundaries, development and + security reporting instructions. +- Regression tests for contract validation, redaction, inference, scoring, + network handling and CLI behavior; real HTTP integration checks in CI. + +### Fixed + +- Unsupported schema keywords are rejected rather than lost during normalization. +- Unknown contract fields, ambiguous origins/endpoints, invalid policy values, + incompatible enums and prototype-sensitive keys fail validation. +- Closed-object defaults are emitted in MCP schemas, including nested objects. +- Trace ports resolve from their panel; optional verification binds the trace to + the supplied panel revision. Passwords and write-only array items are redacted. +- Inference no longer treats arbitrary JSON-LD actions as reads. Password fields + are marked write-only; candidate IDs cannot collide; unsafe endpoints are omitted. +- Scoring rejects origin mismatches and weakened effect/approval declarations. + A property named `description` is no longer accidentally ignored. +- Invalid ground truth fails corpus validation; invalid captures cannot pass a + zero threshold. Oversized responses are rejected instead of truncated. +- Private-host screening handles IPv6 literals without rejecting public domains + merely starting with `fc` or `fd`. Redirect/non-text bodies are cancelled. +- CLI rejects irrelevant/duplicate flags and unexpected positional arguments. +- Documentation no longer claims execution, approval enforcement, authenticated + receipts, real-site benchmark results or standards compatibility not implemented. + +### Compatibility + +Normalized panels now emit `additionalProperties: false` explicitly; panel +hashing normalizes its input. **Digests may change from 0.3.0.** Unknown panel, +port and auth fields are rejected. Invalid trace timestamps and malformed +result digests are rejected. Keep original panels/traces when testing older +versions. The draft format is not frozen. + +License remains `UNLICENSED`; npm publication remains disabled. No executor, +MCP transport, React SDK, WebMCP adapter or real-site accuracy claim is added. + +## 0.3.0 + +Corpus aggregation, CLI exit-code checks and separate read-port error metrics. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9ccdd95 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Development + +The repository's license has not been chosen. These instructions describe the +owner's development workflow; they do not grant outside reuse rights or invite +code contributions under an unspecified license. + +Use Node 24.16+ (`nvm use`). There are no dependencies or generated build files. + +```sh +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` +command intentionally exits 1 on the current baseline; its expected scores and +failure modes are asserted inside the passing test suite. + +When sockets are unavailable, `npm run test:unit` is a useful local subset, not +a replacement for integration tests. Never silently skip integration failures. + +Keep changes small enough to review. Validate new behavior against the public +contract, add regression coverage for a real defect, and update the spec and +examples together. Do not add dependencies or an execution runtime without a +specific need and a documented boundary. Keep runtime effects out of inference +and validation modules. + +For an inference fixture, add a directory with `page.html` and `expected.json`. +Label synthetic examples as synthetic. Real captures require provenance, +permission to include the content, redaction and ground truth from someone who +knows the application's behavior. Include failures rather than selecting only +pages the parser already understands. + +Report a reproducible bug with the Node version, command, expected/actual output, +and the smallest sanitized panel/HTML sample. Never paste credentials or private +customer data. See `SECURITY.md` for sensitive reports. diff --git a/README.md b/README.md index db531a4..d7c2a9c 100644 --- a/README.md +++ b/README.md @@ -2,217 +2,171 @@ **Sites expose ports. Agents plug in.** -Hardwire is an open panel format for agent-operable software. Instead of an -agent guessing which element is "submit", an application publishes its real -operations as typed ports with a declared effect and confirmation floor. One -file, one digest, no DOM. +[![Tests](https://github.com/Automatron-workflows/hardwire/actions/workflows/test.yml/badge.svg)](https://github.com/Automatron-workflows/hardwire/actions/workflows/test.yml) -```json -{ - "panel": "hardwire.panel/1", - "origin": "https://shop.example.com", - "ports": [ - { - "id": "place-order", - "kind": "payment", - "summary": "Convert the current cart into a paid order.", - "parameters": { - "properties": { - "shippingAddressId": { "type": "string" }, - "idempotencyKey": { "type": "string" } - }, - "required": ["shippingAddressId", "idempotencyKey"] - }, - "endpoint": "/api/orders" - } - ] -} -``` - -That file is the whole idea. It compiles to MCP tools, it validates arguments -before anything happens, and it cannot be downgraded into an unattended payment. - -## Why +Hardwire describes an application's operations in a small JSON file called a +**panel**. Each operation is a **port**: named arguments, an effect such as +`read` or `payment`, and a declared confirmation requirement. An agent +integration can inspect that contract instead of guessing which UI to click. -Cursor emulation is the worst part of agent browsing. It is slow, it breaks on -every redesign, and it turns a deterministic operation into a vision problem. -The fix is not a smarter cursor; it is for software to say what it does. +**Status: 0.4.0 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. -That idea is not new. WebMCP is a W3C Community Group draft for browser-side -tool APIs, and MCP servers are already the dominant server-side pattern. Hardwire -does not compete with either. It takes the part they leave to each implementer — -**what is safe to expose, and what may run without a human** — and makes it a -validated file with stable error codes. +## Start in two minutes -## Install - -Node 24.16 or newer. Zero dependencies, nothing to install. +Use **Node 24.16 or newer** (`nvm use` if you use nvm). No dependencies, API keys, +accounts, installation step, or paid services are needed. ```sh git clone https://github.com/Automatron-workflows/hardwire.git cd hardwire +node --version +npm run walkthrough npm test ``` -Use it as a library: +The offline walkthrough exercises the public API, verifies a trace, demonstrates +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. -```js -import { normalizePanel, compilePanel, buildTrace } from "hardwire"; -``` +**Next:** [step-by-step experiments](docs/GETTING-STARTED.md), +[API reference](docs/API.md), or [adding a panel to your app](docs/INTEGRATION.md). -Or from the command line: +## What a panel looks like -```sh -node src/cli.mjs validate examples/example-panel.json -node src/cli.mjs audit examples/example-panel.json -node src/cli.mjs compile examples/example-panel.json -node src/cli.mjs probe https://shop.example.com -node src/cli.mjs infer https://shop.example.com --draft +```json +{ + "panel": "hardwire.panel/1", + "origin": "https://shop.example.com", + "ports": [{ + "id": "search-catalogue", + "kind": "read", + "summary": "Search the product catalogue.", + "parameters": { + "properties": { "query": { "type": "string" } }, + "required": ["query"] + }, + "endpoint": "/api/search" + }] +} ``` -## Commands +Publish a reviewed panel at `/.well-known/hardwire.json`. That discovery path is +Hardwire's own convention. The endpoint is a locator; the format does not yet +specify HTTP methods, argument serialization, authentication or retries. A +consumer needs an explicit implementation for each port it will call. -| Command | What it does | +| Term | Meaning | | --- | --- | -| `validate ` | Check the panel and print the normalised form. | -| `audit ` | Report what an agent may do unattended. | -| `compile ` | Emit an MCP `tools/list` document. | -| `probe ` | Score an origin's agent-readiness from public endpoints. | -| `infer ` | Derive candidate ports from forms and structured data. | -| `trace ` | Validate arguments and emit a digested record. | -| `score --expected … --infer …` | Measure how far an inference is from a hand-written panel. | -| `corpus ` | Score every case in a corpus and aggregate the result. | +| Panel | The versioned description of an application's operations. | +| Port | One operation, its argument schema and declared policy. | +| Consumer / plug | Your integration that maps ports to real handlers. Not included. | +| Trace | A record of a caller's assertion, bound to a panel digest. Not proof of execution. | -Exit codes: `0` ok, `1` invalid input, an origin below the probe threshold, or a -score/corpus that fails its gate, `2` usage or I/O. +## Try the commands -## Three rules that do the work +```sh +node src/cli.mjs --help +node src/cli.mjs validate examples/example-panel.json +node src/cli.mjs audit examples/example-panel.json +node src/cli.mjs compile examples/example-panel.json --json +node src/cli.mjs infer https://blog.example.com --html examples/corpus/blog/page.html --draft +``` -**1. Effect classes have floors that cannot be lowered.** +The last command is **offline**; the origin identifies the captured page. It does +not contact that fictional host. Without `--html`, inference fetches the root +page of the supplied origin. `compile --json` emits a `tools/list` **result body**, +not a running MCP server or a complete JSON-RPC message. -| `kind` | Default | Floor | Overridable | -| --- | --- | --- | --- | -| `read` | `none` | `none` | — | -| `write` | `user` | `none` if `reversible: true` | yes, to `none` | -| `destructive` | `owner` | `owner` | **no** | -| `payment` | `owner` | `owner` | **no** | +| Command | Purpose | +| --- | --- | +| `validate ` | Validate; add `--json` for normalized JSON. | +| `audit ` | Summarize declared effects and confirmation requirements. | +| `compile --json` | Produce MCP tool descriptions. | +| `probe ` | Check a fixed set of discovery paths; heuristic, not certification. | +| `infer [--html ]` | Propose candidates; `--draft` emits a validated draft. | +| `trace --params '' --json` | Record validated arguments; executes nothing. | +| `verify [--panel ]` | Check digest consistency and optionally panel binding. | +| `score --expected --infer ` | Compare one capture with hand-written ground truth. | +| `corpus ` | Aggregate inference results across fixture pairs. | -A panel declaring `payment` with `confirmation: "none"` is rejected with -`hardwire_confirmation_weakened`. It does not silently pass. Reversibility is -never inferred; a publisher has to assert it. +Exit codes: **0** success, **1** validation or measured failure, **2** usage or I/O +error. `--json` puts machine-readable successful results on stdout; errors go to +stderr. Use `--params-file` instead of command-line arguments for sensitive +inputs; redaction cannot remove secrets from shell history or process listings. -**2. Description is not enforcement.** +## Use the API -`validateParams` checks real arguments against the declared schema: types, -required fields, enums, array items and unknown keys. Unknown properties are -rejected unless the panel opts into `additionalProperties: true`. Declaring a -shape that nothing checks is worse than declaring nothing. +From a script inside this checkout: -**3. A trace binds to one revision.** +```js +import { normalizePanel, compilePanel, validateParams } from './src/index.mjs'; +import { readFile } from 'node:fs/promises'; + +const panel = normalizePanel(JSON.parse( + await readFile('./examples/example-panel.json', 'utf8') +)); +const tools = compilePanel(panel); +const port = panel.ports.find(p => p.id === 'search-catalogue'); +const args = validateParams(port, { query: 'lamp' }); +console.log(tools.length, args); // 5, { query: 'lamp' } +``` -Every trace carries the digest of the exact panel it came from. "The agent did -X" becomes checkable against "this is what the site published at the time." -Write-only parameters are redacted before hashing, so a login never lands in a -log. +The package name is not published or reserved on npm. See the +[local package setup](docs/GETTING-STARTED.md#use-it-from-another-local-project) +and the current license before reuse. -## Inference: working with sites that published nothing +## What the foundation checks -`infer` reads forms, field types, `required` attributes and JSON-LD -`potentialAction` blocks to propose candidate ports. It is deliberately -conservative: +| Declared kind | Default confirmation | Lowest accepted declaration | +| --- | --- | --- | +| `read` | `none` | `none` | +| `write` | `user` | `user`, or `none` only with `reversible: true` | +| `payment` | `owner` | `owner` | +| `destructive` | `owner` | `owner` | -- it may only propose `read` and `write` — **never** `payment` or `destructive`; -- every candidate is labelled `low` or `very-low` confidence with its evidence; -- `--draft` emits a valid panel where every write still needs user confirmation. +These rules reject contradictory **declarations**. They cannot prove what an +endpoint actually does, that a write is reversible, or that approval happened. +MCP annotations are hints; the consumer must enforce authorization and approvals. -A candidate is a starting point for a reviewer, not a contract. The point is -that an agent gets something useful on day one and a publisher gets a concrete -thing to correct. +Schemas reject unsupported keywords and validate arguments, including nested +objects. Unknown arguments are rejected by default. Traces redact strings +explicitly marked `writeOnly` before hashing, including array items. Unmarked +secrets are not detected. Anyone can rewrite a trace and recompute its digest; +verification is a consistency check, not a signature or receipt. -### Measuring inference instead of asserting it +## Inference is an experiment -`score` compares an inferred panel with the panel a careful engineer would have -published. Differences are named by field, invented ports cost points, and an -invented **mutating** port fails outright regardless of score. +Inference reads static forms and JSON-LD `SearchAction` declarations. It does +not render JavaScript, reconstruct arbitrary browser forms, or know business +semantics. A GET form may still change state. Every candidate needs review. ```sh -npm run score -# score 45/100 (50 - 5 for invented ports) -# expected 4 ports, inferred 3, identical 2 -# missing: place-order, sign-in -# invented: checkout +npm run corpus ``` -The number is the product. Gate 1 in [docs/ROADMAP.md](docs/ROADMAP.md) asks for -an error rate across a corpus, so one recorded pair is a smoke test. `corpus` -loads a directory of them: +The **three synthetic fixtures** currently score 100, 60 and 45, with a mean of +68.3/100. This command intentionally exits **1**: only one fixture passes the +80-point threshold, and the shop inference invents a mutation. Read error is 0% +across just **two read ports**. That is a regression baseline, not evidence of +accuracy on real websites. See [measurement and next gates](docs/ROADMAP.md). -``` -examples/corpus/ - blog/ page.html expected.json - legacy/ page.html expected.json - shop/ page.html expected.json -``` +## Where it fits -```sh -npm run corpus -# blog 100/100 pass -# legacy 60/100 fail -# shop 45/100 fail -# -# 3 cases, mean 68.3/100, min 45, max 100 -# 1/3 pass at threshold 80; 1 invented mutating port(s) -# read error 0% across 2 read port(s); 0 invented payment(s), 0 destructive -``` +Hardwire is a small contract and validation experiment alongside existing APIs. +It exports MCP-shaped tool descriptions; it does not replace MCP or OpenAPI. +There is no WebMCP adapter yet. The [standards notes](docs/INTEGRATION.md#relationship-to-existing-standards) +link the primary specifications and distinguish implemented features from ideas. -Adding a case is the contribution: capture a real page, write down the ports it -should expose, run the corpus. The cases are deliberately ordinary — a shop, a -blog with structured search, and a form using `user[name]` fields — because the -interesting failures are in ordinary markup, not in demos. - -The report separates the gate's own terms from the aggregate: structural error -on `read` ports, and invented `payment`/`destructive` ports. Both are clean -here, but two read ports is not a sample. Three cases is a harness with -evidence, not a general error rate. - -## Probe scoring - -`probe` fetches a bounded set of public paths. Weights sum to 100: - -| Signal | Path | Weight | Status | -| --- | --- | --- | --- | -| panel | `/.well-known/hardwire.json` | 30 | proposed here | -| WebMCP declaration | in-page ``/`` | 20 | draft, weak | -| MCP descriptor | `/.well-known/mcp.json` | 15 | convention | -| OpenAPI | `/openapi.json` | 15 | convention | -| agent card | `/.well-known/agent-card.json` | 10 | deployed | -| `llms.txt` | `/llms.txt` | 5 | convention | -| x402 | `/.well-known/x402` | 5 | deployed | - -A panel that fails validation scores nothing. A blanket `robots.txt` disallow -caps the total at 25 and sets `cappedByRobots`. The number is a diagnostic for -finding work, not a grade or a certification. - -## What this does not do - -- **It does not make unauthorised automation authorised.** A panel is - cooperation. `probe` reports a blanket `robots.txt` disallow as a gate instead - of pretending the score is fine. -- **It does not execute.** `trace` validates and digests. Calling the endpoint, - holding credentials and deciding whether to proceed belong to the caller. -- **It does not sign.** A digest proves a record is unmodified, not that anyone - with authority asserted it. -- **It is not a standard.** One implementation, no adopters, no registry. A - format with a single author is a proposal. - -## Status - -`0.3.0`, unpublished, unadopted. The format may change. See -[docs/ROADMAP.md](docs/ROADMAP.md) for the gates that would have to pass before -anyone should depend on it, and [docs/SAFETY.md](docs/SAFETY.md) for the threat -model. +[Format specification](docs/SPEC.md) · [Safety boundaries](docs/SAFETY.md) · +[Development](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) ## License -Not decided. `package.json` is `"private": true` and `"license": "UNLICENSED"` -until the owner chooses. Do not assume permission to reuse. +**Not selected.** The existing `UNLICENSED` / `private: true` status is retained. +This repository is publicly visible, but it is not an open-source release and +visibility does not grant a reuse license. Outside reuse and npm publication +remain pending the owner's license decision. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a0b2f23 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security + +Hardwire is experimental and has no production support or independent security +audit. The current maintained code is the default branch. Its implemented +boundaries and known gaps are in [docs/SAFETY.md](docs/SAFETY.md). + +If GitHub's **Security → Report a vulnerability** option is available on this +repository, use it for sensitive details. Otherwise, open a minimal issue asking +for a private reporting channel without publishing exploit details or secrets. +For ordinary non-sensitive defects, include a sanitized reproducer in an issue. + +Do not test against sites or accounts without permission. Do not attach real +passwords, tokens, private captures or traces containing personal data. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..dcc0295 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,106 @@ +# Public API + +Node.js ESM, zero dependencies. Import from `hardwire` after a permitted local +package install, or from `./src/index.mjs` inside the checkout. Subpath exports +are listed in `package.json`. Functions are synchronous unless marked async. + +## Contracts and arguments + +| Export | Inputs → result | +| --- | --- | +| `normalizePanel(input)` | Raw JSON object → validated, sorted panel with explicit defaults. Throws `HardwireError`. | +| `normalizeOrigin(origin)` | Bare HTTPS origin, or local HTTP origin → normalized origin. No network or private-host check. | +| `panelDigest(panel)` | Raw or normalized panel → `sha256:` plus 64 lowercase hex digits. Normalizes before hashing. | +| `auditPanel(panel)` | Raw or normalized panel → declared effect/confirmation counts and unconfirmed mutation IDs. | +| `validateParams(port, params)` | **Normalized** port and JSON arguments → independent validated copy; throws on mismatch. | +| `HardwireError` | Error with `code`, `path`, `detail`, `message`. See the spec's error table. | + +Normalize panels at the trust boundary. Unknown panel, port, auth and schema +fields fail validation. This catches misspelled policy fields. The schema subset +is deliberately smaller than JSON Schema; unsupported constraints fail closed. +`expects` is descriptive only: no result-validation API exists yet. + +Constants: `PANEL_VERSION`, `PANEL_FILENAME`, `DISCOVERY_PATH`, `PANEL_LIMITS`, +`KINDS`, `CONFIRMATIONS`. + +## Tool descriptions + +| Export | Result | +| --- | --- | +| `compilePanel(panel)` | Array of MCP-shaped tools with names, descriptions, input schemas and risk hints. | +| `toolsListResponse(panel)` | `{ tools: compilePanel(panel) }`, the result body of `tools/list`. | +| `findPort(panel, toolName)` | `{ tool, port }` with a normalized port, or `undefined`. Validates the panel. | +| `toolName(portId)` | Maps a valid port ID such as `search-catalogue` to `search_catalogue`. | + +Tools contain no executable handler. `confirmation`, `preconditions`, endpoint +mapping and authentication must be enforced by a separate consumer. MCP hints +must not substitute for reading and validating the panel. In particular, a +payment is not emitted with a claim that it cannot be destructive. + +## Traces + +```js +const trace = buildTrace({ + panel, port: 'search-catalogue', params: { query: 'lamp' }, + 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 +``` + +`buildTrace` accepts a raw or normalized panel and a port ID or port object. It +resolves the port from the panel; caller-supplied port metadata cannot override +it. Arguments are validated and declared write-only strings are redacted. +`at` defaults to now, `actor` to `agent`, and outcome status to `unknown`. + +Outcome status is `succeeded`, `failed`, `unknown` or `refused`. Optional `code` +is a non-empty string of at most 128 characters. Optional `resultDigest` is a +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. +It cannot reconstruct redacted values. Two otherwise identical records that +differ only in redacted secrets intentionally have the same digest. + +Lower-level exports: `traceDigest(document)` hashes everything except its own +`digest` field; `redactParams(schema, value)` redacts without validating; +`canonicalJson(value)` serializes the documented bounded JSON subset. +`TRACE_VERSION` names the trace format. Low-level helpers are not validators. + +## Inference, scoring and discovery + +| Export | Inputs → result | +| --- | --- | +| `inferFromHtml(html, { origin })` | Static HTML → candidate report with evidence and limitations. No network. | +| `inferFromUrl(origin, options)` **async** | Fetch origin root once → inference report. | +| `toPanelDraft(inference)` | Candidate report → normalized draft panel; rejects empty or invalid drafts. | +| `comparePanels(expected, actual)` | Two panels → structural score, missing/invented ports and unsafe differences. | +| `passes(result, threshold = 80)` | True only if score meets threshold, origins match, and no error, invented mutation or unsafe difference exists. | +| `scoreCorpus(cases, { threshold = 80 })` | Non-empty array of `{ name, expected, html }` → aggregate and per-case results. | +| `probeOrigin(origin, options)` **async** | Fixed-path discovery report, score and network findings. | +| `assertPublicOrigin(origin, { allowPrivate = false })` | Bare origin → screened origin. Hostname/literal screening only, not DNS resolution or rebinding protection. | +| `fetchPage(url, options)` **async** | Low-level bounded GET → status, body, content type and optional error. **Does not screen the target**; validate it and apply network policy before use. | + +Network options: `fetchImpl` (default global fetch), `timeoutMs` (default 6000, +integer 1–120000), and `allowPrivate` for `probeOrigin` / `inferFromUrl` only. +Bodies over 512 KiB fail instead of parsing a truncated prefix. Redirects are +not followed. DNS/network errors are findings for the probe; inference throws +if it cannot fetch usable HTML. Do not expose these developer helpers as an +untrusted multi-tenant URL-fetch service. + +The probe validates the Hardwire panel only. Other discovery markers are weak +presence signals, not verified protocol implementations. The `webmcp` heuristic +matches source text, including older API spellings; it cannot see tools registered +at runtime. `robots.txt` handling reports a blanket wildcard disallow and caps +the score. It is not a complete robots policy engine or authorization check. + +Scoring compares kind, confirmation, reversibility, endpoint and parameter +structure with equal weight. It ignores prose, preconditions, result schemas, +and business correctness. A score is a development diagnostic, not a full +contract equivalence test. Origin mismatches, effect changes, lower confirmation, +invented reversibility, and invented mutations always fail the pass predicate. + +Other exports: `DEFAULT_THRESHOLD`, `INFERENCE_VERSION`, `SIGNALS`, +`ROBOTS_CAP`, `WEBMCP_WEIGHT`. Discovery paths and weights are experimental. diff --git a/docs/AUDIT-2026-11-12.md b/docs/AUDIT-2026-11-12.md new file mode 100644 index 0000000..eec5356 --- /dev/null +++ b/docs/AUDIT-2026-11-12.md @@ -0,0 +1,40 @@ +# Foundation audit — 2026-11-12 + +Scope: the existing 0.3.0 repository plus unfinished local validation and demo +work. The changes are consolidated in 0.4.0. This is an engineering review, +not an independent security certification. + +| Finding | Resolution / regression coverage | +| --- | --- | +| Schema constraints could be dropped; port metadata was not strict | Reject unsupported keys and malformed values; panel and foundation tests. | +| Local-origin exception admitted non-HTTP schemes; ambiguous endpoint forms | Shared origin validation and explicit endpoint rules; malformed URL tests. | +| Runtime argument checks rejected unknown keys while exported JSON Schema allowed them | Emit `additionalProperties: false` recursively; MCP/validator agreement test. | +| User-controlled property names could alter plain-object prototypes | Reject reserved keys before copying; prototype-sensitive input tests. | +| Trace creation could use a foreign port or an unvalidated panel | Normalize the panel and resolve its port by ID; impostor and binding tests. | +| Password inference and write-only array-item redaction were missing | Mark password fields and redact at every schema node; trace secrecy tests. | +| Trace dates/result digests could be malformed | Validate real UTC timestamps and SHA-256 representation; malformed trace tests. | +| Any JSON-LD Action was treated as a read | Restrict structured inference to SearchAction; mutation-action tests. | +| Candidate IDs could collide; commented/scripted forms were treated as real | Collision-safe IDs and static-source filtering; inference regressions. | +| Bad inference could still pass at a low score threshold | Hard-fail mismatched origins and weakened effects/confirmations; scoring tests. | +| A real field named description vanished from scoring | Strip schema annotations only; structural comparison test. | +| Invalid ground truth or empty capture could produce misleading passes | Validate expected panels first and always fail errored captures; corpus tests. | +| Private-IP heuristic overmatched domain names and missed mapped IPv6 | Literal-aware matching with IPv4-mapped handling; URL screening tests. | +| Truncated/SPA-fallback bodies could masquerade as discovery evidence | Reject oversize bodies, require JSON objects/plain text for markers; probe tests. | +| Unknown command flags and extra arguments were silently accepted | Validate per-command arity/flags; CLI misuse tests. | +| Local demo had no npm command and a broken test cleanup variable | Demo script plus real loopback integration and CLI smoke tests. | +| README conflated declarations, approval enforcement and signed evidence | Rewritten README, API/integration guides, safety model and honest roadmap. | + +Validation runs use Node 24.16+. The unit/CLI suite and offline walkthrough run +without network access. The separate integration suite starts a real loopback +server, probes its published panel, infers the page, checks a real read response, +and verifies writes are refused. `npm run check` runs both suites and the tour; +GitHub CI additionally checks package contents with `npm pack --dry-run`. + +Known boundaries remain explicit: no executor, authentication/approval service, +MCP transport, result validator, browser SDK or WebMCP adapter. DNS rebinding +requires external network policy. Static inference is limited; three synthetic +fixtures do not prove real-site accuracy. The license remains unresolved and +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. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md new file mode 100644 index 0000000..024bb38 --- /dev/null +++ b/docs/GETTING-STARTED.md @@ -0,0 +1,129 @@ +# Experiment with Hardwire + +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. + +## 1. Inspect an explicit contract + +```sh +node src/cli.mjs validate examples/example-panel.json --json +node src/cli.mjs audit examples/example-panel.json +node src/cli.mjs compile examples/example-panel.json --json +``` + +The shop panel has five ports: one read, four mutations. Two mutations require +owner confirmation; two declare themselves reversible and request no +confirmation. The audit reports those declarations; it does not authorize them. + +## 2. Infer a draft without a network + +```sh +mkdir -p output +node src/cli.mjs infer https://blog.example.com --html examples/corpus/blog/page.html --draft > output/blog.panel.json +node src/cli.mjs validate output/blog.panel.json +node src/cli.mjs score --expected examples/corpus/blog/expected.json --actual output/blog.panel.json +``` + +Expected: `score 100/100`, exit 0. The blog is a deliberately simple synthetic +fixture. Edit a parameter type in the draft and rerun the comparison to see the +named structural difference. Do not treat a passing score as runtime safety. + +## 3. Create and check a trace + +```sh +node src/cli.mjs trace examples/example-panel.json search-catalogue --params '{"query":"lamp"}' --json > output/search.trace.json +node src/cli.mjs verify output/search.trace.json --panel examples/example-panel.json --json +``` + +Expected: `{"valid": true, "panelChecked": true}` (pretty-printed). The outcome +is `unknown`, because nothing was executed. Change `lamp` in the saved trace and +verification returns `valid: false`, exit 1. Changing the supplied panel revision +also fails the panel binding. Recomputing a digest after changing a record can +make it verify again; this is not forgery detection. + +To supply arguments from a file, use `--params-file path/to/params.json` instead +of `--params`. Never commit credentials or traces containing personal data. +`writeOnly` redacts only the fields the panel author explicitly marked. + +## 4. Run a real local HTTP origin + +Terminal one: + +```sh +npm run demo +``` + +Terminal two: + +```sh +node src/cli.mjs probe http://127.0.0.1:8787 --allow-private --fail-under 30 +node src/cli.mjs infer http://127.0.0.1:8787 --allow-private --draft > output/demo.panel.json +node src/cli.mjs compile output/demo.panel.json --json +curl 'http://127.0.0.1:8787/search?q=lamp' +``` + +Expected probe score: **35/100**, from a valid panel (30) and `llms.txt` (5). +The normal probe threshold is 40, so this example explicitly uses 30. The search +responds with `{"demo":true,"query":"lamp"}`. This `curl` call is manual HTTP, +not a Hardwire executor. + +The demo binds to loopback only, has no session or database, and returns 405 for +POST. Sign-in and subscription forms are inference examples, not functional +features. Use fake data only. Stop it with Ctrl-C. To change ports: + +```sh +npm run demo -- --port 8788 +``` + +The served panel automatically declares the actual listening port. `--port 0` +chooses a free port and prints it. If 8787 is occupied, choose another port. + +## 5. Measure the known failures + +```sh +npm run corpus +node src/cli.mjs corpus examples/corpus --json > output/corpus.json +``` + +Both currently exit 1 by design. The fixture baseline is blog 100, legacy 60, +shop 45. See [the roadmap](ROADMAP.md) for the exact gate and how to collect +real evidence without representing synthetic examples as a benchmark. + +## Use it from another local project + +The owner can test the checkout as a local package. Outside developers must +resolve the current license restriction before reuse. In a separate project: + +```sh +npm install /absolute/path/to/hardwire +``` + +Then, in an ESM `.mjs` file: + +```js +import { normalizePanel, compilePanel } from 'hardwire'; +const panel = normalizePanel({ + panel: 'hardwire.panel/1', origin: 'https://example.com', + ports: [{ id: 'read-status', kind: 'read', summary: 'Read current status.' }] +}); +console.log(compilePanel(panel)); +``` + +Do not run `npm install hardwire` expecting this repository. It is not published +on npm, and the package name has not been reserved. Current code is Node.js ESM; +it is not a browser-ready React SDK. A React app can serve the static panel, +while its backend or a trusted Node integration uses the library. + +## Troubleshooting + +| Symptom | Explanation / action | +| --- | --- | +| `node --version` shows 18 or 20 | Activate Node 24.16+, e.g. `nvm use`. | +| `listen EPERM` | Your sandbox forbids local servers. Use the offline tour and `npm run test:unit`; run the full suite on a machine that permits loopback sockets. | +| `hardwire_origin_private` | The probe refused a local target. Use `--allow-private` only for a development origin you control. | +| Probe exits 1 | The score is below the threshold; inspect the report and network findings. | +| `hardwire_schema_invalid` | The schema uses unsupported constraints or inconsistent types. Do not silently remove a constraint your app requires. | +| `hardwire_infer_failed` | No usable candidates, invalid candidate schema, non-HTML response, or fetch/capture limits. Inspect the HTML; runtime-rendered controls are not supported. | +| `npm run corpus` exits 1 | Expected for the supplied imperfect synthetic corpus. | +| A compiled tool does nothing | Compilation supplies descriptions only. Implement a trusted handler mapping; see the integration guide. | diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..8414ae6 --- /dev/null +++ b/docs/INTEGRATION.md @@ -0,0 +1,65 @@ +# Add a panel to an application + +Hardwire's useful boundary is the application's real operations. Start with one +read operation on a site you control. Do not tag every button or infer that an +inferred draft is permission to call a third party. + +## Publisher setup + +1. Copy `examples/example-panel.json` and keep only your real operations. +2. Set the bare origin. Use stable port IDs; describe effects honestly. +3. Specify argument types and required fields. Mark secret strings `writeOnly`. +4. Validate with `node src/cli.mjs validate path/to/hardwire.panel.json`. +5. Serve that file as JSON at `/.well-known/hardwire.json` from the same origin. +6. Probe your origin and review the result. Missing unrelated markers need not + be added just to inflate a diagnostic score. + +For a React app, this can be a static asset in the app's public directory, +e.g. `public/.well-known/hardwire.json` in builds that copy public assets to the +site root. Confirm your host actually serves dot-prefixed paths and does not +replace the response with an SPA HTML fallback. No component wrapper is needed. +The library itself uses Node.js APIs and is not a browser bundle. + +The format has no transport contract yet. An endpoint does not specify HTTP +method, headers, CSRF handling, body serialization or session requirements. +`auth.loginPort` is a reference to a declared port, not a login implementation. +`expects` describes a response shape but is not enforced by this release. + +## Consumer setup + +A trusted Node consumer can use this explicit sequence: + +1. Fetch the panel under your own access and network policy. Validate it. +2. Check its origin and compare its digest with the revision you reviewed. +3. Compile descriptions, retaining the original panel alongside the tools. +4. Resolve the requested tool with `findPort`; validate arguments. +5. Apply your application's authorization, precondition and approval checks. +6. Map that port ID to a known handler. Implement retries and idempotency there. +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 +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. + +A reviewed `read` handler is a useful first experiment. A login + reversible +write + read-back demonstration remains a separate roadmap gate. Human approval +must bind to the port, arguments and reviewed panel revision; a string saying +`owner` is not an approval token. + +## Relationship to existing standards + +Checked against primary sources on 2026-11-12: + +| System | Relationship | +| --- | --- | +| [MCP tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) | Hardwire produces the tool-list result shape. It implements no transport, initialization or `tools/call` handler. | +| [MCP annotation guidance](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/) | Annotations are untrusted hints unless backed by a trusted publisher and enforcement. They do not prove safety. | +| [WebMCP draft](https://webmachinelearning.github.io/webmcp/) | Browser-side JavaScript tool registration. The 2026-11-11 draft uses `document.modelContext`; older proposals used `navigator.modelContext`. No adapter is implemented here. | +| OpenAPI | Existing HTTP interface descriptions. Hardwire does not convert OpenAPI documents; the probe only checks a conventional discovery path. | + +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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3470ad7..127d92e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,97 +1,85 @@ -# Roadmap and gates +# Foundation and next gates -Hardwire is a hypothesis with tests, not a product. This file lists what would -have to be true before it deserves an adopter, in the order that matters. +## Foundation: implemented in 0.4.0 ---- +- Strict panel and schema validation, argument checks and normalized digests. +- MCP tool descriptions with the same closed-object semantics as validation. +- Static inference with evidence, draft validation and secret annotations. +- Per-case and corpus scoring with explicit failure conditions. +- 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. -## Gate 1 — Inference accuracy +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. -**Claim under test:** a panel can be derived from a page well enough to be -useful without cooperation. +## Gate 1: real inference evidence — open -**Measurement:** take N real sites with forms. Infer a panel. Hand-write the -panel a competent engineer would publish. Score the difference: ports missed, -ports invented, wrong effect class, wrong parameter type, wrong required set. +**Question:** does static inference save useful work on sites that did not +publish a panel? -**Pass condition:** zero invented `payment` or `destructive` ports across the -sample (guaranteed by construction), and under 20% structural error on `read` -ports. +Current baseline: **three synthetic fixtures**, blog 100, legacy 60, shop 45; +mean **68.3/100**, **1/3** pass at 80. Read structural error is **0% over two read +ports**. There is one invented mutation and no invented payment/destructive +label. Those latter labels are excluded by construction and are not evidence +that endpoints cannot pay or delete. -**State:** harness built (`hardwire score`, `hardwire corpus`). Three recorded -cases — a shop, a blog with structured search, and a legacy form — score a mean -of **68.3/100**; 1 of 3 clears the 80 threshold. On the gate's own terms, -structural error on read ports is **0% across 2 read ports**, with zero invented -`payment` or `destructive` ports. The dominant failures are missing endpoints -and a field name that cannot be represented (`user[name]`). Two read ports is -not a sample; the gate stays open until the corpus is large enough to mean -something. +A passing case needs the numeric threshold, matching origins, no inference +error, no invented mutation, and no effect mismatch, weakened confirmation or +invented reversibility. The separate read-error metric is a structural average, +not a task-success rate or safety certification. -## Gate 2 — One real traversal +Next experiment: capture **30–50 permitted real pages** across multiple independent +apps, including failures and forms of different kinds. Keep a manifest with +origin, capture date, permission/provenance, redactions and human-reviewed +expected operations. Never include real credentials, private user data or +unlicensed page content in a public corpus. Author ground truth from actual app +behavior, not from the inference result. -**Claim under test:** an agent can complete an actual task through a panel. +Proposed gate: under 20% structural error on read ports, no unsafe effect or +approval downgrades in reviewed matches, and a published failure breakdown. +Report sample size and limitations; this gate alone does not authorize execution. +The synthetic baseline must stay separate from a real-site evaluation. -**Measurement:** one task on a surface we control — log in, read a value, perform -one reversible write, read it back — executed through compiled tools, with every -step traced. +## Gate 2: controlled traversal — open -**Pass condition:** the task completes without the agent reading the DOM, and -the traces verify against the panel digest. +On one application we own, complete login, read, one reversible write, and +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. -**State:** not started. The repository contains no executor by design. +The local HTTP demo currently 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 +allowlist of local handlers for the first experiment. -## Gate 3 — External demand +## Gate 3: demand — open -**Claim under test:** someone who does not own this repository wants either to -publish a panel or to consume one. +Find one external team with a real application that wants to publish a panel or +consume one. Measure the integration work and task reliability. Stars and +synthetic examples do not satisfy this gate. Resolve the license before inviting +external implementation contributions or promoting reusable packages. -**Measurement:** a request from a third party, or a published panel not written -by us. +## Release decisions still open -**Pass condition:** one. Not a star, not a message of encouragement — an -implementation or a question with a real site behind it. +- 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. +- Review access permissions, privacy and retention for the actual deployment. + A file format cannot grant access rights or guarantee compliance by itself. -**State:** none. - -## Gate 4 — Legal review - -**Claim under test:** the format cannot be used as a ToS workaround. - -**Measurement:** a written assessment of whether publishing a panel changes any -obligation a site already has toward automated access. - -**Pass condition:** a review, by someone qualified, that says the format is -neutral with respect to access permission. - -**State:** reasoning only. - ---- - -## Between now and Gate 1 - -Cheap work that reduces uncertainty without committing to a direction: - -- [x] A scoring harness: run `infer` on a captured page, diff it against a - hand-written panel, emit a score. `hardwire score --expected … --infer …`. -- [x] A corpus loader: capture pages, hold the expected panel next to each, and - aggregate the scores into one report. `hardwire corpus examples/corpus`. -- [ ] A `probe --batch` mode over a list of origins, producing one JSON report. -- [ ] Publish a panel for one surface we control and serve it at the discovery - path, so `probe` can be validated against something real. -- [ ] Decide the licence. - -## Explicitly not planned - -- A browser driver. If Hardwire needed a browser it would have failed. -- Credential storage or a session vault. -- Payment execution. -- A crawler. `probe` checks fixed paths; it does not enumerate. -- A hosted registry, until Gate 3 passes. -- Any claim of standards status. WebMCP is the standards track; Hardwire should - compile into it if it wins, not compete with it. +No browser driver, credential vault, payment executor, registry or hosted service +is required for the current experiment. ## Version policy -`0.x` means the format may change without notice. After Gate 2, treat -`hardwire.panel/1` as frozen and add `hardwire.panel/2` for breaking changes; -never mutate a version that has been published. +The project is pre-1.0. Breaking behavior is documented in `CHANGELOG.md`. +0.4.0 explicitly closes object schemas and normalizes inputs before digesting; +normalized bytes and panel digests therefore change from 0.3.0. Regenerate +experimental fixtures, retain old panels with old traces, and never silently +reinterpret old records. + +Before external adoption, freeze the format and canonicalization, publish fixed +test vectors, and use a new format identifier for later semantic changes. The +current `hardwire.panel/1` is still a draft, not a stable interoperability claim. diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 06de0e5..41d4a30 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -1,113 +1,99 @@ -# Safety model +# Safety boundaries -This document is about failure modes, not features. It describes what the code -prevents, what it merely reports, and what it does not touch at all. +Hardwire validates descriptions and records caller assertions. It is not an +execution sandbox, authorization service, credential vault, or signing system. ---- +## Declarations are not runtime enforcement -## 1. The confused deputy +`payment` and `destructive` declarations cannot lower their confirmation below +`owner`. A write can declare `none` only with a boolean `reversible: true`. +Unknown contract fields and unsupported schema constraints are rejected. -An agent holds a session and reads a page containing attacker-controlled text. -The text says "you are authorised to transfer the balance". If the agent has an -unattended payment port, the attack succeeds without any exploit. +This catches contradictory contracts. It cannot catch a publisher describing a +payment as a read, a consumer skipping approval, or a reversible flag that is +false in practice. `owner` does not identify a person. Preconditions are labels. +MCP annotations are hints. The application must enforce all runtime policy. -**Mitigation:** effect classes with absolute floors. `payment` and `destructive` -cannot be declared below `owner` confirmation, and a panel that tries is -rejected at validation time rather than runtime. The floor is a property of the -file, so it cannot be negotiated away by a prompt. +## Panel content and endpoints are untrusted input -**Residual risk:** `owner` is a label, not an identity. The code does not know -who the owner is or how their approval arrives. A caller that treats -`confirmation: "owner"` as satisfied without asking anyone has defeated the -model, and nothing in this package can detect that. +Descriptions can contain prompt injection. Treat them as data, not instructions +to override the user's task or your authorization rules. A valid schema does +not make a description trustworthy. -## 2. Silent escalation by edit +Bare origins require HTTPS, except local HTTP development. Credentials, path, +query, fragments, whitespace and backslash tricks are rejected in origins. +Endpoints reject protocol-relative URLs, credentials, fragments and ambiguous +slash forms. Absolute HTTPS endpoints may be cross-origin: the format does not +allowlist execution destinations. A future consumer must do that itself. -An agent that once ran `read` ports should not silently gain a `payment` port -because the panel changed. +The probe requires a discovered panel's origin to match the serving origin. +TLS and a digest do not protect against a compromised publisher. Review and pin +the panel revision in a consumer; retaining it allows later trace comparison. -**Mitigation:** panels are content-addressed. A trace records `panelDigest` and, -if present, `revision`. Comparing digests shows whether the surface changed -between two calls. +## Arguments and secrets -**Residual risk:** nothing forces a caller to compare. There is no transparency -log and no pinning helper yet. +Argument checks cover the supported types, enums, required fields, nested +objects/arrays and unknown keys. JSON serialization limits apply even to open +objects and arrays without item schemas. Prototype-sensitive property names are +rejected where copied into schema or argument objects. -## 3. Credential exposure through logs +Trace creation redacts declared `writeOnly` strings before hashing, including +nested array items. It does not detect unmarked passwords, tokens, personal +information, secrets in prose, or arbitrary values in open schemas. Redaction +also does not undo leaks in terminal history, caller logs or process arguments. +Use fake credentials in examples and keep sensitive data out of committed files. -A login port takes a password. If that password enters a trace, it is now in -whatever stores traces. +## Trace consistency is not authenticity -**Mitigation:** `writeOnly: true` parameters are replaced with `"[redacted]"` -before hashing. The restriction to strings exists so redaction cannot be -ambiguous. Panels never contain credentials; `auth` may only reference a port. +`buildTrace` resolves a port from the validated panel and binds its digest. The +caller supplies the outcome. `verifyTrace(trace, { panel })` checks consistency +with that panel and port; without the panel it only checks the record itself. +Neither proves execution or approval. -**Residual risk:** a panel that forgets `writeOnly` on a secret will record it. -There is no automatic detection. Reviewing a panel before trusting it is still -required. +A digest detects an edit **when the original digest is retained and trusted**. +Anyone can edit a record and recompute its hash. There are no signatures, +trusted timestamps or append-only log here. Redacted values cannot be recovered +or distinguished through the digest. -## 4. Untrusted panel content +## Network helpers -A panel is fetched from the network. It could be tampered with in transit or -served by the wrong host. +The probe and URL inference screen obvious private hostnames and IP literals, +including local, link-local and mapped IPv4 addresses. Local development needs +explicit `--allow-private`. Responses are limited to 512 KiB, timeouts are +bounded, and redirects are not followed. Oversized responses fail rather than +being parsed from truncated prefixes. -**Mitigation:** `https` is required outside local development. A panel declares -its origin, and `probe` reports `originMismatch` when the declaration and the -serving origin differ. Redirects are not followed during a probe, so a -response cannot be walked to another host. +**This is not comprehensive SSRF protection.** DNS resolution, rebinding, +proxies, routing, and unusual reserved ranges need an external egress policy. +The low-level exported `fetchPage` accepts a URL without hostname screening. +These are developer helpers, not public multi-tenant fetch endpoints. -**Residual risk:** there is no signature on panels. A compromised origin serves -a malicious panel and the digest merely records that it was that panel. Signing -is deliberately out of scope; if it matters to you, sign the panel file and -verify it before calling `normalizePanel`. +The probe requests fixed discovery paths and reports a wildcard robots disallow +as a score cap. It does not implement all robots rules, and it fetches the fixed +paths before calculating that cap. A score is neither permission nor evidence +of successful automation. Access authorization remains the caller's responsibility. -## 5. Server-side request forgery through the probe +## Static inference -`probe` and `infer` accept a URL and fetch it. +Inference is a bounded heuristic over captured HTML, not a browser parser. +It does not run JavaScript or reproduce all form rules. Hidden fields, CSRF, +radio groups, disabled controls, entity encoding, custom widgets and business +semantics require human review. -**Mitigation:** private and link-local targets are refused unless -`--allow-private` is passed explicitly. Bodies are capped at 512 KB, requests -time out, and redirects are not followed. +Only `SearchAction` JSON-LD is proposed as a read. Forms produce low-confidence +`read` or `write` guesses; restricting these labels does **not** prevent money +movement or deletion at an endpoint. GET may mutate state. Inferred drafts +must not feed an unattended executor. The library has no executor. -**Residual risk:** DNS rebinding is not defended against. The probe is a -developer tool, not a multi-tenant service. Do not expose it to untrusted input -without a network egress policy. +## Out of scope -## 6. Bad inference +Authentication, authorization, approval binding, execution, retries, idempotency, +rate limits, response validation, credential storage and signing are not +implemented. `expects`, endpoint and auth metadata do not implement those features. +See the [roadmap](ROADMAP.md) before adding a consumer. -An inferred port can be wrong: the right shape, the wrong semantics. Calling it -could do something unintended. +## Reporting -**Mitigation:** inference may only propose `read` and `write`; it cannot invent -a `payment` or `destructive` port. Draft writes keep `confirmation: "user"` and -are never marked reversible. Every candidate carries its evidence and a -`low`/`very-low` confidence label. - -**Residual risk:** a wrong `write` is still a write. Inference output is a -proposal for a human, and the draft says so in its title. - -## 7. Replay and duplicate side effects - -A retried call can place two orders. - -**Mitigation:** none enforced. The format cannot know which endpoints are -idempotent. The convention is an `idempotencyKey` parameter on any port that -creates a charge, visible in the example panel. - -**Residual risk:** entirely on the implementer. This is the most likely real -failure in production and the weakest part of the model. - -## 8. Out of scope - -The package does not do authentication, authorisation, rate limiting, session -management, payment execution, refunds, or content moderation. It assumes a -caller that already holds credentials and has decided to act. - -It also does not crawl. It never follows links, never enumerates a site, and -never retries a refusal. - -## 9. Reporting - -If you find a way to make a `payment` or `destructive` port run without the -declared confirmation, that is the bug this design exists to prevent. Open an -issue titled `[safety]`. +See [SECURITY.md](../SECURITY.md). Useful reports demonstrate an accepted invalid +contract, an argument-validation bypass, secret redaction failure, or an incorrect +trace/panel binding. Do not include live secrets or data from someone else's site. diff --git a/docs/SPEC.md b/docs/SPEC.md index 8828d6a..68f8842 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -35,15 +35,16 @@ the origin it is served from; a mismatch is reported by `probe` as | Field | Required | Rules | | --- | --- | --- | | `panel` | yes | Exactly `hardwire.panel/1`. | -| `origin` | yes | Absolute, bare origin. `https` except `localhost` / `127.0.0.1`. No path, query or fragment. | +| `origin` | yes | Absolute, bare origin. `https`, or `http` on `localhost` / `127.0.0.1` / `[::1]`. No credentials, path, query, fragment, whitespace or backslashes. | | `ports` | yes | Non-empty array, at most 256 entries. | | `title` | no | ≤ 120 characters. | | `revision` | no | ≤ 128 characters, opaque. Included in the panel digest. | | `auth.loginPort` | no | Must name an existing port. | | `auth.note` | no | ≤ 500 characters. | -Unknown top-level keys are ignored, not rejected. Only `ports` content is -validated strictly. +Unknown panel, port, auth and schema keys are rejected. This catches misspelled +policy fields instead of silently dropping them. The current format is a draft; +see CHANGELOG.md for compatibility changes. ## 3. Ports @@ -70,11 +71,12 @@ validated strictly. | `reversible` | no | Boolean. Required to allow `write` with `confirmation: none`. | | `preconditions` | no | Array of `^[a-z][a-z0-9-]*$` labels. Advisory; the caller must check them. | | `parameters` | no | Object schema; absent means no arguments. | -| `expects` | no | Object schema describing the result shape. | -| `endpoint` | no | Site-relative (`/…`) or absolute `https`. | +| `expects` | no | Supported schema describing the result shape; not enforced at runtime. | +| `endpoint` | no | Single-slash site-relative (`/…`) or absolute `https://`. No credentials, fragments, whitespace, control characters, backslashes or protocol-relative URLs. | -Ports are sorted by `id` on normalisation, so two identical panels always -produce identical bytes. +Ports are sorted by `id` on normalization. Endpoint metadata does not specify +method, authentication or argument serialization. Absolute HTTPS endpoints may +be cross-origin; consumers must allowlist destinations before execution. ## 4. Confirmation @@ -105,14 +107,17 @@ wrong value. | --- | --- | --- | | `type` | all | `string`, `number`, `integer`, `boolean`, `array`, `object`, `null`. | | `description` | all | ≤ 500 characters. | -| `enum` | all | Non-empty array of scalars, ≤ 64 entries. | +| `enum` | scalar types | Non-empty unique scalars matching the declared type, ≤ 64 entries. Numbers must be finite. | | `items` | `array` | One nested schema. | | `properties` | `object` | Map of name → schema. | -| `required` | `object` | Names must exist in `properties`. | +| `required` | `object` | Names must exist in `properties` and match `^[A-Za-z_][A-Za-z0-9_]*$`. | | `additionalProperties` | `object` | Boolean. Default `false`: unknown keys are rejected. | | `writeOnly` | `string` | Boolean. Value is redacted from traces. | -Nesting is limited to four levels. +Nesting is limited to four levels. `__proto__`, `constructor` and `prototype` +property names are rejected. Object schemas explicitly normalize to +`additionalProperties: false` when omitted. Null is not a substitute for an +omitted properties map or required list. `writeOnly` exists because a schema is the only thing that knows which argument is a secret. It is restricted to strings so redaction cannot be ambiguous. @@ -139,8 +144,16 @@ is a secret. It is restricted to strings so redaction cannot be ambiguous. - `digest` is `sha256` over the canonical JSON of the document without `digest`. - `outcome.status` is one of `succeeded`, `failed`, `unknown`, `refused`. -A trace is not a signature and not a receipt. It proves the record was not -edited after the fact. It does not prove who produced it. +A trace is not a signature or a receipt. Its digest checks consistency, provided +the digest is retained and trusted. Anyone can edit the record and recompute the +hash. Verification does not prove identity, execution or approval. + +`buildTrace` normalizes the panel and resolves the port from it. `at` must be a +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. +Write-only array items are redacted too; unmarked secrets are not detected. ## 7. Canonical JSON @@ -150,7 +163,7 @@ Digests require byte-identical serialisation: - arrays keep their order; - no insignificant whitespace; - `null`, `true`, `false`, finite numbers and strings only; -- no sparse arrays, no cycles, no class instances; +- no sparse arrays, no cycles, no class instances, symbols or accessors; - limits: depth 32, 10,000 nodes, 262,144 characters per string, 65,536 bytes of output. @@ -170,6 +183,12 @@ Digests require byte-identical serialisation: | `hardwire_params_invalid` | Arguments do not match the schema. | | `hardwire_trace_invalid` | Bad outcome status or timestamp. | | `hardwire_origin_private` | Private host without explicit permission. | -| `hardwire_infer_failed` | Page could not be fetched or produced nothing. | - -Codes are stable. Callers may branch on them. +| `hardwire_infer_failed` | Page could not be fetched, exceeded limits or produced nothing. | +| `hardwire_origin_mismatch` | Discovered panel declares a different origin (probe finding). | +| `hardwire_corpus_invalid` | Invalid corpus entry or threshold. | +| `hardwire_score_invalid` | Invalid score threshold. | +| `hardwire_probe_invalid` | Invalid timeout. | + +Callers may branch on these codes. The low-level canonical serializer can also +throw TypeError or RangeError for non-JSON data and resource limits. CLI usage +and I/O errors exit 2; validation errors exit 1. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..8b9e700 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,33 @@ +# Examples + +| 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` | +| `demo/` | A local HTTP origin that publishes a panel and serves forms. | `npm run demo`, then `probe` and `infer` | +| `corpus/` | Three synthetic HTML fixtures paired with hand-written panels. | `corpus` | +| `walkthrough.mjs` | An offline tour of validation, compilation, traces and inference. | `npm run walkthrough` | + +## The demo origin + +`examples/demo/server.mjs` is the quickest way to see the whole loop without a +network dependency: + +```sh +npm run demo +# in another terminal +node src/cli.mjs probe http://127.0.0.1:8787 --allow-private --fail-under 30 +node src/cli.mjs infer http://127.0.0.1:8787 --allow-private --draft > /tmp/draft.json +node src/cli.mjs audit /tmp/draft.json +node src/cli.mjs compile /tmp/draft.json +``` + +The probe finds the published panel. Inference ignores it and reads the same +page's forms, which is the point: the two paths are independent. The endpoint +answers a fixed JSON body; nothing is written or stored. + +## The corpus + +Each case is a directory with `page.html` and `expected.json`. These fixtures +are synthetic, not a real-site benchmark. `node src/cli.mjs corpus examples/corpus` +intentionally exits 1 on the known failures. See `docs/ROADMAP.md` for capture +provenance, license and measurement requirements before adding real pages. diff --git a/examples/demo/page.html b/examples/demo/page.html new file mode 100644 index 0000000..3a0dd12 --- /dev/null +++ b/examples/demo/page.html @@ -0,0 +1,32 @@ + + + + + Hardwire demo shop + + +

Hardwire demo shop

+

Search returns a demo response. Sign-in and subscription are schema examples only; + all POST requests are refused. Do not enter real credentials.

+

+ This page exists so infer has markup to read. The panel at + /.well-known/hardwire.json is what probe checks. +

+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ + diff --git a/examples/demo/panel.json b/examples/demo/panel.json new file mode 100644 index 0000000..86bc210 --- /dev/null +++ b/examples/demo/panel.json @@ -0,0 +1,53 @@ +{ + "panel": "hardwire.panel/1", + "origin": "http://127.0.0.1:8787", + "title": "Hardwire demo shop", + "revision": "demo/1", + "auth": { + "loginPort": "sign-in", + "note": "Schema only: this demo refuses sign-in and all writes. Do not enter credentials." + }, + "ports": [ + { + "id": "search", + "kind": "read", + "summary": "Search the demo catalogue.", + "parameters": { + "description": "Arguments for a catalogue search.", + "properties": { + "q": { "type": "string", "description": "Search terms." } + }, + "required": ["q"] + }, + "endpoint": "/search" + }, + { + "id": "subscribe", + "kind": "write", + "summary": "Subscribe an email address to the demo newsletter.", + "confirmation": "user", + "parameters": { + "properties": { + "email": { "type": "string" } + }, + "required": ["email"] + }, + "endpoint": "/subscribe" + }, + { + "id": "sign-in", + "kind": "write", + "summary": "Exchange credentials for a demo session.", + "confirmation": "none", + "reversible": true, + "parameters": { + "properties": { + "email": { "type": "string" }, + "password": { "type": "string", "writeOnly": true } + }, + "required": ["email", "password"] + }, + "endpoint": "/api/session" + } + ] +} diff --git a/examples/demo/server.mjs b/examples/demo/server.mjs new file mode 100644 index 0000000..33d33bc --- /dev/null +++ b/examples/demo/server.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// A local origin that publishes a panel. Zero dependencies. +// +// node examples/demo/server.mjs [--port 8787] +// +// It serves a panel at the discovery path, a page with forms for inference, and +// a robots.txt that allows the probe. Endpoints answer GET with a fixed demo +// response; nothing is written, stored or executed. +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_PORT = 8787; +const HOST = "127.0.0.1"; + +const JSON_HEADERS = Object.freeze({ "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); +const HTML_HEADERS = Object.freeze({ "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); +const TEXT_HEADERS = Object.freeze({ "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); + +const ROBOTS = "User-agent: *\nAllow: /\n"; +const LLMS = `# Hardwire demo shop + +This is a local demonstration origin for Hardwire. It publishes a panel at +/.well-known/hardwire.json and a page with ordinary HTML forms. Nothing here +executes a write, stores credentials or charges anything. + +Run the probe and inference commands printed by examples/demo/server.mjs. +`; + +/** + * Create the demo HTTP server without starting it. Tests use port 0; the CLI + * uses 8787. The panel's origin is rewritten to the address the server is + * actually listening on, so the panel validates on any port. + */ +export async function createDemoServer({ + panelPath = join(HERE, "panel.json"), + pagePath = join(HERE, "page.html"), +} = {}) { + const [panel, page] = await Promise.all([ + readFile(panelPath, "utf8").then((text) => JSON.parse(text)), + readFile(pagePath, "utf8"), + ]); + + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://${HOST}`); + const send = (status, headers, body) => { + response.writeHead(status, headers); + response.end(request.method === "HEAD" ? undefined : body); + }; + + if (request.method !== "GET" && request.method !== "HEAD") { + send(405, { ...TEXT_HEADERS, allow: "GET, HEAD" }, "the demo does not execute writes\n"); + return; + } + + if (url.pathname === "/.well-known/hardwire.json") { + const address = server.address(); + const body = JSON.stringify({ ...panel, origin: `http://${HOST}:${address.port}` }, null, 2); + send(200, JSON_HEADERS, body); + return; + } + + if (url.pathname === "/") { + send(200, HTML_HEADERS, page); + return; + } + if (url.pathname === "/robots.txt") { + send(200, TEXT_HEADERS, ROBOTS); + return; + } + if (url.pathname === "/llms.txt") { + send(200, TEXT_HEADERS, LLMS); + return; + } + if (url.pathname === "/search") { + send(200, JSON_HEADERS, JSON.stringify({ demo: true, query: url.searchParams.get("q") ?? "" })); + return; + } + if (url.pathname === "/subscribe" || url.pathname === "/api/session") { + send(200, JSON_HEADERS, JSON.stringify({ + demo: true, + note: "This endpoint is a fixed response. Nothing was created or stored.", + })); + return; + } + + send(404, TEXT_HEADERS, "not found\n"); + }); + + return server; +} + +/** Start the demo on an ephemeral or explicit port and return its origin. */ +export async function startDemo({ port = DEFAULT_PORT, host = HOST } = {}) { + const server = await createDemoServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, resolve); + }); + const address = server.address(); + return { server, origin: `http://${host}:${address.port}` }; +} + +function parsePort(argv) { + const index = argv.indexOf("--port"); + const raw = index === -1 ? process.env.PORT ?? String(DEFAULT_PORT) : argv[index + 1]; + const port = Number(raw); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`--port must be an integer between 0 and 65535, got ${raw ?? "nothing"}`); + } + return port; +} + +async function main() { + const { server, origin } = await startDemo({ port: parsePort(process.argv.slice(2)) }); + process.stdout.write(`Hardwire demo serving ${origin}\n`); + process.stdout.write(` panel ${origin}/.well-known/hardwire.json\n`); + process.stdout.write("\nTry, in another terminal:\n"); + process.stdout.write(` node src/cli.mjs probe ${origin} --allow-private --fail-under 30\n`); + process.stdout.write(` node src/cli.mjs infer ${origin} --allow-private\n`); + process.stdout.write(` node src/cli.mjs infer ${origin} --allow-private --draft > /tmp/hardwire-draft.json\n`); + process.stdout.write(` node src/cli.mjs compile /tmp/hardwire-draft.json\n`); + process.stdout.write("\nCtrl-C to stop. The demo executes nothing and stores nothing.\n"); + + const stop = () => server.close(() => process.exit(0)); + process.on("SIGINT", stop); + process.on("SIGTERM", stop); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 2; + }); +} diff --git a/examples/walkthrough.mjs b/examples/walkthrough.mjs new file mode 100644 index 0000000..3f2a7af --- /dev/null +++ b/examples/walkthrough.mjs @@ -0,0 +1,33 @@ +// Offline tour of the public API. No network, credentials, model or server. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { + normalizePanel, panelDigest, compilePanel, findPort, validateParams, + buildTrace, verifyTrace, inferFromHtml, toPanelDraft, comparePanels, passes, +} from '../src/index.mjs'; + +const panel = normalizePanel(JSON.parse(await readFile(new URL('./example-panel.json', import.meta.url), 'utf8'))); +console.log(`1. Validated ${panel.ports.length} ports for ${panel.origin}`); +console.log(` ${panelDigest(panel)}`); + +const tools = compilePanel(panel); +const { port } = findPort(panel, 'search_catalogue'); +const params = validateParams(port, { query: 'lamp', limit: 5 }); +console.log(`2. Compiled ${tools.length} MCP tool descriptions; validated search arguments`); + +const trace = buildTrace({ panel, port: port.id, params, outcome: { status: 'unknown' } }); +assert.equal(verifyTrace(trace, { panel }), true); +assert.equal(verifyTrace({ ...trace, actor: 'changed' }, { panel }), false); +console.log('3. Created and verified a trace; changing its contents invalidated its digest'); + +const login = buildTrace({ panel, port: 'sign-in', params: { email: 'demo@example.com', password: 'fake-demo-password' } }); +assert.equal(login.params.password, '[redacted]'); +console.log('4. Confirmed a fake password is redacted before the trace is hashed'); + +const html = await readFile(new URL('./corpus/blog/page.html', import.meta.url), 'utf8'); +const expected = JSON.parse(await readFile(new URL('./corpus/blog/expected.json', import.meta.url), 'utf8')); +const draft = toPanelDraft(inferFromHtml(html, { origin: expected.origin })); +const score = comparePanels(expected, draft); +assert.equal(passes(score), true); +console.log(`5. Inferred ${draft.ports.length} candidate port(s) from the blog fixture: ${score.score}/100`); +console.log('\nWalkthrough passed. No operation was executed; traces describe caller assertions.'); diff --git a/package.json b/package.json index aabb420..f6e28bb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hardwire", - "version": "0.3.0", - "description": "Sites expose ports. Agents plug in. An open panel format for agent-operable software.", + "version": "0.4.0", + "description": "Typed action panels, static inference, MCP tool descriptions and verifiable trace digests. Experimental; no executor.", "type": "module", "private": true, "license": "UNLICENSED", @@ -33,15 +33,23 @@ "src", "examples", "docs", - "README.md" + "README.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "SECURITY.md" ], "scripts": { - "test": "node --test test/*.test.mjs", + "test": "npm run test:unit && npm run test:integration", "validate": "node src/cli.mjs validate examples/example-panel.json", "audit": "node src/cli.mjs audit examples/example-panel.json", "compile": "node src/cli.mjs compile examples/example-panel.json", "score": "node src/cli.mjs score --expected examples/corpus/shop/expected.json --infer examples/corpus/shop/page.html", - "corpus": "node src/cli.mjs corpus examples/corpus" + "corpus": "node src/cli.mjs corpus examples/corpus", + "demo": "node examples/demo/server.mjs", + "walkthrough": "node examples/walkthrough.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" }, "keywords": [ "agents", diff --git a/src/canonical.mjs b/src/canonical.mjs index 6c25bc9..623d770 100644 --- a/src/canonical.mjs +++ b/src/canonical.mjs @@ -44,6 +44,9 @@ export function canonicalJson(value, limits = {}) { seen.add(item); let out; + if (Object.getOwnPropertySymbols(item).length || Object.values(Object.getOwnPropertyDescriptors(item)).some((entry) => entry.get || entry.set)) { + throw new TypeError("document_object_invalid: symbols or accessors"); + } if (Array.isArray(item)) { if (Object.keys(item).length !== item.length) throw new TypeError("document_sparse_array"); @@ -61,11 +64,13 @@ export function canonicalJson(value, limits = {}) { } seen.delete(item); - if (out.length > maxOutputLength) throw new RangeError("document_too_large: output"); + if (encoder.encode(out).byteLength > maxOutputLength) throw new RangeError("document_too_large: output"); return out; } - return encode(value, 0); + const result = encode(value, 0); + if (encoder.encode(result).byteLength > maxOutputLength) throw new RangeError("document_too_large: output"); + return result; } export function canonicalBytes(value, limits) { diff --git a/src/cli.mjs b/src/cli.mjs index ad2f37c..64b5568 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -5,11 +5,13 @@ // hardwire compile [--json] // hardwire audit [--json] // hardwire probe [--json] [--allow-private] [--timeout ] +// [--fail-under ] // hardwire infer [--draft] [--json] [--allow-private] [--timeout ] // hardwire trace [--params ] [--status ] [--json] // hardwire score --expected (--actual | --infer ) // [--fail-under ] [--json] // hardwire corpus [--fail-under ] [--json] +// hardwire --help | --version // // Exit codes: 0 ok, 1 a measured failure (probe, score or corpus gate), 2 usage // or I/O. @@ -22,38 +24,63 @@ import { compilePanel } from "./mcp.mjs"; import { auditPanel, HardwireError, normalizePanel } from "./panel.mjs"; import { probeOrigin } from "./probe.mjs"; import { comparePanels, DEFAULT_THRESHOLD, passes } from "./score.mjs"; -import { buildTrace } from "./trace.mjs"; +import { buildTrace, verifyTrace } from "./trace.mjs"; const USAGE = `usage: hardwire validate [--json] hardwire compile [--json] hardwire audit [--json] hardwire probe [--json] [--allow-private] [--timeout ] - hardwire infer [--draft] [--json] [--allow-private] [--timeout ] - hardwire trace [--params ] [--status ] [--json] + [--fail-under ] + hardwire infer [--html ] [--draft] [--json] [--allow-private] [--timeout ] + hardwire trace [--params | --params-file ] [--status ] [--json] + hardwire verify [--panel ] [--json] hardwire score --expected (--actual | --infer ) [--fail-under ] [--json] - hardwire corpus [--fail-under ] [--json]`; + hardwire corpus [--fail-under ] [--json] + hardwire --help | --version + +The panel format and the reasoning behind it are in docs/SPEC.md. +Exit codes: 0 ok, 1 validation or measured failure, 2 usage or I/O.`; + +const PROBE_THRESHOLD = 40; +const VERSION_URL = new URL("../package.json", import.meta.url); const STATUSES = new Set(["succeeded", "failed", "unknown", "refused"]); -const VALUE_FLAGS = new Set(["--timeout", "--params", "--status", "--expected", "--actual", "--infer", "--fail-under"]); +const VALUE_FLAGS = new Set(["--timeout", "--params", "--params-file", "--panel", "--html", "--status", "--expected", "--actual", "--infer", "--fail-under"]); +const COMMAND_FLAGS = { + validate: [], compile: [], audit: [], + probe: ["--allow-private", "--timeout", "--fail-under"], + infer: ["--allow-private", "--timeout", "--draft", "--html"], + trace: ["--params", "--params-file", "--status"], + verify: ["--panel"], + score: ["--expected", "--actual", "--infer", "--fail-under"], + corpus: ["--fail-under"], +}; class UsageError extends Error {} function parse(argv) { const positional = []; - const flags = { json: false, allowPrivate: false, draft: false }; + const flags = { json: false, allowPrivate: false, draft: false, help: false, version: false }; + const supplied = new Set(); for (let index = 0; index < argv.length; index += 1) { const item = argv[index]; + if (item.startsWith("-")) { + if (supplied.has(item)) throw new UsageError(`duplicate flag ${item}`); + supplied.add(item); + } if (item === "--json") flags.json = true; else if (item === "--allow-private") flags.allowPrivate = true; else if (item === "--draft") flags.draft = true; + else if (item === "--help" || item === "-h") flags.help = true; + else if (item === "--version" || item === "-v") flags.version = true; else if (VALUE_FLAGS.has(item)) { const value = argv[++index]; - if (value === undefined) throw new UsageError(`${item} needs a value`); + if (value === undefined || value.startsWith("--")) throw new UsageError(`${item} needs a value`); if (item === "--timeout") { const ms = Number(value); - if (!Number.isFinite(ms) || ms <= 0) throw new UsageError("--timeout needs a positive number"); + if (!Number.isInteger(ms) || ms < 1 || ms > 120000) throw new UsageError("--timeout needs an integer from 1 to 120000"); flags.timeoutMs = ms; } else if (item === "--fail-under") { const threshold = Number(value); @@ -76,7 +103,7 @@ function parse(argv) { } else if (item.startsWith("--")) throw new UsageError(`unknown flag ${item}`); else positional.push(item); } - return { positional, flags }; + return { positional, flags, supplied }; } async function readText(path, label) { @@ -97,6 +124,14 @@ async function loadJson(path, label) { } } +async function packageVersion() { + try { + return JSON.parse(await readFile(VERSION_URL, "utf8")).version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} + async function score(flags, out) { const expected = await loadJson(flags.expected, "--expected"); if (!flags.actual && !flags.infer) throw new UsageError("score needs --actual or --infer"); @@ -191,10 +226,26 @@ async function corpus(flags, target, out) { } async function run(argv) { - const { positional, flags } = parse(argv); + const { positional, flags, supplied } = parse(argv); + const out = (text = "") => process.stdout.write(`${text}\n`); + if (flags.help) { + out(USAGE); + return 0; + } + if (flags.version) { + out(await packageVersion()); + return 0; + } const [command, target, extra] = positional; if (!command) throw new UsageError("missing command"); - const out = (text = "") => process.stdout.write(`${text}\n`); + if (!Object.hasOwn(COMMAND_FLAGS, command)) throw new UsageError(`unknown command ${command}`); + for (const flag of supplied) { + if (flag !== "--json" && !COMMAND_FLAGS[command].includes(flag)) throw new UsageError(`${flag} is not supported by ${command}`); + } + const arity = command === "score" ? 1 : command === "trace" ? 3 : 2; + if (positional.length !== arity) throw new UsageError(`${command} expects ${arity - 1} positional argument(s)`); + if (flags.params !== undefined && flags["params-file"] !== undefined) throw new UsageError("use --params or --params-file, not both"); + if (flags.html && (flags.allowPrivate || flags.timeoutMs)) throw new UsageError("--html is offline; omit network flags"); const network = { allowPrivate: flags.allowPrivate, ...(flags.timeoutMs ? { timeoutMs: flags.timeoutMs } : {}), @@ -208,11 +259,19 @@ async function run(argv) { } if (!target) throw new UsageError("missing target"); + if (command === "verify") { + const trace = await loadJson(target, "trace"); + const panel = flags.panel ? normalizePanel(await loadJson(flags.panel, "panel")) : undefined; + const valid = verifyTrace(trace, panel === undefined ? {} : { panel }); + out(flags.json ? JSON.stringify({ valid, panelChecked: panel !== undefined }, null, 2) : `${valid ? "valid" : "invalid"} trace digest${panel ? " and panel binding" : ""}; does not prove execution or approval`); + return valid ? 0 : 1; + } if (command === "probe") { const report = await probeOrigin(target, network); + const threshold = flags.failUnder ?? PROBE_THRESHOLD; if (flags.json) { - out(JSON.stringify(report, null, 2)); + out(JSON.stringify({ ...report, threshold }, null, 2)); } else { out(`${report.origin} score ${report.score}/100${report.cappedByRobots ? " (capped by robots.txt)" : ""}`); for (const probe of report.probes) { @@ -222,11 +281,13 @@ async function run(argv) { if (discovered?.valid) out(` panel: ${discovered.ports} ports, origin ${discovered.origin}`); out(`\n${report.note}`); } - return report.score >= 40 ? 0 : 1; + return report.score >= threshold ? 0 : 1; } if (command === "infer") { - const inference = await inferFromUrl(target, network); + const inference = flags.html + ? inferFromHtml(await readText(flags.html, "--html"), { origin: target }) + : await inferFromUrl(target, network); if (flags.draft) { out(JSON.stringify(normalizePanel(toPanelDraft(inference)), null, 2)); return 0; @@ -277,7 +338,7 @@ async function run(argv) { const trace = buildTrace({ panel, port, - params: flags.params ?? {}, + params: flags["params-file"] ? await loadJson(flags["params-file"], "--params-file") : flags.params === undefined ? {} : flags.params, outcome: { status: flags.status ?? "unknown" }, }); if (flags.json) out(JSON.stringify(trace, null, 2)); diff --git a/src/corpus.mjs b/src/corpus.mjs index 42d7d5c..4c2b12b 100644 --- a/src/corpus.mjs +++ b/src/corpus.mjs @@ -3,7 +3,7 @@ // One fixture tells you nothing. Gate 1 in docs/ROADMAP.md asks for an error // rate, which means many pages, each with the panel a human would have // published next to it, scored the same way every time. -import { HardwireError } from "./panel.mjs"; +import { HardwireError, normalizePanel } from "./panel.mjs"; import { inferFromHtml, toPanelDraft } from "./infer.mjs"; import { comparePanels, DEFAULT_THRESHOLD, passes } from "./score.mjs"; @@ -18,6 +18,7 @@ const round1 = (value) => Math.round(value * 10) / 10; * hide the other twenty. */ export function scoreCorpus(cases, { threshold = DEFAULT_THRESHOLD } = {}) { + if (!Number.isFinite(threshold) || threshold < 0 || threshold > 100) throw new HardwireError("hardwire_corpus_invalid", "threshold", "expected 0–100"); if (!Array.isArray(cases) || cases.length === 0) { throw new HardwireError("hardwire_corpus_invalid", "cases", "expected a non-empty array"); } @@ -39,13 +40,13 @@ export function scoreCorpus(cases, { threshold = DEFAULT_THRESHOLD } = {}) { throw new HardwireError("hardwire_corpus_invalid", `cases.${name}.expected`, "expected a panel document"); } - const expectedIds = Array.isArray(entry.expected.ports) - ? entry.expected.ports.map((port) => port.id).sort() - : []; + // Bad ground truth is misuse, never a zero-scoring capture. + const expected = normalizePanel(entry.expected); + const expectedIds = expected.ports.map((port) => port.id); let actual; try { - actual = toPanelDraft(inferFromHtml(entry.html, { origin: entry.expected.origin })); + actual = toPanelDraft(inferFromHtml(entry.html, { origin: expected.origin })); } catch (error) { if (!(error instanceof HardwireError)) throw error; return { @@ -61,6 +62,7 @@ export function scoreCorpus(cases, { threshold = DEFAULT_THRESHOLD } = {}) { missing: expectedIds, extra: [], extraPorts: [], + unsafeDifferences: [], inventedMutations: 0, inventedPayments: 0, inventedDestructive: 0, @@ -101,6 +103,7 @@ export function scoreCorpus(cases, { threshold = DEFAULT_THRESHOLD } = {}) { inventedMutations: results.reduce((total, result) => total + result.inventedMutations, 0), inventedPayments: results.reduce((total, result) => total + result.inventedPayments, 0), inventedDestructive: results.reduce((total, result) => total + result.inventedDestructive, 0), + unsafeDifferences: results.reduce((total, result) => total + result.unsafeDifferences.length, 0), readPorts: readSimilarities.length, readError, missing: results.reduce((total, result) => total + result.missing.length, 0), diff --git a/src/index.mjs b/src/index.mjs index fb8d016..6bf44ba 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -6,6 +6,7 @@ export { HardwireError, KINDS, normalizePanel, + normalizeOrigin, PANEL_FILENAME, PANEL_LIMITS, PANEL_VERSION, diff --git a/src/infer.mjs b/src/infer.mjs index b3a5824..928fd79 100644 --- a/src/infer.mjs +++ b/src/infer.mjs @@ -3,10 +3,10 @@ // This is the part that works without cooperation: a site that published // nothing still has forms and structured data, and those describe operations. // Inference is deliberately conservative. It may only propose `read` and -// `write`, never `payment` or `destructive`, so a bad inference cannot spend -// money or delete an account. Every candidate is labelled low confidence and +// `write`, never `payment` or `destructive`. These guesses do NOT establish the +// endpoint's actual effect; even GET may mutate state. Every candidate is labelled low confidence and // must be confirmed by the publisher before anyone treats it as truth. -import { HardwireError, PANEL_VERSION } from "./panel.mjs"; +import { HardwireError, normalizeOrigin, normalizePanel, PANEL_LIMITS, PANEL_VERSION } from "./panel.mjs"; import { assertPublicOrigin, fetchPage } from "./probe.mjs"; export const INFERENCE_VERSION = "hardwire.inference/1"; @@ -25,7 +25,7 @@ const FIELD_TYPES = Object.freeze({ const IGNORED_INPUTS = new Set(["submit", "button", "reset", "image", "file", "hidden"]); function attributes(source) { - const out = {}; + const out = Object.create(null); for (const match of source.matchAll(ATTRIBUTE)) { const name = match[1].toLowerCase(); if (!(name in out)) out[name] = match[2] ?? match[3] ?? match[4] ?? ""; @@ -38,7 +38,7 @@ function slug(value, fallback) { .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") - .slice(0, 40); + .slice(0, 40).replace(/-+$/, ""); if (!clean) return fallback; return /^[a-z]/.test(clean) ? clean : `port-${clean}`; } @@ -55,6 +55,7 @@ function lastSegment(action) { function fieldSchema(tag, attrs) { if (tag === "select" || tag === "textarea") return { type: "string" }; const type = (attrs.type ?? "text").toLowerCase(); + if (type === "password") return { type: "string", writeOnly: true }; return { type: FIELD_TYPES[type] ?? "string" }; } @@ -71,7 +72,7 @@ function collectFields(body) { // Bracket and dashed names (`user[name]`, `search-term`) are valid HTML and // common. They are rejected here because accepting arbitrary keys invites // prototype pollution, so the omission is reported instead of hidden. - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || ["__proto__", "constructor", "prototype"].includes(name)) { unsupported.push(name); continue; } @@ -123,9 +124,11 @@ function structuredActions(html) { ? raw.find((entry) => typeof entry === "string" && entry.endsWith("Action")) : raw; if (typeof type !== "string") return; + // BuyAction, DeleteAction, etc. must never be recast as a read. + if (!["SearchAction", "https://schema.org/SearchAction", "http://schema.org/SearchAction"].includes(type)) return; const target = actionTarget(node); - if (!target) return; - found.push({ type, target, templated: target.includes("{") }); + if (typeof target !== "string" || !target) return; + found.push({ type: "SearchAction", target, templated: target.includes("{") }); }); } return found; @@ -137,25 +140,38 @@ function structuredActions(html) { */ export function inferFromHtml(html, { origin }) { if (typeof html !== "string") throw new HardwireError("hardwire_infer_failed", "html", "expected a string"); - if (!origin) throw new HardwireError("hardwire_origin_invalid", "origin", "required"); - const source = html.slice(0, MAX_HTML); + origin = normalizeOrigin(origin); + if (Buffer.byteLength(html, "utf8") > MAX_HTML) throw new HardwireError("hardwire_infer_failed", "html", "capture exceeds 512 KiB"); + const source = html.replace(//g, ""); + const formsSource = source.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ""); + const endpointFor = (action) => { + if (!action || /[\u0000-\u0020\u007f\\#{}]/.test(action) || action.startsWith("//")) return undefined; + try { + const url = new URL(action, `${origin}/`); + if (url.origin !== origin || url.username || url.password) return undefined; + return `${url.pathname}${url.search}`; + } catch { return undefined; } + }; const candidates = []; const used = new Map(); const taken = new Set(); const unsupportedNames = new Set(); const unique = (base) => { - const count = (used.get(base) ?? 0) + 1; + let count = (used.get(base) ?? 0) + 1; + let id = count === 1 ? base : `${base}-${count}`; + while (taken.has(id)) id = `${base}-${++count}`; used.set(base, count); - const id = count === 1 ? base : `${base}-${count}`; taken.add(id); return id; }; - for (const match of source.matchAll(/]*)>([\s\S]*?)<\/form>/gi)) { + for (const match of formsSource.matchAll(/]*)>([\s\S]*?)<\/form>/gi)) { + if (candidates.length >= PANEL_LIMITS.maxPorts) throw new HardwireError("hardwire_infer_failed", "html", "too many candidates"); const attrs = attributes(match[1]); const method = (attrs.method ?? "get").toLowerCase(); const action = attrs.action ?? ""; + const endpoint = endpointFor(action); const label = attrs["aria-label"] || attrs.name || attrs.id || lastSegment(action) || "form"; const idBase = attrs.id || attrs.name || lastSegment(action) || "form"; const id = unique(slug(idBase, "submit")); @@ -167,19 +183,21 @@ export function inferFromHtml(html, { origin }) { id, kind, summary: `Submit the ${label} form.`, - confidence: action ? "low" : "very-low", + confidence: endpoint ? "low" : "very-low", evidence: [ `form method=${method}${action ? ` action=${action}` : ""}`, `fields=${Object.keys(fields.properties).length}`, ...(unsupported.length ? [`unsupported=${unsupported.join(",")}`] : []), + ...(action && !endpoint ? ["endpoint omitted: unsafe, cross-origin or unsupported URL"] : []), ], ...(unsupported.length ? { unsupported } : {}), parameters: fields, - ...(action ? { endpoint: action } : {}), + ...(endpoint ? { endpoint } : {}), }); } for (const action of structuredActions(source)) { + if (candidates.length >= PANEL_LIMITS.maxPorts) throw new HardwireError("hardwire_infer_failed", "html", "too many candidates"); const base = slug(action.type.replace(/Action$/, ""), "search"); // A form is stronger evidence than structured data; do not shadow it. if (taken.has(base)) continue; @@ -200,7 +218,7 @@ export function inferFromHtml(html, { origin }) { required: ["query"], }, // A URL template is not callable as written, so it is not offered as one. - ...(!action.templated ? { endpoint: action.target } : {}), + ...(!action.templated && endpointFor(action.target) ? { endpoint: endpointFor(action.target) } : {}), }); } @@ -211,6 +229,8 @@ export function inferFromHtml(html, { origin }) { notes: [ "Candidates are low-confidence guesses from markup, not a published contract.", "Inference never proposes payment or destructive operations.", + "Effect labels are guesses, not safety guarantees; a GET form can still mutate data.", + "This static parser does not execute JavaScript or reproduce browser form submission. Hidden fields, CSRF tokens, radio groups and custom widgets require review.", "Every candidate must be confirmed by the publisher before it is trusted.", ...(unsupportedNames.size ? [`${unsupportedNames.size} field name(s) could not be represented: ${[...unsupportedNames].sort().join(", ")}`] @@ -226,7 +246,7 @@ export async function inferFromUrl(origin, options = {}) { ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), }); - if (page.status !== 200) { + 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}`); } return inferFromHtml(page.body, { origin: base }); @@ -253,11 +273,11 @@ export function toPanelDraft(inference) { throw new HardwireError("hardwire_infer_failed", "candidates", "nothing to draft; no forms or structured actions found"); } - return { + return normalizePanel({ panel: PANEL_VERSION, origin: inference.origin, title: "Draft inferred from a live page. Review every port before use.", revision: "inferred/1", ports, - }; + }); } diff --git a/src/mcp.mjs b/src/mcp.mjs index 8740d79..a30ab92 100644 --- a/src/mcp.mjs +++ b/src/mcp.mjs @@ -14,11 +14,10 @@ export function toolName(id) { } function describe(port, origin) { - const risk = port.kind === "read" - ? "read-only" - : port.confirmation === "none" - ? `${port.kind}, no confirmation` - : `${port.kind}, requires ${port.confirmation} confirmation`; + const effect = port.kind === "read" ? "read-only" : port.kind; + const risk = port.confirmation === "none" + ? `${effect}, no confirmation` + : `${effect}, requires ${port.confirmation} confirmation`; return `${port.summary} (${origin}; ${risk})`; } @@ -40,7 +39,9 @@ export function compilePanel(input) { inputSchema: port.parameters, annotations: { readOnlyHint: port.kind === "read", - destructiveHint: port.kind === "destructive", + // A write may replace data and a payment may debit a balance. Neither + // is known to be additive, so retain MCP's conservative default. + destructiveHint: port.kind !== "read", idempotentHint: port.kind === "read", }, }; @@ -57,6 +58,7 @@ export function toolsListResponse(input) { * throwing so a server can answer "no such tool" without echoing the panel. */ export function findPort(panel, name) { + panel = normalizePanel(panel); const port = panel.ports.find((candidate) => toolName(candidate.id) === name); if (!port) return undefined; return { tool: compilePanel(panel).find((entry) => entry.name === name), port }; diff --git a/src/panel.mjs b/src/panel.mjs index 33f7f81..9293984 100644 --- a/src/panel.mjs +++ b/src/panel.mjs @@ -21,14 +21,25 @@ export const KINDS = Object.freeze(["read", "write", "destructive", "payment"]); export const CONFIRMATIONS = Object.freeze(["none", "user", "owner"]); // A panel may never lower the confirmation floor implied by a port's effect. -// `payment` and `destructive` are absolute: an agent cannot spend or delete -// without a human. `write` defaults to user confirmation and may drop to none +// `payment` and `destructive` declarations cannot drop below owner. A consumer +// must actually enforce approval. `write` defaults to user and may drop to none // only when the publisher asserts the port is reversible, which is itself a -// claim the caller is choosing to trust. `read` never needs confirmation. +// claim the caller is choosing to trust. `read` defaults to no confirmation. const ABSOLUTE_FLOOR = Object.freeze({ destructive: "owner", payment: "owner" }); const DEFAULT_CONFIRMATION = Object.freeze({ read: "none", write: "user" }); const SCHEMA_TYPES = new Set(["string", "number", "integer", "boolean", "array", "object", "null"]); +// Unknown keywords are rejected rather than ignored: silently dropping a +// constraint is how an agent sends a value the publisher thought it forbade. +const SCHEMA_KEYWORDS = new Set([ + "type", "description", "enum", "items", "properties", "required", "additionalProperties", "writeOnly", +]); +const KEYWORD_TYPE = Object.freeze({ + items: "array", + properties: "object", + required: "object", + additionalProperties: "object", +}); const LIMITS = Object.freeze({ maxPorts: 256, maxIdLength: 64, @@ -38,6 +49,13 @@ const LIMITS = Object.freeze({ maxRequired: 64, }); const ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; +const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +function checkKeys(value, allowed, path, code) { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw new HardwireError(code, `${path}.${key}`, "unsupported field"); + } +} export class HardwireError extends Error { constructor(code, path = "", detail = "") { @@ -71,11 +89,21 @@ function requireString(value, path, { max = Infinity, pattern } = {}) { function normalizeSchema(schema, path, depth = 0) { if (!isPlainObject(schema)) throw new HardwireError("hardwire_schema_invalid", path, "expected an object"); if (depth > LIMITS.maxSchemaDepth) throw new HardwireError("hardwire_schema_invalid", path, "too deep"); + for (const key of Object.keys(schema)) { + if (!SCHEMA_KEYWORDS.has(key)) { + throw new HardwireError("hardwire_schema_invalid", `${path}.${key}`, "unsupported keyword"); + } + } if (schema.type === undefined) throw new HardwireError("hardwire_schema_invalid", path, "missing type"); if (!SCHEMA_TYPES.has(schema.type)) { throw new HardwireError("hardwire_schema_invalid", `${path}.type`, `unsupported ${String(schema.type)}`); } + for (const [key, type] of Object.entries(KEYWORD_TYPE)) { + if (schema[key] !== undefined && schema.type !== type) { + throw new HardwireError("hardwire_schema_invalid", `${path}.${key}`, `only valid on ${type} schemas`); + } + } const out = { type: schema.type }; if (schema.description !== undefined) { @@ -93,6 +121,15 @@ function normalizeSchema(schema, path, depth = 0) { if (value !== null && !["string", "number", "boolean"].includes(typeof value)) { throw new HardwireError("hardwire_schema_invalid", `${path}.enum`, "enum values must be scalars"); } + const matches = schema.type === "null" ? value === null + : schema.type === "integer" ? Number.isInteger(value) + : typeof value === schema.type && value !== null; + if (!matches || (typeof value === "number" && !Number.isFinite(value))) { + throw new HardwireError("hardwire_schema_invalid", `${path}.enum`, "value does not match type"); + } + } + if (new Set(schema.enum).size !== schema.enum.length) { + throw new HardwireError("hardwire_schema_invalid", `${path}.enum`, "duplicate values"); } out.enum = [...schema.enum]; } @@ -103,24 +140,28 @@ function normalizeSchema(schema, path, depth = 0) { // A write-only value is accepted but never recorded in a trace. This is the // one place a panel is allowed to say "this argument is a secret". - if (schema.writeOnly === true) { + if (schema.writeOnly !== undefined) { + if (typeof schema.writeOnly !== "boolean") { + throw new HardwireError("hardwire_schema_invalid", `${path}.writeOnly`, "expected a boolean"); + } if (schema.type !== "string") { throw new HardwireError("hardwire_schema_invalid", `${path}.writeOnly`, "only strings can be write-only"); } - out.writeOnly = true; + if (schema.writeOnly) out.writeOnly = true; } if (schema.type === "object") { - const properties = schema.properties ?? {}; + const properties = schema.properties === undefined ? {} : schema.properties; if (!isPlainObject(properties)) { throw new HardwireError("hardwire_schema_invalid", `${path}.properties`, "expected an object"); } out.properties = {}; for (const key of Object.keys(properties).sort()) { + if (RESERVED_KEYS.has(key)) throw new HardwireError("hardwire_schema_invalid", `${path}.properties`, "reserved property name"); out.properties[key] = normalizeSchema(properties[key], `${path}.properties.${key}`, depth + 1); } - const required = schema.required ?? []; + const required = schema.required === undefined ? [] : schema.required; if (!Array.isArray(required)) { throw new HardwireError("hardwire_schema_invalid", `${path}.required`, "expected an array"); } @@ -136,6 +177,7 @@ function normalizeSchema(schema, path, depth = 0) { out.required = [...new Set(required)].sort(); // Unknown keys are rejected by default. An open object must say so. + out.additionalProperties = false; if (schema.additionalProperties !== undefined) { if (typeof schema.additionalProperties !== "boolean") { throw new HardwireError("hardwire_schema_invalid", `${path}.additionalProperties`, "expected a boolean"); @@ -154,6 +196,7 @@ const floorFor = (kind, reversible) => function normalizePort(port, index) { const path = `ports[${index}]`; if (!isPlainObject(port)) throw new HardwireError("hardwire_port_invalid", path, "expected an object"); + checkKeys(port, ["id", "kind", "summary", "confirmation", "reversible", "parameters", "preconditions", "expects", "endpoint"], path, "hardwire_port_invalid"); const id = requireString(port.id, `${path}.id`, { max: LIMITS.maxIdLength, pattern: ID_PATTERN }); if (!KINDS.includes(port.kind)) { @@ -161,8 +204,11 @@ function normalizePort(port, index) { } const summary = requireString(port.summary, `${path}.summary`, { max: LIMITS.maxSummaryLength }); + if (port.reversible !== undefined && typeof port.reversible !== "boolean") { + throw new HardwireError("hardwire_port_invalid", `${path}.reversible`, "expected a boolean"); + } const reversible = port.reversible === true; - const declared = port.confirmation ?? ABSOLUTE_FLOOR[port.kind] ?? DEFAULT_CONFIRMATION[port.kind]; + const declared = port.confirmation === undefined ? ABSOLUTE_FLOOR[port.kind] ?? DEFAULT_CONFIRMATION[port.kind] : port.confirmation; if (!CONFIRMATIONS.includes(declared)) { throw new HardwireError("hardwire_port_invalid", `${path}.confirmation`, `expected one of ${CONFIRMATIONS.join(", ")}`); } @@ -175,12 +221,19 @@ function normalizePort(port, index) { ); } - const parameters = normalizeSchema( - { type: "object", properties: port.parameters?.properties ?? {}, required: port.parameters?.required ?? [] }, - `${path}.parameters`, - ); + const rawParameters = port.parameters === undefined ? {} : port.parameters; + if (!isPlainObject(rawParameters)) { + throw new HardwireError("hardwire_schema_invalid", `${path}.parameters`, "expected an object"); + } + if (rawParameters.type !== undefined && rawParameters.type !== "object") { + throw new HardwireError("hardwire_schema_invalid", `${path}.parameters.type`, "port parameters must be an object schema"); + } + // Pass every supported keyword through, rather than rebuilding a narrower + // object: dropping `description` or `additionalProperties` here would change + // what the panel promises without saying so. + const parameters = normalizeSchema({ ...rawParameters, type: "object" }, `${path}.parameters`); - const preconditions = port.preconditions ?? []; + const preconditions = port.preconditions === undefined ? [] : port.preconditions; if (!Array.isArray(preconditions)) { throw new HardwireError("hardwire_port_invalid", `${path}.preconditions`, "expected an array"); } @@ -200,35 +253,71 @@ function normalizePort(port, index) { if (port.expects !== undefined) out.expects = normalizeSchema(port.expects, `${path}.expects`); if (port.endpoint !== undefined) { - const endpoint = requireString(port.endpoint, `${path}.endpoint`, { max: 2048 }); - if (!/^https:\/\/|^\//.test(endpoint)) { - throw new HardwireError("hardwire_port_invalid", `${path}.endpoint`, "must be https or site-relative"); - } - out.endpoint = endpoint; + out.endpoint = normalizeEndpoint(port.endpoint, `${path}.endpoint`); } return out; } -/** Validate and canonicalise a panel. Throws HardwireError with a stable code. */ -export function normalizePanel(input) { - if (!isPlainObject(input)) throw new HardwireError("hardwire_panel_invalid", "", "expected an object"); - if (input.panel !== PANEL_VERSION) { - throw new HardwireError("hardwire_version_unsupported", "panel", `expected ${PANEL_VERSION}`); +/** + * An endpoint is either a single-slash site-relative path or an absolute https + * URL. `//host` is a protocol-relative URL, not a path, and backslashes are + * normalised as slashes by browsers, so both are rejected rather than left for + * a caller to misinterpret. + */ +function normalizeEndpoint(value, path) { + const endpoint = requireString(value, path, { max: 2048 }); + if (/[\u0000-\u0020\u007f\\]/.test(endpoint)) { + throw new HardwireError("hardwire_port_invalid", path, "spaces, control characters and backslashes are not allowed"); + } + if (endpoint.startsWith("//")) { + throw new HardwireError("hardwire_port_invalid", path, "protocol-relative URLs are not allowed"); + } + if (endpoint.includes("#")) throw new HardwireError("hardwire_port_invalid", path, "fragments are not sent to a server"); + if (endpoint.startsWith("/")) return endpoint; + if (!endpoint.startsWith("https://")) throw new HardwireError("hardwire_port_invalid", path, "absolute endpoints must start with https://"); + let parsed; + try { + parsed = new URL(endpoint); + } catch { + throw new HardwireError("hardwire_port_invalid", path, "must be https or a site-relative path"); } + if (parsed.protocol !== "https:" || parsed.username || parsed.password) { + throw new HardwireError("hardwire_port_invalid", path, "must be https or a site-relative path"); + } + return endpoint; +} - const origin = requireString(input.origin, "origin", { max: 2048 }); +/** Bare HTTPS origin, with HTTP allowed only for local development. */ +export function normalizeOrigin(origin) { + if (typeof origin !== "string" || origin.length > 2048 || !/^https?:\/\//.test(origin) || /[\u0000-\u0020\u007f\\?#]/.test(origin)) { + throw new HardwireError("hardwire_origin_invalid", "origin", "expected a bare http(s) origin"); + } let parsed; try { parsed = new URL(origin); } catch { throw new HardwireError("hardwire_origin_invalid", "origin", "not an absolute URL"); } - if (parsed.protocol !== "https:" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") { + if (parsed.username || parsed.password) { + throw new HardwireError("hardwire_origin_invalid", "origin", "credentials are not allowed"); + } + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname))) { throw new HardwireError("hardwire_origin_invalid", "origin", "https is required outside local development"); } if (parsed.pathname !== "/" || parsed.search || parsed.hash) { throw new HardwireError("hardwire_origin_invalid", "origin", "must be a bare origin"); } + return parsed.origin; +} + +/** Validate and canonicalise a panel. Throws HardwireError with a stable code. */ +export function normalizePanel(input) { + if (!isPlainObject(input)) throw new HardwireError("hardwire_panel_invalid", "", "expected an object"); + if (input.panel !== PANEL_VERSION) { + throw new HardwireError("hardwire_version_unsupported", "panel", `expected ${PANEL_VERSION}`); + } + checkKeys(input, ["panel", "origin", "ports", "title", "revision", "auth"], "panel", "hardwire_panel_invalid"); + const origin = normalizeOrigin(input.origin); const ports = input.ports; if (!Array.isArray(ports) || ports.length === 0) { @@ -248,11 +337,12 @@ export function normalizePanel(input) { } } - const panel = { panel: PANEL_VERSION, origin: parsed.origin, ports: normalized }; + const panel = { panel: PANEL_VERSION, origin, ports: normalized }; if (input.title !== undefined) panel.title = requireString(input.title, "title", { max: 120 }); if (input.revision !== undefined) panel.revision = requireString(input.revision, "revision", { max: 128 }); if (input.auth !== undefined) { if (!isPlainObject(input.auth)) throw new HardwireError("hardwire_schema_invalid", "auth", "expected an object"); + checkKeys(input.auth, ["loginPort", "note"], "auth", "hardwire_schema_invalid"); panel.auth = {}; if (input.auth.loginPort !== undefined) { panel.auth.loginPort = requireString(input.auth.loginPort, "auth.loginPort", { @@ -273,7 +363,7 @@ export function normalizePanel(input) { /** Content address of a normalised panel, used to bind a trace to one revision. */ export function panelDigest(panel) { - return `sha256:${createHash("sha256").update(canonicalJson(panel), "utf8").digest("hex")}`; + return `sha256:${createHash("sha256").update(canonicalJson(normalizePanel(panel)), "utf8").digest("hex")}`; } function checkValue(schema, value, path, depth) { @@ -314,6 +404,7 @@ function checkValue(schema, value, path, depth) { } const out = {}; for (const key of Object.keys(value).sort()) { + if (RESERVED_KEYS.has(key)) throw new HardwireError("hardwire_params_invalid", `${path}.${key}`, "reserved property name"); if (!Object.hasOwn(schema.properties, key)) { if (schema.additionalProperties === true) { out[key] = value[key]; @@ -339,11 +430,18 @@ export function validateParams(port, params) { throw new HardwireError("hardwire_panel_invalid", "port", "a normalised port is required"); } if (!isPlainObject(params)) throw new HardwireError("hardwire_params_invalid", "", "expected an object"); - return checkValue(port.parameters, params, "", 0); + let copy; + try { + copy = JSON.parse(canonicalJson(params)); + } catch { + throw new HardwireError("hardwire_params_invalid", "", "arguments must be bounded, plain JSON data"); + } + return checkValue(port.parameters, copy, "", 0); } /** What an agent may do unattended, derived rather than asserted. */ export function auditPanel(panel) { + panel = normalizePanel(panel); const mutating = panel.ports.filter((port) => port.kind !== "read"); return { origin: panel.origin, diff --git a/src/probe.mjs b/src/probe.mjs index cb31de7..d983ece 100644 --- a/src/probe.mjs +++ b/src/probe.mjs @@ -4,7 +4,8 @@ // project's own proposal. That is why each signal carries a status and the // result is described as a diagnostic rather than a grade. The probe never // throws on network failure: a blocked or missing endpoint is data. -import { DISCOVERY_PATH, HardwireError, normalizePanel } from "./panel.mjs"; +import { isIP } from "node:net"; +import { DISCOVERY_PATH, HardwireError, normalizePanel, normalizeOrigin } from "./panel.mjs"; const DEFAULT_TIMEOUT_MS = 6000; const MAX_BODY_BYTES = 512 * 1024; @@ -31,30 +32,28 @@ export const SIGNALS = Object.freeze([ ]); function isPrivateHostname(hostname) { - const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + const host = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); if (host === "localhost" || host.endsWith(".localhost")) return true; if (host.endsWith(".local") || host.endsWith(".internal")) return true; - if (host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:")) return true; + if (isIP(host) === 6) { + // URL has already canonicalised IPv4-mapped IPv6 to hexadecimal groups. + const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(host); + if (mapped) { + const high = parseInt(mapped[1], 16), low = parseInt(mapped[2], 16); + return isPrivateHostname(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`); + } + return host === "::" || host === "::1" || /^(fc|fd|fe[89ab]|ff)/.test(host); + } const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); if (!v4) return false; const [a, b] = [Number(v4[1]), Number(v4[2])]; return a === 0 || a === 10 || a === 127 || + a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31); } export function assertPublicOrigin(origin, { allowPrivate = false } = {}) { - let url; - try { - url = new URL(origin); - } catch { - throw new HardwireError("hardwire_origin_invalid", "origin", "not an absolute URL"); - } - if (url.protocol !== "https:" && url.protocol !== "http:") { - throw new HardwireError("hardwire_origin_invalid", "origin", "http or https only"); - } - if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") { - throw new HardwireError("hardwire_origin_invalid", "origin", "https is required outside local development"); - } + const url = new URL(normalizeOrigin(origin)); if (!allowPrivate && isPrivateHostname(url.hostname)) { throw new HardwireError("hardwire_origin_private", "origin", "refusing to probe a private host without --allow-private"); } @@ -67,21 +66,23 @@ async function readBounded(response) { const chunks = []; let total = 0; try { - while (total < MAX_BODY_BYTES) { + while (true) { const { done, value } = await reader.read(); if (done) break; - chunks.push(value); total += value.byteLength; + if (total > MAX_BODY_BYTES) throw new RangeError("body_too_large"); + chunks.push(value); } } finally { await reader.cancel().catch(() => {}); } - return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).subarray(0, MAX_BODY_BYTES).toString("utf8"); + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8"); } /** One bounded GET. Redirects are not followed so a probe cannot be walked elsewhere. */ export async function fetchPage(url, { fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { if (typeof fetchImpl !== "function") throw new HardwireError("hardwire_panel_invalid", "fetch", "no fetch available"); + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120000) throw new HardwireError("hardwire_probe_invalid", "timeoutMs", "expected an integer from 1 to 120000"); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { @@ -104,7 +105,7 @@ export async function fetchPage(url, { fetchImpl = globalThis.fetch, timeoutMs = redirect: response.headers.get("location") ?? null, }; } catch (error) { - return { status: 0, contentType: "", body: "", error: error.name === "AbortError" ? "timeout" : "network" }; + return { status: 0, contentType: "", body: "", error: error.message === "body_too_large" ? "body_too_large" : controller.signal.aborted ? "timeout" : "network" }; } finally { clearTimeout(timer); } @@ -114,12 +115,18 @@ function robotsBlocksAllAgents(body) { const lines = body.split(/\r?\n/).map((line) => line.replace(/#.*$/, "").trim()); let inAll = false; let disallowsRoot = false; + let seenRule = false; for (const line of lines) { const [rawKey, ...rest] = line.split(":"); const key = rawKey?.trim().toLowerCase(); const value = rest.join(":").trim(); - if (key === "user-agent") inAll = value === "*"; - else if (inAll && key === "disallow" && (value === "/" || value === "/*")) disallowsRoot = true; + if (key === "user-agent") { + inAll = (seenRule ? false : inAll) || value === "*"; + seenRule = false; + } else if (key === "allow" || key === "disallow") { + seenRule = true; + if (inAll && key === "disallow" && (value === "/" || value === "/*")) disallowsRoot = true; + } } return disallowsRoot; } @@ -130,7 +137,7 @@ function htmlSignals(body) { return { declaresMcpLink: links.some((tag) => /rel=["']?alternate/.test(tag) && /mcp/.test(tag)), declaresWebMcpMeta: /]*name=["']?webmcp/.test(lower), - referencesModelContext: /navigator\.modelcontext|providecontext\s*\(/.test(lower), + referencesModelContext: /(?:navigator|document)\.modelcontext|providecontext\s*\(/.test(lower), }; } @@ -181,7 +188,15 @@ export async function probeOrigin(origin, options = {}) { const probes = SIGNALS.map((signal) => { const response = byPath.get(signal.path); - const ok = response.status === 200 && !response.error; + let ok = response.status === 200 && !response.error && response.body.trim().length > 0; + if (signal.id === "llms") ok = ok && /text\/plain/i.test(response.contentType); + if (ok && signal.id !== "llms") { + // Avoid scoring a successful SPA fallback or a binary response as an API. + try { + const value = JSON.parse(response.body); + ok = /json/i.test(response.contentType) && value !== null && typeof value === "object" && !Array.isArray(value); + } catch { ok = false; } + } return { id: signal.id, path: signal.path, diff --git a/src/score.mjs b/src/score.mjs index 31d2b4e..591edc9 100644 --- a/src/score.mjs +++ b/src/score.mjs @@ -6,7 +6,7 @@ // explainable rather than clever, so two people scoring the same pair of panels // get the same result. import { canonicalJson } from "./canonical.mjs"; -import { normalizePanel } from "./panel.mjs"; +import { CONFIRMATIONS, HardwireError, normalizePanel } from "./panel.mjs"; // Fields compared per port. `summary` is excluded on purpose: two people will // never word it identically, and a wording difference is not an inference bug. @@ -17,17 +17,13 @@ const DEFAULT_THRESHOLD = 80; // Descriptions are prose, like `summary`. Two reviewers will phrase them // differently, and a wording difference is not a structural inference error. -function structural(value) { - if (Array.isArray(value)) return value.map(structural); - if (value && typeof value === "object") { - const out = {}; - for (const key of Object.keys(value).sort()) { - if (key === "description") continue; - out[key] = structural(value[key]); - } - return out; - } - return value; +function structural(schema) { + if (!schema) return schema; + const { description: _description, ...out } = schema; + // Strip only schema annotations, never a property named "description". + if (schema.properties) out.properties = Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, structural(value)])); + if (schema.items) out.items = structural(schema.items); + return out; } /** @@ -75,6 +71,15 @@ export function comparePanels(expectedInput, actualInput) { const penalty = extra.length * EXTRA_PORT_PENALTY; const extraPorts = extra.map((id) => ({ id, kind: actualById.get(id).kind })); const invented = (kind) => extraPorts.filter((port) => port.kind === kind).length; + const unsafeDifferences = expected.ports.flatMap((wanted) => { + const found = actualById.get(wanted.id); + if (!found) return []; + const reasons = []; + if (wanted.kind !== found.kind) reasons.push("kind"); + if (CONFIRMATIONS.indexOf(found.confirmation) < CONFIRMATIONS.indexOf(wanted.confirmation)) reasons.push("confirmation"); + if (!wanted.reversible && found.reversible) reasons.push("reversible"); + return reasons.length ? [{ id: wanted.id, reasons }] : []; + }); return { score: Math.max(0, Math.min(100, base - penalty)), @@ -87,6 +92,7 @@ export function comparePanels(expectedInput, actualInput) { missing, extra, extraPorts, + unsafeDifferences, // An invented mutating port is worse than an invented read. inventedMutations: extraPorts.filter((port) => port.kind !== "read").length, inventedPayments: invented("payment"), @@ -97,7 +103,8 @@ export function comparePanels(expectedInput, actualInput) { /** True when a score passes the adoption threshold. */ export function passes(result, threshold = DEFAULT_THRESHOLD) { - return result.score >= threshold && result.inventedMutations === 0; + if (!Number.isFinite(threshold) || threshold < 0 || threshold > 100) throw new HardwireError("hardwire_score_invalid", "threshold", "expected 0–100"); + return !result.error && result.originMatch === true && result.score >= threshold && result.inventedMutations === 0 && result.unsafeDifferences?.length === 0; } export { DEFAULT_THRESHOLD }; diff --git a/src/trace.mjs b/src/trace.mjs index 6e774c6..4d82706 100644 --- a/src/trace.mjs +++ b/src/trace.mjs @@ -6,11 +6,14 @@ import { createHash } from "node:crypto"; import { canonicalJson } from "./canonical.mjs"; -import { HardwireError, panelDigest, validateParams } from "./panel.mjs"; +import { HardwireError, 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 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); /** * Replace write-only values before they enter a trace. The schema is the only @@ -19,6 +22,7 @@ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; */ export function redactParams(schema, value) { if (value === null || value === undefined) return value; + if (schema.writeOnly) return "[redacted]"; if (schema.type === "object" && typeof value === "object" && !Array.isArray(value)) { const out = {}; for (const [key, entry] of Object.entries(value)) { @@ -51,26 +55,43 @@ export function buildTrace({ at = new Date().toISOString(), actor = "agent", }) { - if (!panel || typeof panel.origin !== "string" || !Array.isArray(panel.ports)) { - throw new HardwireError("hardwire_panel_invalid", "panel", "a normalised panel is required"); + panel = normalizePanel(panel); + const id = typeof port === "string" ? port : port?.id; + if (typeof id !== "string" || id.length === 0) { + throw new HardwireError("hardwire_panel_invalid", "port", "a port id or normalised port is required"); } - if (!port || typeof port.id !== "string") { - throw new HardwireError("hardwire_panel_invalid", "port", "a normalised port is required"); + // The panel is the authority. A caller cannot attach a read port's id to a + // payment's schema, or vice versa, and have the trace record the lie. + const bound = panel.ports.find((candidate) => candidate.id === id); + if (!bound) { + throw new HardwireError("hardwire_panel_invalid", "port", `${id} is not in the panel`); } - if (!OUTCOMES.has(outcome.status)) { + 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)) { throw new HardwireError("hardwire_trace_invalid", "outcome.status", `expected one of ${[...OUTCOMES].join(", ")}`); } - if (!TIMESTAMP.test(at)) throw new HardwireError("hardwire_trace_invalid", "at", "expected ISO-8601 UTC"); + if (!validTime(at)) throw new HardwireError("hardwire_trace_invalid", "at", "expected a real ISO-8601 UTC timestamp"); + if (typeof actor !== "string" || !actor.length || actor.length > 128) { + throw new HardwireError("hardwire_trace_invalid", "actor", "expected 1–128 characters"); + } + if (outcome.code !== undefined && (typeof outcome.code !== "string" || !outcome.code.length || outcome.code.length > 128)) { + throw new HardwireError("hardwire_trace_invalid", "outcome.code", "expected 1–128 characters"); + } + if (outcome.resultDigest !== undefined && !DIGEST.test(outcome.resultDigest)) { + throw new HardwireError("hardwire_trace_invalid", "outcome.resultDigest", "expected sha256 and 64 lowercase hex digits"); + } const document = { trace: TRACE_VERSION, panel: panel.origin, panelDigest: panelDigest(panel), ...(panel.revision ? { revision: panel.revision } : {}), - port: { id: port.id, kind: port.kind, confirmation: port.confirmation }, + port: { id: bound.id, kind: bound.kind, confirmation: bound.confirmation }, actor: String(actor), at, - params: redactParams(port.parameters, validateParams(port, params)), + params: redactParams(bound.parameters, validateParams(bound, params)), outcome: { status: outcome.status, ...(outcome.code ? { code: String(outcome.code) } : {}), @@ -91,11 +112,20 @@ export function traceDigest(document) { * True only when the digest still matches the document. Recomputation catches * truncation and edits; it does not catch forgery, which needs an issuer. */ -export function verifyTrace(trace) { +export function verifyTrace(trace, { panel } = {}) { if (!trace || typeof trace !== "object") return false; if (trace.trace !== TRACE_VERSION) return false; - if (typeof trace.digest !== "string") 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 { + 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; + } return traceDigest(trace) === trace.digest; } catch { return false; diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 8622bb5..d085d68 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -72,3 +72,50 @@ test("misuse exits 2, distinct from a measured failure", async () => { assert.equal((await hardwire(["corpus", "examples/does-not-exist"])).code, 2); assert.equal((await hardwire(["nonsense", "examples/example-panel.json"])).code, 2); }); + +test("--help exits 0 and points at the spec", async () => { + const { code, stdout } = await hardwire(["--help"]); + + assert.equal(code, 0); + assert.match(stdout, /hardwire corpus/); + assert.match(stdout, /docs\/SPEC\.md/); +}); + +test("--version prints the package version", async () => { + const { code, stdout } = await hardwire(["--version"]); + const pkg = JSON.parse(await readFile(join(ROOT, "package.json"), "utf8")); + + assert.equal(code, 0); + assert.equal(stdout.trim(), pkg.version); +}); + +test('flags and extra arguments cannot silently change user intent', async () => { + for (const args of [ + ['validate', 'examples/example-panel.json', 'extra'], + ['validate', 'examples/example-panel.json', '--draft'], + ['trace', 'examples/example-panel.json', 'search-catalogue', '--params', '{}', '--params-file', 'x'], + ['infer', 'https://example.com', '--html', '--draft'], + ['probe', 'https://example.com', '--timeout', '2147483648'], + ['validate', 'examples/example-panel.json', '--json', '--json'], + ]) assert.equal((await hardwire(args)).code, 2, args.join(' ')); +}); + +test('offline inference and trace verification work through the documented CLI', async () => { + const inferred = await hardwire(['infer', 'https://blog.example.com', '--html', 'examples/corpus/blog/page.html', '--draft']); + assert.equal(inferred.code, 0); + assert.equal(JSON.parse(inferred.stdout).panel, 'hardwire.panel/1'); + const dir = await mkdtemp(join(tmpdir(), 'hardwire-verify-')); + try { + const paramsFile = join(dir, 'params.json'); + await writeFile(paramsFile, '{"query":"lamp"}'); + const trace = await hardwire(['trace', 'examples/example-panel.json', 'search-catalogue', '--params-file', paramsFile, '--json']); + assert.equal(trace.code, 0); + const traceFile = join(dir, 'trace.json'); + await writeFile(traceFile, trace.stdout); + const verified = await hardwire(['verify', traceFile, '--panel', 'examples/example-panel.json', '--json']); + assert.equal(verified.code, 0); + assert.deepEqual(JSON.parse(verified.stdout), { valid: true, panelChecked: true }); + await writeFile(traceFile, trace.stdout.replace('lamp', 'changed')); + assert.equal((await hardwire(['verify', traceFile])).code, 1); + } finally { await rm(dir, { recursive: true, force: true }); } +}); diff --git a/test/foundation.test.mjs b/test/foundation.test.mjs new file mode 100644 index 0000000..f6328f8 --- /dev/null +++ b/test/foundation.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { normalizePanel, validateParams, panelDigest } from '../src/panel.mjs'; +import { canonicalJson } from '../src/canonical.mjs'; +import { buildTrace, verifyTrace } from '../src/trace.mjs'; +import { compilePanel, findPort } from '../src/mcp.mjs'; +import { inferFromHtml, toPanelDraft } from '../src/infer.mjs'; +import { assertPublicOrigin, fetchPage } from '../src/probe.mjs'; +import { comparePanels, passes } from '../src/score.mjs'; +import { scoreCorpus } from '../src/corpus.mjs'; + +const raw = (port = {}) => ({ panel: 'hardwire.panel/1', origin: 'https://example.com', ports: [{ id: 'search', kind: 'read', summary: 'Search.', ...port }] }); +const infer = (html) => inferFromHtml(html, { origin: 'https://example.com' }); + +test('malformed origins and endpoints cannot be silently reinterpreted', () => { + for (const origin of ['ftp://localhost', 'file://localhost', 'https:example.com', 'https://user:secret@example.com', 'https://example.com/path', 'https://example.com?', 'https://example.com#', 'https://example.com\\']) { + assert.throws(() => normalizePanel({ ...raw(), origin }), { code: 'hardwire_origin_invalid' }); + assert.throws(() => assertPublicOrigin(origin), { code: 'hardwire_origin_invalid' }); + } + for (const endpoint of ['//evil.com/x', '/\\evil.com/x', 'https:evil.com', '/x#secret']) { + assert.throws(() => normalizePanel(raw({ endpoint })), { code: 'hardwire_port_invalid' }); + } +}); + +test('unknown contract fields and non-boolean policy values fail closed', () => { + assert.throws(() => normalizePanel({ ...raw(), autorun: true }), { code: 'hardwire_panel_invalid' }); + assert.throws(() => normalizePanel(raw({ confirmaton: 'owner' })), { code: 'hardwire_port_invalid' }); + assert.throws(() => normalizePanel(raw({ reversible: 'true' })), { code: 'hardwire_port_invalid' }); + assert.throws(() => normalizePanel(raw({ confirmation: null })), { code: 'hardwire_port_invalid' }); + assert.throws(() => normalizePanel({ ...raw(), auth: { password: 'secret' } }), { code: 'hardwire_schema_invalid' }); +}); + +test('enums must be satisfiable, finite, unique scalar values', () => { + for (const schema of [{ type: 'integer', enum: ['1'] }, { type: 'number', enum: [Infinity] }, { type: 'string', enum: ['a', 'a'] }, { type: 'object', enum: [null] }]) { + assert.throws(() => normalizePanel(raw({ parameters: { properties: { value: schema } } })), { code: 'hardwire_schema_invalid' }); + } +}); + +test('MCP schemas close nested objects exactly as the argument validator does', () => { + const panel = raw({ parameters: { properties: { settings: { type: 'object', properties: { color: { type: 'string' } } } } } }); + const [tool] = compilePanel(panel); + assert.equal(tool.inputSchema.additionalProperties, false); + assert.equal(tool.inputSchema.properties.settings.additionalProperties, false); + assert.throws(() => validateParams(normalizePanel(panel).ports[0], { settings: { extra: true } }), { code: 'hardwire_params_invalid' }); + assert.equal(findPort(panel, 'search').port.parameters.type, 'object'); + const guardedRead = compilePanel(raw({ confirmation: 'owner' }))[0]; + assert.match(guardedRead.description, /requires owner/); + assert.equal(compilePanel(raw({ kind: 'payment' }))[0].annotations.destructiveHint, true); +}); + +test('prototype-sensitive schema and argument keys are never copied into prototypes', () => { + const properties = JSON.parse('{"__proto__":{"type":"string"}}'); + assert.throws(() => normalizePanel(raw({ parameters: { properties } })), { code: 'hardwire_schema_invalid' }); + const port = normalizePanel(raw({ parameters: { additionalProperties: true } })).ports[0]; + assert.throws(() => validateParams(port, JSON.parse('{"__proto__":{"admin":true}}')), { code: 'hardwire_params_invalid' }); + assert.equal({}.admin, undefined); +}); + +test('open objects and untyped arrays still require bounded JSON arguments', () => { + const port = normalizePanel(raw({ parameters: { additionalProperties: true } })).ports[0]; + for (const extra of [undefined, Infinity, new Date(), [undefined], new Map()]) { + assert.throws(() => validateParams(port, { extra }), { code: 'hardwire_params_invalid' }); + } + const params = { extra: { colors: ['blue'] } }; + const copy = validateParams(port, params); + params.extra.colors.push('red'); + assert.deepEqual(copy, { extra: { colors: ['blue'] } }); +}); + +test('canonical limits count UTF-8 bytes and include scalar roots', () => { + assert.throws(() => canonicalJson('éé', { maxOutputLength: 5 }), /too_large/); + assert.throws(() => canonicalJson({ key: 'éé' }, { maxOutputLength: 12 }), /too_large/); + assert.throws(() => canonicalJson({ get secret() { throw new Error('getter ran'); } }), /accessors/); + assert.equal(canonicalJson({ b: 2, a: 1 }), '{"a":1,"b":2}'); +}); + +test('raw and normalized panels have the same digest', () => { + assert.equal(panelDigest(raw()), panelDigest(normalizePanel(raw()))); +}); + +test('traces redact write-only strings inside arrays and bind to reviewed panels', () => { + const panel = raw({ parameters: { properties: { secrets: { type: 'array', items: { type: 'string', writeOnly: true } } } } }); + const trace = buildTrace({ panel, port: 'search', params: { secrets: ['do-not-log'] } }); + assert.deepEqual(trace.params.secrets, ['[redacted]']); + assert.equal(JSON.stringify(trace).includes('do-not-log'), false); + assert.equal(verifyTrace(trace, { panel }), true); + assert.equal(verifyTrace(trace, { panel: { ...panel, revision: 'changed' } }), false); +}); + +test('trace creation rejects impossible dates and malformed outcome digests', () => { + for (const at of ['2026-02-30T10:00:00Z', '2026-99-99T99:99:99Z']) { + assert.throws(() => buildTrace({ panel: raw(), port: 'search', at }), { code: 'hardwire_trace_invalid' }); + } + assert.throws(() => buildTrace({ panel: raw(), port: 'search', outcome: null }), { code: 'hardwire_trace_invalid' }); + assert.throws(() => buildTrace({ panel: raw(), port: 'search', outcome: { status: 'succeeded', resultDigest: 'sha256:abc' } }), { code: 'hardwire_trace_invalid' }); +}); + +test('inferred passwords stay write-only through draft and trace', () => { + const panel = toPanelDraft(infer('
')); + const trace = buildTrace({ panel, port: 'login', params: { password: 'do-not-log' } }); + assert.equal(trace.params.password, '[redacted]'); +}); + +test('non-search JSON-LD actions never become read tools', () => { + for (const type of ['BuyAction', 'DeleteAction', 'PayAction', 'UpdateAction', 'UnknownAction']) { + assert.equal(infer(``).candidates.length, 0); + } +}); + +test('inference handles colliding IDs and ignores forms in comments and scripts', () => { + const report = infer('
'); + const panel = toPanelDraft(report); + assert.equal(new Set(panel.ports.map((port) => port.id)).size, 3); + assert.ok(!panel.ports.some((port) => port.id === 'fake')); +}); + +test('inference omits unsafe endpoints while keeping reviewable candidates', () => { + for (const action of ['javascript:alert(1)', '//evil.com/action', 'https://evil.com/action']) { + const panel = toPanelDraft(infer(`
`)); + assert.equal(panel.ports[0].endpoint, undefined); + assert.equal(panel.ports[0].confirmation, 'user'); + } +}); + +test('private host screening handles IPv6, mapped IPv4 and public prefix near-misses', () => { + for (const host of ['[::1]', '[::]', '[::ffff:127.0.0.1]', '[::ffff:192.168.1.1]', '[fd00::1]', '[fe90::1]', 'localhost.', '100.64.0.1']) { + assert.throws(() => assertPublicOrigin(`https://${host}`), { code: 'hardwire_origin_private' }); + } + for (const host of ['fc.example.com', 'fd.example.com', '10.example.com', '[2606:4700:4700::1111]']) { + assert.equal(assertPublicOrigin(`https://${host}`), `https://${host}`); + } +}); + +test('oversize responses are refused instead of parsed from a truncated prefix', async () => { + const page = await fetchPage('https://example.com', { fetchImpl: async () => new Response('x'.repeat(512 * 1024 + 1), { headers: { 'content-type': 'text/html' } }) }); + assert.equal(page.error, 'body_too_large'); + assert.equal(page.body, ''); +}); + +test('redirect and non-text response streams are cancelled without following them', async () => { + for (const [status, type] of [[302, 'text/html'], [200, 'application/octet-stream']]) { + let cancelled = false; + let calls = 0; + const result = await fetchPage('https://example.com', { fetchImpl: async (_url, options) => { + calls++; + assert.equal(options.redirect, 'manual'); + return new Response(new ReadableStream({ cancel() { cancelled = true; } }), { status, headers: { 'content-type': type, location: 'http://127.0.0.1' } }); + } }); + assert.equal(result.body, ''); + assert.equal(cancelled, true); + assert.equal(calls, 1); + } +}); + +test('mismatched origins and downgraded effects fail regardless of numeric score', () => { + const expected = raw({ kind: 'payment' }); + const downgraded = comparePanels(expected, raw()); + assert.equal(passes(downgraded, 0), false); + assert.equal(downgraded.unsafeDifferences.length, 1); + assert.equal(passes(comparePanels(raw(), { ...raw(), origin: 'https://other.com' }), 0), false); + assert.equal(passes(comparePanels(raw({ confirmation: 'owner' }), raw()), 0), false); +}); + +test('a parameter literally named description participates in scoring', () => { + const expected = raw({ parameters: { properties: { description: { type: 'string' } } } }); + const actual = raw({ parameters: { properties: { description: { type: 'boolean' } } } }); + assert.equal(comparePanels(expected, actual).ports[0].differences[0].field, 'parameters'); +}); + +test('invalid ground truth is rejected even when the captured page is empty', () => { + assert.throws(() => scoreCorpus([{ name: 'bad', expected: { ports: [] }, html: '' }]), { code: 'hardwire_version_unsupported' }); + assert.throws(() => scoreCorpus([{ name: 'bad', expected: raw(), html: '' }], { threshold: NaN }), { code: 'hardwire_corpus_invalid' }); + const report = scoreCorpus([{ name: 'empty', expected: raw(), html: '' }], { threshold: 0 }); + assert.equal(report.passed, 0); +}); + +test('malformed structured targets are ignored rather than crashing inference', () => { + assert.equal(infer('').candidates.length, 0); +}); + +test('SPA fallback HTML and binary bodies do not score as discovery documents', async () => { + const { probeOrigin } = await import('../src/probe.mjs'); + for (const type of ['text/html', 'application/octet-stream']) { + const report = await probeOrigin('https://example.com', { fetchImpl: async () => new Response('fallback', { headers: { 'content-type': type } }) }); + assert.equal(report.score, 0); + } +}); + +test('wildcard robots groups can share rules with another user agent', async () => { + const { probeOrigin } = await import('../src/probe.mjs'); + const report = await probeOrigin('https://example.com', { fetchImpl: async (url) => new Response(new URL(url).pathname === '/robots.txt' ? 'User-agent: *\nUser-agent: AnotherBot\nDisallow: /' : '', { headers: { 'content-type': 'text/plain' } }) }); + assert.equal(report.blockedByRobots, true); +}); diff --git a/test/integration/demo.test.mjs b/test/integration/demo.test.mjs new file mode 100644 index 0000000..11069f4 --- /dev/null +++ b/test/integration/demo.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { startDemo } from "../../examples/demo/server.mjs"; +import { inferFromUrl, toPanelDraft } from "../../src/infer.mjs"; +import { normalizePanel } from "../../src/panel.mjs"; +import { probeOrigin } from "../../src/probe.mjs"; + +test("the demo origin publishes a valid panel and inferable forms", async (t) => { + const { server, origin } = await startDemo({ port: 0 }); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const report = await probeOrigin(origin, { allowPrivate: true }); + const panel = report.probes.find((probe) => probe.id === "hardwire")?.panel; + + assert.equal(panel.valid, true); + assert.equal(panel.ports, 3); + assert.equal(panel.origin, origin); + assert.equal(report.score, 35, "panel (30) + llms.txt (5)"); + assert.deepEqual(report.signals, ["hardwire", "llms"]); + + const inference = await inferFromUrl(origin, { allowPrivate: true }); + assert.deepEqual(inference.candidates.map((candidate) => candidate.id), ["search", "subscribe", "sign-in"]); + assert.deepEqual(inference.candidates.map((candidate) => candidate.kind), ["read", "write", "write"]); + + const draft = normalizePanel(toPanelDraft(inference)); + assert.equal(draft.ports.length, 3); + assert.equal(draft.ports.find((port) => port.id === "sign-in").confirmation, "user"); +}); + +test("the demo refuses to pretend it executed a write", async (t) => { + const { server, origin } = await startDemo({ port: 0 }); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const response = await fetch(`${origin}/subscribe`, { method: "POST" }); + assert.equal(response.status, 405); + assert.match(await response.text(), /does not execute/); +}); + +test('the demo search returns a real response without an agent or UI', async (t) => { + const { server, origin } = await startDemo({ port: 0 }); + t.after(() => new Promise((resolve) => server.close(resolve))); + const response = await fetch(`${origin}/search?q=lamp`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { demo: true, query: 'lamp' }); +}); + +test('documented local probe and inference commands succeed against the demo', async (t) => { + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const { fileURLToPath } = await import('node:url'); + const exec = promisify(execFile); + const cli = fileURLToPath(new URL('../../src/cli.mjs', import.meta.url)); + const { server, origin } = await startDemo({ port: 0 }); + t.after(() => new Promise((resolve) => server.close(resolve))); + const probed = await exec(process.execPath, [cli, 'probe', origin, '--allow-private', '--fail-under', '30', '--json']); + assert.equal(JSON.parse(probed.stdout).score, 35); + const inferred = await exec(process.execPath, [cli, 'infer', origin, '--allow-private', '--draft']); + assert.equal(JSON.parse(inferred.stdout).ports.length, 3); +}); diff --git a/test/panel.test.mjs b/test/panel.test.mjs index 26cd51f..2383bf9 100644 --- a/test/panel.test.mjs +++ b/test/panel.test.mjs @@ -91,6 +91,81 @@ test("unsupported schema keywords fail loudly instead of being ignored", () => { () => normalizePanel(minimal([{ ...bare(), parameters: { properties: { q: { type: "geo" } } } }])), "hardwire_schema_invalid", ); + rejects( + () => normalizePanel(minimal([{ ...bare(), parameters: { properties: { q: { type: "string", pattern: "^a" } } } }])), + "hardwire_schema_invalid", + ); +}); + +test("schema keywords are restricted to the type they apply to", () => { + rejects( + () => normalizePanel(minimal([{ + ...bare(), + parameters: { properties: { q: { type: "string", items: { type: "string" } } } }, + }])), + "hardwire_schema_invalid", + ); + rejects( + () => normalizePanel(minimal([{ + ...bare(), + parameters: { properties: { q: { type: "string", required: ["x"] } } }, + }])), + "hardwire_schema_invalid", + ); +}); + +test("parameters must themselves be an object schema", () => { + rejects(() => normalizePanel(minimal([{ ...bare(), parameters: "query=lamp" }])), "hardwire_schema_invalid"); + rejects(() => normalizePanel(minimal([{ ...bare(), parameters: { type: "string" } }])), "hardwire_schema_invalid"); +}); + +test("parameter-root keywords survive normalisation", () => { + const panel = normalizePanel(minimal([{ + ...bare(), + parameters: { + description: "Search arguments.", + properties: { q: { type: "string" } }, + required: ["q"], + additionalProperties: true, + }, + }])); + const port = panel.ports[0]; + + assert.equal(port.parameters.description, "Search arguments."); + assert.equal(port.parameters.additionalProperties, true); + assert.deepEqual(validateParams(port, { q: "lamp", sort: "price" }), { q: "lamp", sort: "price" }); +}); + +test("endpoints cannot leave the origin through protocol-relative or backslash forms", () => { + for (const endpoint of [ + "//evil.example.com/orders", + "/\\evil.example.com/orders", + "http://example.com/orders", + "https://user:pass@example.com/orders", + "api/orders", + "/path with space", + ]) { + rejects( + () => normalizePanel(minimal([{ ...bare(), endpoint }])), + "hardwire_port_invalid", + ); + } + + assert.equal( + normalizePanel(minimal([{ ...bare(), endpoint: "/api/orders" }])).ports[0].endpoint, + "/api/orders", + ); + assert.equal( + normalizePanel(minimal([{ ...bare(), endpoint: "https://api.example.com/orders" }])).ports[0].endpoint, + "https://api.example.com/orders", + ); +}); + +test("an origin cannot smuggle credentials", () => { + rejects( + () => normalizePanel({ ...minimal([bare()]), origin: "https://user:pass@example.com" }), + "hardwire_origin_invalid", + ); }); test("write-only is restricted to strings so redaction is unambiguous", () => { diff --git a/test/trace.test.mjs b/test/trace.test.mjs index 28186ff..3da4a76 100644 --- a/test/trace.test.mjs +++ b/test/trace.test.mjs @@ -18,12 +18,12 @@ test("traces are deterministic, order-independent and tamper-evident", () => { const first = buildTrace({ panel, port, at: AT, params: { idempotencyKey: "k1", shippingAddressId: "a1" }, - outcome: { status: "succeeded", resultDigest: "sha256:abc" }, + outcome: { status: "succeeded", resultDigest: "sha256:" + "a".repeat(64) }, }); const second = buildTrace({ panel, port, at: AT, params: { shippingAddressId: "a1", idempotencyKey: "k1" }, - outcome: { status: "succeeded", resultDigest: "sha256:abc" }, + outcome: { status: "succeeded", resultDigest: "sha256:" + "a".repeat(64) }, }); assert.equal(first.digest, second.digest); @@ -58,6 +58,34 @@ test("a trace refuses an outcome it cannot name and params it cannot check", () assert.throws(() => buildTrace({ panel, port, at: "yesterday" }), (error) => error.code === "hardwire_trace_invalid"); }); +test("a trace refuses a port that is not in the panel", () => { + const panel = normalizePanel(example); + assert.throws( + () => buildTrace({ panel, port: "charge-everything", at: AT }), + (error) => error.code === "hardwire_panel_invalid", + ); +}); + +test("a trace uses the panel's port, not a caller-supplied impostor", () => { + const panel = normalizePanel(example); + const trace = buildTrace({ + panel, + port: { id: "place-order", kind: "read", confirmation: "none" }, + at: AT, + params: { idempotencyKey: "k1", shippingAddressId: "a1" }, + }); + + assert.equal(trace.port.kind, "payment"); + assert.equal(trace.port.confirmation, "owner"); + assert.equal(verifyTrace(trace), true); +}); + +test("a port id is enough to build a trace", () => { + const panel = normalizePanel(example); + const trace = buildTrace({ panel, port: "search-catalogue", at: AT, params: { query: "lamp" } }); + assert.equal(trace.port.id, "search-catalogue"); +}); + test("write-only parameters never reach the trace", () => { const panel = normalizePanel(example); const port = panel.ports.find((entry) => entry.id === "sign-in");