diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd01212..7495b1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,6 +212,11 @@ jobs: - name: Install Cosign uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + - name: Set up Node + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 24 + - name: Verify published GitHub release artifacts env: GH_TOKEN: ${{ github.token }} diff --git a/Makefile b/Makefile index 7eb8016..1d7efd4 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ CARGO_AUDIT_VERSION ?= 0.22.1 CARGO_DENY_VERSION ?= 0.19.4 CARGO_LLVM_COV_VERSION ?= 0.8.5 -.PHONY: doctor install-dev-tools test test-fast test-security test-ux test-release test-local test-prepush test-release-install fmt clippy cargo-test cargo-nextest cargo-doc cargo-audit cargo-deny cargo-llvm-cov coverage-check fuzz-check test-junit trunk-check trunk-fix trunk-flaky-validate ci-scripts-test gitleaks raycast-install raycast-test raycast-test-junit raycast-audit raycast-lint raycast-build raycast-store-check raycast-verify npm-package-install npm-package-test npm-package-audit npm-package-pack npm-package-verify docs-reference docs-reference-check docs-qa site-install site-test site-audit site-build site-verify demo-assets tui-media release-snapshot verify build install-local clean-reports +.PHONY: doctor install-dev-tools test test-fast test-security test-ux test-release test-local test-prepush test-release-install fmt clippy cargo-test cargo-nextest cargo-doc cargo-audit cargo-deny cargo-llvm-cov coverage-check fuzz-check test-junit trunk-check trunk-fix trunk-flaky-validate ci-scripts-test gitleaks raycast-install raycast-test raycast-test-junit raycast-audit raycast-lint raycast-build raycast-store-check raycast-verify npm-package-install npm-package-test npm-package-audit npm-package-pack npm-package-verify docs-reference docs-reference-check docs-qa site-install site-test site-audit site-build site-verify demo-assets tui-media homebrew-formula release-snapshot verify build install-local clean-reports doctor: bash scripts/dev-doctor.sh @@ -159,6 +159,10 @@ demo-assets: tui-media: node scripts/generate-tui-media.mjs +homebrew-formula: + @if [ -z "$${VERSION:-}" ]; then echo "VERSION is required, for example: make homebrew-formula VERSION=0.1.6" >&2; exit 2; fi + node scripts/generate-homebrew-formula.mjs --version "$${VERSION}" --checksums dist/checksums.txt + release-snapshot: build bash scripts/release-snapshot-rust.sh diff --git a/README.md b/README.md index e0919f4..45eba2c 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Nightward answers the practical questions first: - Redacted plan-only remediation exports for parseable MCP config findings. - Read-only snapshot plans plus confirmed portable config snapshot creation. - Reusable GitHub Action for scan, policy, and SARIF modes. -- Raycast extension for Dashboard, Findings, Analysis, Provider Doctor, Nightward Actions, Explain Finding/Signal, Fix Plan/Analysis export, and report-folder access. +- Raycast extension for Dashboard, Findings, Analysis, Compare Reports, Provider Doctor, Nightward Actions, Explain Finding/Signal, Fix Plan/Analysis export, and report-folder access. - Stdio MCP server for AI clients that need local scan, analysis, finding, rule, provider, policy, report, prompt, fix-plan, and bounded action context. - User-level nightly scan scheduling for macOS launchd and Linux systemd user timers. - No telemetry, no cloud dashboard, and no default network calls from Nightward runtime. diff --git a/docs/distribution.md b/docs/distribution.md index d115639..eb7beb2 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -9,16 +9,19 @@ Nightward distribution should optimize for trust first, then convenience. 3. Source builds with `make install-local`. Development-only. 4. Trunk plugin import from a release tag. Shipped. 5. GitHub Action release tags. Shipped. -6. Homebrew tap. -7. Nix flake/package. -8. Scoop and WinGet. -9. mise and aqua. +6. Homebrew formula generation from signed release checksums. Scaffolded. +7. Homebrew tap publication. +8. Nix flake/package. +9. Scoop and WinGet. +10. mise and aqua. Docker is deferred until Nightward has a useful local report browser. A container is not a good default for safely scanning a user's HOME directory. -## Homebrew Tap Plan +## Homebrew Tap Support -Homebrew is straightforward once the release archive layout stays stable. Add a dedicated tap repository, then generate a formula from the signed GitHub Release archive and checksum data. The formula should install both `nightward` and `nw`, include a lightweight `nightward --version` test, and avoid becoming a separate packaging implementation. +`scripts/generate-homebrew-formula.mjs` generates a tap-ready formula from the signed GitHub Release checksum file. The formula points at the existing `nightward___.tar.gz` archives, installs both `nightward` and `nw`, and tests both command names with `--version`. + +The release verifier runs the generator after Cosign verifies `checksums.txt.sigstore.json` and `sha256sum` validates the downloaded archive. That keeps Homebrew support tied to the release artifact shape instead of creating a second packaging implementation. A dedicated tap repository is still a separate publication step. ## NPM Posture @@ -38,5 +41,6 @@ The npm package is `@jsonbored/nightward`. It is published through npm trusted p 4. Run local verification. 5. Create a signed SemVer tag. 6. Verify GitHub release artifacts. -7. Verify npm metadata and launcher behavior with `scripts/verify-npm-release.sh`. -8. Update OpenSSF badge evidence. +7. Generate/check the Homebrew formula from signed release checksums. +8. Verify npm metadata and launcher behavior with `scripts/verify-npm-release.sh`. +9. Update OpenSSF badge evidence. diff --git a/docs/install.md b/docs/install.md index d60685a..ac1a4f6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -62,9 +62,9 @@ The package should not use a long-lived npm token. It should publish through Git ## Deferred Channels -These are useful, but should wait until signed GitHub Release artifacts prove stable across patch releases: +These are useful, but still need a publication path: -- Homebrew tap +- Homebrew tap repository. Formula generation is scaffolded in `scripts/generate-homebrew-formula.mjs` and verified from signed release checksums. - Nix package - mise/aqua registry entries - Docker image for report browsing diff --git a/docs/raycast-extension.md b/docs/raycast-extension.md index 0067d78..b15dcc5 100644 --- a/docs/raycast-extension.md +++ b/docs/raycast-extension.md @@ -14,6 +14,7 @@ integrations/raycast - `Nightward Status`: compact menu-bar finding count, plus full critical/high/total counts, analysis signals, provider warnings, scheduled-report state, and latest-report access in the dropdown. - `Nightward Findings`: searchable findings with a severity filter, detail pane, scoped fix-plan exports, reviewed-policy-ignore snippets, redacted evidence copy, and open-doc actions. - `Nightward Analysis`: built-in offline signals plus explicitly selected providers. +- `Compare Nightward Reports`: read-only diff of the latest two saved reports from `nw report history`. - `Nightward Provider Doctor`: optional provider availability, privacy posture, install guidance for missing tools, and Raycast Analysis enable/disable controls. - `Nightward Actions`: preview and apply confirmed provider, policy, schedule, backup, cleanup, and setup actions. - `Explain Nightward Finding`: detail view for a known finding ID. @@ -42,6 +43,8 @@ The extension uses `execFile`, not a shell, for local Nightward commands. It cal - `fix export --rule --format markdown` - `analyze [--with providers] [--online] --json` - `analyze finding --json` +- `report history --json` +- `report diff --from --to ` - `providers doctor [--with providers] [--online] --json` Write-capable calls are limited to `actions apply --confirm` through the shared action registry. Provider Doctor previews `provider.install.` and applies that registry action after explicit confirmation; it no longer runs package-manager commands through a shell. No Raycast command runs restore, Git, or hidden shell mutation. @@ -73,6 +76,7 @@ Manual UI validation must use a fixture `Home Override`, not a real local home, - Menu-bar status shows finding, analysis, provider-warning, and schedule counters; its actions open existing read-only commands, open the latest report when present, and copy a redacted summary. - Findings search/filter/detail panes render redacted evidence, docs actions, scoped fix-plan exports, and reviewed-policy-ignore snippets. - Analysis renders built-in signals, selected provider output, provider warnings, and blocked-online-provider state. +- Compare Reports renders the latest two fixture reports, handles missing-history errors, and only offers copy/open/refresh actions. - Provider Doctor shows provider status, install guidance, action-registry provider CLI installation, and enable/disable controls for Raycast Analysis without running online-capable providers unless explicit opt-in is enabled. - Nightward Actions lists action IDs, risk, writes, commands, blocked reasons, and applies only after confirmation. - Export commands copy redacted Markdown and do not mutate local config. diff --git a/docs/release.md b/docs/release.md index 96634ce..815ddb2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -101,6 +101,8 @@ The release workflow verifies the published GitHub archive before npm publish: bash scripts/verify-release-archive.sh vX.Y.Z ``` +That verifier also generates a Homebrew formula from the signed `checksums.txt` file and checks that the formula installs/tests both `nightward` and `nw`. + The npm job then installs the packed npm tarball and runs both command names before publishing. After npm publish, verify package metadata and launcher install behavior: diff --git a/docs/testing.md b/docs/testing.md index 943d004..23e787d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -93,7 +93,7 @@ make verify - Scheduler tests verify generated launchd, systemd user timer, and cron text without installing schedules. - TUI tests cover fixed terminal rendering behavior, redaction boundaries, and embedded OpenTUI layout helpers. - Scheduler tests cover report history ordering, finding counts, non-report filtering, and symlink skipping without installing timers. -- Raycast extension tests cover pure redaction/formatting helpers, safe command execution wrappers, and Provider Doctor install routing through the shared action registry instead of direct shell execution. +- Raycast extension tests cover pure redaction/formatting helpers, safe command execution wrappers, report-history compare loading/error handling, and Provider Doctor install routing through the shared action registry instead of direct shell execution. - `cargo fmt`, `cargo clippy -D warnings`, `cargo test`, optional `cargo audit`/`cargo deny`, Gitleaks, and coverage checks are part of the local verification bar. - `make coverage-check` enforces the practical coverage target when `cargo-llvm-cov` is available, and always runs the Rust workspace tests. - `make ci-scripts-test` verifies repository-maintained CI helper scripts such as DCO checking, action path validation, and release-script input validation. @@ -119,6 +119,8 @@ CI validates that the JUnit report is parseable for every pull request. Trunk up `make release-snapshot` builds the Rust release binaries, creates a local archive, writes `checksums.txt`, and emits a lightweight SBOM placeholder for archive-shape validation. Real release signing remains restricted to the tag-driven release workflow. +`scripts/verify-release-archive.sh` generates the Homebrew formula only after signed checksum verification has passed. `make ci-scripts-test` keeps that helper wired to the current archive/checksum shape. + ## Raycast Extension The extension has its own npm package under `integrations/raycast`. diff --git a/integrations/raycast/CHANGELOG.md b/integrations/raycast/CHANGELOG.md index d9b8be0..53f886c 100644 --- a/integrations/raycast/CHANGELOG.md +++ b/integrations/raycast/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add a top-level Compare Nightward Reports command for read-only latest-report diffs. - Use a compact menu-bar finding count and move severity/provider detail into the dropdown. - Add scoped finding and rule fix-plan copy actions plus reviewed-policy-ignore snippets. - Redact additional provider token-shaped values in Raycast-rendered text. diff --git a/integrations/raycast/README.md b/integrations/raycast/README.md index a9e3f9e..4b9ca33 100644 --- a/integrations/raycast/README.md +++ b/integrations/raycast/README.md @@ -10,6 +10,7 @@ This extension is read-only until a user invokes the shared Nightward action reg - `Nightward Status`: compact menu-bar finding count, plus detailed findings, analysis signals, provider warnings, and scheduled-report state in the dropdown. - `Nightward Findings`: searchable findings with severity filters, detail panes, scoped fix-plan exports, reviewed-policy-ignore snippets, and redacted evidence copy. - `Nightward Analysis`: built-in offline analysis plus any providers explicitly selected in Provider Doctor. +- `Compare Nightward Reports`: top-level read-only comparison of the latest two saved Nightward reports. - `Nightward Provider Doctor`: provider availability, privacy posture, action-registry install guidance for missing tools, and Raycast Analysis enable/disable controls. - `Nightward Actions`: preview and apply confirmation-gated provider, policy, schedule, backup, cleanup, and setup actions from the shared Nightward action registry. - `Explain Nightward Finding`: detail view for a specific finding ID. diff --git a/integrations/raycast/package.json b/integrations/raycast/package.json index fa9479b..b6d9fe5 100644 --- a/integrations/raycast/package.json +++ b/integrations/raycast/package.json @@ -68,6 +68,13 @@ "mode": "view", "keywords": ["analysis", "signals", "security", "privacy", "trust"] }, + { + "name": "compare-reports", + "title": "Compare Nightward Reports", + "description": "Compare the latest two saved Nightward reports without mutating local config.", + "mode": "view", + "keywords": ["reports", "compare", "history", "diff", "security"] + }, { "name": "provider-doctor", "title": "Nightward Provider Doctor", diff --git a/integrations/raycast/raycast-env.d.ts b/integrations/raycast/raycast-env.d.ts index 8ad4782..8dd05e1 100644 --- a/integrations/raycast/raycast-env.d.ts +++ b/integrations/raycast/raycast-env.d.ts @@ -28,6 +28,8 @@ declare namespace Preferences { export type Findings = ExtensionPreferences & {} /** Preferences accessible in the `analysis` command */ export type Analysis = ExtensionPreferences & {} + /** Preferences accessible in the `compare-reports` command */ + export type CompareReports = ExtensionPreferences & {} /** Preferences accessible in the `provider-doctor` command */ export type ProviderDoctor = ExtensionPreferences & {} /** Preferences accessible in the `actions` command */ @@ -53,6 +55,8 @@ declare namespace Arguments { export type Findings = {} /** Arguments passed to the `analysis` command */ export type Analysis = {} + /** Arguments passed to the `compare-reports` command */ + export type CompareReports = {} /** Arguments passed to the `provider-doctor` command */ export type ProviderDoctor = {} /** Arguments passed to the `actions` command */ diff --git a/integrations/raycast/src/compare-reports.tsx b/integrations/raycast/src/compare-reports.tsx new file mode 100644 index 0000000..6f38002 --- /dev/null +++ b/integrations/raycast/src/compare-reports.tsx @@ -0,0 +1,63 @@ +import { + Action, + ActionPanel, + Detail, + Icon, + getPreferenceValues, +} from "@raycast/api"; +import { usePromise } from "@raycast/utils"; +import { + latestReportPair, + normalizePreferences, + reportHistory, + reportsDir, + reportsDirExists, +} from "./nightward"; +import { ReportCompareDetail } from "./report-compare"; + +export default function Command() { + const runtime = normalizePreferences(getPreferenceValues()); + const { data, error, isLoading, revalidate } = usePromise(async () => { + const history = await reportHistory(runtime); + return latestReportPair(history); + }); + + if (error) { + const reportDir = reportsDir(runtime.homeOverride); + const canOpenReports = reportsDirExists(runtime.homeOverride); + return ( + + + {canOpenReports ? ( + + ) : null} + + + } + /> + ); + } + + if (!data) { + return ( + + ); + } + + return ( + + ); +} diff --git a/integrations/raycast/src/dashboard.tsx b/integrations/raycast/src/dashboard.tsx index c3141c9..a921d44 100644 --- a/integrations/raycast/src/dashboard.tsx +++ b/integrations/raycast/src/dashboard.tsx @@ -16,8 +16,6 @@ import { findingTitle, fixPlanTotal, maxSeverity, - reportDiffMarkdown, - reportDiffSubtitle, severityColor, sortedFindings, } from "./format"; @@ -26,16 +24,14 @@ import { doctor, fixPlan, normalizePreferences, - reportDiff, reportsDir, scan, - type RuntimeOptions, } from "./nightward"; +import { ReportCompareDetail } from "./report-compare"; import type { AnalysisReport, DoctorReport, FixPlan, - ReportRecord, ScanReport, } from "./types"; @@ -459,96 +455,6 @@ function ScheduleDetail({ doctor }: { doctor: DoctorReport }) { ); } -function ReportCompareDetail({ - runtime, - base, - head, -}: { - runtime: RuntimeOptions; - base: ReportRecord; - head: ReportRecord; -}) { - const { data, error, isLoading, revalidate } = usePromise(() => - reportDiff(runtime, base.path, head.path), - ); - if (error) { - return ( - - - - - - } - /> - ); - } - if (!data) { - return ; - } - const markdown = reportDiffMarkdown(data); - return ( - - - - - - - - - - - - - - } - actions={ - - - - - - - } - /> - ); -} - function FixPlanDetail({ plan }: { plan: FixPlan }) { const lines = [ "# Fix Plan", diff --git a/integrations/raycast/src/nightward.ts b/integrations/raycast/src/nightward.ts index c2fd0d2..51e4ae9 100644 --- a/integrations/raycast/src/nightward.ts +++ b/integrations/raycast/src/nightward.ts @@ -13,6 +13,7 @@ import type { Finding, FixPlan, ProviderStatus, + ReportRecord, ScanReport, NightwardAction, NightwardActionPreview, @@ -192,6 +193,28 @@ export async function reportDiff( ); } +export async function reportHistory( + options: RuntimeOptions, +): Promise { + return runNightwardJSON( + ["report", "history", "--json"], + options, + ); +} + +export function latestReportPair(history: ReportRecord[]): { + base: ReportRecord; + head: ReportRecord; +} { + if (history.length < 2) { + throw new NightwardCommandError( + "nightward report history", + "At least two saved Nightward reports are required for comparison.", + ); + } + return { base: history[1], head: history[0] }; +} + export async function explainSignal( findingId: string, options: RuntimeOptions, diff --git a/integrations/raycast/src/report-compare.tsx b/integrations/raycast/src/report-compare.tsx new file mode 100644 index 0000000..9edcf62 --- /dev/null +++ b/integrations/raycast/src/report-compare.tsx @@ -0,0 +1,112 @@ +import { Action, ActionPanel, Detail, Icon } from "@raycast/api"; +import { usePromise } from "@raycast/utils"; +import { + basename, + reportDiffMarkdown, + reportDiffSubtitle, + severityColor, +} from "./format"; +import { reportDiff, type RuntimeOptions } from "./nightward"; +import type { ReportRecord } from "./types"; + +export function ReportCompareDetail({ + runtime, + base, + head, +}: { + runtime: RuntimeOptions; + base: ReportRecord; + head: ReportRecord; +}) { + const { data, error, isLoading, revalidate } = usePromise(() => + reportDiff(runtime, base.path, head.path), + ); + if (error) { + return ( + + } + /> + ); + } + if (!data) { + return ; + } + const markdown = reportDiffMarkdown(data); + return ( + + + + + + + + + + + + + + } + actions={ + + } + /> + ); +} + +function ReportCompareActions({ + base, + head, + markdown, + onRefresh, +}: { + base: ReportRecord; + head: ReportRecord; + markdown?: string; + onRefresh: () => void; +}) { + return ( + + {markdown ? ( + + ) : null} + + + + + ); +} diff --git a/integrations/raycast/src/types.ts b/integrations/raycast/src/types.ts index 744d747..1f91255 100644 --- a/integrations/raycast/src/types.ts +++ b/integrations/raycast/src/types.ts @@ -203,7 +203,7 @@ export type ReportRecord = { findings: number; highest_severity?: RiskLevel; findings_by_severity?: Partial>; - size_bytes: number; + size_bytes?: number; report_name: string; }; diff --git a/integrations/raycast/test/nightward.test.ts b/integrations/raycast/test/nightward.test.ts index 36e04c5..40ba000 100644 --- a/integrations/raycast/test/nightward.test.ts +++ b/integrations/raycast/test/nightward.test.ts @@ -8,11 +8,14 @@ import { exportFixPlanMarkdown, fixPlan, applyAction, + latestReportPair, listActions, + NightwardCommandError, normalizePreferences, previewAction, providersDoctor, reportDiff, + reportHistory, reportsDir, runNightwardJSON, type RuntimeOptions, @@ -425,6 +428,74 @@ test("report diff helper calls the CLI compare path", async () => { ]); }); +test("report history helper loads read-only history and selects latest pair", async () => { + let observedArgs: string[] = []; + const options: RuntimeOptions = { + executable: "nightward", + allowOnlineProviders: false, + timeoutMs: 1000, + execFileImpl: (_file, args, _options, callback) => { + observedArgs = args; + callback( + null, + JSON.stringify([ + { + path: "/tmp/current.json", + report_name: "current.json", + mod_time: "2026-05-06T00:00:00Z", + findings: 2, + size_bytes: 100, + }, + { + path: "/tmp/previous.json", + report_name: "previous.json", + mod_time: "2026-05-05T00:00:00Z", + findings: 1, + size_bytes: 100, + }, + ]), + "", + ); + }, + }; + + const history = await reportHistory(options); + const pair = latestReportPair(history); + + assert.deepEqual(observedArgs, ["report", "history", "--json"]); + assert.equal(pair.base.path, "/tmp/previous.json"); + assert.equal(pair.head.path, "/tmp/current.json"); +}); + +test("latest report pair errors when history cannot be compared", () => { + assert.throws( + () => latestReportPair([]), + (error) => + error instanceof NightwardCommandError && + /At least two saved Nightward reports/.test(error.message), + ); +}); + +test("report diff helper surfaces redacted CLI failures", async () => { + const options: RuntimeOptions = { + executable: "nightward", + allowOnlineProviders: false, + timeoutMs: 1000, + execFileImpl: (_file, _args, _options, callback) => { + const error = new Error("exit 1") as NodeJS.ErrnoException; + callback(error, "", "parse failed: API_TOKEN=secret-fixture-value\nmore"); + }, + }; + + await assert.rejects( + () => reportDiff(options, "/tmp/old.json", "/tmp/new.json"), + (error) => + error instanceof NightwardCommandError && + /report diff/.test(error.command) && + /API_TOKEN=\[redacted\]/.test(error.message), + ); +}); + function baseAnalysisJSON(): string { return JSON.stringify({ generated_at: "2026-05-01T00:00:00Z", diff --git a/scripts/generate-homebrew-formula.mjs b/scripts/generate-homebrew-formula.mjs new file mode 100644 index 0000000..7ce869a --- /dev/null +++ b/scripts/generate-homebrew-formula.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +const targets = [ + { os: "darwin", arch: "arm64", platform: "macos", cpu: "arm" }, + { os: "darwin", arch: "amd64", platform: "macos", cpu: "intel" }, + { os: "linux", arch: "arm64", platform: "linux", cpu: "arm" }, + { os: "linux", arch: "amd64", platform: "linux", cpu: "intel" }, +]; + +const options = parseArgs(process.argv.slice(2)); +const repo = options.repo || process.env.GITHUB_REPOSITORY || "JSONbored/nightward"; +const version = normalizeVersion(options.version || process.env.VERSION || ""); +const checksumsPath = options.checksums || "dist/checksums.txt"; +const urlBase = + options.urlBase || `https://github.com/${repo}/releases/download/v${version}`; +const output = options.output || "dist/homebrew/nightward.rb"; +const checksums = parseChecksums(readFileSync(checksumsPath, "utf8")); + +const formula = renderFormula({ repo, version, urlBase, checksums }); +if (output === "-") { + process.stdout.write(formula); +} else { + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, formula); + console.log(output); +} + +function parseArgs(args) { + const out = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg.startsWith("--")) { + throw new Error(`unexpected argument: ${arg}`); + } + const key = arg.slice(2); + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`missing value for ${arg}`); + } + out[key] = value; + index += 1; + } + return out; +} + +function normalizeVersion(value) { + const version = value.startsWith("v") ? value.slice(1) : value; + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) { + throw new Error("version must be strict SemVer, for example 0.1.6"); + } + return version; +} + +function parseChecksums(text) { + const checksums = new Map(); + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^([a-f0-9]{64})\s+\*?(.+)$/i); + if (match) checksums.set(match[2].trim(), match[1].toLowerCase()); + } + return checksums; +} + +function renderFormula({ repo, version, urlBase, checksums }) { + const platformBlocks = ["macos", "linux"].map((platform) => { + const cpuBlocks = targets + .filter((target) => target.platform === platform) + .map((target) => { + const asset = `nightward_${version}_${target.os}_${target.arch}.tar.gz`; + const sha256 = checksums.get(asset); + if (!sha256) { + throw new Error(`missing checksum for ${asset}`); + } + return [ + ` on_${target.cpu} do`, + ` url "${urlBase}/${asset}"`, + ` sha256 "${sha256}"`, + " end", + ].join("\n"); + }) + .join("\n"); + return [` on_${platform} do`, cpuBlocks, " end"].join("\n"); + }); + + return `${[ + "class Nightward < Formula", + ' desc "Local-first AI agent, MCP, and dotfiles risk scanner"', + ` homepage "https://github.com/${repo}"`, + ` version "${version}"`, + ' license "MIT"', + "", + ...platformBlocks, + "", + " def install", + ' bin.install "nightward", "nw"', + " end", + "", + " test do", + ' assert_match version.to_s, shell_output("#{bin}/nightward --version")', + ' assert_match version.to_s, shell_output("#{bin}/nw --version")', + " end", + "end", + ].join("\n")}\n`; +} diff --git a/scripts/test-release-scripts.sh b/scripts/test-release-scripts.sh index 6b002a7..70bb628 100755 --- a/scripts/test-release-scripts.sh +++ b/scripts/test-release-scripts.sh @@ -49,6 +49,7 @@ if [[ "$(grep -c "sigstore/cosign-installer" "${repo_root}/.github/workflows/rel echo "expected release publish and verification jobs to install cosign" >&2 exit 1 fi +grep -q "generate-homebrew-formula.mjs" "${repo_root}/scripts/verify-release-archive.sh" if grep -q "path: dist/nightward_\\*" "${repo_root}/.github/workflows/release.yml"; then echo "expected release upload to exclude staging directories" >&2 exit 1 @@ -91,6 +92,24 @@ if (server.version !== "0.1.10") throw new Error("server version was not stamped if (server.packages[0].version !== "0.1.10") throw new Error("package target was not stamped"); ' "${tmp}/stamp/server.json" +cat >"${tmp}/checksums.txt" <<'EOF' +1111111111111111111111111111111111111111111111111111111111111111 nightward_0.1.10_darwin_arm64.tar.gz +2222222222222222222222222222222222222222222222222222222222222222 nightward_0.1.10_darwin_amd64.tar.gz +3333333333333333333333333333333333333333333333333333333333333333 nightward_0.1.10_linux_arm64.tar.gz +4444444444444444444444444444444444444444444444444444444444444444 nightward_0.1.10_linux_amd64.tar.gz +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa checksums.txt.sigstore.json +EOF +node "${repo_root}/scripts/generate-homebrew-formula.mjs" \ + --version "0.1.10" \ + --repo "JSONbored/nightward" \ + --checksums "${tmp}/checksums.txt" \ + --output "${tmp}/nightward.rb" >/dev/null +grep -q 'url "https://github.com/JSONbored/nightward/releases/download/v0.1.10/nightward_0.1.10_darwin_arm64.tar.gz"' "${tmp}/nightward.rb" +grep -q 'sha256 "1111111111111111111111111111111111111111111111111111111111111111"' "${tmp}/nightward.rb" +grep -q 'bin.install "nightward", "nw"' "${tmp}/nightward.rb" +grep -q '#{bin}/nightward --version' "${tmp}/nightward.rb" +grep -q '#{bin}/nw --version' "${tmp}/nightward.rb" + mkdir -p "${tmp}/target/release" printf '#!/usr/bin/env bash\nprintf "0.1.0\\n"\n' >"${tmp}/target/release/nightward" cp "${tmp}/target/release/nightward" "${tmp}/target/release/nw" diff --git a/scripts/verify-release-archive.sh b/scripts/verify-release-archive.sh index 60be60f..f28870c 100644 --- a/scripts/verify-release-archive.sh +++ b/scripts/verify-release-archive.sh @@ -2,6 +2,7 @@ set -euo pipefail tag="${1:?release tag required}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repo="${GITHUB_REPOSITORY:-JSONbored/nightward}" if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "invalid release tag: ${tag}" >&2 @@ -50,6 +51,14 @@ cosign verify-blob \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ checksums.txt sha256sum -c checksums.txt --ignore-missing +node "${repo_root}/scripts/generate-homebrew-formula.mjs" \ + --version "${version}" \ + --repo "${repo}" \ + --checksums checksums.txt \ + --output "${tmp_dir}/homebrew/nightward.rb" >/dev/null +grep -q 'bin.install "nightward", "nw"' "${tmp_dir}/homebrew/nightward.rb" +grep -q '#{bin}/nightward --version' "${tmp_dir}/homebrew/nightward.rb" +grep -q '#{bin}/nw --version' "${tmp_dir}/homebrew/nightward.rb" mkdir -p extracted if [[ "${asset}" == *.zip ]]; then unzip -q "${asset}" -d extracted diff --git a/site/contribute/docs-maintenance.md b/site/contribute/docs-maintenance.md index 78763a2..1dba671 100644 --- a/site/contribute/docs-maintenance.md +++ b/site/contribute/docs-maintenance.md @@ -9,6 +9,7 @@ Nightward docs are split between human-authored guides and generated references. | CLI reference | `crates/nightward-cli` and `scripts/generate-reference-docs.mjs` | `make docs-reference-check` | | Rules reference | rule metadata emitted by the generator | `make docs-reference-check` | | Provider reference | analysis provider metadata | `make docs-reference-check` | +| Public command snippets | committed docs fixture home and real CLI output | `make docs-qa` | | Config examples | repo docs and fixture policies | `make docs-qa` | | Public guides | `site/**/*.md` | `make site-verify` | | Screenshots and samples | committed fixture homes | `make demo-assets` and manual review | @@ -39,14 +40,6 @@ make docs-qa 4. Run `make docs-qa` and `make site-verify`. 5. If screenshots changed, regenerate fixture-only assets and update `docs/screenshots.md`. -## Future Automation +## Docs Contracts -The next improvement is a docs contract check that compares public snippets against real command output. The useful shape is: - -- run documented commands against fixture homes; -- parse fenced command snippets with stable labels; -- fail when generated references are stale; -- fail when public pages mention future channels as shipped; -- optionally check outbound docs links on a scheduled workflow. - -That keeps the docs living without turning every page into generated text. +`site/test/docs-contract.test.mjs` runs stable public command snippets against `testdata/homes/docs-fixture`. Keep those contracts read-only: no live network commands, no output paths, and no confirmed local actions. The same test fails when public pages describe future channels as shipped or claim output fields that the CLI no longer emits. diff --git a/site/guide/install.md b/site/guide/install.md index 032e2a2..b29a1fb 100644 --- a/site/guide/install.md +++ b/site/guide/install.md @@ -75,7 +75,7 @@ This installs `nightward` and `nw` into `~/.local/bin` by default. | Cargo source build | Development | Useful for local Nightward development and branch comparison. | | Trunk plugin import | Shipped | Pin to a Nightward release tag or SHA. | | GitHub Action tags | Shipped | Use for policy/SARIF checks in CI. | -| Homebrew tap | Planned next | Best next macOS distribution channel. | +| Homebrew formula helper | Scaffolded | Generates a tap-ready formula from signed release checksums; no public tap command yet. | | Nix, Scoop, WinGet, mise, aqua | Later | Add after release artifact behavior stays stable. | Docker is deferred. It is awkward as a primary scanner because Nightward’s most useful mode audits user HOME and local AI-tool config, which should not be casually mounted into containers. diff --git a/site/integrations/raycast.md b/site/integrations/raycast.md index 8af6c20..53ac7cd 100644 --- a/site/integrations/raycast.md +++ b/site/integrations/raycast.md @@ -10,6 +10,7 @@ Nightward’s [Raycast](https://www.raycast.com/) extension is a macOS companion | Nightward Status | Compact menu-bar finding count with a structured dropdown. | No | | Nightward Findings | Browse findings, copy redacted evidence, export finding/rule fix plans, and copy reviewed-ignore snippets. | Clipboard only | | Nightward Analysis | Browse built-in and selected-provider analysis signals. | No | +| Compare Nightward Reports | Compare the latest two saved Nightward reports. | No | | Nightward Provider Doctor | Check provider availability, choose providers for Raycast Analysis, and preview/apply known provider install actions. | Raycast preference or confirmed action-registry provider install | | Nightward Actions | Preview and apply confirmed provider, policy, schedule, backup, cleanup, and setup actions. | Confirmation-gated local writes | | Explain Finding / Explain Signal | Jump directly to one known ID. | No | diff --git a/site/reference/distribution.md b/site/reference/distribution.md index dfe8131..3f24126 100644 --- a/site/reference/distribution.md +++ b/site/reference/distribution.md @@ -16,8 +16,10 @@ Nightward v0.1.4 is distributed through signed GitHub Releases and the npm launc ## Later Channels -Homebrew is the next packaging target. Nix, Scoop, WinGet, mise, and aqua should follow once release artifacts prove stable across a few tags. Docker is deferred because scanning a user's HOME from a container is awkward and easy to misconfigure. +Homebrew tap publication is the next packaging target. Nix, Scoop, WinGet, mise, and aqua should follow once release artifacts prove stable across a few tags. Docker is deferred because scanning a user's HOME from a container is awkward and easy to misconfigure. -## Homebrew Path +## Homebrew Support -Homebrew should be a small tap-backed formula generated from the signed GitHub Release archive and checksum data. The formula should install both `nightward` and `nw`, include a lightweight `nightward --version` test, and point users back to the release-verification docs. +`scripts/generate-homebrew-formula.mjs` now generates a tap-ready formula from `checksums.txt`. The formula uses the existing signed release archive names, installs both `nightward` and `nw`, and tests both command names with `--version`. The release verifier runs this generation after Cosign and checksum verification, so the formula stays tied to the canonical GitHub Release artifacts. + +There is not yet a published Homebrew tap command in public docs. diff --git a/site/reference/json-output.md b/site/reference/json-output.md index 1203c26..f343b96 100644 --- a/site/reference/json-output.md +++ b/site/reference/json-output.md @@ -41,17 +41,17 @@ Policy output includes pass/fail status, threshold, violations, ignored findings ```sh nw report diff --from previous.json --to current.json --json nw report history --json -nw report latest --json +nw report latest ``` Report diff output includes: -- `added_findings` -- `removed_findings` -- `changed_findings` -- summary counts for added, removed, changed, and unchanged findings +- `added` +- `removed` +- `changed` +- summary counts for added, removed, and changed findings -Report history records include `path`, `report_name`, `mod_time`, `findings`, `highest_severity`, `findings_by_severity`, and `size_bytes`. +Report history records include `path`, `report_name`, `mod_time`, and `findings`. `nw report latest` prints only the latest report path. ## Badge diff --git a/site/test/docs-contract.test.mjs b/site/test/docs-contract.test.mjs index c5bd016..a667be7 100644 --- a/site/test/docs-contract.test.mjs +++ b/site/test/docs-contract.test.mjs @@ -8,27 +8,60 @@ import { fileURLToPath } from "node:url"; import { test } from "vitest"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const docsFixtureHome = join(repoRoot, "testdata/homes/docs-fixture"); +const previousReport = join( + docsFixtureHome, + ".local/state/nightward/reports/previous.json", +); +const currentReport = join( + docsFixtureHome, + ".local/state/nightward/reports/current.json", +); test("public docs do not contain stale release placeholders", () => { const stalePatterns = [ - /After the first tagged release/i, - /First signed `?v0\.1\.0`? release/i, - /Trusted npm publishing/i, - /uses:\s*JSONbored\/nightward@v0\.1\.0/i, - /trunk .*v0\.1\.0/i, - /semantic_version:\s*0\.1\.0/i, - /Static HTML report export before any self-hosted dashboard/i, - /Broader provider execution beyond the first explicit local/i, - /Rules list\/explain commands and contributor fixture templates/i, + { pattern: /After the first tagged release/i, reason: "release placeholder" }, + { pattern: /First signed `?v0\.1\.0`? release/i, reason: "release placeholder" }, + { pattern: /uses:\s*JSONbored\/nightward@v0\.1\.0/i, reason: "old action tag" }, + { pattern: /trunk .*v0\.1\.0/i, reason: "old trunk tag" }, + { pattern: /semantic_version:\s*0\.1\.0/i, reason: "old plugin version" }, + { + pattern: /Static HTML report export before any self-hosted dashboard/i, + reason: "shipped report surface still described as roadmap", + }, + { + pattern: /Broader provider execution beyond the first explicit local/i, + reason: "shipped provider surface still described as roadmap", + }, + { + pattern: /Rules list\/explain commands and contributor fixture templates/i, + reason: "shipped rules surface still described as roadmap", + }, + { + pattern: /Homebrew tap\s*\|\s*Shipped/i, + reason: "tap publication must not be described as shipped until a tap exists", + }, + { + pattern: /brew install\s+JSONbored\/nightward\/nightward/i, + reason: "public brew install command must wait for a published tap", + }, + { + pattern: /nw report latest --json/i, + reason: "report latest prints the latest path, not a JSON object", + }, + { + pattern: /\b(?:added_findings|removed_findings|changed_findings)\b/i, + reason: "report diff JSON uses added, removed, and changed arrays", + }, ]; const files = gitTrackedDocs(); const failures = []; for (const file of files) { const text = readFileSync(join(repoRoot, file), "utf8"); - for (const pattern of stalePatterns) { + for (const { pattern, reason } of stalePatterns) { if (pattern.test(text)) { - failures.push(`${file}: ${pattern}`); + failures.push(`${file}: ${reason}: ${pattern}`); } } } @@ -36,6 +69,75 @@ test("public docs do not contain stale release placeholders", () => { assert.deepEqual(failures, []); }); +test("public docs command snippets match fixture-backed CLI behavior", { timeout: 120000 }, () => { + assertFencedSnippet( + "site/reference/json-output.md", + "```sh\nnw scan --json\n```", + ); + assertFencedSnippet( + "site/reference/json-output.md", + "```sh\nnw findings list --json\nnw findings explain --json\n```", + ); + assertFencedSnippet( + "site/reference/json-output.md", + "```sh\nnw report diff --from previous.json --to current.json --json\nnw report history --json\nnw report latest\n```", + ); + assertFencedSnippet("README.md", "```sh\nnw scan --json\n```"); + + const scan = runNightwardJSON(["scan", "--json"]); + assert.equal(scan.summary.total_findings, 4); + assert.equal(scan.summary.findings_by_rule.mcp_secret_env, 1); + assert.equal(scan.summary.findings_by_rule.mcp_unpinned_package, 1); + assert.doesNotMatch(JSON.stringify(scan), /docs-fixture-secret/); + + const findingId = scan.findings.find( + (finding) => finding.rule === "mcp_secret_env", + )?.id; + assert.ok(findingId); + const findings = runNightwardJSON(["findings", "list", "--json"]); + assert.equal(findings.length, scan.summary.total_findings); + const finding = runNightwardJSON([ + "findings", + "explain", + "--json", + findingId, + ]); + assert.equal(finding.rule, "mcp_secret_env"); + assert.equal(finding.severity, "critical"); + + const diff = runNightwardJSON([ + "report", + "diff", + "--from", + previousReport, + "--to", + currentReport, + "--json", + ]); + assert.deepEqual(diff.summary, { + added: 1, + removed: 1, + changed: 1, + max_added_severity: "critical", + }); + assert.equal(diff.added[0]?.id, "fixture-added"); + assert.equal(diff.removed[0]?.id, "fixture-old"); + assert.equal(diff.changed[0]?.id, "fixture-review"); + + const history = runNightwardJSON(["report", "history", "--json"]); + assert.deepEqual( + history.map((record) => record.report_name).sort(), + ["current.json", "previous.json"], + ); + assert.ok(history.every((record) => record.path.startsWith(docsFixtureHome))); + + const latest = runNightward(["report", "latest"]).trim(); + assert.match( + latest, + /testdata\/homes\/docs-fixture\/\.local\/state\/nightward\/reports\/.+\.json$/, + ); +}); + test("demo sample IDs match scrubbed fixture paths", () => { const scanPath = join(repoRoot, "site/public/demo/nightward-sample-scan.json"); const report = JSON.parse(readFileSync(scanPath, "utf8")); @@ -137,6 +239,47 @@ function request(id, method, params = {}) { return JSON.stringify({ jsonrpc: "2.0", id, method, params }); } +function assertFencedSnippet(file, snippet) { + const text = readFileSync(join(repoRoot, file), "utf8"); + assert.ok(text.includes(snippet), `${file} is missing stable snippet:\n${snippet}`); +} + +function runNightwardJSON(args) { + return JSON.parse(runNightward(args)); +} + +function runNightward(args) { + assertReadOnlyArgs(args); + return execFileSync("cargo", ["run", "--quiet", "--bin", "nw", "--", ...args], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + PATH: `${process.env.HOME}/.cargo/bin:/opt/homebrew/bin:${process.env.PATH || ""}`, + NIGHTWARD_HOME: docsFixtureHome, + }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function assertReadOnlyArgs(args) { + const command = ["nw", ...args].join(" "); + const writefulPatterns = [ + /\bnpx\b/, + /\bnpm\s+(?:install|publish|exec|run)\b/, + /--output(?!\s+-)/, + /\breport\s+html\b/, + /\bpolicy\s+init\b/, + /\bactions\s+apply\b/, + /\bschedule\s+(?:install|remove)\b/, + /\bbackup\s+(?:create|snapshot)\b/, + /\bmcp\s+serve\b/, + ]; + for (const pattern of writefulPatterns) { + assert.doesNotMatch(command, pattern, `docs command contract is not read-only: ${command}`); + } +} + function stableId(parts) { const hash = createHash("sha256"); for (const part of parts) { diff --git a/site/use/report-history.md b/site/use/report-history.md index 373a4ee..b620be2 100644 --- a/site/use/report-history.md +++ b/site/use/report-history.md @@ -31,4 +31,4 @@ nw report index `nw report html` scans HOME by default, renders the explicit `--input` scan JSON, or renders the `--from`/`--to` comparison alongside the latest report content. -The history index summarizes local report files with finding totals, highest severity, severity badges, and deltas against the next-newer report. +The history index summarizes local report files with report paths, modification times, and finding totals. The Raycast and TUI surfaces compute their review summaries from those saved reports without mutating them. diff --git a/testdata/homes/docs-fixture/.codex/config.toml b/testdata/homes/docs-fixture/.codex/config.toml new file mode 100644 index 0000000..459a5d4 --- /dev/null +++ b/testdata/homes/docs-fixture/.codex/config.toml @@ -0,0 +1,11 @@ +[mcp_servers.docs] +command = "npx" +args = [ + "@modelcontextprotocol/server-filesystem", + "/Users/example/Documents", + "--api-key", + "docs-fixture-secret" +] + +[mcp_servers.docs.env] +API_KEY = "docs-fixture-secret" diff --git a/testdata/homes/docs-fixture/.gitignore b/testdata/homes/docs-fixture/.gitignore new file mode 100644 index 0000000..d002c1a --- /dev/null +++ b/testdata/homes/docs-fixture/.gitignore @@ -0,0 +1,2 @@ +!.local/ +!.local/** diff --git a/testdata/homes/docs-fixture/.local/state/nightward/reports/current.json b/testdata/homes/docs-fixture/.local/state/nightward/reports/current.json new file mode 100644 index 0000000..d921c6f --- /dev/null +++ b/testdata/homes/docs-fixture/.local/state/nightward/reports/current.json @@ -0,0 +1,95 @@ +{ + "schema_version": 1, + "generated_at": "2026-05-06T00:00:00Z", + "hostname": "fixture", + "home": "/fixture/nightward-docs", + "scan_mode": "home", + "summary": { + "total_items": 1, + "total_findings": 2, + "items_by_classification": { + "portable": 1 + }, + "items_by_risk": { + "info": 1 + }, + "items_by_tool": { + "Codex": 1 + }, + "findings_by_severity": { + "high": 1, + "critical": 1 + }, + "findings_by_rule": { + "mcp_secret_env": 1, + "mcp_server_review": 1 + }, + "findings_by_tool": { + "Codex": 2 + } + }, + "items": [ + { + "id": "fixture-item", + "tool": "Codex", + "path": "/fixture/nightward-docs/.codex/config.toml", + "kind": "file", + "classification": "portable", + "risk": "info", + "reason": "Path is a portable-looking configuration file.", + "recommended_action": "Review generated findings before syncing.", + "exists": true + } + ], + "findings": [ + { + "id": "fixture-review", + "tool": "Codex", + "path": "/fixture/nightward-docs/.codex/config.toml", + "server": "docs", + "severity": "high", + "rule": "mcp_server_review", + "message": "Review MCP server \"docs\" before syncing this config after package args changed.", + "evidence": "npx @modelcontextprotocol/server-filesystem /Users/example/Documents --api-key [redacted]", + "recommended_action": "Confirm this server is intentional and pinned before syncing.", + "impact": "Unsafe portable config can expose secrets or local-only state.", + "why_this_matters": "Fixture reports keep docs examples stable.", + "docs_url": "https://nightward.aethereal.dev/reference/rules", + "fix_available": true, + "fix_kind": "manual-review", + "confidence": "medium", + "risk": "high", + "requires_review": true, + "fix_summary": "Confirm this server is intentional and pinned before syncing.", + "fix_steps": [ + "Inspect the redacted evidence.", + "Re-run Nightward and compare the next report." + ] + }, + { + "id": "fixture-added", + "tool": "Codex", + "path": "/fixture/nightward-docs/.codex/config.toml", + "server": "docs", + "severity": "critical", + "rule": "mcp_secret_env", + "message": "MCP server \"docs\" stores sensitive env key API_KEY inline.", + "evidence": "env.API_KEY=[REDACTED]", + "recommended_action": "Move the value to a local secret source and keep only the variable name in portable config.", + "impact": "Unsafe portable config can expose secrets or local-only state.", + "why_this_matters": "Fixture reports keep docs examples stable.", + "docs_url": "https://nightward.aethereal.dev/guide/remediation", + "fix_available": true, + "fix_kind": "externalize-secret", + "confidence": "medium", + "risk": "critical", + "requires_review": true, + "fix_summary": "Move the value to a local secret source.", + "fix_steps": [ + "Inspect the redacted evidence.", + "Move the value to a local secret source." + ] + } + ], + "adapters": [] +} diff --git a/testdata/homes/docs-fixture/.local/state/nightward/reports/previous.json b/testdata/homes/docs-fixture/.local/state/nightward/reports/previous.json new file mode 100644 index 0000000..10ff1c1 --- /dev/null +++ b/testdata/homes/docs-fixture/.local/state/nightward/reports/previous.json @@ -0,0 +1,94 @@ +{ + "schema_version": 1, + "generated_at": "2026-05-05T00:00:00Z", + "hostname": "fixture", + "home": "/fixture/nightward-docs", + "scan_mode": "home", + "summary": { + "total_items": 1, + "total_findings": 2, + "items_by_classification": { + "portable": 1 + }, + "items_by_risk": { + "info": 1 + }, + "items_by_tool": { + "Codex": 1 + }, + "findings_by_severity": { + "low": 1, + "medium": 1 + }, + "findings_by_rule": { + "config_stale": 1, + "mcp_server_review": 1 + }, + "findings_by_tool": { + "Codex": 2 + } + }, + "items": [ + { + "id": "fixture-item", + "tool": "Codex", + "path": "/fixture/nightward-docs/.codex/config.toml", + "kind": "file", + "classification": "portable", + "risk": "info", + "reason": "Path is a portable-looking configuration file.", + "recommended_action": "Review generated findings before syncing.", + "exists": true + } + ], + "findings": [ + { + "id": "fixture-review", + "tool": "Codex", + "path": "/fixture/nightward-docs/.codex/config.toml", + "server": "docs", + "severity": "medium", + "rule": "mcp_server_review", + "message": "Review MCP server \"docs\" before syncing this config.", + "evidence": "npx @modelcontextprotocol/server-filesystem /Users/example/Documents", + "recommended_action": "Confirm this server is intentional before syncing.", + "impact": "Unsafe portable config can expose secrets or local-only state.", + "why_this_matters": "Fixture reports keep docs examples stable.", + "docs_url": "https://nightward.aethereal.dev/reference/rules", + "fix_available": true, + "fix_kind": "manual-review", + "confidence": "medium", + "risk": "medium", + "requires_review": true, + "fix_summary": "Confirm this server is intentional before syncing.", + "fix_steps": [ + "Inspect the redacted evidence.", + "Re-run Nightward and compare the next report." + ] + }, + { + "id": "fixture-old", + "tool": "Codex", + "path": "/fixture/nightward-docs/.codex/config.toml", + "server": "docs", + "severity": "low", + "rule": "config_stale", + "message": "Config file has not changed in over 180 days.", + "evidence": "last_modified=2025-01-01T00:00:00Z", + "recommended_action": "Review whether this config is still active.", + "impact": "Stale config can preserve old assumptions.", + "why_this_matters": "Fixture reports keep docs examples stable.", + "docs_url": "https://nightward.aethereal.dev/reference/rules", + "fix_available": true, + "fix_kind": "manual-review", + "confidence": "medium", + "risk": "low", + "requires_review": true, + "fix_summary": "Review whether this config is still active.", + "fix_steps": [ + "Inspect the redacted evidence." + ] + } + ], + "adapters": [] +}