Compare commits

...
Author SHA1 Message Date
Abdul WahabandCursor c8f93c55db Fix: omit allowed-tools from Claude output only (#736)
Keep allowed-tools in shared source for other providers; drop it from the
Claude Code transformer and plugin rewrite so claude -p can activate the
skill. Verify gate now checks parsed frontmatter, not body text.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:11:50 +05:00
Abdul WahabandCursor 681569712f Fix: drop allowed-tools so skill activates in claude -p (#736)
Claude Code requires consent to activate skills that declare allowed-tools;
non-interactive hosts cannot provide it and the skill silently degrades.
Remove the frontmatter block from source and strip it in the plugin rewrite.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:07:27 +05:00
Paul BakausandGitHub 8dac6ae7e0 Verify signed skill bundles before extraction (#734)
* Verify signed skill bundles before extraction

Sign release ZIPs locally with an Ed25519 key from 1Password and pin the public trust root in the Rust installer. Reject unauthenticated downloads before extraction and preserve existing installs on failure. Document the signature-first rollout and explicit local trust paths.

AI-assisted implementation prepared by Codex at Paul Bakaus’s request.

* Fix signed-bundle review guardrails

Make keyring loading failures fatal before any download, accept standard release redirect statuses while retaining URL pinning, and require the signature sidecar before tagging. Add regressions for all three review findings.

AI-assisted changes prepared and tested by Codex at Paul Bakaus’s request.
2026-09-04 18:21:01 -07:00
Paul BakausandGitHub 46ffe5caa2 Docs: align contributor guidance with the Rust workspace (#732)
Point runtime contributions and rule changes at crates/, document testing a rebuilt local engine, and correct the published-engine CI gate guidance. No runtime or generated provider output changes.

AI assistance: prepared with Codex under maintainer pbakaus direction.
2026-09-04 14:53:52 -07:00
b077f6f0e4 Release: CLI 4.0.1 (#733)
The npm shim answers --version from its own package.json (#731).


Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-04 14:45:57 -07:00
641ff95502 CLI: --version reports the npm package version (#731)
* CLI: --version reports the npm package version

npx impeccable --version printed 3.6.0 after the 4.0.0 release because
the shim handed every argument to the engine, whose baked-in
CLI_VERSION still said 3.6.0. The shim now answers --version and -v
from its own package.json, as docs/CLI-CONTRACT.md specifies, without
locating or downloading a binary; a test covers both flags. The
engine's CLI_VERSION moves to 4.0.0 for the next engine release, with
the cli-version golden re-recorded and the delta noted.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* CLI shim: --version wins whenever it leads, trailing arguments or not

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-04 13:52:32 -07:00
27 changed files with 918 additions and 53 deletions
+15 -6
View File
@@ -2,11 +2,12 @@
## Project Structure & Module Organization
`skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. `skill/scripts/` holds the launcher (`impeccable`, `impeccable.cmd`), the pinned engine `VERSION`, `command-metadata.json`, and the in-page live-mode JS; every skill verb (`{{scripts_path}}/impeccable <verb>`) runs in the engine binary, which is built in a separate repo and pinned by the root `ENGINE_VERSION` file. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. `cli/` is the npm shim that runs the same binary, the browser extension lives in `extension/`, and regression coverage in `tests/` with fixtures under `tests/fixtures/` and the behavior goldens under `tests/oracle/`. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source.
`skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. `skill/scripts/` holds the launcher (`impeccable`, `impeccable.cmd`), the pinned engine `VERSION`, `command-metadata.json`, and the in-page live-mode JS. Every skill verb (`{{scripts_path}}/impeccable <verb>`) runs in the engine binary, built from this repo's Cargo workspace under `crates/`; the root `ENGINE_VERSION` pins the released binary used by installs. Read `docs/ENGINE.md` before changing runtime code. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. `cli/` is the npm shim that runs the same binary, the browser extension lives in `extension/`, and regression coverage lives in the Rust crates and `tests/`, including fixtures under `tests/fixtures/` and behavior goldens under `tests/oracle/`. The website and service live in the separate private `impeccable-site` repo. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source.
## Build, Test, and Development Commands
- `bun run dev` - start the local Bun server.
- `cargo build --release -p impeccable` - build this checkout's runtime into `target/release/impeccable`.
- `cargo test --workspace` - run the Rust workspace tests.
- `bun run build` - source-first build: regenerate `dist/`, derived site assets, and validation output without syncing tracked harness folders.
- `bun run build:release` - release/distribution build: run the full build and sync tracked root harness folders plus `plugin/`.
- `bun run rebuild` - clean and rebuild everything from scratch without syncing tracked harness folders.
@@ -25,7 +26,7 @@ Run `bun run build` after changing anything in `skill/`, transformer code, or us
The root harness folders (`.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, `.gemini/skills/`, `.github/skills/`, `.grok/skills/`, `.hermes/skills/`, `.kiro/skills/`, `.opencode/skills/`, `.pi/skills/`, `.qoder/skills/`, `.rovodev/skills/`, `.trae*/skills/`, `.vibe/skills/`) and `plugin/` stay tracked so `main` remains installable for direct GitHub, `npx skills`, and submodule users. They are still generated artifacts.
Normal development should be source-first: stage changes in `skill/`, `scripts/`, `cli/`, `site/`, `extension/`, `functions/`, and `tests/`; leave generated harness churn unstaged unless the user asked for it. After source changes land on `main`, `.github/workflows/sync-generated-output.yml` runs `bun run build:release` and commits generated provider output directly back to `main`. Treat generated harness diffs as release artifacts and keep them out of feature PRs unless they are the point of the PR.
Normal development should be source-first: stage changes in `crates/`, `browser-bundle/`, `skill/`, `scripts/`, `cli/`, `extension/`, and `tests/`; leave generated harness churn unstaged unless the user asked for it. After source changes land on `main`, `.github/workflows/sync-generated-output.yml` runs `bun run build:release` and commits generated provider output directly back to `main`. Treat generated harness diffs as release artifacts and keep them out of feature PRs unless they are the point of the PR. The two tracked engine assets under `crates/live/assets/` follow the rule-change workflow below instead.
## Sandbox gotchas for Codex agents
@@ -39,9 +40,13 @@ Some repo workflows need to run outside the sandbox in the desktop app:
Use ESM, semicolons, and the existing two-space indentation style in JS, HTML, and CSS. Prefer small, single-purpose modules over large abstractions. Keep filenames descriptive and lowercase with hyphens where needed; skill entrypoints stay as `SKILL.md`, build and test helpers use `.js` or `.mjs`. In source frontmatter, use clear kebab-case names and concise descriptions. There is no dedicated formatter or linter configured here, so match surrounding code closely.
For Rust, follow the surrounding crate's conventions and workspace formatting configuration. Keep changes scoped; do not reformat unrelated modules.
## Testing Guidelines
Tests use Buns test runner plus Nodes built-in `--test`. Name tests `*.test.js` or `*.test.mjs` and place new fixtures near the behavior they cover, usually under `tests/fixtures/`. Prefer targeted test runs while iterating, then finish with `bun run test`. If you change generated outputs or provider transforms, verify both source parsing and at least one affected provider path in `dist/`.
Tests use Bun's test runner plus Node's built-in `--test`. Name tests `*.test.js` or `*.test.mjs` and place new fixtures near the behavior they cover, usually under `tests/fixtures/`. Prefer targeted test runs while iterating, then finish with `bun run test`. If you change generated outputs or provider transforms, verify both source parsing and at least one affected provider path in `dist/`.
For runtime changes under `crates/`, add a failing regression in the affected crate, run its focused tests, then `cargo test --workspace`. Rebuild with `cargo build --release -p impeccable` and run `IMPECCABLE_BIN="$PWD/target/release/impeccable" bun run test` so the oracle exercises the changed source, not an older downloaded release. Review intended oracle changes by hand; never overwrite goldens just to make a regression pass. `tests/oracle/vectors/calls/` contains frozen function-level vectors and must not be regenerated.
For changes to the live-mode page JS (`skill/scripts/live-browser*.js`) or an `ENGINE_VERSION` bump, also run `bun run test:live-e2e` (kept out of the default suite because it does real `npm install` per fixture and boots framework dev servers). Scope to one fixture with `IMPECCABLE_E2E_ONLY=<fixture-name>` while iterating; pass `IMPECCABLE_E2E_DEBUG=1` for page-DOM and dev-server-log dumps on failure. Schema and authoring guide for new fixtures live in `tests/framework-fixtures/README.md`.
@@ -53,7 +58,11 @@ Other area-to-suite obligations (the canonical mapping is the `triggers` lists i
## Anti-pattern detection rules
The rule engine lives in the engine repo, not here. What this repo owns is the behavior contract: `docs/CLI-CONTRACT.md` describes every verb, `tests/oracle/` holds the recorded goldens and replays them against the binary (`tests/oracle.test.mjs`), and `tests/fixtures/antipatterns/*.html` are the fixtures those goldens scan. A rule change lands in the engine, then here as a new oracle case (`node tests/oracle/record.mjs --bin <prefix>`, golden reviewed by hand) and, when it introduces new design guidance, an edit to `skill/SKILL.src.md` or `skill/reference/*.md`. Rule counts quoted in `README.md` and `README.npm.md` are checked by the build against `crates/live/assets/antipatterns.json`, the tracked registry `cargo xtask bundle` writes (it falls back to the gitignored `extension/detector/antipatterns.json`).
The rule engine lives in this workspace. `crates/core` holds the checks and browser adapters; `crates/foundation` holds the registry and shared types. `crates/html`, `crates/browser`, and `crates/detect` provide the static HTML, URL, and CLI/text paths. `crates/wasm` compiles the shared rules for the extension, live overlay, and site. See `docs/ENGINE.md` for the crate map and bundle flow, and `docs/CLI-CONTRACT.md` for observable behavior.
Add a fixture first under `tests/fixtures/antipatterns/` with should-flag and should-pass columns, at least four flag cases and five false-positive shapes, unique headings, and explicit pixel dimensions. Add failing Rust coverage before implementing the rule. Cover each affected engine path and add or update an oracle case (`node tests/oracle/record.mjs --bin <prefix>`, golden reviewed by hand). When a rule introduces design guidance, update `skill/SKILL.src.md` or `skill/reference/*.md` too.
Run `cargo xtask bundle` after rule or browser-bundle changes and commit its two tracked outputs: `crates/live/assets/detect-antipatterns-browser.js` and `crates/live/assets/antipatterns.json`. The generated `extension/detector/` remains gitignored. Rebuild the native binary after bundling, run the Rust and Bun/Node checks above, and run `bun run build` to validate distribution and rule counts. Verify browser-facing changes on the relevant live fixture; native and browser adapters can disagree.
## Commit & Pull Request Guidelines
@@ -77,4 +86,4 @@ Tags are per-component because the three components ship independently: `skill-v
## Contributor Notes
Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `skill/`, `scripts/`, or `cli/` (or the engine repo for verb behavior), then regenerate artifacts for validation. Stage generated harness artifacts only for release/main-sync or build-system work.
Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `skill/`, `scripts/`, or `cli/`, and `crates/` for runtime behavior, then regenerate artifacts for validation. Stage generated harness artifacts only for release/main-sync or build-system work.
+4 -4
View File
@@ -160,7 +160,7 @@ bun run test:plugin-e2e # Just the plugin loader E2E (also part of the def
bun run test:cleanup # Kill live servers a previous run of THIS checkout left behind
```
Unit tests (build orchestration, transformers, validators) run via `bun test`. Everything that spawns the engine binary (`tests/oracle.test.mjs`, `tests/framework-fixtures.test.mjs`) runs via `node --test`; both skip cleanly when no binary is found (`bun run fetch:engine` or `IMPECCABLE_BIN`). The `test` script handles this split automatically. Verb behavior is not unit-tested here at all: the oracle goldens and the engine repo's own tests own it.
Unit tests (build orchestration, transformers, validators) run via `bun test`. Everything that spawns the engine binary (`tests/oracle.test.mjs`, `tests/framework-fixtures.test.mjs`) runs via `node --test`; both skip cleanly when no binary is found (`bun run fetch:engine` or `IMPECCABLE_BIN`). The `test` script handles this split automatically. Runtime unit and integration tests live under `crates/` and run with `cargo test --workspace`; the oracle goldens pin observable verb behavior across the same workspace.
### Live servers must not outlive their test process
@@ -190,7 +190,7 @@ The default suite does not cover everything. When a change touches one of these
| `ENGINE_VERSION` bump | `bun run test:new-work-e2e` | Playwright, offline, no API cost |
| `plugin/`, `skill/agents/`, `scripts/build.js`, plugin manifest validator | `bun run test:plugin-e2e` | ~1 s; already in the default suite, needs the `claude` CLI |
Verb-level behavior changes happen in the engine repo; the check they owe here is `bun run test` with a binary present (the oracle), and a new oracle case when the contract grows.
For verb-level behavior changes in `crates/`, run focused crate tests and `cargo test --workspace`, then `cargo build --release -p impeccable`. Run `IMPECCABLE_BIN="$PWD/target/release/impeccable" bun run test` to exercise the changed source rather than an older downloaded release. Add a new oracle case when the contract grows and review golden changes by hand. See `docs/ENGINE.md` for browser-bundle checks and generated assets.
**Plugin loader E2E** (`tests/plugin-e2e.test.mjs`, in the default suite): installs the committed `./plugin` subtree into a real Claude Code, sandboxed via `CLAUDE_CONFIG_DIR` in a temp dir, and asserts the component inventory from `claude plugin details`: the skill parses, every `plugin/agents/*.md` is visible, hooks are discovered. This is the only check that catches loader-contract surprises the unit guards can't know about (PR #494 shipped an `agents` manifest key that silently loaded zero agents; `claude plugin validate` never flags plugin-manifest problems). Runs in about a second; skips cleanly when the `claude` CLI is not on PATH. The known contract itself (allowed manifest keys, no `agents` key, trailing-slash `skills` path, source agents shipped) is pinned deterministically by `scripts/lib/validate-plugin-manifest.js`, unit-tested in `tests/validate-plugin-manifest.test.js` and enforced as a `bun run build` gate. Never add a key to the generated plugin manifest without verifying it against a real install and extending `KNOWN_LOADER_KEYS`.
@@ -255,7 +255,7 @@ npx impeccable install # install skills
npx impeccable --help # show help
```
The package no longer exports a JS detector API (`main` / `exports` are gone); the in-page bundle for the extension and site comes from the engine repo.
The package no longer exports a JS detector API (`main` / `exports` are gone); the in-page bundle for the extension and site is built from this workspace by `cargo xtask bundle`.
## Versioning
@@ -313,7 +313,7 @@ The skill launcher, the npm shim (`cli/bin/cli.js`), and `impeccable install` al
2. Publish the five `@impeccable/cli-<os>-<arch>@<ENGINE_VERSION>` npm platform packages.
3. Only then tag/publish the skill or CLI release, and only then merge a branch that bumps `ENGINE_VERSION` (the `sync-generated-output.yml` workflow rewrites provider dirs on merge to `main`).
`scripts/check-engine-release.mjs` verifies step 1 and 2 for the pinned version (ranged-GET each release asset, registry-probe each npm package; honors `IMPECCABLE_DOWNLOAD_BASE`). It exits non-zero and names exactly which assets are missing. `scripts/release.mjs` runs it as a hard gate before tagging the **skill** and **CLI** components and refuses to proceed when any asset is absent; the **extension** release is exempt because it ships a vendored WASM detector and never execs the engine. `IMPECCABLE_SKIP_ENGINE_CHECK=1` bypasses the gate only for the case where the assets exist but the registry probe is unreachable. CI's `engine-release-ready` job runs the same script; it is `continue-on-error: true` with a loud `::warning` until the first engine release is published, at which point flip it to `false` so a mis-ordered merge fails CI.
`scripts/check-engine-release.mjs` verifies step 1 and 2 for the pinned version (ranged-GET each release asset, registry-probe each npm package; honors `IMPECCABLE_DOWNLOAD_BASE`). It exits non-zero and names exactly which assets are missing. `scripts/release.mjs` runs it as a hard gate before tagging the **skill** and **CLI** components and refuses to proceed when any asset is absent; the **extension** release is exempt because it ships a vendored WASM detector and never execs the engine. `IMPECCABLE_SKIP_ENGINE_CHECK=1` bypasses the gate only for the case where the assets exist but the registry probe is unreachable. CI's `engine-release-ready` job runs the same script as a hard gate, so missing release assets fail CI.
## Adding New Commands
Generated
+2
View File
@@ -698,6 +698,8 @@ dependencies = [
"libc",
"once_cell",
"regex",
"ring",
"serde",
"serde_json",
"sha2",
"ureq",
+9 -1
View File
@@ -68,6 +68,14 @@ async function locate() {
return download().catch((err) => { process.stderr.write(`impeccable: ${err.message}\n`); return null; });
}
// `--version` / `-v` is answered by the shim itself: the number users mean
// is this npm package's version, not the engine's (docs/CLI-CONTRACT.md).
const argv = process.argv.slice(2);
if (argv[0] === '--version' || argv[0] === '-v') {
process.stdout.write(`${pkg.version}\n`);
process.exit(0);
}
const bin = await locate();
if (!bin) {
process.stderr.write(
@@ -76,7 +84,7 @@ if (!bin) {
);
process.exit(127);
}
const result = spawnSync(bin, process.argv.slice(2), {
const result = spawnSync(bin, argv, {
stdio: 'inherit',
env: { IMPECCABLE_SELF: 'npx impeccable', ...process.env },
});
+1 -1
View File
@@ -108,7 +108,7 @@ fn run(args: &[String], io: &mut Io) -> i32 {
/// The npm `impeccable` package version `cli.js --version` prints (its
/// `package.json`), tracked separately from the crate version.
pub const CLI_VERSION: &str = "3.6.0";
pub const CLI_VERSION: &str = "4.0.0";
/// The engines wired into `impeccable detect`: the static HTML engine
/// (crates/html). The browser engine (crates/browser) plugs in here once it
+2
View File
@@ -10,9 +10,11 @@ impeccable-common = { path = "../common" }
impeccable-context = { path = "../context" }
impeccable-detect = { path = "../detect" }
serde_json = { workspace = true }
serde = { workspace = true }
regex = { workspace = true }
once_cell = { workspace = true }
sha2 = "0.10"
ring = "0.17.14"
ureq = { version = "2", default-features = false, features = ["tls", "json"] }
url = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
+144 -2
View File
@@ -17,6 +17,7 @@ use crate::providers::{
opencode_global_config_dir, provider_display_name, Scope, Sys, API_BASE, PROVIDER_DIRS,
};
use crate::util::{self, jsp};
use crate::bundle_signature::{self, TrustedKeys, MAX_SIGNATURE_BYTES};
/// Ceiling on any single download this crate performs (triage C4). The
/// launcher-only universal bundle is under 25 MB (the Cloudflare Pages file
@@ -80,6 +81,7 @@ pub struct FetchResponse {
fn ureq_fetch(url: &str) -> Result<FetchResponse, String> {
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(30))
.timeout(std::time::Duration::from_secs(120))
.redirects(0)
.build();
match agent.get(url).call() {
@@ -293,13 +295,48 @@ pub fn download_and_extract_bundle(sys: &Sys) -> Result<String, String> {
if let Some(local) = sys.env.get("IMPECCABLE_BUNDLE_PATH").filter(|v| !v.is_empty()) {
return copy_or_extract_local_bundle(sys, local);
}
download_remote_bundle(sys, &mut ureq_fetch, bundle_signature::trusted_keys())
}
fn download_remote_bundle(
sys: &Sys,
fetch: &mut dyn FnMut(&str) -> Result<FetchResponse, String>,
keys: Result<TrustedKeys, String>,
) -> Result<String, String> {
keys.and_then(|keys| download_and_extract_signed_bundle(sys, fetch, &keys))
.map_err(|e| format!("{}{e}. Nothing was installed; retry or update the CLI. If this persists, report it at https://github.com/pbakaus/impeccable/issues/479", bundle_signature::ERROR_PREFIX))
}
fn download_and_extract_signed_bundle(
sys: &Sys,
fetch: &mut dyn FnMut(&str) -> Result<FetchResponse, String>,
keys: &TrustedKeys,
) -> Result<String, String> {
let tmp = util::tmpdir(&sys.env);
let staging = util::mkdtemp(&jsp::join(&[&tmp, "impeccable-update-"]))?;
let tmp_zip = jsp::join(&[&staging, "bundle.zip"]);
let tmp_signature = jsp::join(&[&staging, "bundle.sig.json"]);
let result = (|| -> Result<(), String> {
download_file(&format!("{API_BASE}/api/download/bundle/universal"), &tmp_zip)?;
extract_zip_file(&tmp_zip, &staging, &sys.cwd)?;
// Resolve once, then request both assets from that exact release. Never
// pair a latest-version lookup with a independently changing ZIP URL.
let response = fetch(&format!("{API_BASE}/api/download/bundle/universal"))?;
if !matches!(response.status, 301 | 302 | 303 | 307 | 308) {
return Err(format!("Expected a signed bundle release redirect (HTTP {})", response.status));
}
let location = response.location.ok_or("Missing bundle release redirect")?;
let version = bundle_signature::release_version(&location)?;
download_file_capped(&format!("{location}.sig.json"), &tmp_signature, fetch, MAX_SIGNATURE_BYTES)?;
download_file_with(&location, &tmp_zip, fetch)?;
let signature = std::fs::read(&tmp_signature).map_err(|e| e.to_string())?;
let file = std::fs::File::open(&tmp_zip).map_err(|e| e.to_string())?;
let mut reader = std::io::BufReader::new(file);
bundle_signature::verify_reader(&mut reader, &signature, &version, keys)?;
// Reuse the verified file handle rather than reopening by pathname.
use std::io::Seek;
reader.rewind().map_err(|e| e.to_string())?;
extract_zip_from(reader, &staging, &sys.cwd)?;
util::rm_rf(&tmp_zip);
util::rm_rf(&tmp_signature);
Ok(())
})();
match result {
@@ -931,4 +968,109 @@ mod tests {
assert_eq!(normalize_for_hash("x .claude/skills/y .trae-cn/skills/z .agent/skills/"), "x .PROVIDER/skills/y .PROVIDER/skills/z .PROVIDER/skills/");
assert_eq!(normalize_for_hash(".other/skills/"), ".other/skills/");
}
#[test]
fn keyring_load_failure_is_fatal_before_any_download() {
let sys = Sys::new(Default::default(), "/".into());
let mut fetch = |_: &str| -> Result<FetchResponse, String> {
panic!("A failed keyring must never reach the network");
};
let error = download_remote_bundle(&sys, &mut fetch, Err("Invalid compiled bundle signing keyring".into())).unwrap_err();
assert!(error.starts_with(bundle_signature::ERROR_PREFIX), "{error}");
assert!(error.contains("Invalid compiled bundle signing keyring"), "{error}");
}
#[test]
fn release_resolution_accepts_standard_redirects_only() {
for status in [200, 300, 301, 302, 303, 304, 305, 306, 307, 308, 404] {
let root = tmp_dir(&format!("redirect-{status}"));
let sys = Sys::new([("TMPDIR".into(), root.clone()), ("TEMP".into(), root.clone())].into(), root.clone());
let mut requests = 0;
let mut fetch = |_: &str| -> Result<FetchResponse, String> {
requests += 1;
if requests > 1 { return Err("reached signature download".into()); }
Ok(FetchResponse {
status,
location: Some("https://github.com/pbakaus/impeccable/releases/download/skill-v4.2.0/universal.zip".into()),
body: Box::new(std::io::empty()),
})
};
let error = download_and_extract_signed_bundle(&sys, &mut fetch, &Default::default()).unwrap_err();
if matches!(status, 301 | 302 | 303 | 307 | 308) {
assert_eq!(error, "reached signature download", "HTTP {status}");
assert_eq!(requests, 2);
} else {
assert!(error.contains("Expected a signed bundle release redirect"), "{error}");
assert_eq!(requests, 1);
}
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
util::rm_rf(&root);
}
}
#[test]
fn signed_download_verifies_before_extraction_and_cleans_all_failures() {
use ring::signature::{Ed25519KeyPair, KeyPair};
let key = Ed25519KeyPair::from_seed_unchecked(&[7; 32]).unwrap();
let hex = |bytes: &[u8]| bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
let keys = [("test-only".into(), hex(key.public_key().as_ref()))].into();
let zip = zip_bytes(&[(".claude/skills/impeccable/SKILL.md", b"verified skill")]);
let digest = format!("{:x}", Sha256::digest(&zip));
let payload = format!("impeccable-skill-bundle-v1\ntest-only\nskill-v4.2.0\nuniversal.zip\n{}\n{digest}\n", zip.len());
let signature = serde_json::to_vec(&serde_json::json!({
"schema": 1, "keyId": "test-only", "version": "4.2.0", "artifact": "universal.zip",
"size": zip.len(), "sha256": digest, "signature": hex(key.sign(payload.as_bytes()).as_ref()),
})).unwrap();
let release = "https://github.com/pbakaus/impeccable/releases/download/skill-v4.2.0/universal.zip";
for case in ["valid", "tampered", "missing", "oversized", "downgrade", "malformed-zip", "invalid-signature"] {
let root = tmp_dir(case);
let temp = format!("{root}/temp");
std::fs::create_dir(&temp).unwrap();
let installed = format!("{root}/existing-skill.md");
std::fs::write(&installed, "user's existing skill").unwrap();
let sys = Sys::new([("TMPDIR".into(), temp.clone()), ("TEMP".into(), temp.clone())].into(), root.clone());
let mut requested = Vec::new();
let mut fetch = |url: &str| -> Result<FetchResponse, String> {
requested.push(url.to_string());
let mut res = FetchResponse { status: 200, location: None, body: Box::new(std::io::Cursor::new(Vec::new())) };
if url.ends_with("/api/download/bundle/universal") {
res.status = 302;
res.location = Some(release.into());
} else if url == format!("{release}.sig.json") {
res.body = Box::new(std::io::Cursor::new(signature.clone()));
match case {
"missing" => res.status = 404,
"oversized" => res.body = Box::new(std::io::repeat(b' ')),
"downgrade" => { res.status = 302; res.location = Some("http://unsafe.test/sig".into()); }
"invalid-signature" => res.body = Box::new(std::io::Cursor::new(b"{}".to_vec())),
_ => {}
}
} else if url == release {
let mut bytes = zip.clone();
if case == "tampered" { bytes[0] ^= 1; }
if case == "malformed-zip" { bytes = b"not even a ZIP".to_vec(); }
res.body = Box::new(std::io::Cursor::new(bytes));
} else { panic!("Unexpected URL: {url}"); }
Ok(res)
};
let result = download_and_extract_signed_bundle(&sys, &mut fetch, &keys);
if case == "valid" {
let staging = result.unwrap();
assert_eq!(std::fs::read_to_string(format!("{staging}/.claude/skills/impeccable/SKILL.md")).unwrap(), "verified skill");
assert!(!util::exists(&format!("{staging}/bundle.zip")));
assert!(!util::exists(&format!("{staging}/bundle.sig.json")));
util::rm_rf(&staging);
} else {
let error = result.unwrap_err();
if case == "malformed-zip" {
assert!(error.contains("size"), "must reject before ZIP parsing: {error}");
}
}
assert_eq!(std::fs::read_to_string(&installed).unwrap(), "user's existing skill");
assert_eq!(std::fs::read_dir(&temp).unwrap().count(), 0, "staging leak in {case}");
assert_eq!(requested[0], format!("{API_BASE}/api/download/bundle/universal"));
assert_eq!(requested[1], format!("{release}.sig.json"));
util::rm_rf(&root);
}
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Authenticity gate for remote skill bundles. The only trust roots are the
//! public keys compiled into this binary, never anything in a download.
use once_cell::sync::Lazy;
use regex::Regex;
use ring::signature::{UnparsedPublicKey, ED25519};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, io::Read};
pub(crate) const MAX_SIGNATURE_BYTES: u64 = 16 * 1024;
pub(crate) const ERROR_PREFIX: &str = "Could not verify skill bundle: ";
pub(crate) type TrustedKeys = BTreeMap<String, String>;
static VERSION: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
).unwrap()
});
pub(crate) fn trusted_keys() -> Result<TrustedKeys, String> {
serde_json::from_str(include_str!("../../../scripts/bundle-signing-keys.json"))
.map_err(|_| "Invalid compiled bundle signing keyring".into())
}
pub(crate) fn release_version(location: &str) -> Result<String, String> {
let version = location
.strip_prefix("https://github.com/pbakaus/impeccable/releases/download/skill-v")
.and_then(|s| s.strip_suffix("/universal.zip"))
.filter(|v| v.len() <= 128 && VERSION.is_match(v));
version.map(str::to_string).ok_or_else(|| {
"Bundle download must redirect to a versioned Impeccable GitHub release".into()
})
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Envelope {
schema: u32,
key_id: String,
version: String,
artifact: String,
size: u64,
sha256: String,
signature: String,
}
fn decode_hex(value: &str, size: usize) -> Result<Vec<u8>, String> {
if value.len() != size * 2
|| !value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err("Invalid bundle signature encoding".into());
}
(0..value.len())
.step_by(2)
.map(|i| u8::from_str_radix(&value[i..i + 2], 16).map_err(|_| "Invalid hex".into()))
.collect()
}
pub(crate) fn verify_reader(
reader: &mut dyn Read,
signature: &[u8],
version: &str,
keys: &TrustedKeys,
) -> Result<(), String> {
if signature.len() as u64 > MAX_SIGNATURE_BYTES {
return Err("Bundle signature is too large".into());
}
let envelope: Envelope = serde_json::from_slice(signature)
.map_err(|_| "Missing or malformed bundle signature".to_string())?;
if envelope.schema != 1
|| envelope.version != version
|| !VERSION.is_match(version)
|| envelope.artifact != "universal.zip"
|| envelope.size == 0
|| envelope.size > crate::bundle::MAX_DOWNLOAD_BYTES
{
return Err("Bundle signature metadata does not match the requested release".into());
}
let public_key = keys
.get(&envelope.key_id)
.ok_or("Unknown bundle signing key; update the Impeccable CLI and retry")?;
let public_key = decode_hex(public_key, 32)?;
let signature = decode_hex(&envelope.signature, 64)?;
decode_hex(&envelope.sha256, 32)?;
let payload = format!(
"impeccable-skill-bundle-v1\n{}\nskill-v{}\n{}\n{}\n{}\n",
envelope.key_id, envelope.version, envelope.artifact, envelope.size, envelope.sha256
);
UnparsedPublicKey::new(&ED25519, public_key)
.verify(payload.as_bytes(), &signature)
.map_err(|_| "Bundle signature verification failed".to_string())?;
let mut hash = Sha256::new();
let mut size = 0u64;
let mut buffer = [0u8; 64 * 1024];
loop {
let count = reader.read(&mut buffer).map_err(|e| e.to_string())?;
if count == 0 {
break;
}
size += count as u64;
if size > envelope.size {
return Err("Bundle size does not match its signature".into());
}
hash.update(&buffer[..count]);
}
if size != envelope.size || format!("{:x}", hash.finalize()) != envelope.sha256 {
return Err("Bundle digest or size does not match its signature".into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ring::signature::{Ed25519KeyPair, KeyPair};
#[test]
#[ignore = "Set IMPECCABLE_VERIFY_BUNDLE and IMPECCABLE_VERIFY_BUNDLE_VERSION to a reviewed release ZIP"]
fn verifies_reviewed_release_with_production_keyring() {
let path = std::env::var("IMPECCABLE_VERIFY_BUNDLE").unwrap();
let version = std::env::var("IMPECCABLE_VERIFY_BUNDLE_VERSION").unwrap();
let signature = std::fs::read(format!("{path}.sig.json")).unwrap();
let mut file = std::fs::File::open(path).unwrap();
verify_reader(&mut file, &signature, &version, &trusted_keys().unwrap()).unwrap();
}
#[test]
fn verifies_node_interoperability_vector() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../tests/fixtures/bundle-signature.json"
))
.unwrap();
let bundle = fixture["bundle"].as_str().unwrap().as_bytes();
let envelope = serde_json::to_vec(&fixture["envelope"]).unwrap();
let keys = serde_json::from_value(fixture["keys"].clone()).unwrap();
verify_reader(&mut &bundle[..], &envelope, "4.2.0", &keys).unwrap();
}
fn fixture() -> (Vec<u8>, Vec<u8>, std::collections::BTreeMap<String, String>) {
// A public, deterministic TEST key. Never present in the production keyring.
let key = Ed25519KeyPair::from_seed_unchecked(&[7; 32]).unwrap();
let bundle = b"test bundle".to_vec();
let digest = format!("{:x}", Sha256::digest(&bundle));
let payload = format!(
"impeccable-skill-bundle-v1\ntest-only\nskill-v4.2.0\nuniversal.zip\n11\n{digest}\n"
);
let hex = |bytes: &[u8]| bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
let envelope = serde_json::json!({
"schema": 1, "keyId": "test-only", "version": "4.2.0",
"artifact": "universal.zip", "size": 11, "sha256": digest,
"signature": hex(key.sign(payload.as_bytes()).as_ref()),
});
(
bundle,
serde_json::to_vec(&envelope).unwrap(),
[("test-only".into(), hex(key.public_key().as_ref()))].into(),
)
}
#[test]
fn accepts_signed_bytes_and_rejects_tampering() {
let (bundle, envelope, keys) = fixture();
verify_reader(&mut &bundle[..], &envelope, "4.2.0", &keys).unwrap();
for tampered in [b"Test bundle".as_slice(), b"test bundle extra", b"test"] {
assert!(verify_reader(&mut &tampered[..], &envelope, "4.2.0", &keys).is_err());
}
assert!(verify_reader(&mut &bundle[..], &envelope, "4.2.1", &keys).is_err());
assert!(verify_reader(&mut &bundle[..], &envelope, "4.2.0", &Default::default()).is_err());
}
#[test]
fn rejects_changed_metadata_bad_encodings_and_unsigned_bundles() {
let (bundle, envelope, keys) = fixture();
for (field, value) in [
("schema", serde_json::json!(2)),
("keyId", serde_json::json!("attacker")),
("version", serde_json::json!("4.2.1")),
("artifact", serde_json::json!("other.zip")),
("size", serde_json::json!(10)),
("sha256", serde_json::json!("0".repeat(64))),
("signature", serde_json::json!("0".repeat(128))),
("signature", serde_json::json!("ff")),
("sha256", serde_json::json!("g".repeat(64))),
(
"publicKey",
serde_json::json!("never trust an embedded key"),
),
] {
let mut bad: serde_json::Value = serde_json::from_slice(&envelope).unwrap();
bad[field] = value;
assert!(
verify_reader(
&mut &bundle[..],
&serde_json::to_vec(&bad).unwrap(),
"4.2.0",
&keys
)
.is_err(),
"{field}"
);
}
for malformed in [b"".as_slice(), b"{}", b"not json"] {
assert!(verify_reader(&mut &bundle[..], malformed, "4.2.0", &keys).is_err());
}
let duplicate = String::from_utf8(envelope)
.unwrap()
.replacen('{', "{\"schema\":1,", 1);
assert!(verify_reader(&mut &bundle[..], duplicate.as_bytes(), "4.2.0", &keys).is_err());
}
#[test]
fn release_location_is_exact_and_versioned() {
let prefix = "https://github.com/pbakaus/impeccable/releases/download/";
assert_eq!(
release_version(&format!("{prefix}skill-v4.2.0/universal.zip")).unwrap(),
"4.2.0"
);
for bad in [
format!("{prefix}skill-v4.2.0/other.zip"),
format!("{prefix}skill-v4.2.0/universal.zip?key=x"),
format!("{prefix}skill-v4.2.0/universal.zip#x"),
format!("{prefix}skill-v04.2.0/universal.zip"),
"http://github.com/pbakaus/impeccable/releases/download/skill-v4.2.0/universal.zip".into(),
"https://github.com/attacker/impeccable/releases/download/skill-v4.2.0/universal.zip".into(),
"https://github.com.evil.test/pbakaus/impeccable/releases/download/skill-v4.2.0/universal.zip".into(),
] {
assert!(release_version(&bad).is_err(), "{bad}");
}
}
}
+2 -1
View File
@@ -588,7 +588,8 @@ fn install(flags: &[String], io: &mut Io) -> R<()> {
match bundle::download_and_extract_bundle(&sys) {
Ok(dir) => bundle_dir = Some(dir),
Err(e) => {
if !missing_hook_targets.is_empty() || !missing_selected_targets.is_empty() {
if e.starts_with(crate::bundle_signature::ERROR_PREFIX)
|| !missing_hook_targets.is_empty() || !missing_selected_targets.is_empty() {
return Err(e);
}
update_check_skipped = true;
+6 -2
View File
@@ -3,8 +3,7 @@
//! `cli/bin/commands/skills.mjs` (plus the slice of `cli/lib/impeccable-config.mjs`
//! it imports).
//!
//! Two deliberate departures from the JS, both part of the release that
//! replaces the Node scripts with the binary:
//! Deliberate departures from the original JS behavior:
//!
//! 1. After a skill directory is written (fresh install, refresh, update), if
//! its `scripts/VERSION` exists and `scripts/bin/<os>-<arch>/impeccable`
@@ -19,11 +18,16 @@
//! (`impeccable_hook::admin`) writes, and both paths recognize a manifest
//! entry as ours through `impeccable_context::hook_markers`, so the two
//! never drift on detection. See `hook_manifest`.
//! 3. Remote skill ZIPs require an Ed25519 signature from a compiled-in key
//! before extraction. Failure is fatal even when an existing install is
//! present. Explicit local bundle overrides remain unsigned development
//! inputs. See `bundle_signature` and docs/BUNDLE-SIGNING.md.
//!
//! Everything else (messages, exit codes, endpoints, flags, prompts, file
//! layout) follows the JS byte for byte.
pub mod bundle;
mod bundle_signature;
pub mod commands;
pub mod engine_binary;
pub mod hook_manifest;
+117
View File
@@ -0,0 +1,117 @@
# Skill bundle signatures
`impeccable install`, `update`, and `check` authenticate a remote skill ZIP
before extracting it. `universal.zip.sig.json` is an Ed25519 signature over
the ZIP's SHA-256 digest, byte length, release version, artifact name, and key
ID. The engine trusts only `scripts/bundle-signing-keys.json`, compiled into
the binary. A signature cannot introduce a new trusted key.
The download endpoint on impeccable.style redirects to a versioned GitHub
release. The installer resolves that redirect once and downloads the ZIP and
its signature from that same release. Every subsequent redirect must use
HTTPS. Missing signatures, unknown keys, changed metadata, and changed ZIP
bytes stop the operation before extraction or writes to installed skills.
The temporary download directory is removed on failure.
## Sign a release
Install the 1Password CLI and enable its desktop app integration. The signing
item holds the PKCS#8 Ed25519 private key in a concealed `private-key` field.
Set references, not key material, in your shell:
```sh
export OP_ACCOUNT='<account ID or sign-in address>'
export IMPECCABLE_SIGNING_KEY_REF='op://<vault ID>/<item ID>/private-key'
bun run release:skill
```
For a persistent setup on your machine, use local Git settings instead:
```sh
git config --local impeccable.signingAccount '<account ID or sign-in address>'
git config --local impeccable.signingKeyRef 'op://<vault ID>/<item ID>/private-key'
```
Those values stay in `.git/config`, outside version control. Environment
variables take precedence. Neither setting contains the private key.
The release command rebuilds the ZIP, reads the key through `op read`, checks
that its public key is trusted, and writes the sidecar before creating any
tag or release. The ZIP and sidecar are uploaded together. The key is never
passed as a command argument, written to a temporary file, or printed. It
does exist briefly in the local signing process's memory. 1Password failures
are reported without forwarding child-process output.
`--dry-run` does not access 1Password or create a signature. It checks the
usual release prerequisites and shows both assets in the upload plan; it
does not prove that signing credentials work.
To sign an already-published release for the initial rollout, download and
review the exact released `universal.zip`, then run:
```sh
node scripts/sign-bundle.mjs 4.2.0 /path/to/universal.zip
```
Check the resulting sidecar against the Rust verifier and compiled public key:
```sh
IMPECCABLE_VERIFY_BUNDLE=/path/to/universal.zip \
IMPECCABLE_VERIFY_BUNDLE_VERSION=4.2.0 \
cargo test -p impeccable-skills verifies_reviewed_release_with_production_keyring -- --ignored
```
This creates only the local sidecar. It neither uploads it nor replaces the
ZIP. Never regenerate an old ZIP and sign those different bytes as the old
release. Uploading the sidecar is a separate maintainer approval step.
## Rollout and rotation
Before shipping the enforcing engine, publish a valid signature beside the
exact ZIP currently served by impeccable.style. Verify the pair using a
locally built engine, then release the engine, its npm platform packages, and
the CLI/skill pins. Keep the existing release available throughout. Do not
release an enforcing engine with an empty keyring or an unsigned served ZIP.
For planned rotation, ship an engine trusting both the old and new public
keys before signing with the new key. Older engines that do not know the new
key will refuse the download and ask for a CLI update. A compromised key
requires an engine update removing that public key; removing it from a
website does not revoke trust in already-installed binaries. Keep the
dedicated signing item separate from GitHub and deployment credentials.
## Scope
This protects against bundle substitution when an attacker can change the
download endpoint, release asset, or both, but cannot use the signing key or
replace the trusted engine. It is not a freshness protocol: a previously
signed release can still be replayed. Signed timestamp metadata and rollback
state are separate work. Signatures do not establish that authored skill
content is safe, and do not authenticate separately downloaded engine
binaries (those currently use their existing SHA-256 sidecars).
`IMPECCABLE_BUNDLE_PATH` and `impeccable link` are explicit local-development
trust paths. They continue to accept unsigned local files/directories. Do not
use those overrides to get around a failed remote verification. There is no
unsigned-network fallback or skip-signature flag.
## Wire format
JSON sidecar fields: `schema` (1), `keyId`, `version`, `artifact`
(`universal.zip`), `size`, `sha256`, `signature`. Hex strings are lowercase;
the public key is 32 bytes and the signature is 64 bytes. Unknown or repeated
fields are rejected. The signature payload is UTF-8 with LF line endings
and a final LF:
```text
impeccable-skill-bundle-v1
<keyId>
skill-v<version>
universal.zip
<size as decimal>
<sha256 as lowercase hex>
```
The Node signer and Rust verifier share a fixed test vector under
`tests/fixtures/bundle-signature.json`. Its deterministic test key must never
be added to the production keyring.
+10
View File
@@ -342,6 +342,16 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
#### `impeccable help|install|link|update|check` and `impeccable skills <verb>` (`cli/bin/commands/skills.mjs`)
**Rust authenticity addition (#479):** the historical JS bundle flow below
is superseded for remote downloads. The Rust installer resolves the site's
redirect (301/302/303/307/308) to a versioned Impeccable GitHub release and verifies
`universal.zip.sig.json` against a compiled-in Ed25519 public key before ZIP
extraction. Missing/invalid signatures, unknown keys, mismatched metadata or
content, and failures fetching either asset exit nonzero, including when
`install` finds an existing installation. No downloaded content reaches the
installed skill or hook files. Explicit `IMPECCABLE_BUNDLE_PATH` and `link`
retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNING.md).
- **Invoked from**: README.md ("npx impeccable install / update"), README.npm.md Quick Start (`npx impeccable skills install`, `... install -y --providers=claude,codex --scope=project`, `... update`, `... install --no-hooks`, `... link --source=.impeccable --providers=claude,cursor`, `... skills help`), `README.md:360` (hook consent explanation).
- `run(args)`: `args[0]``undefined|help|--help|-h``showHelp()`; `install``install(rest)`; `link`; `update`; `check` (ignores flags); else `stderr> Unknown skills command: ${sub}` + `Run 'impeccable --help' for available commands.`, `exit 1`.
- Constants: `API_BASE = 'https://impeccable.style'`; `PROVIDER_DIRS = ['.claude','.cursor','.gemini','.agents','.agent','.github','.grok','.hermes','.kiro','.opencode','.pi','.qoder','.trae','.trae-cn','.rovodev','.vibe']`; aliases (`agent``.agent`, `agents`/`codex``.agents`, `antigravity``.agent`, `claude`/`claude-code``.claude`, `copilot`/`github``.github`, `cursor`, `gemini`, `grok`/`grok-build`/`xai``.grok`, `hermes`, `kiro`, `opencode`, `pi`, `qoder`, `rovo-dev`/`rovodev``.rovodev`, `trae`, `trae-cn`, `vibe`); leading `.` stripped and lowercased before alias lookup; a literal PROVIDER_DIR value is accepted as-is. `DEFAULT_TARGETS = ['.claude','.agents']`. User-scope skill dir overrides: `.agent``~/.gemini/config/skills`, `.hermes``$HERMES_HOME/skills` (only when HERMES_HOME under home) else `~/.hermes/skills`, `.pi``~/.pi/agent/skills`, `.opencode``$OPENCODE_CONFIG_DIR|$XDG_CONFIG_HOME/opencode|~/.config/opencode` + `/skills`; others `~/<provider>/skills`. Project scope: `<root>/<provider>/skills`.
+4
View File
@@ -232,6 +232,10 @@ this feature, replacing the `detectText` call it makes into the npm
## Releases
Remote skill ZIPs require a pinned-key signature before extraction. See
[bundle signing](BUNDLE-SIGNING.md) for the 1Password setup and the required
signature-first rollout order.
Two release kinds touch the runtime, in this order:
1. **Engine** (`engine-v<ENGINE_VERSION>`): `bun run release:engine` verifies
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impeccable",
"version": "4.0.0",
"version": "4.0.1",
"author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [
+3
View File
@@ -0,0 +1,3 @@
{
"release-2026-09": "7433133bb92da2c0da4186925f36dbd36219c11192451df56f25fd6a23dd7db9"
}
+21 -12
View File
@@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import { generateYamlFrontmatter, parseFrontmatter } from './utils.js';
/**
* Rewrite project-relative script paths for the plugin subtree (issue #523).
@@ -26,15 +27,19 @@ export const CLAUDE_PROJECT_SCRIPTS_PATH = '.claude/skills/impeccable/scripts';
export const PLUGIN_SCRIPTS_PATH = '<skill-base-dir>/scripts';
// The project-path rule pre-approves a path inside the user's project, the
// one place the plugin must NOT run scripts from. No replacement rule
// exists: a wildcard pattern such as `node */skills/impeccable/scripts/*`
// auto-approves any same-shaped path anywhere on disk, and frontmatter has
// no variable bound to the loaded plugin root (CLAUDE_PLUGIN_ROOT is
// hook-only). The plugin copy drops the rule and script runs go through
// the normal Bash confirmation.
// Claude Code requires user consent to activate a skill whose frontmatter
// declares allowed-tools; non-interactive hosts (`claude -p`) cannot provide
// it and the skill silently degrades (issue #736). The plugin copy drops the
// key via structured frontmatter rewrite, not regex.
export const PROJECT_ALLOWED_TOOLS_LINE = ` - Bash(${CLAUDE_PROJECT_SCRIPTS_PATH}/impeccable *)\n`;
function stripAllowedToolsFrontmatter(content) {
const { frontmatter, body } = parseFrontmatter(content);
if (frontmatter['allowed-tools'] === undefined) return content;
delete frontmatter['allowed-tools'];
return `${generateYamlFrontmatter(frontmatter)}\n${body}`;
}
// Setup step 1's second sentence names the project path as the fallback
// when the runtime reports no base directory. A plugin install has no
// working project fallback (that path is the bug this rewrite exists to
@@ -72,10 +77,7 @@ export const AGENT_EMBED_FALLBACK =
* unit suite can pin every rewrite without a build.
*/
export function rewritePluginMarkdown(content) {
return content
// Order matters: the allowed-tools line contains the project path, so
// remove it before the generic path replacement rewrites it into a
// line the removal no longer matches.
return stripAllowedToolsFrontmatter(content)
.replaceAll(PROJECT_ALLOWED_TOOLS_LINE, '')
.replaceAll(SETUP_FALLBACK_TEXT, SETUP_PLUGIN_TEXT)
.replaceAll(CLAUDE_PROJECT_SCRIPTS_PATH, PLUGIN_SCRIPTS_PATH)
@@ -154,10 +156,17 @@ export function verifyPluginSkillRewrite(skillMdPath) {
'scripts/lib/plugin-paths.js (issue #523); update SETUP_FALLBACK_TEXT to the new wording.',
);
}
if (parseFrontmatter(content).frontmatter['allowed-tools'] !== undefined) {
throw new Error(
`Plugin rewrite drift: ${skillMdPath} still declares allowed-tools in frontmatter. ` +
'The plugin copy must drop the entire block so non-interactive sessions can activate ' +
'the skill (issue #736); update the removal in scripts/lib/plugin-paths.js.',
);
}
if (/Bash\((?:node |[^)]*scripts\/impeccable)/.test(content)) {
throw new Error(
`Plugin rewrite drift: ${skillMdPath} still pre-approves an engine launcher or node script path. ` +
"SKILL.src.md's allowed-tools entry no longer matches the removal in " +
'A stray Bash(...) entry survived the allowed-tools removal in ' +
'scripts/lib/plugin-paths.js (issue #523); the plugin ships no launcher pre-approval.',
);
}
+3 -1
View File
@@ -29,7 +29,9 @@ export const PROVIDERS = {
providerTags: ['claude-code', 'claude'],
configDir: '.claude',
displayName: 'Claude Code',
frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata', 'allowed-tools'],
frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata'],
// allowed-tools omitted: Claude Code blocks skill activation in non-interactive
// sessions when the field is present (issue #736). Other providers keep it.
agentFormat: 'claude-md',
emitHooks: 'claude',
// Project-local Claude Code hooks live in `.claude/settings.json`.
+20
View File
@@ -19,6 +19,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkEngineRelease } from './check-engine-release.mjs';
import { readEngineVersion } from './fetch-engine.mjs';
import { signReleaseBundle } from './sign-bundle.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -255,6 +256,25 @@ for (const artifact of cfg.artifacts) {
ok(artifact);
}
// Sign the final rebuilt bytes before any tag or upload. Dry runs do not
// unlock 1Password or write a signature; they only show the publishing plan.
if (component === 'skill') {
const signatureArtifact = 'dist/universal.zip.sig.json';
step('Signing universal.zip with the trusted 1Password release key');
if (dryRun) {
console.log(' [dry-run] Sign dist/universal.zip (1Password is not accessed)');
} else {
try {
signReleaseBundle({ zipPath: path.join(repoRoot, 'dist/universal.zip'), version });
} catch (error) {
fail(error.message);
}
if (!existsSync(path.join(repoRoot, signatureArtifact))) fail(`Missing artifact: ${signatureArtifact}`);
ok('signature verified locally');
}
cfg.artifacts.push(signatureArtifact);
}
console.log('\n--- Release notes preview ---');
console.log(notes);
console.log('--- end preview ---\n');
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
// Local release signing. Private key material travels from op through a pipe
// into crypto, never through argv, environment values, logs or temporary files.
import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
export const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const MAX_BUNDLE_BYTES = 256 * 1024 * 1024;
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
function localSetting(name) {
try {
return execFileSync('git', ['config', '--local', '--get', name], {
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch { return undefined; }
}
export function publicKeyHex(key) {
if (key.asymmetricKeyType !== 'ed25519') throw new Error('Signing requires an Ed25519 key.');
const jwk = key.export({ format: 'jwk' });
return Buffer.from(jwk.x, 'base64url').toString('hex');
}
// Shared wire format with crates/skills/src/bundle_signature.rs. UTF-8, LF,
// trailing LF. Sign fields explicitly so JSON whitespace/order is irrelevant.
export function signaturePayload({ keyId, version, artifact, size, sha256 }) {
return Buffer.from(`impeccable-skill-bundle-v1\n${keyId}\nskill-v${version}\n${artifact}\n${size}\n${sha256}\n`);
}
export function readTrustedKeys() {
return JSON.parse(readFileSync(new URL('./bundle-signing-keys.json', import.meta.url), 'utf8'));
}
export function signBundle(bytes, version, privateKey, trustedKeys) {
if (typeof version !== 'string' || version.length > 128 || !VERSION_PATTERN.test(version)) throw new Error('Invalid skill version for signing.');
if (!bytes.length || bytes.length > MAX_BUNDLE_BYTES) throw new Error('Invalid bundle size for signing.');
const publicKey = createPublicKey(privateKey);
const publicHex = publicKeyHex(publicKey);
const keyId = Object.keys(trustedKeys).find(id => trustedKeys[id] === publicHex);
if (!keyId || !/^[a-z0-9-]{1,64}$/.test(keyId)) {
throw new Error('The signing key is not in the trusted bundle keyring.');
}
const envelope = {
schema: 1, keyId, version, artifact: 'universal.zip', size: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex'),
};
const payload = signaturePayload(envelope);
const signature = sign(null, payload, privateKey);
if (!verify(null, payload, publicKey, signature)) throw new Error('Signature self-check failed.');
return { ...envelope, signature: signature.toString('hex') };
}
function readFrom1Password(reference, account) {
return execFileSync('op', ['read', reference, '--no-newline', ...(account ? ['--account', account] : [])], {
stdio: ['ignore', 'pipe', 'pipe'], timeout: 120000, maxBuffer: 16384,
});
}
export function signReleaseBundle({ zipPath, version, trustedKeys = readTrustedKeys(),
secretReference = process.env.IMPECCABLE_SIGNING_KEY_REF ?? localSetting('impeccable.signingKeyRef'),
account = process.env.OP_ACCOUNT ?? localSetting('impeccable.signingAccount'), readSecret = readFrom1Password }) {
if (!secretReference?.startsWith('op://')) {
throw new Error('Set IMPECCABLE_SIGNING_KEY_REF to the 1Password private-key reference (op://vault/item/field).');
}
if (typeof version !== 'string' || version.length > 128 || !VERSION_PATTERN.test(version)) throw new Error('Invalid skill version for signing.');
if (statSync(zipPath).size > MAX_BUNDLE_BYTES) throw new Error('Invalid bundle size for signing.');
const bytes = readFileSync(zipPath);
let pem;
try {
const secret = readSecret(secretReference, account);
pem = Buffer.isBuffer(secret) ? secret : Buffer.from(secret);
} catch {
// Child-process exceptions can contain stdout/stderr. Never propagate them.
throw new Error('Could not read the signing key from 1Password. Check CLI integration and unlock the vault.');
}
let privateKey;
try {
privateKey = createPrivateKey(pem);
} catch {
throw new Error('The 1Password field is not a valid PKCS#8 private key.');
} finally {
pem.fill(0);
}
const envelope = signBundle(bytes, version, privateKey, trustedKeys);
const output = `${zipPath}.sig.json`;
writeFileSync(output, `${JSON.stringify(envelope, null, 2)}\n`);
return output;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const [version, zipPath, ...extra] = process.argv.slice(2);
if (!zipPath || extra.length || path.basename(zipPath) !== 'universal.zip') {
throw new Error('Usage: node scripts/sign-bundle.mjs <skill-version> <path/to/universal.zip>');
}
console.log(`Signed ${signReleaseBundle({ zipPath, version })}`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+1
View File
@@ -70,6 +70,7 @@ export const SUITES = {
'tests/openai-plugin.test.mjs',
'tests/process-group.test.mjs',
'tests/release.test.mjs',
'tests/bundle-signing.test.mjs',
'tests/skill-reference.test.mjs',
'tests/readme-gitignore.test.mjs',
'tests/test-suites.test.mjs',
+85
View File
@@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync, createPrivateKey, createPublicKey, verify } from 'node:crypto';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { signBundle, signaturePayload, publicKeyHex, signReleaseBundle, readTrustedKeys } from '../scripts/sign-bundle.mjs';
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const trustedKeys = { 'test-only': publicKeyHex(publicKey) };
test('production keyring is populated and excludes the public test key', () => {
const keys = readTrustedKeys();
const fixture = JSON.parse(readFileSync(new URL('./fixtures/bundle-signature.json', import.meta.url)));
assert.ok(Object.keys(keys).length > 0);
for (const [id, key] of Object.entries(keys)) {
assert.match(id, /^[a-z0-9-]{1,64}$/);
assert.match(key, /^[0-9a-f]{64}$/);
assert.notEqual(key, fixture.keys['test-only']);
}
});
test('matches the shared Node/Rust interoperability vector (public test seed)', () => {
const fixture = JSON.parse(readFileSync(new URL('./fixtures/bundle-signature.json', import.meta.url)));
const key = createPrivateKey({
key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), Buffer.alloc(32, 7)]),
format: 'der', type: 'pkcs8',
});
assert.deepEqual(signBundle(Buffer.from(fixture.bundle), '4.2.0', key, fixture.keys), fixture.envelope);
});
test('signs the exact bundle with version, size, digest, artifact and domain bound', () => {
const bundle = Buffer.from('test bundle');
const envelope = signBundle(bundle, '4.2.0', privateKey, trustedKeys);
assert.equal(envelope.keyId, 'test-only');
assert.equal(envelope.version, '4.2.0');
assert.equal(envelope.size, bundle.length);
assert.equal(envelope.artifact, 'universal.zip');
assert.equal(envelope.schema, 1);
assert.match(signaturePayload(envelope).toString(), /^impeccable-skill-bundle-v1\n/);
assert.ok(verify(null, signaturePayload(envelope), publicKey, Buffer.from(envelope.signature, 'hex')));
for (const changed of [
{ version: '4.2.1' }, { size: 1 }, { sha256: '0'.repeat(64) },
{ artifact: 'other.zip' }, { keyId: 'other-key' },
]) {
assert.equal(verify(null, signaturePayload({ ...envelope, ...changed }), publicKey,
Buffer.from(envelope.signature, 'hex')), false);
}
});
test('rejects unknown or non-Ed25519 keys and invalid versions', () => {
assert.throws(() => signBundle(Buffer.from('zip'), '4.2.0', privateKey, {}), /trusted/);
for (const version of ['4.2.0\nother', '../4.2.0', '', '04.2.0', '4.2']) {
assert.throws(() => signBundle(Buffer.from('zip'), version, privateKey, trustedKeys), /version/);
}
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 });
assert.throws(() => publicKeyHex(rsa.publicKey), /Ed25519/);
});
test('1Password read uses a pipe, checks the pinned key, writes only a public signature', () => {
const root = mkdtempSync(path.join(tmpdir(), 'impeccable-sign-test-'));
try {
const zipPath = path.join(root, 'universal.zip');
writeFileSync(zipPath, 'test bundle');
const secretReference = 'op://test-vault/test-item/private-key';
let called = false;
signReleaseBundle({ zipPath, version: '4.2.0', trustedKeys, secretReference,
readSecret(reference) {
called = true;
assert.equal(reference, secretReference);
return privateKey.export({ type: 'pkcs8', format: 'pem' });
},
});
assert.ok(called);
const envelope = JSON.parse(readFileSync(`${zipPath}.sig.json`, 'utf8'));
assert.ok(verify(null, signaturePayload(envelope), createPublicKey(privateKey),
Buffer.from(envelope.signature, 'hex')));
assert.doesNotMatch(readFileSync(`${zipPath}.sig.json`, 'utf8'), /PRIVATE KEY/);
assert.throws(() => signReleaseBundle({ zipPath, version: '4.2.0', trustedKeys,
secretReference, readSecret() { throw new Error('SECRET that must not leak'); },
}), /Could not read.*1Password/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+11
View File
@@ -174,6 +174,17 @@ describe('npm shim download verification', { skip: process.platform === 'win32'
assert.deepEqual(cacheEntries(res.home), []);
});
it('answers --version and -v from its own package.json without touching a binary', async () => {
const expected = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8')).version;
for (const args of [['--version'], ['-v'], ['--version', 'extra']]) {
const flag = args.join(' ');
const res = await runShim(args);
assert.equal(res.status, 0, `${flag} exits 0`);
assert.equal(res.stdout, `${expected}\n`);
assert.deepEqual(requests, [], 'no download was attempted');
}
});
it('prefers IMPECCABLE_BIN and never downloads', async () => {
sidecar = { status: 404, body: '' };
const { dir, shim } = stageShim();
+15
View File
@@ -0,0 +1,15 @@
{
"bundle": "test bundle",
"keys": {
"test-only": "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c"
},
"envelope": {
"schema": 1,
"keyId": "test-only",
"version": "4.2.0",
"artifact": "universal.zip",
"size": 11,
"sha256": "9df2a47bee5f48b9752b2cbd2d6075076556ee293adc97f66f0e0a916e4f6471",
"signature": "35dfe147573341b1fdcb4bc4054b0e96c5d07b9069bd1257f745d6ad6c3eca72f52876e7d0403cffeac2e60ef2dea41879ca4cb9cecaebb463332aaf9d15620b"
}
}
+9
View File
@@ -155,3 +155,12 @@ file, the file set, or the printed lines differs from the JS.
- `pin-opencode-project`, `pin-opencode-user-scope`, `pin-opencode-skips-foreign-command`, `pin-opencode-then-unpin`, `pin-opencode-unpin-skips-foreign`.
## Recorded 2026-09-04: `--version` follows the npm package to 4.0.0
The npm shim answers `--version` / `-v` itself from its own `package.json`
(docs/CLI-CONTRACT.md), so the number users see tracks the package they
installed. The binary's `CLI_VERSION` moves from `3.6.0` to `4.0.0` with the
CLI 4.0.0 release; it is what the binary prints when run directly.
- `cli-version`.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "3.6.0\n",
"stdout": "4.0.0\n",
"stderr": "",
"exit": 0,
"signal": null,
+43 -20
View File
@@ -15,6 +15,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parseFrontmatter } from '../scripts/lib/utils.js';
import {
rewritePluginMarkdown,
rewritePluginAgentMarkdown,
@@ -66,20 +67,24 @@ describe('rewritePluginMarkdown', () => {
);
});
test('removes the node pre-approval instead of widening it', () => {
const frontmatter = [
test('removes the entire allowed-tools frontmatter block', () => {
const input = [
'---',
'name: impeccable',
'allowed-tools:',
' - Bash(npx impeccable *)',
' - Bash(.claude/skills/impeccable/scripts/impeccable *)',
'license: Apache 2.0',
'---',
'',
'Body text.',
].join('\n');
const output = rewritePluginMarkdown(frontmatter);
// The generic path rewrite alone would leave Bash(<skill-base-dir>/scripts/impeccable *),
// a dead literal, and any wildcard replacement would auto-approve
// same-shaped paths outside the plugin. The line must go entirely.
const output = rewritePluginMarkdown(input);
expect(output).not.toMatch(/^allowed-tools:/m);
expect(output).not.toContain('scripts/impeccable *');
expect(output).toContain(' - Bash(npx impeccable *)\n---');
expect(output).not.toContain('npx impeccable');
expect(output).toContain('license: Apache 2.0');
expect(output).toContain('Body text.');
});
test('drops the project-path fallback clause from Setup step 1', () => {
@@ -256,9 +261,6 @@ describe('verifyPluginSkillRewrite', () => {
};
const goodSkill = [
'allowed-tools:',
' - Bash(.claude/skills/impeccable/scripts/impeccable *)',
'',
'1. Run `<skill-base-dir>/scripts/impeccable context` once per session, where `<skill-base-dir>` is the ' +
"loaded base directory the runtime reports for this skill; keep cwd at the user's project. " +
'That base directory resolves every `.claude/skills/impeccable/scripts/impeccable <verb>` command in this skill ' +
@@ -269,6 +271,7 @@ describe('verifyPluginSkillRewrite', () => {
test('accepts a correctly rewritten SKILL.md', () => {
const p = writeSkill(rewritePluginMarkdown(goodSkill));
expect(() => verifyPluginSkillRewrite(p)).not.toThrow();
expect(fs.readFileSync(p, 'utf-8')).not.toMatch(/^allowed-tools:/m);
});
test('fails the build when the Setup fallback sentence no longer matched', () => {
@@ -280,22 +283,31 @@ describe('verifyPluginSkillRewrite', () => {
});
test('fails the build when a launcher pre-approval survives the removal', () => {
const reworded = goodSkill.replace(
'Bash(.claude/skills/impeccable/scripts/impeccable *)',
'Bash(.claude/skills/impeccable/scripts/impeccable.cmd *)',
const p = writeSkill(
rewritePluginMarkdown(goodSkill) + '\n - Bash(<skill-base-dir>/scripts/impeccable.cmd *)\n',
);
const p = writeSkill(rewritePluginMarkdown(reworded));
expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher/);
});
test('fails the build when allowed-tools frontmatter survives the removal', () => {
const p = writeSkill([
'---',
'name: impeccable',
'allowed-tools:',
' - Bash(npx impeccable *)',
'license: Apache 2.0',
'---',
'',
rewritePluginMarkdown(goodSkill),
].join('\n'));
expect(() => verifyPluginSkillRewrite(p)).toThrow(/allowed-tools/);
});
test('fails the build when a legacy node pre-approval survives', () => {
// The Node-era line is gone from SKILL.src.md, but a copy that still
// carries one must fail the same way as a surviving launcher line.
// A copy that still carries a node pre-approval must fail the same way as
// a surviving launcher line, even outside an allowed-tools block.
const p = writeSkill(
rewritePluginMarkdown(goodSkill).replace(
'allowed-tools:\n',
'allowed-tools:\n - Bash(node <skill-base-dir>/scripts/*)\n',
),
rewritePluginMarkdown(goodSkill) + '\n - Bash(node <skill-base-dir>/scripts/*)\n',
);
expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher or node script path/);
});
@@ -310,3 +322,14 @@ describe('verifyPluginSkillRewrite', () => {
expect(() => verifyPluginSkillRewrite(p)).toThrow(/still contains the project-relative scripts path/);
});
});
describe('SKILL.src.md frontmatter', () => {
test('keeps allowed-tools in source for non-Claude providers (issue #736)', () => {
const src = fs.readFileSync(
path.join(import.meta.dirname, '../skill/SKILL.src.md'),
'utf-8',
);
const { frontmatter } = parseFrontmatter(src);
expect(frontmatter['allowed-tools']).toBeDefined();
});
});
+52 -1
View File
@@ -88,7 +88,7 @@ describe('release.mjs guards', () => {
// (and check-engine-release.mjs imports fetch-engine.mjs), so stage them
// too or the dry runs fail to resolve the modules instead of exercising
// the guard.
for (const dep of ['check-engine-release.mjs', 'fetch-engine.mjs']) {
for (const dep of ['check-engine-release.mjs', 'fetch-engine.mjs', 'sign-bundle.mjs', 'bundle-signing-keys.json']) {
fs.copyFileSync(path.join(REPO_ROOT, 'scripts', dep), path.join(workDir, 'scripts', dep));
}
write('.claude-plugin/plugin.json', JSON.stringify({ name: 'impeccable', version: '1.2.3' }));
@@ -171,6 +171,57 @@ describe('release.mjs guards', () => {
assert.match(stdout, /tag is free/);
assert.match(stdout, /\[dry-run\] git tag -a skill-v1\.2\.3/);
assert.match(stdout, /\[dry-run\] gh release create skill-v1\.2\.3/);
assert.match(stdout, /1Password is not accessed/);
assert.match(stdout, /gh release create[^\n]+universal\.zip\.sig\.json/);
assert.equal(fs.existsSync(path.join(workDir, 'dist/universal.zip.sig.json')), false);
});
it('refuses a real release before tagging when signing is not configured', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8'));
pkg.scripts = { 'build:release': 'node -e "process.exit(0)"' };
write('package.json', JSON.stringify(pkg));
git(workDir, 'add', 'package.json');
git(workDir, 'commit', '-m', 'fixture build command');
git(workDir, 'push', 'origin', 'main');
assert.throws(() => execFileSync(process.execPath, ['scripts/release.mjs', 'skill'], {
cwd: workDir, encoding: 'utf8', stdio: 'pipe',
env: { ...process.env, IMPECCABLE_SKIP_ENGINE_CHECK: '1', IMPECCABLE_SIGNING_KEY_REF: '' },
}), error => {
assert.match(error.stderr, /Set IMPECCABLE_SIGNING_KEY_REF/);
assert.doesNotMatch(error.stdout, /Creating annotated tag|Creating GitHub release/);
return true;
});
assert.equal(git(workDir, 'tag'), '');
assert.equal(git(workDir, 'ls-remote', '--tags', 'origin'), '');
});
it('refuses before tagging when the signer returns without creating the sidecar', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8'));
pkg.scripts = { 'build:release': 'node -e "process.exit(0)"' };
write('package.json', JSON.stringify(pkg));
// Stub only inside this disposable repository. No 1Password access, tags,
// or real GitHub publication can occur even if the assertion regresses.
write('scripts/sign-bundle.mjs', 'export function signReleaseBundle() {}\n');
const releaseSource = fs.readFileSync(RELEASE_SCRIPT, 'utf8');
const tagStep = 'step(`Creating annotated tag ${tag}`);';
assert.ok(releaseSource.includes(tagStep), 'fixture must intercept the tag step');
write('scripts/release.mjs', releaseSource.replace(
tagStep,
'throw new Error("UNEXPECTED_TAG_STEP");'
));
git(workDir, 'add', 'package.json', 'scripts/sign-bundle.mjs', 'scripts/release.mjs');
git(workDir, 'commit', '-m', 'fixture signer with missing output');
git(workDir, 'push', 'origin', 'main');
assert.throws(() => execFileSync(process.execPath, ['scripts/release.mjs', 'skill'], {
cwd: workDir, encoding: 'utf8', stdio: 'pipe',
env: { ...process.env, IMPECCABLE_SKIP_ENGINE_CHECK: '1' },
}), error => {
assert.match(error.stderr, /Missing artifact: dist\/universal\.zip\.sig\.json/);
assert.doesNotMatch(error.stderr, /UNEXPECTED_TAG_STEP/);
return true;
});
assert.equal(git(workDir, 'tag'), '');
assert.equal(git(workDir, 'ls-remote', '--tags', 'origin'), '');
});
it('converts the changelog entry to markdown release notes', () => {