Compare commits

..
Author SHA1 Message Date
Paul Bakaus 9e30fe34e9 Merge main and preserve documented palette exemptions
Reconcile main's two-hue palette reading with documented color exemptions. Extend regression coverage so declared hues cannot charge unrelated ink. Regenerate the detector bundle; retain upstream rule behavior and goldens.

AI-assisted by Codex under pbakaus's instructions.
2026-09-21 11:14:42 -07:00
Paul BakausandClaude Fable 5.1 3f345f7058 Add a real-browser regression for the documented-palette case
Greptile asked for coverage beyond FakeDom, and it is right that the
interesting question here is a browser one: whether the `oklch()` a
DESIGN.md declares and the `oklch()` Chrome computes for an element are the
same color by the time the rule sees them.

A `file://` target loads the DESIGN.md that governs the page's directory,
so the whole path runs end to end: the allowlist is parsed from markdown,
Chrome renders the page, and the browser element sweep decides. The test
writes two pages with the same shapes — verdigris text on a dark instrument
face, and a violet-to-verdigris gradient — one in declared tokens and one in
colors the DESIGN.md never mentions, and asserts the first is silent while
the second still fires. It skips cleanly with no installed browser or no
built binary, the way differential.rs does.

Verified against the pre-fix engine: the declared page reports two
ai-color-palette findings there and none here.

Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-11 12:24:57 -07:00
Paul BakausandClaude Fable 5.1 bb1ca3c7df Fix: a DESIGN.md token is not the generic AI palette
`ai-color-palette` catches the palette nobody picked: the violet and the
cyan a model reaches for when there is no design system. The browser sweep
was reading only the hue, so a site whose own documented tokens land in
those bands tripped it on every element wearing one. On impeccable-site's
"Paper and instruments" pull request, where the DESIGN.md palette is a
verdigris `oklch(70% 0.12 188)` on an `oklch(24% 0 0)` instrument face,
that was 218 findings across 54 pages, all of them "Cyan neon text on dark
background" against a token the author had written down.

`check_element_ai_palette_dom` now takes the scan's design system and skips
any gradient stop or text color the DESIGN.md declares, matched with the
same `browser_colors_close` tolerance the `design-system-color` rule uses,
so a color that rule calls declared is declared here too. A scan with no
DESIGN.md, or one whose DESIGN.md has no palette, is unchanged: there is
nothing to consult and every color stays in scope.

Measured on that site (54 pages, headless Chrome, the in-page bundle):
with the design system, ai-color-palette drops 218 -> 8; without one it
stays at 218. The 8 survivors are the real thing, the purple gradients the
/slop and /docs before-and-after exhibits ship on purpose. No
design-system-* count moves.

Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-11 12:13:52 -07:00
19 changed files with 337 additions and 415 deletions
@@ -0,0 +1,138 @@
//! `ai-color-palette` against a project's own documented palette, through a
//! real browser.
//!
//! The FakeDom tests in `impeccable-core` pin the rule's decision; this one
//! pins the thing only a browser can answer: that the `oklch()` a DESIGN.md
//! declares and the `oklch()` Chrome computes for an element are the same
//! color by the time the rule sees them. A `file://` target loads the
//! DESIGN.md that governs the page's directory, so the whole path runs — the
//! allowlist is parsed from the markdown, the page is rendered, and the
//! browser element sweep decides.
//!
//! Skips cleanly without an installed browser or a built binary, the way
//! `differential.rs` does.
//!
//! Env:
//! - `IMPECCABLE_BIN` — the binary (default `target/debug/impeccable`).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("workspace root")
}
/// A DESIGN.md whose palette is all `oklch()`, including a verdigris that
/// sits inside the rule's cyan band and a violet inside its purple one.
const DESIGN_MD: &str = "---\n\
name: Instruments\n\
colors:\n\
\x20 paper: \"oklch(97.8% 0 0)\"\n\
\x20 ink: \"oklch(13% 0 0)\"\n\
\x20 instrument: \"oklch(24% 0 0)\"\n\
\x20 patina: \"oklch(70% 0.12 188)\"\n\
\x20 iris: \"oklch(58% 0.2 300)\"\n\
---\n\n\
The palette above is the whole system.\n";
fn page(swatch_color: &str, gradient: &str) -> String {
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<title>Instruments</title></head>\
<body style=\"background: oklch(97.8% 0 0); color: oklch(13% 0 0); font-family: Arial, sans-serif\">\
<div style=\"background: oklch(24% 0 0); padding: 24px\">\
<span style=\"color: {swatch_color}; font-size: 16px\">Live</span>\
</div>\
<section style=\"background-image: {gradient}; height: 200px\"></section>\
</body></html>"
)
}
fn rules(bin: &Path, dir: &Path, file: &str) -> Vec<String> {
let url = format!("file://{}", dir.join(file).display());
let out = Command::new(bin)
.arg("detect")
.arg("--json")
.arg(&url)
.current_dir(dir)
.output()
.expect("run detect");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let parsed: Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("detect {url} did not print JSON ({e}): {stdout}"));
parsed
.as_array()
.expect("findings array")
.iter()
.filter_map(|f| f.get("antipattern")?.as_str().map(str::to_string))
.collect()
}
#[test]
fn ai_palette_respects_a_documented_oklch_palette() {
let env: HashMap<String, String> = std::env::vars().collect();
if impeccable_browser::discovery::find_browser(&env).is_err() {
eprintln!("skip: no installed browser found");
return;
}
let bin = std::env::var("IMPECCABLE_BIN")
.map(PathBuf::from)
.unwrap_or_else(|_| workspace_root().join("target/debug/impeccable"));
if !bin.exists() {
eprintln!(
"skip: {} missing (cargo build -p impeccable, or set IMPECCABLE_BIN)",
bin.display()
);
return;
}
let dir = std::env::temp_dir().join(format!("impeccable-ds-palette-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
// A project marker, so the DESIGN.md walk-up stops here rather than
// climbing out of the temp directory.
std::fs::write(dir.join("package.json"), "{\"name\":\"ds-palette-fixture\"}\n").unwrap();
std::fs::write(dir.join("DESIGN.md"), DESIGN_MD).unwrap();
std::fs::write(
dir.join("declared.html"),
page(
"oklch(70% 0.12 188)",
"linear-gradient(oklch(58% 0.2 300), oklch(70% 0.12 188))",
),
)
.unwrap();
std::fs::write(
dir.join("undeclared.html"),
page(
"rgb(0, 229, 255)",
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
),
)
.unwrap();
let declared = rules(&bin, &dir, "declared.html");
let undeclared = rules(&bin, &dir, "undeclared.html");
let _ = std::fs::remove_dir_all(&dir);
// Every color on this page is a token the DESIGN.md declares, so neither
// the palette rule nor the drift rule has anything to say.
assert!(
!declared.iter().any(|r| r == "ai-color-palette"),
"declared tokens reported as a generic AI palette: {declared:?}"
);
assert!(
!declared.iter().any(|r| r == "design-system-color"),
"declared tokens reported as drift: {declared:?}"
);
// The same shapes in colors the DESIGN.md never declared still fire.
assert!(
undeclared.iter().any(|r| r == "ai-color-palette"),
"undeclared neon and violet gradient went unreported: {undeclared:?}"
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ fn run(args: &[String], io: &mut Io) -> i32 {
"detect" => impeccable_detect::run_detect(rest, io, &engines()),
"ignores" | "ignore" => impeccable_detect::run_ignores(rest, io),
"skills" => impeccable_skills::run(rest, io),
"help" | "install" | "link" | "update" | "check" | "verify-bundle" => impeccable_skills::run(args, io),
"help" | "install" | "link" | "update" | "check" => impeccable_skills::run(args, io),
// skill scripts
"context" => impeccable_context::run_context(rest, io),
"pin" => impeccable_context::run_pin(rest, io),
+72 -1
View File
@@ -1369,7 +1369,7 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
findings.extend(hits(ec::check_element_colors_dom(dom, el)));
findings.extend(hits(ec::check_element_motion_dom(dom, el)));
findings.extend(hits(ec::check_element_glow_dom(dom, el)));
let palette = ec::check_element_ai_palette_dom(dom, el);
let palette = ec::check_element_ai_palette_dom(dom, el, design_system.as_ref());
// An ignored subtree gets no vote in the page-wide reading. A cyan
// tell inside `data-impeccable-ignore="ai-color-palette"` would
// otherwise open the two-hue gate and charge neon ink somewhere else
@@ -1882,6 +1882,77 @@ mod tests {
assert!(types(&out).contains(&"design-system-font".to_string()));
}
/// End to end through the collector: a page whose colors are all its own
/// documented oklch tokens must not report `ai-color-palette`, while a
/// color the DESIGN.md never declared still reports both rules.
#[test]
fn ai_palette_respects_the_design_system_palette() {
// oklch(24% 0 0) instrument face carrying oklch(70% 0.12 188) verdigris.
let make_dom = |text_color: &str, second_color: &str| {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let panel = d.add(Some(body), "div");
d.set_styles(panel, &[("backgroundColor", "rgb(58, 58, 58)")]);
d.el_mut(panel).check_visibility = Some(true);
let label = d.add(Some(panel), "span");
d.add_text(label, "Live");
d.set_styles(
label,
&[
("color", text_color),
("backgroundColor", "rgba(0, 0, 0, 0)"),
("fontFamily", "Inter, sans-serif"),
],
);
d.el_mut(label).check_visibility = Some(true);
let second = d.add(Some(panel), "span");
d.add_text(second, "Status");
d.set_styles(second, &[("color", second_color), ("fontFamily", "Inter, sans-serif")]);
d.el_mut(second).check_visibility = Some(true);
d
};
let types = |out: &CollectResult| -> Vec<String> {
out.groups
.iter()
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.collect()
};
let design_system = json!({
"present": true,
"hasFonts": true, "allowedFonts": ["Inter"],
"hasColors": true,
"allowedColors": [
{ "r": 15, "g": 182, "b": 172 },
{ "r": 168, "g": 85, "b": 247 },
{ "r": 58, "g": 58, "b": 58 }
]
});
let with_ds = BrowserConfig {
design_system: Some(design_system),
..Default::default()
};
let without_ds = BrowserConfig::default();
// No DESIGN.md: two unexplained hues form a palette, not one accent.
let out = collect_browser_findings(&make_dom("rgb(15, 182, 172)", "rgb(168, 85, 247)"), &without_ds);
assert!(types(&out).contains(&"ai-color-palette".to_string()));
// Declared token: neither the palette rule nor the drift rule fires.
let out = collect_browser_findings(&make_dom("rgb(15, 182, 172)", "rgb(168, 85, 247)"), &with_ds);
assert!(!types(&out).contains(&"ai-color-palette".to_string()), "{:?}", types(&out));
assert!(!types(&out).contains(&"design-system-color".to_string()), "{:?}", types(&out));
// A declared purple does not open the two-hue gate for undeclared cyan.
let out = collect_browser_findings(&make_dom("rgb(0, 229, 255)", "rgb(168, 85, 247)"), &with_ds);
assert!(!types(&out).contains(&"ai-color-palette".to_string()), "{:?}", types(&out));
assert!(types(&out).contains(&"design-system-color".to_string()), "{:?}", types(&out));
// Two undeclared hues still report both rules.
let out = collect_browser_findings(&make_dom("rgb(0, 229, 255)", "rgb(220, 0, 255)"), &with_ds);
assert!(types(&out).contains(&"ai-color-palette".to_string()), "{:?}", types(&out));
assert!(types(&out).contains(&"design-system-color".to_string()), "{:?}", types(&out));
}
#[test]
fn disabled_values_parse_and_normalize_like_the_js() {
// JS `.filter(e => e && typeof e === 'object' && e.rule && e.value)`:
+110 -3
View File
@@ -11,6 +11,7 @@ use super::dom::{
class_attr, class_attr_or_prop, closest_or_none, direct_text, has_direct_text_longer_than,
matches_or_false, pf0, safe_id, style_px, tag_lower, Dom, ElId, ElStyle, Rect,
};
use super::driver::{browser_colors_close, DesignSystemConfig};
use super::BrowserFinding;
use crate::checks::measures::{
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
@@ -716,6 +717,30 @@ pub fn check_element_glow_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
})
}
/// True when the scan was given a DESIGN.md and that file declares this
/// color as one of the project's own.
///
/// `ai-color-palette` is a rule about the *unchosen* palette: the purple and
/// the cyan a model reaches for when nobody picked one. A color the author
/// wrote down in DESIGN.md was picked, so it is not that default whatever
/// its hue, and a site whose whole palette is its own documented tokens must
/// not trip the rule on every element that wears one. With no design system
/// there is nothing to consult and every color stays in scope, which is the
/// behavior every scan without a DESIGN.md keeps.
///
/// The tolerance is `browser_colors_close`, the same one the
/// `design-system-color` rule matches computed colors with, so a token the
/// design-system rule calls declared is declared here too.
fn is_declared_design_color(ds: Option<&DesignSystemConfig>, c: &Rgba) -> bool {
let Some(ds) = ds else { return false };
if !ds.has_colors {
return false;
}
ds.allowed_colors
.iter()
.any(|allowed| browser_colors_close(c, allowed))
}
/// The two hues the AI palette is built out of. A page that uses one of them
/// has an accent; a page that uses both has the palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -764,10 +789,17 @@ pub struct AiPaletteReading {
/// `#2fb8a6` on near-black lit 18 places on the bench's base, and every one of
/// them was the same deliberate accent (REN-405). Two different tell hues on
/// one page is the palette the rule is named for.
pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> AiPaletteReading {
pub fn check_element_ai_palette_dom(
dom: &dyn Dom,
el: ElId,
design_system: Option<&DesignSystemConfig>,
) -> AiPaletteReading {
let mut reading = AiPaletteReading::default();
let bg_image = dom.style(el, "backgroundImage");
for c in parse_gradient_colors(Some(&bg_image)) {
if is_declared_design_color(design_system, &c) {
continue;
}
if has_chroma(Some(&c), Some(50.0)) {
if let Some(tell) = TellHue::of(get_hue(Some(&c))) {
reading.tells.push(tell);
@@ -782,7 +814,8 @@ pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> AiPaletteReading
}
}
}
let text_color = parse_rgb_or_any(&dom.style(el, "color"));
let text_color = parse_rgb_or_any(&dom.style(el, "color"))
.filter(|c| !is_declared_design_color(design_system, c));
if let Some(tc) = text_color {
if has_chroma(Some(&tc), Some(80.0)) {
if let Some(tell) = TellHue::of(get_hue(Some(&tc))) {
@@ -1570,7 +1603,81 @@ mod tests {
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
);
d.set_style(hero, "color", "rgb(0, 0, 0)");
let reading = check_element_ai_palette_dom(&d, hero);
let reading = check_element_ai_palette_dom(&d, hero, None);
assert_eq!(reading.hits.len(), 1);
assert_eq!(reading.hits[0].snippet, "Purple/violet gradient background");
assert!(reading.ink.is_none());
assert_eq!(reading.tells, vec![TellHue::Purple]);
}
/// A DESIGN.md palette built out of the project's own oklch tokens is not
/// the generic assistant default, however cyan or violet the tokens are.
/// The verdigris-on-instrument pair here is the shape that fired 80 times
/// on one site whose whole palette is documented.
fn design_system_with(colors: &[(f64, f64, f64)]) -> DesignSystemConfig {
DesignSystemConfig {
has_colors: true,
allowed_colors: colors
.iter()
.map(|&(r, g, b)| Rgba { r, g, b, a: None })
.collect(),
..DesignSystemConfig::default()
}
}
#[test]
fn ai_palette_skips_colors_the_design_system_declares() {
let (mut d, body) = page();
// oklch(24% 0 0) instrument face, oklch(70% 0.12 188) verdigris text.
let panel = d.add(Some(body), "div");
d.set_style(panel, "backgroundColor", "rgb(58, 58, 58)");
let label = d.add(Some(panel), "span");
d.set_style(label, "color", "rgb(15, 182, 172)");
// With no DESIGN.md the teal contributes ink to the page-wide reading.
let reading = check_element_ai_palette_dom(&d, label, None);
assert!(reading.hits.is_empty());
assert_eq!(reading.ink.unwrap().snippet, "Cyan neon text on dark background");
assert_eq!(reading.tells, vec![TellHue::Cyan]);
// Declared in DESIGN.md, so it is the project's palette, not the default.
let ds = design_system_with(&[(15.0, 182.0, 172.0)]);
let declared = check_element_ai_palette_dom(&d, label, Some(&ds));
assert!(declared.hits.is_empty());
assert!(declared.ink.is_none());
assert!(declared.tells.is_empty());
// A design system that declares some other color leaves the rule alone.
let other = design_system_with(&[(200.0, 40.0, 30.0)]);
assert!(check_element_ai_palette_dom(&d, label, Some(&other)).ink.is_some());
// `hasColors: false` is a DESIGN.md with no palette section: no allowlist
// to consult, so the rule keeps its unconstrained behavior.
let empty = DesignSystemConfig::default();
assert!(check_element_ai_palette_dom(&d, label, Some(&empty)).ink.is_some());
}
#[test]
fn ai_palette_gradient_skips_declared_stops_but_not_undeclared_ones() {
let (mut d, body) = page();
let hero = d.add(Some(body), "section");
d.set_style(
hero,
"backgroundImage",
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
);
d.set_style(hero, "color", "rgb(0, 0, 0)");
// The violet stop is a declared token, so this gradient is the project's.
let ds = design_system_with(&[(168.0, 85.0, 247.0), (59.0, 130.0, 246.0)]);
let declared = check_element_ai_palette_dom(&d, hero, Some(&ds));
assert!(declared.hits.is_empty());
assert!(declared.ink.is_none());
assert!(declared.tells.is_empty());
// Declaring only the blue stop leaves the violet one in scope.
let partial = design_system_with(&[(59.0, 130.0, 246.0)]);
let reading = check_element_ai_palette_dom(&d, hero, Some(&partial));
assert_eq!(reading.hits.len(), 1);
assert_eq!(reading.hits[0].snippet, "Purple/violet gradient background");
assert!(reading.ink.is_none());
-1
View File
@@ -67,7 +67,6 @@ Commands:
link Symlink skills from a local checkout or submodule
update Update skills to the latest version
check Check if skill updates are available
verify-bundle Verify a local skill bundle against the pinned signing keys
Options:
--help Show this help message
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -31,15 +31,15 @@ pub(crate) fn release_version(location: &str) -> Result<String, String> {
})
}
#[derive(Debug, Deserialize)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct Envelope {
struct Envelope {
schema: u32,
pub(crate) key_id: String,
pub(crate) version: String,
pub(crate) artifact: String,
pub(crate) size: u64,
pub(crate) sha256: String,
key_id: String,
version: String,
artifact: String,
size: u64,
sha256: String,
signature: String,
}
@@ -62,7 +62,7 @@ pub(crate) fn verify_reader(
signature: &[u8],
version: &str,
keys: &TrustedKeys,
) -> Result<Envelope, String> {
) -> Result<(), String> {
if signature.len() as u64 > MAX_SIGNATURE_BYTES {
return Err("Bundle signature is too large".into());
}
@@ -108,7 +108,7 @@ pub(crate) fn verify_reader(
if size != envelope.size || format!("{:x}", hash.finalize()) != envelope.sha256 {
return Err("Bundle digest or size does not match its signature".into());
}
Ok(envelope)
Ok(())
}
#[cfg(test)]
-1
View File
@@ -55,7 +55,6 @@ pub fn run(args: &[String], io: &mut Io) -> R<()> {
"link" => link(&rest, io),
"update" => update(&rest, io),
"check" => check(io),
"verify-bundle" => crate::verify_bundle::run(&rest, io),
other => {
io.err(&format!("Unknown skills command: {other}\n"));
io.err("Run 'impeccable --help' for available commands.\n");
-1
View File
@@ -34,7 +34,6 @@ pub mod hook_manifest;
pub mod prompt;
pub mod providers;
pub mod util;
mod verify_bundle;
use impeccable_common::Io;
-230
View File
@@ -1,230 +0,0 @@
//! Offline verification of local release assets using the installer's trust roots.
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use impeccable_common::Io;
use crate::bundle_signature::{self, Envelope, TrustedKeys, MAX_SIGNATURE_BYTES};
use crate::{Flow, R};
const USAGE: &str = "Usage: impeccable verify-bundle <zip> --version <expected-version> [options]
Verify a local skill bundle's signature and SHA-256 using the pinned signing keys.
Runs offline without extracting files, installing skills, or enabling hooks.
Options:
--version <version> Required expected skill version (for example, 4.3.1)
--signature <path> Signature manifest (default: <zip>.sig.json)
--json Print verified metadata as JSON
-h, --help Show this help message
Exit codes: 0 verified, 1 verification or file error, 2 invalid arguments.
";
struct Options {
bundle: PathBuf,
signature: PathBuf,
version: String,
json: bool,
}
fn parse(args: &[String]) -> Result<Options, String> {
let (mut bundle, mut signature, mut version) = (None, None, None);
let mut json = false;
let mut positional = false;
let mut args = args.iter();
while let Some(arg) = args.next() {
if !positional && arg == "--" {
positional = true;
} else if !positional && arg == "--json" {
json = true;
} else if !positional
&& (arg == "--version"
|| arg == "--signature"
|| arg.starts_with("--version=")
|| arg.starts_with("--signature="))
{
let (name, value) = match arg.split_once('=') {
Some(pair) => pair,
None => (arg.as_str(), args.next().map(String::as_str).unwrap_or("")),
};
if value.is_empty() || value.starts_with('-') {
return Err(format!("{name} requires a value"));
}
let slot = if name == "--version" {
&mut version
} else {
&mut signature
};
if slot.replace(value.to_string()).is_some() {
return Err(format!("{name} may only be specified once"));
}
} else if !positional && arg.starts_with('-') {
return Err(format!("Unknown option: {arg}"));
} else if bundle.replace(PathBuf::from(arg)).is_some() {
return Err("Expected exactly one local bundle path".into());
}
}
let bundle = bundle.ok_or("A local bundle path is required")?;
let version = version.ok_or("--version is required; specify the expected skill release")?;
// Reuse the installer's exact release-version grammar.
bundle_signature::release_version(&format!(
"https://github.com/pbakaus/impeccable/releases/download/skill-v{version}/universal.zip"
))?;
let signature = signature.map(PathBuf::from).unwrap_or_else(|| {
let mut path = bundle.as_os_str().to_os_string();
path.push(".sig.json");
PathBuf::from(path)
});
Ok(Options {
bundle,
signature,
version,
json,
})
}
fn verify(options: &Options, cwd: &Path, keys: &TrustedKeys) -> Result<Envelope, String> {
let read_error = |path: &Path, error| format!("Could not read {}: {error}", path.display());
let file =
File::open(cwd.join(&options.signature)).map_err(|e| read_error(&options.signature, e))?;
let mut signature = Vec::new();
file.take(MAX_SIGNATURE_BYTES + 1)
.read_to_end(&mut signature)
.map_err(|e| read_error(&options.signature, e))?;
let file = File::open(cwd.join(&options.bundle)).map_err(|e| read_error(&options.bundle, e))?;
bundle_signature::verify_reader(
&mut BufReader::new(file),
&signature,
&options.version,
keys,
)
}
pub(crate) fn run(args: &[String], io: &mut Io) -> R<()> {
if args
.iter()
.take_while(|a| a.as_str() != "--")
.any(|a| a == "--help" || a == "-h")
{
io.out(USAGE);
return Ok(());
}
let options = parse(args).map_err(|e| {
io.err(&format!("{e}\n\n{USAGE}"));
Flow::Exit(2)
})?;
let verified = bundle_signature::trusted_keys()
.and_then(|keys| verify(&options, &io.cwd, &keys))
.map_err(|e| Flow::Throw(format!("{}{e}", bundle_signature::ERROR_PREFIX)))?;
if options.json {
io.out(&format!(
"{}\n",
serde_json::json!({
"verified": true, "version": verified.version, "artifact": verified.artifact,
"keyId": verified.key_id, "size": verified.size, "sha256": verified.sha256,
})
));
} else {
io.out(&format!(
"Verified {} (skill-v{}).\nSigning key: {}\nSHA-256: {}\nSize: {} bytes\n",
verified.artifact, verified.version, verified.key_id, verified.sha256, verified.size
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verifies_local_pair_and_rejects_tampering_without_writes() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../tests/fixtures/bundle-signature.json"
))
.unwrap();
let root = crate::util::mkdtemp(
&std::env::temp_dir()
.join("verify-bundle-")
.to_string_lossy(),
)
.unwrap();
let root = Path::new(&root);
let bundle = fixture["bundle"].as_str().unwrap().as_bytes();
let signature = serde_json::to_vec(&fixture["envelope"]).unwrap();
std::fs::write(root.join("renamed.zip"), bundle).unwrap();
std::fs::write(root.join("manifest.json"), &signature).unwrap();
let options = Options {
bundle: "renamed.zip".into(),
signature: "manifest.json".into(),
version: "4.2.0".into(),
json: true,
};
let keys = serde_json::from_value(fixture["keys"].clone()).unwrap();
let result = verify(&options, root, &keys).unwrap();
assert_eq!(result.version, "4.2.0");
assert_eq!(result.sha256, fixture["envelope"]["sha256"]);
assert!(verify(&options, root, &bundle_signature::trusted_keys().unwrap()).is_err());
let wrong_release = Options {
version: "4.2.1".into(),
..options
};
assert!(verify(&wrong_release, root, &keys).is_err());
let options = Options {
version: "4.2.0".into(),
..wrong_release
};
std::fs::write(root.join("renamed.zip"), b"tampered").unwrap();
assert!(verify(&options, root, &keys).is_err());
std::fs::write(root.join("renamed.zip"), bundle).unwrap();
std::fs::write(
root.join("manifest.json"),
vec![b' '; MAX_SIGNATURE_BYTES as usize + 1],
)
.unwrap();
assert!(verify(&options, root, &keys)
.unwrap_err()
.contains("too large"));
std::fs::remove_file(root.join("manifest.json")).unwrap();
assert!(verify(&options, root, &keys)
.unwrap_err()
.contains("Could not read"));
assert_eq!(std::fs::read(root.join("renamed.zip")).unwrap(), bundle);
assert_eq!(std::fs::read_dir(root).unwrap().count(), 1);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn arguments_are_strict_and_default_to_adjacent_signature() {
let parse_args =
|args: &[&str]| parse(&args.iter().map(|a| a.to_string()).collect::<Vec<_>>());
let options = parse_args(&["bundle.zip", "--version=4.3.1", "--json"]).unwrap();
assert_eq!(options.signature, PathBuf::from("bundle.zip.sig.json"));
assert!(options.json);
let options = parse_args(&[
"--version",
"4.3.1",
"--signature",
"sig.json",
"--",
"-bundle.zip",
])
.unwrap();
assert_eq!(options.bundle, PathBuf::from("-bundle.zip"));
assert_eq!(options.signature, PathBuf::from("sig.json"));
for args in [
vec![],
vec!["bundle.zip"],
vec!["bundle.zip", "--version"],
vec!["bundle.zip", "--version=04.3.1"],
vec!["bundle.zip", "--version=4.3.1", "--version=4.2.0"],
vec!["bundle.zip", "--version=4.3.1", "extra.zip"],
vec!["bundle.zip", "--version=4.3.1", "--skip-signature"],
vec!["bundle.zip", "--version=4.3.1", "--signature="],
] {
assert!(parse_args(&args).is_err(), "{args:?}");
}
}
}
@@ -1,73 +0,0 @@
use impeccable_common::Io;
#[test]
fn verify_bundle_help_is_offline() {
let (mut io, capture) = Io::captured("", std::env::temp_dir(), Default::default());
let code = impeccable_skills::run(&["verify-bundle".into(), "--help".into()], &mut io);
assert_eq!(code, 0);
assert!(String::from_utf8(capture.stdout.borrow().clone())
.unwrap()
.contains("--version"));
assert!(String::from_utf8(capture.stdout.borrow().clone())
.unwrap()
.contains("--signature"));
assert!(String::from_utf8(capture.stderr.borrow().clone())
.unwrap()
.is_empty());
}
#[test]
fn verify_bundle_requires_an_independent_expected_version() {
let (mut io, capture) = Io::captured("", std::env::temp_dir(), Default::default());
let code = impeccable_skills::run(&["verify-bundle".into(), "universal.zip".into()], &mut io);
assert_eq!(code, 2);
assert!(String::from_utf8(capture.stderr.borrow().clone())
.unwrap()
.contains("--version"));
assert!(String::from_utf8(capture.stdout.borrow().clone())
.unwrap()
.is_empty());
}
#[test]
fn local_override_cannot_bypass_signature_verification_in_json_mode() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../tests/fixtures/bundle-signature.json"
))
.unwrap();
let root = impeccable_skills::util::mkdtemp(
&std::env::temp_dir()
.join("verify-command-")
.to_string_lossy(),
)
.unwrap();
let root = std::path::PathBuf::from(root);
std::fs::write(root.join("bundle.zip"), fixture["bundle"].as_str().unwrap()).unwrap();
std::fs::write(
root.join("bundle.zip.sig.json"),
serde_json::to_vec(&fixture["envelope"]).unwrap(),
)
.unwrap();
let env = [(
"IMPECCABLE_BUNDLE_PATH".into(),
root.to_string_lossy().into_owned(),
)]
.into();
let (mut io, capture) = Io::captured("", root.clone(), env);
let code = impeccable_skills::run(
&[
"verify-bundle".into(),
"bundle.zip".into(),
"--version=4.2.0".into(),
"--json".into(),
],
&mut io,
);
assert_eq!(code, 1);
assert!(capture.stdout.borrow().is_empty());
assert!(String::from_utf8(capture.stderr.borrow().clone())
.unwrap()
.contains("Unknown bundle signing key"));
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 2);
std::fs::remove_dir_all(root).unwrap();
}
+4 -41
View File
@@ -13,45 +13,6 @@ 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.
## Verify a downloaded release offline
Use an approved engine that includes `verify-bundle`, and obtain the ZIP and
`universal.zip.sig.json` from the same versioned skill release. Then run:
```sh
impeccable verify-bundle /path/to/universal.zip --version 4.3.1
```
The expected version is required and must be the skill version, not the CLI
or engine version. The signature defaults to `<zip>.sig.json`; use
`--signature /path/to/manifest.json` when stored separately. Both
`--version=4.3.1` and `--version 4.3.1` are accepted.
For an audit record:
```sh
impeccable verify-bundle /path/to/universal.zip --version 4.3.1 --json
```
Successful JSON contains `verified: true`, `version`, `artifact`, `keyId`,
`size`, and `sha256`. Only authenticated metadata is printed. Exit codes are
0 for successful verification, 1 for verification or file errors, and 2 for
invalid arguments. Errors go to stderr, including with `--json`; stdout is
empty on failure.
This command reads local files only. It does not download, extract, install,
or enable hooks, and it ignores local bundle overrides. It uses the same
compiled-in public keys and verifier as remote installation; there is no
custom-key or skip-verification option. Approve the engine and its keyring
through your organization's trust process first. Invoking through `npx` may
still download the npm package or engine; use an already provisioned native
binary for a fully offline workflow.
Verification authenticates the bytes and their declared release. It does not
inspect ZIP contents or prove that skill instructions are safe. Requiring an
expected version rejects a different release, but cannot tell you whether the
version you chose is the newest. Verify again if the files change before use.
## Sign a release
Install the 1Password CLI and enable its desktop app integration. The signing
@@ -92,10 +53,12 @@ review the exact released `universal.zip`, then run:
node scripts/sign-bundle.mjs 4.2.0 /path/to/universal.zip
```
Check the resulting sidecar against the verifier and compiled public key:
Check the resulting sidecar against the Rust verifier and compiled public key:
```sh
impeccable verify-bundle /path/to/universal.zip --version 4.2.0
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
-20
View File
@@ -1831,23 +1831,3 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit
#### E2E harness contract (`tests/live-e2e.test.mjs`, `tests/live-e2e/*`)
- Fake agent polls `GET /poll?token&timeout=5000` (no lease override → 30 s lease), replies via `POST /poll` with `{token,type:'done',sourceEventType:'generate',id,file}`, `steer_done {message,file}`, `error`, accept/discard completions with `data:{carbonize:true,_acceptResult}`/`{_acceptResult}`, manual apply via `live-poll.mjs --reply <id> done --data <json>`. Variant format: 3 variants (font-weights 300/900/600 for render proof), params `lightness` (range), `face` (steps), `italic` (toggle). Scenarios: core, manual, annotations, exit, missed-done, params, mount-failure, republish, storage-loss (fixtures README). Fixture `runtime` block schema is authoritative for what a reimplementation must satisfy end-to-end.
### `verify-bundle`: offline release verification
`impeccable verify-bundle <zip> --version <expected-version>` (also under
`impeccable skills`) authenticates local bytes with the remote installer's
compiled-in Ed25519 keyring before reporting success. No network, extraction,
installation, hooks, or writes occur. `IMPECCABLE_BUNDLE_PATH` does not bypass
verification. The expected skill version is required; no `v` or `skill-v`
prefix. The default signature path is `<zip>.sig.json`; `--signature <path>`
overrides it. Value options also accept `=`, and `--` ends option parsing.
Unknown options, duplicate value options, extra paths, and invalid versions
exit 2. `--help` / `-h` prints static help and exits 0.
Success exits 0 and prints the authenticated artifact, version, key ID,
SHA-256, and size. `--json` instead prints one object with `verified: true`,
`version`, `artifact`, `keyId`, `size`, and `sha256`. File/signature failures
exit 1 with `Could not verify skill bundle: ...` on stderr and empty stdout,
including in JSON mode. Signature reads are capped at 16 KiB plus one byte to
detect oversized input; bundle hashing streams through the shared verifier.
See [BUNDLE-SIGNING.md](BUNDLE-SIGNING.md) for trust scope and examples.
-7
View File
@@ -164,10 +164,3 @@ 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`.
## Recorded 2026-09-18: offline skill bundle verification
- `cli-help`: adds the `verify-bundle` command to root help.
- `skills-verify-bundle-help`, `skills-verify-bundle-namespace-help`, and
`skills-verify-bundle-version-required`: new offline command help and
required expected-version behavior. Existing installer output is unchanged.
-3
View File
@@ -7,9 +7,6 @@
* the top-level verb and the legacy `skills` namespace.
*/
export default [
{ id: 'skills-verify-bundle-help', verb: 'verify-bundle', args: ['--help'] },
{ id: 'skills-verify-bundle-version-required', verb: 'verify-bundle', args: ['universal.zip'] },
{ id: 'skills-verify-bundle-namespace-help', verb: 'skills', args: ['verify-bundle', '--help'] },
{ id: 'skills-install-help', verb: 'install', args: ['--help'] },
{ id: 'skills-install-help-short', verb: 'install', args: ['-h'] },
{ id: 'skills-link-help', verb: 'link', args: ['--help'] },
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Usage: impeccable <command> [options]\n\nCommands:\n detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues\n ignores Manage detector ignore rules, files, and values\n help List all available skills and commands\n install Install impeccable skills into your project or global harness\n link Symlink skills from a local checkout or submodule\n update Update skills to the latest version\n check Check if skill updates are available\n verify-bundle Verify a local skill bundle against the pinned signing keys\n\nOptions:\n --help Show this help message\n --version Show version number\n\nCompatibility:\n impeccable skills <command> Legacy namespace; still supported.\n",
"stdout": "Usage: impeccable <command> [options]\n\nCommands:\n detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues\n ignores Manage detector ignore rules, files, and values\n help List all available skills and commands\n install Install impeccable skills into your project or global harness\n link Symlink skills from a local checkout or submodule\n update Update skills to the latest version\n check Check if skill updates are available\n\nOptions:\n --help Show this help message\n --version Show version number\n\nCompatibility:\n impeccable skills <command> Legacy namespace; still supported.\n",
"stderr": "",
"exit": 0,
"signal": null,
@@ -1,7 +0,0 @@
{
"stdout": "Usage: impeccable verify-bundle <zip> --version <expected-version> [options]\n\nVerify a local skill bundle's signature and SHA-256 using the pinned signing keys.\nRuns offline without extracting files, installing skills, or enabling hooks.\n\nOptions:\n --version <version> Required expected skill version (for example, 4.3.1)\n --signature <path> Signature manifest (default: <zip>.sig.json)\n --json Print verified metadata as JSON\n -h, --help Show this help message\n\nExit codes: 0 verified, 1 verification or file error, 2 invalid arguments.\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -1,7 +0,0 @@
{
"stdout": "Usage: impeccable verify-bundle <zip> --version <expected-version> [options]\n\nVerify a local skill bundle's signature and SHA-256 using the pinned signing keys.\nRuns offline without extracting files, installing skills, or enabling hooks.\n\nOptions:\n --version <version> Required expected skill version (for example, 4.3.1)\n --signature <path> Signature manifest (default: <zip>.sig.json)\n --json Print verified metadata as JSON\n -h, --help Show this help message\n\nExit codes: 0 verified, 1 verification or file error, 2 invalid arguments.\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -1,7 +0,0 @@
{
"stdout": "",
"stderr": "--version is required; specify the expected skill release\n\nUsage: impeccable verify-bundle <zip> --version <expected-version> [options]\n\nVerify a local skill bundle's signature and SHA-256 using the pinned signing keys.\nRuns offline without extracting files, installing skills, or enabling hooks.\n\nOptions:\n --version <version> Required expected skill version (for example, 4.3.1)\n --signature <path> Signature manifest (default: <zip>.sig.json)\n --json Print verified metadata as JSON\n -h, --help Show this help message\n\nExit codes: 0 verified, 1 verification or file error, 2 invalid arguments.\n",
"exit": 2,
"signal": null,
"files": {}
}