Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,7 @@ maintenance changes are in source and are not in the npm 0.1.0/GitHub v0.1.4 rel
- [CI](./docs/ci.md)
- [Runner](./docs/runner-contract.md)
- [Full design](./SkillSync-Complete-Design.md)

### Offline verification review

Build the source checkout, then run `skillsync verify --path ./skills --format html > verification.html`. Open the report locally to review severity groups, causes, and next steps. Summary counts cover all findings; details are capped at 256 deduplicated issues. JSON retains complete evidence. No scripts, network assets, Skill execution, or automatic fixes are added. Inspired by [MLflow's reviewable tracking UI](https://mlflow.org/docs/latest/ml/tracking/). This option is source functionality until a new package is published.
9 changes: 6 additions & 3 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
import { renderJson } from "../reporters/json.js";
import { renderSarif } from "../reporters/sarif.js";
import { renderText } from "../reporters/text.js";
import { renderVerificationHtml } from "../reporters/verification-html.js";
import { parseOutputFormat } from "./output.js";

import { VERSION } from "../version.js";
Expand Down Expand Up @@ -286,7 +287,7 @@ export function createCli(io: CliIO = defaultCliIO): Command {
.option("--policy <path>", "YAML or JSON policy file")
.option("--profile-dir <path>", "Additional directory containing external profile YAML")
.option("--follow-symlinks", "Follow only symlinks that remain inside the Skill root")
.option("--format <format>", "Output format: text, json, or sarif", "text")
.option("--format <format>", "Output format: text, json, sarif, or offline html", "text")
.action(async (options: VerifyCliOptions) => {
const report = await runVerification({
paths: options.path ?? [],
Expand All @@ -295,8 +296,10 @@ export function createCli(io: CliIO = defaultCliIO): Command {
profileDir: options.profileDir,
followSymlinks: options.followSymlinks,
});
const format = parseOutputFormat(options.format);
const output = format === "json"
const format = options.format === "html" ? "html" : parseOutputFormat(options.format);
const output = format === "html"
? renderVerificationHtml(report)
: format === "json"
? renderJson(report)
: format === "sarif"
? renderSarif(report)
Expand Down
42 changes: 42 additions & 0 deletions src/reporters/verification-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { VerificationReport } from "../cli/commands/verify.js";
import { toIssues } from "../domain/issue.js";
import { redactLocalPaths } from "./local-paths.js";

const DETAIL_LIMIT = 256;
function text(value: unknown): string {
const valueText = String(value ?? "Not reported");
return (valueText.length > 600 ? valueText.slice(0, 600) + "…" : valueText)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}

/** A bounded, offline review of existing evidence; no scripts or automatic fixes. */
export function renderVerificationHtml(input: VerificationReport): string {
const report = redactLocalPaths(input);
const issues = report.issues?.length ? report.issues : toIssues(report.findings);
const counts = report.summary;
let remaining = DETAIL_LIMIT;
const groups = (["critical", "error", "warn", "info"] as const).map(severity => {
const all = issues.filter(issue => issue.finding.severity === severity);
const shown = all.slice(0, remaining);
remaining -= shown.length;
return `<details ${severity === "info" ? "" : "open"}><summary>${severity.toUpperCase()} · ${all.length} issues</summary>${shown.map((issue, index) => {
const f = issue.finding;
const resolution = issue.resolutions[0];
const steps = resolution?.kind === "manual" ? resolution.steps.slice(0, 8) : [];
return `<article id="${severity}-${index}"><h3>${text(f.code)} <small>${text(f.status)}</small></h3>
<p><strong>Skill:</strong> ${text(f.skill)} · <strong>Target:</strong> ${text(f.target ?? "All")}</p>
<p>${text(issue.explanation.cause)}</p><p><strong>Next step:</strong> ${text(resolution?.title ?? f.remediation ?? issue.explanation.impact)}</p>
${steps.length ? `<ol>${steps.map(step => `<li>${text(step)}</li>`).join("")}</ol>` : ""}
<details><summary>Issue reference</summary><code>${text(issue.id)}</code><p>${text(issue.location?.path ?? "Location not included")}</p></details></article>`;
}).join("")}${all.length > shown.length ? `<p>Showing ${shown.length} of ${all.length} issues in this group. Use JSON for complete evidence.</p>` : ""}${all.length ? "" : "<p>No issues in this group.</p>"}</details>`;
}).join("");
const title = report.exitCode === 2 ? "Verification setup required" : counts.fail ? "Changes require attention" : counts.unknown || counts.warn ? "Review incomplete evidence" : "Declared checks passed";
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>SkillSync · ${title}</title>
<style>:root{font-family:system-ui,sans-serif;color:#19343c;background:#f1f5f6;line-height:1.6}*{box-sizing:border-box}body{margin:0}header,main,footer{max-width:960px;margin:auto;padding:24px}a{color:#066d78}:focus-visible{outline:3px solid #18849b;outline-offset:4px}nav{display:flex;flex-wrap:wrap;gap:18px}h1{font-size:clamp(28px,4vw,42px);line-height:1.2}.hero,article,details{background:white;border:1px solid #cddce0;border-radius:12px;padding:20px;margin:16px 0}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metrics strong{display:block;font-size:28px}summary{cursor:pointer;font-weight:700}code{overflow-wrap:anywhere}small{font-size:13px}footer{color:#4e6871;font-size:13px}@media(max-width:600px){.metrics{grid-template-columns:1fr 1fr}header,main,footer{padding:16px}}@media print{body{background:white}nav{display:none}article{break-inside:avoid}details{border-radius:0}}</style></head>
<body><header><strong>SkillSync · Offline verification review</strong><nav aria-label="Report sections"><a href="#overview">Overview</a><a href="#issues">Issues and next steps</a><a href="#scope">Scope</a></nav></header><main>
<section class="hero" id="overview"><h1>${title}</h1><p>Read the cause and next step for each issue. These deterministic checks do not establish runtime safety, authenticated provenance, or approval.</p><div class="metrics">${(["pass", "fail", "warn", "unknown"] as const).map(key => `<div><strong>${text(counts[key])}</strong>${key}</div>`).join("")}</div><p>${text(counts.total)} findings · ${text(issues.length)} deduplicated issues · exit code ${report.exitCode}</p></section>
<section id="issues"><h2>Issues and next steps</h2><p>Groups are ordered by severity. At most ${DETAIL_LIMIT} issues are shown. Expand information groups to review successful checks.</p>${groups}</section>
<section id="scope"><h2>Scope and evidence</h2><p>${report.targets.length} selected targets. ${text(report.generated_at)}</p><p>Local paths and raw evidence contents are excluded. No Skill was executed and no fix was applied by this report.</p></section></main><footer>Generated from local verification metadata. No scripts, external assets, or network requests. Keep the JSON report for complete machine-readable evidence.</footer></body></html>\n`;
}
11 changes: 11 additions & 0 deletions tests/cli/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import { describe, expect, it } from "vitest";
import { runCli } from "../../src/cli/index";

describe("skillsync verify", () => {
it("exports an offline HTML review with actionable findings", async () => {
const root = await mkdtemp(join(tmpdir(), "skillsync-html-"));
await writeFile(join(root, "SKILL.md"), "---\nname: review\ndescription: Review\n---\n[missing](missing.md)\n");
const result = await runCli(["verify", "--path", root, "--target", "codex", "--format", "html"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain("<!doctype html>");
expect(result.stdout).toContain("Add the referenced file");
expect(result.stdout).toContain("Content-Security-Policy");
expect(result.stdout).not.toContain(root);
});

it("aggregates structure, compatibility, and provenance findings without executing scripts", async () => {
const root = await mkdtemp(join(tmpdir(), "skillsync-verify-"));
const skillRoot = join(root, "review");
Expand Down
18 changes: 18 additions & 0 deletions tests/reporters/verification-html.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, it } from "vitest";
import { toIssues } from "../../src/domain/issue";
import type { VerificationReport } from "../../src/cli/commands/verify";
import { renderVerificationHtml } from "../../src/reporters/verification-html";

it("escapes metadata, hides raw evidence, and caps details while retaining totals", () => {
const finding = { level: 1 as const, severity: "error" as const, status: "fail" as const, code: "test", skill: "<script>alert(1)</script>", message: "Read /private/work/file.md", evidence: [{ content: "RAW_SECRET", path: "/private/work/file.md" }], remediation: "Review <img src=x>" };
const issues = Array.from({ length: 270 }, () => toIssues([finding])[0]!);
const report: VerificationReport = { schema_version: 1, generated_at: "now", targets: [], findings: [finding], issues, summary: { total: 270, pass: 0, fail: 270, warn: 0, unknown: 0 }, exitCode: 1 };
const html = renderVerificationHtml(report);
expect(html).toContain("Showing 256 of 270");
expect(html.match(/<article /g)).toHaveLength(256);
expect(html).toContain("&lt;script&gt;");
expect(html).not.toContain("<script>");
expect(html).not.toContain("RAW_SECRET");
expect(html).not.toContain("/private/work");
expect(html).toContain("&lt;local-path&gt;");
});
Loading