mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8f93c55db | ||
|
|
681569712f | ||
|
|
8dac6ae7e0 | ||
|
|
46ffe5caa2 | ||
|
|
b077f6f0e4 | ||
|
|
641ff95502 |
@@ -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 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/`.
|
||||
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.
|
||||
|
||||
@@ -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
@@ -698,6 +698,8 @@ dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
|
||||
+9
-1
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"release-2026-09": "7433133bb92da2c0da4186925f36dbd36219c11192451df56f25fd6a23dd7db9"
|
||||
}
|
||||
+21
-12
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Vendored
+15
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"stdout": "3.6.0\n",
|
||||
"stdout": "4.0.0\n",
|
||||
"stderr": "",
|
||||
"exit": 0,
|
||||
"signal": null,
|
||||
|
||||
+43
-20
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user