diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 77d5b8c..2621eff 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,8 @@ "plugins": [ { "name": "harness-native-plugin-prototype", - "description": "Dependency-closed skills using a verified, plugin-managed Bun runtime", + "displayName": "Harness Plugin Prototype", + "description": "Portable Bun skills and a guided native capability tour for Claude Code and Codex", "author": { "name": "Prototype" }, diff --git a/CONTEXT.md b/CONTEXT.md index 22f35e6..864f8ca 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,3 +39,23 @@ _Avoid_: Package registry, artifact store **Release**: An immutable, versioned Plugin Payload made available for production installation. _Avoid_: Main build, CI artifact, merged commit + +**Capability Tour**: +One shared model-only skill that reports declarations, direct mechanics, current-session observation, external qualification status, and skill-seeded native delegation separately. It never reads, ingests, or infers private qualification receipts, and its default result is not an automated native-qualification claim. It uses one skill-local reviewer prompt with generic host delegation and an inline fallback; it has no standalone agent. +_Avoid_: Capability runner, agent package + +**Lifecycle Mechanics Proof**: +The dependency-free `SessionStart`/`Stop` sidecar and plugin-owned drift fixture. It emits one bounded start context, stays silent on a clean Stop and active re-entry, blocks only a proven fixture mismatch, and otherwise fails open. It is not a production integrity or security guarantee and never sets up the runtime. +_Avoid_: Integrity monitor, security control + +**Fresh-Native Qualification Receipt**: +A private, human-operated record from a fresh client profile. It owns native activation, UI identity, exact hook definition trust, host-corroborated delegation, and host-observed lifecycle claims. Raw receipts stay in private XDG state with `0700` directories and `0600` files; only hashes and bounded conclusions may be promoted. +_Avoid_: Automated proof report, transcript + +## Capability boundaries + +Automated proof binds the source candidate, package and installed payload bytes, declarations, fixture equality, and direct handler behavior. It leaves native activation, trust, UI, and delegation explicitly unproved. Fresh-native qualification binds promoted claims to the exact source commit, archive checksum, packaged payload hash, and installed payload hash. A derived drift copy keeps the source candidate SHA and records a distinct derived payload hash. + +Claude installs disabled by default and can run the capability tour and portable skills when hooks are disabled. Codex keeps plugin enablement separate from hook trust: the user reviews the exact hook definition through `/hooks`; disabled or untrusted hooks preserve the same fallback behavior. Qualified platforms are macOS and Linux POSIX hosts. Native Windows support is not claimed. + +The capability tour extends the same plugin. It adds no MCP, standalone agent, telemetry, user settings, companion installer, runtime setup hook, or second qualification framework. diff --git a/README.md b/README.md index 0b79602..3191ad1 100644 --- a/README.md +++ b/README.md @@ -170,9 +170,29 @@ diff -qr "$PREFLIGHT_ROOT/repository/plugin" "$INSTALLED_PATH" jq -e '.installed[] | select(.pluginId == "PLUGIN_NAME@PLUGIN_NAME" and .version == "X.Y.Z")' "$PREFLIGHT_ROOT/codex-plugins-after-add.json" ``` -Start an isolated task with `codex -C "$PREFLIGHT_ROOT"` and invoke one installed skill. A missing runtime returns `BUN_MISSING` without mutation. The agent previews the verified repair, asks for approval in plain language, runs `runtime/runtime-exec repair --apply` only after approval, and retries the skill. No lifecycle hook or manual setup command is involved. +Start an isolated task with `codex -C "$PREFLIGHT_ROOT"` and invoke one installed skill. A missing runtime returns `BUN_MISSING` without mutation. The agent previews the verified repair, asks for approval in plain language, runs `runtime/runtime-exec repair --apply` only after approval, and retries the skill. The lifecycle sidecar is a mechanics proof; it never installs, repairs, or configures the runtime. -For a release qualification, keep three bounded task receipts private under `$XDG_STATE_HOME/agent-plugin-template/runtime-custody/` (defaulting `XDG_STATE_HOME` to `~/.local/state`): `claude-cli--.json`, `codex-cli--.json`, and `codex-desktop--.json`. Each receipt records the repository, candidate commit, plugin version, target, runtime-lock digest, bundle-inventory digest, payload digest, and the real `BUN_MISSING` → preview → approved repair → retry journey. The CLI receipts must come from fresh agent tasks; direct launcher execution from `prove:harness-install` is installed-payload mechanics evidence and explicitly does not claim agent workflow proof. Set `humanApprovalClaimed: true` only on receipts created from an actual interaction after the person approves; automated platform and fixture receipts must say `humanApprovalClaimed: false`. The reviewer who approves the protected `release` environment verifies these candidate-bound receipts. Store only their digests and pass/fail conclusions in release notes, never the private raw receipts. +### Fresh native capability qualification + +All fresh-native cells remain **UNPROVED** until a person records receipts from fresh Claude and Codex profiles. `prove:harness-install` proves package bytes, declarations, installed bytes, and direct handler mechanics. It explicitly does not prove native activation, hook trust, UI presentation, or native delegation. + +Keep raw receipts in the existing private qualification location under `$XDG_STATE_HOME/agent-plugin-template/runtime-custody/`, defaulting `XDG_STATE_HOME` to `~/.local/state`. Create every directory with mode `0700`, create every receipt with mode `0600`, and begin with `umask 077`. Extend the existing per-client receipt with a `nativeCapability` summary; do not create a second receipt framework. Use macOS and Linux POSIX hosts only; this lifecycle proof does not claim native Windows support. + +For each client, bind the receipt to the exact source candidate SHA, archive SHA-256 from `*.checksums.json`, packaged payload hash, and independently measured installed payload hash. The packaged and installed hashes must match. A drift receipt also records the source candidate SHA and a distinct derived payload hash. `ship-canary` owns this candidate-lineage check. + +Record these bounded cells per client: + +- Fresh discovery and branded UI identity. +- Skill-seeded generic native delegation, a correlated handback, and host-owned subagent lifecycle evidence. +- One native `SessionStart` receipt for startup or resume. +- Host-observed zero-output clean `Stop` completion. +- One continuation from a disposable candidate-derived drift copy, with no other blocking Stop hook active. +- Silent `stop_hook_active: true` re-entry and unchanged fixture bytes. +- Capability-tour and existing-skill operation when hooks are disabled or untrusted, with `currentSessionHook: unknown` and no native-activation claim. + +Claude qualification starts with the generated disabled plugin, verifies installed bytes, then enables it for a fresh session. Record the fallback in a separate fresh session with hooks disabled. Codex qualification first observes the untrusted fallback, reviews and trusts the exact hook definition through `/hooks`, then uses a second fresh task for activation receipts. A changed Codex definition requires fresh exact-definition review; a version-only release leaves the definition unchanged. + +Promote only the receipt SHA-256 values, lineage hashes, platform/client labels, and bounded pass/fail conclusions. Never promote paths, prompts, transcript text, session data, environment dumps, or raw host receipts. The existing files remain `claude-cli--.json`, `codex-cli--.json`, and `codex-desktop--.json`. They also record the real `BUN_MISSING` → preview → approved repair → retry journey. Set `humanApprovalClaimed: true` only after actual human approval; automated platform and fixture receipts use `humanApprovalClaimed: false`. The replacement recipe below preserves the marketplace source, ref, and prior `enabled` state. Remove the pinned marketplace entry only after target and restoration preflights pass. @@ -328,7 +348,7 @@ skill id + arguments + invocation identity | Skills | Portable Agent Skills content | `/PLUGIN:SKILL` invocation and Claude extensions | `$SKILL` invocation and Codex extensions | | Runtime | Closed bundles, generated launchers, and one Bun custody engine | Executes the shared launcher | Executes the shared launcher | | Manifest | Plugin identity only | Claude-native manifest | Codex-native manifest | -| Lifecycle hooks | None | None | None | +| Lifecycle hooks | One shared fail-open mechanics handler | Native `SessionStart`/`Stop` declaration; plugin enablement controls activation | Native `SessionStart`/`Stop` declaration; exact hook definition requires user trust | | Development refresh | Source and payload | Direct checkout plus `/reload-plugins` | Staged reinstall plus a fresh task | | Harness-only features | Nothing by default | Keep Claude-only components native | Keep Codex-only components native | @@ -504,7 +524,7 @@ bun run ship:canary -- --dry-run --ref origin/main bun run ship:canary -- --execute --ref origin/main ``` -Unprivileged PR CI checks generated manifests with the candidate's own generator. The privileged canary driver executes only trusted base code. It accepts the exact same-repository PR head, binds the active `gh` login and real SSH or HTTPS Git transport identity to trusted canary targets, verifies visibility and the exact source SHA, and never executes candidate code. The private canary receives that source commit. The public canary receives a deterministic root commit containing only `plugin/`, the Claude and Codex marketplace files, and a trusted minimal hosted-proof workflow, so private repository source and history cannot become public. Each target uses `refs/heads/candidate/` and a create-only lease: the missing ref may be created or an identical concurrent winner accepted, but an existing ref cannot be replaced. Execute mode waits for hosted CI, then installs both native Claude and Codex clients through each proven Git remote and compares their caches with the exact published candidate. It never deletes, replaces, or reuses candidate history. +Unprivileged PR CI checks generated manifests with the candidate's own generator. The privileged canary driver executes only trusted base code. It accepts the exact same-repository PR head, binds the active `gh` login and real SSH or HTTPS Git transport identity to trusted canary targets, verifies visibility and the exact source SHA, and never executes candidate code. The private canary receives that source commit. The public canary receives a deterministic root commit containing only `plugin/`, the Claude and Codex marketplace files, and a trusted minimal hosted-proof workflow, so private repository source and history cannot become public. Each target uses `refs/heads/candidate/` and a create-only lease: the missing ref may be created or an identical concurrent winner accepted, but an existing ref cannot be replaced. Execute mode waits for hosted CI, then installs both native Claude and Codex clients through each proven Git remote and compares their caches with the exact published candidate. Candidate qualification lineage additionally binds the source commit, archive checksum, packaged payload hash, and installed payload hash before native claims can be promoted. It never deletes, replaces, or reuses candidate history. These canaries prove this repository's Git publishing transport and native Git-marketplace installation path. They do not validate or claim OpenAI universal-directory ZIP acceptance, review, approval, or publication. @@ -524,6 +544,6 @@ These canaries prove this repository's Git publishing transport and native Git-m - Publisher-reviewed bundles and dependencies execute with the user's normal Bun and OS capabilities. This is not a sandbox or an untrusted-plugin runtime. - The build rejects native addons, statically visible computed loaders and direct `eval`/`Function` use, undeclared assets, and runtime package installation. These are deterministic bundle-hygiene checks, not adversarial capability confinement; publisher review owns indirect or obfuscated code, and architecture-layer isolation owns untrusted code (ADR 0006). - Claude reloads a direct development plugin in the existing session. Codex needs a staged reinstall and fresh task. -- Runtime lifecycle hooks, prewarm, doctor, inventory, and prune commands are intentionally absent. +- The capability-tour `SessionStart`/`Stop` sidecar is a fail-open lifecycle mechanics proof, not a production integrity or security guarantee. Runtime setup hooks, prewarm, doctor, inventory, and prune commands remain absent. - Managed, workspace-installed, or non-removable plugins require administrator replacement or rollback. - Vendor plugin specifications change. Recheck the linked official documentation when manifests, discovery, installation, or reload behavior changes. diff --git a/docs/adr/0005-shared-runtime-custody.md b/docs/adr/0005-shared-runtime-custody.md index 4194b7d..2c68124 100644 --- a/docs/adr/0005-shared-runtime-custody.md +++ b/docs/adr/0005-shared-runtime-custody.md @@ -72,7 +72,9 @@ bundle/runtime tampering, argument pass-through, and shared warm reuse. Platform CI selects and acquires each of the four reviewed assets, runs a packaged skill, and proves warm execution with custody network denied. Native Claude and Codex receipts own discovery and the approval/repair/retry journey; -a named private manual receipt owns the bounded Codex Desktop smoke. +a named private manual receipt owns the bounded Codex Desktop smoke. The +capability-tour lifecycle sidecar is proved separately: it never selects, +installs, repairs, or prewarms this runtime. ## Consequences @@ -85,5 +87,8 @@ a named private manual receipt owns the bounded Codex Desktop smoke. verified use works offline. - The active contract supports Bun only. Another runtime requires a new decision, not a generic registry or per-skill bootstrap framework. -- There are no lifecycle hooks, prewarm, doctor, inventory, prune, automatic - repair-on-run, or user-managed setup commands. +- The capability tour has one dependency-free, fail-open `SessionStart`/`Stop` + lifecycle mechanics proof. It is not a runtime-custody hook and provides no + production integrity or security guarantee. +- Runtime setup hooks, prewarm, doctor, inventory, prune, automatic repair-on-run, + and user-managed setup commands remain absent. diff --git a/docs/adr/0008-native-plugin-capability-tour.md b/docs/adr/0008-native-plugin-capability-tour.md new file mode 100644 index 0000000..721e6a7 --- /dev/null +++ b/docs/adr/0008-native-plugin-capability-tour.md @@ -0,0 +1,94 @@ +# Extend one plugin with a native capability tour + +## Status + +Accepted — 2026-08-10. + +## Context + +The repository needs one truthful tour of shared skills, client-native lifecycle +declarations, branded identity, and native delegation. Automated package and +handler checks can prove bytes and direct mechanics. They cannot prove that a +fresh Claude or Codex client discovered the plugin, presented its identity, +trusted and activated hooks, or delivered a native subagent lifecycle. + +Claude and Codex also have asymmetric trust. Claude installs the generated +plugin disabled by default and activates its declarations when the plugin is +enabled. Codex separates plugin enablement from hook trust and requires the user +to review the exact hook definition through `/hooks`. + +## Decision + +Extend the same plugin as one capability tour. + +- Keep one shared model-only skill and one skill-local reviewer prompt. +- Seed one generic native subagent through the client host primitive when + available. Use equivalent read-only inline checks when delegation is + unavailable or fails. Do not package a standalone agent. +- Generate separate Claude and Codex hook declarations around one shared POSIX + handler. +- Limit hooks to `SessionStart` and `Stop`. Show success once at startup or + resume. Stay silent for a clean Stop and active Stop re-entry. +- Use the plugin-owned source/projection pair only as a lifecycle mechanics + proof. Request one continuation only for a proven byte mismatch. Fail open for + malformed input, missing tools, unreadable metadata, or operational doubt. +- Keep the handler dependency-free and mutation-free. It never runs workspace + code or performs runtime setup, repair, prewarm, inventory, or pruning. + +The lifecycle mechanics proof is not a production integrity or security +guarantee. It demonstrates bounded host hook behavior only. + +## Evidence and trust boundary + +Repository tests and candidate-bound automated proofs own generated declaration +bytes, package inventory, fixture equality, direct handler mechanics, archive +identity, and installed payload equality. `prove-harness-install` retains an +explicit `not-proved` native qualification cell unless a bounded external +summary is supplied. Direct execution never becomes a native activation claim. + +Fresh native qualification remains human-operated. Raw receipts live in private +XDG state with `0700` directories and `0600` files. Only receipt hashes, lineage +hashes, client/platform labels, and bounded conclusions may be promoted. Paths, +prompts, transcripts, session data, environment dumps, and raw host receipts +remain private. + +Each client receipt covers: + +- Fresh discovery and UI identity. +- Skill-seeded native delegation with correlated handback and host-owned + lifecycle evidence. +- One native `SessionStart` receipt and host-observed silent clean `Stop`. +- A candidate-derived drift continuation bound to the source candidate SHA and + a distinct derived payload hash, followed by silent re-entry. +- Capability-tour and existing-skill fallback while hooks are disabled or + untrusted, without a native-activation claim. +- Codex exact hook definition trust; Claude records this as not applicable. + +`ship-canary` owns candidate lineage. Promoted claims bind to the exact source +commit, archive checksum, packaged payload hash, and independently measured +installed payload hash. Packaged and installed payload hashes must agree. The +derived drift hash must differ. + +Qualification cells remain unproved until those receipts exist. Documentation +must not infer native behavior from declarations, direct handler execution, or +historical client runs. + +## Exclusions + +- No MCP. +- No standalone agent or agent configuration. +- No telemetry. +- No user settings mutation or companion installer. +- No generic capability framework or second qualification framework. +- No hook-driven runtime setup. +- No native Windows claim. Qualification covers macOS and Linux POSIX hosts. + +## Consequences + +- One capability tour works across both clients while native declarations and + trust remain client-specific. +- Disabled or untrusted hooks do not block the tour or existing skills. +- Static hooks, the skill, reviewer prompt, and assets stay outside the + version-only release projection. +- A changed Codex hook definition requires fresh exact-definition review. A + version-only release does not change the definition. diff --git a/docs/plans/native-plugin-capability-tour-plan.md b/docs/plans/native-plugin-capability-tour-plan.md new file mode 100644 index 0000000..a053dcc --- /dev/null +++ b/docs/plans/native-plugin-capability-tour-plan.md @@ -0,0 +1,393 @@ +--- +title: "Native Plugin Capability Tour Plan" +type: reference +status: active +updated: 2026-08-10 +last_verified: 2026-08-10 +deepened: 2026-08-10 +summary: "Implementation-ready plan for a branded v0.3.0 plugin that preserves portable Bun skills and proves shared skills, truthful delegation, and bounded hooks across Claude and Codex." +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: confirmed-conversation +execution: code +related: + - ../README.md + - ../GOAL.md + - ../decisions/native-plugin-capability-tour-decision-log.md + - ../../single-bun-runtime-custody/README.md +sources: + - https://github.com/myagentdojo/agent-plugin-template/releases/tag/v0.2.0 + - https://code.claude.com/docs/en/plugins-reference + - https://code.claude.com/docs/en/hooks + - https://developers.openai.com/codex/plugins/ + - https://developers.openai.com/plugins/build/plugins + - https://developers.openai.com/codex/hooks/ + - https://learn.chatgpt.com/docs/agent-configuration/subagents + - repo://compound-engineering-plugin/@3.21.4 +--- + +# Native Plugin Capability Tour Plan + +## Goal Capsule + +- **Objective:** Release Harness Plugin Prototype v0.3.0 as one branded, candidate-bound capability tour for Claude and Codex. +- **Baseline:** v0.2.0 at `db4d5fff2ecd6140ad1788d6bfb9e7eff504e3ba`; revalidate current main before implementation and record any assumption changes. +- **Product authority:** This project owns the v0.3.0 capability experience. [Single Bun Runtime Custody](../../single-bun-runtime-custody/README.md) remains authoritative for runtime installation and repair. +- **Actors and outcome:** A plugin author or evaluator installs/enables the plugin, decides hook trust, and runs the tour to determine which portable plugin capabilities actually work in the active client. The native client discovers components; the shared skill orchestrates the tour; a bounded reviewer or inline fallback verifies read-only facts; the agent responds to any Stop continuation. +- **Open blockers:** None at product scope. Codex trust and fresh-client activation remain human-operated qualification boundaries. +- **Tail ownership:** Repository tests own deterministic package and handler contracts. Candidate-bound fresh-client receipts own native activation, UI identity, and delegation claims. + +## Product Contract + +### Summary + +Turn the existing portable-skill prototype into a coherent tour of useful native plugin capabilities without creating a showroom framework. +The same installed plugin retains `hello-world`, the ESM `skill-a`, the CJS `skill-b`, and the model-only `runtime-custody` skill; adds one model-only `capability-tour` skill; demonstrates truthful client-specific delegation; gains recognizable identity; and includes a dependency-free lifecycle-hook proof. + +### Problem Frame + +Harness Plugin Prototype v0.2.0 proves dependency-closed skill execution and agent-approved Bun custody, but its installed surface looks generic and does not demonstrate hooks or agent-style decomposition. +A hook-only v0.3.0 would prove mechanics yet still leave the plugin feeling fragmented. + +The useful whole is one guided experience: identify the installed plugin, inspect what it packaged, run a bounded independent check, distinguish native from direct evidence, and show lifecycle behavior. +The design must remain honest where Claude and Codex differ and must not add components merely to fill a capability checklist. + +### Key Decisions + +- **Make v0.3.0 one capability tour.** (session-settled: user-directed — chosen over a hook-only increment after the user requested the fullest cohesive feature that fits one plugin.) Governs R1, R18-R27. +- **Extend the same plugin.** (session-settled: user-directed — chosen over another installed proof plugin: one stable identity and release lineage.) Governs R1, R18-R20. +- **Use one shared skill with agentless delegation.** (session-settled: user-approved — chosen after inspecting Compound Engineering 3.21.4: a skill-local prompt seeds each host's generic subagent, avoiding a Claude-only agent and Codex companion install.) Governs R21-R26. +- **Keep MCP out of v0.3.0.** (session-settled: user-approved — chosen over a no-op server: MCP would add startup/runtime/network ownership without a real service use case.) Governs R28. +- **Prove lifecycle behavior, not runtime setup.** (session-settled: user-approved — chosen over Bun prewarm or repair hooks: runtime custody already has an agent-native approval flow.) Governs R2, R11. +- **Use a plugin-owned drift fixture.** (session-settled: user-directed — chosen over executing workspace checks: automatic hooks must not run user project code.) Governs R5, R6, R13. +- **Show success once at session start.** (session-settled: user-directed — chosen over silent or repeated receipts: visible proof without recurring noise.) Governs R3, R4. + +### Requirements + +#### One plugin and recognizable identity + +- R1. Ship all v0.3.0 additions inside the existing `harness-native-plugin-prototype` payload and preserve its stable plugin ID. +- R18. Keep one consistent display name, short description, long description, capability list, and starter prompt across generated native manifests and marketplaces. The canonical starter prompt is `Run the native plugin capability tour.` +- R19. Supply one restrained brand color plus an identity-neutral composer icon and logo through Codex's documented interface metadata. The checked-in assets must remain valid when the template is initialized under another plugin name; Claude metadata must remain coherent without claiming unsupported icon behavior. +- R20. Preserve all v0.2.0 skills, Bun bundles, runtime-custody behavior, and direct launchers without adding a setup prerequisite. + +#### Lifecycle hooks + +- R2. Limit hooks to `SessionStart` and `Stop`; neither event may install, repair, prewarm, or select Bun. +- R3. On `SessionStart` source `startup` or `resume`, emit `Harness Plugin Prototype v{version} | {client} | SessionStart:{source}` with no private path, session identifier, content, or environment value. +- R4. A successful `Stop` validation must emit no stdout or stderr and must not create a user-visible success message. +- R5. `Stop` must compare one plugin-owned canonical fixture with one deterministic generated projection through a read-only dependency-free path. Generation must use fixed literal content, fixed field order, and LF line endings; runtime must set `LC_ALL=C` and compare exact shipped bytes without enumeration or regeneration. +- R6. Hooks must not execute package scripts, binaries, configuration, or any other code owned by the current workspace. +- R7. Only a proven byte mismatch may return the common structured `decision: block` response. Its canonical reason is: `Harness Plugin Prototype v{version} found a mismatch in its packaged lifecycle proof files. Do not modify the workspace. Continue once to explain how to reinstall this plugin from its trusted source; if the mismatch remains, report this plugin version.` +- R8. If top-level `stop_hook_active` is exactly `true`, this plugin must exit successfully and silently before comparison, so it requests at most one continuation for a Stop attempt. Because the flag reflects any Stop hook's continuation, drift qualification must run without another blocking Stop hook and absence of this plugin's drift receipt must not be interpreted as handler failure. +- R9. Hook execution must never rewrite, regenerate, delete, or chmod the fixture, plugin payload, workspace, or user state. +- R10. Every plugin skill must remain directly usable when hooks are disabled, untrusted, rejected, or unsupported. +- R11. Hook execution must require no Bun, Node, Python, jq, git, network access, background service, persistent log, or plugin-owned setup/bootstrap command. Codex's client-native exact-definition trust remains an explicit user-controlled activation step. +- R12. The tour may report `currentSessionHook` as `observed` only from the current session's native context marker; otherwise it reports `unknown`. Candidate qualification remains external evidence owned by the release receipt matrix and is never inferred or ingested by the tour. +- R13. User-facing copy must call the fixture a lifecycle mechanics proof, not a production integrity, security, or workspace-quality guarantee. +- R14. Exact-candidate native Claude and Codex proofs must cover trusted activation, the versioned start receipt, normal existing-skill execution, and a silent clean Stop backed by host-owned evidence that the exact installed handler ran with zero stdout and stderr. +- R15. Qualification must prove the shared tour and an existing v0.2.0 skill remain usable with Claude hooks disabled and with Codex hooks disabled or the exact definition untrusted. +- R16. Drift qualification may alter only a candidate-derived disposable copy, must record source candidate SHA and derived payload hash, and must never label the altered copy as the release candidate. +- R17. Evidence must distinguish package declaration, direct handler execution, native hook activation, and native delegation as separate proof layers. A model-authored delivery label is non-authoritative; native delegation requires host-owned subagent lifecycle evidence correlated with the structured reviewer handback. + +#### Shared tour and delegation + +- R21. Add exactly one model-only `capability-tour` skill as the user-facing diagnostic and guided tour; it must not enter `runtime/skill-catalog.json` or receive a `plugin/bin` launcher. +- R22. The tour's default response must contain four ordered groups: (1) one-line overall verdict and installed identity; (2) an evidence matrix for declaration, direct handler, `currentSessionHook`, external candidate qualification, and delegation delivery; (3) the available portable skills; and (4) a next action only for an untrusted hook, fixture failure, failed delegation, or other non-healthy state. Raw paths, hashes, and handler JSON stay out of the default response. +- R23. The tour must request exactly one bounded read-only verification task from a generic native subagent when the active client supports it, then synthesize the result. +- R24. The reviewer instruction must live as one skill-local prompt asset under `plugin/skills/capability-tour/references/`; it must specify no mutation, no model pin, bounded inputs, and a structured handback. +- R25. Claude and Codex must both report `native-subagent-via-skill` plus the active client only when the reviewer handback satisfies R24 and the native receipt supplies the host-owned evidence required by R17. Neither path may claim a packaged standalone agent or write agent configuration into user/project state. +- R26. The tour must distinguish `native-subagent-via-skill`, `inline-fallback-unavailable`, and `inline-recovery-delegation-failed`. Both inline modes complete the same bounded checks and avoid claiming subagent proof; the failed mode remains visibly non-healthy. +- R27. A tour result must never infer native hook activation from manifest presence or successful direct handler execution. + +#### Scope guard + +- R28. v0.3.0 must not add standalone plugin agents, MCP, connectors/apps, browser extensions, LSP, monitors, scheduled tasks, output styles/themes, channels, telemetry, screenshots, default settings, user configuration mutation, additional hook events, or a generic component/capability framework. + +### Capability Model + +```mermaid +flowchart LR + P["One Harness Plugin Prototype v0.3.0"] --> S["Existing portable Bun skills"] + P --> T["Shared capability-tour skill"] + P --> H["Shared POSIX hook handler"] + P --> I["Branded identity"] + T --> C{"Active client"} + C -->|"Claude or Codex"| A["Generic native subagent seeded from skill-local prompt"] + C -->|"Delegation unavailable"| F["Inline read-only fallback"] + H --> HC["Claude declaration"] + H --> HX["Codex declaration and trust"] +``` + +### Key Flows + +- F1. **Discover one recognizable plugin.** Fresh Claude and Codex installations show one stable plugin identity; Codex displays the documented visual assets and starter prompt. Covers R1, R18-R20. +- F2. **Run the capability tour.** The shared skill reads installed package facts, directly checks the hook sidecar, requests one bounded read-only delegation, and synthesizes a truth-labeled result. Covers R21-R27. +- F3. **Trusted session start.** A supported native client invokes `SessionStart` on startup or resume and receives the concise version/client/event receipt. Covers R2-R3, R11-R14. +- F4. **Clean completion.** `Stop` parses only top-level `stop_hook_active`; when false and fixture bytes match, it exits with no output. Covers R4-R6, R8-R11. +- F5. **Drift continuation.** A disposable derived payload has a mismatched projection; first Stop returns one structured block, re-entry with `stop_hook_active: true` exits silently, and no file is changed. Covers R7-R9, R14, R16. +- F6. **Hooks unavailable.** Claude hooks are disabled or Codex hooks are disabled/untrusted; the capability tour and an existing skill still run, and the tour reports `currentSessionHook: unknown` without making a candidate-qualification claim. Covers R10, R12, R15, R17. +- F7. **Delegation unavailable or failed.** The shared skill performs the same bounded check inline and distinguishes host unavailability from an attempted delegation failure. Covers R23, R25-R27. + +### Acceptance Examples + +- AE1. **Covers R1, R18-R20.** Given the exact v0.3.0 candidate, when installed fresh in both clients, then one stable plugin identity is discovered and no legacy duplicate proof plugin is required. +- AE2. **Covers R19.** Given fresh Codex discovery, when the plugin card/composer is inspected, then the declared color, icon/logo, and starter prompt resolve from packaged assets; Claude documentation claims only its supported metadata. +- AE3. **Covers R21-R27.** Given the shared tour in Claude, when delegation is available, then one generic native subagent is seeded from the skill-local reviewer prompt and the host-owned receipt plus correlated handback prove `native-subagent-via-skill` with client `claude` and no mutation. +- AE4. **Covers R21-R27.** Given the shared tour in Codex, when delegation is available, then the same prompt seeds one generic native subagent and the host-owned receipt plus correlated handback prove `native-subagent-via-skill` with client `codex`. +- AE5. **Covers R26.** Given delegation is unavailable or an attempted delegation fails, when the tour runs, then equivalent read-only checks complete inline and the report distinguishes `inline-fallback-unavailable` from `inline-recovery-delegation-failed` without claiming agent proof. +- AE6. **Covers R2-R4, R14.** Given a fresh trusted installation, when Claude or Codex starts or resumes and later stops with matching fixture bytes, then one versioned start receipt appears, Stop is silent, and host-owned evidence proves that the exact handler ran with zero output. +- AE7. **Covers R5-R9, R16.** Given a candidate-derived disposable copy with a changed projection, when Stop runs, then one continuation is requested, re-entry is silent, neither fixture is modified by hook execution (both match their pre-Stop bytes), and the receipt identifies the derived payload hash separately from the source candidate. +- AE8. **Covers R10, R12, R15, R17.** Given hooks are disabled or untrusted, when the tour and `hello-world` are invoked, then both remain usable and the report distinguishes declared/direct health from unproved native activation. +- AE9. **Covers R6, R11.** Given a hostile workspace or no workspace metadata, when either hook fires, then no workspace code runs and behavior is unchanged. +- AE10. **Covers R20-R21.** Given generation and build complete, then v0.2.0's `hello-world`, `skill-a`, `skill-b`, and `runtime-custody` skills plus the three Bun launchers remain intact while `capability-tour` has no runtime catalog row, bundle, or launcher. +- AE11. **Covers R28.** Given the packaged inventory, then it contains no standalone agent directory, MCP, user settings, Codex agent TOML, extra hook event, telemetry, or generic capability runner. + +### Scope Boundaries + +- No Bun installation, repair, prewarm, cache management, or runtime selection from hooks or the model-only tour. +- No workspace formatting, linting, testing, generated-file repair, or command execution from hooks. +- No production integrity/security claim, permission auto-approval, command policy, sandbox, scanner, or persistent audit log. +- No MCP merely to demonstrate MCP; revisit only with a real controlled service/tool use case and its own runtime/auth plan. +- No standalone-agent distribution: both clients use their host-native generic subagent primitive seeded from the same skill-local prompt. +- No generic registry, runner, adapter SDK, theme, design system, screenshot suite, or additional standalone plugin. + +## Planning Contract + +### Key Technical Decisions + +- KTD1. Generate separate Claude and Codex hook declaration files around one shared POSIX handler. This follows the repository's explicit native-adapter boundary and permits truthful client/event arguments without duplicating logic. +- KTD2. Invoke the handler by absolute installed-plugin-root path, never session cwd. Claude uses its plugin-root environment; Codex uses its documented plugin-root environment and compatibility variables only where explicitly tested. +- KTD3. Do not embed the plugin version in hook command declarations. Read the single semver from the installed native manifest so a version-only release does not churn Codex's exact hook-definition trust hash. +- KTD4. Validate the complete bounded input as JSON using dependency-free POSIX `awk`, then extract only the top-level `stop_hook_active` boolean needed by the contract. Do not use raw grep or parse string/nested decoys as the target member. +- KTD5. Fail open on malformed/missing input, duplicate or non-boolean guard values, invalid version metadata, missing/unreadable fixture, or unavailable comparison tooling. Emit at most one bounded static warning, never a continuation. On invalid or unreadable version metadata, SessionStart emits the warning instead of R3's receipt and never substitutes a placeholder version. Only a proven byte mismatch blocks. +- KTD6. Keep one canonical fixture source and one deterministic generated projection. Generation occurs in repository tooling; runtime only compares exact bytes. +- KTD7. Keep `capability-tour` model-only. Its package contract is a `SKILL.md` with no runtime catalog entry, bundled JS, launcher, install step, or network dependency. +- KTD8. Follow Compound Engineering's current agentless distribution pattern: keep one reviewer prompt in the skill, dispatch a generic native subagent through the host primitive, and fall back inline. Do not create an `agents/` package surface or companion installer. +- KTD9. Bind automated proofs to bytes and candidate identity, but reserve native activation, UI presentation, trust, and delegation claims for fresh-client qualification receipts. +- KTD10. Add exact allowlists for the sidecar surfaces instead of weakening the existing Bun-only closure checks globally. + +### High-Level Technical Design + +All paths below are relative to `agent-plugin-template` unless noted. +Exact leaf filenames may change during implementation if all one-owner and inventory invariants remain intact. + +```mermaid +flowchart TD + C["plugin.config.json canonical identity"] --> G["scripts/generate.ts"] + G --> MC["plugin/.claude-plugin/plugin.json"] + G --> MX["plugin/.codex-plugin/plugin.json"] + G --> HC["plugin/hooks/claude/hooks.json"] + G --> HX["plugin/hooks/codex/hooks.json"] + FS["canonical fixture"] --> G + G --> FP["generated fixture projection"] + HC --> HH["shared POSIX hook handler"] + HX --> HH + CT["plugin/skills/capability-tour/SKILL.md"] --> CR["skill-local references/capability-reviewer.md"] + CR --> CS["Claude or Codex generic subagent, or inline fallback"] + B["scripts/build.ts narrow sidecar validator"] --> PKG["exact packaged payload"] + PKG --> Q["automated package and direct-handler proofs"] + Q --> N["fresh Claude and Codex qualification"] +``` + +### Implementation Constraints + +- Preserve unrelated worktree state and begin from current remote main, not a stale local main. +- Preserve `runtime/skill-catalog.json` as the closed owner of Bun-backed skills and `plugin/bin` as its launcher projection. +- Use `/bin/sh` plus required POSIX tools under a system-only `PATH`; do not depend on the user's shell initialization. +- Treat `SessionStart` sources other than startup/resume as silent; omission or future values must not create repeated receipts. +- The Stop guard must inspect the top-level JSON key, resist string/nested decoys, and read at most 1 MiB plus one overflow byte. Overflow fails open with the bounded warning; no unbounded buffering or input evaluation is permitted. +- Do not place a version literal in static hooks, reviewer prompts, assets, or the capability skill unless it is a generated projection with one canonical owner. +- Static hooks, skills, reviewer prompt assets, and brand assets remain outside Release Please's version-only projection; add tests that lock this ownership boundary. +- Asset bytes must be deterministic, identity-neutral for downstream template initialization, packaged, referenced by relative manifest paths, and small enough for normal plugin distribution. +- `prove-harness-install` may report declarations/direct health but must retain its explicit statement that it does not prove native activation. +- Native proof receipts go under private XDG state (`0700` directories, `0600` files), with hashes and durable conclusions promoted—not raw sessions. + +### Sequencing + +1. Build and test the dependency-free hook/fixture seam before exposing it in manifests. +2. Add the model-only tour and reviewer prompt asset, assets, generated declarations, and exact package admission. +3. Extend package/distribution/install/release proofs while preserving the v0.2.0 runtime closure. +4. Qualify one exact candidate in fresh Claude and Codex, using a separately identified derived copy for drift, then update release-facing docs and ADRs from observed behavior. + +### System-Wide Impact and Risks + +- **Generation:** `plugin.config.json`, `scripts/plugin-config.ts`, and `scripts/generate.ts` gain new projections. Risk is duplicate identity/version ownership; generation checks must prove one canonical owner. +- **Packaging:** `scripts/plugin-files.ts`, `scripts/build.ts`, and package proofs must accept an exact sidecar inventory without weakening executable closure. Risk is accidentally admitting arbitrary model-only skills or commands. +- **Development:** `scripts/dev.ts` must watch hooks, skills (including the reviewer prompt), and assets. Risk is local iteration not reflecting packaged output. +- **Release:** release impact already includes `plugin/**`; projection tests must prove static sidecars are release-relevant without becoming version projections. +- **Trust:** Codex requires exact hook-definition review. A version-only update should not alter definitions; any declaration change must be requalified and documented as trust-affecting. +- **Failure:** hooks fail open for operational uncertainty and block only on proven fixture mismatch. This trades enforcement for loop safety because the feature is a mechanics proof. +- **Compatibility:** qualify macOS and Linux POSIX hosts only unless a separate Windows contract is added. Do not imply native Windows support from `/bin/sh` syntax. +- **Privacy:** receipts contain version/client/event and hashes only; no paths, prompts, session data, transcript text, or environment dumps. + +## Implementation Units + +### U1 — Dependency-free lifecycle sidecar + +**Goal:** Establish the complete hook behavior as a small, independently testable module before native registration. + +**Requirements covered:** R2-R9, R11-R13. + +**Dependencies:** Current v0.2.0 generation conventions and official Claude/Codex hook contracts. + +**Primary files:** + +- Add `plugin/hooks/native-capability-hook`. +- Add one canonical fixture source and one generated projection under `plugin/hooks/fixture/`. +- Extend `scripts/generate.ts` or a narrowly owned helper for deterministic projection. +- Add `scripts/native-capability-hook.test.ts` and generation assertions. + +**Approach:** + +- Implement fixed event/client arguments with JSON on stdin and JSON/no-output on stdout. +- Read version from the installed manifest and require exactly one valid semver. +- Emit SessionStart context only for startup/resume. +- Enforce the 1 MiB input cap before parsing; parse only top-level `stop_hook_active`; true exits silently before comparison. +- Compare exact fixture bytes; mismatch returns one common block envelope; all operational ambiguity fails open with one bounded warning on stderr and empty stdout. + +**Test scenarios:** clean Stop silence; first mismatch block; active re-entry silence; startup/resume receipt; clear/compact/fork silence; whitespace/reordered JSON; string and nested decoys; duplicate/missing/non-boolean guard; malformed/oversized input; invalid version; missing/unreadable fixture; unavailable comparison tool; hostile cwd/PATH; no file mutation. Exercise malformed input, oversized input, and operational Stop failures for both Claude and Codex. + +**Verification:** Focused Bun tests run the handler in isolated temporary plugin roots and assert stdout, stderr, exit code, byte hashes, filesystem snapshot, and absence of Bun/Node/network/workspace execution. + +### U2 — One coherent native plugin surface + +**Goal:** Package the tour, agentless delegation, client declarations, and branded identity as exact sidecars around the unchanged portable core. + +**Requirements covered:** R1, R18-R28. + +**Dependencies:** U1's stable handler/fixture contract. + +**Primary files:** + +- Update `plugin.config.json`, `scripts/plugin-config.ts`, `scripts/generate.ts`, and generated native manifests/marketplaces. +- Add `plugin/hooks/claude/hooks.json` and `plugin/hooks/codex/hooks.json` as generated declarations. +- Add `plugin/skills/capability-tour/SKILL.md`. +- Add `plugin/skills/capability-tour/references/capability-reviewer.md` as the single cross-client reviewer prompt. +- Add deterministic `plugin/assets/` icon/logo files. +- Update `scripts/build.ts`, `scripts/dev.ts`, and their tests. Reuse the existing recursive `scripts/plugin-files.ts` payload inventory unchanged. +- Update `scripts/init.test.ts` to prove initialized plugins retain valid neutral asset references without Harness-specific visual identity. +- Add focused manifest, sidecar, delegation-prompt, asset, and model-only-skill contract tests, including a negative assertion that no standalone agent surface exists. + +**Approach:** + +- Keep one identity owner in `plugin.config.json`; project client-specific fields through existing manifest generation. +- Give each client an explicit hook declaration that invokes the same handler with fixed client/event arguments. +- Make the tour inspect only installed plugin facts, render R22's four groups, and seed one generic native subagent from the reviewer prompt, with explicit client and fallback delivery labels. +- Permit only the named model-only skill without a launcher/catalog entry; reject arbitrary executable sidecars. +- Replace blanket no-hooks assertions with an exact capability-sidecar allowlist while retaining Bun closure validation. + +**Test scenarios:** exact manifest paths/copy/version; asset existence and package hashes; one reviewer prompt asset; no standalone agent directory or companion install; no model pin/default agent; tour absent from catalog/bin/bundle inventory; declarations unchanged by version-only projection; dev watches new surfaces; no MCP/settings/excess components. + +**Verification:** `generate:check`, focused contract tests, build/package inventory comparison, and a generated-diff test proving all generated surfaces are clean. + +### U3 — Proof and release admission + +**Goal:** Make every automated claim candidate-bound and preserve the full v0.2.0 executable closure while admitting only the intended sidecars. + +**Requirements covered:** R10, R12, R14-R17, R20-R22, R27-R28. + +**Dependencies:** U1-U2 packaged candidate. + +**Primary files:** + +- Update `scripts/prove-distribution.ts`, `scripts/prove-dx.ts`, `scripts/prove-harness-install.ts`, `scripts/harness-install-codex.ts`, and focused tests. +- Update `scripts/release-validate.ts`, `scripts/release-projection.test.ts`, `scripts/runtime-custody-generation.test.ts`, and package/checksum tests. +- Update `package.json` proof composition only if a narrow new proof command is necessary. + +**Approach:** + +- Validate exact hook, fixture, skill, reviewer-prompt, and asset bytes in source, archive, extracted payload, and installed payload. +- Report declaration health, direct handler health, fixture state, and current-session observation separately. Do not ingest release qualification receipts or turn direct execution into a native claim. +- Prove the model-only skill cannot gain a launcher or runtime bundle and that `hello-world`, `skill-a`, `skill-b`, and `runtime-custody` plus the existing three launchers remain intact. +- Keep installed proof honest: no direct invocation may satisfy a native-activation field. +- Bind proof results to candidate commit, archive checksum, payload hash, and installed payload hash. + +**Test scenarios:** missing/orphan/extra sidecar; tampered asset/reviewer-prompt/declaration/fixture; stale generated projection; version-only release; tour accidentally catalogued or bundled; hooks disabled/untrusted fallback; existing ESM/CJS/hello-world execution with package-manager dependencies absent from runtime; no Bun host until separately approved repair. + +**Verification:** full automated suite, build, package, distribution proof, runtime-custody/platform proof, release validation JSON, and `prove:all` with no weakened v0.2.0 assertions. + +### U4 — Fresh native qualification and documentation + +**Goal:** Prove the exact v0.3.0 candidate in both clients and publish only claims supported by native receipts. + +**Requirements covered:** All requirements, especially R3-R4, R14-R17, R19, R23-R27. + +**Dependencies:** U1-U3 green exact candidate and human availability for Codex hook trust and UI/client inspection. + +**Primary files:** + +- Update repository `README.md`, `CONTEXT.md`, and ADR 0005 language that currently says no lifecycle hooks. +- Add `docs/adr/0008-native-plugin-capability-tour.md` (next available number if changed). +- Extend the existing `prove-harness-install` evidence envelope and `ship-canary` candidate-lineage owner; keep the human-operated private-XDG receipt recipe in the existing qualification documentation rather than creating a second framework. + +**Approach:** + +- Install the exact candidate into isolated fresh Claude and Codex profiles. +- Claude: prove enabled plugin, skill-seeded native delegation with host-owned lifecycle evidence, native SessionStart, host-observed silent clean Stop, one derived-copy drift continuation, re-entry silence, and disabled-hooks fallback. +- Codex: explicitly trust the exact hook definition through `/hooks`; prove native subagent-via-skill with host-owned lifecycle evidence, the same hook behavior, and disabled/untrusted fallback. +- Inspect one installed identity in each client and Codex's documented asset/prompt presentation. +- Hash the candidate and installed payloads; keep raw receipts private; publish only bounded summary/evidence metadata. + +**Test scenarios:** fresh discovery; changed hook definition requires Codex review; plugin enabled with hooks off; delegation unavailable fallback; attempted delegation failure with inline recovery; exact clean candidate; separately hashed derived drift copy; second Stop silent; no other blocking Stop hook during drift proof; no workspace/plugin mutation. + +**Verification:** candidate-bound Claude and Codex receipt matrices, installed payload hashes, automated `prove:all`, release validation, repository checks, and final review with no concerning P0/P1/P2. + +## Verification Contract + +### Automated gate + +From the `agent-plugin-template` worktree: + +```sh +bun run generate:check +bun test +bun run build +bun run package +bun run prove:distribution +bun run prove:runtime-custody +bun run prove:runtime-platform +bun run release:validate -- --json +bun run prove:all +git diff --check +``` + +If workflow files change, also run `actionlint .github/workflows/*.yml`. +Focused unit tests are required during development, but they do not replace the full gate. + +### Native evidence gate + +- Exact source commit, archive checksum, and payload hashes bind one candidate lineage; only the packaged and installed payload hashes must be equal. +- Fresh Claude receipt covers discovery, host-corroborated skill-seeded native delegation, shared tour synthesis, SessionStart, host-observed silent Stop, and hooks-disabled fallback. +- Fresh Codex receipt covers discovery/assets, host-corroborated native subagent-via-skill, explicit exact-definition trust, SessionStart, host-observed silent Stop, and disabled/untrusted fallback. +- Derived drift receipts identify the source candidate plus distinct derived payload hash and prove first block, active re-entry silence, and zero mutation. +- Each result explicitly states whether it proves declaration, direct execution, native activation, delegation, or UI presentation. + +### Review threshold + +- No open concerning P0, P1, or P2 finding at the exact final head. +- All actionable review threads are resolved with tests or an explicit accepted decision. +- P3 suggestions may remain only when recorded as non-blocking and outside the accepted v0.3.0 scope. +- Hosted checks and final-head qualification must be green; historical runs do not satisfy the gate. + +## Definition of Done + +- One v0.3.0 plugin retains the complete v0.2.0 portable runtime surface and adds only the approved capability sidecars. +- One shared capability-tour skill works in Claude and Codex with R22's ordered output, host-corroborated delegation labels, and visibly distinct unavailable/failed inline recovery. +- One skill-local reviewer prompt drives generic native delegation in both clients; no standalone agent artifact, companion installer, or user config write exists. +- Codex identity assets and prompt render from packaged files; cross-client copy remains coherent and support claims are precise. +- Hook behavior satisfies the visible-start, silent-clean-stop, proven-drift-only block, one-continuation, fail-open, no-mutation, and no-workspace-execution contracts. +- Disabled/untrusted hooks do not block the tour or existing skills. +- Automated, fresh-native, derived-drift, and UI claims are separated and bound to the exact final candidate. +- Documentation and ADRs describe the resulting behavior, asymmetric client support, trust boundary, and explicit exclusions. +- Full automated gates pass, native receipt matrices pass, hosted final-head checks pass, and no concerning P0/P1/P2 remains. +- No MCP, generic framework, extra plugin, plugin-owned setup/bootstrap command, telemetry, user settings mutation, or unrelated capability enters the release. Codex's user-controlled native hook trust remains required for activation. + +## Research Notes + +- Claude documents plugin-packaged agents, but this cross-client plan deliberately does not use them because the reviewer is an internal skill step rather than a separately discoverable persona. +- OpenAI's plugin package schema documents skills, hooks, MCP/apps, and interface assets, but not plugin-packaged custom agents. +- Codex custom agents are configured separately in user/project TOML; this plan therefore uses native delegation from the shared skill instead of mutating that configuration. +- Compound Engineering 3.21.4 provides the proven precedent: current skills seed generic subagents from skill-local prompt assets, while its older standalone-agent converter remains a legacy/general conversion path rather than the native CE install contract. +- Both hook contracts share the command-hook stdin model, SessionStart context output, Stop `stop_hook_active`, and structured block response needed for the bounded proof. +- Codex's explicit hook-definition trust makes definition stability and fresh trusted qualification part of the product contract. diff --git a/plugin.config.json b/plugin.config.json index 5ae4411..4e838c1 100644 --- a/plugin.config.json +++ b/plugin.config.json @@ -3,7 +3,7 @@ "name": "harness-native-plugin-prototype", "displayName": "Harness Plugin Prototype", "version": "0.2.0", - "description": "Dependency-closed skills using a verified, plugin-managed Bun runtime", + "description": "Portable Bun skills and a guided native capability tour for Claude Code and Codex", "author": { "name": "Prototype" }, @@ -14,17 +14,23 @@ "bun" ], "category": "Developer Tools", - "shortDescription": "Run a portable plugin skill", - "longDescription": "Dependency-closed skills for Claude Code and Codex using one verified, plugin-managed Bun runtime.", + "shortDescription": "Tour native plugin features", + "longDescription": "Run a guided capability tour across Claude Code and Codex, inspect native lifecycle declarations, and keep using dependency-closed skills through one verified, plugin-managed Bun runtime.", "capabilities": [ "Execute verified Bun code", "Download Bun after approval", "Write private runtime cache", - "Use network during repair" + "Use network during repair", + "Inspect native plugin declarations", + "Prove lifecycle hook mechanics", + "Verify with a native subagent" ], "defaultPrompts": [ - "Run the hello-world distribution proof." + "Run the native plugin capability tour." ], + "brandColor": "#3B5CCC", + "composerIcon": "./assets/composer-icon.svg", + "logo": "./assets/logo.svg", "canary": { "owner": "myagentdojo", "actor": "myagentdojo", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 9ef510e..786f1fc 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "displayName": "Harness Plugin Prototype", "version": "0.2.0", "defaultEnabled": false, - "description": "Dependency-closed skills using a verified, plugin-managed Bun runtime", + "description": "Portable Bun skills and a guided native capability tour for Claude Code and Codex", "author": { "name": "Prototype" }, @@ -13,5 +13,6 @@ "agent-plugin", "bun" ], - "skills": "./skills/" + "skills": "./skills/", + "hooks": "./hooks/claude/hooks.json" } diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index a6a58cd..6b50757 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "harness-native-plugin-prototype", "version": "0.2.0", - "description": "Dependency-closed skills using a verified, plugin-managed Bun runtime", + "description": "Portable Bun skills and a guided native capability tour for Claude Code and Codex", "author": { "name": "Prototype" }, @@ -12,20 +12,27 @@ "bun" ], "skills": "./skills/", + "hooks": "./hooks/codex/hooks.json", "interface": { "displayName": "Harness Plugin Prototype", - "shortDescription": "Run a portable plugin skill", - "longDescription": "Dependency-closed skills for Claude Code and Codex using one verified, plugin-managed Bun runtime.", + "shortDescription": "Tour native plugin features", + "longDescription": "Run a guided capability tour across Claude Code and Codex, inspect native lifecycle declarations, and keep using dependency-closed skills through one verified, plugin-managed Bun runtime.", "developerName": "Prototype", "category": "Developer Tools", "capabilities": [ "Execute verified Bun code", "Download Bun after approval", "Write private runtime cache", - "Use network during repair" + "Use network during repair", + "Inspect native plugin declarations", + "Prove lifecycle hook mechanics", + "Verify with a native subagent" ], "defaultPrompt": [ - "Run the hello-world distribution proof." - ] + "Run the native plugin capability tour." + ], + "brandColor": "#3B5CCC", + "composerIcon": "./assets/composer-icon.svg", + "logo": "./assets/logo.svg" } } diff --git a/plugin/assets/composer-icon.svg b/plugin/assets/composer-icon.svg new file mode 100644 index 0000000..1b8adff --- /dev/null +++ b/plugin/assets/composer-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugin/assets/logo.svg b/plugin/assets/logo.svg new file mode 100644 index 0000000..03a806c --- /dev/null +++ b/plugin/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugin/hooks/claude/hooks.json b/plugin/hooks/claude/hooks.json new file mode 100644 index 0000000..9e2b724 --- /dev/null +++ b/plugin/hooks/claude/hooks.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/native-capability-hook\" SessionStart claude" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/native-capability-hook\" Stop claude" + } + ] + } + ] + } +} diff --git a/plugin/hooks/codex/hooks.json b/plugin/hooks/codex/hooks.json new file mode 100644 index 0000000..a34660f --- /dev/null +++ b/plugin/hooks/codex/hooks.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${PLUGIN_ROOT}/hooks/native-capability-hook\" SessionStart codex" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${PLUGIN_ROOT}/hooks/native-capability-hook\" Stop codex" + } + ] + } + ] + } +} diff --git a/plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json b/plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json new file mode 100644 index 0000000..9ea8b3b --- /dev/null +++ b/plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "purpose": "Harness Plugin Prototype lifecycle mechanics proof" +} diff --git a/plugin/hooks/fixture/lifecycle-mechanics-proof.source.json b/plugin/hooks/fixture/lifecycle-mechanics-proof.source.json new file mode 100644 index 0000000..9ea8b3b --- /dev/null +++ b/plugin/hooks/fixture/lifecycle-mechanics-proof.source.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "purpose": "Harness Plugin Prototype lifecycle mechanics proof" +} diff --git a/plugin/hooks/native-capability-hook b/plugin/hooks/native-capability-hook new file mode 100755 index 0000000..89c8dea --- /dev/null +++ b/plugin/hooks/native-capability-hook @@ -0,0 +1,405 @@ +#!/bin/sh +set -u + +PATH=/usr/bin:/bin +export PATH +LC_ALL=C +export LC_ALL + +warning='This plugin could not run its lifecycle mechanics proof; continuing without blocking.' + +warn() { + printf '%s\n' "$warning" >&2 + exit 0 +} + +case "$0" in +/*/hooks/native-capability-hook) plugin_root=${0%/hooks/native-capability-hook} ;; +*) warn ;; +esac + +event=${1-} +client=${2-} +[ "$#" -eq 2 ] || warn +case "$event:$client" in +SessionStart:claude|SessionStart:codex|Stop:claude|Stop:codex) ;; +*) warn ;; +esac + +for required_tool in awk dd mktemp od rm wc; do + command -v "$required_tool" >/dev/null 2>&1 || warn +done +temporary_directory=$(mktemp -d /tmp/harness-native-capability-hook.XXXXXX 2>/dev/null) || warn +case "$temporary_directory" in +/tmp/harness-native-capability-hook.*) ;; +*) warn ;; +esac +trap 'rm -rf "$temporary_directory" >/dev/null 2>&1' 0 1 2 3 15 +input_file=$temporary_directory/input.json +: >"$input_file" || warn +input_size=0 +while [ "$input_size" -lt 1048577 ]; do + previous_size=$input_size + remaining_bytes=$((1048577 - input_size)) + dd bs="$remaining_bytes" count=1 >>"$input_file" 2>/dev/null || warn + input_size=$(wc -c <"$input_file" 2>/dev/null) || warn + [ "$input_size" -gt "$previous_size" ] || break +done +[ "$input_size" -le 1048576 ] || warn + +# Validate Unicode scalar-value UTF-8 before the byte-oriented JSON parser can +# authorize output. POSIX od keeps this independent of host locale and awk +# multibyte behavior. +valid_utf8() { + utf8_bytes=$temporary_directory/utf8.bytes + od -An -t u1 -v "$1" >"$utf8_bytes" 2>/dev/null || return 1 + awk ' + { + for (byte_index = 1; byte_index <= NF; byte_index++) { + byte = $byte_index + 0 + if (remaining > 0) { + minimum = 128 + maximum = 191 + if (first_continuation) { + minimum = first_minimum + maximum = first_maximum + first_continuation = 0 + } + if (byte < minimum || byte > maximum) exit 1 + remaining-- + continue + } + first_continuation = 0 + if (byte <= 127) continue + if (byte >= 194 && byte <= 223) { + remaining = 1 + } else if (byte == 224) { + remaining = 2 + first_continuation = 1 + first_minimum = 160 + first_maximum = 191 + } else if ((byte >= 225 && byte <= 236) || (byte >= 238 && byte <= 239)) { + remaining = 2 + } else if (byte == 237) { + remaining = 2 + first_continuation = 1 + first_minimum = 128 + first_maximum = 159 + } else if (byte == 240) { + remaining = 3 + first_continuation = 1 + first_minimum = 144 + first_maximum = 191 + } else if (byte >= 241 && byte <= 243) { + remaining = 3 + } else if (byte == 244) { + remaining = 3 + first_continuation = 1 + first_minimum = 128 + first_maximum = 143 + } else { + exit 1 + } + } + } + END { if (remaining != 0) exit 1 } + ' "$utf8_bytes" +} + +# Validate one JSON object and extract one unescaped top-level member. Both hook +# inputs and manifests use this parser so malformed unrelated members cannot +# authorize a Stop block or produce startup context. Input and nesting caps keep +# hostile payloads bounded; any unsupported or uncertain input fails open. +top_level_member() { + valid_utf8 "$1" || return 2 + awk -v target="$2" -v expected_kind="$3" ' + function whitespace(c) { return c == " " || c == "\t" || c == "\r" || c == "\n" } + function charat(i) { return char_indexed ? chars[i] : substr(document, i, 1) } + function skip_whitespace(i) { + while (i <= document_length && whitespace(charat(i))) i++ + return i + } + function parse_string(start, i,c,e,k,content_length) { + parsed_raw = "" + parsed_encoded = "" + parsed_plain = 1 + parsed_too_long = 0 + for (i = start + 1; i <= document_length; i++) { + c = charat(i) + if (c == "\"") { + parsed_end = i + content_length = i - start - 1 + if (content_length > short_string_limit) parsed_too_long = 1 + return 1 + } + if (i - start <= short_string_limit) parsed_encoded = parsed_encoded c + if (c == "\\") { + parsed_plain = 0 + i++ + if (i > document_length) return 0 + e = charat(i) + if (i - start <= short_string_limit) parsed_encoded = parsed_encoded e + if (e == "u") { + for (k = 1; k <= 4; k++) { + i++ + if (charat(i) !~ /^[0-9A-Fa-f]$/) return 0 + if (i - start <= short_string_limit) parsed_encoded = parsed_encoded charat(i) + } + } else if (e !~ /^["\\\/bfnrt]$/) return 0 + continue + } + if (c ~ /[[:cntrl:]]/) return 0 + if (parsed_plain && i - start <= short_string_limit) parsed_raw = parsed_raw c + } + return 0 + } + function parse_number(start, i,c) { + i = start + if (charat(i) == "-") i++ + if (charat(i) == "0") { + i++ + if (charat(i) ~ /^[0-9]$/) return 0 + } else { + if (charat(i) !~ /^[1-9]$/) return 0 + while (charat(i) ~ /^[0-9]$/) i++ + } + if (charat(i) == ".") { + i++ + if (charat(i) !~ /^[0-9]$/) return 0 + while (charat(i) ~ /^[0-9]$/) i++ + } + c = charat(i) + if (c == "e" || c == "E") { + i++ + c = charat(i) + if (c == "+" || c == "-") i++ + if (charat(i) !~ /^[0-9]$/) return 0 + while (charat(i) ~ /^[0-9]$/) i++ + } + parsed_end = i - 1 + parsed_kind = "number" + return 1 + } + function parse_array(start, depth, i,value_end) { + if (depth > nesting_limit) return 0 + i = skip_whitespace(start + 1) + if (charat(i) == "]") { parsed_end = i; return 1 } + for (;;) { + if (!parse_value(i, depth)) return 0 + value_end = parsed_end + i = skip_whitespace(value_end + 1) + if (charat(i) == "]") { parsed_end = i; return 1 } + if (charat(i) != ",") return 0 + i = skip_whitespace(i + 1) + if (charat(i) == "]") return 0 + } + } + function parse_object(start, depth, i,key,key_plain,key_too_long,key_end,value_end,value_kind,value_raw,value_encoded,value_plain,value_too_long,kind_matches) { + if (depth > nesting_limit) return 0 + i = skip_whitespace(start + 1) + if (charat(i) == "}") { parsed_end = i; return 1 } + for (;;) { + if (charat(i) != "\"" || !parse_string(i)) return 0 + key = parsed_raw + key_plain = parsed_plain + key_too_long = parsed_too_long + key_end = parsed_end + # Escaped top-level keys can decode to the protected target. Treat the + # object as ambiguous instead of authorizing a block from a raw spelling. + if (depth == 0 && !key_plain) return 0 + i = skip_whitespace(key_end + 1) + if (charat(i) != ":") return 0 + i = skip_whitespace(i + 1) + if (!parse_value(i, depth)) return 0 + value_end = parsed_end + value_kind = parsed_kind + value_raw = parsed_raw + value_encoded = parsed_encoded + value_plain = parsed_plain + value_too_long = parsed_too_long + if (depth == 0 && key_plain && !key_too_long && key == target) { + matches++ + kind_matches = value_kind == expected_kind || (value_kind == "string" && expected_kind == "encoded-string") + if (matches > 1 || !kind_matches) return 0 + if (expected_kind == "string") { + if (!value_plain || value_too_long) return 0 + target_value = value_raw + } else if (expected_kind == "encoded-string") { + if (value_too_long) return 0 + target_value = value_encoded + } else target_value = value_raw + } + i = skip_whitespace(value_end + 1) + if (charat(i) == "}") { parsed_end = i; return 1 } + if (charat(i) != ",") return 0 + i = skip_whitespace(i + 1) + if (charat(i) == "}") return 0 + } + } + function parse_value(start, depth, c) { + start = skip_whitespace(start) + c = charat(start) + parsed_raw = "" + parsed_plain = 0 + parsed_too_long = 0 + if (c == "\"") { + if (!parse_string(start)) return 0 + parsed_kind = "string" + return 1 + } + if (c == "{") { + if (!parse_object(start, depth + 1)) return 0 + parsed_kind = "object" + return 1 + } + if (c == "[") { + if (!parse_array(start, depth + 1)) return 0 + parsed_kind = "array" + return 1 + } + if (substr(document, start, 4) == "true") { + parsed_end = start + 3 + parsed_kind = "boolean" + parsed_raw = "true" + return 1 + } + if (substr(document, start, 5) == "false") { + parsed_end = start + 4 + parsed_kind = "boolean" + parsed_raw = "false" + return 1 + } + if (substr(document, start, 4) == "null") { + parsed_end = start + 3 + parsed_kind = "null" + return 1 + } + return parse_number(start) + } + { + if (NR > 1) pending = pending "\n" + pending = pending $0 + if (length(pending) >= 4096) { document = document pending; pending = "" } + } + END { + document = document pending + document_length = length(document) + short_string_limit = 256 + nesting_limit = 128 + char_indexed = split(document, chars, "") == document_length + if (!char_indexed && document_length > 65536) exit 2 + i = skip_whitespace(1) + if (charat(i) != "{" || !parse_object(i, 0)) exit 2 + i = skip_whitespace(parsed_end + 1) + if (i <= document_length || matches > 1) exit 2 + if (matches == 0) print "__MISSING__" + else print target_value + } + ' "$1" +} + +top_level_string() { + top_level_member "$1" "$2" string +} + +top_level_encoded_string() { + top_level_member "$1" "$2" encoded-string +} + +valid_semver() { + awk -v value="$1" ' + function numeric(value) { return value ~ /^(0|[1-9][0-9]*)$/ } + function identifiers(value, prerelease, count,parts,part_index,item) { + if (value == "") return 0 + count = split(value, parts, ".") + for (part_index = 1; part_index <= count; part_index++) { + item = parts[part_index] + if (item == "" || item !~ /^[0-9A-Za-z-]+$/) return 0 + if (prerelease && item ~ /^[0-9]+$/ && !numeric(item)) return 0 + } + return 1 + } + BEGIN { + if (value == "" || value ~ /[[:space:]]/) exit 1 + main = value + plus = index(main, "+") + if (plus) { + build = substr(main, plus + 1) + main = substr(main, 1, plus - 1) + if (index(build, "+") || !identifiers(build, 0)) exit 1 + } + if (index(main, "-")) { + dash = index(main, "-") + prerelease = substr(main, dash + 1) + main = substr(main, 1, dash - 1) + if (!identifiers(prerelease, 1)) exit 1 + } + if (split(main, core, ".") != 3) exit 1 + for (part_index = 1; part_index <= 3; part_index++) if (!numeric(core[part_index])) exit 1 + exit 0 + } + ' /dev/null 2>&1 || warn +cmp "$fixture_source" "$fixture_projection" >/dev/null 2>&1 +comparison_status=$? +case "$comparison_status" in +0) exit 0 ;; +1) + printf '{"decision":"block","reason":"%s v%s found a mismatch in its packaged lifecycle proof files. Do not modify the workspace. Continue once to explain how to reinstall this plugin from its trusted source; if the mismatch remains, report this plugin version."}\n' "$display_name" "$version" + exit 0 + ;; +*) warn ;; +esac + +exit 0 diff --git a/plugin/skills/capability-tour/SKILL.md b/plugin/skills/capability-tour/SKILL.md new file mode 100644 index 0000000..fab22d1 --- /dev/null +++ b/plugin/skills/capability-tour/SKILL.md @@ -0,0 +1,43 @@ +--- +name: capability-tour +description: "Run the native plugin capability tour and report truthful hook, skill, candidate, and delegation evidence." +--- + +# Native Plugin Capability Tour + +Inspect only the installed plugin. Do not inspect the source checkout or unrelated project and user state. Resolve the installed plugin root three directories above this `SKILL.md`. + +## Evidence collection + +Read the installed Claude and Codex manifests, declared hook files, lifecycle fixture pair, runtime skill catalog, bundle inventory, launcher names, installed skill names, and `references/capability-reviewer.md`. + +Keep these evidence layers separate: + +- `declaration`: present and valid only when both manifests resolve their client-specific declarations and those declarations contain exactly `SessionStart` and `Stop` commands for the shared installed handler. +- `direct handler`: run `/hooks/native-capability-hook Stop ` directly once, where `` is exactly `claude` or `codex`, using `{"stop_hook_active":false}` on stdin. Use the resolved absolute installed-plugin root, not a relative path or source-checkout path. Report only pass, lifecycle fixture mismatch, or operational failure. Direct execution never proves native activation. +- `currentSessionHook`: `observed` only when the current session already contains the native `SessionStart` context marker matching the installed identity and active client. Otherwise report `unknown`. Never infer it from manifest presence or direct handler success. +- `external candidate qualification`: report `qualified` only from a supplied host-owned receipt bound to this exact installed candidate. Otherwise report `unknown` or the supplied non-healthy conclusion. +- `delegation delivery`: use the rules below. + +Call the fixture a lifecycle mechanics proof. Do not claim production integrity, security, or workspace quality. + +## Delegation + +When the active client exposes a generic native subagent primitive, request exactly one bounded read-only verification task. Seed it with the complete reviewer prompt from `references/capability-reviewer.md` plus only the bounded installed-plugin inputs named there. Do not select a model or agent persona. Do not create or modify an agent file, harness setting, project setting, or user setting. + +After the handback: + +- Use `native-subagent-via-skill` plus `claude` or `codex` only when the handback satisfies the reviewer schema, reports no mutation, and the host call supplies host-owned subagent lifecycle evidence correlated with that handback. Handback text or a model-authored delivery label is not proof. +- Use `inline-fallback-unavailable` when the active client has no generic native subagent primitive. Perform the same reviewer checks inline. Do not claim subagent proof. +- Use `inline-recovery-delegation-failed` when a delegation attempt fails or its handback/receipt is invalid. Perform the same checks inline and keep the overall result non-healthy. Do not attempt a second delegation or claim subagent proof. + +Never claim a packaged standalone agent or companion installation. Never write agent configuration. + +## Default response + +Return these ordered groups. Keep raw paths, hashes, handler JSON, and reviewer JSON out of the response. + +1. `Overall verdict`: one line with `healthy` or `non-healthy` plus installed name and version. Include the active client only alongside a validated `native-subagent-via-skill` delivery. +2. `Evidence matrix`: a compact table with rows in this order: declaration, direct handler, `currentSessionHook`, external candidate qualification, delegation delivery. State `observed`, `passed`, `qualified`, `unknown`, or the precise non-healthy result without merging evidence layers. +3. `Available portable skills`: list `hello-world`, `skill-a`, `skill-b`, `runtime-custody`, and `capability-tour`, marking the first three Bun-backed and the last two model-only. +4. `Next action`: include only for an untrusted hook, lifecycle fixture failure, failed delegation, or another non-healthy state. Give one concrete recovery action. Omit this group when healthy. diff --git a/plugin/skills/capability-tour/references/capability-reviewer.md b/plugin/skills/capability-tour/references/capability-reviewer.md new file mode 100644 index 0000000..f77e950 --- /dev/null +++ b/plugin/skills/capability-tour/references/capability-reviewer.md @@ -0,0 +1,43 @@ +# Capability Reviewer + +Perform one bounded, read-only review of the installed plugin facts supplied by the caller. + +Constraints: + +- Use only the bounded inputs: installed plugin root, active client, current native context marker if present, and external qualification receipt if supplied. +- Do not mutate plugin, workspace, project, user, or harness state. +- Do not install, repair, download, invoke network access, or write configuration. +- Do not select or pin a model. Use the host's generic native subagent defaults. +- Treat package declaration, direct handler execution, current-session hook activation, external candidate qualification, and native delegation as separate evidence layers. +- Never infer native hook activation from a declaration or direct handler result. +- Treat a delivery label written by the model as non-authoritative. Native delegation requires the caller's host-owned subagent lifecycle receipt correlated with this handback. +- Call the fixture check a lifecycle mechanics proof. Never describe it as an integrity, security, or workspace-quality guarantee. + +Checks: + +1. Read both installed native manifests and their declared hook files. +2. Confirm both declarations contain only `SessionStart` and `Stop`, target the shared installed handler, and fix the matching client and event arguments. +3. Compare the packaged lifecycle fixture source and generated projection as exact bytes. +4. Run `/hooks/native-capability-hook Stop ` directly once, where `` is exactly `claude` or `codex`, with `stop_hook_active` set to false. Use the resolved absolute installed-plugin root, not a relative path or source-checkout path. Capture only pass, mismatch, or operational failure. Do not return raw handler output. +5. Read the skill catalog, bundle inventory, launchers, and installed skill names. Confirm `capability-tour` is model-only and the existing portable skills remain available. +6. Classify the supplied current native context marker and external qualification receipt without upgrading missing evidence to proof. + +Return this structured handback and nothing else: + +```json +{ + "schemaVersion": 1, + "installedIdentity": { "name": "string", "version": "string" }, + "activeClient": "claude | codex | unknown", + "declaration": "valid | invalid | unknown", + "directHandler": "passed | mismatch | failed", + "fixture": "matched | mismatch | unknown", + "currentSessionHook": "observed | unknown", + "externalCandidateQualification": "qualified | unqualified | unknown", + "portableSkills": ["string"], + "mutation": "none", + "findings": ["short finding"] +} +``` + +This JSON is the structured handback. It is not host-owned lifecycle evidence. diff --git a/scripts/build.test.ts b/scripts/build.test.ts index 20e49ed..9d4bee2 100644 --- a/scripts/build.test.ts +++ b/scripts/build.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, cpSync, existsSync, mkdirSync, @@ -1458,14 +1459,39 @@ function runRepositoryBuild(): { return { bundles: inventory.bundles, notices: inventory.notices } } -test("workspace bundles build, relocate, and execute without workspaces or node_modules", () => { +test("workspace bundles build, relocate, and execute without hooks, workspaces, or node_modules", () => { const result = runRepositoryBuild() expect(Object.keys(result.bundles)).toEqual(["hello-world", "skill-a", "skill-b"]) validateBundleClosure(root) const installedRoot = temporaryDirectory("relocated-plugin-") copyPluginPayload(root, installedRoot) + rmSync(join(installedRoot, "hooks"), { recursive: true }) + expect(readFileSync(join(installedRoot, "skills", "capability-tour", "SKILL.md"), "utf8")).toContain( + "capability tour", + ) const environment = { PATH: "/usr/bin:/bin", HOME: installedRoot } + const helloWorld = Bun.spawnSync({ + cmd: [ + process.execPath, + join(installedRoot, result.bundles["hello-world"].path), + "hello", + "--json", + ], + cwd: installedRoot, + env: { ...environment, HELLO_WORLD_RUN_ID: "relocated-offline-proof" }, + stdout: "pipe", + stderr: "pipe", + }) + expect(helloWorld.stderr.toString()).toBe("") + expect(helloWorld.exitCode).toBe(0) + expect(JSON.parse(helloWorld.stdout.toString())).toEqual({ + ok: true, + command: "hello", + message: "Hello, world!", + sideEffects: "none", + runId: "relocated-offline-proof", + }) const skillA = Bun.spawnSync({ cmd: [process.execPath, join(installedRoot, result.bundles["skill-a"].path)], @@ -1528,12 +1554,88 @@ test("Bun-only release admission accepts the complete current payload", () => { expect(() => validateBunOnlyPayload(bunPayloadFixture())).not.toThrow() }) +test("Bun-only release admission rejects an arbitrary model-only skill", () => { + const fixtureRoot = bunPayloadFixture() + mkdirSync(join(fixtureRoot, "plugin", "skills", "unexpected-model-skill"), { + recursive: true, + }) + writeFileSync( + join(fixtureRoot, "plugin", "skills", "unexpected-model-skill", "SKILL.md"), + "# Unexpected model skill\n", + ) + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow(/skill sidecar inventory/) +}) + +test("Bun-only release admission rejects an arbitrary executable sidecar", () => { + const fixtureRoot = bunPayloadFixture() + writeFileSync(join(fixtureRoot, "plugin", "hooks", "unexpected-handler"), "#!/bin/sh\n") + chmodSync(join(fixtureRoot, "plugin", "hooks", "unexpected-handler"), 0o755) + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow(/hook sidecar inventory/) +}) + +test.each([ + [ + "hooks/native-capability-hook", + "Bun payload closure: missing hooks/native-capability-hook", + ], + ["assets/logo.svg", "Bun payload closure: missing assets/logo.svg"], + [ + "skills/capability-tour/references/capability-reviewer.md", + 'unsafe plugin payload entry "plugin/skills/capability-tour/references": empty directory', + ], +] as const)("Bun-only release admission rejects missing capability sidecar %s", (path, rejection) => { + const fixtureRoot = bunPayloadFixture() + rmSync(join(fixtureRoot, "plugin", path)) + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow(rejection) +}) + +test("Bun-only release admission rejects a non-executable native hook", () => { + const fixtureRoot = bunPayloadFixture() + chmodSync(join(fixtureRoot, "plugin", "hooks", "native-capability-hook"), 0o644) + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow( + "Bun payload closure: hooks/native-capability-hook is not executable", + ) +}) + +test.each([ + "assets/orphan.svg", + "skills/capability-tour/references/orphan.md", +] as const)("Bun-only release admission rejects orphaned capability sidecar %s", (path) => { + const fixtureRoot = bunPayloadFixture() + writeFileSync(join(fixtureRoot, "plugin", path), "orphan\n") + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow(/sidecar inventory/) +}) + +test("Bun-only release admission rejects an unsupported component surface", () => { + const fixtureRoot = bunPayloadFixture() + mkdirSync(join(fixtureRoot, "plugin", "mcp")) + writeFileSync(join(fixtureRoot, "plugin", "mcp", "server.json"), "{}\n") + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow(/unsupported payload surface mcp/) +}) + test("Bun-only release admission rejects a legacy runtime surface", () => { const fixtureRoot = bunPayloadFixture() writeFileSync(join(fixtureRoot, "plugin", "runtime", "qjs-legacy"), "legacy\n") expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow(/legacy runtime surface/) }) +test("Bun-only release admission rejects an unlisted runtime executable", () => { + const fixtureRoot = bunPayloadFixture() + writeFileSync(join(fixtureRoot, "plugin", "runtime", "rogue.sh"), "#!/bin/sh\n") + chmodSync(join(fixtureRoot, "plugin", "runtime", "rogue.sh"), 0o755) + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow( + /unexpected payload file runtime\/rogue\.sh/, + ) +}) + +test("Bun-only release admission rejects an unlisted manifest sidecar", () => { + const fixtureRoot = bunPayloadFixture() + writeFileSync(join(fixtureRoot, "plugin", ".claude-plugin", "extra.json"), "{}\n") + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow( + /unexpected payload file \.claude-plugin\/extra\.json/, + ) +}) + test("Bun-only release admission rejects an orphaned launcher", () => { const fixtureRoot = bunPayloadFixture() writeFileSync(join(fixtureRoot, "plugin", "bin", "orphan"), "#!/bin/sh\n") diff --git a/scripts/build.ts b/scripts/build.ts index 0c7474f..2f2cbde 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1621,11 +1621,33 @@ export function validateBundleClosure(root: string): void { } const forbiddenRuntimePaths = [ - /^hooks\//, /^runtime\/qjs-/, /^runtime\/quickjs-assets\.json$/, /^QUICKJS-LICENSE$/, ] +const capabilityHookFiles = [ + "hooks/claude/hooks.json", + "hooks/codex/hooks.json", + "hooks/fixture/lifecycle-mechanics-proof.generated.json", + "hooks/fixture/lifecycle-mechanics-proof.source.json", + "hooks/native-capability-hook", +].sort(compareCodeUnits) +const capabilityAssetFiles = ["assets/composer-icon.svg", "assets/logo.svg"].sort(compareCodeUnits) +const modelOnlySkillFiles = [ + "skills/capability-tour/SKILL.md", + "skills/capability-tour/references/capability-reviewer.md", + "skills/runtime-custody/SKILL.md", +] +const allowedPayloadSurfaces = new Set([ + ".claude-plugin", + ".codex-plugin", + "THIRD-PARTY-NOTICES.md", + "assets", + "bin", + "hooks", + "runtime", + "skills", +]) const requiredCapabilities = [ "Execute verified Bun code", "Download Bun after approval", @@ -1644,6 +1666,12 @@ export function validateBunOnlyPayload(root: string): void { const catalog = loadSkillCatalog(root) const inventory = pluginPayloadInventory(root) const inventorySet = new Set(inventory) + for (const path of inventory) { + const surface = path.split("/", 1)[0] + if (!allowedPayloadSurfaces.has(surface)) { + throw new Error(`Bun payload closure: unsupported payload surface ${surface}/`) + } + } const required = [ ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", @@ -1653,6 +1681,9 @@ export function validateBunOnlyPayload(root: string): void { "runtime/runtime-exec", "runtime/runtime-lock.sh", "runtime/skill-catalog.sh", + ...capabilityHookFiles, + ...capabilityAssetFiles, + ...modelOnlySkillFiles, ] const bundleInventory = JSON.parse( readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json"), "utf8"), @@ -1668,6 +1699,22 @@ export function validateBunOnlyPayload(root: string): void { for (const path of required) { if (!inventorySet.has(path)) throw new Error(`Bun payload closure: missing ${path}`) } + const hookFiles = inventory.filter((path) => path.startsWith("hooks/")) + if (hookFiles.join("\0") !== capabilityHookFiles.join("\0")) { + throw new Error("Bun payload closure: hook sidecar inventory does not match the capability contract") + } + const assetFiles = inventory.filter((path) => path.startsWith("assets/")) + if (assetFiles.join("\0") !== capabilityAssetFiles.join("\0")) { + throw new Error("Bun payload closure: asset sidecar inventory does not match the capability contract") + } + const catalogSkillFiles = Object.keys(catalog.skills) + .sort(compareCodeUnits) + .map((skillId) => `skills/${skillId}/SKILL.md`) + const expectedSkillFiles = [...modelOnlySkillFiles, ...catalogSkillFiles].sort(compareCodeUnits) + const skillFiles = inventory.filter((path) => path.startsWith("skills/")) + if (skillFiles.join("\0") !== expectedSkillFiles.join("\0")) { + throw new Error("Bun payload closure: skill sidecar inventory does not match the catalog and model-only allowlist") + } const drifted = checkRuntimeCustodyFiles(root) if (drifted.length > 0) { @@ -1680,7 +1727,11 @@ export function validateBunOnlyPayload(root: string): void { if (launchers.join("\0") !== expectedLaunchers.join("\0")) { throw new Error("Bun payload closure: launcher inventory does not match the skill catalog") } - for (const path of ["runtime/runtime-exec", ...expectedLaunchers.map((id) => `bin/${id}`)]) { + for (const path of [ + "runtime/runtime-exec", + "hooks/native-capability-hook", + ...expectedLaunchers.map((id) => `bin/${id}`), + ]) { if ((statSync(join(root, "plugin", path)).mode & 0o111) === 0) { throw new Error(`Bun payload closure: ${path} is not executable`) } @@ -1696,14 +1747,46 @@ export function validateBunOnlyPayload(root: string): void { } } } - for (const manifestPath of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"]) { + // The expected inventory is complete: manifest sidecars, generated runtime + // scripts, capability sidecars, and catalog-derived launchers, skills, and + // bundles. Anything outside that exact set never ships. + const expectedInventory = new Set(required) + for (const path of inventory) { + if (!expectedInventory.has(path)) { + throw new Error(`Bun payload closure: unexpected payload file ${path}`) + } + } + for (const manifestPath of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"] as const) { const manifest = JSON.parse(readFileSync(join(root, "plugin", manifestPath), "utf8")) as { hooks?: unknown description?: unknown interface?: { capabilities?: unknown } + [key: string]: unknown } - if (manifest.hooks !== undefined) { - throw new Error(`Bun payload closure: runtime hooks remain active in ${manifestPath}`) + const expectedHooks = + manifestPath === ".claude-plugin/plugin.json" + ? "./hooks/claude/hooks.json" + : "./hooks/codex/hooks.json" + if (manifest.hooks !== expectedHooks) { + throw new Error(`Bun payload closure: invalid hook declaration in ${manifestPath}`) + } + for (const forbidden of [ + "agents", + "apps", + "channels", + "commands", + "connectors", + "extensions", + "lspServers", + "mcpServers", + "monitors", + "outputStyles", + "settings", + "themes", + ]) { + if (manifest[forbidden] !== undefined) { + throw new Error(`Bun payload closure: unsupported component ${forbidden} in ${manifestPath}`) + } } if (typeof manifest.description !== "string" || !manifest.description.includes("Bun")) { throw new Error(`Bun payload closure: ${manifestPath} does not disclose Bun execution`) diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 00dea0d..d6ed6e5 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -7,6 +7,8 @@ test("Claude development watches workspace, runtime, manifest, and lock inputs", { relativePath: "runtime", recursive: true }, { relativePath: "packages", recursive: true }, { relativePath: "plugin/skills", recursive: true }, + { relativePath: "plugin/hooks", recursive: true }, + { relativePath: "plugin/assets", recursive: true }, { relativePath: "plugin/.claude-plugin", recursive: true }, { relativePath: "plugin/.codex-plugin", recursive: true }, { relativePath: "package.json", recursive: false }, diff --git a/scripts/dev.ts b/scripts/dev.ts index 480e027..bdd69cd 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -26,6 +26,8 @@ export const claudeWatchSources = [ { relativePath: "runtime", recursive: true }, { relativePath: "packages", recursive: true }, { relativePath: "plugin/skills", recursive: true }, + { relativePath: "plugin/hooks", recursive: true }, + { relativePath: "plugin/assets", recursive: true }, { relativePath: "plugin/.claude-plugin", recursive: true }, { relativePath: "plugin/.codex-plugin", recursive: true }, { relativePath: "package.json", recursive: false }, diff --git a/scripts/distribution-checksums.test.ts b/scripts/distribution-checksums.test.ts index 7f6f2fa..a02d4e5 100644 --- a/scripts/distribution-checksums.test.ts +++ b/scripts/distribution-checksums.test.ts @@ -14,6 +14,7 @@ const expected: DistributionChecksumIdentity = { archive: "example-plugin-1.2.3.tar.gz", archiveBytes: 1234, archiveSha256: "b".repeat(64), + payloadInventorySha256: "e".repeat(64), } test.each([ @@ -25,6 +26,7 @@ test.each([ ["archive", "wrong.tar.gz"], ["archiveBytes", 9999], ["archiveSha256", "d".repeat(64)], + ["payloadInventorySha256", "f".repeat(64)], ] as const)("distribution proof rejects mismatched %s", (field, value) => { expect(() => assertDistributionChecksumIdentity({ ...expected, [field]: value }, expected), diff --git a/scripts/distribution-checksums.ts b/scripts/distribution-checksums.ts index 11b9402..3c23919 100644 --- a/scripts/distribution-checksums.ts +++ b/scripts/distribution-checksums.ts @@ -12,6 +12,7 @@ * archive: "example-plugin-1.0.0.tar.gz", * archiveBytes: 1024, * archiveSha256: "b".repeat(64), + * payloadInventorySha256: "c".repeat(64), * } * ``` */ @@ -32,6 +33,8 @@ export interface DistributionChecksumIdentity { archiveBytes: number /** Measured SHA-256 digest of the archive. */ archiveSha256: string + /** Canonical path-and-byte digest of the complete plugin payload. */ + payloadInventorySha256: string } /** @@ -59,6 +62,7 @@ export function assertDistributionChecksumIdentity( "archive", "archiveBytes", "archiveSha256", + "payloadInventorySha256", ] as const) { if (checksums[field] !== expected[field]) { throw new Error(`checksum ${field} does not match the packaged archive identity`) diff --git a/scripts/generate.ts b/scripts/generate.ts index bda77fc..5f31a3c 100644 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -1,5 +1,9 @@ import { resolve } from "node:path" +import { + checkNativeCapabilityFixture, + writeNativeCapabilityFixture, +} from "./native-capability-fixture" import { checkGeneratedFiles, loadPluginConfig, writeGeneratedFiles } from "./plugin-config" import { checkRuntimeCustodyFiles, @@ -12,7 +16,7 @@ const check = arguments_.includes("--check") const json = arguments_.includes("--json") if (arguments_.includes("--help") || arguments_.includes("-h")) { - console.log(`Generate native manifests and runtime-custody projections from canonical sources. + console.log(`Generate native manifests, hook declarations, lifecycle proof fixture, and runtime-custody projections from canonical sources. Usage: bun run generate @@ -36,6 +40,7 @@ const config = loadPluginConfig(root) if (check) { const drifted = [ ...checkGeneratedFiles(root, config), + ...checkNativeCapabilityFixture(root), ...checkRuntimeCustodyFiles(root), ] if (drifted.length > 0) { @@ -48,6 +53,7 @@ const files = check ? [] : [ ...writeGeneratedFiles(root, config), + ...writeNativeCapabilityFixture(root), ...writeRuntimeCustodyFiles(root), ] const result = { diff --git a/scripts/harness-install-codex.test.ts b/scripts/harness-install-codex.test.ts index 8cb93ea..b3f07cb 100644 --- a/scripts/harness-install-codex.test.ts +++ b/scripts/harness-install-codex.test.ts @@ -42,7 +42,11 @@ function installedState( writeFileSync(join(installedPath, "runtime", "hello-world.js"), runtime) writeFileSync( join(installedPath, ".codex-plugin", "plugin.json"), - JSON.stringify({ name: "plugin", version: listedVersion }), + JSON.stringify({ + name: "plugin", + version: listedVersion, + hooks: "./hooks/codex/hooks.json", + }), ) return { marketplaceAdd: { marketplaceName: "plugin", installedRoot: root, alreadyAdded: false }, diff --git a/scripts/harness-install-codex.ts b/scripts/harness-install-codex.ts index c48fa3b..2ac5245 100644 --- a/scripts/harness-install-codex.ts +++ b/scripts/harness-install-codex.ts @@ -164,7 +164,9 @@ export function proveCodexNative( const manifest = JSON.parse( readFileSync(join(restored.add.installedPath, ".codex-plugin", "plugin.json"), "utf8"), ) - if (manifest.hooks !== undefined) throw new Error("Codex installed payload activates lifecycle hooks") + if (manifest.hooks !== "./hooks/codex/hooks.json") { + throw new Error("Codex installed payload does not reference the exact capability hook declaration") + } return { mode: "native-local-marketplace", version: restored.add.version, @@ -196,9 +198,8 @@ export function proveCodexNative( enabledStateRestored: true, failureRestored, }, - activation: { + installedState: { pluginEnabled: restored.plugin.enabled, - lifecycleHookPresent: false, executionEntry: "explicit skill launcher", runtimeRepairOwner: "agent workflow with human approval", }, @@ -244,6 +245,6 @@ export function proveCodexFixtureCopy( enabledStateRestored: false, failureRestored: false, }, - activation: null, + installedState: null, } } diff --git a/scripts/init.test.ts b/scripts/init.test.ts index a7dd0e5..6a8dbcb 100644 --- a/scripts/init.test.ts +++ b/scripts/init.test.ts @@ -203,8 +203,8 @@ test("template user initializes both harness manifests from one metadata source" version: initialVersion, defaultEnabled: false, skills: "./skills/", + hooks: "./hooks/claude/hooks.json", }) - expect(claudeManifest).not.toHaveProperty("hooks") const claudeMarketplace = JSON.parse( readFileSync(join(temporaryRoot, ".claude-plugin", "marketplace.json"), "utf8"), ) @@ -221,9 +221,19 @@ test("template user initializes both harness manifests from one metadata source" name: "dojo-hello", version: initialVersion, skills: "./skills/", - interface: { displayName: "Dojo Hello" }, + hooks: "./hooks/codex/hooks.json", + interface: { + displayName: "Dojo Hello", + brandColor: "#3B5CCC", + composerIcon: "./assets/composer-icon.svg", + logo: "./assets/logo.svg", + }, }) - expect(codexManifest).not.toHaveProperty("hooks") + for (const field of ["composerIcon", "logo"] as const) { + const assetPath = join(temporaryRoot, "plugin", codexManifest.interface[field]) + expect(existsSync(assetPath)).toBe(true) + expect(readFileSync(assetPath, "utf8")).not.toMatch(/Harness Plugin Prototype|dojo-hello|Dojo Hello/) + } const codexMarketplace = JSON.parse( readFileSync(join(temporaryRoot, ".agents", "plugins", "marketplace.json"), "utf8"), @@ -348,7 +358,8 @@ test("initialization resets recipient release lineage to 0.1.0", () => { ) expect(releaseConfig.packages["."]["package-name"]).toBe("dojo-hello") - expect(existsSync(join(temporaryRoot, "plugin", "hooks"))).toBe(false) + expect(existsSync(join(temporaryRoot, "plugin", "hooks", "claude", "hooks.json"))).toBe(true) + expect(existsSync(join(temporaryRoot, "plugin", "hooks", "codex", "hooks.json"))).toBe(true) }) test("reinitialization without force preserves recipient release files", () => { @@ -582,7 +593,7 @@ test("package accepts an explicit source commit when Git metadata is unavailable expect(checksums.sourceCommit).toBe(sourceCommit) }) -test("package preserves filenames containing newlines and backslashes", () => { +test("package rejects payload files outside the exact expected inventory", () => { const temporaryRoot = copyTemplate("agent-plugin-template-package-filenames-") const init = initializeTemplate(temporaryRoot) expect(init.exitCode, init.stderr.toString()).toBe(0) @@ -590,21 +601,9 @@ test("package preserves filenames containing newlines and backslashes", () => { const unusualPath = join(temporaryRoot, "plugin", "runtime", unusualName) writeFileSync(unusualPath, "unusual filename payload\n") const sourceCommit = commitPackageCheckout(temporaryRoot) - const result = successfulPackageWithSource(temporaryRoot, "SOURCE_COMMIT", sourceCommit) - const extractRoot = mkdtempSync(join(tmpdir(), "agent-plugin-template-package-extract-")) - try { - const extracted = Bun.spawnSync({ - cmd: ["tar", "-xzf", result.archive, "-C", extractRoot], - stdout: "pipe", - stderr: "pipe", - }) - expect(extracted.exitCode, extracted.stderr.toString()).toBe(0) - const archivedPath = join(extractRoot, `dojo-hello-${initialVersion}`, "runtime", unusualName) - expect(existsSync(archivedPath)).toBe(true) - expect(readFileSync(archivedPath, "utf8")).toBe("unusual filename payload\n") - } finally { - rmSync(extractRoot, { recursive: true, force: true }) - } + const packaged = packageWithSource(temporaryRoot, "SOURCE_COMMIT", sourceCommit) + expect(packaged.exitCode).not.toBe(0) + expect(packaged.stderr.toString()).toContain("Bun payload closure: unexpected payload file") }) test.each(["SOURCE_COMMIT", "GITHUB_SHA"] as const)( diff --git a/scripts/init.ts b/scripts/init.ts index f1cfb79..f9f1822 100644 --- a/scripts/init.ts +++ b/scripts/init.ts @@ -1,6 +1,10 @@ import { readFileSync, writeFileSync } from "node:fs" import { resolve } from "node:path" +import { + renderNativeCapabilityFixtureFor, + writeNativeCapabilityFixture, +} from "./native-capability-fixture" import { type PluginConfig, loadPluginConfig, renderGeneratedFiles, writeGeneratedFiles } from "./plugin-config" const root = resolve(import.meta.dir, "..") @@ -190,10 +194,12 @@ const config: PluginConfig = { }, } const generatedFiles = renderGeneratedFiles(config) +const fixtureFiles = renderNativeCapabilityFixtureFor(config.displayName) const resetFiles = releaseResetFiles(root, config.name) if (!options.dryRun) { writeFileSync(resolve(root, "plugin.config.json"), `${JSON.stringify(config, null, 2)}\n`) writeGeneratedFiles(root, config) + writeNativeCapabilityFixture(root) for (const file of resetFiles) writeFileSync(resolve(root, file.path), file.contents) } @@ -206,6 +212,7 @@ const result = { files: [ "plugin.config.json", ...generatedFiles.map((file) => file.path), + ...fixtureFiles.map((file) => file.path), ...resetFiles.map((file) => file.path), ], nextAction: options.dryRun ? "rerun without --dry-run" : "bun run prove:all", diff --git a/scripts/native-capability-fixture.ts b/scripts/native-capability-fixture.ts new file mode 100644 index 0000000..725b529 --- /dev/null +++ b/scripts/native-capability-fixture.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync } from "node:fs" +import { join } from "node:path" + +import { + type GeneratedFile, + loadPluginConfig, + writeGeneratedFileSet, +} from "./plugin-config" + +const sourcePath = "plugin/hooks/fixture/lifecycle-mechanics-proof.source.json" +const projectionPath = "plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json" + +function serializeLifecycleMechanicsProof(displayName: string): string { + return `${JSON.stringify( + { + schemaVersion: 1, + purpose: `${displayName} lifecycle mechanics proof`, + }, + null, + 2, + )}\n` +} + +/** + * Render both lifecycle proof fixture files for one configured display name. + * + * The purpose string derives from the plugin's display name, so initialized + * templates regenerate an identity-correct fixture instead of keeping the + * template's product name. + * + * @param displayName - Validated human-readable plugin title + * @returns The source and projection payload files with identical LF bytes + * + * @example + * ```ts + * const [source, projection] = renderNativeCapabilityFixtureFor("Dojo Hello") + * ``` + */ +export function renderNativeCapabilityFixtureFor(displayName: string): GeneratedFile[] { + const contents = serializeLifecycleMechanicsProof(displayName) + return [ + { path: sourcePath, contents }, + { path: projectionPath, contents }, + ] +} + +/** + * Render the lifecycle proof fixture files with fixed field order and LF bytes. + * + * @param root - Plugin Repository root containing plugin.config.json + * @returns The generated source and projection payload files + * @throws {Error} When plugin.config.json is missing or violates its contract + * + * @example + * ```ts + * const [source, projection] = renderNativeCapabilityFixture(process.cwd()) + * ``` + */ +export function renderNativeCapabilityFixture(root: string): GeneratedFile[] { + return renderNativeCapabilityFixtureFor(loadPluginConfig(root).displayName) +} + +/** + * Write the generated lifecycle proof source and projection into the payload. + * + * @param root - Plugin Repository root receiving the fixture files + * @returns The generated files written to the payload + * @throws {Error} When configuration validation or fixture writing fails + * + * @example + * ```ts + * writeNativeCapabilityFixture(process.cwd()) + * ``` + */ +export function writeNativeCapabilityFixture(root: string): GeneratedFile[] { + return writeGeneratedFileSet(root, renderNativeCapabilityFixture(root)) +} + +/** + * Find lifecycle proof fixture files whose checked-in bytes drifted. + * + * @param root - Plugin Repository root containing source and projection + * @returns Repository-relative paths needing regeneration + * @throws {Error} When plugin.config.json is missing or invalid + * + * @example + * ```ts + * const drifted = checkNativeCapabilityFixture(process.cwd()) + * ``` + */ +export function checkNativeCapabilityFixture(root: string): string[] { + return renderNativeCapabilityFixture(root) + .filter((file) => { + const path = join(root, file.path) + return !existsSync(path) || readFileSync(path, "utf8") !== file.contents + }) + .map((file) => file.path) +} diff --git a/scripts/native-capability-hook.test.ts b/scripts/native-capability-hook.test.ts new file mode 100644 index 0000000..90b29cb --- /dev/null +++ b/scripts/native-capability-hook.test.ts @@ -0,0 +1,615 @@ +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +import { afterEach, expect, test } from "bun:test" + +const root = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, "") +const temporaryRoots: string[] = [] +const warning = + "This plugin could not run its lifecycle mechanics proof; continuing without blocking.\n" + +afterEach(() => { + for (const temporaryRoot of temporaryRoots.splice(0)) { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +function installedPlugin(): { handler: string; pluginRoot: string } { + const installationRoot = mkdtempSync(join(tmpdir(), "native-capability-hook-")) + temporaryRoots.push(installationRoot) + const pluginRoot = join(installationRoot, "plugin") + mkdirSync(join(pluginRoot, "hooks", "fixture"), { recursive: true }) + cpSync( + join(root, "plugin", "hooks", "native-capability-hook"), + join(pluginRoot, "hooks", "native-capability-hook"), + ) + cpSync( + join(root, "plugin", "hooks", "fixture"), + join(pluginRoot, "hooks", "fixture"), + { recursive: true }, + ) + mkdirSync(join(pluginRoot, ".claude-plugin")) + mkdirSync(join(pluginRoot, ".codex-plugin")) + for (const harness of ["claude", "codex"]) { + writeFileSync( + join(pluginRoot, `.${harness}-plugin`, "plugin.json"), + '{"name":"fixture","displayName":"Harness Plugin Prototype","version":"0.3.0"}\n', + ) + } + const handler = join(pluginRoot, "hooks", "native-capability-hook") + chmodSync(handler, 0o755) + return { handler, pluginRoot } +} + +function runHook( + handler: string, + event: "SessionStart" | "Stop", + client: "claude" | "codex", + input: string | Uint8Array, + options: { cwd?: string; env?: Record } = {}, +) { + return Bun.spawnSync({ + cmd: [handler, event, client], + stdin: Buffer.from(input), + stdout: "pipe", + stderr: "pipe", + ...options, + }) +} + +function expectFailsOpen(result: ReturnType): void { + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toBe("") + expect(result.stderr.toString()).toBe(warning) +} + +function treeSnapshot(directory: string): string[] { + const entries: string[] = [] + function visit(current: string, relative: string): void { + const status = statSync(current) + if (status.isDirectory()) { + entries.push(`d ${relative} ${status.mode & 0o7777} ${status.mtimeMs}`) + for (const name of readdirSync(current).sort()) { + visit(join(current, name), relative ? `${relative}/${name}` : name) + } + return + } + const digest = new Bun.CryptoHasher("sha256").update(readFileSync(current)).digest("hex") + entries.push(`f ${relative} ${status.mode & 0o7777} ${status.mtimeMs} ${digest}`) + } + visit(directory, ".") + return entries +} + +function runGenerateCheck(repositoryRoot = root): ReturnType { + return Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + join(repositoryRoot, "scripts", "generate.ts"), + "--check", + ], + cwd: repositoryRoot, + stdout: "pipe", + stderr: "pipe", + }) +} + +test("a clean Stop lifecycle mechanics proof is silent", () => { + const fixture = installedPlugin() + const before = readFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + ) + + const result = runHook( + fixture.handler, + "Stop", + "claude", + '{"stop_hook_active":false}', + ) + + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toBe("") + expect(result.stderr.toString()).toBe("") + expect( + readFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + ), + ).toEqual(before) +}) + +test("SessionStart emits one versioned receipt only for startup and resume", () => { + const fixture = installedPlugin() + for (const client of ["claude", "codex"] as const) { + for (const source of ["startup", "resume"] as const) { + const result = runHook( + fixture.handler, + "SessionStart", + client, + JSON.stringify({ source }), + ) + expect(result.exitCode).toBe(0) + expect(result.stderr.toString()).toBe("") + expect(JSON.parse(result.stdout.toString())).toEqual({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: `Harness Plugin Prototype v0.3.0 | ${client} | SessionStart:${source}`, + }, + }) + } + for (const source of ["clear", "compact", "fork", "future", ""] as const) { + const result = runHook( + fixture.handler, + "SessionStart", + client, + JSON.stringify(source ? { source } : {}), + ) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toBe("") + expect(result.stderr.toString()).toBe("") + } + } +}) + +test("a proven fixture mismatch returns the common versioned block envelope", () => { + const fixture = installedPlugin() + writeFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + "drifted\n", + ) + + const result = runHook( + fixture.handler, + "Stop", + "codex", + '{"stop_hook_active":false}', + ) + + expect(result.exitCode).toBe(0) + expect(result.stderr.toString()).toBe("") + expect(JSON.parse(result.stdout.toString())).toEqual({ + decision: "block", + reason: + "Harness Plugin Prototype v0.3.0 found a mismatch in its packaged lifecycle proof files. Do not modify the workspace. Continue once to explain how to reinstall this plugin from its trusted source; if the mismatch remains, report this plugin version.", + }) +}) + +test("an active Stop re-entry exits silently before fixture and version checks", () => { + const fixture = installedPlugin() + writeFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + "drifted\n", + ) + writeFileSync(join(fixture.pluginRoot, ".claude-plugin", "plugin.json"), "not-json\n") + + const result = runHook( + fixture.handler, + "Stop", + "claude", + '{"stop_hook_active":true}', + ) + + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toBe("") + expect(result.stderr.toString()).toBe("") +}) + +test("Stop guard parsing accepts reordered whitespace and ignores string and nested decoys", () => { + const fixture = installedPlugin() + writeFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + "drifted\n", + ) + const input = `{ + "message": "a string with \\\"stop_hook_active\\\": true", + "nested": { "stop_hook_active": true }, + "other": [1, { "stop_hook_active": true }], + "stop_hook_active" : false + }` + + const result = runHook(fixture.handler, "Stop", "claude", input) + + expect(result.exitCode).toBe(0) + expect(result.stderr.toString()).toBe("") + expect(JSON.parse(result.stdout.toString())).toMatchObject({ decision: "block" }) +}) + +test("ambiguous Stop guards fail open with one bounded warning", () => { + const fixture = installedPlugin() + writeFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + "drifted\n", + ) + const inputs = [ + "{}", + '{"stop_hook_active":"true"}', + '{"stop_hook_active":null}', + '{"stop_hook_active":1}', + '{"stop_hook_active":false,"stop_hook_active":true}', + '{"stop_hook_active":false,"\\u0073top_hook_active":true}', + '{"\\u0073top_hook_active":true,"stop_hook_active":false}', + '{"nested":{"stop_hook_active":false}}', + '{"stop_hook_active":false', + '{"other":1 "stop_hook_active":false}', + '{"stop_hook_active":false "other":1}', + '{"stop_hook_active":false,}', + '{"stop_hook_active":false,"other":}', + '{"stop_hook_active":false,"other":tru}', + '{"stop_hook_active":false,"other":[1,]}', + '{"stop_hook_active":false} trailing', + '[false]', + 'not json', + ] + + for (const client of ["claude", "codex"] as const) { + for (const input of inputs) { + expectFailsOpen(runHook(fixture.handler, "Stop", client, input)) + } + } +}) + +test("invalid UTF-8 Stop payloads fail open before fixture mismatch blocking", () => { + const fixture = installedPlugin() + writeFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + "drifted\n", + ) + const prefix = Buffer.from('{"stop_hook_active":false,"x":"') + const suffix = Buffer.from('"}') + + for (const invalidBytes of [ + Buffer.from([0x80]), + Buffer.from([0xff]), + Buffer.from([0xc0, 0x80]), + Buffer.from([0xed, 0xa0, 0x80]), + Buffer.from([0xf4, 0x90, 0x80, 0x80]), + Buffer.from([0xe2, 0x82]), + ]) { + const input = Buffer.concat([prefix, invalidBytes, suffix]) + for (const client of ["claude", "codex"] as const) { + expectFailsOpen(runHook(fixture.handler, "Stop", client, input)) + } + } + + const validUnicode = runHook( + fixture.handler, + "Stop", + "claude", + '{"stop_hook_active":false,"x":"é€🙂"}', + ) + expect(validUnicode.stderr.toString()).toBe("") + expect(JSON.parse(validUnicode.stdout.toString())).toMatchObject({ decision: "block" }) +}) + +test("oversized input fails open after reading only 1 MiB plus one overflow byte", () => { + const fixture = installedPlugin() + const overflowInput = "x".repeat(1024 * 1024 + 1) + expect(Buffer.byteLength(overflowInput)).toBe(1024 * 1024 + 1) + + for (const client of ["claude", "codex"] as const) { + expectFailsOpen(runHook(fixture.handler, "Stop", client, overflowInput)) + } +}) + +test("near-limit hostile payloads complete within a bounded wall clock", () => { + const fixture = installedPlugin() + const boundMilliseconds = 10_000 + const giantString = `{"payload":"${"a".repeat(1024 * 1024 - 64)}","stop_hook_active":false}` + const escapeDense = `{"payload":"${"\\n".repeat((1024 * 1024 - 64) / 2)}","stop_hook_active":false}` + const manyMembers = `{${Array.from({ length: 65_536 }, (_, index) => `"k${index}":"v"`).join(",")},"stop_hook_active":false}` + for (const input of [giantString, escapeDense, manyMembers]) { + expect(Buffer.byteLength(input)).toBeLessThanOrEqual(1024 * 1024) + const started = performance.now() + const result = runHook(fixture.handler, "Stop", "claude", input) + const elapsed = performance.now() - started + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toBe("") + expect(result.stderr.toString()).toBe("") + expect(elapsed).toBeLessThan(boundMilliseconds) + } + + const newlineFlood = "\n".repeat(1024 * 1024 - 1) + const floodStarted = performance.now() + const flood = runHook(fixture.handler, "Stop", "claude", newlineFlood) + const floodElapsed = performance.now() - floodStarted + expect(flood.exitCode).toBe(0) + expect(flood.stdout.toString()).toBe("") + expect(flood.stderr.toString()).toBe(warning) + expect(floodElapsed).toBeLessThan(boundMilliseconds) + + const sessionStart = `{"payload":"${"a".repeat(1024 * 1024 - 64)}","source":"startup"}` + expect(Buffer.byteLength(sessionStart)).toBeLessThanOrEqual(1024 * 1024) + const started = performance.now() + const result = runHook(fixture.handler, "SessionStart", "claude", sessionStart) + const elapsed = performance.now() - started + expect(result.exitCode).toBe(0) + expect(result.stderr.toString()).toBe("") + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + hookSpecificOutput: { + additionalContext: "Harness Plugin Prototype v0.3.0 | claude | SessionStart:startup", + }, + }) + expect(elapsed).toBeLessThan(boundMilliseconds) +}, 60_000) + +test("a renamed manifest identity reaches the receipt and the block reason", () => { + const fixture = installedPlugin() + const displayName = 'Dojo "Hello" \\ Tools' + writeFileSync( + join(fixture.pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: "dojo-hello", displayName, version: "0.3.0" })}\n`, + ) + + for (const client of ["claude", "codex"] as const) { + const start = runHook(fixture.handler, "SessionStart", client, '{"source":"startup"}') + expect(start.exitCode).toBe(0) + expect(start.stderr.toString()).toBe("") + expect(JSON.parse(start.stdout.toString())).toMatchObject({ + hookSpecificOutput: { + additionalContext: `${displayName} v0.3.0 | ${client} | SessionStart:startup`, + }, + }) + } + + writeFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + "drifted\n", + ) + const stop = runHook(fixture.handler, "Stop", "claude", '{"stop_hook_active":false}') + expect(stop.exitCode).toBe(0) + expect(stop.stderr.toString()).toBe("") + expect(JSON.parse(stop.stdout.toString())).toMatchObject({ + decision: "block", + reason: expect.stringContaining(`${displayName} v0.3.0 found a mismatch`), + }) +}) + +test("an unreadable display name fails open instead of substituting a placeholder", () => { + const fixture = installedPlugin() + writeFileSync( + join(fixture.pluginRoot, ".claude-plugin", "plugin.json"), + '{"name":"fixture","version":"0.3.0"}\n', + ) + + const start = runHook(fixture.handler, "SessionStart", "claude", '{"source":"startup"}') + expect(start.exitCode).toBe(0) + expect(start.stdout.toString()).toBe("") + expect(start.stderr.toString()).toBe(warning) + + writeFileSync( + join(fixture.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + "drifted\n", + ) + const stop = runHook(fixture.handler, "Stop", "claude", '{"stop_hook_active":false}') + expect(stop.exitCode).toBe(0) + expect(stop.stdout.toString()).toBe("") + expect(stop.stderr.toString()).toBe(warning) +}) + +test("malformed SessionStart input fails open without a context marker", () => { + const fixture = installedPlugin() + for (const client of ["claude", "codex"] as const) { + for (const input of [ + '{"source":"startup"', + '{"source":"startup","source":"resume"}', + '{"source":true}', + '{"other":1 "source":"startup"}', + ]) { + expectFailsOpen(runHook(fixture.handler, "SessionStart", client, input)) + } + } +}) + +test("version metadata requires exactly one top-level strict semver", () => { + const invalidManifests = [ + "{}\n", + '{"version":"v0.3.0"}\n', + '{"version":"01.2.3"}\n', + '{"version":"1.2"}\n', + '{"version":"1.2.3-01"}\n', + '{"version":"1.2.3+"}\n', + '{"version":"1.2.3+build+again"}\n', + '{"version":"0.3.0","version":"0.3.1"}\n', + '{"nested":{"version":"0.3.0"}}\n', + '{"version":"0.3.0"\n', + ] + for (const manifest of invalidManifests) { + const fixture = installedPlugin() + writeFileSync(join(fixture.pluginRoot, ".claude-plugin", "plugin.json"), manifest) + const result = runHook( + fixture.handler, + "SessionStart", + "claude", + '{"source":"startup"}', + ) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toBe("") + expect(result.stderr.toString()).toBe(warning) + } + + const valid = installedPlugin() + writeFileSync( + join(valid.pluginRoot, ".codex-plugin", "plugin.json"), + '{"version":"1.2.3-rc.1+build.7"}\n', + ) + const result = runHook( + valid.handler, + "SessionStart", + "codex", + '{"source":"resume"}', + ) + expect(result.stderr.toString()).toBe("") + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + hookSpecificOutput: { + additionalContext: + "Harness Plugin Prototype v1.2.3-rc.1+build.7 | codex | SessionStart:resume", + }, + }) +}) + +test("missing or unreadable proof files and unavailable comparison fail open", () => { + const missing = installedPlugin() + rmSync( + join(missing.pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.source.json"), + ) + + const runningAsRoot = process.getuid?.() === 0 + const unreadable = runningAsRoot ? undefined : installedPlugin() + if (unreadable) { + chmodSync( + join( + unreadable.pluginRoot, + "hooks", + "fixture", + "lifecycle-mechanics-proof.generated.json", + ), + 0o000, + ) + } + + const noComparison = installedPlugin() + const systemPath = mkdtempSync(join(tmpdir(), "native-capability-system-path-")) + temporaryRoots.push(systemPath) + for (const tool of ["awk", "dd", "mktemp", "rm", "wc"]) { + const path = Bun.which(tool) + if (!path) throw new Error(`required test tool unavailable: ${tool}`) + symlinkSync(path, join(systemPath, tool)) + } + writeFileSync( + noComparison.handler, + readFileSync(noComparison.handler, "utf8").replace( + "PATH=/usr/bin:/bin", + `PATH=${systemPath}`, + ), + ) + + for (const client of ["claude", "codex"] as const) { + for (const fixture of [missing, ...(unreadable ? [unreadable] : []), noComparison]) { + expectFailsOpen( + runHook( + fixture.handler, + "Stop", + client, + '{"stop_hook_active":false}', + ), + ) + } + } +}) + +test("hostile cwd and PATH cannot execute workspace or user-selected tools or mutate files", () => { + const fixture = installedPlugin() + const hostileRoot = mkdtempSync(join(tmpdir(), "native-capability-hostile-")) + temporaryRoots.push(hostileRoot) + const hostileBin = join(hostileRoot, "bin") + mkdirSync(hostileBin) + const marker = join(hostileRoot, "unexpected-execution") + for (const tool of [ + "awk", + "bun", + "cmp", + "curl", + "dd", + "git", + "jq", + "mktemp", + "node", + "npm", + "python", + "python3", + "rm", + "sh", + "wc", + "wget", + ]) { + const executable = join(hostileBin, tool) + writeFileSync(executable, `#!/bin/sh\nprintf touched >'${marker}'\nexit 97\n`) + chmodSync(executable, 0o755) + } + writeFileSync( + join(hostileRoot, "package.json"), + '{"scripts":{"stop":"touch unexpected-workspace-script"}}\n', + ) + writeFileSync(join(hostileRoot, "stop_hook_active"), "workspace decoy\n") + const pluginBefore = treeSnapshot(fixture.pluginRoot) + const workspaceBefore = treeSnapshot(hostileRoot) + + const result = runHook( + fixture.handler, + "Stop", + "codex", + '{"stop_hook_active":false}', + { + cwd: hostileRoot, + env: { + PATH: hostileBin, + SECRET_VALUE: "must-not-appear", + TMPDIR: join(hostileRoot, "user-selected-temp"), + }, + }, + ) + + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toBe("") + expect(result.stderr.toString()).toBe("") + expect(existsSync(marker)).toBe(false) + expect(treeSnapshot(fixture.pluginRoot)).toEqual(pluginBefore) + expect(treeSnapshot(hostileRoot)).toEqual(workspaceBefore) +}) + +test("generation owns one deterministic LF fixture projection", () => { + const temporaryRepositoryParent = mkdtempSync( + join(tmpdir(), "native-capability-generation-"), + ) + temporaryRoots.push(temporaryRepositoryParent) + const temporaryRepository = join(temporaryRepositoryParent, "repository") + cpSync(root, temporaryRepository, { recursive: true }) + const sourcePath = join( + temporaryRepository, + "plugin", + "hooks", + "fixture", + "lifecycle-mechanics-proof.source.json", + ) + const projectionPath = join( + temporaryRepository, + "plugin", + "hooks", + "fixture", + "lifecycle-mechanics-proof.generated.json", + ) + const expected = + '{\n "schemaVersion": 1,\n "purpose": "Harness Plugin Prototype lifecycle mechanics proof"\n}\n' + expect(readFileSync(sourcePath, "utf8")).toBe(expected) + expect(readFileSync(projectionPath, "utf8")).toBe(expected) + expect(runGenerateCheck(temporaryRepository).exitCode).toBe(0) + + writeFileSync(projectionPath, "drifted\r\n") + const drift = runGenerateCheck(temporaryRepository) + expect(drift.exitCode).toBe(1) + expect(drift.stderr.toString()).toContain( + "plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json", + ) + + writeFileSync(projectionPath, expected) + writeFileSync(sourcePath, expected.replaceAll("\n", "\r\n")) + const nonCanonicalSource = runGenerateCheck(temporaryRepository) + expect(nonCanonicalSource.exitCode).toBe(1) + expect(nonCanonicalSource.stderr.toString()).toContain( + "plugin/hooks/fixture/lifecycle-mechanics-proof.source.json", + ) +}) diff --git a/scripts/native-capability-surface.test.ts b/scripts/native-capability-surface.test.ts new file mode 100644 index 0000000..34336ba --- /dev/null +++ b/scripts/native-capability-surface.test.ts @@ -0,0 +1,277 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +import { expect, test } from "bun:test" + +import { type PluginConfig, renderGeneratedFiles } from "./plugin-config" +import { copyPluginPayload, pluginPayloadInventory } from "./plugin-files" + +const root = resolve(import.meta.dir, "..") +const config = JSON.parse(readFileSync(join(root, "plugin.config.json"), "utf8")) as PluginConfig + +test("generation projects one exact native hook declaration per supported client", () => { + const generated = new Map( + renderGeneratedFiles(config).map((file) => [file.path, JSON.parse(file.contents)]), + ) + + expect(generated.get("plugin/hooks/claude/hooks.json")).toEqual({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: + '"${CLAUDE_PLUGIN_ROOT}/hooks/native-capability-hook" SessionStart claude', + }, + ], + }, + ], + Stop: [ + { + hooks: [ + { + type: "command", + command: '"${CLAUDE_PLUGIN_ROOT}/hooks/native-capability-hook" Stop claude', + }, + ], + }, + ], + }, + }) + expect(generated.get("plugin/hooks/codex/hooks.json")).toEqual({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: + '"${PLUGIN_ROOT}/hooks/native-capability-hook" SessionStart codex', + }, + ], + }, + ], + Stop: [ + { + hooks: [ + { + type: "command", + command: '"${PLUGIN_ROOT}/hooks/native-capability-hook" Stop codex', + }, + ], + }, + ], + }, + }) +}) + +test("version-only generation leaves exact hook definitions unchanged", () => { + const nextVersion = structuredClone(config) + nextVersion.version = "99.0.0" + const hookFiles = (value: PluginConfig) => + renderGeneratedFiles(value) + .filter((file) => file.path.startsWith("plugin/hooks/")) + .map((file) => ({ path: file.path, contents: file.contents })) + + expect(hookFiles(nextVersion)).toEqual(hookFiles(config)) +}) + +test("checked-in manifests expose one coherent tour identity and relative native surfaces", () => { + const claudeManifest = JSON.parse( + readFileSync(join(root, "plugin", ".claude-plugin", "plugin.json"), "utf8"), + ) + const codexManifest = JSON.parse( + readFileSync(join(root, "plugin", ".codex-plugin", "plugin.json"), "utf8"), + ) + + expect(config.name).toBe("harness-native-plugin-prototype") + expect(config.defaultPrompts).toEqual(["Run the native plugin capability tour."]) + expect(claudeManifest).toMatchObject({ + name: config.name, + displayName: config.displayName, + description: config.description, + hooks: "./hooks/claude/hooks.json", + }) + expect(codexManifest).toMatchObject({ + name: config.name, + description: config.description, + hooks: "./hooks/codex/hooks.json", + interface: { + displayName: config.displayName, + shortDescription: config.shortDescription, + longDescription: config.longDescription, + capabilities: config.capabilities, + defaultPrompt: config.defaultPrompts, + brandColor: "#3B5CCC", + composerIcon: "./assets/composer-icon.svg", + logo: "./assets/logo.svg", + }, + }) + for (const manifest of [claudeManifest, codexManifest]) { + expect(manifest.hooks.startsWith("./")).toBe(true) + expect(existsSync(join(root, "plugin", manifest.hooks))).toBe(true) + } + for (const field of ["composerIcon", "logo"] as const) { + const path = codexManifest.interface[field] + expect(path.startsWith("./assets/")).toBe(true) + expect(existsSync(join(root, "plugin", path))).toBe(true) + } +}) + +test("capability tour is one model-only skill with one cross-client reviewer prompt", () => { + const skillRoot = join(root, "plugin", "skills", "capability-tour") + const skill = readFileSync(join(skillRoot, "SKILL.md"), "utf8") + const references = readdirSync(join(skillRoot, "references")).sort() + expect(references).toEqual(["capability-reviewer.md"]) + const reviewer = readFileSync(join(skillRoot, "references", references[0]), "utf8") + const combined = `${skill}\n${reviewer}` + + for (const label of [ + "native-subagent-via-skill", + "inline-fallback-unavailable", + "inline-recovery-delegation-failed", + ]) { + expect(skill).toContain(label) + } + for (const group of [ + "Overall verdict", + "Evidence matrix", + "Available portable skills", + "Next action", + ]) { + expect(skill).toContain(group) + } + for (const evidence of [ + "declaration", + "direct handler", + "currentSessionHook", + "external candidate qualification", + "delegation delivery", + ]) { + expect(skill).toContain(evidence) + } + expect(skill).toContain("exactly one") + expect(skill).toContain("three directories above this `SKILL.md`") + expect(skill).toContain("host-owned") + expect(skill).toContain("lifecycle mechanics proof") + expect(skill).toContain( + "/hooks/native-capability-hook Stop ", + ) + expect(reviewer).toContain("Do not mutate") + expect(reviewer).toContain("bounded inputs") + expect(reviewer).toContain("structured handback") + expect(reviewer).toContain("Do not select or pin a model") + expect(reviewer).toContain( + "/hooks/native-capability-hook Stop ", + ) + expect(combined).not.toMatch(/\bv?\d+\.\d+\.\d+\b/) + expect(combined).not.toMatch(/default[_ -]?agent|model:\s|model_name|modelName/i) + + const catalog = JSON.parse(readFileSync(join(root, "runtime", "skill-catalog.json"), "utf8")) + const bundles = JSON.parse( + readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json"), "utf8"), + ) + expect(catalog.skills).not.toHaveProperty("capability-tour") + expect(bundles.bundles).not.toHaveProperty("capability-tour") + expect(existsSync(join(root, "plugin", "bin", "capability-tour"))).toBe(false) +}) + +test("payload contains only the named capability sidecars and no standalone agent surface", () => { + const inventory = pluginPayloadInventory(root) + expect(inventory.filter((path) => path.startsWith("hooks/"))).toEqual([ + "hooks/claude/hooks.json", + "hooks/codex/hooks.json", + "hooks/fixture/lifecycle-mechanics-proof.generated.json", + "hooks/fixture/lifecycle-mechanics-proof.source.json", + "hooks/native-capability-hook", + ]) + expect(inventory.filter((path) => path.startsWith("assets/"))).toEqual([ + "assets/composer-icon.svg", + "assets/logo.svg", + ]) + for (const forbidden of [ + "agents/", + "apps/", + "channels/", + "connectors/", + "extensions/", + "mcp/", + "monitors/", + "settings/", + "themes/", + ]) { + expect(inventory.some((path) => path.startsWith(forbidden))).toBe(false) + } +}) + +test("identity-neutral assets have deterministic packaged bytes", () => { + const expected = { + "assets/composer-icon.svg": + "70bd6e6077d47ac739f1669e2694b07fe3edeb21bbddb537882c3a60acb0c850", + "assets/logo.svg": "4f61b6d0d1fb75695fc882ce99cf3e5ad750b6e5ca0eb54a62534d2ee38e8a9b", + } + for (const [path, digest] of Object.entries(expected)) { + const bytes = readFileSync(join(root, "plugin", path)) + expect(bytes.byteLength).toBeLessThan(1_024) + expect(new Bun.CryptoHasher("sha256").update(bytes).digest("hex")).toBe(digest) + expect(bytes.toString()).not.toMatch(/Harness Plugin Prototype|harness-native-plugin-prototype/) + } +}) + +test("static capability sidecars do not own the plugin version", () => { + for (const path of [ + "assets/composer-icon.svg", + "assets/logo.svg", + "hooks/claude/hooks.json", + "hooks/codex/hooks.json", + "skills/capability-tour/SKILL.md", + "skills/capability-tour/references/capability-reviewer.md", + ]) { + expect(readFileSync(join(root, "plugin", path), "utf8")).not.toMatch( + /\bv?\d+\.\d+\.\d+\b/, + ) + } +}) + +test("repository docs separate lifecycle mechanics from fresh-native qualification", () => { + const readme = readFileSync(join(root, "README.md"), "utf8") + const context = readFileSync(join(root, "CONTEXT.md"), "utf8") + const custody = readFileSync(join(root, "docs", "adr", "0005-shared-runtime-custody.md"), "utf8") + const capability = readFileSync( + join(root, "docs", "adr", "0008-native-plugin-capability-tour.md"), + "utf8", + ) + const docs = `${readme}\n${context}\n${custody}\n${capability}` + + expect(docs).not.toMatch(/no lifecycle hooks|lifecycle hooks \| None/i) + for (const claim of [ + "lifecycle mechanics proof", + "SessionStart", + "Stop", + "native activation", + "exact hook definition", + "disabled or untrusted", + ]) { + expect(docs).toContain(claim) + } + expect(docs).toMatch(/fail[- ]open/) + for (const exclusion of ["No MCP", "No standalone agent", "No telemetry"]) { + expect(capability).toContain(exclusion) + } + expect(capability).toMatch(/production integrity or security\s+guarantee/) + expect(capability).not.toMatch(/\bv?\d+\.\d+\.\d+\b/) +}) + +test("recursive package copy preserves the complete payload inventory", () => { + const installation = mkdtempSync(join(tmpdir(), "capability-tour-payload-")) + const installedRoot = join(installation, "plugin") + try { + const sourceInventory = pluginPayloadInventory(root) + expect(copyPluginPayload(root, installedRoot)).toEqual(sourceInventory) + expect(pluginPayloadInventory(installation)).toEqual(sourceInventory) + } finally { + rmSync(installation, { recursive: true, force: true }) + } +}) diff --git a/scripts/package.ts b/scripts/package.ts index bd56254..9949f40 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -1,20 +1,9 @@ -import { - chmodSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - utimesSync, - writeFileSync, -} from "node:fs" -import { tmpdir } from "node:os" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" import { join, resolve } from "node:path" import { validateBunOnlyPayload } from "./build" import { - copyPluginPayload, - directoryArchiveEntries, + deterministicPluginArchive, payloadInventorySha256, pluginPayloadInventory, } from "./plugin-files" @@ -25,8 +14,6 @@ const pluginConfig = loadPluginConfig(root) const version = pluginConfig.version const outputRoot = join(root, "dist") const packageName = `${pluginConfig.name}-${version}` -const stagingRoot = mkdtempSync(join(tmpdir(), "plugin-package-")) -const packageRoot = join(stagingRoot, packageName) function resolveSourceCommit(): string { const sourceCommit = process.env.SOURCE_COMMIT @@ -72,117 +59,42 @@ function payloadInventoryDigest(): string { return payloadInventorySha256(join(root, "plugin"), pluginPayloadInventory(root)) } -try { - // Missing, stale, or orphaned bundle mappings fail before packaging. - validateBunOnlyPayload(root) - const sourceCommit = resolveSourceCommit() - const runtimeLockSha256 = sha256(readFileSync(join(root, "runtime", "runtime.lock.json"))) - const bundleInventorySha256 = sha256( - readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json")), - ) - const payloadInventorySha256 = payloadInventoryDigest() - mkdirSync(outputRoot, { recursive: true }) - copyPluginPayload(root, packageRoot) - - const entries = directoryArchiveEntries(packageRoot, packageName) - const epoch = new Date(0) - for (const relativePath of entries) { - const absolutePath = join(stagingRoot, relativePath) - const status = statSync(absolutePath) - chmodSync(absolutePath, status.isDirectory() ? 0o755 : status.mode & 0o111 ? 0o755 : 0o644) - utimesSync(absolutePath, epoch, epoch) - } - - const fileList = join(stagingRoot, "entries.bin") - writeFileSync(fileList, Buffer.from(`${entries.join("\0")}\0`)) - const uncompressedArchive = join(stagingRoot, `${packageName}.tar`) - const tarArguments = - process.platform === "darwin" - ? [ - "tar", - "-cf", - uncompressedArchive, - "--format", - "ustar", - "--uid", - "0", - "--gid", - "0", - "--uname", - "root", - "--gname", - "root", - "--no-xattrs", - "--no-acls", - "--no-fflags", - "--no-mac-metadata", - "--no-recursion", - "--null", - "-C", - stagingRoot, - "-T", - fileList, - ] - : [ - "tar", - "--sort=name", - "--mtime=@0", - "--owner=0", - "--group=0", - "--numeric-owner", - "--no-xattrs", - "--no-acls", - "--no-selinux", - "--format=ustar", - "--no-recursion", - "--null", - "-cf", - uncompressedArchive, - "-C", - stagingRoot, - "-T", - fileList, - ] - const tar = Bun.spawnSync({ cmd: tarArguments, stdout: "inherit", stderr: "inherit" }) - if (tar.exitCode !== 0) process.exit(tar.exitCode) +// Missing, stale, or orphaned bundle mappings fail before packaging. +validateBunOnlyPayload(root) +const sourceCommit = resolveSourceCommit() +const runtimeLockSha256 = sha256(readFileSync(join(root, "runtime", "runtime.lock.json"))) +const bundleInventorySha256 = sha256( + readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json")), +) +const payloadInventoryDigestValue = payloadInventoryDigest() +mkdirSync(outputRoot, { recursive: true }) - const gzip = Bun.spawnSync({ - cmd: ["gzip", "-n", "-9", "-c", uncompressedArchive], - stdout: "pipe", - stderr: "inherit", - }) - if (gzip.exitCode !== 0) process.exit(gzip.exitCode) - - const archive = join(outputRoot, `${packageName}.tar.gz`) - writeFileSync(archive, gzip.stdout) - const archiveBytes = statSync(archive).size - const archiveDigest = new Bun.CryptoHasher("sha256") - .update(readFileSync(archive)) - .digest("hex") - const checksums = join(outputRoot, `${packageName}.checksums.json`) - writeFileSync( - checksums, - `${JSON.stringify( - { - repository: pluginConfig.repository, - sourceCommit, - tag: `v${version}`, - plugin: pluginConfig.name, - version, - archive: `${packageName}.tar.gz`, - archiveBytes, - archiveSha256: archiveDigest, - runtimeLockSha256, - bundleInventorySha256, - payloadInventorySha256, - evidence: - "Checksum metadata is integrity evidence for these archive bytes, not independent publisher or builder authenticity.", - }, - null, - 2, - )}\n`, - ) - console.log(JSON.stringify({ archive, checksums, archiveBytes, archiveDigest })) -} finally { - rmSync(stagingRoot, { recursive: true, force: true }) -} +const archiveArtifact = deterministicPluginArchive(root, packageName) +const archive = join(outputRoot, `${packageName}.tar.gz`) +writeFileSync(archive, archiveArtifact.bytes) +const archiveBytes = archiveArtifact.bytes.length +const archiveDigest = archiveArtifact.sha256 +const checksums = join(outputRoot, `${packageName}.checksums.json`) +writeFileSync( + checksums, + `${JSON.stringify( + { + repository: pluginConfig.repository, + sourceCommit, + tag: `v${version}`, + plugin: pluginConfig.name, + version, + archive: `${packageName}.tar.gz`, + archiveBytes, + archiveSha256: archiveDigest, + runtimeLockSha256, + bundleInventorySha256, + payloadInventorySha256: payloadInventoryDigestValue, + evidence: + "Checksum metadata is integrity evidence for these archive bytes, not independent publisher or builder authenticity.", + }, + null, + 2, + )}\n`, +) +console.log(JSON.stringify({ archive, checksums, archiveBytes, archiveDigest })) diff --git a/scripts/plugin-config.ts b/scripts/plugin-config.ts index 756a5a9..3098920 100644 --- a/scripts/plugin-config.ts +++ b/scripts/plugin-config.ts @@ -1,5 +1,5 @@ -import { readFileSync, writeFileSync } from "node:fs" -import { join } from "node:path" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" /** Canonical plugin identity and presentation metadata. */ export interface PluginConfig { @@ -31,6 +31,12 @@ export interface PluginConfig { capabilities: string[] /** Starter prompts shown by Codex. */ defaultPrompts: string[] + /** Restrained hexadecimal accent projected into Codex interface metadata. */ + brandColor: string + /** Plugin-relative identity-neutral composer icon. */ + composerIcon: string + /** Plugin-relative identity-neutral logo. */ + logo: string /** Public and private repositories used for hosted distribution proof. */ canary: { /** GitHub account or organization that owns the canary repositories. */ @@ -274,6 +280,17 @@ export function validatePackageContract(config: PluginConfig): void { "defaultPrompts accepts at most three unique, non-empty, single-line entries that do not start with @mentions", ) } + if (!/^#[0-9A-F]{6}$/.test(config.brandColor)) { + throw packageContractError("brandColor must be an uppercase six-digit hexadecimal color") + } + for (const [field, path] of [ + ["composerIcon", config.composerIcon], + ["logo", config.logo], + ] as const) { + if (!/^\.\/assets\/[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/.test(path)) { + throw packageContractError(`${field} must be a plugin-relative SVG asset path`) + } + } } /** @@ -341,6 +358,7 @@ function claudeMarketplace(config: PluginConfig): GeneratedFile { plugins: [ { name: config.name, + displayName: config.displayName, description: config.description, author: config.author, source: "./plugin", @@ -383,10 +401,26 @@ function claudeManifest(config: PluginConfig): GeneratedFile { license: config.license, keywords: config.keywords, skills: "./skills/", + hooks: "./hooks/claude/hooks.json", }), } } +function codexInterface(config: PluginConfig): Record { + return { + displayName: config.displayName, + shortDescription: config.shortDescription, + longDescription: config.longDescription, + developerName: config.author.name, + category: config.category, + capabilities: config.capabilities, + defaultPrompt: config.defaultPrompts, + brandColor: config.brandColor, + composerIcon: config.composerIcon, + logo: config.logo, + } +} + function codexManifest(config: PluginConfig): GeneratedFile { return { path: "plugin/.codex-plugin/plugin.json", @@ -399,18 +433,31 @@ function codexManifest(config: PluginConfig): GeneratedFile { license: config.license, keywords: config.keywords, skills: "./skills/", - interface: { - displayName: config.displayName, - shortDescription: config.shortDescription, - longDescription: config.longDescription, - developerName: config.author.name, - category: config.category, - capabilities: config.capabilities, - defaultPrompt: config.defaultPrompts, - }, + hooks: "./hooks/codex/hooks.json", + interface: codexInterface(config), }), } } + +/** Build one client's hook declaration object; proofs reuse this as the comparison contract. */ +export function hookDeclarationBody(client: "claude" | "codex"): Record { + const pluginRoot = client === "claude" ? "CLAUDE_PLUGIN_ROOT" : "PLUGIN_ROOT" + const command = (event: "SessionStart" | "Stop") => + `"\${${pluginRoot}}/hooks/native-capability-hook" ${event} ${client}` + return { + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: command("SessionStart") }] }], + Stop: [{ hooks: [{ type: "command", command: command("Stop") }] }], + }, + } +} + +function hookDeclaration(client: "claude" | "codex"): GeneratedFile { + return { + path: `plugin/hooks/${client}/hooks.json`, + contents: serialize(hookDeclarationBody(client)), + } +} /** Render native Claude and Codex files from canonical metadata. */ export function renderGeneratedFiles(config: PluginConfig): GeneratedFile[] { validateConfig(config) @@ -419,19 +466,34 @@ export function renderGeneratedFiles(config: PluginConfig): GeneratedFile[] { codexMarketplace(config), claudeManifest(config), codexManifest(config), + hookDeclaration("claude"), + hookDeclaration("codex"), ] } +/** Write rendered files idempotently to deterministic repository paths. */ +export function writeGeneratedFileSet(root: string, files: GeneratedFile[]): GeneratedFile[] { + for (const file of files) { + const path = join(root, file.path) + if (existsSync(path) && readFileSync(path, "utf8") === file.contents) continue + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, file.contents) + } + return files +} + /** Write every generated manifest to its deterministic repository path. */ export function writeGeneratedFiles(root: string, config: PluginConfig): GeneratedFile[] { - const files = renderGeneratedFiles(config) - for (const file of files) writeFileSync(join(root, file.path), file.contents) - return files + return writeGeneratedFileSet(root, renderGeneratedFiles(config)) } /** Return generated paths whose checked-in contents differ from canonical metadata. */ export function checkGeneratedFiles(root: string, config: PluginConfig): string[] { return renderGeneratedFiles(config) - .filter((file) => readFileSync(join(root, file.path), "utf8") !== file.contents) + .filter( + (file) => + !existsSync(join(root, file.path)) || + readFileSync(join(root, file.path), "utf8") !== file.contents, + ) .map((file) => file.path) } diff --git a/scripts/plugin-files.test.ts b/scripts/plugin-files.test.ts index d9da3d0..0f3e265 100644 --- a/scripts/plugin-files.test.ts +++ b/scripts/plugin-files.test.ts @@ -8,6 +8,7 @@ import { afterEach, expect, test } from "bun:test" import { compareCodeUnits, copyPluginPayload, + deterministicPluginArchive, directoryArchiveEntries, payloadInventorySha256, pluginPayloadInventory, @@ -221,3 +222,48 @@ test("archive order keeps each directory beside its descendants", () => { "plugin-0.1.0/runtime.txt", ]) }) + +test("in-process USTAR bytes are platform canonical", () => { + const { sourceRoot } = pluginFixture() + const first = deterministicPluginArchive(sourceRoot, "plugin-0.1.0") + const second = deterministicPluginArchive(sourceRoot, "plugin-0.1.0") + const header = Buffer.from(Bun.gunzipSync(first.bytes)).subarray(0, 512) + const headerText = (offset: number) => + header.subarray(offset, offset + 32).toString("utf8").replace(/\0.*$/, "") + + expect(first.bytes).toEqual(second.bytes) + expect(first.sha256).toBe( + "116a569020e91b24e5994dae842f0a4fe3bfd90bb0ee7f20c4c66f3e38b805ae", + ) + expect(headerText(265)).toBe("root") + expect(headerText(297)).toBe("root") +}) + +test("USTAR prefix fields preserve representable long paths", () => { + const { sourceRoot, pluginRoot } = pluginFixture() + const directory = `nested-${"p".repeat(80)}` + const file = `${"f".repeat(30)}.txt` + fileSystem.mkdirSync(join(pluginRoot, directory)) + fileSystem.writeFileSync(join(pluginRoot, directory, file), "long path\n") + + const archive = deterministicPluginArchive(sourceRoot, "plugin-0.1.0") + const listing = Bun.spawnSync({ + cmd: ["tar", "-tzf", "-"], + stdin: archive.bytes, + stdout: "pipe", + stderr: "pipe", + }) + + expect(listing.exitCode, listing.stderr.toString()).toBe(0) + expect(listing.stdout.toString()).toContain(`plugin-0.1.0/${directory}/${file}\n`) +}) + +test("USTAR packaging rejects an unrepresentable path component", () => { + const { sourceRoot, pluginRoot } = pluginFixture() + const file = "x".repeat(101) + fileSystem.writeFileSync(join(pluginRoot, file), "too long\n") + + expect(() => deterministicPluginArchive(sourceRoot, "plugin-0.1.0")).toThrow( + "USTAR path cannot be represented", + ) +}) diff --git a/scripts/plugin-files.ts b/scripts/plugin-files.ts index 6f80362..d5b5308 100644 --- a/scripts/plugin-files.ts +++ b/scripts/plugin-files.ts @@ -4,10 +4,15 @@ import { copyFileSync, lstatSync, mkdirSync, + mkdtempSync, readFileSync, readdirSync, realpathSync, + rmSync, + statSync, + utimesSync, } from "node:fs" +import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" /** Canonical directory copied by development staging and release packaging. */ @@ -35,6 +40,83 @@ function framedLength(length: number): Buffer { return frame } +const USTAR_BLOCK_BYTES = 512 + +function writeUstarText(header: Buffer, offset: number, width: number, value: string): void { + const bytes = Buffer.from(value, "utf8") + if (bytes.length > width) throw new Error(`USTAR field exceeds ${width} bytes: ${value}`) + bytes.copy(header, offset) +} + +function writeUstarOctal(header: Buffer, offset: number, width: number, value: number): void { + const octal = value.toString(8) + if (octal.length > width - 1) throw new Error(`USTAR numeric field exceeds ${width} bytes`) + writeUstarText(header, offset, width, `${octal.padStart(width - 1, "0")}\0`) +} + +function splitUstarPath(relativePath: string): { name: string; prefix: string } { + if (Buffer.byteLength(relativePath, "utf8") <= 100) return { name: relativePath, prefix: "" } + for ( + let slash = relativePath.lastIndexOf("/"); + slash > 0; + slash = relativePath.lastIndexOf("/", slash - 1) + ) { + const prefix = relativePath.slice(0, slash) + const name = relativePath.slice(slash + 1) + if ( + name.length > 0 && + Buffer.byteLength(name, "utf8") <= 100 && + Buffer.byteLength(prefix, "utf8") <= 155 + ) { + return { name, prefix } + } + } + throw new Error(`USTAR path cannot be represented: ${relativePath}`) +} + +function ustarHeader( + relativePath: string, + status: { isDirectory(): boolean; mode: number; size: number }, +): Buffer { + const header = Buffer.alloc(USTAR_BLOCK_BYTES) + const { name, prefix } = splitUstarPath(relativePath) + writeUstarText(header, 0, 100, name) + writeUstarOctal(header, 100, 8, status.mode & 0o777) + writeUstarOctal(header, 108, 8, 0) + writeUstarOctal(header, 116, 8, 0) + writeUstarOctal(header, 124, 12, status.isDirectory() ? 0 : status.size) + writeUstarOctal(header, 136, 12, 0) + header.fill(0x20, 148, 156) + header[156] = status.isDirectory() ? 0x35 : 0x30 + writeUstarText(header, 257, 6, "ustar\0") + writeUstarText(header, 263, 2, "00") + writeUstarText(header, 265, 32, "root") + writeUstarText(header, 297, 32, "root") + writeUstarOctal(header, 329, 8, 0) + writeUstarOctal(header, 337, 8, 0) + writeUstarText(header, 345, 155, prefix) + let checksum = 0 + for (const byte of header) checksum += byte + writeUstarText(header, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `) + return header +} + +function deterministicUstar(stagingRoot: string, entries: readonly string[]): Buffer { + const chunks: Buffer[] = [] + for (const relativePath of entries) { + const absolutePath = join(stagingRoot, relativePath) + const status = statSync(absolutePath) + chunks.push(ustarHeader(relativePath, status)) + if (status.isDirectory()) continue + const contents = readFileSync(absolutePath) + chunks.push(contents) + const padding = (USTAR_BLOCK_BYTES - (contents.length % USTAR_BLOCK_BYTES)) % USTAR_BLOCK_BYTES + if (padding > 0) chunks.push(Buffer.alloc(padding)) + } + chunks.push(Buffer.alloc(USTAR_BLOCK_BYTES * 2)) + return Buffer.concat(chunks) +} + /** Hash an ordered payload inventory with collision-free path/body framing. */ export function payloadInventorySha256( payloadRoot: string, @@ -131,6 +213,59 @@ export function pluginPayloadInventory(sourceRoot: string): string[] { return inventory.sort(compareCodeUnits) } +/** + * Build the deterministic release archive bytes for one plugin payload. + * + * Release packaging and canary qualification share this builder, so the archive + * SHA-256 bound into candidate lineage is byte-identical to the released + * `*.tar.gz` whenever the payload bytes and package name match. + * + * @param sourceRoot - Repository or candidate root containing the canonical `plugin/` directory + * @param packageName - Archive root entry name, `-` + * @returns Gzipped deterministic tar bytes and their SHA-256 digest + * @throws {Error} When the payload is unsafe or tar/gzip fails + * + * @example + * ```ts + * const { sha256 } = deterministicPluginArchive(process.cwd(), "hello-0.1.0") + * ``` + */ +export function deterministicPluginArchive( + sourceRoot: string, + packageName: string, +): { bytes: Buffer; sha256: string } { + const stagingRoot = mkdtempSync(join(tmpdir(), "plugin-package-")) + try { + const packageRoot = join(stagingRoot, packageName) + copyPluginPayload(sourceRoot, packageRoot) + const entries = directoryArchiveEntries(packageRoot, packageName) + const epoch = new Date(0) + for (const relativePath of entries) { + const absolutePath = join(stagingRoot, relativePath) + const status = statSync(absolutePath) + chmodSync(absolutePath, status.isDirectory() ? 0o755 : status.mode & 0o111 ? 0o755 : 0o644) + utimesSync(absolutePath, epoch, epoch) + } + const uncompressedArchive = deterministicUstar(stagingRoot, entries) + const gzip = Bun.spawnSync({ + cmd: ["gzip", "-n", "-9", "-c"], + stdin: uncompressedArchive, + stdout: "pipe", + stderr: "pipe", + }) + if (gzip.exitCode !== 0) { + const diagnostics = gzip.stderr.toString().trim() + throw new Error( + `deterministic archive gzip failed with exit code ${gzip.exitCode}${diagnostics ? `: ${diagnostics}` : ""}`, + ) + } + const bytes = Buffer.from(gzip.stdout) + return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") } + } finally { + rmSync(stagingRoot, { recursive: true, force: true }) + } +} + /** * Copy the exact canonical plugin payload without repository tooling or source. * diff --git a/scripts/plugin-manifest-contract.test.ts b/scripts/plugin-manifest-contract.test.ts index 7b5ce8d..4211177 100644 --- a/scripts/plugin-manifest-contract.test.ts +++ b/scripts/plugin-manifest-contract.test.ts @@ -74,6 +74,9 @@ test("checked-in native manifests satisfy the published harness contracts", () = category: config.category, capabilities: config.capabilities, defaultPrompt: config.defaultPrompts, + brandColor: config.brandColor, + composerIcon: config.composerIcon, + logo: config.logo, }) expect(config.shortDescription).not.toContain("\n") expect(config.shortDescription.length).toBeLessThanOrEqual(30) @@ -83,19 +86,24 @@ test("checked-in native manifests satisfy the published harness contracts", () = expect(config.defaultPrompts.every((prompt: string) => prompt.length <= 128)).toBe(true) for (const manifest of [claudeManifest, codexManifest]) { - expect(manifest).not.toHaveProperty("hooks") - for (const field of ["skills"]) { + for (const field of ["skills", "hooks"]) { const path = manifest[field] expect(path.startsWith("./"), `${field} must be plugin-relative`).toBe(true) expect(existsSync(join(pluginRoot, path)), `${field} target must exist`).toBe(true) } } + for (const field of ["composerIcon", "logo"]) { + const path = codexManifest.interface[field] + expect(path.startsWith("./"), `${field} must be plugin-relative`).toBe(true) + expect(existsSync(join(pluginRoot, path)), `${field} target must exist`).toBe(true) + } }) test("native marketplace projections preserve identity, source, policy, and activation safety", () => { expect(claudeMarketplace.metadata.version).toBe(config.version) expect(claudeMarketplace.plugins[0]).toMatchObject({ name: config.name, + displayName: config.displayName, source: "./plugin", defaultEnabled: false, }) @@ -199,6 +207,10 @@ test.each([ ["multiline prompt", (value: PluginConfig) => (value.defaultPrompts = ["Run\nthis"]), "defaultPrompts"], ["duplicate prompt", (value: PluginConfig) => (value.defaultPrompts = ["Run this", " Run this "]), "defaultPrompts"], ["mention prompt", (value: PluginConfig) => (value.defaultPrompts = ["@agent run this"]), "defaultPrompts"], + ["brand color", (value: PluginConfig) => (value.brandColor = "blue"), "brandColor"], + ["lowercase brand color", (value: PluginConfig) => (value.brandColor = "#abcdef"), "brandColor"], + ["composer icon escape", (value: PluginConfig) => (value.composerIcon = "../icon.svg"), "composerIcon"], + ["logo extension", (value: PluginConfig) => (value.logo = "./assets/logo.png"), "logo"], ] as const)("rejects invalid published %s metadata", (_name, mutate, message) => { const invalid = structuredClone(config) mutate(invalid) diff --git a/scripts/prove-distribution.ts b/scripts/prove-distribution.ts index 678b2fa..4b29920 100644 --- a/scripts/prove-distribution.ts +++ b/scripts/prove-distribution.ts @@ -15,6 +15,10 @@ import { pluginPayloadInventory, } from "./plugin-files" import { loadPluginConfig } from "./plugin-config" +import { + proveInstalledCapabilityEvidence, + resolveCandidatePayloadCommit, +} from "./prove-harness-install" const root = resolve(import.meta.dir, "..") const pluginConfig = loadPluginConfig(root) @@ -87,6 +91,15 @@ const entries = directoryArchiveEntries(extractedRoot, "") for (const required of [ `${packageName}/.claude-plugin/plugin.json`, `${packageName}/.codex-plugin/plugin.json`, + `${packageName}/assets/composer-icon.svg`, + `${packageName}/assets/logo.svg`, + `${packageName}/hooks/claude/hooks.json`, + `${packageName}/hooks/codex/hooks.json`, + `${packageName}/hooks/fixture/lifecycle-mechanics-proof.generated.json`, + `${packageName}/hooks/fixture/lifecycle-mechanics-proof.source.json`, + `${packageName}/hooks/native-capability-hook`, + `${packageName}/skills/capability-tour/SKILL.md`, + `${packageName}/skills/capability-tour/references/capability-reviewer.md`, `${packageName}/skills/hello-world/SKILL.md`, `${packageName}/skills/runtime-custody/SKILL.md`, `${packageName}/skills/skill-a/SKILL.md`, @@ -104,8 +117,8 @@ for (const required of [ ]) { if (!entries.includes(required)) throw new Error(`package is missing ${required}`) } -if (entries.some((entry) => /(?:^|\/)(?:hooks|qjs-|quickjs-assets\.json|QUICKJS-LICENSE)(?:\/|$)/i.test(entry))) { - throw new Error("package contains an active legacy runtime or hook surface") +if (entries.some((entry) => /(?:^|\/)(?:qjs-[^/]*|quickjs-assets\.json|QUICKJS-LICENSE)(?:\/|$)/i.test(entry))) { + throw new Error("package contains an active legacy runtime surface") } const expectedEntries = directoryArchiveEntries(join(root, PLUGIN_DIRECTORY), packageName) if (JSON.stringify(entries) !== JSON.stringify(expectedEntries)) { @@ -149,6 +162,37 @@ for (const relativePath of inventory) { } } +const packagedSkills = entries + .filter((entry) => entry.startsWith(`${packageName}/skills/`) && entry.endsWith("/SKILL.md")) + .map((entry) => entry.slice(`${packageName}/skills/`.length, -"/SKILL.md".length)) +if (JSON.stringify(packagedSkills) !== JSON.stringify([ + "capability-tour", + "hello-world", + "runtime-custody", + "skill-a", + "skill-b", +])) { + throw new Error("package skill inventory does not preserve the exact portable and model-only closure") +} +const packagedLaunchers = entries + .filter((entry) => entry.startsWith(`${packageName}/bin/`) && !entry.endsWith("/")) + .map((entry) => entry.slice(`${packageName}/bin/`.length)) +if (JSON.stringify(packagedLaunchers) !== JSON.stringify(["hello-world", "skill-a", "skill-b"])) { + throw new Error("package launcher inventory does not preserve the v0.2.0 closure") +} +const catalog = JSON.parse(readFileSync(join(root, "runtime", "skill-catalog.json"), "utf8")) +const bundles = JSON.parse( + readFileSync(join(installedRoot, "runtime", "bundle-inventory.json"), "utf8"), +) +for (const surface of [catalog.skills, bundles.bundles]) { + const surfaceKeys = Object.keys(surface).sort() + if (JSON.stringify(surfaceKeys) !== JSON.stringify(["hello-world", "skill-a", "skill-b"])) { + throw new Error( + `capability-tour entered the executable runtime closure: ${JSON.stringify(surfaceKeys)}`, + ) + } +} + const coldXdg = join(extractedRoot, "cold-xdg") for (const skillId of ["hello-world", "skill-a", "skill-b"]) { const launcher = join(installedRoot, "bin", skillId) @@ -170,20 +214,22 @@ if (lstatSync(coldXdg, { throwIfNoEntry: false }) !== undefined) { const checksums = JSON.parse(readFileSync(second.checksums, "utf8")) const sha256 = (bytes: Uint8Array): string => new Bun.CryptoHasher("sha256").update(bytes).digest("hex") +const sourcePayloadHash = payloadInventorySha256(join(root, PLUGIN_DIRECTORY), inventory) +const installedPayloadHash = payloadInventorySha256(installedRoot, inventory) if ( checksums.runtimeLockSha256 !== sha256(readFileSync(join(root, "runtime", "runtime.lock.json"))) || checksums.bundleInventorySha256 !== sha256(readFileSync(join(installedRoot, "runtime", "bundle-inventory.json"))) || - checksums.payloadInventorySha256 !== payloadInventorySha256(installedRoot, inventory) + checksums.payloadInventorySha256 !== sourcePayloadHash || + installedPayloadHash !== sourcePayloadHash ) { throw new Error("checksum metadata does not bind the runtime lock, bundle inventory, and payload inventory") } -const sourceCommit = process.env.SOURCE_COMMIT ?? process.env.GITHUB_SHA ?? Bun.spawnSync({ - cmd: ["git", "rev-parse", "HEAD"], - cwd: root, - stdout: "pipe", - stderr: "inherit", -}).stdout.toString().trim() +const sourceCommit = resolveCandidatePayloadCommit(root) +const configuredSourceCommit = process.env.SOURCE_COMMIT || process.env.GITHUB_SHA || undefined +if (configuredSourceCommit !== undefined && configuredSourceCommit !== sourceCommit) { + throw new Error("distribution proof source commit does not match Git HEAD") +} assertDistributionChecksumIdentity(checksums, { repository: pluginConfig.repository, sourceCommit, @@ -193,7 +239,22 @@ assertDistributionChecksumIdentity(checksums, { archive: basename(second.archive), archiveBytes: second.archiveBytes, archiveSha256: second.archiveDigest, + payloadInventorySha256: sourcePayloadHash, }) +const capabilityEvidence = { + claude: proveInstalledCapabilityEvidence( + installedRoot, + "claude", + sourceCommit, + sourcePayloadHash, + ), + codex: proveInstalledCapabilityEvidence( + installedRoot, + "codex", + sourceCommit, + sourcePayloadHash, + ), +} console.log( JSON.stringify({ @@ -201,6 +262,20 @@ console.log( deterministic: true, archiveBytes: second.archiveBytes, archiveSha256: second.archiveDigest, + candidateCommit: sourceCommit, + payloadHash: sourcePayloadHash, + installedPayloadHash, + capabilityEvidence, + nativeActivation: "not-proved", + nativeDelegation: "not-proved", + qualificationReceiptsIngested: false, + hookIndependentSkills: [ + "hello-world", + "skill-a", + "skill-b", + "runtime-custody", + "capability-tour", + ], entries: entries.length, offlinePackageExecution: true, bunRequiredAtRuntime: true, diff --git a/scripts/prove-dx.ts b/scripts/prove-dx.ts index 59fdbb7..5c15111 100644 --- a/scripts/prove-dx.ts +++ b/scripts/prove-dx.ts @@ -40,11 +40,31 @@ const codexManifest = JSON.parse( if (claudeManifest.version !== codexManifest.version) { throw new Error("native manifest versions do not match") } -if (claudeManifest.hooks !== undefined || codexManifest.hooks !== undefined) { - throw new Error("runtime lifecycle hooks must not be active in native manifests") +if ( + claudeManifest.hooks !== "./hooks/claude/hooks.json" || + codexManifest.hooks !== "./hooks/codex/hooks.json" +) { + throw new Error("native manifests do not reference the exact client hook declarations") +} +for (const path of [ + "plugin/hooks/claude/hooks.json", + "plugin/hooks/codex/hooks.json", + "plugin/hooks/native-capability-hook", + "plugin/hooks/fixture/lifecycle-mechanics-proof.source.json", + "plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json", +]) { + if (!existsSync(join(root, path))) throw new Error(`plugin payload is missing ${path}`) } -if (existsSync(join(root, "plugin", "hooks"))) { - throw new Error("plugin payload must not carry runtime lifecycle hook definitions") +const catalog = JSON.parse(readFileSync(join(root, "runtime", "skill-catalog.json"), "utf8")) +const bundles = JSON.parse( + readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json"), "utf8"), +) +if ( + Object.hasOwn(catalog.skills, "capability-tour") || + Object.hasOwn(bundles.bundles, "capability-tour") || + existsSync(join(root, "plugin", "bin", "capability-tour")) +) { + throw new Error("model-only capability-tour entered the executable runtime closure") } for (const marketplacePath of [ @@ -81,6 +101,12 @@ console.log( "no npm publication", "Claude live reload is explicit", "Codex reload means a fresh task", + "direct handler checks do not prove native activation", ], + automatedClaimBoundary: { + nativeActivation: "not-proved", + nativeDelegation: "not-proved", + qualificationReceiptsIngested: false, + }, }), ) diff --git a/scripts/prove-harness-install.test.ts b/scripts/prove-harness-install.test.ts index 88bbe41..d13160a 100644 --- a/scripts/prove-harness-install.test.ts +++ b/scripts/prove-harness-install.test.ts @@ -19,9 +19,12 @@ import { copyMarketplaceDistribution, hostedMarketplaceSources, nativeHarnessEnvironment, + promoteNativeQualificationEvidence, proveHarnessInstall, + proveInstalledCapabilityEvidence, redactTemporaryEvidencePath, resolveCleanSourceCommit, + resolveCandidatePayloadCommit, runtimeClosureEvidence, } from "./prove-harness-install" import { @@ -35,6 +38,212 @@ let proof: ReturnType const claudeNativeTest = Bun.which("claude") ? test : test.skip const codexNativeTest = Bun.which("codex") ? test : test.skip +function nativeQualificationSummary(client: "claude" | "codex") { + const hash = (value: string) => value.repeat(64) + return { + schema: "native-capability-qualification-v1", + client, + platform: "macos", + receiptSha256: hash("1"), + sourceCandidateSha: "a".repeat(40), + archiveSha256: hash("2"), + packagedPayloadHash: hash("3"), + installedPayloadHash: hash("3"), + derivedPayloadHash: hash("4"), + conclusions: { + discovery: "proved", + uiIdentity: "proved", + skillSeededNativeDelegation: "proved", + hostOwnedLifecycleEvidence: "proved", + sessionStart: "proved", + cleanStop: "silent", + driftContinuation: "proved", + reentry: "silent", + hooksFallback: "proved", + exactDefinitionTrust: client === "codex" ? "proved" : "not-applicable", + }, + evidence: { + discoverySha256: hash("5"), + uiIdentitySha256: hash("6"), + delegationLifecycleSha256: hash("7"), + sessionStartSha256: hash("8"), + cleanStopSha256: hash("9"), + driftSha256: hash("a"), + reentrySha256: hash("b"), + hooksFallbackSha256: hash("c"), + ...(client === "codex" ? { exactDefinitionTrustSha256: hash("d") } : {}), + }, + } +} + +test("fresh-native qualification promotion accepts only candidate-bound bounded evidence", () => { + const summary = nativeQualificationSummary("codex") + expect( + promoteNativeQualificationEvidence(summary, { + client: "codex", + sourceCommit: summary.sourceCandidateSha, + archiveSha256: summary.archiveSha256, + packagedPayloadHash: summary.packagedPayloadHash, + installedPayloadHash: summary.installedPayloadHash, + }), + ).toEqual(summary) + + expect(() => + promoteNativeQualificationEvidence( + { ...summary, rawSession: { transcript: "must stay private" } }, + { + client: "codex", + sourceCommit: summary.sourceCandidateSha, + archiveSha256: summary.archiveSha256, + packagedPayloadHash: summary.packagedPayloadHash, + installedPayloadHash: summary.installedPayloadHash, + }, + ), + ).toThrow("bounded summary and evidence hashes") +}) + +test("a bounded failed qualification records evidence without promoting native claims", () => { + const summary = nativeQualificationSummary("codex") + const failed = { + ...summary, + conclusions: { ...summary.conclusions, sessionStart: "failed" }, + } + expect( + promoteNativeQualificationEvidence(failed, { + client: "codex", + sourceCommit: summary.sourceCandidateSha, + archiveSha256: summary.archiveSha256, + packagedPayloadHash: summary.packagedPayloadHash, + installedPayloadHash: summary.installedPayloadHash, + }), + ).toEqual(failed) +}) + +test("fresh-native qualification promotion rejects unbound or incomplete claims", () => { + const summary = nativeQualificationSummary("claude") + const expected = { + client: "claude" as const, + sourceCommit: summary.sourceCandidateSha, + archiveSha256: summary.archiveSha256, + packagedPayloadHash: summary.packagedPayloadHash, + installedPayloadHash: summary.installedPayloadHash, + } + + expect(() => + promoteNativeQualificationEvidence( + { ...summary, sourceCandidateSha: "f".repeat(40) }, + expected, + ), + ).toThrow("candidate lineage") + expect(() => + promoteNativeQualificationEvidence( + { ...summary, archiveSha256: "e".repeat(64) }, + expected, + ), + ).toThrow("candidate lineage") + expect(() => + promoteNativeQualificationEvidence( + { ...summary, derivedPayloadHash: summary.packagedPayloadHash }, + expected, + ), + ).toThrow("distinct derived payload hash") + expect(() => + promoteNativeQualificationEvidence( + { + ...summary, + conclusions: { ...summary.conclusions, exactDefinitionTrust: "proved" }, + }, + expected, + ), + ).toThrow("exact-definition trust") +}) + +test("installed evidence promotes native claims only with a validated fresh-client summary", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "installed-native-promotion-")) + const pluginRoot = join(temporaryRoot, "plugin") + try { + cpSync(join(root, "plugin"), pluginRoot, { recursive: true }) + const candidateCommit = "a".repeat(40) + const payloadHash = runtimeClosureEvidence(pluginRoot).payloadHash + const base = nativeQualificationSummary("claude") + const receipt = { + ...base, + sourceCandidateSha: candidateCommit, + packagedPayloadHash: payloadHash, + installedPayloadHash: payloadHash, + } + + expect( + proveInstalledCapabilityEvidence( + pluginRoot, + "claude", + candidateCommit, + payloadHash, + { + summary: receipt, + lineage: { + sourceCommit: candidateCommit, + archiveSha256: receipt.archiveSha256, + packagedPayloadHash: payloadHash, + installedPayloadHash: payloadHash, + }, + }, + ), + ).toMatchObject({ + currentSessionHook: "unknown", + nativeActivation: "proved", + externalCandidateQualification: "proved", + nativeDelegation: "proved", + nativeQualification: { status: "proved", receipt }, + }) + expect( + proveInstalledCapabilityEvidence( + pluginRoot, + "claude", + candidateCommit, + payloadHash, + undefined, + true, + ), + ).toMatchObject({ + currentSessionHook: "proved", + nativeActivation: "not-proved", + externalCandidateQualification: "unknown", + nativeDelegation: "not-proved", + nativeQualification: { status: "not-proved", receipt: null }, + }) + const failedReceipt = { + ...receipt, + conclusions: { ...receipt.conclusions, cleanStop: "failed" }, + } + expect( + proveInstalledCapabilityEvidence( + pluginRoot, + "claude", + candidateCommit, + payloadHash, + { + summary: failedReceipt, + lineage: { + sourceCommit: candidateCommit, + archiveSha256: failedReceipt.archiveSha256, + packagedPayloadHash: payloadHash, + installedPayloadHash: payloadHash, + }, + }, + ), + ).toMatchObject({ + currentSessionHook: "unknown", + nativeActivation: "not-proved", + externalCandidateQualification: "unknown", + nativeDelegation: "not-proved", + nativeQualification: { status: "failed", receipt: failedReceipt }, + }) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + test("source-bound native receipts reject dirty checkout bytes", () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "native-receipt-source-")) try { @@ -45,18 +254,48 @@ test("source-bound native receipts reject dirty checkout bytes", () => { GIT_COMMITTER_NAME: "Receipt Test", GIT_COMMITTER_EMAIL: "receipt@example.invalid", } - writeFileSync(join(fixtureRoot, "payload.txt"), "clean\n") + mkdirSync(join(fixtureRoot, "plugin")) + writeFileSync(join(fixtureRoot, "plugin", "payload.txt"), "clean\n") for (const command of [ ["git", "init", "--quiet"], - ["git", "add", "payload.txt"], + ["git", "add", "plugin/payload.txt"], ["git", "-c", "commit.gpgSign=false", "commit", "--quiet", "-m", "fixture"], ]) { const result = Bun.spawnSync({ cmd: command, cwd: fixtureRoot, env: environment }) expect(result.exitCode).toBe(0) } expect(resolveCleanSourceCommit(fixtureRoot)).toMatch(/^[a-f0-9]{40}$/) - writeFileSync(join(fixtureRoot, "payload.txt"), "dirty\n") - expect(() => resolveCleanSourceCommit(fixtureRoot)).toThrow(/requires a clean source checkout/) + writeFileSync(join(fixtureRoot, "plugin", "payload.txt"), "dirty\n") + expect(() => resolveCleanSourceCommit(fixtureRoot)).toThrow(/requires clean candidate payload sources/) + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } +}) + +test("candidate payload binding permits tooling changes but rejects plugin drift", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "candidate-payload-source-")) + try { + const environment = { + ...process.env, + GIT_AUTHOR_NAME: "Candidate Test", + GIT_AUTHOR_EMAIL: "candidate@example.invalid", + GIT_COMMITTER_NAME: "Candidate Test", + GIT_COMMITTER_EMAIL: "candidate@example.invalid", + } + mkdirSync(join(fixtureRoot, "plugin")) + writeFileSync(join(fixtureRoot, "plugin", "payload.txt"), "clean\n") + writeFileSync(join(fixtureRoot, "tooling.txt"), "clean\n") + for (const command of [ + ["git", "init", "--quiet"], + ["git", "add", "plugin/payload.txt", "tooling.txt"], + ["git", "-c", "commit.gpgSign=false", "commit", "--quiet", "-m", "fixture"], + ]) { + expect(Bun.spawnSync({ cmd: command, cwd: fixtureRoot, env: environment }).exitCode).toBe(0) + } + writeFileSync(join(fixtureRoot, "tooling.txt"), "changed\n") + expect(resolveCandidatePayloadCommit(fixtureRoot)).toMatch(/^[a-f0-9]{40}$/) + writeFileSync(join(fixtureRoot, "plugin", "payload.txt"), "changed\n") + expect(() => resolveCandidatePayloadCommit(fixtureRoot)).toThrow("plugin payload bytes") } finally { rmSync(fixtureRoot, { recursive: true, force: true }) } @@ -105,6 +344,145 @@ test("both isolated harness installs report the tagged manifest version", () => expect(proof.versionAgreement).toBe(true) }) +test("automated install evidence binds bytes without claiming native activation", () => { + const head = Bun.spawnSync({ cmd: ["git", "rev-parse", "HEAD"], cwd: root, stdout: "pipe" }) + expect(proof.capabilityEvidence.candidateCommit).toBe(head.stdout.toString().trim()) + expect(proof.capabilityEvidence.fixtureCommit).toBe(proof.preflight.resolvedSha) + expect(proof.capabilityEvidence.candidatePayloadHash).toMatch(/^[a-f0-9]{64}$/) + for (const client of ["claude", "codex"] as const) { + expect(proof.capabilityEvidence.clients[client]).toEqual({ + candidateCommit: proof.capabilityEvidence.candidateCommit, + candidatePayloadHash: proof.capabilityEvidence.candidatePayloadHash, + installedPayloadHash: proof.capabilityEvidence.candidatePayloadHash, + declarationHealth: "healthy", + directHandlerHealth: "healthy", + fixtureState: "matched", + currentSessionHook: "unknown", + nativeActivation: "not-proved", + externalCandidateQualification: "unknown", + nativeDelegation: "not-proved", + nativeQualification: { status: "not-proved", receipt: null }, + portableSkillsWithoutHooks: [ + "hello-world", + "skill-a", + "skill-b", + "runtime-custody", + "capability-tour", + ], + }) + } + expect(proof.codex).not.toHaveProperty("activation") +}) + +test.each([ + { + path: "assets/logo.svg", + tamper: (bytes: Buffer) => Buffer.concat([bytes, Buffer.from("tampered\n")]), + expected: "installed payload hash differs from the candidate payload", + }, + { + path: "skills/capability-tour/references/capability-reviewer.md", + tamper: (bytes: Buffer) => Buffer.concat([bytes, Buffer.from("tampered\n")]), + expected: "installed payload hash differs from the candidate payload", + }, + { + path: "hooks/claude/hooks.json", + tamper: () => Buffer.from("{}\n"), + expected: "installed declaration bytes do not match the capability contract", + }, + { + path: "hooks/fixture/lifecycle-mechanics-proof.generated.json", + tamper: (bytes: Buffer) => Buffer.concat([bytes, Buffer.from("tampered\n")]), + expected: "installed lifecycle mechanics proof fixture differs", + }, +] as const)("installed capability evidence rejects tampered $path bytes", ({ path, tamper, expected }) => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "installed-capability-tamper-")) + const pluginRoot = join(temporaryRoot, "plugin") + try { + cpSync(join(root, "plugin"), pluginRoot, { recursive: true }) + const candidatePayloadHash = runtimeClosureEvidence(pluginRoot).payloadHash + const absolutePath = join(pluginRoot, path) + writeFileSync(absolutePath, tamper(readFileSync(absolutePath))) + expect(() => + proveInstalledCapabilityEvidence( + pluginRoot, + "claude", + "a".repeat(40), + candidatePayloadHash, + ), + ).toThrow(expected) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +test("installed payload hash is validated before the candidate handler executes", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "installed-capability-handler-tamper-")) + const pluginRoot = join(temporaryRoot, "plugin") + const marker = join(temporaryRoot, "handler-ran") + try { + cpSync(join(root, "plugin"), pluginRoot, { recursive: true }) + const candidatePayloadHash = runtimeClosureEvidence(pluginRoot).payloadHash + writeFileSync( + join(pluginRoot, "hooks", "native-capability-hook"), + `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`, + ) + expect(() => + proveInstalledCapabilityEvidence( + pluginRoot, + "claude", + "a".repeat(40), + candidatePayloadHash, + ), + ).toThrow("installed payload hash differs from the candidate payload") + expect(existsSync(marker)).toBe(false) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +test("installed capability evidence rejects a catalogued model-only tour", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "installed-capability-catalog-")) + const pluginRoot = join(temporaryRoot, "plugin") + try { + cpSync(join(root, "plugin"), pluginRoot, { recursive: true }) + const catalogPath = join(pluginRoot, "runtime", "skill-catalog.sh") + writeFileSync(catalogPath, `${readFileSync(catalogPath, "utf8")}\n\tcapability-tour)\n`) + expect(() => + proveInstalledCapabilityEvidence( + pluginRoot, + "claude", + "a".repeat(40), + runtimeClosureEvidence(pluginRoot).payloadHash, + ), + ).toThrow("installed runtime catalog includes capability-tour") + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +test("installed capability evidence rejects a bundled model-only tour", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "installed-capability-bundle-")) + const pluginRoot = join(temporaryRoot, "plugin") + try { + cpSync(join(root, "plugin"), pluginRoot, { recursive: true }) + const inventoryPath = join(pluginRoot, "runtime", "bundle-inventory.json") + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) + inventory.bundles["capability-tour"] = inventory.bundles["hello-world"] + writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, "\t")}\n`) + expect(() => + proveInstalledCapabilityEvidence( + pluginRoot, + "codex", + "a".repeat(40), + runtimeClosureEvidence(pluginRoot).payloadHash, + ), + ).toThrow("installed bundle inventory differs") + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + test("release proof requires both native harness CLIs", () => { const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) expect(packageJson.scripts["prove:harness-install"]).toBe( @@ -268,7 +646,13 @@ test("cleaned CLI proof reports that temporary evidence was removed", () => { evidenceRetained: false, codex: { mode: "fixture-copy", - activation: null, + installedState: null, + }, + capabilityEvidence: { + clients: { + claude: { nativeActivation: "not-proved" }, + codex: { nativeActivation: "not-proved" }, + }, }, }) }, 60_000) @@ -423,14 +807,13 @@ codexNativeTest("Codex local refresh changes bytes (Codex CLI required; fallback expect(proof.codex.localRefresh.failureRestored).toBe(true) }) -test("Codex activation evidence matches the proof mode", () => { +test("Codex installed state never claims native activation", () => { if (proof.codex.mode === "fixture-copy") { - expect(proof.codex.activation).toBeNull() + expect(proof.codex.installedState).toBeNull() return } - expect(proof.codex.activation).toMatchObject({ + expect(proof.codex.installedState).toMatchObject({ pluginEnabled: true, - lifecycleHookPresent: false, executionEntry: "explicit skill launcher", runtimeRepairOwner: "agent workflow with human approval", }) diff --git a/scripts/prove-harness-install.ts b/scripts/prove-harness-install.ts index 0296882..495a830 100644 --- a/scripts/prove-harness-install.ts +++ b/scripts/prove-harness-install.ts @@ -25,6 +25,7 @@ import { import { copyPluginPayload, payloadInventorySha256, pluginPayloadInventory } from "./plugin-files" import { CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, + hookDeclarationBody, loadPluginConfig, type PluginConfig, writeGeneratedFiles, @@ -217,14 +218,213 @@ export interface CodexProof { enabledStateRestored: boolean failureRestored: boolean } - activation: { + installedState: { pluginEnabled: boolean - lifecycleHookPresent: false executionEntry: "explicit skill launcher" runtimeRepairOwner: "agent workflow with human approval" } | null } +export interface InstalledCapabilityEvidence { + candidateCommit: string + candidatePayloadHash: string + installedPayloadHash: string + declarationHealth: "healthy" + directHandlerHealth: "healthy" + fixtureState: "matched" + currentSessionHook: "unknown" | "proved" + nativeActivation: "not-proved" | "proved" + externalCandidateQualification: "unknown" | "proved" + nativeDelegation: "not-proved" | "proved" + nativeQualification: + | { status: "not-proved"; receipt: null } + | { status: "proved"; receipt: NativeQualificationEvidence } + | { status: "failed"; receipt: NativeQualificationEvidence } + portableSkillsWithoutHooks: ["hello-world", "skill-a", "skill-b", "runtime-custody", "capability-tour"] +} + +/** Hash-only conclusions that may be promoted from a private fresh-client receipt. */ +export interface NativeQualificationEvidence { + schema: "native-capability-qualification-v1" + client: "claude" | "codex" + platform: "macos" | "linux" + receiptSha256: string + sourceCandidateSha: string + archiveSha256: string + packagedPayloadHash: string + installedPayloadHash: string + derivedPayloadHash: string + conclusions: { + discovery: "proved" | "failed" + uiIdentity: "proved" | "failed" + skillSeededNativeDelegation: "proved" | "failed" + hostOwnedLifecycleEvidence: "proved" | "failed" + sessionStart: "proved" | "failed" + cleanStop: "silent" | "failed" + driftContinuation: "proved" | "failed" + reentry: "silent" | "failed" + hooksFallback: "proved" | "failed" + exactDefinitionTrust: "proved" | "failed" | "not-applicable" + } + evidence: { + discoverySha256: string + uiIdentitySha256: string + delegationLifecycleSha256: string + sessionStartSha256: string + cleanStopSha256: string + driftSha256: string + reentrySha256: string + hooksFallbackSha256: string + exactDefinitionTrustSha256?: string + } +} + +export interface NativeQualificationBinding { + client: "claude" | "codex" + sourceCommit: string + archiveSha256: string + packagedPayloadHash: string + installedPayloadHash: string +} + +export interface NativeQualificationPromotion { + summary: unknown + lineage: Omit +} + +function qualificationRecord(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("native qualification promotion accepts only bounded summary and evidence hashes") + } + return value as Record +} + +function requireQualificationKeys(value: unknown, expected: string[]): Record { + const record = qualificationRecord(value) + if (Object.keys(record).sort().join("\0") !== [...expected].sort().join("\0")) { + throw new Error("native qualification promotion accepts only bounded summary and evidence hashes") + } + return record +} + +function qualificationSha256(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/.test(value) +} + +/** Validate a private receipt's promotable subset without accepting paths, transcripts, or session data. */ +export function promoteNativeQualificationEvidence( + input: unknown, + expected: NativeQualificationBinding, +): NativeQualificationEvidence { + const summary = requireQualificationKeys(input, [ + "schema", + "client", + "platform", + "receiptSha256", + "sourceCandidateSha", + "archiveSha256", + "packagedPayloadHash", + "installedPayloadHash", + "derivedPayloadHash", + "conclusions", + "evidence", + ]) + const conclusions = requireQualificationKeys(summary.conclusions, [ + "discovery", + "uiIdentity", + "skillSeededNativeDelegation", + "hostOwnedLifecycleEvidence", + "sessionStart", + "cleanStop", + "driftContinuation", + "reentry", + "hooksFallback", + "exactDefinitionTrust", + ]) + const evidenceKeys = [ + "discoverySha256", + "uiIdentitySha256", + "delegationLifecycleSha256", + "sessionStartSha256", + "cleanStopSha256", + "driftSha256", + "reentrySha256", + "hooksFallbackSha256", + ] + if (expected.client === "codex") evidenceKeys.push("exactDefinitionTrustSha256") + const evidence = requireQualificationKeys(summary.evidence, evidenceKeys) + + if ( + summary.schema !== "native-capability-qualification-v1" || + summary.client !== expected.client || + (summary.platform !== "macos" && summary.platform !== "linux") || + !qualificationSha256(summary.receiptSha256) || + !qualificationSha256(summary.archiveSha256) || + !qualificationSha256(summary.packagedPayloadHash) || + !qualificationSha256(summary.installedPayloadHash) || + !qualificationSha256(summary.derivedPayloadHash) || + !Object.values(evidence).every(qualificationSha256) + ) { + throw new Error("native qualification promotion contains invalid bounded evidence metadata") + } + if ( + summary.sourceCandidateSha !== expected.sourceCommit || + summary.archiveSha256 !== expected.archiveSha256 || + summary.packagedPayloadHash !== expected.packagedPayloadHash || + summary.installedPayloadHash !== expected.installedPayloadHash || + summary.packagedPayloadHash !== summary.installedPayloadHash + ) { + throw new Error("native qualification promotion does not match candidate lineage") + } + if (summary.derivedPayloadHash === summary.packagedPayloadHash) { + throw new Error("native qualification promotion requires a distinct derived payload hash") + } + for (const field of [ + "discovery", + "uiIdentity", + "skillSeededNativeDelegation", + "hostOwnedLifecycleEvidence", + "sessionStart", + "driftContinuation", + "hooksFallback", + ] as const) { + if (conclusions[field] !== "proved" && conclusions[field] !== "failed") { + throw new Error("native qualification promotion contains an invalid bounded conclusion") + } + } + for (const field of ["cleanStop", "reentry"] as const) { + if (conclusions[field] !== "silent" && conclusions[field] !== "failed") { + throw new Error("native qualification promotion contains an invalid bounded conclusion") + } + } + if ( + (expected.client === "claude" && conclusions.exactDefinitionTrust !== "not-applicable") || + (expected.client === "codex" && + conclusions.exactDefinitionTrust !== "proved" && + conclusions.exactDefinitionTrust !== "failed") + ) { + throw new Error("native qualification promotion has the wrong exact-definition trust conclusion") + } + return input as NativeQualificationEvidence +} + +function nativeQualificationPassed(receipt: NativeQualificationEvidence): boolean { + return ( + receipt.conclusions.discovery === "proved" && + receipt.conclusions.uiIdentity === "proved" && + receipt.conclusions.skillSeededNativeDelegation === "proved" && + receipt.conclusions.hostOwnedLifecycleEvidence === "proved" && + receipt.conclusions.sessionStart === "proved" && + receipt.conclusions.cleanStop === "silent" && + receipt.conclusions.driftContinuation === "proved" && + receipt.conclusions.reentry === "silent" && + receipt.conclusions.hooksFallback === "proved" && + (receipt.client === "claude" + ? receipt.conclusions.exactDefinitionTrust === "not-applicable" + : receipt.conclusions.exactDefinitionTrust === "proved") + ) +} + interface HarnessInstallProof { ok: true runId: string @@ -235,6 +435,15 @@ interface HarnessInstallProof { restorationPreflight: TaggedCheckout claude: ClaudeProof codex: CodexProof + capabilityEvidence: { + candidateCommit: string + fixtureCommit: string + candidatePayloadHash: string + clients: { + claude: InstalledCapabilityEvidence + codex: InstalledCapabilityEvidence + } + } runtimeJourneys?: { claude: NativeRuntimeJourney codex: NativeRuntimeJourney @@ -882,8 +1091,20 @@ function installCodex( export interface HostedHarnessInstallProof { temporaryRoot: string preflight: TaggedCheckout - claude: { mode: "native-hosted-marketplace"; version: string; inventory: string[] } - codex: { mode: "native-hosted-marketplace"; version: string; inventory: string[] } + claude: { + mode: "native-hosted-marketplace" + version: string + inventory: string[] + /** SHA-256 measured over the bytes each native client actually installed. */ + installedPayloadHash: string + } + codex: { + mode: "native-hosted-marketplace" + version: string + inventory: string[] + /** SHA-256 measured over the bytes each native client actually installed. */ + installedPayloadHash: string + } } /** Bind each native client to the same transport-specific hosted Git remote and ref. */ @@ -973,6 +1194,10 @@ export function proveHostedHarnessInstall( throw new Error("Claude hosted install reported the wrong manifest version") } const claudeInventory = comparePayload(expected, claudeInstall.activeCachePath) + const claudeInstalledPayloadHash = payloadInventorySha256( + claudeInstall.activeCachePath, + claudeInventory, + ) const codexHome = join(temporaryRoot, "codex", "home") const codexProject = join(temporaryRoot, "codex", "project") @@ -988,6 +1213,10 @@ export function proveHostedHarnessInstall( ) assertCodexReportedVersion(codexInstall, claudeManifest.version, "hosted install") const codexInventory = comparePayload(expected, codexInstall.add.installedPath) + const codexInstalledPayloadHash = payloadInventorySha256( + codexInstall.add.installedPath, + codexInventory, + ) return { temporaryRoot, preflight: expected, @@ -995,11 +1224,13 @@ export function proveHostedHarnessInstall( mode: "native-hosted-marketplace", version: claudeInstall.version, inventory: claudeInventory, + installedPayloadHash: claudeInstalledPayloadHash, }, codex: { mode: "native-hosted-marketplace", version: codexInstall.add.version, inventory: codexInventory, + installedPayloadHash: codexInstalledPayloadHash, }, } } catch (error) { @@ -1095,7 +1326,10 @@ export function assertReplacementAdmission( * const evidence = runtimeClosureEvidence(installedPath) * ``` */ -export function runtimeClosureEvidence(pluginRoot: string): { +export function runtimeClosureEvidence( + pluginRoot: string, + inventory: string[] = regularFiles(pluginRoot), +): { version: string inventoryHash: string payloadHash: string @@ -1103,7 +1337,6 @@ export function runtimeClosureEvidence(pluginRoot: string): { const manifest = JSON.parse( readFileSync(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), ) - const inventory = regularFiles(pluginRoot) return { version: manifest.version, inventoryHash: createHash("sha256").update(inventory.join("\0")).digest("hex"), @@ -1111,6 +1344,175 @@ export function runtimeClosureEvidence(pluginRoot: string): { } } +/** + * Inspect installed bytes and direct handler mechanics without making a native claim. + * + * `currentSessionHook` derives only from the explicit `currentSessionMarker` input + * (the current session's native context marker); a qualification receipt is a + * separate evidence layer and never proves the current session's hook. + */ +export function proveInstalledCapabilityEvidence( + pluginRoot: string, + client: "claude" | "codex", + candidateCommit: string, + candidatePayloadHash: string, + qualification?: NativeQualificationPromotion, + currentSessionMarker?: boolean, +): InstalledCapabilityEvidence { + if (!/^[a-f0-9]{40}$/.test(candidateCommit) || !/^[a-f0-9]{64}$/.test(candidatePayloadHash)) { + throw new Error("installed capability evidence requires a candidate commit and payload hash") + } + const manifest = JSON.parse( + readFileSync(join(pluginRoot, `.${client}-plugin`, "plugin.json"), "utf8"), + ) as { version?: unknown; hooks?: unknown } + const declarationPath = `./hooks/${client}/hooks.json` + if (manifest.hooks !== declarationPath) { + throw new Error(`${client} installed declaration path is invalid`) + } + const declaration = JSON.parse( + readFileSync(join(pluginRoot, declarationPath), "utf8"), + ) as Record + if (JSON.stringify(declaration) !== JSON.stringify(hookDeclarationBody(client))) { + throw new Error(`${client} installed declaration bytes do not match the capability contract`) + } + const fixtureSource = readFileSync( + join(pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.source.json"), + ) + const fixtureProjection = readFileSync( + join(pluginRoot, "hooks", "fixture", "lifecycle-mechanics-proof.generated.json"), + ) + if (!fixtureSource.equals(fixtureProjection)) { + throw new Error(`${client} installed lifecycle mechanics proof fixture differs`) + } + const installedInventory = regularFiles(pluginRoot) + const installed = runtimeClosureEvidence(pluginRoot, installedInventory) + if (installed.payloadHash !== candidatePayloadHash) { + throw new Error(`${client} installed payload hash differs from the candidate payload`) + } + const installedSkills = installedInventory + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.slice("skills/".length, -"/SKILL.md".length)) + const portableSkills = [ + "capability-tour", + "hello-world", + "runtime-custody", + "skill-a", + "skill-b", + ] + if (JSON.stringify(installedSkills) !== JSON.stringify(portableSkills)) { + throw new Error(`${client} installed portable skill inventory differs`) + } + const executableSkills = ["hello-world", "skill-a", "skill-b"] + const launchers = installedInventory + .filter((path) => path.startsWith("bin/")) + .map((path) => path.slice("bin/".length)) + const catalogProjection = readFileSync( + join(pluginRoot, "runtime", "skill-catalog.sh"), + "utf8", + ) + if (JSON.stringify(launchers) !== JSON.stringify(executableSkills)) { + throw new Error(`${client} installed launcher inventory differs`) + } + for (const skillId of executableSkills) { + if (!catalogProjection.includes(`\n\t${skillId})`)) { + throw new Error(`${client} installed runtime catalog omits ${skillId}`) + } + const launcher = readFileSync(join(pluginRoot, "bin", skillId), "utf8") + if (!launcher.includes(`runtime/runtime-exec\" run ${skillId} --`) || launcher.includes("hooks/")) { + throw new Error(`${client} installed ${skillId} launcher is not hook-independent`) + } + } + if (catalogProjection.includes("capability-tour")) { + throw new Error(`${client} installed runtime catalog includes capability-tour`) + } + const bundleInventory = JSON.parse( + readFileSync(join(pluginRoot, "runtime", "bundle-inventory.json"), "utf8"), + ) as { bundles?: Record } + if ( + JSON.stringify(Object.keys(bundleInventory.bundles ?? {}).sort()) !== + JSON.stringify(executableSkills) + ) { + throw new Error(`${client} installed bundle inventory differs`) + } + if (typeof manifest.version !== "string") throw new Error(`${client} installed version is invalid`) + const handler = join(pluginRoot, "hooks", "native-capability-hook") + const runHandler = (event: "SessionStart" | "Stop", input: string) => + Bun.spawnSync({ + cmd: [handler, event, client], + cwd: pluginRoot, + env: { PATH: "/usr/bin:/bin" }, + timeout: 15_000, + stdin: Buffer.from(input), + stdout: "pipe", + stderr: "pipe", + }) + const start = runHandler("SessionStart", '{"source":"startup"}') + // The hook reads its display name from the claude manifest for both clients; + // only that manifest carries a top-level displayName. + const identityManifest = JSON.parse( + readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8"), + ) as { displayName?: unknown } + if (typeof identityManifest.displayName !== "string" || !identityManifest.displayName) { + throw new Error(`${client} installed claude manifest displayName is invalid`) + } + const expectedContext = `${identityManifest.displayName} v${manifest.version} | ${client} | SessionStart:startup` + let startContext: unknown + try { + startContext = JSON.parse(start.stdout.toString()).hookSpecificOutput?.additionalContext + } catch { + startContext = undefined + } + if (start.exitCode !== 0 || start.stderr.toString() !== "" || startContext !== expectedContext) { + throw new Error(`${client} installed direct SessionStart handler check failed`) + } + const stop = runHandler("Stop", '{"stop_hook_active":false}') + if (stop.exitCode !== 0 || stop.stdout.toString() !== "" || stop.stderr.toString() !== "") { + throw new Error(`${client} installed direct Stop handler check failed`) + } + let nativeQualification: InstalledCapabilityEvidence["nativeQualification"] = { + status: "not-proved", + receipt: null, + } + if (qualification !== undefined) { + if ( + qualification.lineage.sourceCommit !== candidateCommit || + qualification.lineage.packagedPayloadHash !== candidatePayloadHash || + qualification.lineage.installedPayloadHash !== installed.payloadHash + ) { + throw new Error("native qualification lineage does not match the installed candidate proof") + } + const receipt = promoteNativeQualificationEvidence(qualification.summary, { + client, + ...qualification.lineage, + }) + nativeQualification = { + status: nativeQualificationPassed(receipt) ? "proved" : "failed", + receipt, + } + } + const nativeProved = nativeQualification.status === "proved" + return { + candidateCommit, + candidatePayloadHash, + installedPayloadHash: installed.payloadHash, + declarationHealth: "healthy", + directHandlerHealth: "healthy", + fixtureState: "matched", + currentSessionHook: currentSessionMarker === true ? "proved" : "unknown", + nativeActivation: nativeProved ? "proved" : "not-proved", + externalCandidateQualification: nativeProved ? "proved" : "unknown", + nativeDelegation: nativeProved ? "proved" : "not-proved", + nativeQualification, + portableSkillsWithoutHooks: [ + "hello-world", + "skill-a", + "skill-b", + "runtime-custody", + "capability-tour", + ], + } +} + function nativeRuntimeTarget(): string { const target = currentRuntimeTarget() if (!target) { @@ -1241,38 +1643,57 @@ function proveNativeRuntimeJourney( } } -/** Resolve the exact commit used by a source-bound native receipt, refusing dirty bytes. */ -export function resolveCleanSourceCommit(repositoryRoot: string): string { - const sourceStatus = Bun.spawnSync({ - cmd: ["git", "status", "--porcelain=v1", "--untracked-files=all"], +function resolveCleanPathsCommit( + repositoryRoot: string, + paths: string[], + errors: { status: string; dirty: string; resolve: string; invalid?: string }, +): string { + const status = Bun.spawnSync({ + cmd: ["git", "status", "--porcelain=v1", "--untracked-files=all", "--", ...paths], cwd: repositoryRoot, stdout: "pipe", stderr: "pipe", + timeout: 15_000, }) - if (sourceStatus.exitCode !== 0) { - throw new Error("native receipt could not verify source checkout cleanliness") - } - if (sourceStatus.stdout.toString().trim() !== "") { - throw new Error( - "native runtime qualification requires a clean source checkout so sourceCommit matches the installed payload bytes", - ) - } - const sourceCommitResult = Bun.spawnSync({ + if (status.exitCode !== 0) throw new Error(errors.status) + if (status.stdout.toString().trim() !== "") throw new Error(errors.dirty) + const head = Bun.spawnSync({ cmd: ["git", "rev-parse", "HEAD"], cwd: repositoryRoot, stdout: "pipe", stderr: "pipe", + timeout: 15_000, }) - if (sourceCommitResult.exitCode !== 0) { - throw new Error("native receipt could not resolve source commit") - } - const sourceCommit = sourceCommitResult.stdout.toString().trim() - if (!/^[a-f0-9]{40}$/.test(sourceCommit)) { - throw new Error("native receipt source commit is invalid") - } + if (head.exitCode !== 0) throw new Error(errors.resolve) + const sourceCommit = head.stdout.toString().trim() + if (!/^[a-f0-9]{40}$/.test(sourceCommit)) throw new Error(errors.invalid ?? errors.resolve) return sourceCommit } +/** Resolve the exact commit used by a source-bound native receipt, refusing dirty bytes. */ +export function resolveCleanSourceCommit(repositoryRoot: string): string { + return resolveCleanPathsCommit( + repositoryRoot, + ["plugin", "runtime", "packages", "package.json", "bun.lock", "bunfig.toml", "plugin.config.json"], + { + status: "native receipt could not verify candidate payload source cleanliness", + dirty: + "native runtime qualification requires clean candidate payload sources so sourceCommit matches the installed payload bytes", + resolve: "native receipt could not resolve source commit", + invalid: "native receipt source commit is invalid", + }, + ) +} + +/** Bind automated payload evidence to Git HEAD while allowing tooling-only worktree changes. */ +export function resolveCandidatePayloadCommit(repositoryRoot: string): string { + return resolveCleanPathsCommit(repositoryRoot, ["plugin"], { + status: "candidate proof could not inspect plugin payload status", + dirty: "candidate proof requires plugin payload bytes to match the candidate commit", + resolve: "candidate proof could not resolve the source commit", + }) +} + function runHarnessInstallProof( repositoryRoot: string, temporaryRoot: string, @@ -1281,7 +1702,11 @@ function runHarnessInstallProof( ): HarnessInstallProof { const sourceCommit = qualifyRuntimeJourney ? resolveCleanSourceCommit(repositoryRoot) - : undefined + : resolveCandidatePayloadCommit(repositoryRoot) + const configuredSourceCommit = process.env.SOURCE_COMMIT || process.env.GITHUB_SHA || undefined + if (configuredSourceCommit !== undefined && configuredSourceCommit !== sourceCommit) { + throw new Error("candidate proof source commit does not match Git HEAD") + } const fixture = createFixtureRelease(repositoryRoot, temporaryRoot) admitGitTransport({ source: fixture.repositoryRoot, @@ -1297,7 +1722,7 @@ function runHarnessInstallProof( const pluginConfig = loadPluginConfig(repositoryRoot) const nativeIdentity = { repository: pluginConfig.repository, - sourceCommit: sourceCommit ?? fixture.base.resolvedSha, + sourceCommit, runtimeLockSha256: createHash("sha256") .update(readFileSync(join(repositoryRoot, "runtime", "runtime.lock.json"))) .digest("hex"), @@ -1343,15 +1768,37 @@ function runHarnessInstallProof( } const trustBase = runtimeClosureEvidence(join(fixture.base.checkoutRoot, "plugin")) const trustTarget = runtimeClosureEvidence(join(fixture.target.checkoutRoot, "plugin")) + const sourceCandidate = runtimeClosureEvidence(join(repositoryRoot, "plugin")) if ( trustBase.inventoryHash !== trustTarget.inventoryHash || - trustBase.payloadHash === trustTarget.payloadHash + trustBase.payloadHash === trustTarget.payloadHash || + trustBase.payloadHash !== sourceCandidate.payloadHash ) { throw new Error("release change did not preserve inventory while changing exact payload bytes") } const versionAgreement = claude.version === fixture.base.manifestVersion && codex.version === fixture.base.manifestVersion if (!versionAgreement) throw new Error("Claude and Codex installed versions do not agree with the tag") + const candidatePayloadHash = sourceCandidate.payloadHash + const capabilityEvidence = { + candidateCommit: sourceCommit, + fixtureCommit: fixture.base.resolvedSha, + candidatePayloadHash, + clients: { + claude: proveInstalledCapabilityEvidence( + claude.activeCachePath, + "claude", + sourceCommit, + candidatePayloadHash, + ), + codex: proveInstalledCapabilityEvidence( + codex.installedPath, + "codex", + sourceCommit, + candidatePayloadHash, + ), + }, + } const runtimeJourneys = qualifyRuntimeJourney ? { claude: proveNativeRuntimeJourney( @@ -1380,11 +1827,12 @@ function runHarnessInstallProof( restorationPreflight: fixture.base, claude, codex, + capabilityEvidence, runtimeJourneys, versionAgreement, payloadClosureChanged: true, skips, - nextAction: "Review the installed-payload mechanics evidence. Before release, capture candidate-bound Claude task, Codex task, and Codex Desktop human-approval receipts; this fixture does not claim agent workflow proof.", + nextAction: "Review the candidate-bound package, declaration, direct-handler, and installed-payload evidence. Fresh-client receipts separately own native activation, trust, UI, and delegation; this automated proof claims none of them.", } } diff --git a/scripts/release-projection.test.ts b/scripts/release-projection.test.ts index 09709ae..ec2f639 100644 --- a/scripts/release-projection.test.ts +++ b/scripts/release-projection.test.ts @@ -4,6 +4,8 @@ import { join } from "node:path" import { expect, test } from "bun:test" +import { classifyReleaseImpact } from "./release-impact" + import { RELEASE_PROJECTION_PATHS, validateReleaseProjection, @@ -31,13 +33,32 @@ test("one projection policy accepts version-only changes and rejects behavioral ).toThrow("unsupported path") }) -test("runtime hook files are outside the release projection", () => { - expect(() => - validateReleaseProjection( - [{ filename: "plugin/hooks/codex/hooks.json", status: "modified" }], - () => ({ before: "{}", after: "{}" }), - ), - ).toThrow("unsupported path") +test("static capability sidecars are release-relevant but outside the version projection", () => { + for (const path of [ + "plugin/hooks/codex/hooks.json", + "plugin/hooks/fixture/lifecycle-mechanics-proof.source.json", + "plugin/skills/capability-tour/SKILL.md", + "plugin/skills/capability-tour/references/capability-reviewer.md", + "plugin/assets/logo.svg", + ]) { + expect(() => + validateReleaseProjection( + [{ filename: path, status: "modified" }], + () => ({ before: "before", after: "after" }), + ), + ).toThrow("unsupported path") + expect( + classifyReleaseImpact({ + title: "chore: change a static capability sidecar", + changedFiles: [{ path }], + pullRequestIdentity: { + headRef: "release-please--branches--main", + authorLogin: "github-actions[bot]", + expectedAutomationLogin: "github-actions[bot]", + }, + }), + ).toMatchObject({ payloadChanged: true, isReleasePleaseProjection: false, ok: false }) + } }) test("projection binds every changed candidate blob to the reviewed pull-request head", () => { diff --git a/scripts/release-validate.test.ts b/scripts/release-validate.test.ts index 80e968b..94f8e8c 100644 --- a/scripts/release-validate.test.ts +++ b/scripts/release-validate.test.ts @@ -16,6 +16,7 @@ import { admitPublicationCandidate, validatePublicationBinding, parsePublicationCandidateRecord, + validateCapabilitySidecars, validateRepairCandidateBinding, validateRepairBinding, } from "./release-validate" @@ -72,6 +73,19 @@ const allowedProjection = [ "plugin/.codex-plugin/plugin.json", ] +test("capability sidecar validation rejects a missing static file", () => { + const temporaryRoot = copyRepository() + try { + const missingPath = "plugin/assets/logo.svg" + rmSync(join(temporaryRoot, missingPath)) + expect(() => validateCapabilitySidecars(temporaryRoot)).toThrow( + `static capability sidecar is missing: ${missingPath}`, + ) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + function releasePullRequest(overrides: Record = {}) { return { number: 42, @@ -202,6 +216,46 @@ test("release validation rejects a drifted version surface", () => { expect(result.stderr.toString()).toContain("plugin.config.json") }) +test.each([ + "plugin/hooks/claude/hooks.json", + "plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json", +] as const)("release validation rejects stale generated capability bytes at %s", (path) => { + const temporaryRoot = copyRepository() + try { + const absolutePath = join(temporaryRoot, path) + writeFileSync(absolutePath, Buffer.concat([readFileSync(absolutePath), Buffer.from("tampered\n")])) + + const result = validate(temporaryRoot) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain(path) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +test("release validation reports every drifted generated capability path", () => { + const temporaryRoot = copyRepository() + try { + const paths = [ + "plugin/hooks/claude/hooks.json", + "plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json", + ] + for (const path of paths) { + const absolutePath = join(temporaryRoot, path) + writeFileSync( + absolutePath, + Buffer.concat([readFileSync(absolutePath), Buffer.from("tampered\n")]), + ) + } + + const result = validate(temporaryRoot) + expect(result.exitCode).toBe(1) + for (const path of paths) expect(result.stderr.toString()).toContain(path) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + test("release validation rejects unexpected release-please extra-files", () => { const temporaryRoot = copyRepository() const configPath = join(temporaryRoot, ".github", "release-please-config.json") @@ -993,13 +1047,20 @@ test.each([ ["converge", "\n converge:\n"], ] as const)("release validation fails closed without the %s job boundary", (_job, marker) => { const temporaryRoot = copyRepository() - const workflowPath = join(temporaryRoot, ".github", "workflows", "release.yml") - writeFileSync(workflowPath, readFileSync(workflowPath, "utf8").replace(marker, "\n renamed-job:\n")) + try { + const workflowPath = join(temporaryRoot, ".github", "workflows", "release.yml") + writeFileSync( + workflowPath, + readFileSync(workflowPath, "utf8").replace(marker, "\n renamed-job:\n"), + ) - const result = validate(temporaryRoot) + const result = validate(temporaryRoot) - expect(result.exitCode).toBe(1) - expect(result.stderr.toString()).toContain("job boundary") + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("job boundary") + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } }) test("release validation rejects read-only pull-request lineage permissions", () => { diff --git a/scripts/release-validate.ts b/scripts/release-validate.ts index 4c000e1..536a007 100644 --- a/scripts/release-validate.ts +++ b/scripts/release-validate.ts @@ -1,11 +1,45 @@ -import { readFileSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { join, resolve } from "node:path" import { validateBunOnlyPayload } from "./build" +import { checkNativeCapabilityFixture } from "./native-capability-fixture" +import { checkGeneratedFiles, loadPluginConfig } from "./plugin-config" import { RELEASE_PROJECTION_PATH_SET } from "./release-projection" const root = resolve(import.meta.dir, "..") +const STATIC_CAPABILITY_SIDECAR_PATHS = [ + "plugin/assets/composer-icon.svg", + "plugin/assets/logo.svg", + "plugin/hooks/native-capability-hook", + "plugin/hooks/fixture/lifecycle-mechanics-proof.source.json", + "plugin/skills/capability-tour/SKILL.md", + "plugin/skills/capability-tour/references/capability-reviewer.md", +] as const + +/** Admit the exact static sidecar inventory and generated capability bytes. */ +export function validateCapabilitySidecars(repositoryRoot: string): string[] { + const config = loadPluginConfig(repositoryRoot) + const drifted = [ + ...checkGeneratedFiles(repositoryRoot, config), + ...checkNativeCapabilityFixture(repositoryRoot), + ] + if (drifted.length > 0) { + throw new Error(`generated capability sidecars differ from their sources: ${drifted.join(", ")}`) + } + for (const path of STATIC_CAPABILITY_SIDECAR_PATHS) { + if (!existsSync(join(repositoryRoot, path))) { + throw new Error(`static capability sidecar is missing: ${path}`) + } + } + return [ + ...STATIC_CAPABILITY_SIDECAR_PATHS, + "plugin/hooks/claude/hooks.json", + "plugin/hooks/codex/hooks.json", + "plugin/hooks/fixture/lifecycle-mechanics-proof.generated.json", + ].sort() +} + const help = `Validate release metadata and workflow invariants. Usage: @@ -462,6 +496,7 @@ function readJson(repositoryRoot: string, path: string): Record { function validateRepository(repositoryRoot: string) { validateBunOnlyPayload(repositoryRoot) + const capabilitySidecars = validateCapabilitySidecars(repositoryRoot) const packageJson = readJson(repositoryRoot, "package.json") const pluginConfig = readJson(repositoryRoot, "plugin.config.json") const claudeMarketplace = readJson(repositoryRoot, ".claude-plugin/marketplace.json") @@ -488,8 +523,11 @@ function validateRepository(repositoryRoot: string) { throw new Error(`${name} version ${String(actual)} does not match plugin.config.json ${version}`) } } - if (claudeManifest.hooks !== undefined || codexManifest.hooks !== undefined) { - throw new Error("runtime lifecycle hooks must not be active in native manifests") + if ( + claudeManifest.hooks !== "./hooks/claude/hooks.json" || + codexManifest.hooks !== "./hooks/codex/hooks.json" + ) { + throw new Error("native manifests must reference the exact client-specific hook declarations") } if (releaseState === "bootstrap") { @@ -704,6 +742,12 @@ function validateRepository(repositoryRoot: string) { changelog: "CHANGELOG.md", tag: `v${version}`, npmPublicationRequired: false, + capabilitySidecars, + automatedClaimBoundary: { + nativeActivation: "not-proved", + nativeDelegation: "not-proved", + qualificationReceiptsIngested: false, + }, versionSurfaces: [ ...versionSurfaces.map(([name]) => name), ...(releaseState === "released" ? ["release-please manifest"] : []), diff --git a/scripts/runtime-custody-generation.test.ts b/scripts/runtime-custody-generation.test.ts index 3de2ddb..54d7ae1 100644 --- a/scripts/runtime-custody-generation.test.ts +++ b/scripts/runtime-custody-generation.test.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs" import { expect, test } from "bun:test" import { fileURLToPath } from "node:url" @@ -22,7 +23,6 @@ test("one Bun version is pinned across packageManager, bun.lock, CI, and the run expect(await Bun.file(new URL("../bun.lock", import.meta.url)).exists()).toBe(true) const workflowsDirectory = fileURLToPath(new URL("../.github/workflows/", import.meta.url)) - const { readdirSync, readFileSync } = await import("node:fs") const workflowFiles = readdirSync(workflowsDirectory).filter((name) => name.endsWith(".yml")) expect(workflowFiles.length).toBeGreaterThan(0) let pinnedWorkflows = 0 @@ -90,9 +90,19 @@ test("runtime custody sources generate one thin launcher and checked shell proje }, }, }) + const installedSkills = readdirSync( + fileURLToPath(new URL("../plugin/skills", import.meta.url)), + ).sort() + expect(installedSkills).toEqual([ + "capability-tour", + "hello-world", + "runtime-custody", + "skill-a", + "skill-b", + ]) + expect(catalog.skills).not.toHaveProperty("capability-tour") // One logical catalog owns workspace, SKILL, and runtime identity per skill. - const { existsSync } = await import("node:fs") for (const [skillId, skill] of Object.entries( catalog.skills as Record, )) { @@ -112,12 +122,22 @@ test("runtime custody sources generate one thin launcher and checked shell proje for (const skillId of Object.keys(catalog.skills)) { const launcher = await Bun.file(new URL(`../plugin/bin/${skillId}`, import.meta.url)).text() expect(launcher).toContain(`runtime-exec\" run ${skillId} --`) + expect(launcher).not.toContain("hooks/") const skillDocument = await Bun.file( new URL(`../plugin/skills/${skillId}/SKILL.md`, import.meta.url), ).text() expect(skillDocument).not.toContain("Status: not yet invocable") } expect(generated.filter((file) => file.path.startsWith("plugin/bin/")).length).toBe(3) + const launcherNames = readdirSync( + fileURLToPath(new URL("../plugin/bin", import.meta.url)), + ).sort() + expect(launcherNames).toEqual(["hello-world", "skill-a", "skill-b"]) + const bundleInventory = await Bun.file( + new URL("../plugin/runtime/bundle-inventory.json", import.meta.url), + ).json() + expect(Object.keys(bundleInventory.bundles).sort()).toEqual(["hello-world", "skill-a", "skill-b"]) + expect(bundleInventory.bundles).not.toHaveProperty("capability-tour") const lockProjection = await Bun.file( new URL("../plugin/runtime/runtime-lock.sh", import.meta.url), diff --git a/scripts/ship-canary.test.ts b/scripts/ship-canary.test.ts index e22bde4..44cace4 100644 --- a/scripts/ship-canary.test.ts +++ b/scripts/ship-canary.test.ts @@ -19,11 +19,13 @@ import { PUBLISHING_SYSTEM_PATHS, admitCandidateRef, bindTransportIdentity, + bindCandidateQualificationLineage, bindTrustedPrivateRun, candidateRefForSource, classifyPublishingSystemChanges, createSanitizedPublicCandidate, qualifyTargets, + validateLineageManifestVersion, type CandidateInstallEvidence, type Target, } from "./ship-canary" @@ -31,6 +33,52 @@ import { const root = resolve(import.meta.dir, "..") const ignoredEntries = new Set([".claude", ".dev", ".git", ".worktrees", "dist", "node_modules"]) +test("candidate qualification lineage binds package and installed bytes to one source", () => { + const sourceCommit = "1".repeat(40) + const archiveSha256 = "2".repeat(64) + const payloadInventorySha256 = "3".repeat(64) + expect( + bindCandidateQualificationLineage( + sourceCommit, + { sourceCommit, archiveSha256, payloadInventorySha256 }, + payloadInventorySha256, + ), + ).toEqual({ + sourceCommit, + archiveSha256, + packagedPayloadHash: payloadInventorySha256, + installedPayloadHash: payloadInventorySha256, + }) +}) + +test("candidate qualification lineage rejects a different source or installed payload", () => { + const sourceCommit = "1".repeat(40) + const checksums = { + sourceCommit, + archiveSha256: "2".repeat(64), + payloadInventorySha256: "3".repeat(64), + } + expect(() => + bindCandidateQualificationLineage( + sourceCommit, + { ...checksums, sourceCommit: "4".repeat(40) }, + checksums.payloadInventorySha256, + ), + ).toThrow("source commit") + expect(() => + bindCandidateQualificationLineage(sourceCommit, checksums, "5".repeat(64)), + ).toThrow("installed payload") +}) + +test.each(["1.2", "01.2.3", "1.2.3/../../escape", `1.2.3+${"a".repeat(64)}`])( + "lineage packaging rejects unsafe manifest version %s", + (manifestVersion) => { + expect(() => validateLineageManifestVersion(manifestVersion)).toThrow( + "manifest version must be exact semantic versioning and at most 64 characters", + ) + }, +) + function executable(path: string, contents: string): void { writeFileSync(path, contents) chmodSync(path, 0o755) @@ -767,6 +815,12 @@ function installEvidence(target: Target, candidateSha: string): CandidateInstall version: "0.1.0", cachedPayloadMatches: true, }, + lineage: { + sourceCommit: candidateSha, + archiveSha256: "a".repeat(64), + packagedPayloadHash: "b".repeat(64), + installedPayloadHash: "b".repeat(64), + }, } } @@ -799,8 +853,26 @@ test("public and private candidates pass hosted proof then native cache comparis { repository: "myagentdojo/private-canary", conclusion: "success" }, ], installs: [ - { repository: "myagentdojo/public-canary", checkoutSha: "2".repeat(40) }, - { repository: "myagentdojo/private-canary", checkoutSha: sourceSha }, + { + repository: "myagentdojo/public-canary", + checkoutSha: "2".repeat(40), + lineage: { + sourceCommit: "2".repeat(40), + archiveSha256: "a".repeat(64), + packagedPayloadHash: "b".repeat(64), + installedPayloadHash: "b".repeat(64), + }, + }, + { + repository: "myagentdojo/private-canary", + checkoutSha: sourceSha, + lineage: { + sourceCommit: sourceSha, + archiveSha256: "a".repeat(64), + packagedPayloadHash: "b".repeat(64), + installedPayloadHash: "b".repeat(64), + }, + }, ], }) expect(calls).toEqual([ @@ -814,6 +886,56 @@ test("public and private candidates pass hosted proof then native cache comparis expect(JSON.stringify(result).toLowerCase()).not.toContain("universal-directory") }) +test("qualification binds candidate lineage and rejects unbound install evidence", async () => { + const sourceSha = "1".repeat(40) + const hostedProof = async (target: Target) => ({ + repository: target.repository, + databaseId: 1, + conclusion: "success", + url: "https://example.invalid/run/1", + sourceSha: target.candidateSha, + workflowSha: "3".repeat(40), + authority: + target.visibility === "PUBLIC" + ? ("candidate-sanitized-workflow" as const) + : ("protected-trusted-workflow" as const), + }) + + await expect( + qualifyTargets(targets(sourceSha), sourceSha, { + publish: () => {}, + hostedProof, + install: (target, candidateSha) => { + const evidence = installEvidence(target, candidateSha) + return { + ...evidence, + lineage: { ...evidence.lineage, installedPayloadHash: "c".repeat(64) }, + } + }, + }), + ).rejects.toMatchObject({ + category: "qualification_lineage_mismatch", + retrySafe: false, + }) + + await expect( + qualifyTargets(targets(sourceSha), sourceSha, { + publish: () => {}, + hostedProof, + install: (target, candidateSha) => { + const evidence = installEvidence(target, candidateSha) + return { + ...evidence, + lineage: { ...evidence.lineage, archiveSha256: "not-a-digest" }, + } + }, + }), + ).rejects.toMatchObject({ + category: "qualification_lineage_invalid", + retrySafe: false, + }) +}) + test("repository, visibility, hosted CI, and install failures carry non-rewriting repairs", async () => { const missing = runCanary(canaryFixture(), "--dry-run", { FAKE_MISSING_REPO: "1" }) expect(missing.exitCode).toBe(0) diff --git a/scripts/ship-canary.ts b/scripts/ship-canary.ts index 11c0fb6..746e208 100644 --- a/scripts/ship-canary.ts +++ b/scripts/ship-canary.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { loadPluginConfig } from "./plugin-config" +import { deterministicPluginArchive, payloadInventorySha256 } from "./plugin-files" import { copyMarketplaceDistribution, proveHostedHarnessInstall } from "./prove-harness-install" const root = resolve(import.meta.dir, "..") @@ -62,6 +63,10 @@ Safety: const DEFAULT_HOSTED_RUN_DEADLINE_MS = 10 * 60 * 1000 const DEFAULT_NETWORK_COMMAND_TIMEOUT_MS = 30 * 1000 const DEFAULT_HOSTED_POLL_DELAY_MS = 3000 +// Hosted sanitized candidates omit plugin.config.json, so mirror its private +// strict-semver contract at the archive-name boundary. +const strictManifestSemver = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ type Visibility = "PUBLIC" | "PRIVATE" type TransportKind = "ssh" | "https" @@ -142,6 +147,64 @@ export interface CandidateInstallEvidence { version: string cachedPayloadMatches: boolean } + /** Package and installed-byte lineage bound to the exact candidate commit. */ + lineage: CandidateQualificationLineage +} + +/** Exact package and installed-byte lineage required before promoting native claims. */ +export interface CandidateQualificationLineage { + sourceCommit: string + archiveSha256: string + packagedPayloadHash: string + installedPayloadHash: string +} + +/** Bind qualification lineage to checksum metadata and the independently hashed installation. */ +export function bindCandidateQualificationLineage( + expectedSourceCommit: string, + checksums: { + sourceCommit?: unknown + archiveSha256?: unknown + payloadInventorySha256?: unknown + }, + installedPayloadHash: string, +): CandidateQualificationLineage { + if (!/^[a-f0-9]{40}$/.test(expectedSourceCommit) || checksums.sourceCommit !== expectedSourceCommit) { + throw new CanaryError( + "qualification_lineage_mismatch", + "qualification checksum source commit does not match the exact candidate", + "use the checksums and installed payload from the exact candidate commit", + false, + ) + } + if ( + typeof checksums.archiveSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(checksums.archiveSha256) || + typeof checksums.payloadInventorySha256 !== "string" || + !/^[a-f0-9]{64}$/.test(checksums.payloadInventorySha256) || + !/^[a-f0-9]{64}$/.test(installedPayloadHash) + ) { + throw new CanaryError( + "qualification_lineage_invalid", + "qualification lineage requires complete SHA-256 package and installed-payload evidence", + "record the archive, packaged payload, and installed payload SHA-256 values", + false, + ) + } + if (installedPayloadHash !== checksums.payloadInventorySha256) { + throw new CanaryError( + "qualification_lineage_mismatch", + "qualification installed payload does not match the packaged payload", + "reinstall the exact candidate and hash the selected installed payload", + false, + ) + } + return { + sourceCommit: expectedSourceCommit, + archiveSha256: checksums.archiveSha256, + packagedPayloadHash: checksums.payloadInventorySha256, + installedPayloadHash, + } } /** Injectable hosted-I/O seams keep qualification behavior unit-testable without real repositories. */ @@ -184,6 +247,23 @@ export class CanaryError extends Error { } } +/** Validate the exact bounded manifest version before using it in archive staging. */ +export function validateLineageManifestVersion(manifestVersion: unknown): string { + if ( + typeof manifestVersion !== "string" || + manifestVersion.length > 64 || + !strictManifestSemver.test(manifestVersion) + ) { + throw new CanaryError( + "install_mismatch", + "candidate plugin manifest version must be exact semantic versioning and at most 64 characters for lineage packaging", + "repair plugin/.claude-plugin/plugin.json before publishing a new immutable candidate ref", + false, + ) + } + return manifestVersion +} + /** * Classify whether changed paths alter the template publishing system. * @@ -945,7 +1025,47 @@ function installCandidate(target: Target, sourceSha: string): CandidateInstallEv target.candidateRef.replace(/^refs\/heads\//, ""), sourceSha, ) - const manifestVersion = proof.preflight.manifestVersion + const manifestVersion = validateLineageManifestVersion(proof.preflight.manifestVersion) + const manifestName = ( + JSON.parse( + readFileSync(join(checkoutRoot, "plugin", ".claude-plugin", "plugin.json"), "utf8"), + ) as { name?: unknown } + ).name + if ( + typeof manifestName !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifestName) || + manifestName.length > 64 + ) { + throw new CanaryError( + "install_mismatch", + `${target.repository} candidate plugin manifest name must be kebab-case and at most 64 characters for lineage packaging`, + `repair plugin/.claude-plugin/plugin.json at ${target.candidateRef}; never rewrite history`, + false, + ) + } + if (proof.claude.installedPayloadHash !== proof.codex.installedPayloadHash) { + throw new CanaryError( + "install_mismatch", + `${target.repository} native clients installed different payload bytes`, + `repair the native install proof and rerun from ${target.candidateRef}; never rewrite history`, + false, + ) + } + const lineage = bindCandidateQualificationLineage( + sourceSha, + { + sourceCommit: checkoutSha, + archiveSha256: deterministicPluginArchive( + checkoutRoot, + `${manifestName}-${manifestVersion}`, + ).sha256, + payloadInventorySha256: payloadInventorySha256( + join(checkoutRoot, "plugin"), + proof.preflight.inventory, + ), + }, + proof.claude.installedPayloadHash, + ) return { repository: target.repository, candidateRef: target.candidateRef, @@ -961,6 +1081,7 @@ function installCandidate(target: Target, sourceSha: string): CandidateInstallEv version: proof.codex.version, cachedPayloadMatches: proof.codex.inventory.join("\n") === proof.preflight.inventory.join("\n"), }, + lineage, } } catch (error) { if (error instanceof CanaryError) throw error @@ -1032,7 +1153,7 @@ function assertCandidateInstall( * @param targets - Public and private immutable candidate targets * @param sourceSha - Recipient source commit bound into the deterministic public fixture * @param dependencies - Hosted publication, workflow, and install adapters - * @returns Hosted runs and native install comparisons without unrelated distribution claims + * @returns Hosted runs and native install comparisons, each bound to exact candidate lineage, without unrelated distribution claims * @throws {CanaryError} When hosted or native qualification fails * * @example @@ -1079,7 +1200,20 @@ export async function qualifyTargets( for (const target of targets) { const evidence = await adapters.install(target, target.candidateSha) assertCandidateInstall(target, evidence) - installs.push(evidence) + // Re-bind lineage at the qualification seam so emitted claims always carry + // checked source, archive, packaged, and installed hashes for this candidate. + installs.push({ + ...evidence, + lineage: bindCandidateQualificationLineage( + target.candidateSha, + { + sourceCommit: evidence.lineage.sourceCommit, + archiveSha256: evidence.lineage.archiveSha256, + payloadInventorySha256: evidence.lineage.packagedPayloadHash, + }, + evidence.lineage.installedPayloadHash, + ), + }) } for (const target of targets.filter((candidate) => candidate.visibility === "PRIVATE")) { runs.set(target.visibility, await adapters.hostedProof(target, target.candidateSha))