diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..50dcd34 --- /dev/null +++ b/CLA.md @@ -0,0 +1,82 @@ +# Contributor License Agreement (CLA) + +> **DRAFT — pending legal review.** This document is adapted from standard +> open-source CLA templates (notably the Apache Individual CLA) for Project +> Auxo, Inc. It has not yet been reviewed by counsel. Do not rely on it as +> final legal text until that review is complete. + +Thank you for contributing to the Capability Host Protocol (CHP). To keep CHP +open and re-usable for everyone — and to preserve the project's ability to +evolve its licensing in the future — we ask every contributor to agree to this +Contributor License Agreement ("Agreement") with **Project Auxo, Inc.** ("the +Company"). This Agreement protects you, the Company, and all users of CHP. + +You retain ownership of your contributions. This Agreement only grants the +Company the licenses described below. + +## 1. Definitions + +- **"You"** means the individual or legal entity agreeing to this Agreement. +- **"Contribution"** means any original work of authorship — including any + modifications or additions to existing work — that You intentionally submit + to the Company for inclusion in, or documentation of, any CHP project. +- **"Submit"** means any form of electronic, verbal, or written communication + sent to the Company or its projects (e.g., pull requests, issues, patches), + excluding anything conspicuously marked "Not a Contribution." + +## 2. Copyright license + +You grant the Company, and recipients of software distributed by the Company, a +perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare derivative works of, publicly display, +publicly perform, sublicense, and distribute Your Contributions and such +derivative works. + +**This includes the right for the Company to license and re-license Your +Contribution under any license terms, including both open-source licenses and +commercial/proprietary licenses (dual licensing).** This relicensing right is +what allows CHP to remain durable and to fund its own development. + +## 3. Patent license + +You grant the Company, and recipients of software distributed by the Company, a +perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated below) patent license to make, have made, use, offer to sell, +sell, import, and otherwise transfer Your Contribution, where such license +applies only to those patent claims licensable by You that are necessarily +infringed by Your Contribution alone or by combination of Your Contribution with +the project to which it was submitted. + +If any entity institutes patent litigation against You or any other entity +alleging that Your Contribution, or the project it was submitted to, constitutes +direct or contributory patent infringement, then any patent licenses granted to +that entity under this Agreement terminate as of the date such litigation is +filed. + +## 4. Your representations + +- You represent that You are legally entitled to grant the above licenses. +- If Your employer has rights to intellectual property You create, You represent + that You have received permission to make Contributions on behalf of that + employer, that Your employer has waived such rights, or that Your employer has + executed a separate Corporate CLA with the Company. +- You represent that each Contribution is Your original creation, or that You + have clearly identified any third-party material (and its license) within it. +- You are not expected to provide support for Your Contributions, and they are + provided "AS IS" without warranties of any kind. + +## 5. Corporate contributions + +If You are agreeing on behalf of a legal entity, "You" includes that entity and +all entities that control, are controlled by, or are under common control with +it. The signatory represents they are authorized to bind that entity. + +## How to sign + +Contributions are gated by an automated CLA check on pull requests. When you +open your first pull request, a bot will ask you to read this Agreement and +confirm your acceptance by commenting as instructed. Your GitHub identity and +the commit history serve as the signature record. + +Questions: open an issue or contact the maintainers. See +[`CONTRIBUTING.md`](CONTRIBUTING.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3cf9093..a15c166 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,21 @@ CHP is early. Contributions should keep the v0.1 surface small, explicit, and testable. +## Contributor License Agreement + +Before we can merge your contribution, you must sign the +[Contributor License Agreement](CLA.md). You keep ownership of your work; the CLA +lets Project Auxo, Inc. distribute and (re)license it so CHP stays durable. The +process is automated: open a pull request and a bot will prompt you to sign by +posting a one-line comment. You only sign once. + +## What's open vs. commercial + +This repository is the open core — Apache-2.0 (code) and CC BY 4.0 (spec/docs). +Commercial components (the hosted evidence service, registry network, compliance +products, and enterprise/regulated-system adapters) live in separate repositories +and are **not** accepted here. See [`GOVERNANCE.md`](GOVERNANCE.md). + ## Principles - Prefer local-first behavior over distributed assumptions. diff --git a/LICENSE b/LICENSE index 3211a3d..b7a6c91 100644 --- a/LICENSE +++ b/LICENSE @@ -168,7 +168,7 @@ END OF TERMS AND CONDITIONS - Copyright 2026 Auxo + Copyright 2026 Project Auxo, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/LICENSE-DOCS b/LICENSE-DOCS index ae1b5df..6e4dabc 100644 --- a/LICENSE-DOCS +++ b/LICENSE-DOCS @@ -20,4 +20,4 @@ measures that legally restrict others from doing anything the license permits. Full license text: https://creativecommons.org/licenses/by/4.0/legalcode -Copyright 2026 Auxo +Copyright 2026 Project Auxo, Inc. diff --git a/NOTICE b/NOTICE index 3800f2a..45ce465 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,5 @@ Capability Host Protocol (CHP) -Copyright 2026 Auxo +Copyright 2026 Project Auxo, Inc. This product includes software developed by the CHP project contributors. @@ -9,3 +9,10 @@ the Apache License, Version 2.0. See LICENSE for the full text. Specification, schemas, and documentation (spec/, docs/, schemas/) are licensed under the Creative Commons Attribution 4.0 International License. See LICENSE-DOCS for the full text. + +Implementing the specification is additionally covered by a royalty-free +patent grant. See PATENTS for the full text. + +"Capability Host Protocol", "CHP", "CHP-Certified", and the CHP logo are +trademarks of Project Auxo, Inc. Trademark use is governed by TRADEMARK.md +and is NOT granted by the code or documentation licenses above. diff --git a/PATENTS b/PATENTS new file mode 100644 index 0000000..84bc367 --- /dev/null +++ b/PATENTS @@ -0,0 +1,55 @@ +CHP Specification — Royalty-Free Patent Grant + + DRAFT — pending legal review. This grant is adapted from common royalty-free + standards patent policies (e.g., the Open Web Foundation / W3C style). It has + not yet been reviewed by counsel and is not final. + +Purpose + + The CHP specification, schemas, and documentation are licensed for copyright + purposes under CC BY 4.0 (see LICENSE-DOCS). Copyright permission alone does + not assure implementers that they may build conforming implementations free of + patent claims. This document adds that assurance. The intent is simple: anyone + may implement the CHP specification, for free, forever. + +Definitions + + "Specification" means the version(s) of the Capability Host Protocol + specification and schemas published in this repository under spec/ and + schemas/ to which this grant is attached. + + "Covered Claims" means those patent claims, owned or controlled by Project + Auxo, Inc., that are necessarily infringed by implementing the required + portions of the Specification, where "necessarily infringed" means there is no + commercially reasonable, non-infringing way to implement those required + portions. Covered Claims do NOT include claims that would be infringed only by: + (a) enabling technologies not required to implement the Specification; + (b) implementing optional portions; or + (c) implementation techniques, optimizations, or services not described as + required in the Specification (for example, a hosted evidence service, + anchoring/timestamping methods, or compliance products). + +Grant + + Project Auxo, Inc. grants to every implementer a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable (except as stated in + "Defensive Termination") license under the Covered Claims to make, have made, + use, sell, offer for sale, import, and distribute implementations of the + Specification. + +Defensive Termination + + The license granted above terminates automatically as to any person or entity + that initiates patent litigation (including a cross-claim or counterclaim) + alleging that the Specification, or any conforming implementation of it, + infringes a patent. + +Scope and reservation of rights + + This grant is limited to the Covered Claims and to the published version(s) of + the Specification. Future versions may be accompanied by their own grants. + Except for the license expressly granted here, Project Auxo, Inc. reserves all + patent rights — including all rights in inventions that are not required to + implement the Specification. Contributions to the Specification are governed by + the patent license in CLA.md, which is intended to be consistent with this + grant. diff --git a/README.md b/README.md index 9452150..d750756 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Capability Host Protocol -CHP is an open protocol for making agent, tool, and system execution visible, replayable, and ready for governance. +CHP is the open protocol for declaring, **governing**, and **proving** what agents, tools, and systems do — the single signed plane where a human approval, an agent's action, and a system call become the same governed, tamper-evident, replayable event. -The first launch goal is simple: +The hook is simple: -> See what your agents and tools actually did. +> See what your agents and tools actually did — and what governed it. -CHP is not another agent framework, tool protocol, or workflow engine. It is an execution evidence layer at the capability boundary. +CHP is not another agent framework, tool protocol, or workflow engine. It is the **governed evidence plane** at the capability boundary: what ran *and* what governed it (policy, risk tier, safety checks, human approval, autonomy budgets, denial) emit onto one signed, correlated record. Observability tools split execution across separate, optional, unsigned signals and carry no governance; CHP unifies both and proves them. ## What CHP Defines @@ -162,4 +162,12 @@ Guiding rule: ## License -MIT. See `LICENSE`. +CHP is dual-licensed by asset: + +- **Code** (`packages/`, `conformance/`, `examples/`, `scripts/`): Apache License 2.0 — see [`LICENSE`](LICENSE). +- **Specification, schemas & docs** (`spec/`, `schemas/`, `docs/`): Creative Commons Attribution 4.0 (CC BY 4.0) — see [`LICENSE-DOCS`](LICENSE-DOCS). Implementing the specification is additionally covered by a royalty-free patent grant — see [`PATENTS`](PATENTS). +- **Trademarks**: "CHP" and "CHP-Certified" — see [`TRADEMARK.md`](TRADEMARK.md). + +Contributions are accepted under the [Contributor License Agreement](CLA.md); see [`CONTRIBUTING.md`](CONTRIBUTING.md). + +Copyright © 2026 Project Auxo, Inc. See [`NOTICE`](NOTICE). diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 0000000..27507b5 --- /dev/null +++ b/TRADEMARK.md @@ -0,0 +1,61 @@ +# CHP Trademark & Conformance-Mark Policy + +> **DRAFT — pending legal review.** Adapted from common open-source trademark +> policies (e.g., the model used by CNCF/Linux Foundation projects) for Project +> Auxo, Inc. Not yet reviewed by counsel. + +The names **"Capability Host Protocol"** and **"CHP"**, the CHP logo, and +**"CHP-Certified"** (together, the "Marks") are trademarks of **Project Auxo, +Inc.** ("the Company"). The *code* and *specification* are openly licensed (see +[`NOTICE`](NOTICE)); the *Marks* are not — they are how users know something is +genuinely CHP and trustworthy. This policy explains how you may use them. + +The guiding principle: **you may use the Marks to refer to CHP truthfully; you +may not use them in a way that implies endorsement, certification, or origin +that isn't real.** + +## You may, without asking (nominative/fair use) + +- State that your product "works with CHP", "implements the Capability Host + Protocol", or "is built on CHP" — if true. +- Use "CHP" in prose, talks, blog posts, and documentation to refer to the + protocol. +- Use the word marks in the name of a community adapter or tool in a descriptive + way (e.g., "a CHP adapter for Acme") — provided it does not imply official + origin (see below). + +## You may not, without written permission + +- Use the Marks (or confusingly similar names/logos) as the name of your product, + company, or service, or in a way that suggests the Company produces or endorses + it. +- Use the Marks on merchandise, domains, or social accounts in a way likely to + cause confusion about origin. +- Modify the logo, or use it as your own product's icon. +- Claim or imply **certification or conformance** except as allowed below. + +## "CHP-Certified" and conformance claims + +"CHP-Certified" and "CHP-Conformant" are **certification claims** and are +governed: + +- You may state that an implementation **"passes the CHP v0.x conformance + suite"** if it genuinely passes the suite in [`conformance/`](conformance/) at + the stated version, and you can show the evidence on request. +- You may **not** use the "CHP-Certified" mark or logo until you are enrolled in + the certification program operated by the Company (forthcoming). Certification + ties the claim to a passing conformance run plus a security/quality review, so + that the mark means something to the people relying on it. + +## Adapters + +Naming and certification of adapters follow the tiered model in +[`docs/adapter-strategy.md`](docs/adapter-strategy.md). In short: descriptive use +in a community adapter's name is fine; presenting an adapter as *official* or +*certified* requires permission/enrollment. + +## Questions & permission requests + +Open an issue or contact the maintainers. We grant reasonable requests for +community, educational, and integration use. This policy may evolve; the spirit — +*truthful reference yes, implied endorsement no* — will not. diff --git a/conformance/FIXTURES.md b/conformance/FIXTURES.md new file mode 100644 index 0000000..8967f3e --- /dev/null +++ b/conformance/FIXTURES.md @@ -0,0 +1,62 @@ +# CHP Conformance Fixture Profile + +Status: normative for the `wire` conformance suite. A host-under-test that wants +to be validated by the black-box runner (`conformance/runner.py --url`) MUST +pre-register the capabilities below and configure the host as specified. The +runner exercises them and asserts each expected outcome, denial code, and event +sequence. Event orderings reference +[spec/chp-invocation-pipeline.md](../spec/chp-invocation-pipeline.md). + +This file is the language-agnostic contract: it replaces "read the Python +`build_passing_host()`" with a spec any implementation can register from. + +## Host configuration + +The host under test MUST be configured with: +- **Policy:** `max_risk_tier = "medium"` (so a `high`-risk capability is blocked). + No block-patterns or blocked-ids are required. +- **Safety evaluator:** a rule-based evaluator with one guardrail that blocks + `conformance.unsafe` — a guardrail whose `capability_id_pattern` matches + `conformance.unsafe` and lists it in `requires_human_for` (so it always blocks, + regardless of the computed risk score). +- **Auth:** an `X-CHP-Key` the runner is given via `--key` / `CHP_HOST_API_KEY`. + +## Capabilities + +All fixtures are version `1.0.0`, `sync` mode. "Events" lists the evidence event +types emitted for the exercised call, in order. + +| id | Config | Exercised with | Outcome | `denial.code` | Events (in order) | +|---|---|---|---|---|---| +| `conformance.echo` | none | `{"value": "..."}` | `success` | — | `execution_started`, `execution_completed` | +| `conformance.fail` | handler always raises | `{}` | `failure` | — | `execution_started`, `execution_failed` | +| `conformance.guarded` | host invariant `requires_value` (`required_payload_fields: ["value"]`, `failure_behavior: deny`) | `{}` (missing `value`) | `denied` | `invariant_failed` | `execution_denied` | +| `conformance.approval` | `autonomy.tier = "approval_required"` | `{}` | `denied` | `approval_required` | `approval_requested`, `execution_denied` | +| `conformance.budgeted` | `autonomy.action_limit = 1` | invoked **twice** on one correlation | 1st `success`, 2nd `denied` | (2nd) `budget_exceeded` | 1st: `execution_started`, `execution_completed`; 2nd: `budget_exceeded`, `execution_denied` | +| `conformance.risky` | `risk = "high"` | `{}` | `denied` | `policy_blocked` | `execution_denied` | +| `conformance.unsafe` | (blocked by the host guardrail above) | `{}` | `denied` | `safety_blocked` | `safety_assessment_started`, `safety_assessment_completed`, `safety_guardrail_triggered`, `safety_action_blocked`, `execution_denied` | + +### Behaviour notes + +- `conformance.echo` returns its input `value` in `InvocationResult.data` (e.g. + `{"echo": ""}`); the exact shape isn't asserted, only `outcome:success`. +- `conformance.fail`'s handler MUST raise so the host records `execution_failed` + (a runtime failure, not a denial). +- `conformance.guarded` is denied at the **invariant** gate (pipeline gate 6), + *before* safety — so it emits no safety events even on a safety-configured host. +- `conformance.risky` is denied at the **policy** gate (gate 5), before invariants, + autonomy, and safety — so it too emits no governance side-events, only + `execution_denied`. +- `conformance.unsafe` is the only fixture that exercises the safety pipeline; + its assessment pair is emitted because it passed gates 1–8 and reached gate 9. +- Numbers in emitted event payloads (e.g. a safety `score`) are **string-encoded** + per `chp-stable-v1` ([chp-v0.2.md](../spec/chp-v0.2.md) §2 rule 6) — the runner + does not assert their value, only the event types. + +## Running the check + +``` + +python conformance/runner.py --url http://localhost:PORT --key --suite wire +``` +A conforming host prints `[wire] 14/14`. diff --git a/conformance/README.md b/conformance/README.md index f0625d0..690cb67 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,37 +1,81 @@ -# CHP v0.1 Conformance +# CHP Conformance Kit -The conformance suite verifies the minimum behaviors of a CHP-compatible host: +How anyone — in any language — proves their Capability Host Protocol +implementation is conformant. There are two independent surfaces: -1. Capability declaration -2. Capability discovery -3. Invocation through an envelope-compatible boundary -4. Correlation propagation -5. Evidence emission on success -6. Evidence emission on failure -7. Evidence emission on denial -8. Replay by correlation ID -9. Optional representation of skipped execution, where the host supports disabled or skipped capabilities +1. **Wire conformance** — your *host* behaves correctly over the HTTP binding. +2. **Canonicalization + signing interop** — your *bytes* match the reference. -Run the passing reference host: +A reference second implementation (TypeScript: `packages/chp-sdk`, +`packages/chp-host`) passes both and is the worked example. + +## 1. Wire conformance (host under test) + +Register the fixture profile ([FIXTURES.md](FIXTURES.md)) and serve your host +over the [HTTP binding](../spec/chp-http-binding.md). Then run the reference +black-box runner against it: ```bash -python conformance/runner.py +python conformance/runner.py --url http://localhost:PORT --key --suite wire ``` -Run a deliberately broken host: +A conforming host prints **`[wire] 14/14`**. The 14 checks: capability +declaration + discovery, envelope invocation, correlation propagation, evidence +on success / failure / denial, replay by correlation, standard denial codes, the +four governance gates (approval-required, budget-exceeded, risk-tier, +safety-guardrail), and chain verification over `/verify`. + +The runner drives your host purely over HTTP through the reference client. What +it asserts (outcomes, reserved denial codes, event sequences, the 200-for-denied +rule) is specified in [FIXTURES.md](FIXTURES.md) + +[chp-invocation-pipeline.md](../spec/chp-invocation-pipeline.md) — so you +implement from the spec, not from reading a reference. + +*Worked example:* `node packages/chp-host-ts/dist/bin/serve.js --port 8899 --key k` +then `python conformance/runner.py --url http://localhost:8899 --key k --suite wire`. + +## 2. Canonicalization + signing interop (bytes under test) + +Your `chp-stable-v1` implementation must reproduce the published bytes exactly, +and a bundle you sign must verify under a *different* implementation. + +- **Canonicalization golden set:** for every case in + [`spec/test-vectors/canon/cases.json`](../spec/test-vectors/canon/cases.json), + `canon(input)` MUST equal `expected_canon` byte-for-byte (surrogate pairs, + control chars, unicode key-sort, no floats — see + [chp-v0.2.md §2](../spec/chp-v0.2.md)). +- **Verify reference bundles:** your verifier MUST accept the Python-signed + `signed-bundle.json` and the *governed* `governance-bundle.json`, and MUST + reject a tampered or relabelled bundle. +- **Cross-verify your signature:** a bundle *you* sign MUST verify under the + stdlib Node reference verifier and Python: ```bash -python conformance/runner.py --sample failing-no-evidence +node spec/test-vectors/verify.mjs your-signed-bundle.json # → VALID +python -c "import sys; sys.path.insert(0,'packages/python'); \ + from chp_core.signing import verify_bundle; import json; \ + print(verify_bundle(json.load(open('your-signed-bundle.json'))).valid)" # → True ``` -The runner currently ships with built-in sample hosts. External host adapters -should implement `discover()`, `invoke(...)` or async `ainvoke(...)`, and -`replay(correlation_id)`. +*Worked example:* the TS SDK reproduces the golden set byte-for-byte, produces a +**byte-identical** signature to Python for the same input, and a fresh TS-signed +governed bundle verifies VALID under both `verify.mjs` and Python. + +## Reference runner internals -The development host also exposes the conformance matrix as a CHP capability: +The runner ships built-in sample hosts for local development: ```bash -chp work conformance-matrix +python conformance/runner.py # the passing reference host +python conformance/runner.py --sample failing-no-evidence # a deliberately broken host +python conformance/runner.py --suite normative # spec MUSTs (in-process) ``` -This records matrix results as CHP evidence under the provided correlation ID. +Suites: `normative` (spec MUSTs, in-process) · `reference` (bundled capability +library) · `wire` (black-box HTTP, needs `--url`) · `all`. + +## Claiming conformance + +A host that prints `14/14` on suite `wire` **and** passes the §2 interop checks +is CHP-conformant at the tier it declares in `/host` (`assurance`: +`hash-chain` or `signed`). Record the runner output as your evidence. diff --git a/conformance/runner.py b/conformance/runner.py index 5318d5e..a567f66 100644 --- a/conformance/runner.py +++ b/conformance/runner.py @@ -5,6 +5,7 @@ import argparse import asyncio +import os import sys from dataclasses import dataclass from pathlib import Path @@ -57,7 +58,25 @@ async def invoke_host(host: Any, *args: Any, **kwargs: Any) -> Any: async def build_passing_host() -> LocalCapabilityHost: - host = LocalCapabilityHost("conformance-host", store=SQLiteEvidenceStore(":memory:")) + from chp_core.policy import PolicyConfig # noqa: PLC0415 + from chp_core.safety import RuleBasedSafetyEvaluator # noqa: PLC0415 + from chp_core.types import GuardrailDefinition # noqa: PLC0415 + + # A fully-governed fixture: cap the allowed risk tier at 'medium' (a 'high' + # capability is policy_blocked) and configure a safety guardrail that blocks + # conformance.unsafe (safety_blocked). Explicit config (not load_policy()) + # keeps the fixture deterministic. + evaluator = RuleBasedSafetyEvaluator(guardrails=[ + GuardrailDefinition( + id="conformance-guardrail", capability_id_pattern="conformance.unsafe", + max_risk_level="critical", requires_human_for=["conformance.unsafe"], + ), + ]) + host = LocalCapabilityHost( + "conformance-host", store=SQLiteEvidenceStore(":memory:"), + policy=PolicyConfig(max_risk_tier="medium"), + safety_evaluator=evaluator, + ) async def echo(_ctx, payload): return {"echo": payload.get("value")} @@ -97,6 +116,41 @@ async def fail(_ctx, _payload): ), echo, ) + # Governance fixtures (v0.2): an approval-gated and a budget-capped capability, + # so approval/budget conformance is verifiable — including black-box over HTTP. + from chp_core import AutonomyProfile # noqa: PLC0415 + + host.register( + CapabilityDescriptor( + id="conformance.approval", version="1.0.0", + description="Approval-gated (every invocation requires approval).", + autonomy=AutonomyProfile(tier="approval_required"), + ), + echo, + ) + host.register( + CapabilityDescriptor( + id="conformance.budgeted", version="1.0.0", + description="Budget-capped (action_limit=1 per correlation).", + autonomy=AutonomyProfile(action_limit=1), + ), + echo, + ) + host.register( + CapabilityDescriptor( + id="conformance.risky", version="1.0.0", + description="High-risk (exceeds the host's max_risk_tier).", + risk="high", + ), + echo, + ) + host.register( + CapabilityDescriptor( + id="conformance.unsafe", version="1.0.0", + description="Blocked by a safety guardrail.", + ), + echo, + ) return host @@ -1211,7 +1265,195 @@ async def echo(_ctx, payload): os.unlink(store_path) -CHECKS: list[tuple[str, Check]] = [ +async def check_signed_evidence_bundle(_host: Any) -> None: + """v0.2: a host can export a signed evidence bundle that verifies offline, + and any tampering is detected.""" + import tempfile, os + from chp_core import CapabilityDescriptor, LocalCapabilityHost, SQLiteEvidenceStore + from chp_core import signing + + if not signing.signing_available(): + return # signing is an optional tier; nothing to assert without the backend + + with tempfile.TemporaryDirectory() as d: + store = SQLiteEvidenceStore(os.path.join(d, "ev.sqlite")) + host = LocalCapabilityHost("conf-sign", store=store) + + async def echo(_ctx, payload): + return {"echo": payload.get("value")} + + host.register(CapabilityDescriptor(id="conf.sign.echo", version="1.0.0", description=""), echo) + await host.ainvoke("conf.sign.echo", {"value": "v"}, correlation={"correlation_id": "cs"}) + + key = signing.generate_keypair(os.path.join(d, "keys")) + events = store.export_correlation("cs") + bundle = signing.sign_bundle(signing.build_bundle("conf-sign", events, created_at="2026-01-01T00:00:00Z"), key) + store.close() + + assert signing.verify_bundle(bundle, expected_key_id=key.key_id).valid, "signed bundle must verify" + tampered = dict(bundle) + tampered["events"] = [dict(e) for e in bundle["events"]] + tampered["events"][0]["payload"] = {"value": "TAMPERED"} + assert not signing.verify_bundle(tampered).valid, "tampered bundle must fail verification" + + +async def check_strict_verify_rejects_unhashed(_host: Any) -> None: + """v0.2: strict verification fails on a legacy unhashed event; lenient tolerates it.""" + import tempfile, os + from chp_core import SQLiteEvidenceStore + + with tempfile.TemporaryDirectory() as d: + store = SQLiteEvidenceStore(os.path.join(d, "ev.sqlite")) + with store._lock: + store._conn.execute("INSERT INTO evidence_sequence DEFAULT VALUES") + store._conn.execute( + "INSERT INTO evidence_events (sequence,event_id,event_type,invocation_id," + "capability_id,host_id,correlation_id,timestamp,payload_json,event_json," + "content_hash,prev_hash) VALUES (1,'e','execution_started','i','c','h','cx','t','{}','{}',NULL,NULL)" + ) + store._conn.commit() + assert store.verify_chain("cx").valid, "lenient must tolerate legacy unhashed events" + assert not store.verify_chain("cx", strict=True).valid, "strict must reject unhashed events" + store.close() + + +async def check_retention_preserves_chain(_host: Any) -> None: + """v0.2: retention prunes whole old correlations without breaking a survivor's chain.""" + import tempfile, os + from chp_core import CapabilityDescriptor, LocalCapabilityHost, SQLiteEvidenceStore + from chp_core.compliance import SQLiteComplianceManager + from chp_core.types import RetentionPolicy + + with tempfile.TemporaryDirectory() as d: + store = SQLiteEvidenceStore(os.path.join(d, "ev.sqlite")) + host = LocalCapabilityHost("conf-ret", store=store) + + async def noop(_ctx, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="conf.ret.noop", version="1.0.0", description=""), noop) + for cid in ("old", "keep"): + await host.ainvoke("conf.ret.noop", {}, correlation={"correlation_id": cid}) + await host.ainvoke("conf.ret.noop", {}, correlation={"correlation_id": cid}) + with store._lock: + store._conn.execute("UPDATE evidence_events SET timestamp='2020-01-01T00:00:00Z' WHERE correlation_id='old'") + store._conn.commit() + + SQLiteComplianceManager(store).apply_retention([ + RetentionPolicy(policy_id="p", applies_to=["*"], retain_days=365) + ]) + assert store.count_by_correlation("old") == 0, "fully-old correlation must be pruned" + assert store.count_by_correlation("keep") == 4, "recent correlation must survive intact" + assert store.verify_chain("keep").valid, "survivor chain must still verify after prune" + store.close() + + +async def check_approval_required_governance(host: Any) -> None: + """v0.2 governance (chp-governance-v0.2.md §4.1): a capability whose autonomy + tier is 'approval_required' is denied with the reserved code + 'approval_required', emits 'approval_requested' BEFORE denying, and does NOT + start execution. The human-in-the-loop differentiator — guarded so a host + can't silently drop the approval evidence and still claim conformance. + Uses the fixture profile's conformance.approval, so it holds black-box too.""" + result = await invoke_host( + host, "conformance.approval", {}, correlation={"correlation_id": "conf-approval-001"} + ) + assert not result_value(result, "success"), f"approval-gated must be denied: {result}" + assert result_value(result, "outcome") == "denied", ( + f"expected 'denied', got {result_value(result, 'outcome')!r}" + ) + denial = result_value(result, "denial") + code = denial.get("code") if isinstance(denial, dict) else getattr(denial, "code", None) + assert code == "approval_required", f"expected 'approval_required', got {code!r}" + types = [e["event_type"] for e in host.replay("conf-approval-001")] + assert "approval_requested" in types, f"approval_requested not emitted: {types}" + assert "execution_started" not in types, "gated capability must not begin execution" + + +async def check_budget_exceeded_governance(host: Any) -> None: + """v0.2 governance (§4.1): once an autonomy action_limit is exhausted, further + invocations on that correlation are denied with the reserved code + 'budget_exceeded' and a 'budget_exceeded' event — the autonomy-budget + differentiator. The invocation before the limit still succeeds. Uses the + fixture profile's conformance.budgeted (action_limit=1).""" + corr = {"correlation_id": "conf-budget-001"} + first = await invoke_host(host, "conformance.budgeted", {}, correlation=corr) + assert result_value(first, "success"), f"first invocation (within budget) must succeed: {first}" + second = await invoke_host(host, "conformance.budgeted", {}, correlation=corr) + assert not result_value(second, "success"), f"over-budget invocation must be denied: {second}" + assert result_value(second, "outcome") == "denied", ( + f"expected 'denied', got {result_value(second, 'outcome')!r}" + ) + denial = result_value(second, "denial") + code = denial.get("code") if isinstance(denial, dict) else getattr(denial, "code", None) + assert code == "budget_exceeded", f"expected 'budget_exceeded', got {code!r}" + types = [e["event_type"] for e in host.replay("conf-budget-001")] + assert "budget_exceeded" in types, f"budget_exceeded event not emitted: {types}" + + +async def check_risk_tier_governance(host: Any) -> None: + """v0.2 governance (chp-governance-v0.2.md §3): a capability whose risk tier + orders above the host's max_risk_tier is denied with the reserved code + 'policy_blocked'. The risk-tier differentiator — guarded. Uses the fixture + profile's conformance.risky ('high') against a host capped at 'medium'.""" + result = await invoke_host( + host, "conformance.risky", {}, correlation={"correlation_id": "conf-risk-001"} + ) + assert not result_value(result, "success"), f"over-tier capability must be denied: {result}" + assert result_value(result, "outcome") == "denied", ( + f"expected 'denied', got {result_value(result, 'outcome')!r}" + ) + denial = result_value(result, "denial") + code = denial.get("code") if isinstance(denial, dict) else getattr(denial, "code", None) + assert code == "policy_blocked", f"expected 'policy_blocked', got {code!r}" + types = [e["event_type"] for e in host.replay("conf-risk-001")] + assert "execution_started" not in types, "over-tier capability must not begin execution" + + +async def check_safety_governance(host: Any) -> None: + """v0.2 governance (chp-governance-v0.2.md §4.2): with a safety evaluator + configured, an invocation a guardrail blocks is denied with the reserved + 'safety_blocked' code, records the assessment pair (started/completed) and + safety_action_blocked, and never starts execution. The safety differentiator + — a signed safety verdict on the governed plane, guarded. Uses the fixture + profile's conformance.unsafe.""" + result = await invoke_host( + host, "conformance.unsafe", {}, correlation={"correlation_id": "conf-safety-001"} + ) + assert not result_value(result, "success"), f"guardrail-blocked must be denied: {result}" + assert result_value(result, "outcome") == "denied", ( + f"expected 'denied', got {result_value(result, 'outcome')!r}" + ) + denial = result_value(result, "denial") + code = denial.get("code") if isinstance(denial, dict) else getattr(denial, "code", None) + assert code == "safety_blocked", f"expected 'safety_blocked', got {code!r}" + types = [e["event_type"] for e in host.replay("conf-safety-001")] + for required in ("safety_assessment_started", "safety_assessment_completed", + "safety_action_blocked"): + assert required in types, f"missing {required}: {types}" + assert "execution_started" not in types, "blocked capability must not begin execution" + + +async def check_wire_verify(host: Any) -> None: + """v0.2 over the wire: after an invocation, GET /verify/{corr} confirms the + host's own chain is intact (or, in gateway mode, says so honestly).""" + corr = "conf-wire-verify" + await invoke_host(host, "conformance.echo", {"value": "v"}, correlation={"correlation_id": corr}) + result = host.verify(corr) + if "valid" in result: + assert result["valid"] is True, f"host /verify reported an invalid chain: {result}" + else: + # Gateway mode: no local store — must say so, not claim validity. + assert "note" in result, f"/verify returned neither 'valid' nor a gateway 'note': {result}" + + +# The NORMATIVE suite = the spec's MUST behaviors. Passing THIS is what +# "spec-conformant CHP host" means — it does NOT require shipping the reference +# capability library. (Previously the two were mixed, so a spec-perfect host +# that omitted the reference RAG/graph/workflow capabilities failed ~half the +# runner. That redefined "conforming" as "ships our library"; this split undoes +# it.) See spec/chp-v0.1.md §11 + chp-v0.2.md. +NORMATIVE_CHECKS: list[tuple[str, Check]] = [ ("capability declaration", check_declaration), ("capability discovery", check_discovery), ("invocation through envelope", check_invocation_envelope), @@ -1220,6 +1462,24 @@ async def echo(_ctx, payload): ("evidence emission on failure", check_failure_evidence), ("evidence emission on denial", check_denial_evidence), ("replay by correlation id", check_replay_by_correlation), + ("identity propagation", check_identity_propagation), + ("standard denial codes", check_standard_denial_codes), + ("input schema validation", check_input_schema_validation), + ("approval-required governance (v0.2)", check_approval_required_governance), + ("budget-exceeded governance (v0.2)", check_budget_exceeded_governance), + ("risk-tier governance (v0.2)", check_risk_tier_governance), + ("safety-guardrail governance (v0.2)", check_safety_governance), + ("sqlite persistence", check_persistence), + ("evidence hash chain", check_evidence_hash_chain), + ("signed evidence bundle (v0.2)", check_signed_evidence_bundle), + ("strict verify rejects unhashed (v0.2)", check_strict_verify_rejects_unhashed), + ("retention preserves chain (v0.2)", check_retention_preserves_chain), +] + +# The REFERENCE suite exercises the bundled reference capability library. These +# are NOT protocol MUSTs — a conforming host need not ship them. They gate the +# reference implementation's quality, not spec conformance. +REFERENCE_CHECKS: list[tuple[str, Check]] = [ ("pre-tool governance", check_pretool_governance), ("retrieval capability", check_retrieval_capability), ("ingestion capability", check_ingestion_capability), @@ -1230,19 +1490,47 @@ async def echo(_ctx, payload): ("metrics report", check_metrics_report), ("certification", check_certification), ("version control capability", check_version_control_capability), - ("identity propagation", check_identity_propagation), ("composability declaration", check_composability_declaration), ("state machine capability", check_state_machine_capability), ("agent interface", check_agent_interface), ("safety capability", check_safety_capability), ("compliance capability", check_compliance_capability), ("incident capability", check_incident_capability), - ("sqlite persistence", check_persistence), +] + +# The WIRE suite = the normative behaviours observable over the HTTP binding +# (spec/chp-http-binding.md §5), driving a running host through the reference +# RemoteCapabilityHost client. It's the subset of NORMATIVE_CHECKS that needs +# only the wire surface (discover / invoke / replay / verify) — the checks that +# reach into a local SQLite store can't run black-box. A host-under-test +# pre-registers the fixture profile (conformance.echo/fail/guarded). +WIRE_CHECKS: list[tuple[str, Check]] = [ + ("capability declaration", check_declaration), + ("capability discovery", check_discovery), + ("invocation through envelope", check_invocation_envelope), + ("correlation propagation", check_correlation_propagation), + ("evidence emission on success", check_success_evidence), + ("evidence emission on failure", check_failure_evidence), + ("evidence emission on denial", check_denial_evidence), + ("replay by correlation id", check_replay_by_correlation), ("standard denial codes", check_standard_denial_codes), - ("input schema validation", check_input_schema_validation), - ("evidence hash chain", check_evidence_hash_chain), + ("approval-required governance (v0.2)", check_approval_required_governance), + ("budget-exceeded governance (v0.2)", check_budget_exceeded_governance), + ("risk-tier governance (v0.2)", check_risk_tier_governance), + ("safety-guardrail governance (v0.2)", check_safety_governance), + ("chain verification over /verify", check_wire_verify), ] +SUITES: dict[str, list[tuple[str, Check]]] = { + "normative": NORMATIVE_CHECKS, + "reference": REFERENCE_CHECKS, + "wire": WIRE_CHECKS, + "all": NORMATIVE_CHECKS + REFERENCE_CHECKS, +} + +# Back-compat: existing callers importing CHECKS get the full run. +CHECKS: list[tuple[str, Check]] = SUITES["all"] + SAMPLE_HOSTS = { "passing": build_passing_host, @@ -1252,15 +1540,9 @@ async def echo(_ctx, payload): } -async def run(sample: str) -> list[CheckResult]: - builder = SAMPLE_HOSTS.get(sample) - if builder is None: - raise ValueError(f"unknown sample host: {sample!r}. Choices: {list(SAMPLE_HOSTS)}") - host_or_coro = builder() - host = await host_or_coro if hasattr(host_or_coro, "__await__") else host_or_coro - +async def _run_checks(host: Any, checks: list[tuple[str, Check]]) -> list[CheckResult]: results = [] - for name, check in CHECKS: + for name, check in checks: try: await check(host) results.append(CheckResult(name, True)) @@ -1269,6 +1551,30 @@ async def run(sample: str) -> list[CheckResult]: return results +async def run(sample: str, suite: str = "all") -> list[CheckResult]: + builder = SAMPLE_HOSTS.get(sample) + if builder is None: + raise ValueError(f"unknown sample host: {sample!r}. Choices: {list(SAMPLE_HOSTS)}") + checks = SUITES.get(suite) + if checks is None: + raise ValueError(f"unknown suite: {suite!r}. Choices: {list(SUITES)}") + host_or_coro = builder() + host = await host_or_coro if hasattr(host_or_coro, "__await__") else host_or_coro + return await _run_checks(host, checks) + + +async def run_url(base_url: str, *, api_key: str | None = None, + suite: str = "wire") -> list[CheckResult]: + """Black-box: drive a running host over HTTP through RemoteCapabilityHost.""" + from chp_core.http import RemoteCapabilityHost + + checks = SUITES.get(suite) + if checks is None: + raise ValueError(f"unknown suite: {suite!r}. Choices: {list(SUITES)}") + host = RemoteCapabilityHost(base_url, api_key=api_key) + return await _run_checks(host, checks) + + def main() -> int: parser = argparse.ArgumentParser(description="Run CHP v0.1 conformance checks.") parser.add_argument( @@ -1277,14 +1583,43 @@ def main() -> int: default="passing", help="Built-in sample host to test against.", ) + parser.add_argument( + "--suite", + choices=list(SUITES), + default="all", + help="normative = spec MUSTs (defines conformance); reference = bundled " + "capability library; wire = black-box HTTP checks (--url); all = " + "normative + reference (default).", + ) + parser.add_argument( + "--url", + default=None, + help="Black-box: base URL of a running host to test over HTTP " + "(spec/chp-http-binding.md). Defaults --suite to 'wire'.", + ) + parser.add_argument( + "--key", + default=os.environ.get("CHP_HOST_API_KEY"), + help="X-CHP-Key for the black-box host (or set CHP_HOST_API_KEY).", + ) args = parser.parse_args() - results = asyncio.run(run(args.sample)) + if args.url: + suite = args.suite if args.suite != "all" else "wire" + results = asyncio.run(run_url(args.url, api_key=args.key, suite=suite)) + else: + results = asyncio.run(run(args.sample, args.suite)) for result in results: status = "PASS" if result.ok else "FAIL" suffix = f" - {result.detail}" if result.detail else "" print(f"{status} {result.name}{suffix}") + if args.url: + print(f"\n[wire] {sum(r.ok for r in results)}/{len(results)} black-box HTTP checks " + f"against {args.url}") + if args.suite == "normative": + print(f"\n[normative] {sum(r.ok for r in results)}/{len(results)} spec MUST checks " + "— this is what spec-conformance means (reference library not required).") return 0 if all(result.ok for result in results) else 1 diff --git a/docs/comparisons/chp-and-opentelemetry.md b/docs/comparisons/chp-and-opentelemetry.md index 081bed7..2430761 100644 --- a/docs/comparisons/chp-and-opentelemetry.md +++ b/docs/comparisons/chp-and-opentelemetry.md @@ -1,11 +1,22 @@ # CHP And OpenTelemetry -CHP should compose with OpenTelemetry, not compete with it. - -OpenTelemetry defines observability signals such as traces, metrics, logs, and -baggage. In the trace model, spans represent units of work and span events -represent meaningful points in time during a span. See the OpenTelemetry traces -concept page: . +CHP and OpenTelemetry operate at different layers. OTel is **observability**; +CHP is a **governed, signed evidence plane**. They interoperate — CHP exports +its evidence as OpenTelemetry spans — but CHP is not an OTel trace and does not +concede its own record. + +The difference is structural. OpenTelemetry **splits execution across separate, +optional, unsigned signals** — traces, metrics, logs, baggage — none of which +is a contract, none signed, and none carrying governance. CHP unifies **what ran +and what governed it** — policy, risk tier, invariants, safety checks, human +approval, autonomy budgets, and denial — onto **one mandatory, signed, +correlated plane**. A denial, a safety block, or a human approval is a +first-class, tamper-evident event in CHP; in OTel it is, at best, an unsigned +custom attribute on an optional span. That single governed signed plane is what +no observability standard provides. + +See the OpenTelemetry traces concept page: +. ## Relationship @@ -70,7 +81,9 @@ CHP v0.1 includes no-dependency mapping helpers in `chp_core.otel`: These return OTLP-like dictionaries that preserve CHP fields. They are not a full OpenTelemetry SDK exporter. -CHP v0.1 does not replace logs, spans, traces, metrics, baggage, collectors, or -observability backends. - -The right integration is export, not duplication. +CHP does not replace logs, spans, traces, metrics, baggage, collectors, or +observability backends — it keeps its own governed, signed plane as the source +of truth and **exports to OTel as a bridge** into that ecosystem. Notably, a CHP +export is a span no other source produces: **signed** (carrying `chp.content_hash`) +and **denial-aware** (carrying `chp.denied` / denial code). CHP feeds OTel on +CHP's terms; it does not fold its governed record into OTel's unsigned signals. diff --git a/docs/comparisons/chp-and-w3c-prov.md b/docs/comparisons/chp-and-w3c-prov.md new file mode 100644 index 0000000..462fd1a --- /dev/null +++ b/docs/comparisons/chp-and-w3c-prov.md @@ -0,0 +1,30 @@ +# CHP And W3C PROV + +W3C PROV (and de-facto lineage formats like OpenLineage) model **provenance** — +the record of entities, the activities that used or produced them, and the +agents responsible. CHP's evidence is provenance too: a capability invocation is +an *Activity*, its `subject` an *Agent*, its inputs/outputs *Entities*, and its +correlation/causation edges the activity-to-activity links. + +So CHP and PROV overlap in what they describe. They differ in three ways that +matter, and all three favor CHP for governing agents: + +1. **Active vs passive.** PROV is an after-the-fact description of what happened, + assembled by tooling. CHP evidence is emitted *at the boundary, as it + happens*, as a mandatory contract of the host — not reconstructed later. + +2. **Governed vs history-only.** PROV records what *was done*. CHP also records + what was **refused and why** — denial, policy decisions, risk-tier and safety + evaluations, and human approvals are first-class events on the same record. + PROV has no vocabulary for a refusal; CHP treats it as evidence. + +3. **Signed vs unsigned.** PROV and OpenLineage carry no integrity model — + provenance you cannot prove wasn't edited. CHP's `signed` assurance tier makes + the lineage **tamper-evident** (hash chain + ed25519). "Signed, governed + provenance" is an advance over PROV, not a copy of it. + +CHP is expressible *as* PROV for its positive history (Activity / Entity / Agent), +and can export to PROV / OpenLineage to interoperate with lineage and catalog +tooling — adding the governance and integrity those standards lack. CHP is the +active, governed, **signed** provenance plane; PROV is the passive, unsigned +description CHP can feed. diff --git a/docs/comparisons/chp-vs-mcp.md b/docs/comparisons/chp-vs-mcp.md index f0d2921..6ebb7a6 100644 --- a/docs/comparisons/chp-vs-mcp.md +++ b/docs/comparisons/chp-vs-mcp.md @@ -116,7 +116,7 @@ The repository now includes a dependency-light experimental prototype in `exampl At launch: -- Position CHP as the execution evidence layer for MCP and non-MCP tools. -- Ship the MCP bridge as an experimental prototype or separate package, not as part of the core spec. -- Do not claim MCP equivalence or replacement. -- Demonstrate one MCP-style tool wrapped by CHP evidence. +- Position CHP as the governed, signed evidence plane that subsumes tool exposure — MCP and non-MCP — as one input: an MCP `tools/call` becomes a governed, tamper-evident, replayable CHP event (with denial, risk, and approval on the same record) that MCP itself does not carry. +- The MCP bridge is real and shipping (`chp-adapter-mcp` records live MCP tool calls as signed CHP evidence; `chp-host` exposes CHP capabilities over MCP) — lead with it as the flagship integration. +- Do not claim MCP equivalence or replacement — CHP complements MCP by governing and proving the execution MCP exposes. +- Demonstrate an MCP tool call producing a signed, denial-capable CHP bundle that `chp verify-evidence --bundle` validates. diff --git a/docs/comparisons/landscape.md b/docs/comparisons/landscape.md index 3165b50..1a5263c 100644 --- a/docs/comparisons/landscape.md +++ b/docs/comparisons/landscape.md @@ -10,7 +10,8 @@ CHP is a capability execution evidence layer. It does not replace tool-calling A | LangChain tools | Callable tool with schema and runtime context | Agent tool abstraction, state access, memory, middleware, streaming. | Protocol-level evidence requirements and conformance. | Register LangChain tools as CHP capabilities or emit CHP evidence from middleware/tool runtime. | | LlamaIndex tools | Tool or ToolSpec | Agent-facing API-like tools with name, description, function schema. | Evidence semantics, denial, replay, assurance. | Wrap LlamaIndex `Tool` calls through a CHP host. | | Temporal | Workflow execution and activity | Durable workflow orchestration, replay, retries, long-running execution. | Tool/agent capability boundary protocol and open evidence schema for arbitrary hosts. | Treat Temporal activities or workflow steps as capabilities; emit CHP evidence at activity boundaries. | -| OpenTelemetry | Trace/span/log/metric | Observability signals and distributed tracing. | Capability identity, invocation envelopes, denial semantics, replayable execution evidence. | Map CHP correlation to trace IDs and emit OTel spans/events from CHP evidence. | +| OpenTelemetry | Trace/span/log/metric | Observability signals and distributed tracing — execution split across separate, optional, unsigned signals. | Governance on the same record (denial, risk tier, safety, approval), one signed plane, and integrity — OTel is unsigned and ungoverned. | Export CHP evidence as signed, denial-aware OTel spans (a bridge; CHP keeps its governed plane as source of truth). | +| W3C PROV / OpenLineage | Entity / Activity / Agent | Provenance and lineage — a passive, after-the-fact description of what was used and produced. | Active emission at the boundary, governance/denial semantics, and cryptographic integrity — PROV/OpenLineage lineage is unsigned and history-only. | Express CHP evidence as PROV/OpenLineage; CHP adds the signing + governance those lack (signed, governed provenance). | | API gateways | Route/service/API request | Traffic management, auth, rate limiting, API publishing, monitoring. | Agent/tool capability semantics and per-capability execution evidence. | A gateway route can invoke a CHP capability or add CHP correlation/evidence at upstream boundaries. | | Event streaming systems | Event/topic/stream | Durable event transport, pub/sub, stream processing. | Capability declaration and governed invocation boundary. | Publish CHP evidence events to Kafka or similar streams for production fan-out. | diff --git a/docs/why-chp.md b/docs/why-chp.md index f5b1ec5..15852be 100644 --- a/docs/why-chp.md +++ b/docs/why-chp.md @@ -26,9 +26,9 @@ Logs help, but logs are inconsistent, optional, and usually not a protocol contr ## The CHP Thesis -Execution should be observable, governable, replayable, and provable at the capability boundary. +At the capability boundary, execution should be observed, **governed**, replayed, and proved — on one record. CHP makes a human approval, an agent's action, and a system call the same *governed, tamper-evident event*: **what ran and what governed it — policy, risk tier, safety checks, human approval, autonomy budgets, denial — emit onto one signed, correlated, replayable plane.** -v0.1 starts with observable and replayable. Governance comes later. +Governance is present, not future. The policy engine, risk tiers, the safety evaluator, approval workflows, denial-as-evidence, and autonomy budgets are first-class today — signed together with the execution they govern. That single governed, signed plane is the differentiation: observability tools split execution across separate optional unsigned signals and carry no governance; CHP unifies both and proves them. ## What Makes A Capability Different From A Tool? diff --git a/packages/chp-host-ts/package.json b/packages/chp-host-ts/package.json new file mode 100644 index 0000000..a2182e8 --- /dev/null +++ b/packages/chp-host-ts/package.json @@ -0,0 +1,48 @@ +{ + "name": "@capabilityhostprotocol/host", + "version": "0.1.0-alpha.0", + "description": "A conformance-grade Capability Host Protocol (CHP) host in TypeScript — governed invocation pipeline + HTTP binding.", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "chp-host-ts": "./dist/serve.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup src/index.ts src/bin/serve.ts --format cjs,esm --dts", + "prepublishOnly": "npm run build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "keywords": ["chp", "capability-host-protocol", "evidence", "governance", "typescript"], + "author": "Capability Host Protocol Contributors", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/capabilityhostprotocol/chp-core", + "directory": "packages/chp-host-ts" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@capabilityhostprotocol/sdk": "0.1.0-alpha.0" + }, + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^2.1.9" + } +} diff --git a/packages/chp-host-ts/src/bin/serve.ts b/packages/chp-host-ts/src/bin/serve.ts new file mode 100644 index 0000000..cef3b27 --- /dev/null +++ b/packages/chp-host-ts/src/bin/serve.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node +/** + * chp-host-ts serve --port --key + * Boots the conformance fixture host over HTTP for `conformance/runner.py --url`. + */ + +import { generateKeypair } from '@capabilityhostprotocol/sdk'; +import { buildFixtureHost } from '../fixtures.js'; +import { createHostServer } from '../server.js'; + +function arg(name: string, def?: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def; +} + +const port = Number(arg('port', '8899')); +const key = arg('key'); +const sign = process.argv.includes('--sign'); + +const server = createHostServer(buildFixtureHost(sign ? generateKeypair() : undefined), { apiKey: key }); +server.listen(port, '127.0.0.1', () => { + process.stdout.write(`chp-host-ts listening on http://127.0.0.1:${port}${key ? ' (auth on)' : ''}\n`); +}); diff --git a/packages/chp-host-ts/src/fixtures.ts b/packages/chp-host-ts/src/fixtures.ts new file mode 100644 index 0000000..30ef79c --- /dev/null +++ b/packages/chp-host-ts/src/fixtures.ts @@ -0,0 +1,45 @@ +/** + * The conformance fixture profile (conformance/FIXTURES.md). Registers the seven + * capabilities + host config a `wire`-suite host-under-test must expose. + */ + +import { type HostKey } from '@capabilityhostprotocol/sdk'; +import { LocalCapabilityHost } from './host.js'; +import { RuleBasedSafetyEvaluator } from './safety.js'; +import type { Ctx, JsonValue } from './types.js'; + +export function buildFixtureHost(signingKey?: HostKey): LocalCapabilityHost { + const evaluator = new RuleBasedSafetyEvaluator([ + { + id: 'conformance-guardrail', + capability_id_pattern: 'conformance.unsafe', + max_risk_level: 'critical', + requires_human_for: ['conformance.unsafe'], + }, + ]); + const host = new LocalCapabilityHost('conformance-host', { + policy: { max_risk_tier: 'medium' }, + safetyEvaluator: evaluator, + signingKey, + }); + + const echo = async (_c: Ctx, payload: JsonValue) => ({ echo: (payload as { value?: JsonValue }).value ?? null }); + + host.register({ id: 'conformance.echo', version: '1.0.0', description: 'Echo a value.' }, echo); + host.register({ id: 'conformance.fail', version: '1.0.0', description: 'Fail deterministically.' }, async () => { + throw new Error('expected failure'); + }); + host.register( + { + id: 'conformance.guarded', version: '1.0.0', description: 'Require payload.value.', + invariants: [{ id: 'requires_value', kind: 'required_payload_fields', enforcement: 'host', parameters: { fields: ['value'] }, failure_behavior: 'deny' }], + }, + echo, + ); + host.register({ id: 'conformance.approval', version: '1.0.0', description: 'Approval-gated.', autonomy: { tier: 'approval_required' } }, echo); + host.register({ id: 'conformance.budgeted', version: '1.0.0', description: 'Budget-capped.', autonomy: { action_limit: 1 } }, echo); + host.register({ id: 'conformance.risky', version: '1.0.0', description: 'High-risk.', risk: 'high' }, echo); + host.register({ id: 'conformance.unsafe', version: '1.0.0', description: 'Blocked by a safety guardrail.' }, echo); + + return host; +} diff --git a/packages/chp-host-ts/src/host.ts b/packages/chp-host-ts/src/host.ts new file mode 100644 index 0000000..ab1a6f9 --- /dev/null +++ b/packages/chp-host-ts/src/host.ts @@ -0,0 +1,299 @@ +/** + * LocalCapabilityHost — the governed invocation pipeline (spec/chp-invocation-pipeline.md). + * Gates are applied in the exact normative order; the first that fires decides the + * outcome and stops. This is the TS peer of chp_core/host.py:ainvoke_envelope. + */ + +import { randomBytes } from 'node:crypto'; +import { buildBundle, signBundle, type EvidenceEvent, type HostKey } from '@capabilityhostprotocol/sdk'; +import { InMemoryEvidenceStore } from './store.js'; +import { RuleBasedSafetyEvaluator } from './safety.js'; +import type { + CapabilityDescriptor, Correlation, Ctx, DenialReason, Handler, + InvocationEnvelope, InvocationResult, JsonValue, PolicyConfig, RiskTier, +} from './types.js'; + +const RISK_ORDER: Record = { low: 0, medium: 1, high: 2, critical: 3 }; +const newId = (p: string): string => `${p}_${randomBytes(8).toString('hex')}`; +const nowIso = (): string => new Date().toISOString().replace(/\.\d+Z$/, 'Z'); + +/** chp-stable-v1 forbids floats in canonicalized content — string-encode at emit. */ +function stringifyFloats(v: JsonValue): JsonValue { + if (typeof v === 'number') return Number.isInteger(v) ? v : String(v); + if (Array.isArray(v)) return v.map(stringifyFloats); + if (v && typeof v === 'object') { + const o: Record = {}; + for (const [k, x] of Object.entries(v)) o[k] = stringifyFloats(x as JsonValue); + return o; + } + return v; +} + +interface Registered { descriptor: CapabilityDescriptor; handler: Handler; enabled: boolean; } + +export class LocalCapabilityHost { + readonly store = new InMemoryEvidenceStore(); + private readonly caps = new Map(); + + constructor( + readonly hostId = 'ts-chp-host', + private readonly opts: { + policy?: PolicyConfig; + safetyEvaluator?: RuleBasedSafetyEvaluator; + signingKey?: HostKey; + } = {}, + ) {} + + /** Declared evidence assurance tier (chp-v0.2.md §1). */ + private assurance(): Record { + const k = this.opts.signingKey; + return k + ? { assurance: 'signed', key_id: k.keyId, public_key: k.publicKeyB64 } + : { assurance: 'hash-chain' }; + } + + /** Export a correlation as a bundle — signed when the host holds a key (M3). */ + exportBundle(correlationId: string): Record { + const events = this.store.byCorrelation(correlationId); + const bundle = buildBundle(this.hostId, events, nowIso()); + return this.opts.signingKey ? signBundle(bundle, this.opts.signingKey) : bundle; + } + + register(descriptor: CapabilityDescriptor, handler: Handler): void { + this.caps.set(descriptor.id, { descriptor, handler, enabled: descriptor.enabled !== false }); + } + + discover(): Record { + return { + id: this.hostId, + version: '0.1.0', + protocol_version: '0.1', + kind: 'local', + capabilities: [...this.caps.values()].map((c) => ({ + id: c.descriptor.id, + version: c.descriptor.version, + description: c.descriptor.description ?? '', + modes: c.descriptor.modes ?? ['sync'], + ...(c.descriptor.risk ? { risk: c.descriptor.risk } : {}), + })), + evidence: { store: 'memory', append_only: true }, + metadata: {}, + ...this.assurance(), + }; + } + + replay(correlationId: string): EvidenceEvent[] { + return this.store.byCorrelation(correlationId); + } + + verify(correlationId: string): JsonValue { + const r = this.store.verifyChainFor(correlationId); + const events = this.store.byCorrelation(correlationId); + return { + correlation_id: correlationId, + valid: r.valid, + event_count: events.length, + first_broken_sequence: r.firstBrokenSequence, + }; + } + + private emit( + eventType: string, + env: InvocationEnvelope, + payload: JsonValue, + outcome: string | null = null, + extra: { denial?: DenialReason; error?: JsonValue } = {}, + ): EvidenceEvent { + const ev: EvidenceEvent = { + event_id: newId('evt'), + event_type: eventType, + invocation_id: env.invocation_id!, + capability_id: env.capability_id, + host_id: this.hostId, + correlation: env.correlation as EvidenceEvent['correlation'], + timestamp: nowIso(), + outcome, + payload: stringifyFloats(payload), + ...(extra.denial ? { denial: extra.denial as unknown as JsonValue } : {}), + ...(extra.error ? { error: extra.error } : {}), + subject: env.subject ?? { id: 'local', type: 'user' }, + }; + return this.store.append(ev); + } + + private result(env: InvocationEnvelope, o: Partial): InvocationResult { + return { + invocation_id: env.invocation_id!, + capability_id: env.capability_id, + correlation: env.correlation!, + outcome: 'failure', + success: false, + evidence_ids: [], + ...o, + } as InvocationResult; + } + + private deny(env: InvocationEnvelope, denial: DenialReason): InvocationResult { + const e = this.emit('execution_denied', env, { reason: denial.code }, 'denied', { denial }); + return this.result(env, { outcome: 'denied', success: false, denial, evidence_ids: [e.event_id] }); + } + + private skip(env: InvocationEnvelope, code: string, message: string): InvocationResult { + const e = this.emit('execution_skipped', env, { code, message }, 'skipped'); + return this.result(env, { outcome: 'skipped', success: false, evidence_ids: [e.event_id] }); + } + + async ainvokeEnvelope(input: InvocationEnvelope): Promise { + const env: InvocationEnvelope = { + mode: 'sync', + payload: {}, + subject: { id: 'local', type: 'user' }, + ...input, + invocation_id: input.invocation_id ?? newId('inv'), + correlation: (input.correlation as Correlation) ?? { correlation_id: newId('corr') }, + }; + + // Gate 1: non-empty id + if (!env.capability_id || !env.capability_id.trim()) { + return this.deny(env, { code: 'capability_not_found', message: 'capability_id must be non-empty', retryable: false }); + } + // Gate 2: resolution + const entry = this.caps.get(env.capability_id); + if (!entry || (env.version && env.version !== entry.descriptor.version)) { + return this.deny(env, { code: 'capability_not_found', message: `Capability not found: ${env.capability_id}`, retryable: false }); + } + const d = entry.descriptor; + env.version = d.version; + // Gate 3: enabled → SKIP (not deny) + if (!entry.enabled) { + return this.skip(env, 'capability_disabled', `Capability disabled: ${d.id}:${d.version}`); + } + // Gate 4: mode + const modes = d.modes ?? ['sync']; + if (!modes.includes(env.mode!)) { + return this.deny(env, { code: 'unsupported_mode', message: `mode ${env.mode} unsupported`, retryable: false }); + } + // Gate 5: policy + const pd = this.checkPolicy(d); + if (pd) return this.deny(env, pd); + // Gate 6: invariants + const inv = this.checkInvariants(d, env); + if (inv) return this.deny(env, inv); + // Gate 7: autonomy budget / approval + const auto = this.checkAutonomy(d, env); + if (auto) return this.deny(env, auto); + // Gate 8: input schema (minimal required-fields check; full JSON Schema out of scope) + const sch = this.checkInputSchema(d, env); + if (sch) return this.deny(env, sch); + // Gate 9: safety + const saf = this.checkSafety(d, env); + if (saf) return this.deny(env, saf); + + // Gate 10: execute + const started = this.emit('execution_started', env, { capability_uri: `${d.id}:${d.version}` }, null); + const ctx: Ctx = { + envelope: env, + emit: (t, p, o = null) => this.emit(t, env, p, o), + }; + try { + const data = await entry.handler(ctx, env.payload ?? {}); + const done = this.emit('execution_completed', env, { capability_uri: `${d.id}:${d.version}` }, 'success'); + return this.result(env, { + outcome: 'success', success: true, capability_version: d.version, + data: data as JsonValue, evidence_ids: [started.event_id, done.event_id], started_at: started.timestamp, + }); + } catch (err) { + const failed = this.emit('execution_failed', env, { capability_uri: `${d.id}:${d.version}` }, 'failure', + { error: { type: (err as Error).name, message: (err as Error).message } }); + return this.result(env, { + outcome: 'failure', success: false, capability_version: d.version, + error: { type: (err as Error).name, message: (err as Error).message }, + evidence_ids: [started.event_id, failed.event_id], started_at: started.timestamp, + }); + } + } + + // ── gate helpers ────────────────────────────────────────────────────────── + + private checkPolicy(d: CapabilityDescriptor): DenialReason | null { + const p = this.opts.policy; + if (!p) return null; + let blocked: string | null = null; + if (p.allowed_capability_ids && !p.allowed_capability_ids.includes(d.id)) blocked = 'not in allowlist'; + else if (p.block_capability_ids?.includes(d.id)) blocked = 'blocked capability id'; + else if (p.max_risk_tier != null) { + const eff = (d.risk && d.risk in RISK_ORDER ? d.risk : 'medium') as RiskTier; + if (RISK_ORDER[eff] > RISK_ORDER[p.max_risk_tier]) blocked = `risk ${eff} exceeds max ${p.max_risk_tier}`; + } + if (blocked && !p.audit_only) { + return { code: 'policy_blocked', message: blocked, retryable: false }; + } + return null; + } + + private checkInvariants(d: CapabilityDescriptor, env: InvocationEnvelope): DenialReason | null { + const payload = (env.payload ?? {}) as Record; + for (const iv of d.invariants ?? []) { + if (iv.enforcement !== 'host') continue; + if (iv.kind === 'required_payload_fields') { + const fields = (iv.parameters?.fields as string[]) ?? []; + const missing = fields.filter((f) => !(f in payload)); + if (missing.length && iv.failure_behavior !== 'warn') { + return { code: 'invariant_failed', message: `missing required fields: ${missing.join(', ')}`, invariant_id: iv.id, retryable: false }; + } + } + } + return null; + } + + private checkAutonomy(d: CapabilityDescriptor, env: InvocationEnvelope): DenialReason | null { + const a = d.autonomy; + if (!a) return null; + const corr = env.correlation!.correlation_id; + const started = this.store.countEventType(corr, 'execution_started'); + if (a.action_limit != null && started >= a.action_limit) { + this.emit('budget_exceeded', env, { limit_type: 'action_limit', action_limit: a.action_limit, actions_taken: started }, 'denied'); + return { code: 'budget_exceeded', message: `action_limit ${a.action_limit} reached`, retryable: true }; + } + if (a.spend_limit != null) { + const spend = started * (a.spend_units ?? 1); + if (spend >= a.spend_limit) { + this.emit('budget_exceeded', env, { limit_type: 'spend_limit', spend_limit: a.spend_limit, spend_so_far: spend }, 'denied'); + return { code: 'budget_exceeded', message: `spend_limit ${a.spend_limit} reached`, retryable: true }; + } + } + if (a.tier === 'approval_required') { + this.emit('approval_requested', env, { tier: a.tier }, 'denied'); + return { code: 'approval_required', message: `${d.id} requires approval`, retryable: true }; + } + return null; + } + + private checkInputSchema(d: CapabilityDescriptor, env: InvocationEnvelope): DenialReason | null { + const s = d.input_schema as { required?: string[] } | null | undefined; + if (!s || !Array.isArray(s.required)) return null; + const payload = (env.payload ?? {}) as Record; + const missing = s.required.filter((f) => !(f in payload)); + if (missing.length) { + return { code: 'input_schema_validation_failed', message: `missing: ${missing.join(', ')}`, retryable: false }; + } + return null; + } + + private checkSafety(d: CapabilityDescriptor, env: InvocationEnvelope): DenialReason | null { + const evaluator = this.opts.safetyEvaluator; + if (!evaluator) return null; + const uri = `${d.id}:${d.version}`; + this.emit('safety_assessment_started', env, { capability_uri: uri }); + const report = evaluator.report(d.id, env.payload ?? {}); + const a = report.assessment; + this.emit('safety_assessment_completed', env, { capability_uri: uri, level: a.level, score: a.score, approved: report.approved }); + if (!report.approved) { + this.emit('safety_guardrail_triggered', env, { capability_uri: uri, reason: report.blockReason }); + this.emit('safety_action_blocked', env, { capability_uri: uri, reason: report.blockReason }, 'denied'); + return { code: 'safety_blocked', message: report.blockReason ?? 'blocked by safety guardrail', retryable: false, details: { level: a.level } }; + } + this.emit('safety_action_approved', env, { capability_uri: uri, level: a.level }); + return null; + } +} diff --git a/packages/chp-host-ts/src/index.ts b/packages/chp-host-ts/src/index.ts new file mode 100644 index 0000000..ce44f3b --- /dev/null +++ b/packages/chp-host-ts/src/index.ts @@ -0,0 +1,14 @@ +/** + * @capabilityhostprotocol/host — a conformance-grade CHP host in TypeScript: + * in-memory hash-chained store, the governed invocation pipeline, and the HTTP + * binding server. The second implementation that passes the black-box wire suite. + * + * @packageDocumentation + */ + +export { LocalCapabilityHost } from './host.js'; +export { InMemoryEvidenceStore } from './store.js'; +export { RuleBasedSafetyEvaluator, type Guardrail } from './safety.js'; +export { createHostServer } from './server.js'; +export { buildFixtureHost } from './fixtures.js'; +export type * from './types.js'; diff --git a/packages/chp-host-ts/src/safety.ts b/packages/chp-host-ts/src/safety.ts new file mode 100644 index 0000000..0a86a1a --- /dev/null +++ b/packages/chp-host-ts/src/safety.ts @@ -0,0 +1,69 @@ +/** + * Rule-based safety evaluator (spec/chp-governance-v0.2.md §4.2). Minimal port of + * the Python reference — enough for the conformance profile: a guardrail whose + * `requires_human_for` (or exceeded `max_risk_level`) lists a capability blocks it. + */ + +import type { JsonValue, RiskTier } from './types.js'; + +const RISK_ORDER: RiskTier[] = ['low', 'medium', 'high', 'critical']; +const HIGH_RISK_PATTERNS = ['bash', 'exec', 'shell', 'delete', 'drop', 'destroy']; +const LEVEL_SCORE: Record = { low: 0.1, medium: 0.4, high: 0.7, critical: 0.95 }; + +export interface Guardrail { + id: string; + capability_id_pattern: string; + max_risk_level: RiskTier; + requires_human_for?: string[]; +} + +export interface Assessment { + level: RiskTier; + score: number; + recommendation: string; +} + +export interface SafetyReport { + assessment: Assessment; + approved: boolean; + blockReason: string | null; + guardrailsEvaluated: string[]; +} + +const matches = (id: string, pattern: string): boolean => { + // fnmatch-style: '*' wildcard, case-insensitive + const re = new RegExp('^' + pattern.toLowerCase().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'); + return re.test(id.toLowerCase()); +}; + +export class RuleBasedSafetyEvaluator { + constructor(private readonly guardrails: Guardrail[] = []) {} + + assess(capabilityId: string): Assessment { + let score = 0.0; + const cid = capabilityId.toLowerCase(); + if (HIGH_RISK_PATTERNS.some((p) => cid.includes(p))) score = LEVEL_SCORE.high; + const level = + score >= 0.8 ? 'critical' : score >= 0.55 ? 'high' : score >= 0.3 ? 'medium' : 'low'; + const recommendation = { low: 'allow', medium: 'warn', high: 'require_approval', critical: 'block' }[ + level as RiskTier + ]; + return { level: level as RiskTier, score: Math.round(score * 1000) / 1000, recommendation }; + } + + report(capabilityId: string, _payload: JsonValue): SafetyReport { + const assessment = this.assess(capabilityId); + const evaluated: string[] = []; + for (const g of this.guardrails) { + if (!matches(capabilityId, g.capability_id_pattern)) continue; + evaluated.push(g.id); + if (RISK_ORDER.indexOf(assessment.level) > RISK_ORDER.indexOf(g.max_risk_level)) { + return { assessment, approved: false, blockReason: `guardrail '${g.id}': risk ${assessment.level} exceeds ${g.max_risk_level}`, guardrailsEvaluated: evaluated }; + } + if ((g.requires_human_for ?? []).includes(capabilityId)) { + return { assessment, approved: false, blockReason: `guardrail '${g.id}': '${capabilityId}' requires human approval`, guardrailsEvaluated: evaluated }; + } + } + return { assessment, approved: true, blockReason: null, guardrailsEvaluated: evaluated }; + } +} diff --git a/packages/chp-host-ts/src/server.ts b/packages/chp-host-ts/src/server.ts new file mode 100644 index 0000000..3ea37ec --- /dev/null +++ b/packages/chp-host-ts/src/server.ts @@ -0,0 +1,123 @@ +/** + * HTTP binding server (spec/chp-http-binding.md). node:http only. The load-bearing + * rule: a processed invocation — success/failure/denied/skipped — returns 200 with + * the InvocationResult in the body; only bad-JSON (400), bad/missing auth (401), + * and unknown route (404) escape as non-2xx. + */ + +import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http'; +import { timingSafeEqual } from 'node:crypto'; +import type { LocalCapabilityHost } from './host.js'; +import type { InvocationEnvelope, JsonValue } from './types.js'; + +const HOST_VERSION = '0.1.0-alpha.0'; + +function sendJson(res: ServerResponse, status: number, body: JsonValue): void { + // sorted-key JSON output (chp-http-binding §3) + const sorted = JSON.stringify(sortKeys(body)); + res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(sorted) }); + res.end(sorted); +} + +function sortKeys(v: JsonValue): JsonValue { + if (Array.isArray(v)) return v.map(sortKeys); + if (v && typeof v === 'object') { + const o: Record = {}; + for (const k of Object.keys(v).sort()) o[k] = sortKeys((v as Record)[k]); + return o; + } + return v; +} + +const err = (res: ServerResponse, status: number, code: string, message: string): void => + sendJson(res, status, { error: { code, message } }); + +function constantTimeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + return Buffer.concat(chunks).toString('utf8'); +} + +export function createHostServer(host: LocalCapabilityHost, opts: { apiKey?: string } = {}): Server { + const { apiKey } = opts; + + const authed = (req: IncomingMessage): boolean => { + if (!apiKey) return true; + const presented = (req.headers['x-chp-key'] as string) ?? ''; + return constantTimeEqual(presented, apiKey); + }; + + return createServer((req, res) => { + void handle(req, res).catch((e) => err(res, 500, 'internal_error', String((e as Error).message))); + }); + + async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', 'http://x'); + const path = url.pathname; + const method = req.method ?? 'GET'; + + // Public: /health (= /) + if (method === 'GET' && (path === '/' || path === '/health')) { + const d = host.discover(); + return sendJson(res, 200, { + status: 'ok', host_id: d.id, protocol: 'chp', version: '0.1', host_version: HOST_VERSION, + }); + } + + if (!authed(req)) return err(res, 401, 'unauthorized', 'Missing or invalid X-CHP-Key'); + + if (method === 'GET' && path === '/host') { + return sendJson(res, 200, { ...host.discover(), host_version: HOST_VERSION }); + } + if (method === 'GET' && path === '/capabilities') { + return sendJson(res, 200, { capabilities: (host.discover().capabilities as JsonValue) }); + } + if (method === 'GET' && path.startsWith('/replay/')) { + const corr = decodeURIComponent(path.slice('/replay/'.length)); + return sendJson(res, 200, { correlation_id: corr, events: host.replay(corr) as unknown as JsonValue }); + } + if (method === 'GET' && path.startsWith('/verify/')) { + const corr = decodeURIComponent(path.slice('/verify/'.length)); + return sendJson(res, 200, host.verify(corr)); + } + // Signed-tier export (v0.2): a signed bundle for a correlation, offline-verifiable. + if (method === 'GET' && path.startsWith('/export/')) { + const corr = decodeURIComponent(path.slice('/export/'.length)); + return sendJson(res, 200, host.exportBundle(corr)); + } + if (method === 'GET' && path === '/metrics') { + res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' }); + return void res.end('# chp ts host metrics (stub)\n'); + } + + if (method === 'POST' && (path === '/invoke' || path === '/replay')) { + let body: Record; + try { + body = JSON.parse((await readBody(req)) || '{}') as Record; + } catch (e) { + return err(res, 400, 'invalid_json', String((e as Error).message)); + } + if (path === '/replay') { + const corr = String(body.correlation_id ?? ''); + return sendJson(res, 200, { correlation_id: corr, events: host.replay(corr) as unknown as JsonValue }); + } + // /invoke — lift top-level correlation_id, always 200 (outcome in body) + const env = { ...body } as unknown as InvocationEnvelope & { correlation_id?: string }; + if (env.correlation_id && !env.correlation) { + env.correlation = { correlation_id: env.correlation_id }; + delete env.correlation_id; + } + const result = await host.ainvokeEnvelope(env); + return sendJson(res, 200, result as unknown as JsonValue); + } + + return err(res, 404, 'not_found', `Unknown route: ${path}`); + } +} diff --git a/packages/chp-host-ts/src/store.ts b/packages/chp-host-ts/src/store.ts new file mode 100644 index 0000000..ff7d6c9 --- /dev/null +++ b/packages/chp-host-ts/src/store.ts @@ -0,0 +1,42 @@ +/** + * In-memory append-only, SHA256-chained evidence store. The backend is + * non-normative (spec/chp-v0.2.md) — an array is enough for a conformance host. + * Chaining matches the Python reference: prev_hash links to the last event with + * the SAME correlation_id; a global sequence orders the store. + */ + +import { contentHash, verifyChain, type EvidenceEvent, type ChainResult } from '@capabilityhostprotocol/sdk'; + +export class InMemoryEvidenceStore { + private events: EvidenceEvent[] = []; + private seq = 0; + + append(ev: EvidenceEvent): EvidenceEvent { + this.seq += 1; + const corr = ev.correlation?.correlation_id ?? null; + let prev: string | null = null; + for (let i = this.events.length - 1; i >= 0; i--) { + if ((this.events[i].correlation?.correlation_id ?? null) === corr) { + prev = this.events[i].content_hash ?? null; + break; + } + } + ev.sequence = this.seq; + ev.content_hash = contentHash(ev, prev); + ev.prev_hash = prev; + this.events.push(ev); + return ev; + } + + byCorrelation(correlationId: string): EvidenceEvent[] { + return this.events.filter((e) => (e.correlation?.correlation_id ?? null) === correlationId); + } + + countEventType(correlationId: string, eventType: string): number { + return this.byCorrelation(correlationId).filter((e) => e.event_type === eventType).length; + } + + verifyChainFor(correlationId: string): ChainResult { + return verifyChain(this.byCorrelation(correlationId)); + } +} diff --git a/packages/chp-host-ts/src/types.ts b/packages/chp-host-ts/src/types.ts new file mode 100644 index 0000000..5659d2f --- /dev/null +++ b/packages/chp-host-ts/src/types.ts @@ -0,0 +1,88 @@ +/** Internal host types. Lean by design — the host is a conformance instrument. */ + +import type { JsonValue, EvidenceEvent } from '@capabilityhostprotocol/sdk'; + +export type { JsonValue, EvidenceEvent }; +export type Outcome = 'success' | 'failure' | 'denied' | 'skipped'; +export type RiskTier = 'low' | 'medium' | 'high' | 'critical'; + +export interface AutonomyProfile { + tier?: string; + action_limit?: number | null; + spend_limit?: number | null; + spend_units?: number; + rollback_policy?: string; +} + +export interface Invariant { + id: string; + kind: string; + enforcement: string; + parameters?: Record; + failure_behavior?: string; +} + +export interface CapabilityDescriptor { + id: string; + version: string; + description?: string; + modes?: string[]; + risk?: RiskTier; + autonomy?: AutonomyProfile | null; + invariants?: Invariant[]; + input_schema?: JsonValue | null; + enabled?: boolean; +} + +export interface Correlation { + correlation_id: string; + causation_id?: string | null; + [k: string]: JsonValue | undefined; +} + +export interface InvocationEnvelope { + capability_id: string; + payload?: JsonValue; + version?: string | null; + invocation_id?: string; + mode?: string; + correlation?: Correlation; + subject?: JsonValue; +} + +export interface DenialReason { + code: string; + message: string; + retryable?: boolean; + invariant_id?: string | null; + details?: JsonValue; +} + +export interface InvocationResult { + invocation_id: string; + capability_id: string; + capability_version?: string; + correlation: Correlation; + outcome: Outcome; + success: boolean; + data?: JsonValue; + error?: JsonValue; + denial?: DenialReason | null; + evidence_ids: string[]; + started_at?: string; + completed_at?: string; +} + +export interface Ctx { + envelope: InvocationEnvelope; + emit(eventType: string, payload: JsonValue, outcome?: string | null): EvidenceEvent; +} + +export type Handler = (ctx: Ctx, payload: JsonValue) => JsonValue | Promise; + +export interface PolicyConfig { + allowed_capability_ids?: string[]; + block_capability_ids?: string[]; + max_risk_tier?: RiskTier | null; + audit_only?: boolean; +} diff --git a/packages/chp-host-ts/test/pipeline.test.ts b/packages/chp-host-ts/test/pipeline.test.ts new file mode 100644 index 0000000..3e47ec4 --- /dev/null +++ b/packages/chp-host-ts/test/pipeline.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { buildFixtureHost } from '../src/fixtures.js'; +import { LocalCapabilityHost } from '../src/host.js'; + +const types = (evs: { event_type: string }[]) => evs.map((e) => e.event_type); + +describe('governed invocation pipeline (spec/chp-invocation-pipeline.md)', () => { + it('echo → success with started+completed', async () => { + const h = buildFixtureHost(); + const r = await h.ainvokeEnvelope({ capability_id: 'conformance.echo', payload: { value: 'x' }, correlation: { correlation_id: 'c' } }); + expect(r.outcome).toBe('success'); + expect(types(h.replay('c'))).toContain('execution_completed'); + }); + + it('missing capability → denied capability_not_found', async () => { + const h = buildFixtureHost(); + const r = await h.ainvokeEnvelope({ capability_id: 'nope.x', correlation: { correlation_id: 'c' } }); + expect(r.outcome).toBe('denied'); + expect(r.denial?.code).toBe('capability_not_found'); + }); + + it('guarded {} → denied invariant_failed', async () => { + const h = buildFixtureHost(); + const r = await h.ainvokeEnvelope({ capability_id: 'conformance.guarded', payload: {}, correlation: { correlation_id: 'c' } }); + expect(r.denial?.code).toBe('invariant_failed'); + }); + + it('approval → approval_requested BEFORE denial, no execution', async () => { + const h = buildFixtureHost(); + const r = await h.ainvokeEnvelope({ capability_id: 'conformance.approval', correlation: { correlation_id: 'c' } }); + expect(r.denial?.code).toBe('approval_required'); + const t = types(h.replay('c')); + expect(t).toEqual(['approval_requested', 'execution_denied']); + }); + + it('budgeted twice → 2nd is budget_exceeded', async () => { + const h = buildFixtureHost(); + const first = await h.ainvokeEnvelope({ capability_id: 'conformance.budgeted', correlation: { correlation_id: 'c' } }); + const second = await h.ainvokeEnvelope({ capability_id: 'conformance.budgeted', correlation: { correlation_id: 'c' } }); + expect(first.success).toBe(true); + expect(second.denial?.code).toBe('budget_exceeded'); + expect(types(h.replay('c'))).toContain('budget_exceeded'); + }); + + it('risky → policy_blocked, no safety events (policy precedes safety)', async () => { + const h = buildFixtureHost(); + const r = await h.ainvokeEnvelope({ capability_id: 'conformance.risky', correlation: { correlation_id: 'c' } }); + expect(r.denial?.code).toBe('policy_blocked'); + expect(types(h.replay('c'))).toEqual(['execution_denied']); + }); + + it('unsafe → safety_blocked with the full assessment sequence', async () => { + const h = buildFixtureHost(); + const r = await h.ainvokeEnvelope({ capability_id: 'conformance.unsafe', correlation: { correlation_id: 'c' } }); + expect(r.denial?.code).toBe('safety_blocked'); + const t = types(h.replay('c')); + expect(t).toEqual([ + 'safety_assessment_started', 'safety_assessment_completed', + 'safety_guardrail_triggered', 'safety_action_blocked', 'execution_denied', + ]); + }); + + it('disabled capability → skipped, not denied', async () => { + const h = new LocalCapabilityHost('t'); + h.register({ id: 'd.x', version: '1.0.0', enabled: false }, async () => ({})); + const r = await h.ainvokeEnvelope({ capability_id: 'd.x', correlation: { correlation_id: 'c' } }); + expect(r.outcome).toBe('skipped'); + expect(types(h.replay('c'))).toEqual(['execution_skipped']); + }); + + it('emits a verifiable hash chain', async () => { + const h = buildFixtureHost(); + await h.ainvokeEnvelope({ capability_id: 'conformance.echo', payload: { value: 'x' }, correlation: { correlation_id: 'c' } }); + expect(h.store.verifyChainFor('c').valid).toBe(true); + }); +}); diff --git a/packages/chp-host-ts/tsconfig.json b/packages/chp-host-ts/tsconfig.json new file mode 100644 index 0000000..0a120d1 --- /dev/null +++ b/packages/chp-host-ts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist" + }, + "include": ["src", "test"] +} diff --git a/packages/chp-host/chp_host/cli.py b/packages/chp-host/chp_host/cli.py index 16fd00d..df52ecb 100644 --- a/packages/chp-host/chp_host/cli.py +++ b/packages/chp-host/chp_host/cli.py @@ -6,6 +6,7 @@ chp-host mcp --adapters git,github,planning,delegation,safety chp-host mcp --profile my-profile.json chp-host init [--role primary|worker|raspi|linux-worker] + chp-host onboard [--module M --ops a,b --name X [--register]] chp-host mesh invite|add|list|remove chp-host gateway (defaults to ~/.chp/mesh.json) chp-host adapters @@ -953,6 +954,128 @@ def _install_gateway_service(chp_dir: Path, yes: bool = False) -> None: print(f" Run manually: launchctl load {plist_path}") +# --------------------------------------------------------------------------- +# chp-host onboard — portable onboarding wizard (scan → wrap / hand off → gate) +# --------------------------------------------------------------------------- + +def _find_onboard_dir() -> Path | None: + """Locate the onboarding scripts (scanner/onboard.py + scan.py), portably. + + Order: $CHP_SCANNER_DIR, a copy bundled in this package (chp_host/onboarding), + then walk up from the cwd looking for `scanner/onboard.py` (the dev checkout). + """ + env = os.environ.get("CHP_SCANNER_DIR") + if env and (Path(env) / "onboard.py").exists(): + return Path(env) + bundled = Path(__file__).resolve().parent / "onboarding" + if (bundled / "onboard.py").exists(): + return bundled + here = Path.cwd().resolve() + for d in (here, *here.parents): + cand = d / "scanner" + if (cand / "onboard.py").exists(): + return cand + return None + + +def _load_onboard(): + """Import the onboarding module (and put its dir on sys.path so `from scan import scan` works).""" + d = _find_onboard_dir() + if d is None: + return None + import importlib.util + if str(d) not in sys.path: + sys.path.insert(0, str(d)) + spec = importlib.util.spec_from_file_location("chp_onboard", str(d / "onboard.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _conformance_gate(adapter_py: str) -> tuple[int, list] | None: + """Run the local conformance checker on a source file. Pure, no mesh/ctx required. + + Returns (score, violations), or None if chp-adapter-conformance isn't installed (the gate is + skipped with a warning — Mode A still generated the adapter; portability over a hard requirement). + """ + try: + from chp_adapter_conformance.checker import check_source_file, score + except ImportError: + return None + viols = check_source_file(adapter_py) + return score(viols), viols + + +def _cmd_onboard(args: argparse.Namespace) -> int: + onb = _load_onboard() + if onb is None: + print("ERROR: onboarding scripts not found. Set CHP_SCANNER_DIR to the dir holding " + "onboard.py + scan.py (the repo's scanner/).", file=sys.stderr) + return 1 + + if getattr(args, "detect_agents", False): + agents = onb.detect_agents() + print("coding agents available:", [a for a, _ in agents] or "none on PATH") + return 0 + + repo = getattr(args, "repo", None) + if not repo: + print("usage: chp-host onboard [--module M --ops a,b --name X [--register]]", file=sys.stderr) + return 2 + repo = os.path.abspath(os.path.expanduser(repo)) + if not os.path.isdir(repo): + print(f"ERROR: {repo} is not a directory.", file=sys.stderr) + return 1 + + module = getattr(args, "module", None) + if not module: + return onb.wizard(repo) # no --module → guided wizard (scan + the two paths) + + ops = [o for o in (getattr(args, "ops", "") or "").split(",") if o] + name = getattr(args, "name", None) + if not (name and ops): + print("Mode A needs --module, --ops, --name. (Omit --module for the guided wizard.)", file=sys.stderr) + return 2 + + # Mode A — deterministic wrap. Generate next to the repo (or --out), then gate on conformance. + out_root = getattr(args, "out", None) or os.path.join(os.path.dirname(repo) or ".", "_onboarded") + if repo not in sys.path: + sys.path.insert(0, repo) + try: + pkg = onb.generate_mode_a(repo, module, ops, name, out_root) + except Exception as exc: # noqa: BLE001 + print(f"ERROR: Mode A generation failed: {exc}", file=sys.stderr) + return 1 + adapter_py = os.path.join(pkg, f"chp_adapter_{name}", "adapter.py") + print(f"✓ generated {pkg}") + + gate = _conformance_gate(adapter_py) + if gate is None: + print(" conformance: skipped (chp-adapter-conformance not installed — install it to gate locally)") + else: + sc, viols = gate + print(f" conformance: {sc}/100" + (f" ({len(viols)} issue(s))" if viols else " ✓")) + for v in viols: + print(f" - [{v.severity}] {v.rule}: {v.message} ({v.location})") + if sc < 100: + print(" ✗ generated adapter is below the bar — fix the wrapper template before registering.", file=sys.stderr) + return 1 + + if getattr(args, "register", False): + print(" registering (pip install -e + restart to pick up the entry point)…") + r = subprocess.run([sys.executable, "-m", "pip", "install", "-e", pkg], + capture_output=True, text=True) + if r.returncode != 0: + print(f" WARNING: pip install -e failed:\n{r.stderr.strip()[:400]}", file=sys.stderr) + return 1 + print(f" ✓ installed chp-adapter-{name} (entry point chp.adapters.{name}). " + f"Restart the host to load it: chp-host restart") + else: + print(f" → to register: chp-host onboard {repo} --module {module} --ops {','.join(ops)} " + f"--name {name} --register (or: pip install -e {pkg})") + return 0 + + # --------------------------------------------------------------------------- # chp-host mesh subcommands # --------------------------------------------------------------------------- @@ -1074,6 +1197,76 @@ def _cmd_mesh_list(args: argparse.Namespace) -> int: return 0 +def _cmd_mesh_verify_keys(args: argparse.Namespace) -> int: + """Fetch each remote's /host (authed), then trust-on-first-use its signing key. + A key that CHANGED from the pinned one is a hard error (exit 1).""" + from .mesh import load_mesh, mesh_path, pin_or_check_key + + remotes = load_mesh().get("agent_remotes") or [] + if not remotes: + print(f"No remotes in {mesh_path()}.") + return 0 + + print(f"{'URL':<32} {'Assurance':<12} {'Key':<18} {'Trust'}") + print("-" * 76) + bad = False + for r in remotes: + raw = r.get("url", "") + url = _resolve_mesh_url(raw) + key = os.environ.get(r.get("api_key_env", "") or "", "") + try: + req = urllib.request.Request(f"{url}/host", headers={"X-CHP-Key": key} if key else {}) + h = json.loads(urllib.request.urlopen(req, timeout=4).read().decode()) + except Exception as exc: + print(f"{url:<32} {'-':<12} {'-':<18} ✗ unreachable ({type(exc).__name__})") + continue + assurance = h.get("assurance", "none") + key_id = h.get("key_id") + if assurance != "signed" or not key_id: + print(f"{url:<32} {assurance:<12} {'(none)':<18} — no signing key") + continue + # Verify the self-signed host-identity attestation BEFORE pinning: the + # key must self-attest this host_id (chp-v0.2.md §3). A present-but-invalid + # attestation is a malformed/forged identity claim — refuse to pin it, + # rather than blindly trusting whatever /host reports. + att = h.get("host_identity") + if att is not None: + from chp_core import signing as _signing + from chp_core.types import utc_now as _utc_now + if not _signing.verify_attestation( + att, public_key=h.get("public_key"), + expected_host_id=h.get("id"), at_time=_utc_now(), + ): + bad = True + print(f"{url:<32} {assurance:<12} {key_id:<18} ✗ INVALID attestation — not pinned") + continue + status, detail = pin_or_check_key(raw, key_id, h.get("public_key")) + mark = {"pinned": "✓ pinned (TOFU)", "ok": "✓ trusted", + "mismatch": f"✗ CHANGED (was {detail})", "no-remote": "? not in manifest"}[status] + if status == "mismatch": + bad = True + print(f"{url:<32} {assurance:<12} {key_id:<18} {mark}") + + if bad: + print("\n⚠ A pinned key CHANGED. If this was a deliberate rotation, run " + "'chp-host mesh trust --url --reset-key'; otherwise investigate — " + "a changed key can mean impersonation.") + return 1 + return 0 + + +def _cmd_mesh_trust(args: argparse.Namespace) -> int: + from .mesh import reset_key + if args.reset_key: + if reset_key(args.url.rstrip("/")): + print(f"Cleared pinned key for {args.url}. Next 'mesh verify-keys' will re-pin (TOFU).") + return 0 + print(f"No remote {args.url} in the mesh manifest.", file=sys.stderr) + return 1 + print("Nothing to do — pass --reset-key.", file=sys.stderr) + return 1 + + def _cmd_mesh_remove(args: argparse.Namespace) -> int: from .mesh import remove_remote, mesh_path @@ -1695,6 +1888,21 @@ def build_parser() -> argparse.ArgumentParser: init_cmd.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompts.") init_cmd.set_defaults(func=_cmd_init) + onboard_cmd = sub.add_parser( + "onboard", + help="Onboard a codebase to CHP — scan, then wrap functions (Mode A) or hand off to your coding agent (Mode B).", + ) + onboard_cmd.add_argument("repo", nargs="?", help="Path to the codebase to onboard.") + onboard_cmd.add_argument("--module", help="Mode A: importable module whose functions to wrap.") + onboard_cmd.add_argument("--ops", help="Mode A: comma-separated function names to expose as capabilities.") + onboard_cmd.add_argument("--name", help="Mode A: adapter name (chp-adapter-).") + onboard_cmd.add_argument("--out", help="Output root for the generated package (default: /../_onboarded).") + onboard_cmd.add_argument("--register", action="store_true", + help="Mode A: on a clean conformance gate, pip install -e the generated adapter.") + onboard_cmd.add_argument("--detect-agents", action="store_true", + help="List the coding agents detected on PATH (Mode B handoff targets).") + onboard_cmd.set_defaults(func=_cmd_onboard) + mesh_cmd = sub.add_parser("mesh", help="Manage the mesh manifest (~/.chp/mesh.json).") mesh_sub = mesh_cmd.add_subparsers(dest="mesh_action", required=True) @@ -1716,6 +1924,16 @@ def build_parser() -> argparse.ArgumentParser: mesh_remove.add_argument("url", help="URL to remove.") mesh_remove.set_defaults(func=_cmd_mesh_remove) + mesh_verify_keys = mesh_sub.add_parser( + "verify-keys", help="Fetch each remote's /host assurance + signing key; pin on first use (TOFU).") + mesh_verify_keys.set_defaults(func=_cmd_mesh_verify_keys) + + mesh_trust = mesh_sub.add_parser("trust", help="Manage a remote's pinned signing key.") + mesh_trust.add_argument("--url", required=True) + mesh_trust.add_argument("--reset-key", action="store_true", + help="Clear the pinned key so the next verify re-pins (use after a deliberate rotation).") + mesh_trust.set_defaults(func=_cmd_mesh_trust) + mesh_revoke = mesh_sub.add_parser( "revoke", help="Remove a remote and delete its pre-shared key from the keychain.") mesh_revoke.add_argument("url", help="URL to revoke.") diff --git a/packages/chp-host/tests/test_mcp_server.py b/packages/chp-host/tests/test_mcp_server.py new file mode 100644 index 0000000..dee31c1 --- /dev/null +++ b/packages/chp-host/tests/test_mcp_server.py @@ -0,0 +1,56 @@ +"""Smoke tests for the CHP→MCP server surface (previously untested, 497 LOC). + +Covers the pure mapping helpers — capability→Tool, result formatting, status +filtering — without standing up the async MCP transport. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("mcp") + +from chp_host.mcp_server import ( # noqa: E402 + _build_name_index, + _cap_id_to_tool_name, + _filter_caps_by_status, + _format_result, + _make_tool, +) + + +def test_cap_id_to_tool_name_and_index_roundtrip(): + assert _cap_id_to_tool_name("chp.adapters.git.status") == "chp_adapters_git_status" + caps = [{"id": "chp.adapters.git.status"}, {"id": "chp.echo"}] + idx = _build_name_index(caps) + assert idx["chp_adapters_git_status"] == "chp.adapters.git.status" + assert idx["chp_echo"] == "chp.echo" + + +def test_make_tool_maps_risk_and_annotations(): + low = _make_tool({"id": "chp.read", "description": "Read.", "risk": "low"}) + assert low.name == "chp_read" + assert low.annotations.readOnlyHint is True + assert low.annotations.destructiveHint is False + + high = _make_tool({"id": "chp.deploy", "description": "Deploy.", "risk": "high"}) + assert "[risk:high]" in high.description + assert high.annotations.destructiveHint is True + assert high.annotations.readOnlyHint is False + # input schema defaulted when absent + assert high.inputSchema.get("type") == "object" + + +def test_format_result_carries_outcome_and_evidence(): + text = _format_result("denied", None, {"code": "policy_blocked"}, ["evt_1"]) + assert '"outcome": "denied"' in text + assert "policy_blocked" in text + assert "evt_1" in text + + +def test_filter_caps_by_status(): + caps = [{"id": "a", "status": "certified"}, {"id": "b", "status": "draft"}] + certified = _filter_caps_by_status(caps, "certified") + assert [c["id"] for c in certified] == ["a"] + # a low bar keeps everything + assert len(_filter_caps_by_status(caps, "draft")) == 2 diff --git a/packages/chp-sdk/package.json b/packages/chp-sdk/package.json new file mode 100644 index 0000000..7dd565a --- /dev/null +++ b/packages/chp-sdk/package.json @@ -0,0 +1,49 @@ +{ + "name": "@capabilityhostprotocol/sdk", + "version": "0.1.0-alpha.0", + "description": "Pure TypeScript client + verifier for the Capability Host Protocol (CHP) — canonicalization, signing, and the HTTP-binding client.", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "prepublishOnly": "npm run build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "keywords": [ + "chp", + "capability-host-protocol", + "evidence", + "ed25519", + "signing", + "typescript" + ], + "author": "Capability Host Protocol Contributors", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/capabilityhostprotocol/chp-core", + "directory": "packages/chp-sdk" + }, + "engines": { + "node": ">=18.0.0" + }, + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^2.1.9" + } +} diff --git a/packages/chp-sdk/src/canon.ts b/packages/chp-sdk/src/canon.ts new file mode 100644 index 0000000..87a1037 --- /dev/null +++ b/packages/chp-sdk/src/canon.ts @@ -0,0 +1,69 @@ +/** + * chp-stable-v1 canonicalization — the byte-exact serialization CHP hashes and + * signs over (spec/chp-v0.2.md §2). Lifted from spec/test-vectors/verify.mjs, + * which is proven byte-compatible with Python `json.dumps(sort_keys=True)`. + * + * This is the single most bug-prone piece of a CHP implementation. Do NOT + * replace it with `JSON.stringify` — that emits raw UTF-8, no inter-token + * spaces, and unsorted keys, all of which diverge from chp-stable-v1. + */ + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +/** Escape a string exactly as Python `json.dumps(..., ensure_ascii=True)` does. */ +export function encodeStr(s: string): string { + let out = '"'; + for (const ch of s) { + const c = ch.codePointAt(0)!; + if (ch === '"') out += '\\"'; + else if (ch === '\\') out += '\\\\'; + else if (c === 0x08) out += '\\b'; + else if (c === 0x09) out += '\\t'; + else if (c === 0x0a) out += '\\n'; + else if (c === 0x0c) out += '\\f'; + else if (c === 0x0d) out += '\\r'; + else if (c < 0x20) out += '\\u' + c.toString(16).padStart(4, '0'); + else if (c < 0x7f) out += ch; + else if (c <= 0xffff) out += '\\u' + c.toString(16).padStart(4, '0'); + else { + // astral code point → UTF-16 surrogate pair, lowercase hex + const cc = c - 0x10000; + const hi = 0xd800 + (cc >> 10); + const lo = 0xdc00 + (cc & 0x3ff); + out += '\\u' + hi.toString(16).padStart(4, '0') + '\\u' + lo.toString(16).padStart(4, '0'); + } + } + return out + '"'; +} + +/** + * Serialize a value to its chp-stable-v1 canonical string: recursively sorted + * keys, `", "` / `": "` separators, ASCII-escaped strings, integers bare. + * + * Throws on a non-integer number — chp-stable-v1 forbids floats in canonicalized + * content (§2 rule 6). Producers string-encode fractional values before hashing. + */ +export function canon(v: JsonValue): string { + if (v === null) return 'null'; + if (v === true) return 'true'; + if (v === false) return 'false'; + if (typeof v === 'number') { + if (!Number.isInteger(v)) { + throw new Error( + `chp-stable-v1 forbids non-integer numbers in canonicalized content: ${v} ` + + `(string-encode fractional values before hashing — spec §2 rule 6)`, + ); + } + return String(v); + } + if (typeof v === 'string') return encodeStr(v); + if (Array.isArray(v)) return '[' + v.map(canon).join(', ') + ']'; + const keys = Object.keys(v).sort(); + return '{' + keys.map((k) => encodeStr(k) + ': ' + canon(v[k])).join(', ') + '}'; +} diff --git a/packages/chp-sdk/src/chain.ts b/packages/chp-sdk/src/chain.ts new file mode 100644 index 0000000..5913cb4 --- /dev/null +++ b/packages/chp-sdk/src/chain.ts @@ -0,0 +1,40 @@ +/** + * Hash-chain verification: recompute each event's content_hash and confirm the + * prev_hash links form an unbroken chain (spec/chp-v0.2.md §2). + */ + +import { contentHash, rootHash, type EvidenceEvent } from './hash.js'; + +export interface ChainResult { + valid: boolean; + eventHashesOk: boolean; + rootOk: boolean; + firstBrokenSequence: number | null; +} + +/** Verify per-event hashes + chain continuity; optionally check a claimed root. */ +export function verifyChain(events: EvidenceEvent[], expectedRoot?: string): ChainResult { + let prev: string | null = null; + let eventHashesOk = true; + let firstBroken: number | null = null; + + for (let i = 0; i < events.length; i++) { + const ev = events[i]; + const storedHash = ev.content_hash ?? null; + const storedPrev = ev.prev_hash ?? null; + if (storedHash === null) { + eventHashesOk = false; + firstBroken = i; + break; + } + if (contentHash(ev, storedPrev) !== storedHash || storedPrev !== prev) { + eventHashesOk = false; + firstBroken = i; + break; + } + prev = storedHash; + } + + const rootOk = expectedRoot === undefined ? true : rootHash(events) === expectedRoot; + return { valid: eventHashesOk && rootOk, eventHashesOk, rootOk, firstBrokenSequence: firstBroken }; +} diff --git a/packages/chp-sdk/src/client.ts b/packages/chp-sdk/src/client.ts new file mode 100644 index 0000000..d37b51c --- /dev/null +++ b/packages/chp-sdk/src/client.ts @@ -0,0 +1,98 @@ +/** + * RemoteCapabilityHost — a client for the CHP HTTP binding (spec/chp-http-binding.md). + * Uses global `fetch` (Node ≥18). A *processed* invocation — including a denial — + * returns HTTP 200 with the verdict in the body; only transport failures throw. + */ + +import type { JsonValue } from './canon.js'; + +export interface InvocationResult { + invocation_id: string; + capability_id: string; + outcome: 'success' | 'failure' | 'denied' | 'skipped'; + success: boolean; + data?: JsonValue; + denial?: { code: string; message: string; retryable?: boolean } | null; + correlation?: JsonValue; + evidence_ids?: string[]; + [k: string]: JsonValue | undefined; +} + +export class RemoteCapabilityHost { + private readonly base: string; + private readonly apiKey?: string; + private readonly timeoutMs: number; + + constructor(baseUrl: string, opts: { apiKey?: string; timeoutMs?: number } = {}) { + this.base = baseUrl.replace(/\/+$/, ''); + this.apiKey = opts.apiKey; + this.timeoutMs = opts.timeoutMs ?? 30_000; + } + + private headers(json = false): Record { + const h: Record = {}; + if (json) h['Content-Type'] = 'application/json'; + if (this.apiKey) h['X-CHP-Key'] = this.apiKey; + return h; + } + + private async req(path: string, init?: RequestInit): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), this.timeoutMs); + try { + const resp = await fetch(`${this.base}${path}`, { ...init, signal: ctrl.signal }); + const text = await resp.text(); + if (!resp.ok) { + // non-2xx = transport-level (bad json / auth / route), NOT a CHP outcome + throw new Error(`CHP remote ${resp.status} on ${path}: ${text.slice(0, 300)}`); + } + return text ? (JSON.parse(text) as JsonValue) : null; + } finally { + clearTimeout(t); + } + } + + async health(): Promise { + return this.req('/health'); + } + + async discover(): Promise> { + return this.req('/host', { headers: this.headers() }) as Promise>; + } + + async capabilities(): Promise { + const r = (await this.req('/capabilities', { headers: this.headers() })) as { capabilities?: JsonValue[] }; + return r.capabilities ?? []; + } + + async invoke( + capabilityId: string, + payload: JsonValue = {}, + opts: { correlation?: JsonValue; subject?: JsonValue; mode?: string; version?: string } = {}, + ): Promise { + const body: Record = { + capability_id: capabilityId, + payload, + mode: opts.mode ?? 'sync', + correlation: opts.correlation ?? {}, + subject: opts.subject ?? { id: 'remote', type: 'user' }, + }; + if (opts.version) body.version = opts.version; + return this.req('/invoke', { + method: 'POST', + headers: this.headers(true), + body: JSON.stringify(body), + }) as Promise; + } + + async replay(correlationId: string): Promise { + const r = (await this.req(`/replay/${encodeURIComponent(correlationId)}`, { + headers: this.headers(), + })) as { events?: JsonValue[] }; + return r.events ?? []; + } + + async verify(correlationId: string): Promise { + return this.req(`/verify/${encodeURIComponent(correlationId)}`, { headers: this.headers() }); + } +} diff --git a/packages/chp-sdk/src/hash.ts b/packages/chp-sdk/src/hash.ts new file mode 100644 index 0000000..879ba24 --- /dev/null +++ b/packages/chp-sdk/src/hash.ts @@ -0,0 +1,51 @@ +/** + * Event content hashing + bundle root hash (spec/chp-v0.2.md §2). + * The stable-field set and ordering match the Python reference exactly. + */ + +import { createHash } from 'node:crypto'; +import { canon, type JsonValue } from './canon.js'; + +export interface EvidenceEvent { + event_id: string; + event_type: string; + invocation_id: string; + capability_id: string; + host_id: string; + correlation?: { correlation_id?: string | null; [k: string]: JsonValue | undefined }; + timestamp: string; + outcome?: string | null; + payload?: JsonValue; + content_hash?: string; + prev_hash?: string | null; + [k: string]: unknown; +} + +const sha256hex = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex'); + +/** SHA-256 over the canonical stable fields of an event (+ prev_hash). */ +export function contentHash(ev: EvidenceEvent, prevHash: string | null): string { + const corr = ev.correlation ?? {}; + const stable: JsonValue = { + event_id: ev.event_id, + event_type: ev.event_type, + invocation_id: ev.invocation_id, + capability_id: ev.capability_id, + host_id: ev.host_id, + correlation_id: (typeof corr === 'object' && corr ? (corr.correlation_id ?? null) : null) as JsonValue, + timestamp: ev.timestamp, + outcome: ev.outcome ?? null, + payload: ev.payload ?? {}, + prev_hash: prevHash ?? null, + }; + return sha256hex(canon(stable)); +} + +/** Root hash = SHA-256 over each event's content_hash, each followed by "\n". */ +export function rootHash(events: EvidenceEvent[]): string { + const h = createHash('sha256'); + for (const ev of events) { + h.update((ev.content_hash ?? '') + '\n'); + } + return h.digest('hex'); +} diff --git a/packages/chp-sdk/src/index.ts b/packages/chp-sdk/src/index.ts new file mode 100644 index 0000000..8844e95 --- /dev/null +++ b/packages/chp-sdk/src/index.ts @@ -0,0 +1,29 @@ +/** + * @capabilityhostprotocol/sdk — a pure TypeScript client + verifier for the + * Capability Host Protocol. No server; Node ≥18, `node:crypto` only. + * + * The canonicalization and signing here are byte-compatible with the Python + * reference and validated against spec/test-vectors/ — this is the second + * implementation that proves CHP is a protocol, not a Python detail. + * + * @packageDocumentation + */ + +export { canon, encodeStr, type JsonValue } from './canon.js'; +export { contentHash, rootHash, type EvidenceEvent } from './hash.js'; +export { verifyChain, type ChainResult } from './chain.js'; +export { + CANONICALIZATION, + SIGNATURE_ALGORITHM, + type HostKey, + keyIdFor, + publicKeyFromB64, + keypairFromSeed, + generateKeypair, + bundleHeader, + buildAttestation, + buildBundle, + signBundle, +} from './signing.js'; +export { verifyBundle, type BundleVerification } from './verify.js'; +export { RemoteCapabilityHost, type InvocationResult } from './client.js'; diff --git a/packages/chp-sdk/src/signing.ts b/packages/chp-sdk/src/signing.ts new file mode 100644 index 0000000..2b58dea --- /dev/null +++ b/packages/chp-sdk/src/signing.ts @@ -0,0 +1,128 @@ +/** + * ed25519 signing for CHP evidence bundles (spec/chp-v0.2.md §3). + * + * Uses only `node:crypto`. Ed25519 raw keys are wrapped in DER (SPKI for public, + * PKCS8 for private seed) — the same wrapping proven in verify.mjs. Signs the + * canonical bundle HEADER (not bare root_hash) and attaches a self-signed + * host-identity attestation. + */ + +import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as edSign, type KeyObject } from 'node:crypto'; +import { canon, type JsonValue } from './canon.js'; +import { rootHash, type EvidenceEvent } from './hash.js'; + +export const CANONICALIZATION = 'chp-stable-v1'; +export const SIGNATURE_ALGORITHM = 'ed25519'; + +// DER wrappers for raw 32-byte ed25519 keys. +const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); // + 32-byte public key +const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); // + 32-byte seed + +export interface HostKey { + keyId: string; + publicKeyB64: string; + privateKey?: KeyObject; // absent → verify-only +} + +const sha256 = (b: Buffer): Buffer => createHash('sha256').update(b).digest(); + +/** key_id = first 16 hex chars of SHA-256(raw public key). */ +export function keyIdFor(rawPublicKey: Buffer): string { + return sha256(rawPublicKey).toString('hex').slice(0, 16); +} + +export function publicKeyFromB64(b64: string): KeyObject { + const raw = Buffer.from(b64, 'base64'); + return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' }); +} + +function privateKeyFromSeed(seed: Buffer): KeyObject { + return createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, seed]), format: 'der', type: 'pkcs8' }); +} + +/** Last 32 bytes of a public key's SPKI DER export = the raw ed25519 public key. */ +function rawPublicOf(pub: KeyObject): Buffer { + const der = pub.export({ format: 'der', type: 'spki' }) as Buffer; + return der.subarray(-32); +} + +/** Deterministic keypair from a 32-byte seed (used for test vectors). */ +export function keypairFromSeed(seed: Buffer): HostKey { + const priv = privateKeyFromSeed(seed); + const rawPub = rawPublicOf(createPublicKey(priv)); + return { keyId: keyIdFor(rawPub), publicKeyB64: rawPub.toString('base64'), privateKey: priv }; +} + +/** Fresh random keypair. */ +export function generateKeypair(): HostKey { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const rawPub = rawPublicOf(publicKey); + return { keyId: keyIdFor(rawPub), publicKeyB64: rawPub.toString('base64'), privateKey }; +} + +function signCanon(priv: KeyObject, obj: JsonValue): string { + return edSign(null, Buffer.from(canon(obj), 'utf8'), priv).toString('base64'); +} + +const HEADER_FIELDS = ['host_id', 'protocol_version', 'created_at', 'canonicalization', 'root_hash'] as const; + +export function bundleHeader(bundle: Record): JsonValue { + const h: Record = {}; + for (const f of HEADER_FIELDS) h[f] = bundle[f] ?? null; + return h; +} + +export function buildAttestation( + hostId: string, + key: HostKey, + validFrom: string, + validUntil: string | null = null, +): JsonValue { + if (!key.privateKey) throw new Error('host key has no private component; cannot attest'); + const claim: JsonValue = { + host_id: hostId, + public_key: key.publicKeyB64, + key_id: key.keyId, + valid_from: validFrom, + valid_until: validUntil, + }; + return { ...(claim as object), signature: signCanon(key.privateKey, claim) } as JsonValue; +} + +export function buildBundle( + hostId: string, + events: EvidenceEvent[], + createdAt: string, + protocolVersion = '0.2', +): Record { + return { + host_id: hostId, + protocol_version: protocolVersion, + created_at: createdAt, + canonicalization: CANONICALIZATION, + assurance: 'hash-chain', + events: events as unknown as JsonValue, + root_hash: rootHash(events), + }; +} + +export function signBundle( + bundle: Record, + key: HostKey, + opts: { validUntil?: string | null } = {}, +): Record { + if (!key.privateKey) throw new Error('host key has no private component; cannot sign'); + const signed: Record = { ...bundle, assurance: 'signed', public_key: key.publicKeyB64 }; + signed.host_identity = buildAttestation( + signed.host_id as string, + key, + (signed.created_at as string) ?? '', + opts.validUntil ?? null, + ); + signed.signature = { + algorithm: SIGNATURE_ALGORITHM, + key_id: key.keyId, + signature: signCanon(key.privateKey, bundleHeader(signed)), + }; + return signed; +} diff --git a/packages/chp-sdk/src/verify.ts b/packages/chp-sdk/src/verify.ts new file mode 100644 index 0000000..5221ba3 --- /dev/null +++ b/packages/chp-sdk/src/verify.ts @@ -0,0 +1,76 @@ +/** + * Offline verification of a CHP evidence bundle (spec/chp-v0.2.md §3): + * per-event hashes, chain continuity, root hash, the header signature, and the + * host-identity attestation (binding + temporal validity). Library form of + * spec/test-vectors/verify.mjs. + */ + +import { verify as edVerify } from 'node:crypto'; +import { canon, type JsonValue } from './canon.js'; +import { rootHash, type EvidenceEvent } from './hash.js'; +import { verifyChain } from './chain.js'; +import { bundleHeader, publicKeyFromB64 } from './signing.js'; + +export interface BundleVerification { + valid: boolean; + assurance: string; + checks: Record; + reason?: string; +} + +function verifyCanon(pubB64: string, obj: JsonValue, sigB64: string): boolean { + return edVerify(null, Buffer.from(canon(obj), 'utf8'), publicKeyFromB64(pubB64), Buffer.from(sigB64, 'base64')); +} + +export function verifyBundle( + bundle: Record, + opts: { expectedKeyId?: string } = {}, +): BundleVerification { + const checks: Record = {}; + const events = (bundle.events as EvidenceEvent[] | undefined) ?? []; + + const chain = verifyChain(events); + checks.event_hashes = chain.eventHashesOk; + checks.root_hash = bundle.root_hash === rootHash(events); + + const assurance = (bundle.assurance as string) ?? 'none'; + + if (assurance === 'signed') { + const sig = bundle.signature as { key_id?: string; signature?: string } | undefined; + const pub = bundle.public_key as string | undefined; + if (!sig || !sig.signature) return { valid: false, assurance, checks, reason: 'signed bundle missing signature' }; + if (!pub) return { valid: false, assurance, checks, reason: 'signed bundle missing public_key' }; + if (opts.expectedKeyId !== undefined && sig.key_id !== opts.expectedKeyId) { + return { valid: false, assurance, checks, reason: `signed by unexpected key ${sig.key_id}` }; + } + checks.signature = verifyCanon(pub, bundleHeader(bundle), sig.signature); + + const att = bundle.host_identity as Record | undefined; + if (att) { + const claim: JsonValue = { + host_id: att.host_id, + public_key: att.public_key, + key_id: att.key_id, + valid_from: att.valid_from, + valid_until: att.valid_until, + }; + const created = bundle.created_at as string | null; + const vf = att.valid_from as string | null; + const vu = att.valid_until as string | null; + const temporalOk = + (vf === null || created === null || vf <= created) && + (vu === null || created === null || created <= vu); + checks.host_identity = + att.host_id === bundle.host_id && + att.public_key === pub && + temporalOk && + verifyCanon(pub, claim, att.signature as string); + } + } + + const valid = Object.values(checks).every(Boolean); + const reason = valid + ? undefined + : 'failed checks: ' + Object.entries(checks).filter(([, v]) => !v).map(([k]) => k).join(', '); + return { valid, assurance, checks, reason }; +} diff --git a/packages/chp-sdk/test/canon.test.ts b/packages/chp-sdk/test/canon.test.ts new file mode 100644 index 0000000..344bc08 --- /dev/null +++ b/packages/chp-sdk/test/canon.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { canon, type JsonValue } from '../src/canon.js'; + +const vectorsDir = fileURLToPath(new URL('../../../spec/test-vectors/', import.meta.url)); + +describe('chp-stable-v1 canon() vs the published golden set', () => { + const golden = JSON.parse(readFileSync(vectorsDir + 'canon/cases.json', 'utf8')) as { + cases: { name: string; input: JsonValue; expected_canon: string }[]; + }; + + for (const c of golden.cases) { + it(`reproduces ${c.name} byte-for-byte`, () => { + expect(canon(c.input)).toBe(c.expected_canon); + }); + } + + it('throws on a non-integer number (chp-stable-v1 §2 rule 6)', () => { + expect(() => canon(0.5 as JsonValue)).toThrow(/non-integer/); + }); +}); diff --git a/packages/chp-sdk/test/roundtrip.test.ts b/packages/chp-sdk/test/roundtrip.test.ts new file mode 100644 index 0000000..4e6cc7c --- /dev/null +++ b/packages/chp-sdk/test/roundtrip.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { keypairFromSeed, buildBundle, signBundle } from '../src/signing.js'; +import { verifyBundle } from '../src/verify.js'; +import type { EvidenceEvent } from '../src/hash.js'; +import type { JsonValue } from '../src/canon.js'; + +const dir = fileURLToPath(new URL('../../../spec/test-vectors/', import.meta.url)); +const load = (f: string) => JSON.parse(readFileSync(dir + f, 'utf8')); + +// The published vectors were signed with this exact seed (bytes 0..31). +const SEED = Buffer.from(Array.from({ length: 32 }, (_, i) => i)); + +describe('cross-language interop (TS ↔ Python)', () => { + it('derives the same public key + key_id as Python', () => { + const expected = load('expected.json'); + const key = keypairFromSeed(SEED); + expect(key.publicKeyB64).toBe(expected.public_key_b64); + expect(key.keyId).toBe(load('signed-bundle.json').signature.key_id); + }); + + it('produces a byte-identical signature to the Python-signed bundle', () => { + const original = load('signed-bundle.json') as Record; + const key = keypairFromSeed(SEED); + const rebuilt = signBundle( + buildBundle( + original.host_id as string, + original.events as EvidenceEvent[], + original.created_at as string, + original.protocol_version as string, + ), + key, + ); + // Same header signature AND same self-attestation signature as Python. + expect((rebuilt.signature as { signature: string }).signature).toBe( + (original.signature as { signature: string }).signature, + ); + expect((rebuilt.host_identity as { signature: string }).signature).toBe( + (original.host_identity as { signature: string }).signature, + ); + }); + + it('round-trips a freshly TS-signed bundle', () => { + const key = keypairFromSeed(SEED); + const events = load('signed-bundle.json').events as EvidenceEvent[]; + const signed = signBundle(buildBundle('ts-host', events, '2026-07-05T00:00:00Z'), key); + expect(verifyBundle(signed).valid).toBe(true); + expect(verifyBundle(signed, { expectedKeyId: key.keyId }).valid).toBe(true); + expect(verifyBundle(signed, { expectedKeyId: 'deadbeefdeadbeef' }).valid).toBe(false); + }); +}); diff --git a/packages/chp-sdk/test/vectors.test.ts b/packages/chp-sdk/test/vectors.test.ts new file mode 100644 index 0000000..3823e0a --- /dev/null +++ b/packages/chp-sdk/test/vectors.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { contentHash, type EvidenceEvent } from '../src/hash.js'; +import { verifyBundle } from '../src/verify.js'; +import type { JsonValue } from '../src/canon.js'; + +const dir = fileURLToPath(new URL('../../../spec/test-vectors/', import.meta.url)); +const load = (f: string) => JSON.parse(readFileSync(dir + f, 'utf8')); + +describe('published test vectors', () => { + it('recomputes the single-event content_hash', () => { + const expected = load('expected.json'); + const ev = load('event.json').event as EvidenceEvent; + expect(contentHash(ev, null)).toBe(expected.event_content_hash); + }); + + it('verifies the Python-signed echo bundle', () => { + const bundle = load('signed-bundle.json') as Record; + const v = verifyBundle(bundle); + expect(v.valid).toBe(true); + expect(v.checks.signature).toBe(true); + expect(v.checks.host_identity).toBe(true); + }); + + it('verifies the Python-signed GOVERNED bundle (string-encoded score)', () => { + const bundle = load('governance-bundle.json') as Record; + expect(verifyBundle(bundle).valid).toBe(true); + }); + + it('rejects a tampered event payload', () => { + const bundle = load('signed-bundle.json') as Record; + (bundle.events as EvidenceEvent[])[0].payload = { note: 'TAMPERED' }; + expect(verifyBundle(bundle).valid).toBe(false); + }); + + it('rejects a relabelled host_id', () => { + const bundle = load('signed-bundle.json') as Record; + bundle.host_id = 'prod-gateway-acme'; + const v = verifyBundle(bundle); + expect(v.valid).toBe(false); + expect(v.checks.signature).toBe(false); + }); +}); diff --git a/packages/chp-sdk/tsconfig.json b/packages/chp-sdk/tsconfig.json new file mode 100644 index 0000000..0a120d1 --- /dev/null +++ b/packages/chp-sdk/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist" + }, + "include": ["src", "test"] +} diff --git a/packages/python/chp_core/__init__.py b/packages/python/chp_core/__init__.py index 8aec7b6..104a9c9 100644 --- a/packages/python/chp_core/__init__.py +++ b/packages/python/chp_core/__init__.py @@ -29,6 +29,7 @@ register_codex_observation_capabilities, ) from .otel import evidence_to_otel_span, replay_to_otel_spans +from .prov import replay_to_prov from .metrics import aggregate_session_metrics, format_prometheus from .certification import assess_maturity from .memory import MemoryCapability, register_memory_capability @@ -223,6 +224,7 @@ "register_hosted_capabilities", "register_trace_execution", "replay_to_otel_spans", + "replay_to_prov", "serve_http", "AgentSession", "AUTONOMY_EVIDENCE_TYPES", diff --git a/packages/python/chp_core/agent_interface.py b/packages/python/chp_core/agent_interface.py index c1b64ce..e576258 100644 --- a/packages/python/chp_core/agent_interface.py +++ b/packages/python/chp_core/agent_interface.py @@ -1,7 +1,8 @@ -"""Agent interface serialization for CHP v0.5.0 (§7.2). +"""Agent interface serialization for CHP. Converts CapabilityDescriptor objects into tool call formats used by -AI agent frameworks (Anthropic and OpenAI). +AI agent frameworks (Anthropic and OpenAI). Cost/safety hints on the +descriptor are described in spec/chp-governance-v0.2.md. """ from __future__ import annotations diff --git a/packages/python/chp_core/cli/__init__.py b/packages/python/chp_core/cli/__init__.py index d67e639..566e79e 100644 --- a/packages/python/chp_core/cli/__init__.py +++ b/packages/python/chp_core/cli/__init__.py @@ -7,9 +7,12 @@ from ..work import DEFAULT_WORK_STORE from ._core import ( cmd_demo_endpoint, + cmd_export_evidence, cmd_host, cmd_invoke, + cmd_keygen, cmd_replay, + cmd_retention_apply, cmd_serve_demo, cmd_validate_contract, cmd_verify_evidence, @@ -51,6 +54,7 @@ cmd_session_list, cmd_session_metrics_report, cmd_session_otel, + cmd_session_prov, cmd_session_replay, cmd_session_retrieval_report, cmd_session_show, @@ -459,6 +463,11 @@ def build_parser() -> argparse.ArgumentParser: session_otel_p.add_argument("--dry-run", action="store_true", help="Print spans as JSON instead of exporting.") session_otel_p.set_defaults(func=cmd_session_otel) + session_prov_p = session_sub.add_parser("prov", help="Export a session as W3C PROV-JSON (signed, governed provenance).") + session_prov_p.add_argument("session_id") + session_prov_p.add_argument("--store", default=None) + session_prov_p.set_defaults(func=cmd_session_prov) + session_export_p = session_sub.add_parser("export", help="Export a session as a portable JSON bundle.") session_export_p.add_argument("session_id") session_export_p.add_argument("--store", default=None) @@ -596,11 +605,39 @@ def build_parser() -> argparse.ArgumentParser: delegation_show_p.add_argument("--store", default=None) delegation_show_p.set_defaults(func=cmd_delegation_show) - verify_p = subcommands.add_parser("verify-evidence", help="Verify the SHA256 hash chain for a session.") - verify_p.add_argument("session_id") + verify_p = subcommands.add_parser("verify-evidence", help="Verify an evidence chain (strict) or a signed bundle.") + verify_p.add_argument("session_id", nargs="?", help="Correlation/session ID to chain-verify.") verify_p.add_argument("--store", default=None) + verify_p.add_argument("--lenient", action="store_true", help="Tolerate legacy unhashed events (default: strict).") + verify_p.add_argument("--bundle", default=None, metavar="PATH", help="Verify an exported bundle JSON instead.") + verify_p.add_argument("--expect-key", default=None, dest="expect_key", metavar="KEY_ID", + help="Pin the signer: reject a bundle signed by any other key.") verify_p.set_defaults(func=cmd_verify_evidence) + retention_p = subcommands.add_parser("retention", help="Apply evidence retention (chain-preserving).") + retention_sub = retention_p.add_subparsers(dest="retention_command", required=True) + retention_apply_p = retention_sub.add_parser("apply", help="Apply retention policies from a config file.") + retention_apply_p.add_argument("--config", required=True, metavar="PATH") + retention_apply_p.add_argument("--store", default=None) + retention_apply_p.add_argument("--dry-run", action="store_true") + retention_apply_p.add_argument("--vacuum", action="store_true", help="Compact the store after pruning.") + retention_apply_p.set_defaults(func=cmd_retention_apply) + + keygen_p = subcommands.add_parser("keygen", help="Generate a host ed25519 signing keypair.") + keygen_p.add_argument("--key-dir", default=None, dest="key_dir", metavar="DIR") + keygen_p.add_argument("--overwrite", action="store_true", help="Replace an existing key.") + keygen_p.set_defaults(func=cmd_keygen) + + export_p = subcommands.add_parser("export-evidence", help="Export a correlation as a (signed) evidence bundle.") + export_p.add_argument("session_id", help="Correlation/session ID to export.") + export_p.add_argument("--store", default=None) + export_p.add_argument("--out", default=None, metavar="PATH", help="Write bundle to a file.") + export_p.add_argument("--host-id", default="local-chp-host", dest="host_id") + export_p.add_argument("--key-dir", default=None, dest="key_dir", metavar="DIR") + export_p.add_argument("--sign", action="store_true", help="Require signing (error if no key).") + export_p.add_argument("--no-sign", action="store_true", help="Export unsigned (hash-chain tier).") + export_p.set_defaults(func=cmd_export_evidence) + # --- policy group --- policy_p = subcommands.add_parser("policy", help="Validate and lint CHP policy files.") policy_sub = policy_p.add_subparsers(dest="policy_command", required=True) diff --git a/packages/python/chp_core/cli/_core.py b/packages/python/chp_core/cli/_core.py index 0752a3c..b7653a9 100644 --- a/packages/python/chp_core/cli/_core.py +++ b/packages/python/chp_core/cli/_core.py @@ -4,6 +4,7 @@ import argparse import json +import os import threading from typing import Any from urllib.request import Request, urlopen @@ -220,12 +221,31 @@ def cmd_validate_contract(args: argparse.Namespace) -> int: def cmd_verify_evidence(args: argparse.Namespace) -> int: import sys + + # Bundle mode: verify an exported (optionally signed) evidence bundle offline. + if getattr(args, "bundle", None): + from .. import signing + + with open(args.bundle) as fh: + bundle = json.load(fh) + v = signing.verify_bundle(bundle, expected_key_id=getattr(args, "expect_key", None)) + print(json.dumps({ + "assurance": v.assurance, + "valid": v.valid, + "checks": v.checks, + "reason": v.reason, + }, indent=2)) + return 0 if v.valid else 1 + from ..store import SQLiteEvidenceStore + # Chain mode: strict by default at the CLI (an unhashed/legacy event fails); + # --lenient restores the tolerant library default. + strict = not getattr(args, "lenient", False) store_path = _resolve_store(args.store) store = SQLiteEvidenceStore(store_path) try: - result = store.verify_chain(args.session_id) + result = store.verify_chain(args.session_id, strict=strict) finally: store.close() @@ -235,6 +255,7 @@ def cmd_verify_evidence(args: argparse.Namespace) -> int: "verified_count": result.verified_count, "unverified_count": result.unverified_count, "valid": result.valid, + "strict": strict, "first_broken_sequence": result.first_broken_sequence, } print(json.dumps(output, indent=2)) @@ -244,6 +265,115 @@ def cmd_verify_evidence(args: argparse.Namespace) -> int: return 0 +def cmd_keygen(args: argparse.Namespace) -> int: + import sys + from .. import signing + + try: + key = signing.generate_keypair( + args.key_dir or signing.DEFAULT_KEY_DIR, overwrite=args.overwrite + ) + except signing.SigningUnavailable as exc: + print(str(exc), file=sys.stderr) + return 2 + except FileExistsError as exc: + print(f"{exc}", file=sys.stderr) + return 1 + print(json.dumps({ + "key_id": key.key_id, + "public_key": key.public_key_b64, + "key_dir": str(args.key_dir or signing.DEFAULT_KEY_DIR), + }, indent=2)) + return 0 + + +def cmd_export_evidence(args: argparse.Namespace) -> int: + import sys + from .. import signing + from ..store import SQLiteEvidenceStore + from ..types import utc_now + + store_path = _resolve_store(args.store) + store = SQLiteEvidenceStore(store_path) + try: + events = store.export_correlation(args.session_id) + finally: + store.close() + if not events: + print(f"no events for correlation {args.session_id!r}", file=sys.stderr) + return 1 + + bundle = signing.build_bundle(args.host_id, events, created_at=utc_now()) + # Sign if a key is present (or explicitly requested); otherwise hash-chain tier. + key = signing.load_host_key(args.key_dir or signing.DEFAULT_KEY_DIR) + if args.sign and key is None: + print("--sign requested but no host key found; run `chp keygen`", file=sys.stderr) + return 2 + if key is not None and key.can_sign and (args.sign or not args.no_sign): + bundle = signing.sign_bundle(bundle, key) + + text = json.dumps(bundle, indent=2) + if args.out: + with open(args.out, "w") as fh: + fh.write(text) + print(json.dumps({"out": args.out, "assurance": bundle["assurance"], + "events": len(events), "root_hash": bundle["root_hash"]}, indent=2)) + else: + print(text) + return 0 + + +def cmd_retention_apply(args: argparse.Namespace) -> int: + """Apply retention policies to an evidence store (chain-preserving), then + optionally compact. Config JSON: {"retain_days":30, "stores":[...], + "policies":[{"policy_id","retain_days","applies_to",...}]}.""" + import sys + from ..store import SQLiteEvidenceStore + from ..compliance import SQLiteComplianceManager + from ..types import RetentionPolicy + + with open(args.config) as fh: + cfg = json.load(fh) + + if cfg.get("policies"): + policies = [RetentionPolicy(**p) for p in cfg["policies"]] + else: + policies = [RetentionPolicy( + policy_id="default", applies_to=["*"], + retain_days=int(cfg.get("retain_days", 30)), + redact_payload_after_days=cfg.get("redact_payload_after_days"), + )] + stores = cfg.get("stores") or ([_resolve_store(args.store)]) + + results = [] + for store_path in stores: + store_path = os.path.expanduser(store_path) + store = SQLiteEvidenceStore(store_path) + try: + if args.dry_run: + # Report what WOULD be pruned without mutating. + with store._lock: + row = store._conn.execute("SELECT COUNT(*) AS c FROM evidence_events").fetchone() + results.append({"store": store_path, "dry_run": True, "events": int(row["c"])}) + continue + report = SQLiteComplianceManager(store).apply_retention(policies) + entry = {"store": store_path, "purged": report.events_purged, + "redacted": report.events_redacted, "inspected": report.events_inspected} + if args.vacuum: + # VACUUM reclaims freed pages. Plain VACUUM needs a full-size temp + # copy (no headroom at 97% disk); callers on a tight disk should + # move archives off-box first — deletes already stop growth. + with store._lock: + store._conn.execute("VACUUM") + entry["vacuumed"] = True + results.append(entry) + finally: + store.close() + + print(json.dumps({"results": results}, indent=2)) + return 0 + + def request_json(method: str, url: str, body: JSON | None = None) -> JSON: data = None if body is None else json.dumps(body).encode("utf-8") request = Request( diff --git a/packages/python/chp_core/cli/_session.py b/packages/python/chp_core/cli/_session.py index 3e7721a..652ef2c 100644 --- a/packages/python/chp_core/cli/_session.py +++ b/packages/python/chp_core/cli/_session.py @@ -173,7 +173,9 @@ def cmd_session_otel(args: argparse.Namespace) -> int: store_path = _resolve_store(args.store) store = SQLiteEvidenceStore(store_path) try: - events = store.by_correlation(args.session_id) + # export_correlation attaches content_hash/prev_hash so the OTel spans + # carry the tamper-evidence anchor (signed OTel, not plain OTel). + events = store.export_correlation(args.session_id) finally: store.close() @@ -196,6 +198,28 @@ def cmd_session_otel(args: argparse.Namespace) -> int: return 1 +def cmd_session_prov(args: argparse.Namespace) -> int: + import sys + from ..prov import replay_to_prov + from ..store import SQLiteEvidenceStore + + store_path = _resolve_store(args.store) + store = SQLiteEvidenceStore(store_path) + try: + # export_correlation attaches content_hash so the PROV entities carry the + # tamper-evidence anchor (signed, governed provenance — not plain PROV). + events = store.export_correlation(args.session_id) + finally: + store.close() + + if not events: + print(f"No events found for session: {args.session_id}", file=sys.stderr) + return 1 + + print(json.dumps(replay_to_prov(events), indent=2)) + return 0 + + def cmd_session_autonomy_report(args: argparse.Namespace) -> int: from ..store import SQLiteEvidenceStore from ..types import AUTONOMY_EVIDENCE_TYPES @@ -251,7 +275,9 @@ def cmd_session_retrieval_report(args: argparse.Namespace) -> int: for e in completed if (e.get("payload") or {}).get("latency_ms") is not None ] - avg_latency = round(sum(latencies) / len(latencies), 2) if latencies else None + # latencies are string-encoded in hashed evidence (chp-stable-v1 §2 forbids + # floats); coerce back to float for the (non-hashed) report aggregate. + avg_latency = round(sum(float(x) for x in latencies) / len(latencies), 2) if latencies else None print_json({ "session_id": args.session_id, @@ -285,7 +311,9 @@ def cmd_session_ingestion_report(args: argparse.Namespace) -> int: for e in completed if (e.get("payload") or {}).get("latency_ms") is not None ] - avg_latency = round(sum(latencies) / len(latencies), 2) if latencies else None + # latencies are string-encoded in hashed evidence (chp-stable-v1 §2 forbids + # floats); coerce back to float for the (non-hashed) report aggregate. + avg_latency = round(sum(float(x) for x in latencies) / len(latencies), 2) if latencies else None print_json({ "session_id": args.session_id, @@ -323,7 +351,9 @@ def cmd_session_transformation_report(args: argparse.Namespace) -> int: for e in completed if (e.get("payload") or {}).get("latency_ms") is not None ] - avg_latency = round(sum(latencies) / len(latencies), 2) if latencies else None + # latencies are string-encoded in hashed evidence (chp-stable-v1 §2 forbids + # floats); coerce back to float for the (non-hashed) report aggregate. + avg_latency = round(sum(float(x) for x in latencies) / len(latencies), 2) if latencies else None print_json({ "session_id": args.session_id, @@ -357,7 +387,9 @@ def cmd_session_workflow_report(args: argparse.Namespace) -> int: for e in completed if (e.get("payload") or {}).get("total_duration_ms") is not None ] - avg_latency = round(sum(latencies) / len(latencies), 2) if latencies else None + # latencies are string-encoded in hashed evidence (chp-stable-v1 §2 forbids + # floats); coerce back to float for the (non-hashed) report aggregate. + avg_latency = round(sum(float(x) for x in latencies) / len(latencies), 2) if latencies else None print_json({ "session_id": args.session_id, @@ -425,7 +457,9 @@ def cmd_session_graph_report(args: argparse.Namespace) -> int: for e in query_events if (e.get("payload") or {}).get("latency_ms") is not None ] - avg_latency = round(sum(latencies) / len(latencies), 2) if latencies else None + # latencies are string-encoded in hashed evidence (chp-stable-v1 §2 forbids + # floats); coerce back to float for the (non-hashed) report aggregate. + avg_latency = round(sum(float(x) for x in latencies) / len(latencies), 2) if latencies else None print_json({ "session_id": args.session_id, diff --git a/packages/python/chp_core/compliance.py b/packages/python/chp_core/compliance.py index 5aeb350..facd912 100644 --- a/packages/python/chp_core/compliance.py +++ b/packages/python/chp_core/compliance.py @@ -48,13 +48,25 @@ def apply_retention(self, policies: list[RetentionPolicy]) -> ComplianceReport: cutoff = datetime.now(timezone.utc) - timedelta(days=policy.retain_days) cutoff_str = cutoff.isoformat().replace("+00:00", "Z") + # Prune WHOLE correlations whose newest event is past the + # cutoff — never individual events, which would leave a + # survivor's prev_hash pointing at a deleted row and break + # verify_chain. A fully-removed correlation can't split a + # chain. (ponytail: whole-correlation granularity; add a + # checkpoint table only if partial pruning is ever needed.) if pattern == "*": - sql = "DELETE FROM evidence_events WHERE timestamp < ?" + sql = ( + "DELETE FROM evidence_events WHERE correlation_id IN (" + " SELECT correlation_id FROM evidence_events " + " GROUP BY correlation_id HAVING MAX(timestamp) < ?)" + ) params: list = [cutoff_str] else: sql = ( - "DELETE FROM evidence_events " - "WHERE timestamp < ? AND capability_id GLOB ?" + "DELETE FROM evidence_events WHERE correlation_id IN (" + " SELECT correlation_id FROM evidence_events " + " GROUP BY correlation_id " + " HAVING MAX(timestamp) < ? AND SUM(capability_id GLOB ?) > 0)" ) params = [cutoff_str, pattern] cursor = self._store._conn.execute(sql, params) @@ -66,12 +78,16 @@ def apply_retention(self, policies: list[RetentionPolicy]) -> ComplianceReport: - timedelta(days=policy.redact_payload_after_days) ) redact_str = redact_cutoff.isoformat().replace("+00:00", "Z") - empty = "{}" + # Redaction rewrites the payload, so the stored content_hash + # no longer matches — NULL it (event becomes honestly + # `unverified` in verify_chain) rather than leaving a hash + # that fails as if tampered. if pattern == "*": sql = ( "UPDATE evidence_events " "SET payload_json = '{}', " - "event_json = json_set(event_json, '$.payload', json('{}')) " + "event_json = json_set(event_json, '$.payload', json('{}')), " + "content_hash = NULL " "WHERE timestamp < ? AND payload_json != '{}'" ) params = [redact_str] @@ -79,12 +95,12 @@ def apply_retention(self, policies: list[RetentionPolicy]) -> ComplianceReport: sql = ( "UPDATE evidence_events " "SET payload_json = '{}', " - "event_json = json_set(event_json, '$.payload', json('{}')) " + "event_json = json_set(event_json, '$.payload', json('{}')), " + "content_hash = NULL " "WHERE timestamp < ? AND capability_id GLOB ? " "AND payload_json != '{}'" ) params = [redact_str, pattern] - _ = empty # referenced only in the SQL literals above cursor = self._store._conn.execute(sql, params) total_redacted += cursor.rowcount diff --git a/packages/python/chp_core/events.py b/packages/python/chp_core/events.py index 09ef177..5d455b4 100644 --- a/packages/python/chp_core/events.py +++ b/packages/python/chp_core/events.py @@ -256,11 +256,6 @@ async def _emit_event(ctx, payload) -> dict: data: dict[str, Any] = payload.get("data") or {} correlation_id: str | None = payload.get("correlation_id") - ctx.emit( - "execution_started", - {"capability_id": emit_desc.id, "capability_version": version}, - redacted=False, - ) try: record = bus.emit_event( event_type, source, data, correlation_id=correlation_id @@ -271,7 +266,6 @@ async def _emit_event(ctx, payload) -> dict: {"error": str(exc), "operation": "emit"}, redacted=False, ) - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise ctx.emit( "domain_event_emitted", @@ -283,11 +277,6 @@ async def _emit_event(ctx, payload) -> dict: }, redacted=False, ) - ctx.emit( - "execution_completed", - {"capability_id": emit_desc.id, "outcome": "success"}, - redacted=False, - ) return record.to_dict() host.register(emit_desc, _emit_event) @@ -308,11 +297,6 @@ async def _query_events(ctx, payload) -> dict: source: str | None = payload.get("source") limit: int | None = payload.get("limit") - ctx.emit( - "execution_started", - {"capability_id": query_desc.id, "capability_version": version}, - redacted=False, - ) try: result = bus.query_events(event_type=event_type, source=source, limit=limit) except Exception as exc: @@ -321,7 +305,6 @@ async def _query_events(ctx, payload) -> dict: {"error": str(exc), "operation": "query"}, redacted=False, ) - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise ctx.emit( "domain_events_queried", @@ -332,11 +315,6 @@ async def _query_events(ctx, payload) -> dict: }, redacted=False, ) - ctx.emit( - "execution_completed", - {"capability_id": query_desc.id, "outcome": "success"}, - redacted=False, - ) return result.to_dict() host.register(query_desc, _query_events) diff --git a/packages/python/chp_core/host.py b/packages/python/chp_core/host.py index 30729b1..54feee4 100644 --- a/packages/python/chp_core/host.py +++ b/packages/python/chp_core/host.py @@ -4,6 +4,7 @@ import asyncio import inspect +import os import threading import traceback import warnings @@ -14,12 +15,14 @@ from .store import SQLiteEvidenceStore from .decorators import adapt_callable, get_capability_descriptor +from .policy import PolicyConfig, evaluate_policy, load_policy from .redaction import redact_payload from .types import ( AssuranceMetadata, AutonomyProfile, AUTONOMY_EVIDENCE_TYPES, CapabilityDescriptor, + CORE_EVIDENCE_TYPES, ConversationEvent, CorrelationContext, DenialReason, @@ -38,6 +41,25 @@ CapabilityHandler = Callable[["CapabilityExecutionContext", JSON], Any | Awaitable[Any]] +def _stringify_floats(value: Any) -> Any: + """Represent every float in an evidence payload as its string form. + + chp-stable-v1 (spec/chp-v0.2.md §2) forbids non-integer numbers in + canonicalized content: Python `json.dumps(0.0)` → `0.0` but an ECMAScript + `Number.toString` → `0`, so the same value would hash differently across + languages and silently break cross-language verification. `bool` (an `int` + subclass) and `int` pass through unchanged.""" + if isinstance(value, bool): + return value + if isinstance(value, float): + return repr(value) + if isinstance(value, dict): + return {k: _stringify_floats(v) for k, v in value.items()} + if isinstance(value, list): + return [_stringify_floats(v) for v in value] + return value + + @dataclass(slots=True) class RegisteredCapability: descriptor: CapabilityDescriptor @@ -69,6 +91,20 @@ def emit( outcome: str | None = None, redacted: bool = True, ) -> ExecutionEvidence: + # Host-owned lifecycle events (execution_started/completed/failed/ + # denied/skipped) are emitted by the host wrapper around every + # invocation. A capability emitting them too produces duplicate, + # outcome-less terminal events (the "outcome: unknown" bug). Warn so the + # capability gets migrated to a domain event, but still record it — + # silently dropping a downstream consumer's events is a breaking change. + # (A future major version may drop instead.) chp-core's own capabilities + # no longer emit these; the audit adapter is robust to any that remain. + if event_type in CORE_EVIDENCE_TYPES: + warnings.warn( + f"capability emitted host-reserved lifecycle event {event_type!r}; " + "the host owns these — emit a domain-specific event instead.", + stacklevel=2, + ) event = self.host.emit_evidence( event_type=event_type, envelope=self.envelope, @@ -89,11 +125,21 @@ async def ainvoke( *, subject: "JSON | None" = None, ) -> "InvocationResult": - """Invoke another capability governed through the host, propagating correlation.""" + """Invoke another capability governed through the host, propagating correlation. + + Records the causal edge: the child inherits the same correlation_id but + its ``causation_id`` points at THIS invocation, so the evidence stream is + a real call tree (group by invocation_id; child.causation_id == parent + invocation_id) — not just a flat sequence. Exports directly to OTel's + parent_span_id.""" + from dataclasses import replace + child_corr = replace( + self.envelope.correlation, causation_id=self.envelope.invocation_id + ) return await self.host.ainvoke( capability_id, payload, - correlation=self.envelope.correlation, + correlation=child_corr, subject=subject, ) @@ -113,6 +159,8 @@ def __init__( version: str = "0.1.0", store: SQLiteEvidenceStore | None = None, metadata: JSON | None = None, + policy: PolicyConfig | None = None, + safety_evaluator: Any = None, ) -> None: self.host_id = host_id self.version = version @@ -120,6 +168,14 @@ def __init__( self.metadata = metadata or {} self._capabilities: dict[str, RegisteredCapability] = {} self._registry_lock = threading.RLock() + # Governance: enforce policy on every invocation path (not just the + # Claude Code hook). None → load from CHP_POLICY_FILE/.chp/~/.chp; + # still None (no policy file) means no enforcement. + self.policy = policy if policy is not None else load_policy() + # Safety: when a RuleBasedSafetyEvaluator is configured, every invocation + # is assessed (assessment events emitted as evidence) and its guardrails + # enforced. None (default) = no safety gate — opt-in, like policy. + self.safety_evaluator = safety_evaluator def register( self, @@ -421,6 +477,26 @@ async def ainvoke_envelope(self, envelope: InvocationEnvelope | JSON) -> Invocat ), ) + # Governance gate — enforced on every invocation path (host.invoke, + # ctx.ainvoke, HTTP /invoke), not just the Claude Code hook. Blocks by + # allowlist / capability id / risk tier / input pattern. + if self.policy is not None: + verdict = evaluate_policy( + descriptor.id, + envelope.payload if isinstance(envelope.payload, dict) else {}, + self.policy, + capability_risk=descriptor.risk, + ) + if verdict.should_block: + return self._deny( + envelope, + DenialReason( + code="policy_blocked", + message=verdict.reason or "blocked by policy", + retryable=False, + ), + ) + invariant_denial = self._check_host_invariants(descriptor, envelope) if invariant_denial is not None: return self._deny(envelope, invariant_denial) @@ -457,6 +533,10 @@ async def ainvoke_envelope(self, envelope: InvocationEnvelope | JSON) -> Invocat ), ) + safety_denial = self._check_safety(descriptor, envelope) + if safety_denial is not None: + return self._deny(envelope, safety_denial) + started = self.emit_evidence( "execution_started", envelope, @@ -495,7 +575,14 @@ async def ainvoke_envelope(self, envelope: InvocationEnvelope | JSON) -> Invocat "type": exc.__class__.__name__, "error_type": exc.__class__.__name__, "message": str(exc), - "traceback": traceback.format_exc(), + # Full traceback is NOT persisted — it leaks local paths and + # variable reprs (incl. secrets) into the evidence store and + # /replay. Opt in for debugging only. + **( + {"traceback": traceback.format_exc()} + if os.environ.get("CHP_EVIDENCE_TRACEBACKS") == "1" + else {} + ), }, ) return InvocationResult( @@ -531,7 +618,15 @@ def emit_evidence( correlation=envelope.correlation, timestamp=utc_now(), outcome=outcome, # type: ignore[arg-type] - payload=redact_payload(payload or {}) if redacted else (payload or {}), + # chp-stable-v1 forbids floats in canonicalized (hashed) content — + # float serialization diverges across languages (Python 0.0 vs JS 0), + # which would silently break cross-language verification. Normalize any + # float to its string form at the single emission boundary so no + # emitter can produce unverifiable evidence. Non-hashed surfaces + # (InvocationResult.data, OTel attrs) keep the float. + payload=_stringify_floats( + redact_payload(payload or {}) if redacted else (payload or {}) + ), redacted=redacted, error=error, denial=denial, @@ -750,6 +845,64 @@ def _check_autonomy_budget( return None + def _emit_safety_event( + self, + event_type: str, + envelope: InvocationEnvelope, + descriptor: CapabilityDescriptor, + *, + detail: JSON | None = None, + ) -> ExecutionEvidence: + payload: JSON = {"capability_uri": descriptor.capability_uri} + if detail: + payload.update(detail) + outcome = "denied" if event_type == "safety_action_blocked" else None + return self.emit_evidence(event_type, envelope, payload=payload, outcome=outcome) + + def _check_safety( + self, + descriptor: CapabilityDescriptor, + envelope: InvocationEnvelope, + ) -> DenialReason | None: + """Assess the invocation and enforce safety guardrails, if an evaluator is + configured (chp-governance-v0.2.md §4.2). The assessment is ALWAYS recorded + as evidence (safety_assessment_started/completed) — a signed safety verdict + on every governed invocation is the differentiator; a guardrail block then + denies with the reserved 'safety_blocked' code. No evaluator → no-op.""" + evaluator = self.safety_evaluator + if evaluator is None: + return None + payload = envelope.payload if isinstance(envelope.payload, dict) else {} + self._emit_safety_event("safety_assessment_started", envelope, descriptor) + report = evaluator.report(descriptor.id, payload) + assessment = report.assessment + self._emit_safety_event( + "safety_assessment_completed", envelope, descriptor, + detail={"level": assessment.level, "score": assessment.score, + "approved": report.approved}, + ) + if not report.approved: + self._emit_safety_event( + "safety_guardrail_triggered", envelope, descriptor, + detail={"reason": report.block_reason, + "guardrails_evaluated": report.guardrails_evaluated}, + ) + self._emit_safety_event( + "safety_action_blocked", envelope, descriptor, + detail={"reason": report.block_reason}, + ) + return DenialReason( + code="safety_blocked", + message=report.block_reason or "blocked by safety guardrail", + retryable=False, + details={"level": assessment.level, "score": assessment.score}, + ) + self._emit_safety_event( + "safety_action_approved", envelope, descriptor, + detail={"level": assessment.level, "recommendation": assessment.recommendation}, + ) + return None + def _check_host_invariants( self, descriptor: CapabilityDescriptor, diff --git a/packages/python/chp_core/http.py b/packages/python/chp_core/http.py index 0c37dc7..35045af 100644 --- a/packages/python/chp_core/http.py +++ b/packages/python/chp_core/http.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import hmac import inspect import json import logging @@ -42,6 +43,32 @@ def _host_version() -> str: return "unknown" +def _host_assurance(host_id: str | None = None) -> JSON: + """Declared evidence assurance tier for this host (v0.2). `signed` when a + host keypair is present, else `hash-chain` (the store always chains). + Verifiers reject a lower-than-expected tier rather than degrade silently. + + A signed host also serves its self-signed `host_identity` attestation so a + mesh peer can verify the key self-attests this host_id *before* pinning it + (chp-v0.2.md §3) — not blindly trust whatever /host reports.""" + try: + from .signing import load_host_key + key = load_host_key() + except Exception: + key = None + if key is None: + return {"assurance": "hash-chain"} + out: JSON = {"assurance": "signed", "key_id": key.key_id, "public_key": key.public_key_b64} + if host_id and key.can_sign: + try: + from .signing import build_attestation + from .types import utc_now + out["host_identity"] = build_attestation(host_id, key, valid_from=utc_now()) + except Exception: + pass + return out + + class CapabilityHostHTTPServer(ThreadingHTTPServer): """Threading HTTP server bound to a CHP host (LocalCapabilityHost or MultiHostRouter).""" @@ -55,13 +82,42 @@ class CapabilityHostRequestHandler(BaseHTTPRequestHandler): server: CapabilityHostHTTPServer + # Slowloris guard: drop connections that stall mid-request rather than + # pinning a server thread indefinitely. BaseHTTPRequestHandler applies this + # as the per-request socket timeout. + timeout = 30 + + # Cap request bodies read into memory (DoS guard). Override via env. + _MAX_BODY_BYTES = int(os.environ.get("CHP_HOST_MAX_BODY_BYTES", str(8 * 1024 * 1024))) + def _check_auth(self) -> bool: - """Return True if the request is authorized (or auth is not configured).""" - key = os.environ.get("CHP_HOST_API_KEY") - if not key: - return True - if self.headers.get("X-CHP-Key") == key: - return True + """Return True if the request is authorized (or auth is not configured). + + Also records the *authenticated caller* on ``self._caller`` (a verified + principal name, or None for the anonymous shared-key / no-auth case) so + the invoke path can bind a VERIFIED subject to the evidence — the + difference between "claims to be agent X" and "is agent X". + + Per-caller keys: ``CHP_HOST_API_KEYS="agent-a:key1,steward:key2"`` — a + match sets the caller to that name. ``CHP_HOST_API_KEY`` stays as the + anonymous shared-key fallback. + """ + self._caller: str | None = None + presented = self.headers.get("X-CHP-Key", "") + named = os.environ.get("CHP_HOST_API_KEYS") + shared = os.environ.get("CHP_HOST_API_KEY") + + if named: + for entry in named.split(","): + name, sep, k = entry.partition(":") + if sep and hmac.compare_digest(presented, k.strip()): + self._caller = name.strip() + return True + if shared and hmac.compare_digest(presented, shared): + return True # anonymous authenticated (single shared key) + if not named and not shared: + return True # no auth configured — open + self._write_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "Missing or invalid X-CHP-Key") return False @@ -77,14 +133,14 @@ def do_GET(self) -> None: # /health is always public — required for mesh probes and load balancers if path == "/" or path == "/health": host_desc = self._sync_discover() - cap_count = len(host_desc.get("capabilities", [])) + # /health is unauthenticated — do not disclose live capability_count + # here (mesh-count privacy). It stays on the authed /host descriptor. self._write_json({ "status": "ok", "host_id": host_desc.get("id") or host_desc.get("hosts", ["unknown"])[0], "protocol": "chp", "version": "0.1", "host_version": _host_version(), - "capability_count": cap_count, }) return if not self._check_auth(): @@ -92,6 +148,9 @@ def do_GET(self) -> None: if path == "/host": desc = self._sync_discover() desc.setdefault("host_version", _host_version()) + host_id = desc.get("id") or (desc.get("hosts") or [None])[0] + for k, v in _host_assurance(host_id).items(): + desc.setdefault(k, v) self._write_json(desc) return if path == "/capabilities": @@ -186,6 +245,12 @@ def _invoke(self, body: JSON) -> JSON: envelope_body["correlation"] = { "correlation_id": envelope_body.pop("correlation_id") } + # Bind the VERIFIED caller as the subject — overriding any client-asserted + # subject — so evidence attributes the action to who actually authenticated, + # not to whatever the request body claimed. Accountability, not assertion. + caller = getattr(self, "_caller", None) + if caller is not None: + envelope_body["subject"] = {"id": caller, "type": "api_key", "verified": True} envelope = InvocationEnvelope.from_mapping(envelope_body) result = asyncio.run(self.server.chp_host.ainvoke_envelope(envelope)) return result.to_dict() @@ -320,6 +385,10 @@ def _read_json(self) -> JSON: length = int(self.headers.get("Content-Length", "0")) if length == 0: return {} + if length < 0 or length > self._MAX_BODY_BYTES: + # Reject negative (would become read-until-EOF) and oversized bodies + # before allocating — caught by do_POST and returned as 400. + raise ValueError(f"request body too large or invalid (Content-Length={length})") raw = self.rfile.read(length).decode("utf-8") value = json.loads(raw) if not isinstance(value, dict): diff --git a/packages/python/chp_core/incident.py b/packages/python/chp_core/incident.py index 4609571..bf442b0 100644 --- a/packages/python/chp_core/incident.py +++ b/packages/python/chp_core/incident.py @@ -1,4 +1,7 @@ -"""Incident detection, lifecycle, and remediation capability for CHP §9.5.""" +"""Incident detection, lifecycle, and remediation capability for CHP. + +Incident event vocabulary is normative in spec/chp-governance-v0.2.md §4.3. +""" from __future__ import annotations diff --git a/packages/python/chp_core/ingestion.py b/packages/python/chp_core/ingestion.py index 7cb1261..0128485 100644 --- a/packages/python/chp_core/ingestion.py +++ b/packages/python/chp_core/ingestion.py @@ -197,11 +197,6 @@ async def _ingest(ctx, payload) -> dict: uri: str | None = payload.get("uri") content_type: str = payload.get("content_type", "text/plain") - ctx.emit( - "execution_started", - {"capability_id": cap.capability_id, "capability_version": cap.capability_version}, - redacted=False, - ) ctx.emit("ingestion_started", {"uri": uri, "content_type": content_type}, redacted=False) t0 = time.perf_counter() @@ -220,7 +215,6 @@ async def _ingest(ctx, payload) -> dict: {"error": str(exc), "latency_ms": latency_ms}, redacted=False, ) - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise latency_ms = round((time.perf_counter() - t0) * 1000, 2) @@ -235,11 +229,6 @@ async def _ingest(ctx, payload) -> dict: }, redacted=False, ) - ctx.emit( - "execution_completed", - {"capability_id": cap.capability_id, "outcome": "success"}, - redacted=False, - ) return result.to_dict() host.register(cap.as_capability_descriptor(), _ingest) diff --git a/packages/python/chp_core/knowledge_graph.py b/packages/python/chp_core/knowledge_graph.py index e8a1ef0..7c8ae8d 100644 --- a/packages/python/chp_core/knowledge_graph.py +++ b/packages/python/chp_core/knowledge_graph.py @@ -398,19 +398,16 @@ async def _add_entity(ctx, payload) -> dict: label: str | None = payload.get("label") properties: dict = payload.get("properties") or {} - ctx.emit("execution_started", {"capability_id": add_entity_desc.id, "capability_version": version}, redacted=False) try: record = kg.add_entity(entity_id, entity_type, label=label, properties=properties) except Exception as exc: ctx.emit("graph_operation_failed", {"error": str(exc), "operation": "add_entity"}, redacted=False) - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise ctx.emit("graph_entity_added", { "entity_id": record.entity_id, "entity_type": record.entity_type, "label": record.label, }, redacted=False) - ctx.emit("execution_completed", {"capability_id": add_entity_desc.id, "outcome": "success"}, redacted=False) return record.to_dict() host.register(add_entity_desc, _add_entity) @@ -432,19 +429,16 @@ async def _add_relation(ctx, payload) -> dict: relation_type: str = payload.get("relation_type", "related_to") properties: dict = payload.get("properties") or {} - ctx.emit("execution_started", {"capability_id": add_relation_desc.id, "capability_version": version}, redacted=False) try: record = kg.add_relation(from_id, to_id, relation_type, properties=properties) except Exception as exc: ctx.emit("graph_operation_failed", {"error": str(exc), "operation": "add_relation"}, redacted=False) - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise ctx.emit("graph_relation_added", { "from_entity_id": record.from_entity_id, "to_entity_id": record.to_entity_id, "relation_type": record.relation_type, }, redacted=False) - ctx.emit("execution_completed", {"capability_id": add_relation_desc.id, "outcome": "success"}, redacted=False) return record.to_dict() host.register(add_relation_desc, _add_relation) @@ -464,19 +458,16 @@ async def _query_entities(ctx, payload) -> dict: entity_type: str | None = payload.get("entity_type") limit: int | None = payload.get("limit") - ctx.emit("execution_started", {"capability_id": query_desc.id, "capability_version": version}, redacted=False) try: result = kg.query_entities(entity_type=entity_type, limit=limit) except Exception as exc: ctx.emit("graph_operation_failed", {"error": str(exc), "operation": "query_entities"}, redacted=False) - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise ctx.emit("graph_queried", { "entity_type": entity_type, "entity_count": result.entity_count, "latency_ms": result.latency_ms, }, redacted=False) - ctx.emit("execution_completed", {"capability_id": query_desc.id, "outcome": "success"}, redacted=False) return result.to_dict() host.register(query_desc, _query_entities) @@ -498,12 +489,10 @@ async def _traverse(ctx, payload) -> dict: direction: str = payload.get("direction", "outgoing") depth: int = int(payload.get("depth", 1)) - ctx.emit("execution_started", {"capability_id": traverse_desc.id, "capability_version": version}, redacted=False) try: result = kg.traverse(start_id, relation_type=relation_type, direction=direction, depth=depth) except Exception as exc: ctx.emit("graph_operation_failed", {"error": str(exc), "operation": "traverse"}, redacted=False) - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise ctx.emit("graph_traversed", { "start_id": start_id, @@ -512,7 +501,6 @@ async def _traverse(ctx, payload) -> dict: "entity_count": result.entity_count, "latency_ms": result.latency_ms, }, redacted=False) - ctx.emit("execution_completed", {"capability_id": traverse_desc.id, "outcome": "success"}, redacted=False) return result.to_dict() host.register(traverse_desc, _traverse) diff --git a/packages/python/chp_core/memory.py b/packages/python/chp_core/memory.py index b85aedc..03d8af8 100644 --- a/packages/python/chp_core/memory.py +++ b/packages/python/chp_core/memory.py @@ -129,16 +129,10 @@ async def _get(ctx: Any, payload: JSON) -> JSON: key = str(payload.get("key", "")) scope: MemoryScope = payload.get("scope", "session") scope_id: str = payload.get("scope_id", "") - ctx.emit("execution_started", {"capability_id": "memory.get"}, redacted=False) - try: - value = memory.get(key, scope=scope, scope_id=scope_id) - found = value is not None - ctx.emit("memory_read", {"key": key, "scope": scope, "scope_id": scope_id, "found": found}, redacted=False) - ctx.emit("execution_completed", {"outcome": "success"}, redacted=False) - return {"value": value, "found": found} - except Exception as exc: - ctx.emit("execution_failed", {"reason": str(exc), "exception_type": type(exc).__name__}, redacted=False) - raise + value = memory.get(key, scope=scope, scope_id=scope_id) + found = value is not None + ctx.emit("memory_read", {"key": key, "scope": scope, "scope_id": scope_id, "found": found}, redacted=False) + return {"value": value, "found": found} # ── memory.set ──────────────────────────────────────────────────────────── async def _set(ctx: Any, payload: JSON) -> JSON: @@ -146,44 +140,26 @@ async def _set(ctx: Any, payload: JSON) -> JSON: value = payload.get("value") scope: MemoryScope = payload.get("scope", "session") scope_id: str = payload.get("scope_id", "") - ctx.emit("execution_started", {"capability_id": "memory.set"}, redacted=False) - try: - memory.set(key, value, scope=scope, scope_id=scope_id) - serialized_len = len(json.dumps(value).encode()) - ctx.emit("memory_written", {"key": key, "scope": scope, "scope_id": scope_id, "bytes_written": serialized_len}, redacted=False) - ctx.emit("execution_completed", {"outcome": "success"}, redacted=False) - return {"key": key, "scope": scope} - except Exception as exc: - ctx.emit("execution_failed", {"reason": str(exc), "exception_type": type(exc).__name__}, redacted=False) - raise + memory.set(key, value, scope=scope, scope_id=scope_id) + serialized_len = len(json.dumps(value).encode()) + ctx.emit("memory_written", {"key": key, "scope": scope, "scope_id": scope_id, "bytes_written": serialized_len}, redacted=False) + return {"key": key, "scope": scope} # ── memory.delete ───────────────────────────────────────────────────────── async def _delete(ctx: Any, payload: JSON) -> JSON: key = str(payload.get("key", "")) scope: MemoryScope = payload.get("scope", "session") scope_id: str = payload.get("scope_id", "") - ctx.emit("execution_started", {"capability_id": "memory.delete"}, redacted=False) - try: - existed = memory.delete(key, scope=scope, scope_id=scope_id) - ctx.emit("memory_deleted", {"key": key, "scope": scope, "scope_id": scope_id, "existed": existed}, redacted=False) - ctx.emit("execution_completed", {"outcome": "success"}, redacted=False) - return {"key": key, "existed": existed} - except Exception as exc: - ctx.emit("execution_failed", {"reason": str(exc), "exception_type": type(exc).__name__}, redacted=False) - raise + existed = memory.delete(key, scope=scope, scope_id=scope_id) + ctx.emit("memory_deleted", {"key": key, "scope": scope, "scope_id": scope_id, "existed": existed}, redacted=False) + return {"key": key, "existed": existed} # ── memory.list ─────────────────────────────────────────────────────────── async def _list(ctx: Any, payload: JSON) -> JSON: scope: MemoryScope = payload.get("scope", "session") scope_id: str = payload.get("scope_id", "") - ctx.emit("execution_started", {"capability_id": "memory.list"}, redacted=False) - try: - keys = memory.list(scope=scope, scope_id=scope_id) - ctx.emit("execution_completed", {"outcome": "success"}, redacted=False) - return {"keys": keys, "count": len(keys)} - except Exception as exc: - ctx.emit("execution_failed", {"reason": str(exc), "exception_type": type(exc).__name__}, redacted=False) - raise + keys = memory.list(scope=scope, scope_id=scope_id) + return {"keys": keys, "count": len(keys)} _memory_emits = _MEMORY_EMITS + ["memory_read"] _write_emits = _MEMORY_EMITS + ["memory_written"] diff --git a/packages/python/chp_core/otel.py b/packages/python/chp_core/otel.py index 6b8512d..10d1af3 100644 --- a/packages/python/chp_core/otel.py +++ b/packages/python/chp_core/otel.py @@ -7,23 +7,99 @@ from __future__ import annotations +import hashlib import json import urllib.error import urllib.request from collections import defaultdict +from datetime import datetime, timezone from typing import Any from .types import JSON +def _hex_id(value: str | None, n_bytes: int) -> str: + """Deterministic valid OTLP hex id (n_bytes → 2*n_bytes hex chars) from a CHP + id. OTLP requires 16-byte trace ids and 8-byte span ids as hex — CHP's string + ids (`inv_…`, `corr_…`) aren't valid, so we hash them into the required shape. + Deterministic, so the same CHP id always maps to the same span/trace id.""" + if not value: + return "0" * (2 * n_bytes) + return hashlib.sha256(value.encode()).hexdigest()[: 2 * n_bytes] + + +def _unix_nano(ts: str | None) -> str: + """ISO-8601 → nanoseconds-since-epoch string (OTLP time format).""" + if not ts: + return "0" + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return str(int(dt.timestamp() * 1_000_000_000)) + except ValueError: + return "0" + + +def _gen_ai_attributes(capability_id: str) -> dict[str, Any]: + """OTel GenAI semantic conventions for LLM/agent capabilities, so CHP evidence + lands in GenAI-aware backends. Best-effort by capability id.""" + cid = capability_id.lower() + if any(k in cid for k in ("llm", "chat", "generate", "completion", "gemini", "openai", "claude", "mlx")): + return {"gen_ai.operation.name": "chat", "gen_ai.system": "chp"} + return {} + + +def _governance_attributes(events: list[JSON]) -> dict[str, Any]: + """Surface the governance decisions on an invocation as first-class, queryable + span attributes — not only nested span events. This is CHP's differentiator + carried into OTel: a backend can filter 'chp.safety.blocked = true' or + 'chp.approval.requested = true' the same way it filters chp.denied. The + governance evidence rides the same span (same invocation_id).""" + by_type: dict[str, JSON] = {e["event_type"]: (e.get("payload") or {}) for e in events} + attrs: dict[str, Any] = {} + + # Safety: a signed assessment on every governed invocation, block or not. + completed = by_type.get("safety_assessment_completed") + if completed is not None: + attrs["chp.safety.assessed"] = True + if completed.get("level") is not None: + attrs["chp.safety.level"] = completed["level"] + if completed.get("score") is not None: + # score is a string in hashed evidence (chp-stable-v1 §2 forbids floats); + # re-float it here — an OTel attribute is a non-hashed surface, so a + # numeric value is fine and nicer to range-query. + try: + attrs["chp.safety.score"] = float(completed["score"]) + except (TypeError, ValueError): + attrs["chp.safety.score"] = completed["score"] + attrs["chp.safety.blocked"] = "safety_action_blocked" in by_type + + # Autonomy budget. + if "budget_exceeded" in by_type: + attrs["chp.budget.exceeded"] = True + + # Human approval. + if "approval_requested" in by_type: + attrs["chp.approval.requested"] = True + if "approval_granted" in by_type: + attrs["chp.approval.decision"] = "granted" + elif "approval_denied" in by_type: + attrs["chp.approval.decision"] = "denied" + + return attrs + + def evidence_to_otel_span(event: JSON) -> JSON: """Map one CHP evidence event to an OTLP-like span payload.""" correlation = event.get("correlation") or {} + causation_id = correlation.get("causation_id") return { "name": event["capability_id"], - "trace_id": correlation.get("trace_id") or correlation.get("correlation_id"), - "span_id": event["invocation_id"], + "trace_id": _hex_id(correlation.get("trace_id") or correlation.get("correlation_id"), 16), + "span_id": _hex_id(event["invocation_id"], 8), + "parent_span_id": _hex_id(causation_id, 8) if causation_id else None, "attributes": { "chp.host_id": event["host_id"], "chp.capability_id": event["capability_id"], @@ -71,20 +147,35 @@ def replay_to_otel_spans(events: list[JSON]) -> list[JSON]: for event in invocation_events ] + causation_id = correlation.get("causation_id") + # content_hash of the terminal event = this span's tamper-evident anchor. + content_hash = last.get("content_hash") or first.get("content_hash") spans.append( { "name": first["capability_id"], - "trace_id": correlation.get("trace_id") or correlation.get("correlation_id"), - "span_id": invocation_id, - "start_time": first["timestamp"], - "end_time": last["timestamp"], + # Valid OTLP: trace from correlation, span from invocation, and + # parent from the causal edge (causation_id) → a real span tree. + "trace_id": _hex_id(correlation.get("trace_id") or correlation.get("correlation_id"), 16), + "span_id": _hex_id(invocation_id, 8), + "parent_span_id": _hex_id(causation_id, 8) if causation_id else None, + "start_time": _unix_nano(first["timestamp"]), + "end_time": _unix_nano(last["timestamp"]), "attributes": { "chp.host_id": first["host_id"], "chp.capability_id": first["capability_id"], "chp.capability_version": first.get("capability_version"), "chp.invocation_id": invocation_id, "chp.correlation_id": correlation.get("correlation_id"), + "chp.causation_id": causation_id, "chp.outcome": last.get("outcome"), + # The CHP differentiators, carried into OTel: tamper-evidence… + "chp.content_hash": content_hash, + # …and denial as a first-class, queryable attribute. + "chp.denied": last.get("event_type") == "execution_denied", + "chp.denial_code": (last.get("denial") or {}).get("code") if last.get("denial") else None, + # …and the full governance decision surface, queryable. + **_governance_attributes(invocation_events), + **_gen_ai_attributes(first["capability_id"]), }, "events": span_events, "status": _status_for_outcome(last.get("outcome")), @@ -124,6 +215,8 @@ def export_otlp_http( { "traceId": span.get("trace_id", ""), "spanId": span.get("span_id", ""), + **({"parentSpanId": span["parent_span_id"]} + if span.get("parent_span_id") else {}), "name": span.get("name", ""), "startTimeUnixNano": span.get("start_time", ""), "endTimeUnixNano": span.get("end_time", span.get("start_time", "")), diff --git a/packages/python/chp_core/policy.py b/packages/python/chp_core/policy.py index ca78aeb..c009d37 100644 --- a/packages/python/chp_core/policy.py +++ b/packages/python/chp_core/policy.py @@ -30,12 +30,19 @@ from __future__ import annotations import json +import logging import os import re from dataclasses import dataclass, field from pathlib import Path from typing import Any +logger = logging.getLogger(__name__) + + +class PolicyError(ValueError): + """A policy file exists but could not be parsed — treated as fail-closed.""" + RISK_ORDER: dict[str, int] = {"low": 0, "medium": 1, "high": 2, "critical": 3} @@ -83,8 +90,11 @@ def load_policy(path: str | None = None) -> PolicyConfig | None: try: with candidate.open() as f: return _parse_policy(json.load(f)) - except (json.JSONDecodeError, KeyError, TypeError): - pass + except (json.JSONDecodeError, KeyError, TypeError) as exc: + # Fail closed: a policy file that exists but can't be parsed must + # not silently disable all blocking. Surface it loudly. + logger.error("policy file %s is unparseable: %s", candidate, exc) + raise PolicyError(f"unparseable policy file {candidate}: {exc}") from exc return None @@ -139,27 +149,32 @@ def evaluate_policy( should_block = True reason = f"capability blocked by policy: {capability_id}" - # Risk tier: block if capability risk exceeds the configured maximum - if not should_block and policy.max_risk_tier is not None and capability_risk is not None: - cap_order = RISK_ORDER.get(capability_risk, -1) + # Risk tier: block if capability risk exceeds the configured maximum. + # Unmapped/unknown capability risk defaults to "medium" so the gate still + # bites rather than silently passing an uncharacterised capability. + if not should_block and policy.max_risk_tier is not None: + effective_risk = capability_risk if capability_risk in RISK_ORDER else "medium" + cap_order = RISK_ORDER.get(effective_risk, 1) max_order = RISK_ORDER.get(policy.max_risk_tier, 99) if cap_order > max_order: should_block = True reason = ( - f"capability risk '{capability_risk}' exceeds " + f"capability risk '{effective_risk}' exceeds " f"max_risk_tier '{policy.max_risk_tier}'" ) - # Pattern match on tool input fields + # Pattern match on tool input fields. Case-insensitive so trivial casing + # ("RM -RF /") can't slip past a lowercase rule; patterns are + # defense-in-depth, not a sandbox. if not should_block: for bp in policy.block_patterns: if bp.capability_id != capability_id: continue value = str(tool_input.get(bp.field, "")) try: - matched = bool(re.search(bp.pattern, value)) + matched = bool(re.search(bp.pattern, value, re.IGNORECASE)) except re.error: - matched = bp.pattern in value + matched = bp.pattern.lower() in value.lower() if matched: should_block = True reason = bp.reason diff --git a/packages/python/chp_core/protocol_checks.py b/packages/python/chp_core/protocol_checks.py index dcbb24a..71b7efc 100644 --- a/packages/python/chp_core/protocol_checks.py +++ b/packages/python/chp_core/protocol_checks.py @@ -226,6 +226,93 @@ def check_alignment(repo_root: Path) -> JSON: {"forbidden": ["CapabilityHostDescriptor", "ExecutionEvidenceEvent"]}, ) + # Published canonicalization vectors must still match what the code produces. + # Non-Python verifiers pin these bytes; silent drift breaks cross-language interop. + vec_dir = repo_root / "spec" / "test-vectors" + if (vec_dir / "expected.json").exists(): + try: + from .store import _compute_event_hash + + exp = read_json(vec_dir / "expected.json") + ev = read_json(vec_dir / "event.json")["event"] + recomputed = _compute_event_hash(ev, None) + add_check( + checks, + "canonicalization_vectors_match", + recomputed == exp["event_content_hash"], + {"expected": exp["event_content_hash"], "recomputed": recomputed, + "hint": "canonicalization changed — regenerate spec/test-vectors/"}, + ) + except Exception as exc: # pragma: no cover - defensive + add_check(checks, "canonicalization_vectors_match", False, {"error": str(exc)}) + + # Governance vocabulary: the reserved denial registry, the schema examples, + # the codes the runtime actually emits, and the normative spec MUST agree. + from .types import DenialReason + + reserved = DenialReason.RESERVED_CODES + host_src = read_text(repo_root / "packages" / "python" / "chp_core" / "host.py") + emitted = set(re.findall(r'code="([a-z_]+)"', host_src)) | set( + re.findall(r'"code": "([a-z_]+)"', host_src)) + add_check( + checks, + "denial_codes_runtime_reserved", + emitted <= reserved, + {"emitted_not_reserved": sorted(emitted - reserved), "hint": + "host.py emits a bare code missing from DenialReason.RESERVED_CODES"}, + ) + denial_schema = read_json(repo_root / "schemas" / "denial-reason.schema.json") + schema_examples = set(denial_schema["properties"]["code"].get("examples", [])) + add_check( + checks, + "denial_codes_schema_reserved", + schema_examples == reserved, + {"schema_only": sorted(schema_examples - reserved), + "reserved_only": sorted(reserved - schema_examples)}, + ) + gov = read_text(repo_root / "spec" / "chp-governance-v0.2.md") + add_check( + checks, + "governance_spec_names_denial_codes", + all(f"`{c}`" in gov for c in reserved), + {"missing": sorted(c for c in reserved if f"`{c}`" not in gov)}, + ) + add_check( + checks, + "governance_spec_names_risk_tiers", + all(f"`{t}`" in gov for t in ("low", "medium", "high", "critical")) + and "RISK_ORDER" in gov, + {"path": "spec/chp-governance-v0.2.md"}, + ) + # The normative invocation pipeline must name every reserved denial code + # (it's the authoritative trigger + ordering source per governance §2). + pipeline_path = repo_root / "spec" / "chp-invocation-pipeline.md" + if pipeline_path.exists(): + pipeline = read_text(pipeline_path) + add_check( + checks, + "pipeline_spec_names_denial_codes", + all(f"`{c}`" in pipeline for c in reserved), + {"missing": sorted(c for c in reserved if f"`{c}`" not in pipeline)}, + ) + # Canonicalization golden set must recompute — chp-stable-v1 is + # json.dumps(sort_keys=True); a second implementation pins to these bytes. + canon_path = repo_root / "spec" / "test-vectors" / "canon" / "cases.json" + if canon_path.exists(): + import json as _json + + canon = read_json(canon_path) + drifted = [ + c["name"] for c in canon.get("cases", []) + if _json.dumps(c["input"], sort_keys=True) != c["expected_canon"] + ] + add_check( + checks, + "canon_golden_set_recomputes", + not drifted, + {"drifted": drifted, "hint": "regenerate spec/test-vectors/canon/cases.json"}, + ) + sync = check_sync_integrity(repo_root) checks.extend(sync["checks"]) @@ -248,9 +335,13 @@ def check_messaging(repo_root: Path) -> JSON: add_check( checks, - "readme_evidence_first_positioning", - "visible, replayable, and ready for governance" in readme - and "See what your agents and tools actually did." in readme, + "readme_governed_plane_positioning", + # Guard the reinforced positioning: the governed, signed evidence PLANE + # (not the old undersold "execution evidence layer"), while keeping the + # "see what your agents did" hook. + "the single signed plane" in readme + and "governed evidence plane" in readme + and "See what your agents and tools actually did" in readme, {"path": "README.md"}, ) add_check( diff --git a/packages/python/chp_core/prov.py b/packages/python/chp_core/prov.py new file mode 100644 index 0000000..49c678a --- /dev/null +++ b/packages/python/chp_core/prov.py @@ -0,0 +1,114 @@ +"""W3C PROV export — CHP evidence as signed, governed provenance. + +CHP's evidence *is* provenance: an invocation is a prov:Activity, its `subject` +a prov:Agent, its output a prov:Entity, and the causal edge (causation_id) a +prov:wasInformedBy link. This exporter serializes a replayed correlation as +PROV-JSON (the W3C PROV-JSON serialization) so CHP interoperates with lineage +and catalog tooling — while carrying the two things PROV/OpenLineage lack: + +- **Governance** — denial, risk/safety, approval, budget as chp: annotations on + the Activity. PROV has no vocabulary for a *refusal*; CHP exports it anyway. +- **Integrity** — the content_hash tamper anchor as a chp: annotation on the + Entity. "Signed, governed provenance" — see docs/comparisons/chp-and-w3c-prov.md. + +The native signed CHP plane stays source of truth; PROV is a bridge out. +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from .otel import _governance_attributes +from .types import JSON + +CHP_PROV_NS = "https://chp.dev/prov#" + + +def _prov_governance(events: list[JSON]) -> dict[str, Any]: + """Reuse the OTel governance surface, remapped to PROV attribute names + (``chp.safety.blocked`` -> ``chp:safety_blocked``).""" + return { + "chp:" + key[len("chp."):].replace(".", "_"): value + for key, value in _governance_attributes(events).items() + } + + +def replay_to_prov(events: list[JSON]) -> JSON: + """Serialize a replayed correlation's events as a PROV-JSON document.""" + grouped: dict[str, list[JSON]] = defaultdict(list) + for event in events: + grouped[event["invocation_id"]].append(event) + + activities: dict[str, JSON] = {} + agents: dict[str, JSON] = {} + entities: dict[str, JSON] = {} + associations: dict[str, JSON] = {} + generations: dict[str, JSON] = {} + informed: dict[str, JSON] = {} + n = 0 + + for invocation_id, inv_events in grouped.items(): + first, last = inv_events[0], inv_events[-1] + correlation = first.get("correlation") or {} + act_id = f"chp:{invocation_id}" + + # Activity — the invocation, with outcome + governance annotations. + activity: JSON = { + "prov:startTime": first["timestamp"], + "prov:endTime": last["timestamp"], + "chp:capability_id": first["capability_id"], + "chp:correlation_id": correlation.get("correlation_id"), + "chp:outcome": last.get("outcome"), + "chp:denied": last.get("event_type") == "execution_denied", + } + denial = last.get("denial") or {} + if denial.get("code"): + activity["chp:denial_code"] = denial["code"] + activity.update(_prov_governance(inv_events)) + activities[act_id] = activity + + # Agent — the subject the invocation acted for + the host (softwareAgent). + subject = first.get("subject") or {} + subject_id = subject.get("id") or "unknown" + agent_id = f"chp:agent:{subject_id}" + agents.setdefault(agent_id, { + "prov:type": "prov:Agent", + "chp:subject_type": subject.get("type"), + "chp:verified": subject.get("verified", False), + }) + host_id = f"chp:host:{first['host_id']}" + agents.setdefault(host_id, {"prov:type": "prov:SoftwareAgent"}) + + associations[f"_:wa{n}"] = {"prov:activity": act_id, "prov:agent": agent_id} + associations[f"_:wah{n}"] = {"prov:activity": act_id, "prov:agent": host_id} + + # Entity — the tamper-evident evidence record, anchored by content_hash. + content_hash = last.get("content_hash") or first.get("content_hash") + ent_id = f"chp:evidence:{invocation_id}" + entities[ent_id] = { + "prov:type": "chp:EvidenceRecord", + "chp:content_hash": content_hash, + "chp:hash_chained": content_hash is not None, + } + generations[f"_:wg{n}"] = {"prov:entity": ent_id, "prov:activity": act_id} + + # Causal edge — child activity was informed by its parent (causation_id). + causation_id = correlation.get("causation_id") + if causation_id: + informed[f"_:wi{n}"] = { + "prov:informed": act_id, "prov:informant": f"chp:{causation_id}", + } + n += 1 + + doc: JSON = { + "prefix": {"chp": CHP_PROV_NS}, + "activity": activities, + "agent": agents, + "entity": entities, + "wasAssociatedWith": associations, + "wasGeneratedBy": generations, + } + if informed: + doc["wasInformedBy"] = informed + return doc diff --git a/packages/python/chp_core/retrieval.py b/packages/python/chp_core/retrieval.py index 12c3664..ddaaa47 100644 --- a/packages/python/chp_core/retrieval.py +++ b/packages/python/chp_core/retrieval.py @@ -457,7 +457,6 @@ async def _query(ctx: Any, payload: JSON) -> JSON: top_k = int(payload.get("top_k", 5)) filters = payload.get("filters") - ctx.emit("execution_started", {"capability_id": cap.capability_id}, redacted=False) ctx.emit("retrieval_started", {"query": query, "top_k": top_k}, redacted=False) t0 = time.perf_counter() try: @@ -472,12 +471,10 @@ async def _query(ctx: Any, payload: JSON) -> JSON: "source_refs": [r.to_dict() for r in result.source_refs], } ctx.emit("retrieval_completed", event_payload, redacted=False) - ctx.emit("execution_completed", {"outcome": "success"}, redacted=False) return result.to_dict() except Exception as exc: err: JSON = {"reason": str(exc), "exception_type": type(exc).__name__} ctx.emit("retrieval_failed", err, redacted=False) - ctx.emit("execution_failed", err, redacted=False) raise host.register(cap.as_capability_descriptor(), _query) diff --git a/packages/python/chp_core/safety.py b/packages/python/chp_core/safety.py index 08fef42..aeb403b 100644 --- a/packages/python/chp_core/safety.py +++ b/packages/python/chp_core/safety.py @@ -1,4 +1,8 @@ -"""Risk evaluation and guardrail capability for CHP §8.6.""" +"""Risk evaluation and guardrail capability for CHP. + +Risk-tier semantics and the safety event vocabulary are normative in +spec/chp-governance-v0.2.md §3–§4.2. +""" from __future__ import annotations @@ -30,9 +34,16 @@ ] _HIGH_RISK_CAP_PATTERNS = [ - "*bash*", "*exec*", "*shell*", "*delete*", "*drop*", "*rm*", "*destroy*", + # NOTE: these match capability IDs (not shell text), so keep them specific. "*rm*" was removed — + # it false-matched "confo[rm]ance" (and "transform", etc.); the `rm -rf` shell command is caught by + # *bash*/*shell* on cap ids and the critical "rm -rf" payload keyword instead. + "*bash*", "*exec*", "*shell*", "*delete*", "*drop*", "*destroy*", + # Secrets + arbitrary subprocess/container execution (safety.assess previously rated these "allow"). + "*secrets.set*", "*secrets.delete*", "*process.run*", "*process.exec*", "*process.spawn*", + "*container.run*", "*container.exec*", # Mesh control actions: remote update/restart/stop/install on a node's runtime. "*host.update*", "*host.restart*", "*host.stop*", "*host.install_adapter*", + "*launchd.start*", "*launchd.stop*", "*launchd.install*", "*launchd.uninstall*", # Inference-server lifecycle: spawning/killing model servers. "*start_server*", "*stop_server*", ] diff --git a/packages/python/chp_core/signing.py b/packages/python/chp_core/signing.py new file mode 100644 index 0000000..97c0762 --- /dev/null +++ b/packages/python/chp_core/signing.py @@ -0,0 +1,359 @@ +"""Evidence integrity v0.2 — ed25519 keypairs and signed evidence bundles. + +CHP v0.1 evidence is hash-chained (mutation/reorder detection) but unsigned: a +local actor with store access can recompute the whole chain. v0.2 adds an +*optional* signing layer that binds an exported bundle to a host keypair, so a +verifier can detect tampering by any party without the private key. + +Assurance tiers (declared by the host, per the v0.2 design doc): +- ``none`` — local append-only evidence (v0.1 baseline) +- ``hash-chain`` — per-event content_hash + prev_hash (mutation/reorder) +- ``signed`` — hash-chain bundle + ed25519 signature over the root hash + +Design decisions (see docs/design/evidence-integrity-v0.2.md): +- Sign the bundle ROOT hash, not every event — cheap, and event-level + hash-chaining still gives per-event integrity. +- Canonicalization is named ``chp-stable-v1`` (the shipped stable-field + ``json.dumps(sort_keys=True)`` hashing), NOT RFC 8785 JCS. Switching schemes + would invalidate every existing chain mesh-wide, and JCS's value (cross- + language verification) is moot until a non-Python verifier exists. The + ``canonicalization`` field makes adopting ``chp-jcs-v1`` a non-breaking + addition later. + +``cryptography`` is an OPTIONAL extra (``chp-core[signing]``). Without it, hosts +stay at the ``hash-chain`` tier: unsigned bundles still build and verify; only +signing/signature-verification raise a clear error. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .store import _compute_event_hash + +CANONICALIZATION = "chp-stable-v1" +SIGNATURE_ALGORITHM = "ed25519" +DEFAULT_KEY_DIR = Path.home() / ".chp" / "keys" +_PRIVATE_NAME = "host_ed25519" +_PUBLIC_NAME = "host_ed25519.pub" + + +class SigningUnavailable(RuntimeError): + """Raised when a signing/verification op needs `cryptography` but it's absent.""" + + +def _load_backend() -> Any: + try: + from cryptography.hazmat.primitives.asymmetric import ed25519 # noqa: PLC0415 + return ed25519 + except ImportError as exc: # pragma: no cover - exercised via monkeypatch + raise SigningUnavailable( + "ed25519 signing requires the optional dependency: pip install 'chp-core[signing]'" + ) from exc + + +def signing_available() -> bool: + try: + _load_backend() + return True + except SigningUnavailable: + return False + + +def key_id_for(public_key_bytes: bytes) -> str: + """Stable short identifier for a public key (first 16 hex of its SHA256).""" + return hashlib.sha256(public_key_bytes).hexdigest()[:16] + + +# -------------------------------------------------------------------------- +# Keypair management (file-based, like ssh keys — works on mac/worker/raspi) +# -------------------------------------------------------------------------- + +@dataclass +class HostKey: + key_id: str + public_key_b64: str + _private: Any = field(default=None, repr=False) + + @property + def can_sign(self) -> bool: + return self._private is not None + + +def generate_keypair(key_dir: str | Path = DEFAULT_KEY_DIR, *, overwrite: bool = False) -> HostKey: + """Create an ed25519 keypair under *key_dir* (private 0600, dir 0700).""" + ed25519 = _load_backend() + key_dir = Path(key_dir) + priv_path = key_dir / _PRIVATE_NAME + pub_path = key_dir / _PUBLIC_NAME + if priv_path.exists() and not overwrite: + raise FileExistsError(f"key already exists at {priv_path}; pass overwrite=True to replace") + + key_dir.mkdir(parents=True, exist_ok=True) + try: + key_dir.chmod(0o700) + except OSError: + pass + + private = ed25519.Ed25519PrivateKey.generate() + from cryptography.hazmat.primitives import serialization # noqa: PLC0415 + priv_bytes = private.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + pub_bytes = private.public_key().public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + # Write private with 0600 from the start (no world-readable window). + import os # noqa: PLC0415 + fd = os.open(str(priv_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as fh: + fh.write(base64.b64encode(priv_bytes)) + pub_path.write_bytes(base64.b64encode(pub_bytes)) + return HostKey(key_id=key_id_for(pub_bytes), public_key_b64=base64.b64encode(pub_bytes).decode(), _private=private) + + +def load_host_key(key_dir: str | Path = DEFAULT_KEY_DIR) -> HostKey | None: + """Load the host keypair if present. Returns None if no key exists (the host + then operates at the hash-chain tier).""" + key_dir = Path(key_dir) + priv_path = key_dir / _PRIVATE_NAME + pub_path = key_dir / _PUBLIC_NAME + if not pub_path.exists(): + return None + pub_bytes = base64.b64decode(pub_path.read_bytes()) + private = None + if priv_path.exists(): + ed25519 = _load_backend() + priv_bytes = base64.b64decode(priv_path.read_bytes()) + private = ed25519.Ed25519PrivateKey.from_private_bytes(priv_bytes) + return HostKey( + key_id=key_id_for(pub_bytes), + public_key_b64=base64.b64encode(pub_bytes).decode(), + _private=private, + ) + + +def _canon(obj: Any) -> bytes: + """chp-stable-v1 canonical bytes: sorted keys, spaced separators, ensure_ascii.""" + return json.dumps(obj, sort_keys=True).encode() + + +# Fields covered by the header signature. Everything a stranger reads to decide +# "who/when/how" must be inside the signature — not just root_hash (events are +# already bound via root_hash). See spec/chp-v0.2.md §3. +_HEADER_FIELDS = ("host_id", "protocol_version", "created_at", "canonicalization", "root_hash") + + +def bundle_header(bundle: dict) -> dict: + """The signed header: the origin/time/scheme claims + root_hash.""" + return {k: bundle.get(k) for k in _HEADER_FIELDS} + + +def build_attestation(host_id: str, host_key: HostKey, *, valid_from: str, + valid_until: str | None = None) -> dict: + """Self-signed statement binding host_id <-> public_key. + + key_id = sha256(pubkey)[:16] only binds a key to itself; host_id is a free + string anyone can label a bundle with. This puts the identity claim inside a + signature by the key, so a verifier checks host_id is asserted by the keyholder + (the TOFU trust floor, now cryptographic rather than a bare string).""" + if not host_key.can_sign: + raise SigningUnavailable("host key has no private component; cannot attest") + claim = { + "host_id": host_id, + "public_key": host_key.public_key_b64, + "key_id": host_key.key_id, + "valid_from": valid_from, + "valid_until": valid_until, + } + return {**claim, "signature": _sign(host_key._private, _canon(claim))} + + +def _sign(private: Any, message: bytes) -> str: + return base64.b64encode(private.sign(message)).decode() + + +def _verify_sig(public_key_b64: str, message: bytes, signature_b64: str) -> bool: + ed25519 = _load_backend() + pub = ed25519.Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64)) + from cryptography.exceptions import InvalidSignature # noqa: PLC0415 + try: + pub.verify(base64.b64decode(signature_b64), message) + return True + except InvalidSignature: + return False + + +# -------------------------------------------------------------------------- +# Bundle build / sign / verify +# -------------------------------------------------------------------------- + +def compute_root_hash(events: list[dict]) -> str: + """Root hash = SHA256 over each event's content_hash in sequence order. + + Events already carry content_hash from the store's hash chain, so the root + binds the whole ordered set without re-canonicalizing every event here. + """ + h = hashlib.sha256() + for ev in events: + ch = ev.get("content_hash") + # An unhashed (legacy) event can't participate in a signed bundle. + h.update((ch or "").encode()) + h.update(b"\n") + return h.hexdigest() + + +def build_bundle( + host_id: str, + events: list[dict], + *, + created_at: str, + protocol_version: str = "0.2", +) -> dict: + """Build an unsigned (`hash-chain` tier) evidence bundle from exported events.""" + return { + "host_id": host_id, + "protocol_version": protocol_version, + "created_at": created_at, + "canonicalization": CANONICALIZATION, + "assurance": "hash-chain", + "events": events, + "root_hash": compute_root_hash(events), + } + + +def sign_bundle(bundle: dict, host_key: HostKey, *, valid_until: str | None = None) -> dict: + """Sign a bundle's canonical header, promoting it to the `signed` tier. + + Signs the header (host_id/created_at/protocol_version/canonicalization + + root_hash), not just root_hash, so a stranger cannot relabel the origin + without breaking the signature. Attaches a self-signed host-identity + attestation binding host_id <-> public_key.""" + if not host_key.can_sign: + raise SigningUnavailable("host key has no private component; cannot sign") + signed = dict(bundle) + signed["assurance"] = "signed" + signed["public_key"] = host_key.public_key_b64 + signed["host_identity"] = build_attestation( + signed["host_id"], host_key, + valid_from=signed.get("created_at", ""), valid_until=valid_until, + ) + signed["signature"] = { + "algorithm": SIGNATURE_ALGORITHM, + "key_id": host_key.key_id, + "signature": _sign(host_key._private, _canon(bundle_header(signed))), + } + return signed + + +def verify_attestation( + attestation: dict, + *, + public_key: str | None = None, + expected_host_id: str | None = None, + at_time: str | None = None, +) -> bool: + """Verify a host-identity attestation (chp-v0.2.md §3): the key self-signs a + {host_id, public_key, key_id, valid_from, valid_until} claim. + + Checks the self-signature and, when given, that ``public_key`` matches the + attested key, ``expected_host_id`` matches, and ``at_time`` falls within the + validity window. Used by both the bundle path and the mesh key-pinning path, + so a mesh peer verifies the attestation before trusting a key rather than + pinning whatever ``/host`` self-reports.""" + att_pub = attestation.get("public_key") + if not att_pub: + return False + if public_key is not None and att_pub != public_key: + return False + if expected_host_id is not None and attestation.get("host_id") != expected_host_id: + return False + vf, vu = attestation.get("valid_from"), attestation.get("valid_until") + if at_time is not None: + if vf is not None and at_time < vf: + return False + if vu is not None and at_time > vu: + return False + claim = {k: attestation.get(k) for k in + ("host_id", "public_key", "key_id", "valid_from", "valid_until")} + return _verify_sig(att_pub, _canon(claim), attestation.get("signature", "")) + + +@dataclass +class BundleVerification: + valid: bool + assurance: str + checks: dict[str, bool] + reason: str | None = None + + +def verify_bundle(bundle: dict, *, expected_key_id: str | None = None) -> BundleVerification: + """Offline-verify an exported bundle: per-event hashes, chain continuity, + root hash, and (for signed bundles) the ed25519 signature. + + ``expected_key_id`` pins the signer — a bundle signed by a different key is + rejected (defends against a valid signature from an untrusted key).""" + checks: dict[str, bool] = {} + events = bundle.get("events") or [] + + # 1. Per-event hash recompute + chain continuity. + chain_ok = True + expected_prev: str | None = None + for ev in events: + stored_hash = ev.get("content_hash") + stored_prev = ev.get("prev_hash") + if stored_hash is None: + chain_ok = False # unhashed event can't be in an integrity bundle + break + recomputed = _compute_event_hash(ev, stored_prev) + if recomputed != stored_hash or stored_prev != expected_prev: + chain_ok = False + break + expected_prev = stored_hash + checks["event_hashes"] = chain_ok + + # 2. Root hash binds the ordered set. + checks["root_hash"] = bundle.get("root_hash") == compute_root_hash(events) + + assurance = bundle.get("assurance", "none") + sig = bundle.get("signature") + + # 3. Signature (only for signed bundles). + if assurance == "signed": + if not sig or "signature" not in sig: + return BundleVerification(False, assurance, checks, "signed bundle missing signature") + pub = bundle.get("public_key") + if not pub: + return BundleVerification(False, assurance, checks, "signed bundle missing public_key") + if expected_key_id is not None and sig.get("key_id") != expected_key_id: + return BundleVerification( + False, assurance, checks, + f"signed by unexpected key {sig.get('key_id')!r} (expected {expected_key_id!r})", + ) + checks["signature"] = _verify_sig(pub, _canon(bundle_header(bundle)), sig["signature"]) + + # Host-identity attestation: the public_key must self-assert this host_id, + # so a relabelled host_id (with a matching re-signed header) is still caught + # unless the attesting key also vouches for the new host_id. + att = bundle.get("host_identity") + if att: + # The key must have been valid WHEN it signed this bundle: created_at + # within [valid_from, valid_until]. A rotated-out key is rejected — + # offline, no wall clock needed. + checks["host_identity"] = verify_attestation( + att, public_key=pub, expected_host_id=bundle.get("host_id"), + at_time=bundle.get("created_at"), + ) + + valid = all(checks.values()) + reason = None if valid else "one or more integrity checks failed: " + ", ".join( + k for k, v in checks.items() if not v + ) + return BundleVerification(valid=valid, assurance=assurance, checks=checks, reason=reason) diff --git a/packages/python/chp_core/state_machine.py b/packages/python/chp_core/state_machine.py index 9e422de..de2ba67 100644 --- a/packages/python/chp_core/state_machine.py +++ b/packages/python/chp_core/state_machine.py @@ -335,14 +335,11 @@ async def _create(ctx, payload) -> dict: initial_state=defn_raw.get("initial_state", ""), terminal_states=defn_raw.get("terminal_states", []), ) - ctx.emit("execution_started", {"capability_id": create_desc.id}, redacted=False) try: record = sm.create(name, definition, context) except Exception as exc: - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise ctx.emit("state_machine_created", {"machine_id": record.machine_id, "initial_state": record.current_state}, redacted=False) - ctx.emit("execution_completed", {"capability_id": create_desc.id, "outcome": "success"}, redacted=False) return record.to_dict() host.register(create_desc, _create) @@ -362,12 +359,10 @@ async def _transition(ctx, payload) -> dict: machine_id: str = payload.get("machine_id", "") event: str = payload.get("event", "") - ctx.emit("execution_started", {"capability_id": transition_desc.id}, redacted=False) ctx.emit("state_machine_transition_started", {"machine_id": machine_id, "event": event}, redacted=False) try: result = sm.transition(machine_id, event) except Exception as exc: - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise if result.allowed: record = sm.get(machine_id) @@ -382,7 +377,6 @@ async def _transition(ctx, payload) -> dict: ctx.emit("state_machine_transition_completed", {"machine_id": machine_id, "from": result.from_state, "to": result.to_state}, redacted=False) else: ctx.emit("state_machine_blocked", {"machine_id": machine_id, "reason": result.reason}, redacted=False) - ctx.emit("execution_completed", {"capability_id": transition_desc.id, "outcome": "success"}, redacted=False) return result.to_dict() host.register(transition_desc, _transition) @@ -400,9 +394,7 @@ async def _transition(ctx, payload) -> dict: async def _get(ctx, payload) -> dict: machine_id: str = payload.get("machine_id", "") - ctx.emit("execution_started", {"capability_id": get_desc.id}, redacted=False) record = sm.get(machine_id) - ctx.emit("execution_completed", {"capability_id": get_desc.id, "outcome": "success"}, redacted=False) return record.to_dict() if record else {} host.register(get_desc, _get) @@ -420,9 +412,7 @@ async def _get(ctx, payload) -> dict: async def _list(ctx, payload) -> dict: status_filter: StateMachineStatus | None = payload.get("status") - ctx.emit("execution_started", {"capability_id": list_desc.id}, redacted=False) machines = sm.list_machines(status=status_filter) - ctx.emit("execution_completed", {"capability_id": list_desc.id, "outcome": "success"}, redacted=False) return {"machines": [m.to_dict() for m in machines], "count": len(machines)} host.register(list_desc, _list) diff --git a/packages/python/chp_core/store.py b/packages/python/chp_core/store.py index bf50ace..7bf5367 100644 --- a/packages/python/chp_core/store.py +++ b/packages/python/chp_core/store.py @@ -409,11 +409,14 @@ def children_of(self, session_id: str) -> list[str]: pass return result - def verify_chain(self, correlation_id: str) -> ChainVerificationResult: + def verify_chain(self, correlation_id: str, *, strict: bool = False) -> ChainVerificationResult: """Walk stored events in sequence order and verify the SHA256 hash chain. Events without a stored hash (legacy, written before v0.2.6) are counted - separately and do not break the chain. + separately. In lenient mode (default) they don't break the chain; in + strict mode the first such event fails verification — an unhashed event + is an integrity gap that a `signed`/`hash-chain` assurance tier must not + silently accept. """ with self._lock: rows = self._conn.execute( @@ -437,6 +440,8 @@ def verify_chain(self, correlation_id: str) -> ChainVerificationResult: if stored_hash is None: unverified_count += 1 + if strict and first_broken is None: + first_broken = int(row["sequence"]) # Don't advance expected_prev — legacy events break the chain tracking continue @@ -466,6 +471,28 @@ def verify_chain(self, correlation_id: str) -> ChainVerificationResult: first_broken_sequence=first_broken, ) + def export_correlation(self, correlation_id: str) -> list[JSON]: + """Ordered events for a correlation, each with its stored content_hash / + prev_hash / sequence attached — the raw material for a signed bundle.""" + with self._lock: + rows = self._conn.execute( + """ + SELECT sequence, event_json, content_hash, prev_hash + FROM evidence_events + WHERE correlation_id = ? + ORDER BY sequence ASC + """, + (correlation_id,), + ).fetchall() + out: list[JSON] = [] + for row in rows: + event = json.loads(row["event_json"]) + event["sequence"] = int(row["sequence"]) + event["content_hash"] = row["content_hash"] + event["prev_hash"] = row["prev_hash"] + out.append(event) + return out + def count_by_correlation(self, correlation_id: str) -> int: with self._lock: row = self._conn.execute( diff --git a/packages/python/chp_core/transformation.py b/packages/python/chp_core/transformation.py index ce8a662..ce5ddce 100644 --- a/packages/python/chp_core/transformation.py +++ b/packages/python/chp_core/transformation.py @@ -150,11 +150,6 @@ async def _transform(ctx, payload) -> dict: transform_type: str = payload.get("transform_type", "normalize") params: dict = payload.get("params") or {} - ctx.emit( - "execution_started", - {"capability_id": cap.capability_id, "capability_version": cap.capability_version}, - redacted=False, - ) ctx.emit( "transformation_started", {"transform_type": transform_type, "input_byte_count": len(content.encode("utf-8"))}, @@ -171,7 +166,6 @@ async def _transform(ctx, payload) -> dict: {"error": str(exc), "latency_ms": latency_ms}, redacted=False, ) - ctx.emit("execution_failed", {"error": str(exc)}, redacted=False) raise latency_ms = round((time.perf_counter() - t0) * 1000, 2) @@ -187,11 +181,6 @@ async def _transform(ctx, payload) -> dict: }, redacted=False, ) - ctx.emit( - "execution_completed", - {"capability_id": cap.capability_id, "outcome": "success"}, - redacted=False, - ) return result.to_dict() host.register(cap.as_capability_descriptor(), _transform) diff --git a/packages/python/chp_core/types.py b/packages/python/chp_core/types.py index c85f9d4..fdb3d56 100644 --- a/packages/python/chp_core/types.py +++ b/packages/python/chp_core/types.py @@ -4,7 +4,7 @@ from dataclasses import asdict, dataclass, field from datetime import datetime, timezone -from typing import Any, Literal +from typing import Any, ClassVar, Literal from uuid import uuid4 JSON = dict[str, Any] @@ -593,6 +593,22 @@ class DenialReason: retryable: bool = False details: JSON = field(default_factory=dict) + # Normative registry: the reserved denial codes a conforming host emits at the + # governed boundary (spec/chp-governance-v0.2.md §2). The runtime, the schema + # examples, and the spec MUST agree on this set — guarded by protocol_checks. + # Vendor-specific codes MUST be reverse-DNS namespaced (e.g. "com.acme.quota"). + RESERVED_CODES: ClassVar[frozenset[str]] = frozenset({ + "capability_not_found", # no capability with that id/version + "capability_disabled", # registered but disabled by the host + "unsupported_mode", # invoke mode the host doesn't support + "policy_blocked", # PolicyConfig rule (pattern or risk tier) blocked it + "input_schema_validation_failed", # payload failed the capability's input schema + "invariant_failed", # a declared invariant did not hold + "budget_exceeded", # AutonomyProfile budget (calls/tokens/cost) exhausted + "approval_required", # human approval gate not satisfied + "safety_blocked", # a safety guardrail blocked the invocation + }) + def to_dict(self) -> JSON: return asdict(self) diff --git a/packages/python/chp_core/version_control.py b/packages/python/chp_core/version_control.py index a05782b..46d480c 100644 --- a/packages/python/chp_core/version_control.py +++ b/packages/python/chp_core/version_control.py @@ -1377,6 +1377,22 @@ def parse_numstat(output: str, *, staged: bool) -> list[JSON]: ], "alignment": ["python", "-m", "chp_core.cli", "work", "check-alignment", "--repo-root", "."], "conformance": ["python", "conformance/runner.py"], + # Cross-package gate: run the DOWNSTREAM suites (chp-host, chp-agent) against + # the working-tree (candidate) chp-core, so a chp-core change that breaks a + # consumer fails HERE — not by luck after ship, as happened with the 0.9.0 + # ctx.emit drop (123 chp-agent failures) and the /health change (chp-host). + # Deselects the known env-drift test_serve class (76 adapters installed vs + # its hardcoded set — tracked separately). chp-agent is a sibling repo; + # skipped cleanly when absent (e.g. CI without it checked out). + "crosspkg": [ + "bash", "-c", + "set -e; " + "python -m pytest packages/chp-host/tests/ --no-cov -q -W ignore " + "-k 'not TestBuildAdapterHost'; " # env-drift: 76 installed adapters vs its hardcoded set + "if [ -d ../chp-agent/tests ]; then " + "python -m pytest ../chp-agent/tests/ --no-cov -q -W ignore; " + "else echo 'chp-agent absent — cross-package agent suite skipped'; fi", + ], "schemas": [ "python", "-c", "import json,glob,sys; files=sorted(glob.glob('schemas/*.json'));" diff --git a/packages/python/chp_core/workflow.py b/packages/python/chp_core/workflow.py index 291f4af..031600d 100644 --- a/packages/python/chp_core/workflow.py +++ b/packages/python/chp_core/workflow.py @@ -68,11 +68,6 @@ async def _run_workflow(ctx, payload) -> dict: name: str | None = payload.get("name") raw_steps: list[dict] = payload.get("steps") or [] - ctx.emit( - "execution_started", - {"capability_id": desc.id, "capability_version": desc.version}, - redacted=False, - ) ctx.emit( "workflow_started", {"workflow_id": workflow_id, "name": name, "step_count": len(raw_steps)}, @@ -163,7 +158,6 @@ async def _run_workflow(ctx, payload) -> dict: }, redacted=False, ) - ctx.emit("execution_failed", {"error": step_error}, redacted=False) raise RuntimeError(f"step {step_id!r} failed: {step_error}") total_ms = round((time.perf_counter() - total_start) * 1000, 2) @@ -179,11 +173,6 @@ async def _run_workflow(ctx, payload) -> dict: }, redacted=False, ) - ctx.emit( - "execution_completed", - {"capability_id": desc.id, "outcome": "success"}, - redacted=False, - ) return WorkflowResult( workflow_id=workflow_id, name=name, diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 3ae90db..eb9eb2c 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "chp-core" -version = "0.8.10" +version = "0.9.1" description = "Capability Host Protocol — local execution evidence for agents, tools, and systems" readme = "README.md" requires-python = ">=3.10" @@ -33,7 +33,8 @@ chp = "chp_core.cli:main" chp-git = "chp_core.version_control:GitAdapter" [project.optional-dependencies] -dev = ["jsonschema>=4.23", "pytest>=8.0", "pytest-cov>=5.0", "pytest-asyncio>=0.21", "hypothesis>=6.0"] +dev = ["jsonschema>=4.23", "pytest>=8.0", "pytest-cov>=5.0", "pytest-asyncio>=0.21", "hypothesis>=6.0", "cryptography>=41.0"] +signing = ["cryptography>=41.0"] claude = ["anthropic>=0.30"] openai = ["openai>=1.0"] gemini = ["google-generativeai>=0.5"] diff --git a/packages/python/tests/test_autonomy.py b/packages/python/tests/test_autonomy.py index 9a5e888..ab448ac 100644 --- a/packages/python/tests/test_autonomy.py +++ b/packages/python/tests/test_autonomy.py @@ -279,7 +279,10 @@ def test_spend_limit_payload_has_spend_so_far(tmp_path: Path) -> None: host.invoke("test.noop", correlation=_corr(corr)) host.invoke("test.noop", correlation=_corr(corr)) ev = next(e for e in _events(store_path, corr) if e["event_type"] == "budget_exceeded") - assert ev["payload"]["spend_so_far"] == 2.0 + # Floats are string-encoded in hashed evidence (chp-stable-v1 §2 forbids + # floats in canonicalized content — cross-language hash portability). + assert ev["payload"]["spend_so_far"] == "2.0" + assert float(ev["payload"]["spend_so_far"]) == 2.0 def test_spend_limit_payload_has_spend_limit_and_spend_units(tmp_path: Path) -> None: @@ -289,8 +292,10 @@ def test_spend_limit_payload_has_spend_limit_and_spend_units(tmp_path: Path) -> for _ in range(3): host.invoke("test.noop", correlation=_corr(corr)) ev = next(e for e in _events(store_path, corr) if e["event_type"] == "budget_exceeded") - assert ev["payload"]["spend_limit"] == 5.0 - assert ev["payload"]["spend_units"] == 2.5 + # String-encoded in hashed evidence (chp-stable-v1 §2), numeric on parse. + assert ev["payload"]["spend_limit"] == "5.0" + assert ev["payload"]["spend_units"] == "2.5" + assert (float(ev["payload"]["spend_limit"]), float(ev["payload"]["spend_units"])) == (5.0, 2.5) def test_spend_limit_not_retryable(tmp_path: Path) -> None: diff --git a/packages/python/tests/test_compliance.py b/packages/python/tests/test_compliance.py index ac56c2f..96ec719 100644 --- a/packages/python/tests/test_compliance.py +++ b/packages/python/tests/test_compliance.py @@ -37,6 +37,53 @@ async def _noop(ctx, payload): os.unlink(path) +@pytest.mark.asyncio +async def test_retention_prunes_whole_correlations_and_preserves_survivor_chains(seeded_host): + # A correlation that STARTED old but has recent activity must survive intact + # (pruning individual old events would split its hash chain); a fully-old + # correlation is removed; survivors stay verify_chain-valid. + store, host = seeded_host + for cid in ("corr-old", "corr-mixed"): + await host.ainvoke("test.noop", {}, correlation={"correlation_id": cid}) + await host.ainvoke("test.noop", {}, correlation={"correlation_id": cid}) + + with store._lock: + # corr-old: both events ancient. corr-mixed: first ancient, newest fresh. + store._conn.execute("UPDATE evidence_events SET timestamp='2020-01-01T00:00:00Z' WHERE correlation_id='corr-old'") + store._conn.execute("UPDATE evidence_events SET timestamp='2020-01-01T00:00:00Z' WHERE correlation_id='corr-mixed' AND sequence=(SELECT MIN(sequence) FROM evidence_events WHERE correlation_id='corr-mixed')") + store._conn.commit() + + manager = SQLiteComplianceManager(store) + report = manager.apply_retention([RetentionPolicy( + policy_id="p", applies_to=["*"], retain_days=365, # cutoff ~1yr ago + )]) + + assert store.count_by_correlation("corr-old") == 0 # fully removed + assert report.events_purged >= 1 # corr-old events gone + mixed_count = store.count_by_correlation("corr-mixed") + assert mixed_count == 4 # all 4 survived intact (2 invocations) + assert store.verify_chain("corr-mixed").valid # chain still verifies + + +@pytest.mark.asyncio +async def test_redaction_nulls_content_hash_not_tamper(seeded_host): + # Redacting a payload must NULL the content_hash (honest "unverified"), not + # leave a hash that now mismatches the redacted payload (false "tampered"). + store, host = seeded_host + await host.ainvoke("test.noop", {"secret": "x"}, correlation={"correlation_id": "corr-r"}) + with store._lock: + store._conn.execute("UPDATE evidence_events SET timestamp='2020-01-01T00:00:00Z', payload_json='{\"secret\":\"x\"}' WHERE correlation_id='corr-r'") + store._conn.commit() + manager = SQLiteComplianceManager(store) + manager.apply_retention([RetentionPolicy( + policy_id="p", applies_to=["*"], retain_days=-1, redact_payload_after_days=365, + )]) + # Redacted rows are unverified (hash NULL), not counted as broken/tampered. + result = store.verify_chain("corr-r") + assert result.unverified_count >= 1 + assert result.valid # lenient: NULL-hash rows don't fail the chain + + @pytest.mark.asyncio async def test_generate_report_returns_nonzero_count(seeded_host): store, _ = seeded_host diff --git a/packages/python/tests/test_contract.py b/packages/python/tests/test_contract.py new file mode 100644 index 0000000..7f787d6 --- /dev/null +++ b/packages/python/tests/test_contract.py @@ -0,0 +1,93 @@ +"""Contract tests — freeze the cross-package contracts that broke this session. + +Two breaking changes shipped in 0.9.0 because nothing guarded these contracts: + 1. The ctx.emit guard DROPPED host-reserved lifecycle events → broke 123 + chp-agent tests (which assert capability-level lifecycle emission). + 2. Removing capability_count from /health → broke chp-host tests. + +These live in chp-core's own (gated) suite so either regression fails HERE, at +chp-core commit time, before it can reach a downstream package. A deliberate +change updates the frozen expectation on purpose. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import urllib.request + +from chp_core import LocalCapabilityHost, SQLiteEvidenceStore +from chp_core.http import create_http_server +from chp_core.types import CapabilityDescriptor, CORE_EVIDENCE_TYPES + + +# -------------------------------------------------------------------------- +# Event contract +# -------------------------------------------------------------------------- + +def test_core_evidence_types_frozen(): + # The host-reserved lifecycle event set is a contract with every downstream + # consumer. Changing it is a breaking change — update this set deliberately. + assert CORE_EVIDENCE_TYPES == { + "execution_started", + "execution_completed", + "execution_failed", + "execution_denied", + "execution_skipped", + } + + +def test_ctx_emit_still_records_lifecycle_events(): + # Guards break #1: a capability emitting a host-reserved lifecycle event + # must still be RECORDED (warn-only), not silently dropped — dropping is a + # breaking change for consumers that emit + assert these (e.g. chp-agent). + host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:")) + + async def handler(ctx, _payload): + ev = ctx.emit("execution_started", {"capability_id": "x"}, redacted=False) + assert ev is not None, "ctx.emit must record lifecycle events, not drop them" + return {"ok": True} + + host.register(CapabilityDescriptor(id="c.cap", version="1.0.0", description=""), handler) + asyncio.run(host.ainvoke("c.cap", {}, correlation={"correlation_id": "cc"})) + types = [e["event_type"] for e in host.store.all()] + # host emits its own started/completed; the capability's started is also recorded. + assert types.count("execution_started") == 2 + + +# -------------------------------------------------------------------------- +# HTTP surface contract (/health is public, /host is the authed descriptor) +# -------------------------------------------------------------------------- + +def _serve(host): + server = create_http_server(host, port=0) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, port + + +def _get(port, path): + with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=3) as r: + return json.loads(r.read()) + + +def test_health_vs_host_field_contract(): + # Guards break #2: the unauthenticated /health must NOT disclose + # capability_count (mesh-count privacy); the /host descriptor MUST carry it. + host = LocalCapabilityHost("contract-host", store=SQLiteEvidenceStore(":memory:")) + + async def handler(_ctx, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="m.cap", version="1.0.0", description=""), handler) + server, port = _serve(host) + try: + health = _get(port, "/health") + host_desc = _get(port, "/host") + assert health["status"] == "ok" + assert "capability_count" not in health, "/health must not leak capability_count" + assert "capabilities" in host_desc, "/host must expose the capability list" + finally: + server.shutdown() diff --git a/packages/python/tests/test_http.py b/packages/python/tests/test_http.py index 533d284..cf87bbd 100644 --- a/packages/python/tests/test_http.py +++ b/packages/python/tests/test_http.py @@ -358,5 +358,46 @@ def test_malformed_json_body_returns_error_response(self) -> None: self.assertIn(exc.code, (400, 422, 500)) +def test_authenticated_subject_overrides_client_asserted(monkeypatch) -> None: + # A verified caller's identity must REPLACE any client-asserted subject — + # evidence attributes the action to who authenticated, not to what the body + # claimed ("is agent X", not "claims to be agent X"). + import os + monkeypatch.setenv("CHP_HOST_API_KEYS", "agent-a:s3cret") + host = LocalCapabilityHost("auth-subj-host", store=SQLiteEvidenceStore(":memory:")) + + async def noop(_ctx, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="x.cap", version="1.0.0", description=""), noop) + server = create_http_server(host, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + body = json.dumps({ + "capability_id": "x.cap", "payload": {}, + "subject": {"id": "ATTACKER-spoof", "type": "user"}, + "correlation": {"correlation_id": "c1"}, + }).encode() + req = Request(f"http://127.0.0.1:{server.server_port}/invoke", data=body, + headers={"X-CHP-Key": "s3cret", "Content-Type": "application/json"}, method="POST") + result = json.loads(urlopen(req, timeout=5).read()) + assert result["outcome"] == "success" + subjects = {json.dumps(e.get("subject")) for e in host.store.all() if e.get("subject")} + assert subjects == {'{"id": "agent-a", "type": "api_key", "verified": true}'}, subjects + # a wrong key is rejected outright + bad = Request(f"http://127.0.0.1:{server.server_port}/invoke", data=body, + headers={"X-CHP-Key": "wrong"}, method="POST") + try: + urlopen(bad, timeout=5) + assert False, "expected 401" + except Exception as exc: + assert getattr(exc, "code", None) == 401 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + if __name__ == "__main__": unittest.main() diff --git a/packages/python/tests/test_otel_export.py b/packages/python/tests/test_otel_export.py index b26067e..3f7e1fc 100644 --- a/packages/python/tests/test_otel_export.py +++ b/packages/python/tests/test_otel_export.py @@ -24,6 +24,43 @@ # replay_to_otel_spans # --------------------------------------------------------------------------- +def test_otel_export_is_valid_signed_tree(tmp_path) -> None: + # The signed-OTel differentiator: valid OTLP ids, a real parent/child span + # tree from the causal edge, and CHP's tamper-evidence + denial as span attrs. + import asyncio + import re + from chp_core.host import LocalCapabilityHost + from chp_core.types import CapabilityDescriptor + + store = SQLiteEvidenceStore(str(tmp_path / "t.sqlite")) + host = LocalCapabilityHost(store=store) + + async def child(_ctx, _p): + return {"leaf": True} + + async def parent(ctx, _p): + await ctx.ainvoke("b.child", {}) + return {"ok": True} + + host.register(CapabilityDescriptor(id="b.child", version="1.0.0", description=""), child) + host.register(CapabilityDescriptor(id="a.parent", version="1.0.0", description=""), parent) + asyncio.run(host.ainvoke("a.parent", {}, correlation={"correlation_id": "c1"})) + + spans = replay_to_otel_spans(store.export_correlation("c1")) + by_name = {s["name"]: s for s in spans} + a, b = by_name["a.parent"], by_name["b.child"] + + hexre = re.compile(r"^[0-9a-f]+$") + assert len(a["trace_id"]) == 32 and hexre.match(a["trace_id"]) # valid OTLP trace id + assert len(a["span_id"]) == 16 and hexre.match(a["span_id"]) # valid OTLP span id + assert a["parent_span_id"] is None # A is root + assert b["parent_span_id"] == a["span_id"] # B is A's child (causal tree) + assert a["trace_id"] == b["trace_id"] # same correlation → same trace + assert a["start_time"].isdigit() # unix-nano, not ISO string + assert a["attributes"]["chp.content_hash"] # tamper-evidence anchor carried + assert "chp.denied" in a["attributes"] # denial is a first-class attr + + def test_replay_to_otel_spans_groups_by_invocation(tmp_path) -> None: store_path = str(tmp_path / "s.sqlite") session_id = "otel-group-test" @@ -126,7 +163,9 @@ def test_session_otel_cli_empty_session_exits_1(tmp_path) -> None: # HTTP /health endpoint # --------------------------------------------------------------------------- -def test_health_endpoint_has_capability_count() -> None: +def test_health_endpoint_omits_capability_count() -> None: + # /health is unauthenticated; it must NOT disclose live capability_count + # (mesh-count privacy). The count stays on the authed /host descriptor. from chp_core import LocalCapabilityHost from chp_core.http import create_http_server @@ -143,5 +182,47 @@ def test_health_endpoint_has_capability_count() -> None: data = json.loads(resp.read()) assert data["status"] == "ok" - assert "capability_count" in data + assert "capability_count" not in data assert "host_id" in data + + +def test_governance_decisions_are_queryable_span_attributes(tmp_path) -> None: + # The governance differentiator carried into OTel: safety/approval/budget + # decisions become first-class span attributes a backend can filter on, + # not just nested events. + import asyncio + + from chp_core import ( + AutonomyProfile, + CapabilityDescriptor, + LocalCapabilityHost, + SQLiteEvidenceStore as Store, + ) + from chp_core.safety import RuleBasedSafetyEvaluator + from chp_core.types import GuardrailDefinition + + ev = RuleBasedSafetyEvaluator(guardrails=[GuardrailDefinition( + id="g", capability_id_pattern="x.unsafe", max_risk_level="critical", + requires_human_for=["x.unsafe"])]) + host = LocalCapabilityHost("t", store=Store(str(tmp_path / "e.sqlite")), safety_evaluator=ev) + + async def _h(_c, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="x.unsafe", version="1.0.0", description=""), _h) + host.register(CapabilityDescriptor(id="x.gated", version="1.0.0", description="", + autonomy=AutonomyProfile(tier="approval_required")), _h) + asyncio.run(host.ainvoke("x.unsafe", {}, correlation={"correlation_id": "cs"})) + asyncio.run(host.ainvoke("x.gated", {}, correlation={"correlation_id": "ca"})) + + safety = replay_to_otel_spans(host.store.export_correlation("cs"))[0]["attributes"] + assert safety["chp.safety.assessed"] is True + assert safety["chp.safety.blocked"] is True + assert safety["chp.safety.level"] == "low" + assert safety["chp.denial_code"] == "safety_blocked" + + approval = replay_to_otel_spans(host.store.export_correlation("ca"))[0]["attributes"] + assert approval["chp.approval.requested"] is True + assert approval["chp.denial_code"] == "approval_required" + # A permitted, ungoverned invocation carries no governance attrs (no noise). + assert "chp.safety.assessed" not in approval diff --git a/packages/python/tests/test_policy_gates.py b/packages/python/tests/test_policy_gates.py index cb42b70..277e2b1 100644 --- a/packages/python/tests/test_policy_gates.py +++ b/packages/python/tests/test_policy_gates.py @@ -5,8 +5,16 @@ import pytest from chp_core.hooks import CAPABILITY_RISK_MAP, process_pre_tool_use -from chp_core.policy import BlockPattern, PolicyConfig, evaluate_policy +from chp_core.host import LocalCapabilityHost +from chp_core.policy import ( + BlockPattern, + PolicyConfig, + PolicyError, + evaluate_policy, + load_policy, +) from chp_core.store import SQLiteEvidenceStore +from chp_core.types import CapabilityDescriptor # --------------------------------------------------------------------------- @@ -77,9 +85,19 @@ def test_max_risk_tier_passes_lower_tier() -> None: assert result.should_block is False -def test_max_risk_tier_no_risk_provided_passes() -> None: +def test_max_risk_tier_unknown_risk_defaults_to_medium_and_blocks() -> None: + # Fail-closed: an uncharacterised capability (no/unknown risk) is treated as + # "medium", so a max_risk_tier of "low" blocks it rather than passing it + # through un-gated (previously a fail-open bypass). policy = PolicyConfig(max_risk_tier="low") result = evaluate_policy("unknown.cap", {}, policy, capability_risk=None) + assert result.should_block is True + + +def test_max_risk_tier_unknown_risk_passes_when_max_is_medium() -> None: + # Default "medium" does not exceed a "medium" ceiling — still allowed. + policy = PolicyConfig(max_risk_tier="medium") + result = evaluate_policy("unknown.cap", {}, policy, capability_risk=None) assert result.should_block is False @@ -117,3 +135,56 @@ def test_risk_map_covers_known_high_risk_capabilities() -> None: high_risk = [k for k, v in CAPABILITY_RISK_MAP.items() if v == "high"] assert "codex.delete" in high_risk assert "gemini.delete" in high_risk + + +# --------------------------------------------------------------------------- +# Policy enforced on the host invocation path (not just the Claude Code hook) +# --------------------------------------------------------------------------- + +def _run(coro): + import asyncio + return asyncio.run(coro) + + +def test_host_blocks_capability_on_invoke_path() -> None: + async def handler(_ctx, _payload): + return {"ran": True} + + host = LocalCapabilityHost( + store=SQLiteEvidenceStore(":memory:"), + policy=PolicyConfig(block_capability_ids=["danger.cap"]), + ) + host.register(CapabilityDescriptor(id="danger.cap", version="1.0.0", description=""), handler) + result = _run(host.ainvoke("danger.cap", {}, correlation={"correlation_id": "c1"})) + assert result.outcome == "denied" + assert result.success is False + + +def test_host_allows_unblocked_capability_on_invoke_path() -> None: + async def handler(_ctx, _payload): + return {"ran": True} + + host = LocalCapabilityHost( + store=SQLiteEvidenceStore(":memory:"), + policy=PolicyConfig(block_capability_ids=["other.cap"]), + ) + host.register(CapabilityDescriptor(id="safe.cap", version="1.0.0", description=""), handler) + result = _run(host.ainvoke("safe.cap", {}, correlation={"correlation_id": "c2"})) + assert result.success is True + + +def test_host_no_policy_does_not_block() -> None: + async def handler(_ctx, _payload): + return {"ran": True} + + host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"), policy=None) + host.register(CapabilityDescriptor(id="any.cap", version="1.0.0", description=""), handler) + result = _run(host.ainvoke("any.cap", {}, correlation={"correlation_id": "c3"})) + assert result.success is True + + +def test_load_policy_fails_closed_on_malformed_file(tmp_path) -> None: + bad = tmp_path / "policy.json" + bad.write_text("{ this is not valid json") + with pytest.raises(PolicyError): + load_policy(str(bad)) diff --git a/packages/python/tests/test_prov_export.py b/packages/python/tests/test_prov_export.py new file mode 100644 index 0000000..5577943 --- /dev/null +++ b/packages/python/tests/test_prov_export.py @@ -0,0 +1,91 @@ +"""Tests for W3C PROV-JSON export — signed, governed provenance.""" + +from __future__ import annotations + +import asyncio + +from chp_core import ( + CapabilityDescriptor, + LocalCapabilityHost, + SQLiteEvidenceStore, +) +from chp_core.prov import replay_to_prov +from chp_core.safety import RuleBasedSafetyEvaluator +from chp_core.types import GuardrailDefinition + + +def _host(tmp_path): + ev = RuleBasedSafetyEvaluator(guardrails=[GuardrailDefinition( + id="g", capability_id_pattern="x.unsafe", max_risk_level="critical", + requires_human_for=["x.unsafe"])]) + return LocalCapabilityHost("h1", store=SQLiteEvidenceStore(str(tmp_path / "e.sqlite")), + safety_evaluator=ev) + + +def test_prov_maps_activity_agent_entity(tmp_path): + host = _host(tmp_path) + + async def _h(_c, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="x.act", version="1.0.0", description=""), _h) + asyncio.run(host.ainvoke("x.act", {}, correlation={"correlation_id": "c"}, + subject={"id": "agent-a", "type": "api_key", "verified": True})) + + doc = replay_to_prov(host.store.export_correlation("c")) + assert doc["prefix"]["chp"].startswith("https://chp.dev/prov") + # one Activity (the invocation), the subject Agent + host SoftwareAgent, one Entity. + (act_id, act), = doc["activity"].items() + assert act["chp:capability_id"] == "x.act" + assert act["chp:outcome"] == "success" + assert "chp:agent:agent-a" in doc["agent"] + assert doc["agent"]["chp:agent:agent-a"]["chp:verified"] is True + assert any(a.get("prov:type") == "prov:SoftwareAgent" for a in doc["agent"].values()) + # Entity carries the content_hash tamper anchor. + (ent_id, ent), = doc["entity"].items() + assert ent["prov:type"] == "chp:EvidenceRecord" + assert ent["chp:content_hash"] and ent["chp:hash_chained"] is True + # wasAssociatedWith links the activity to both agents; wasGeneratedBy the entity. + assert any(w["prov:activity"] == act_id for w in doc["wasAssociatedWith"].values()) + assert any(g["prov:entity"] == ent_id for g in doc["wasGeneratedBy"].values()) + + +def test_prov_carries_governance_and_denial(tmp_path): + host = _host(tmp_path) + + async def _h(_c, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="x.unsafe", version="1.0.0", description=""), _h) + asyncio.run(host.ainvoke("x.unsafe", {}, correlation={"correlation_id": "c"})) + + doc = replay_to_prov(host.store.export_correlation("c")) + (_, act), = doc["activity"].items() + # The refusal PROV has no vocabulary for — exported as chp: annotations. + assert act["chp:denied"] is True + assert act["chp:denial_code"] == "safety_blocked" + assert act["chp:safety_assessed"] is True + assert act["chp:safety_blocked"] is True + + +def test_prov_causal_edge_is_was_informed_by(tmp_path): + host = _host(tmp_path) + + async def _child(_c, _p): + return {"ok": True} + + async def _parent(ctx, _p): + await ctx.ainvoke("x.child", {}) # auto-propagates correlation + causation + return {"done": True} + + host.register(CapabilityDescriptor(id="x.child", version="1.0.0", description=""), _child) + host.register(CapabilityDescriptor(id="x.parent", version="1.0.0", description=""), _parent) + asyncio.run(host.ainvoke("x.parent", {}, correlation={"correlation_id": "c"})) + + doc = replay_to_prov(host.store.export_correlation("c")) + assert len(doc["activity"]) == 2, "parent + child activities" + assert "wasInformedBy" in doc, "causal edge must map to wasInformedBy" + (edge,) = doc["wasInformedBy"].values() + # child (informed) was informed by parent (informant) + assert edge["prov:informed"] != edge["prov:informant"] + assert edge["prov:informant"] in doc["activity"] diff --git a/packages/python/tests/test_safety.py b/packages/python/tests/test_safety.py index 806dfa5..49c4eb1 100644 --- a/packages/python/tests/test_safety.py +++ b/packages/python/tests/test_safety.py @@ -117,3 +117,61 @@ async def test_safety_via_host(): store.close() finally: os.unlink(path) + + +# ── Safety as a first-class invocation gate (host-wired, not the capability) ── + +def _safety_host(tmp_path, guardrails): + import asyncio + from chp_core import CapabilityDescriptor, LocalCapabilityHost, SQLiteEvidenceStore + + host = LocalCapabilityHost( + "safety-gate", store=SQLiteEvidenceStore(str(tmp_path / "ev.sqlite")), + safety_evaluator=RuleBasedSafetyEvaluator(guardrails=guardrails), + ) + + async def _h(_ctx, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="gate.act", version="1.0.0", description=""), _h) + return host, asyncio + + +def test_safety_gate_blocks_with_reserved_code_and_events(tmp_path): + g = GuardrailDefinition(id="g", capability_id_pattern="gate.act", + max_risk_level="critical", requires_human_for=["gate.act"]) + host, asyncio = _safety_host(tmp_path, [g]) + r = asyncio.run(host.ainvoke("gate.act", {}, correlation={"correlation_id": "c"})) + assert r.outcome == "denied" + assert r.denial.code == "safety_blocked" + types = [e["event_type"] for e in host.replay("c")] + assert "safety_assessment_started" in types + assert "safety_assessment_completed" in types + assert "safety_action_blocked" in types + assert "execution_started" not in types # blocked before execution + + +def test_safety_gate_records_assessment_even_when_it_permits(tmp_path): + # No matching guardrail → permitted, but the assessment is still evidence. + host, asyncio = _safety_host(tmp_path, []) + r = asyncio.run(host.ainvoke("gate.act", {}, correlation={"correlation_id": "c"})) + assert r.success + types = [e["event_type"] for e in host.replay("c")] + assert "safety_assessment_started" in types + assert "safety_action_approved" in types + assert "execution_completed" in types + + +def test_no_evaluator_means_no_safety_events(tmp_path): + from chp_core import CapabilityDescriptor, LocalCapabilityHost, SQLiteEvidenceStore + import asyncio + + host = LocalCapabilityHost("plain", store=SQLiteEvidenceStore(str(tmp_path / "ev.sqlite"))) + + async def _h(_ctx, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="plain.act", version="1.0.0", description=""), _h) + asyncio.run(host.ainvoke("plain.act", {}, correlation={"correlation_id": "c"})) + types = [e["event_type"] for e in host.replay("c")] + assert not any(t.startswith("safety_") for t in types) diff --git a/packages/python/tests/test_signing.py b/packages/python/tests/test_signing.py new file mode 100644 index 0000000..1ef8b72 --- /dev/null +++ b/packages/python/tests/test_signing.py @@ -0,0 +1,311 @@ +"""Tests for evidence integrity v0.2 — signing.py + strict verify_chain.""" + +from __future__ import annotations + +import asyncio +import os +import stat + +import pytest + +from chp_core.host import LocalCapabilityHost +from chp_core.store import SQLiteEvidenceStore +from chp_core.types import CapabilityDescriptor +from chp_core import signing + + +CORR = "corr-sign-1" + + +def _host_with_events(tmp_path) -> LocalCapabilityHost: + store = SQLiteEvidenceStore(str(tmp_path / "ev.sqlite")) + host = LocalCapabilityHost(store=store) + + async def handler(_ctx, _payload): + return {"ok": True} + + host.register(CapabilityDescriptor(id="s.cap", version="1.0.0", description=""), handler) + asyncio.run(host.ainvoke("s.cap", {"n": 1}, correlation={"correlation_id": CORR})) + asyncio.run(host.ainvoke("s.cap", {"n": 2}, correlation={"correlation_id": CORR})) + return host + + +# -------------------------------------------------------------------------- +# Keypair +# -------------------------------------------------------------------------- + +def test_generate_keypair_private_is_0600(tmp_path): + key = signing.generate_keypair(tmp_path / "keys") + assert key.can_sign + assert len(key.key_id) == 16 + priv = tmp_path / "keys" / "host_ed25519" + mode = stat.S_IMODE(os.stat(priv).st_mode) + assert mode == 0o600, oct(mode) + + +def test_load_host_key_none_when_absent(tmp_path): + assert signing.load_host_key(tmp_path / "nope") is None + + +def test_key_id_stable_from_pubkey(tmp_path): + k1 = signing.generate_keypair(tmp_path / "k") + k2 = signing.load_host_key(tmp_path / "k") + assert k2 is not None and k2.key_id == k1.key_id + + +# -------------------------------------------------------------------------- +# Bundle build / sign / verify round trip +# -------------------------------------------------------------------------- + +def test_unsigned_bundle_verifies_at_hash_chain_tier(tmp_path): + host = _host_with_events(tmp_path) + events = host.store.export_correlation(CORR) + bundle = signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z") + assert bundle["assurance"] == "hash-chain" + v = signing.verify_bundle(bundle) + assert v.valid and v.assurance == "hash-chain" + + +def test_signed_bundle_round_trip(tmp_path): + host = _host_with_events(tmp_path) + key = signing.generate_keypair(tmp_path / "keys") + events = host.store.export_correlation(CORR) + bundle = signing.sign_bundle( + signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z"), key + ) + assert bundle["assurance"] == "signed" + v = signing.verify_bundle(bundle) + assert v.valid + assert v.checks["signature"] is True + # pinning the correct signer still passes + assert signing.verify_bundle(bundle, expected_key_id=key.key_id).valid + + +def test_tampered_event_payload_fails(tmp_path): + host = _host_with_events(tmp_path) + key = signing.generate_keypair(tmp_path / "keys") + events = host.store.export_correlation(CORR) + bundle = signing.sign_bundle( + signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z"), key + ) + # Mutate a payload but leave its content_hash — hash recompute must catch it. + bundle["events"][0]["payload"] = {"n": 999} + v = signing.verify_bundle(bundle) + assert not v.valid + assert v.checks["event_hashes"] is False + + +def test_tampered_root_hash_fails(tmp_path): + host = _host_with_events(tmp_path) + key = signing.generate_keypair(tmp_path / "keys") + events = host.store.export_correlation(CORR) + bundle = signing.sign_bundle( + signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z"), key + ) + bundle["root_hash"] = "0" * 64 + v = signing.verify_bundle(bundle) + assert not v.valid + + +def test_signature_from_unexpected_key_rejected(tmp_path): + host = _host_with_events(tmp_path) + key = signing.generate_keypair(tmp_path / "keys") + events = host.store.export_correlation(CORR) + bundle = signing.sign_bundle( + signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z"), key + ) + # A valid signature, but not from the key we trust. + v = signing.verify_bundle(bundle, expected_key_id="deadbeefdeadbeef") + assert not v.valid + + +def test_forged_signature_fails(tmp_path): + host = _host_with_events(tmp_path) + attacker = signing.generate_keypair(tmp_path / "attacker") + events = host.store.export_correlation(CORR) + # Attacker rebuilds + re-signs after tampering (the exact threat unsigned + # hash-chains can't stop). Verifier pins the real host key → rejected. + real = signing.generate_keypair(tmp_path / "real") + bundle = signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z") + bundle["events"][0]["payload"] = {"n": 999} + forged = signing.sign_bundle(bundle, attacker) + v = signing.verify_bundle(forged, expected_key_id=real.key_id) + assert not v.valid + + +# -------------------------------------------------------------------------- +# Strict verify_chain +# -------------------------------------------------------------------------- + +def test_degrades_without_cryptography(tmp_path, monkeypatch): + # Optional-dep contract: no cryptography → unsigned bundles still build and + # verify at hash-chain tier; only signing raises a clear error. + def _boom(): + raise signing.SigningUnavailable("simulated missing cryptography") + + monkeypatch.setattr(signing, "_load_backend", _boom) + assert signing.signing_available() is False + host = _host_with_events(tmp_path) + events = host.store.export_correlation(CORR) + bundle = signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z") + assert signing.verify_bundle(bundle).valid # hash-chain tier unaffected + with pytest.raises(signing.SigningUnavailable): + signing.generate_keypair(tmp_path / "k2") + + +def test_strict_verify_chain_fails_on_null_hash(tmp_path): + store = SQLiteEvidenceStore(str(tmp_path / "legacy.sqlite")) + # Simulate a legacy event with no content_hash. + with store._lock: + store._conn.execute("INSERT INTO evidence_sequence DEFAULT VALUES") + store._conn.execute( + "INSERT INTO evidence_events (sequence, event_id, event_type, invocation_id, " + "capability_id, host_id, correlation_id, timestamp, payload_json, event_json, " + "content_hash, prev_hash) VALUES (1,'e1','execution_started','i1','c','h','cx','t','{}','{}',NULL,NULL)" + ) + store._conn.commit() + lenient = store.verify_chain("cx") + strict = store.verify_chain("cx", strict=True) + assert lenient.valid is True # legacy tolerated by default + assert strict.valid is False # strict flags the unhashed event + assert strict.first_broken_sequence == 1 + + +def test_verify_attestation_primitive(tmp_path): + # The primitive both the bundle path and the mesh key-pinning path use. + from chp_core.types import utc_now + key = signing.generate_keypair(tmp_path / "keys") + att = signing.build_attestation("prod-gateway", key, valid_from="2026-01-01T00:00:00Z") + + assert signing.verify_attestation(att, public_key=key.public_key_b64, + expected_host_id="prod-gateway", at_time=utc_now()) + # wrong host_id, wrong key, and a tampered claim all fail + assert not signing.verify_attestation(att, expected_host_id="attacker") + assert not signing.verify_attestation(att, public_key="AAAA") + tampered = {**att, "host_id": "spoofed"} + assert not signing.verify_attestation(tampered) + # expired: bundle/pin time after valid_until + expiring = signing.build_attestation("h", key, valid_from="2026-01-01T00:00:00Z", + valid_until="2026-02-01T00:00:00Z") + assert not signing.verify_attestation(expiring, at_time="2026-06-01T00:00:00Z") + + +def test_relabelled_host_id_fails_verification(tmp_path): + # Provenance: the header signature covers host_id, so relabelling the origin + # (the exact "anyone can sign a bundle labeled prod-gateway-acme" gap) breaks it. + host = _host_with_events(tmp_path) + key = signing.generate_keypair(tmp_path / "keys") + events = host.store.export_correlation(CORR) + bundle = signing.sign_bundle( + signing.build_bundle("real-host", events, created_at="2026-07-03T00:00:00Z"), key + ) + assert signing.verify_bundle(bundle).valid + bundle["host_id"] = "prod-gateway-acme" + v = signing.verify_bundle(bundle) + assert not v.valid + assert v.checks["signature"] is False + + +def test_attestation_binds_key_to_host(tmp_path): + host = _host_with_events(tmp_path) + key = signing.generate_keypair(tmp_path / "keys") + events = host.store.export_correlation(CORR) + bundle = signing.sign_bundle( + signing.build_bundle("real-host", events, created_at="2026-07-03T00:00:00Z"), key + ) + att = bundle["host_identity"] + assert att["host_id"] == "real-host" and att["public_key"] == key.public_key_b64 + assert signing.verify_bundle(bundle).checks["host_identity"] is True + # Swapping the attestation's host_id (without re-signing) is caught. + bundle["host_identity"]["host_id"] = "someone-else" + assert signing.verify_bundle(bundle).checks["host_identity"] is False + + +def test_expired_key_identity_fails(tmp_path): + # Key lifecycle: a bundle whose created_at is after the attestation's + # valid_until (the key was rotated out) fails — offline, no wall clock. + host = _host_with_events(tmp_path) + key = signing.generate_keypair(tmp_path / "keys") + events = host.store.export_correlation(CORR) + bundle = signing.sign_bundle( + signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z"), + key, valid_until="2026-01-01T00:00:00Z", # expired well before created_at + ) + v = signing.verify_bundle(bundle) + assert not v.valid + assert v.checks["host_identity"] is False + + +def test_unexpired_key_identity_passes(tmp_path): + host = _host_with_events(tmp_path) + key = signing.generate_keypair(tmp_path / "keys") + events = host.store.export_correlation(CORR) + bundle = signing.sign_bundle( + signing.build_bundle("h", events, created_at="2026-07-03T00:00:00Z"), + key, valid_until="2027-01-01T00:00:00Z", # still valid at created_at + ) + assert signing.verify_bundle(bundle).checks["host_identity"] is True + + +def test_governed_bundle_has_no_floats_and_verifies(tmp_path): + # chp-stable-v1 §2: no floats in canonicalized content. A governed bundle + # carrying a safety score would silently fail cross-language verification if + # the score were a float (Python "0.0" vs JS "0"). Guard: the score is a + # string in the hashed payload, and the signed bundle verifies. + from chp_core.safety import RuleBasedSafetyEvaluator + from chp_core.types import GuardrailDefinition + + ev = RuleBasedSafetyEvaluator(guardrails=[GuardrailDefinition( + id="g", capability_id_pattern="x.cap", max_risk_level="critical", + requires_human_for=[])]) + store = SQLiteEvidenceStore(str(tmp_path / "ev.sqlite")) + host = LocalCapabilityHost("gh", store=store, safety_evaluator=ev) + + async def _h(_c, _p): + return {"ok": True} + + host.register(CapabilityDescriptor(id="x.cap", version="1.0.0", description=""), _h) + asyncio.run(host.ainvoke("x.cap", {}, correlation={"correlation_id": "c"})) + + events = store.export_correlation("c") + completed = next(e for e in events if e["event_type"] == "safety_assessment_completed") + assert isinstance(completed["payload"]["score"], str), "score must be string-encoded, not float" + + def _no_floats(v): + assert not isinstance(v, float), f"float in hashed payload: {v!r}" + if isinstance(v, dict): + [_no_floats(x) for x in v.values()] + elif isinstance(v, list): + [_no_floats(x) for x in v] + for e in events: + _no_floats(e.get("payload") or {}) + + key = signing.generate_keypair(tmp_path / "keys") + bundle = signing.sign_bundle(signing.build_bundle("gh", events, created_at="2026-07-05T00:00:00Z"), key) + assert signing.verify_bundle(bundle).valid + + +def test_published_vectors_match_current_canonicalization(): + # Drift guard: the published spec/test-vectors are what non-Python verifiers + # rely on. If the canonicalization ever changes without regenerating them, + # cross-language verification silently breaks — this test catches it. + import json + from pathlib import Path + from chp_core.store import _compute_event_hash + + root = Path(__file__).resolve().parents[3] / "spec" / "test-vectors" + exp = json.loads((root / "expected.json").read_text()) + ev = json.loads((root / "event.json").read_text())["event"] + + assert _compute_event_hash(ev, None) == exp["event_content_hash"], \ + "content_hash drifted from published vector — regenerate spec/test-vectors" + bundle = json.loads((root / "signed-bundle.json").read_text()) + assert signing.verify_bundle(bundle).valid, "published signed-bundle vector no longer verifies" + assert bundle["root_hash"] == exp["root_hash"] + + # The governed vector: a safety-blocked chain with a string-encoded score. + # If canonicalization or the no-float rule regresses, this stops verifying. + gov = json.loads((root / "governance-bundle.json").read_text()) + assert signing.verify_bundle(gov).valid, "published governance-bundle vector no longer verifies" + completed = next(e for e in gov["events"] if e["event_type"] == "safety_assessment_completed") + assert isinstance(completed["payload"]["score"], str), "governed vector score must be string-encoded" diff --git a/packages/ts-types/README.md b/packages/ts-types/README.md index 88e92c2..ae9ff59 100644 --- a/packages/ts-types/README.md +++ b/packages/ts-types/README.md @@ -1,4 +1,4 @@ -# @auxo/ts-types +# @capabilityhostprotocol/types TypeScript protocol types for CHP v0.1. @@ -12,9 +12,9 @@ older internal mesh/governance model. ## Installation ```bash -npm install @auxo/ts-types +npm install @capabilityhostprotocol/types # or -pnpm add @auxo/ts-types +pnpm add @capabilityhostprotocol/types ``` ## Usage @@ -28,7 +28,7 @@ import type { InvocationEnvelope, InvocationResult, ReplayResult, -} from '@auxo/ts-types'; +} from '@capabilityhostprotocol/types'; ``` ```typescript @@ -69,7 +69,7 @@ import { type Evidence, type RiskClass, type AssuranceTier, -} from '@auxo/ts-types/legacy'; +} from '@capabilityhostprotocol/types/legacy'; // Create a subject const subject = createSubjectContext({ @@ -100,7 +100,7 @@ const evidence = createEvidence({ ## Type Correspondence -| CHP v0.1 Concept | Python `chp_core` | TypeScript `@auxo/ts-types` | +| CHP v0.1 Concept | Python `chp_core` | TypeScript `@capabilityhostprotocol/types` | |---|---|---| | Capability descriptor | `CapabilityDescriptor` | `CapabilityDescriptor` | | Host descriptor | `HostDescriptor` | `HostDescriptor` | @@ -114,10 +114,10 @@ const evidence = createEvidence({ ## Verification ```bash -npm run typecheck --workspace @auxo/ts-types -npm run build --workspace @auxo/ts-types +npm run typecheck --workspace @capabilityhostprotocol/types +npm run build --workspace @capabilityhostprotocol/types ``` ## License -MIT +Apache-2.0. See the repository `LICENSE`. Copyright © 2026 Project Auxo, Inc. diff --git a/packages/ts-types/src/index.ts b/packages/ts-types/src/index.ts index dda9d4c..d53695f 100644 --- a/packages/ts-types/src/index.ts +++ b/packages/ts-types/src/index.ts @@ -5,7 +5,7 @@ * described by `spec/chp-v0.1.md` and the JSON Schemas in `schemas/`. * * Internal legacy mesh/governance helpers are available from - * `@auxo/ts-types/legacy`. + * `@capabilityhostprotocol/types/legacy`. * * @packageDocumentation */ diff --git a/schemas/denial-reason.schema.json b/schemas/denial-reason.schema.json index 824d39a..88de2dc 100644 --- a/schemas/denial-reason.schema.json +++ b/schemas/denial-reason.schema.json @@ -9,16 +9,17 @@ "code": { "type": "string", "minLength": 1, - "description": "Stable denial code. Implementations SHOULD use the standard codes when applicable (spec §9).", + "description": "Stable denial code. A conforming host MUST use a reserved code when one applies (chp-governance-v0.2.md §2); vendor-specific codes MUST be reverse-DNS namespaced (e.g. 'com.acme.quota_exceeded').", "examples": [ "capability_not_found", "capability_disabled", "unsupported_mode", - "invariant_failed", + "policy_blocked", "input_schema_validation_failed", - "policy_block_pattern_matched", - "risk_tier_exceeded", - "entitlement_denied" + "invariant_failed", + "budget_exceeded", + "approval_required", + "safety_blocked" ] }, "message": { diff --git a/schemas/evidence-bundle.schema.json b/schemas/evidence-bundle.schema.json new file mode 100644 index 0000000..a61b4ae --- /dev/null +++ b/schemas/evidence-bundle.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chp.dev/schemas/v0.2/evidence-bundle.schema.json", + "title": "EvidenceBundle", + "description": "A portable, offline-verifiable export of a correlation's evidence (CHP v0.2). Signed bundles are tamper-evident; hash-chain bundles detect mutation/reordering.", + "type": "object", + "required": ["host_id", "protocol_version", "created_at", "canonicalization", "assurance", "events", "root_hash"], + "additionalProperties": false, + "properties": { + "host_id": { + "type": "string", + "description": "The host that produced the evidence." + }, + "protocol_version": { + "type": "string", + "description": "CHP protocol version (e.g. \"0.2\")." + }, + "created_at": { + "type": "string", + "description": "ISO-8601 bundle creation time." + }, + "canonicalization": { + "type": "string", + "description": "Named canonicalization scheme for event hashes.", + "enum": ["chp-stable-v1"] + }, + "assurance": { + "type": "string", + "description": "Assurance tier of this bundle.", + "enum": ["hash-chain", "signed"] + }, + "events": { + "type": "array", + "description": "Ordered evidence events, each carrying its content_hash and prev_hash.", + "items": { "type": "object" } + }, + "root_hash": { + "type": "string", + "description": "SHA256 over each event's content_hash in sequence order.", + "pattern": "^[0-9a-f]{64}$" + }, + "public_key": { + "type": "string", + "description": "Base64 ed25519 public key (present on signed bundles)." + }, + "signature": { + "type": "object", + "description": "ed25519 signature over root_hash (present on signed bundles).", + "required": ["algorithm", "key_id", "signature"], + "additionalProperties": false, + "properties": { + "algorithm": { "type": "string", "enum": ["ed25519"] }, + "key_id": { "type": "string" }, + "signature": { "type": "string", "description": "Base64 signature bytes." } + } + } + }, + "allOf": [ + { + "if": { "properties": { "assurance": { "const": "signed" } } }, + "then": { "required": ["public_key", "signature"] } + } + ] +} diff --git a/schemas/host-descriptor.schema.json b/schemas/host-descriptor.schema.json index bcc1f1b..abb4211 100644 --- a/schemas/host-descriptor.schema.json +++ b/schemas/host-descriptor.schema.json @@ -41,6 +41,23 @@ "type": "object", "additionalProperties": true, "default": {} + }, + "host_version": { + "type": "string", + "description": "Implementation version of the host software (distinct from protocol_version)." + }, + "assurance": { + "type": "string", + "enum": ["none", "hash-chain", "signed"], + "description": "Declared evidence assurance tier (chp-v0.2.md §1). A verifier MUST reject a tier lower than it requires." + }, + "key_id": { + "type": "string", + "description": "Short id of the host signing key (sha256(pubkey)[:16]); present when assurance is 'signed'." + }, + "public_key": { + "type": "string", + "description": "base64 ed25519 public key; present when assurance is 'signed'." } } } diff --git a/spec/chp-governance-v0.2.md b/spec/chp-governance-v0.2.md new file mode 100644 index 0000000..06513fd --- /dev/null +++ b/spec/chp-governance-v0.2.md @@ -0,0 +1,144 @@ +# Capability Host Protocol — Governance Vocabulary (v0.2) + +Status: draft. **Additive** over [v0.1](chp-v0.1.md). Governance is CHP's +differentiator: the boundary doesn't just record *what an agent did*, it records +*what governed it* — denials, risk tiers, autonomy budgets, human approval, +safety guardrails — as first-class, correlated evidence on the same signed plane. +v0.1 left this vocabulary implementation-defined; this document promotes it to +**normative, interoperable** surface so two independent hosts agree on what +`denied`, `high` risk, or `approval_required` mean. + +Key words MUST, SHOULD, MAY per RFC 2119. (This supersedes the private-spec +section numbers — `§7.2`, `§8.5`, `§8.6`, `§9.3`, `§9.5` — cited in earlier code +docstrings; those referred to an unpublished draft. This is the published home.) + +## 1. Scope + +Normative here: the denial-code registry (§2), risk-tier semantics (§3), the +governance event-type vocabulary (§4), and the namespacing convention that keeps +extensions from colliding with the core (§5). All build on the v0.1 outcome model +(`success`/`failure`/`denied`/`skipped`) and the `execution_*` core events. + +## 2. Denial-Code Registry + +A `denied` outcome MUST carry a `DenialReason` (`schemas/denial-reason.schema.json`) +with a stable `code`, human-readable `message`, and `retryable` flag. When a +denial matches one of the reserved conditions below, a conforming host **MUST** +emit the corresponding reserved code (not a synonym) — this is what lets a +consumer branch on `code` across implementations. + +The **exact trigger predicate** for each code, and the **order** in which a host +applies the gates that produce them, are normative in +[chp-invocation-pipeline.md](chp-invocation-pipeline.md). That ordering is +observable (a `policy_blocked` invocation emits no safety events), so an +implementation MUST follow it — the table below is the vocabulary; the pipeline +doc is the authoritative trigger + ordering. Note `capability_disabled` +accompanies a **`skipped`** outcome, not `denied` (pipeline gate 3). + +| Reserved code | Meaning | `retryable` | +|---|---|---| +| `capability_not_found` | No capability with that id (or version) is registered. | false | +| `capability_disabled` | Registered but disabled by the host. | false | +| `unsupported_mode` | The requested invoke `mode` isn't supported. | false | +| `policy_blocked` | A `PolicyConfig` rule blocked it — a block-pattern match **or** the capability's risk tier exceeding `max_risk_tier` (§3). `details` SHOULD name the rule. | false | +| `input_schema_validation_failed` | The payload failed the capability's declared input schema. | false | +| `invariant_failed` | A declared invariant did not hold. `invariant_id` SHOULD be set. | false | +| `budget_exceeded` | An `AutonomyProfile` budget (calls / tokens / cost) was exhausted (§4.1). | true | +| `approval_required` | A human-approval gate is unsatisfied (§4.1). | true | +| `safety_blocked` | A safety guardrail blocked the invocation (§4.2). | false | + +`retryable` is normative advice to the caller: `budget_exceeded` and +`approval_required` describe transient governance state that may clear (budget +resets, approval granted); the rest describe stable rejections that will recur +for the same input. `RESERVED_CODES` in `types.py:DenialReason` is the source of +truth; the schema examples and this table MUST match it (guarded by +`protocol_checks`). + +A host MAY deny for a reason outside this set, but the `code` MUST then be +reverse-DNS namespaced (§5) — e.g. `com.acme.quota_exceeded` — never a bare +lowercase token that could collide with a future reserved code. + +## 3. Risk-Tier Semantics + +A capability MAY declare a risk tier; a `PolicyConfig` MAY cap the allowed tier +via `max_risk_tier`. The tiers are **totally ordered** +`low < medium < high < critical` (`policy.py:RISK_ORDER = {low:0, medium:1, +high:2, critical:3}`). An invocation is `policy_blocked` when the capability's +effective tier orders **above** the policy's `max_risk_tier`. A capability with +an unknown/absent tier is treated as `medium` for this comparison. + +The tiers denote **blast radius if the invocation misbehaves**, not likelihood: + +- `low` — read-only or trivially reversible; no external side effects (a query, a hash). +- `medium` — writes to local/owned state, or reversible external calls (a DB write, an idempotent API call). **Default** for an unclassified capability. +- `high` — non-trivially-reversible external side effects (send a message, spend money, mutate shared infra). +- `critical` — irreversible or wide-blast-radius (delete data, deploy, financial transfer, anything a human would want to sign off first). + +`high`/`critical` are the tiers a host SHOULD gate behind autonomy budgets or +human approval (§4.1). + +## 4. Governance Event Vocabulary + +Governance decisions are recorded as evidence events on the same chain as +`execution_*`, so a replay shows the decision *and* its context in order. The +following event-type families are reserved and normative. + +### 4.1 Autonomy & Approval (`AUTONOMY_EVIDENCE_TYPES`) + +- `budget_exceeded` — an autonomy budget was hit (pairs with the `budget_exceeded` denial). +- `approval_requested` — the host paused an invocation pending human approval. +- `approval_granted` / `approval_denied` — the human decision, correlated to the request. + +A host enforcing an `AutonomyProfile` MUST emit `approval_requested` before an +`approval_required` denial or a paused execution, and exactly one of +`approval_granted`/`approval_denied` when the decision resolves. + +### 4.2 Safety (`SAFETY_EVIDENCE_TYPES`) + +- `safety_assessment_started` / `safety_assessment_completed` — a guardrail evaluation ran. +- `safety_guardrail_triggered` — a guardrail matched. +- `safety_action_blocked` / `safety_action_approved` — the guardrail's decision. + +A host with a configured safety evaluator MUST emit the assessment pair +(`safety_assessment_started` / `safety_assessment_completed`) around every +governed invocation and a `safety_action_approved` or, when a guardrail blocks, +`safety_guardrail_triggered` + `safety_action_blocked` for its decision — so the +safety verdict is auditable alongside the execution it governed. A guardrail +block denies with the reserved `safety_blocked` code (§2). The assessment is +recorded even when it permits the action: a signed safety verdict on every +invocation is the point, not only the blocks. + +### 4.3 Incident & Compliance + +`INCIDENT_EVIDENCE_TYPES` (`incident_opened`, `incident_escalated`, +`incident_remediation_applied`, `incident_resolved`, `incident_closed`, +`incident_trigger_fired`) and `COMPLIANCE_EVIDENCE_TYPES` +(`retention_policy_applied`, `evidence_purged`, `evidence_redacted`, +`compliance_report_generated`) are reserved with their lifecycle orderings as +named. Redaction/purge events are how retention (v0.2 §4) stays auditable: the +act of removing evidence is itself evidence. + +## 5. Namespacing & Extension + +To let independent implementations extend the vocabulary without colliding: + +- **Reserved core.** All event types published here and in v0.1 (the + `execution_*` core and the `*_EVIDENCE_TYPES` families), all reserved denial + codes (§2), and the `chp.*` capability-id prefix are **reserved**. An + implementation MUST NOT redefine their meaning. +- **Vendor extensions.** Custom event types, denial codes, and capability ids + MUST be **reverse-DNS namespaced** under a domain the author controls — + `com.acme.deploy_requested`, `com.acme.quota_exceeded`, + `com.acme.billing.charge`. This guarantees two vendors can't silently collide + on a bare name like `task_completed`. +- A bare (non-namespaced) name that is not in the reserved set is **undefined**; + a strict consumer MAY reject it. This is a convention, not a central registry — + no IANA-style allocation is required. + +## 6. Conformance + +A host that emits governance evidence MUST: use reserved denial codes for +reserved conditions (§2); order risk tiers per §3; emit the approval/safety event +sequences in §4 when it runs those subsystems; and reverse-DNS namespace every +extension (§5). `protocol_checks` asserts the runtime, schema, and this document +agree on the reserved denial set. diff --git a/spec/chp-http-binding.md b/spec/chp-http-binding.md new file mode 100644 index 0000000..2f2e4ba --- /dev/null +++ b/spec/chp-http-binding.md @@ -0,0 +1,104 @@ +# Capability Host Protocol — HTTP Binding (v0.1) + +Status: draft. Normative binding of the CHP object model +([v0.1](chp-v0.1.md)) onto HTTP, so a host in any language is wire-compatible +with the reference `RemoteCapabilityHost` client and the black-box conformance +runner (`conformance/runner.py --url`). CHP is transport-agnostic; this is the +one binding v0.2 specifies normatively. + +Key words MUST, SHOULD, MAY per RFC 2119. + +## 1. The load-bearing rule: process vs transport + +**A CHP outcome is not an HTTP status.** If the host *processed* an invocation — +including deciding to deny, fail, or skip it — the HTTP response is **`200 OK`** +and the CHP `outcome` lives in the JSON body. A non-2xx status means the request +never became a governed invocation (bad JSON, unknown route, unauthenticated). + +| Situation | HTTP status | Body | +|---|---|---| +| Invocation processed — `success`, `failure`, `denied`, or `skipped` | `200` | `InvocationResult` (`outcome` field carries the verdict) | +| Malformed / missing field | `400` | error envelope | +| Missing or invalid `X-CHP-Key` | `401` | error envelope | +| Unknown route | `404` | error envelope | + +This is deliberate: a `denied` invocation is a **successful governance +decision**, not a transport failure — it produces evidence and MUST be returned +as `200` so the caller reads `outcome: "denied"` and its `DenialReason` +(chp-governance-v0.2.md §2), never a bare `403`. A client that treats HTTP status +as the outcome is non-conforming. + +The error envelope is `{"error": {"code": string, "message": string}}`. + +## 2. Authentication + +Authenticated routes require an **`X-CHP-Key`** request header. The host compares +credentials in **constant time** (MUST — no early-exit string compare). Two +configurations: + +- **Shared key** — a single key accepted from any caller. +- **Named per-caller keys** — `name:key` pairs. On a match, the host binds the + caller `name` as the **verified `subject`** on the resulting evidence, + overriding any `subject` in the request body (chp-v0.2.md §5 — a MUST for the + governed/signed tiers). This is what makes "agent X did Y" *provable* rather + than *asserted*. + +If no keys are configured the host MAY accept all callers (local-first default). +Network-layer confidentiality (e.g. a private mesh) MAY substitute for TLS. + +## 3. Routes + +`/` and `/health` are **public** (unauthenticated) for mesh probes and load +balancers; every other route requires auth (§2). + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| GET | `/health` (= `/`) | public | — | `{status:"ok", host_id, protocol:"chp", version, host_version}` | +| GET | `/host` | required | — | `HostDescriptor` (+ `host_version`, and `assurance`/`key_id`/`public_key` when signed) | +| GET | `/capabilities` | required | — | `{capabilities: CapabilityDescriptor[]}` | +| POST | `/invoke` | required | `InvocationEnvelope` | `InvocationResult` (see §1) | +| GET | `/replay/{correlation_id}` | required | — | `ReplayResult` | +| POST | `/replay` | required | `ReplayQuery` | `ReplayResult` | +| GET | `/verify/{correlation_id}` | required | — | chain-verification result (§4) | +| GET | `/metrics` | required | — | Prometheus text (`text/plain; version=0.0.4`) | + +`/invoke` accepts a convenience form: a top-level `correlation_id` is lifted into +`correlation.correlation_id`. Responses are JSON with sorted keys. + +`/health` MUST NOT disclose the live capability count (it stays on the authed +`/host` descriptor) — mesh-count privacy. `version` here is the protocol version; +`host_version` is the implementation version. + +A host MAY add non-normative routes (the reference host exposes an OpenAI- +compatible `/v1/chat/completions` inference shim); a conforming client MUST NOT +depend on them. + +## 4. Verification route + +`GET /verify/{correlation_id}` returns the host's own chain check for that +correlation — at minimum `{valid: boolean}`, plus `first_broken_sequence` and the +counts the store's `verify_chain` produces. A gateway that holds no local store +(evidence distributed across a mesh) MUST instead return a JSON object with a +`note` explaining verification isn't available in gateway mode and the `hosts` +that hold the evidence — never a false `valid`. Offline bundle verification +(signatures, cross-language) is separate and lives in chp-v0.2.md §3. + +## 5. Conformance + +The black-box runner (`conformance/runner.py --url `) drives a running host +over this binding through the reference `RemoteCapabilityHost` client and checks +the class-A normative behaviours: discovery (`/host`, `/capabilities`), an +`InvocationEnvelope` round-trip, correlation propagation, the §1 status rule +(a denied invocation returns `200` with `outcome:"denied"`), replay by +correlation id, and — when the host declares a signed tier — `/verify`. + +A host-under-test SHOULD pre-register the **conformance fixture profile** so the +runner has known capabilities to exercise: `conformance.echo` (returns its +payload — `success`), `conformance.fail` (always `failure`), `conformance.guarded` +(`denied` with a reserved `DenialReason`), `conformance.approval` (autonomy tier +`approval_required` — exercises the approval-gate governance path), +`conformance.budgeted` (autonomy `action_limit=1` — exercises the budget path), +`conformance.risky` (risk tier above the host's cap — exercises risk-tier +enforcement), and `conformance.unsafe` (blocked by a safety guardrail — +exercises the safety path). The runner reports which normative checks the wire +host passed. diff --git a/spec/chp-invocation-pipeline.md b/spec/chp-invocation-pipeline.md new file mode 100644 index 0000000..fcc6308 --- /dev/null +++ b/spec/chp-invocation-pipeline.md @@ -0,0 +1,108 @@ +# Capability Host Protocol — Governed Invocation Pipeline (v0.2) + +Status: draft. **Normative.** Additive over [v0.1](chp-v0.1.md); refines the +outcome model (§8) and denial semantics (§9) of v0.1 and the governance +vocabulary of [chp-governance-v0.2.md](chp-governance-v0.2.md). + +Key words MUST, SHOULD, MAY per RFC 2119. + +## 1. Purpose + +v0.1 specifies *what* an invocation outcome and a denial are; the governance doc +specifies the *vocabulary* (reserved denial codes, event types). Neither pins the +**order** in which a host applies its gates, nor the **exact trigger predicate** +for each reserved denial code. That order is observable — two hosts that gate in +a different order emit *different evidence chains for identical input* — so it +MUST be normative for independent implementations to agree. This document is the +authoritative source for both the gate ordering and each reserved code's trigger. + +A conforming host processes every invocation through the gates below **in this +order**. The first gate whose condition holds determines the outcome and the host +MUST stop (no later gate runs, no handler executes). This ordering is why, e.g., a +`policy_blocked` invocation emits **no** safety-assessment events: policy (gate 5) +precedes safety (gate 9). + +"Processed" means the invocation reached the pipeline. Every processed invocation +— including a denial or skip — produces evidence and, over the HTTP binding, +returns **HTTP 200** with the outcome in the body (see +[chp-http-binding.md](chp-http-binding.md) §1). Transport-level rejections +(malformed request, failed auth, unknown route) are *not* processed invocations. + +## 2. The pipeline (MUST be applied in this order) + +For each gate: **Trigger** is the exact predicate; **Outcome** is the +`InvocationResult.outcome`; **Code** is the reserved `DenialReason.code` (n/a for +non-denials); **Events** are the evidence events emitted for that gate. + +| # | Gate | Trigger (exact predicate) | Outcome | Code | `retryable` | Events emitted | +|---|------|---------------------------|---------|------|-------------|----------------| +| 1 | Non-empty id | `capability_id` is missing, empty, or whitespace-only | `denied` | `capability_not_found` | false | `execution_denied` | +| 2 | Resolution | No registered capability matches `(capability_id, version)`. If `version` is null and exactly one version is registered, it resolves; an ambiguous unversioned match does **not** resolve | `denied` | `capability_not_found` | false | `execution_denied` | +| 3 | Enabled | The resolved capability is registered but **disabled** | **`skipped`** | `capability_disabled` | n/a | `execution_skipped` | +| 4 | Mode | `envelope.mode` ∉ `descriptor.modes` | `denied` | `unsupported_mode` | false | `execution_denied` | +| 5 | Policy | A `PolicyConfig` is active and blocks (see §3) | `denied` | `policy_blocked` | false | `execution_denied` | +| 6 | Invariants | A host-enforced invariant with `failure_behavior="deny"` does not hold for the payload | `denied` | `invariant_failed` | false | `execution_denied` (carries `invariant_id`) | +| 7 | Autonomy | An `AutonomyProfile` budget/tier gate fires (see §4) | `denied` | `budget_exceeded` **or** `approval_required` | see §4 | a governance event **then** `execution_denied` | +| 8 | Input schema | `descriptor.input_schema` is set and the payload fails JSON-Schema validation | `denied` | `input_schema_validation_failed` | false | `execution_denied` (SHOULD carry `schema_id`, `path`) | +| 9 | Safety | A safety evaluator is configured and a guardrail blocks (see §5) | `denied` | `safety_blocked` | false | assessment pair + guardrail/block events **then** `execution_denied` | +| 10 | Execute | All gates passed | `success` \| `failure` | n/a | n/a | `execution_started` → `execution_completed` (success) \| `execution_failed` (handler raised) | + +**Subtlety 1 — gate 3 is a skip, not a deny.** A disabled capability yields +outcome **`skipped`** and event `execution_skipped`, *not* `denied`. It carries +the `capability_disabled` code as descriptive metadata, but it is not a denial. +Implementers frequently get this wrong. + +**Subtlety 2 — see §4** for the autonomy counting rule. + +## 3. Gate 5 — Policy (evaluation order within the gate) + +When a `PolicyConfig` is active, evaluate in this order; the first match blocks: +1. **Allowlist** — if `allowed_capability_ids` is set and the id is not in it → block. +2. **Blocked ids** — if the id is in `block_capability_ids` → block. +3. **Risk tier** — if `max_risk_tier` is set: the capability's effective risk + (`descriptor.risk`; an unknown/absent tier is treated as `medium`) ordered + above `max_risk_tier` under `low < medium < high < critical` → block. +4. **Block patterns** — if any `block_patterns` entry matches the payload + (case-insensitive substring/regex per the pattern) → block. + +A policy in `audit_only` mode records the decision as evidence but MUST NOT block +(the invocation proceeds to gate 6). A block at any step yields `policy_blocked`; +the `details` SHOULD identify which rule matched. + +## 4. Gate 7 — Autonomy (budget + approval) + +When `descriptor.autonomy` is set, evaluate in this order: +1. **`action_limit`** — count the `execution_started` events already recorded for + this `correlation_id`. **Only `execution_started` counts** — denials, skips, + and governance side-events do NOT. If the count ≥ `action_limit`: emit a + `budget_exceeded` **event**, then deny `budget_exceeded` (`retryable: true`). +2. **`spend_limit`** — if `execution_started_count × spend_units ≥ spend_limit`: + emit `budget_exceeded`, then deny `budget_exceeded` (`retryable: true`). +3. **`tier == "approval_required"`** — emit an `approval_requested` **event**, + then deny `approval_required` (`retryable: true`). + +The governance event MUST be emitted **before** the `execution_denied` event, so a +replay shows the budget/approval decision preceding the denial. `budget_exceeded` +and `approval_required` are `retryable` because the governing state can clear +(budget resets, approval is granted); all other reserved codes are non-retryable. + +## 5. Gate 9 — Safety + +When a safety evaluator is configured, it assesses **every** invocation that +reaches this gate (i.e. one that passed gates 1–8): +1. Emit `safety_assessment_started`, then `safety_assessment_completed` (carrying + `level`, `score`, `approved`) — **on every such invocation**, whether or not it + blocks. A signed safety verdict on every governed invocation is the point. +2. If a guardrail blocks: emit `safety_guardrail_triggered`, then + `safety_action_blocked`, then deny `safety_blocked`. +3. If it permits: emit `safety_action_approved` and proceed to gate 10. + +## 6. Conformance + +A host that applies these gates out of order, emits a denial where a skip is +required (gate 3), miscounts the autonomy budget (§4.1), or omits a required +governance event is **non-conforming** even if each individual outcome looks +correct — because its evidence chain for a given input differs from the reference. +The `wire` conformance suite ([chp-http-binding.md](chp-http-binding.md) §5) +exercises gates 2, 3, 4, 5(risk), 6, 7(budget+approval), 8, 9 and the success/ +failure paths against the fixture profile ([conformance/FIXTURES.md](../conformance/FIXTURES.md)). diff --git a/spec/chp-v0.1.md b/spec/chp-v0.1.md index 33acfc0..9b93895 100644 --- a/spec/chp-v0.1.md +++ b/spec/chp-v0.1.md @@ -246,13 +246,12 @@ CHP v0.1 does not define: - a required network transport - a workflow language - an agent framework -- a complete policy engine -- enterprise RBAC +- a standalone entitlement / enterprise-RBAC product — CHP *governs* at the boundary (policy, risk tier, invariants, safety, human approval, autonomy budgets) and records each decision as first-class signed evidence; it is not a separate policy-management product - hosted retention -- cryptographic proof of evidence integrity -- a replacement for MCP, OpenTelemetry, Temporal, Kafka, or API gateways -These systems can integrate with CHP, but CHP v0.1 stays focused on the capability execution boundary. +Note: cryptographic proof of evidence integrity is **not** a non-goal — it is delivered by the v0.2 `signed` assurance tier (hash chain + ed25519). It was out of scope for the v0.1 baseline only. + +CHP is complementary to MCP, OpenTelemetry, Temporal, Kafka, and API gateways and interoperates with them (e.g. exporting evidence as signed OpenTelemetry spans) — but it is not a re-skin of any of them: it is the one *governed, signed* plane, where those carry either tools without evidence, or execution telemetry without governance or integrity. CHP v0.1 stays focused on the capability execution boundary. ## 13. Versioning Strategy diff --git a/spec/chp-v0.2.md b/spec/chp-v0.2.md new file mode 100644 index 0000000..15280ae --- /dev/null +++ b/spec/chp-v0.2.md @@ -0,0 +1,156 @@ +# Capability Host Protocol — v0.2 (Evidence Integrity) + +Status: draft. **Additive** over [v0.1](chp-v0.1.md); a v0.1-only host remains +conformant at the `none` assurance tier. v0.2 defines an *optional* tamper- +evident evidence layer without changing the v0.1 local-first experience. + +Key words MUST, SHOULD, MAY per RFC 2119. + +## 1. Assurance Tiers + +A host declares one of three evidence assurance tiers: + +- `none` — local append-only evidence (v0.1 baseline). +- `hash-chain` — each event carries `content_hash` + `prev_hash` forming a + per-store SHA256 chain that detects mutation and reordering. +- `signed` — a `hash-chain` bundle whose root hash is signed with the host's + ed25519 key, detecting tampering by any party without the private key. + +A host MUST declare its tier in the `/host` descriptor as `assurance`. A signed +host MUST additionally expose `key_id` and `public_key`. A verifier MUST reject +a tier lower than the one it requires rather than silently degrading. + +## 2. Hash Chain (`hash-chain` and above) + +Each evidence event MUST carry: +- `content_hash` — SHA256 over the event's canonicalized stable fields + (`event_id`, `event_type`, `invocation_id`, `capability_id`, `host_id`, + `correlation_id`, `timestamp`, `outcome`, `payload`) plus the `prev_hash`. +- `prev_hash` — the `content_hash` of the preceding event in the same chain. + +The canonicalization scheme MUST be named. This version defines **`chp-stable-v1`** +**byte-exact** so any implementation — in any language — computes the identical +`content_hash` and can verify a signature produced by another. The stable object is: + +``` +{event_id, event_type, invocation_id, capability_id, host_id, + correlation_id, timestamp, outcome, payload, prev_hash} +``` + +where `correlation_id` is extracted from the event's `correlation` object and +`prev_hash` is a member key (null for the first event). It is serialized with +these exact rules (matching Python `json.dumps(obj, sort_keys=True)`): + +1. **Object keys sorted** ascending by Unicode code point, **recursively**. +2. **Separators with spaces**: `", "` between members/items, `": "` between key + and value. (Object `{"a": 1, "b": 2}`, not compact `{"a":1,"b":2}`.) +3. **ASCII-only strings**: every non-ASCII character is escaped as `\uXXXX` + (lowercase hex; surrogate pairs for astral code points). `"café"` → + `"café"`. (This supersedes any earlier "over UTF-8" wording.) +4. Standard JSON escapes for `"` `\` and control chars `\b \t \n \f \r`, + `\u00XX` for other C0 controls. +5. `null`/`true`/`false` lowercase; integers bare. +6. **No non-integer numbers.** Canonicalized content MUST NOT contain a JSON + *float*. Float-to-string serialization is not portable — Python `json.dumps` + emits `0.0` where an ECMAScript `Number.toString` emits `0`, so the same value + would hash differently across languages. A value that is conceptually + fractional (e.g. a safety `score`, an autonomy `spend`) MUST be represented in + canonicalized fields as a **string** (fixed precision, e.g. `"0.000"`) or a + scaled integer. Producers put the human/float form only in non-hashed surfaces + (an `InvocationResult.data`, an OTel attribute), never in an evidence event + payload. (This is the one place `chp-stable-v1` deliberately narrows JSON.) +7. The resulting string is UTF-8-encoded (pure ASCII here) and SHA-256'd → + lowercase hex `content_hash`. + +The **root hash** = SHA-256 over each event's `content_hash`, in sequence order, +each **followed by a `\n`** (`0x0a`) — i.e. `sha256(h1 + "\n" + h2 + "\n" + …)`. +The **signature** (ed25519) is computed over the **ASCII-hex `root_hash` string** +(not the raw digest bytes). + +`spec/test-vectors/` publishes fixed inputs → exact `content_hash`, `root_hash`, +and signature (with a fixed key seed), plus `verify.mjs` — a stdlib-only Node +verifier that validates a Python-signed bundle from these rules alone, proving +cross-language interoperability. A future `chp-jcs-v1` (RFC 8785 JCS: compact, +raw-UTF-8) MAY be added non-breakingly via the `canonicalization` field. + +Strict verification MUST fail on any event lacking a `content_hash`. Lenient +verification MAY tolerate legacy unhashed events, but MUST NOT be the default +for a host declaring `hash-chain` or `signed`. + +## 3. Signed Bundles (`signed`) + +A host at the `signed` tier MUST support exporting a correlation as a bundle: + +```json +{ + "host_id": "…", "protocol_version": "0.2", "created_at": "…", + "canonicalization": "chp-stable-v1", "assurance": "signed", + "events": [ … ], "root_hash": "hex…", + "public_key": "base64…", + "host_identity": { + "host_id": "…", "public_key": "base64…", "key_id": "…", + "valid_from": "…", "valid_until": null, "signature": "base64…" + }, + "signature": { "algorithm": "ed25519", "key_id": "…", "signature": "base64…" } +} +``` + +- `root_hash` MUST be the SHA256 over each event's `content_hash` in sequence + order. Signing the root (not each event) is REQUIRED. +- **The signature covers the canonical *header***, not just `root_hash`. The + signed message is `chp-stable-v1( {host_id, protocol_version, created_at, + canonicalization, root_hash} )`. This binds the claimed origin/time/scheme + into the signature: a relabelled top-level `host_id` (or `created_at`, …) + MUST fail verification. (Events are bound transitively via `root_hash`.) +- **Host-identity attestation** (`host_identity`): a self-signed statement + binding `host_id` ↔ `public_key`. `key_id = sha256(pubkey)[:16]` only binds a + key to *itself* and `host_id` is a free string, so a signed bundle otherwise + proves *integrity*, not *provenance*. The attestation is + `chp-stable-v1({host_id, public_key, key_id, valid_from, valid_until})` signed + by the key. A verifier at the `signed` tier MUST, when `host_identity` is + present, check that its `host_id`/`public_key` match the bundle and its + signature verifies under `public_key`. This is the trust *floor* (the key + self-asserts its host_id — TOFU/`mesh.py:pin_or_check_key` pins it on first + contact); anchoring `public_key` to a resolvable external identity (e.g. a + Radicle DID) is the ceiling and is OPTIONAL. +- A verifier MUST check: per-event hash recompute, chain continuity, root hash, + the header signature, and (when present) the host-identity attestation. A + verifier offered an `expected_key_id` MUST reject a bundle signed by any other + key. + +A `signed` host SHOULD also serve its `host_identity` attestation on the `/host` +descriptor, so a mesh peer can verify the key self-attests this `host_id` (and is +within its validity window) **before** trust-on-first-use pinning it — rather than +pinning whatever `/host` self-reports. This is the same offline +`verify_attestation` check the bundle path uses. + +Key rotation uses `valid_from`/`valid_until` in the `host_identity` attestation; +a rotated key is a new identity. A verifier MUST reject a signed bundle whose +`created_at` falls outside the attestation's `[valid_from, valid_until]` window +(the key had expired when it signed) — enforced offline against `created_at`, so +no wall clock or revocation infrastructure is required at this tier. `null` +bounds are unbounded. Chained rotation (a new key countersigned by the old) and +a published key-transparency registry are deliberately out of scope until +cross-organization verification is a live requirement. + +## 4. Retention (all tiers) + +Retention MUST NOT break the verifiability of retained evidence. An +implementation MUST prune at whole-correlation granularity (or an equivalent +that never orphans a survivor's `prev_hash`). Payload redaction that rewrites a +stored event MUST clear that event's `content_hash` (rendering it `unverified`) +rather than leave a hash that no longer matches. + +## 5. Transport / Auth (informative for v0.2) + +HTTP hosts MUST compare authentication credentials in constant time. A host +SHOULD bind an authenticated caller to a verified `subject` on the evidence it +records. Network-layer confidentiality (e.g. a private mesh) MAY substitute for +transport TLS. + +## 6. Conformance + +v0.2 adds these checks to the runner (gated by declared tier): +`signed evidence bundle`, `strict verify rejects unhashed`, +`retention preserves chain`. A host declaring a tier MUST pass the checks for +that tier and below. diff --git a/spec/test-vectors/canon/cases.json b/spec/test-vectors/canon/cases.json new file mode 100644 index 0000000..f7ddde8 --- /dev/null +++ b/spec/test-vectors/canon/cases.json @@ -0,0 +1,108 @@ +{ + "canonicalization": "chp-stable-v1", + "note": "canon(value) = json.dumps(value, sort_keys=True) per spec/chp-v0.2.md 2. A conforming implementation MUST reproduce every expected_canon byte-for-byte. Floats are intentionally absent - chp-stable-v1 forbids them in canonicalized content (2 rule 6).", + "cases": [ + { + "name": "ascii_string", + "input": "hello", + "expected_canon": "\"hello\"" + }, + { + "name": "non_ascii_escaped", + "input": "caf\u00e9", + "expected_canon": "\"caf\\u00e9\"" + }, + { + "name": "emoji_surrogate_pair", + "input": "\ud83d\udd12", + "expected_canon": "\"\\ud83d\\udd12\"" + }, + { + "name": "control_chars", + "input": "\b\t\n\f\r", + "expected_canon": "\"\\b\\t\\n\\f\\r\"" + }, + { + "name": "c0_control_u0001", + "input": "\u0001", + "expected_canon": "\"\\u0001\"" + }, + { + "name": "del_0x7f", + "input": "\u007f", + "expected_canon": "\"\\u007f\"" + }, + { + "name": "quote_and_backslash", + "input": "a\"b\\c", + "expected_canon": "\"a\\\"b\\\\c\"" + }, + { + "name": "key_sort_flat", + "input": { + "c": 1, + "a": 2, + "b": 3 + }, + "expected_canon": "{\"a\": 2, \"b\": 3, \"c\": 1}" + }, + { + "name": "key_sort_nested", + "input": { + "z": { + "b": 1, + "a": 2 + }, + "a": [ + 3, + 2, + 1 + ] + }, + "expected_canon": "{\"a\": [3, 2, 1], \"z\": {\"a\": 2, \"b\": 1}}" + }, + { + "name": "integers_and_bools", + "input": { + "i": 42, + "neg": -7, + "t": true, + "f": false, + "n": null + }, + "expected_canon": "{\"f\": false, \"i\": 42, \"n\": null, \"neg\": -7, \"t\": true}" + }, + { + "name": "empty_object", + "input": {}, + "expected_canon": "{}" + }, + { + "name": "empty_array", + "input": [], + "expected_canon": "[]" + }, + { + "name": "unicode_key_sort", + "input": { + "\u00e9": 1, + "a": 2, + "z": 3 + }, + "expected_canon": "{\"a\": 2, \"z\": 3, \"\\u00e9\": 1}" + }, + { + "name": "nested_mixed", + "input": { + "event": { + "payload": { + "note": "caf\u00e9 \ud83d\udd12", + "n": 1 + }, + "ok": true + } + }, + "expected_canon": "{\"event\": {\"ok\": true, \"payload\": {\"n\": 1, \"note\": \"caf\\u00e9 \\ud83d\\udd12\"}}}" + } + ] +} diff --git a/spec/test-vectors/event.json b/spec/test-vectors/event.json new file mode 100644 index 0000000..a1e0d10 --- /dev/null +++ b/spec/test-vectors/event.json @@ -0,0 +1,19 @@ +{ + "event": { + "capability_id": "demo.cap", + "correlation": { + "causation_id": null, + "correlation_id": "corr_test" + }, + "event_id": "evt_test0002", + "event_type": "execution_started", + "host_id": "vector-host", + "invocation_id": "inv_test0002", + "outcome": null, + "payload": { + "note": "caf\u00e9" + }, + "timestamp": "2026-01-01T00:00:00Z" + }, + "prev_hash": null +} diff --git a/spec/test-vectors/expected.json b/spec/test-vectors/expected.json new file mode 100644 index 0000000..3c71c97 --- /dev/null +++ b/spec/test-vectors/expected.json @@ -0,0 +1,15 @@ +{ + "chained": { + "completed_content_hash": "dd80f48eff20ee43edcd04d34af76378fb53f08a74407d09aba33c1870e25724", + "prev_hash_of_completed": "9c0b8cf83a8638fe27a201fa291a7c0bf4f99b2b174fb60786362a865b5e4dd5", + "started_content_hash": "9c0b8cf83a8638fe27a201fa291a7c0bf4f99b2b174fb60786362a865b5e4dd5" + }, + "event_content_hash": "9c0b8cf83a8638fe27a201fa291a7c0bf4f99b2b174fb60786362a865b5e4dd5", + "host_identity_signature_b64": "Tfsj5Su5RD/IpDWm6LLAfZ5rNmP6gDf/V2efNZZcqYMeWJg7ZWHRBSmcBGyp0km9NajWcOTKAoSLdC3+gWpvBw==", + "note": "Deterministic vectors for chp-stable-v1. Recompute to check a re-implementation.", + "private_key_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "public_key_b64": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", + "root_hash": "8a81bf36321de97a88bdc793978879e6c61134e70613160ad624bdb207795557", + "signature_b64": "uZAYj9ZXTOubcHHY/AGqEt4gOxll5UG0V6d1/cJB+GKcEE+XrpmVRlmxJjIMtu+/T8rxWf5hVW3lDRQcn4dKAw==", + "signed_over": "canonical bundle header (host_id, protocol_version, created_at, canonicalization, root_hash)" +} diff --git a/spec/test-vectors/governance-bundle.json b/spec/test-vectors/governance-bundle.json new file mode 100644 index 0000000..8fe4314 --- /dev/null +++ b/spec/test-vectors/governance-bundle.json @@ -0,0 +1,119 @@ +{ + "assurance": "signed", + "canonicalization": "chp-stable-v1", + "created_at": "2026-01-01T00:00:00Z", + "events": [ + { + "capability_id": "conformance.unsafe", + "content_hash": "1424fae3aba218597a6bcf32e99a7dba140f14b0a0bbb600c87f75940674ae2b", + "correlation": { + "causation_id": null, + "correlation_id": "gov_corr" + }, + "event_id": "evt_gov0001", + "event_type": "safety_assessment_started", + "host_id": "vector-host", + "invocation_id": "inv_gov0001", + "outcome": null, + "payload": { + "capability_uri": "conformance.unsafe:1.0.0" + }, + "prev_hash": null, + "timestamp": "2026-01-01T00:00:00Z" + }, + { + "capability_id": "conformance.unsafe", + "content_hash": "23b02a22661d9e720a4652090a3f54fc4ad3ad9a9d8588da982a8139c924d55e", + "correlation": { + "causation_id": null, + "correlation_id": "gov_corr" + }, + "event_id": "evt_gov0002", + "event_type": "safety_assessment_completed", + "host_id": "vector-host", + "invocation_id": "inv_gov0001", + "outcome": null, + "payload": { + "approved": false, + "capability_uri": "conformance.unsafe:1.0.0", + "level": "low", + "score": "0.0" + }, + "prev_hash": "1424fae3aba218597a6bcf32e99a7dba140f14b0a0bbb600c87f75940674ae2b", + "timestamp": "2026-01-01T00:00:00Z" + }, + { + "capability_id": "conformance.unsafe", + "content_hash": "ea1fb64199a3da8fe0561297be20e486f05ce8f13c7e9a719c08d81caf361a27", + "correlation": { + "causation_id": null, + "correlation_id": "gov_corr" + }, + "event_id": "evt_gov0003", + "event_type": "safety_guardrail_triggered", + "host_id": "vector-host", + "invocation_id": "inv_gov0001", + "outcome": null, + "payload": { + "capability_uri": "conformance.unsafe:1.0.0", + "reason": "guardrail 'g': requires human approval" + }, + "prev_hash": "23b02a22661d9e720a4652090a3f54fc4ad3ad9a9d8588da982a8139c924d55e", + "timestamp": "2026-01-01T00:00:00Z" + }, + { + "capability_id": "conformance.unsafe", + "content_hash": "b1b0c32598b5da9b30935cffda5f6e1daa03d3830d22cbc6490200e2ebce218e", + "correlation": { + "causation_id": null, + "correlation_id": "gov_corr" + }, + "event_id": "evt_gov0004", + "event_type": "safety_action_blocked", + "host_id": "vector-host", + "invocation_id": "inv_gov0001", + "outcome": "denied", + "payload": { + "capability_uri": "conformance.unsafe:1.0.0", + "reason": "guardrail 'g': requires human approval" + }, + "prev_hash": "ea1fb64199a3da8fe0561297be20e486f05ce8f13c7e9a719c08d81caf361a27", + "timestamp": "2026-01-01T00:00:00Z" + }, + { + "capability_id": "conformance.unsafe", + "content_hash": "c2d0d45eaa51d945916b43ea5451e3be20694d0309eb5c6de8a8b7e147075189", + "correlation": { + "causation_id": null, + "correlation_id": "gov_corr" + }, + "event_id": "evt_gov0005", + "event_type": "execution_denied", + "host_id": "vector-host", + "invocation_id": "inv_gov0001", + "outcome": "denied", + "payload": { + "reason": "safety_blocked" + }, + "prev_hash": "b1b0c32598b5da9b30935cffda5f6e1daa03d3830d22cbc6490200e2ebce218e", + "timestamp": "2026-01-01T00:00:00Z" + } + ], + "host_id": "vector-host", + "host_identity": { + "host_id": "vector-host", + "key_id": "56475aa75463474c", + "public_key": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", + "signature": "Tfsj5Su5RD/IpDWm6LLAfZ5rNmP6gDf/V2efNZZcqYMeWJg7ZWHRBSmcBGyp0km9NajWcOTKAoSLdC3+gWpvBw==", + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": null + }, + "protocol_version": "0.2", + "public_key": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", + "root_hash": "d4f0202dfb3e5dc0f52fbf9b151c2259c4b350b45e600cbf3782d1c02c9bb23f", + "signature": { + "algorithm": "ed25519", + "key_id": "56475aa75463474c", + "signature": "mzq5wzuGfhNFbELkaQqK7PPeKQl4akemlDmPz1WRS3t7dVG4KGgF2mSrlcXKG8k3Bs45oT36P2dPa4HIeND9Cw==" + } +} diff --git a/spec/test-vectors/signed-bundle.json b/spec/test-vectors/signed-bundle.json new file mode 100644 index 0000000..95d3088 --- /dev/null +++ b/spec/test-vectors/signed-bundle.json @@ -0,0 +1,60 @@ +{ + "assurance": "signed", + "canonicalization": "chp-stable-v1", + "created_at": "2026-01-01T00:00:00Z", + "events": [ + { + "capability_id": "demo.cap", + "content_hash": "9c0b8cf83a8638fe27a201fa291a7c0bf4f99b2b174fb60786362a865b5e4dd5", + "correlation": { + "causation_id": null, + "correlation_id": "corr_test" + }, + "event_id": "evt_test0002", + "event_type": "execution_started", + "host_id": "vector-host", + "invocation_id": "inv_test0002", + "outcome": null, + "payload": { + "note": "caf\u00e9" + }, + "prev_hash": null, + "timestamp": "2026-01-01T00:00:00Z" + }, + { + "capability_id": "demo.cap", + "content_hash": "dd80f48eff20ee43edcd04d34af76378fb53f08a74407d09aba33c1870e25724", + "correlation": { + "causation_id": null, + "correlation_id": "corr_test" + }, + "event_id": "evt_test0001", + "event_type": "execution_completed", + "host_id": "vector-host", + "invocation_id": "inv_test0001", + "outcome": "success", + "payload": { + "note": "caf\u00e9" + }, + "prev_hash": "9c0b8cf83a8638fe27a201fa291a7c0bf4f99b2b174fb60786362a865b5e4dd5", + "timestamp": "2026-01-01T00:00:00Z" + } + ], + "host_id": "vector-host", + "host_identity": { + "host_id": "vector-host", + "key_id": "56475aa75463474c", + "public_key": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", + "signature": "Tfsj5Su5RD/IpDWm6LLAfZ5rNmP6gDf/V2efNZZcqYMeWJg7ZWHRBSmcBGyp0km9NajWcOTKAoSLdC3+gWpvBw==", + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": null + }, + "protocol_version": "0.2", + "public_key": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", + "root_hash": "8a81bf36321de97a88bdc793978879e6c61134e70613160ad624bdb207795557", + "signature": { + "algorithm": "ed25519", + "key_id": "56475aa75463474c", + "signature": "uZAYj9ZXTOubcHHY/AGqEt4gOxll5UG0V6d1/cJB+GKcEE+XrpmVRlmxJjIMtu+/T8rxWf5hVW3lDRQcn4dKAw==" + } +} diff --git a/spec/test-vectors/verify.mjs b/spec/test-vectors/verify.mjs new file mode 100644 index 0000000..31c713b --- /dev/null +++ b/spec/test-vectors/verify.mjs @@ -0,0 +1,108 @@ +// Non-Python verifier for CHP signed evidence bundles (chp-stable-v1). +// +// Proves the signing moat is a PROTOCOL, not a Python detail: this script +// verifies a Python-signed bundle using ONLY the chp-stable-v1 byte rules from +// spec/chp-v0.2.md + Node's stdlib crypto — no chp_core import. +// +// node verify.mjs signed-bundle.json +// +// Exit 0 = valid; exit 1 = invalid/tampered. + +import { readFileSync } from "node:fs"; +import { createHash, verify as edVerify, createPublicKey } from "node:crypto"; + +// chp-stable-v1: reproduce Python json.dumps(obj, sort_keys=True) EXACTLY — +// separators ", " and ": " (spaces), ensure_ascii=True (\uXXXX for non-ASCII), +// recursive key sort. This is the whole ballgame for cross-language interop. +function canon(v) { + if (v === null) return "null"; + if (v === true) return "true"; + if (v === false) return "false"; + if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v); + if (typeof v === "string") return encodeStr(v); + if (Array.isArray(v)) return "[" + v.map(canon).join(", ") + "]"; + const keys = Object.keys(v).sort(); + return "{" + keys.map((k) => encodeStr(k) + ": " + canon(v[k])).join(", ") + "}"; +} +function encodeStr(s) { + let out = '"'; + for (const ch of s) { + const c = ch.codePointAt(0); + if (ch === '"') out += '\\"'; + else if (ch === "\\") out += "\\\\"; + else if (c === 0x08) out += "\\b"; + else if (c === 0x09) out += "\\t"; + else if (c === 0x0a) out += "\\n"; + else if (c === 0x0c) out += "\\f"; + else if (c === 0x0d) out += "\\r"; + else if (c < 0x20) out += "\\u" + c.toString(16).padStart(4, "0"); + else if (c < 0x7f) out += ch; + else if (c <= 0xffff) out += "\\u" + c.toString(16).padStart(4, "0"); // ensure_ascii + else { // surrogate pair + const cc = c - 0x10000; + const hi = 0xd800 + (cc >> 10), lo = 0xdc00 + (cc & 0x3ff); + out += "\\u" + hi.toString(16).padStart(4, "0") + "\\u" + lo.toString(16).padStart(4, "0"); + } + } + return out + '"'; +} +const sha256hex = (s) => createHash("sha256").update(s, "utf8").digest("hex"); + +function contentHash(ev, prevHash) { + const corr = ev.correlation || {}; + const stable = { + event_id: ev.event_id, event_type: ev.event_type, invocation_id: ev.invocation_id, + capability_id: ev.capability_id, host_id: ev.host_id, + correlation_id: typeof corr === "object" ? (corr.correlation_id ?? null) : null, + timestamp: ev.timestamp, outcome: ev.outcome ?? null, payload: ev.payload ?? {}, + prev_hash: prevHash ?? null, + }; + return sha256hex(canon(stable)); +} + +const bundle = JSON.parse(readFileSync(process.argv[2] || "signed-bundle.json", "utf8")); +let prev = null, ok = true; +const h = createHash("sha256"); +for (const ev of bundle.events) { + const recomputed = contentHash(ev, ev.prev_hash ?? null); + if (recomputed !== ev.content_hash) { console.error(`content_hash mismatch on ${ev.event_id}`); ok = false; } + if ((ev.prev_hash ?? null) !== prev) { console.error(`chain break at ${ev.event_id}`); ok = false; } + prev = ev.content_hash; + h.update(ev.content_hash + "\n"); // root = SHA256 over content_hashes joined by \n +} +const root = h.digest("hex"); +if (root !== bundle.root_hash) { console.error("root_hash mismatch"); ok = false; } + +if (bundle.assurance === "signed") { + // Wrap the raw 32-byte public key in SPKI DER so Node's crypto can consume it. + const raw = Buffer.from(bundle.public_key, "base64"); + const spki = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]); + const pub = createPublicKey({ key: spki, format: "der", type: "spki" }); + const verifyCanon = (obj, sigB64) => + edVerify(null, Buffer.from(canon(obj), "utf8"), pub, Buffer.from(sigB64, "base64")); + + // Signature is over the canonical HEADER (origin/time/scheme + root_hash), not + // bare root_hash — so a relabelled host_id breaks it. + const header = { host_id: bundle.host_id, protocol_version: bundle.protocol_version, + created_at: bundle.created_at, canonicalization: bundle.canonicalization, + root_hash: bundle.root_hash }; + if (!verifyCanon(header, bundle.signature.signature)) { console.error("signature INVALID"); ok = false; } + + // Host-identity attestation: the key must self-assert this host_id + public_key. + const att = bundle.host_identity; + if (att) { + const claim = { host_id: att.host_id, public_key: att.public_key, key_id: att.key_id, + valid_from: att.valid_from, valid_until: att.valid_until }; + // Temporal validity: created_at within [valid_from, valid_until] (ISO-8601 UTC + // strings compare lexicographically; null = unbounded). + const c = bundle.created_at; + const temporalOk = (att.valid_from == null || c == null || att.valid_from <= c) + && (att.valid_until == null || c == null || c <= att.valid_until); + const bound = att.host_id === bundle.host_id && att.public_key === bundle.public_key + && temporalOk && verifyCanon(claim, att.signature); + if (!bound) { console.error("host_identity attestation INVALID"); ok = false; } + } +} + +console.log(ok ? `VALID (${bundle.assurance}, ${bundle.events.length} events)` : "INVALID"); +process.exit(ok ? 0 : 1);