From 1afbab1e397e148b0d8e4318da660ee3b02ba6c1 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Wed, 6 May 2026 01:05:07 -0700
Subject: [PATCH] feat(security): harden mcp and action surfaces
Upgrade Nightward's MCP, TUI, Raycast, provider, release, and docs surfaces so write-capable behavior flows through the shared action registry with disclosure, confirmation, bounded paths, redaction, and audit logging.
Add regression coverage for MCP schemas/path scoping, Raycast provider install routing, symlink-safe state and snapshot writes, npm archive validation, provider fixtures, and docs contracts.
Signed-off-by: JSONbored <49853598+JSONbored@users.noreply.github.com>
---
.github/PULL_REQUEST_TEMPLATE.md | 2 +-
.github/workflows/ci.yml | 9 +-
.github/workflows/pages.yml | 7 +-
.github/workflows/release.yml | 10 +-
CHANGELOG.md | 6 +-
Makefile | 19 +-
README.md | 79 +-
ROADMAP.md | 2 +-
SECURITY.md | 2 +-
crates/nightward-cli/src/cli.rs | 224 ++-
crates/nightward-cli/src/tui.rs | 373 +++-
crates/nightward-core/src/actions.rs | 1103 ++++++++++++
crates/nightward-core/src/analysis.rs | 5 +-
crates/nightward-core/src/fixplan.rs | 5 +
crates/nightward-core/src/inventory.rs | 208 +++
crates/nightward-core/src/lib.rs | 2 +
crates/nightward-core/src/mcpserver.rs | 1598 +++++++++++++++--
crates/nightward-core/src/policy.rs | 7 +-
crates/nightward-core/src/providers.rs | 296 ++-
crates/nightward-core/src/rules.rs | 28 +
crates/nightward-core/src/schedule.rs | 298 ++-
crates/nightward-core/src/state.rs | 362 ++++
.../tests/provider_contracts.rs | 8 +
docs/analysis.md | 13 +-
docs/ci-security.md | 2 +-
docs/contributing-fixtures.md | 2 +-
docs/growth.md | 4 +-
docs/install.md | 2 +
docs/mcp-server.md | 89 +-
docs/openssf-best-practices.md | 8 +-
docs/privacy-model.md | 30 +-
docs/raycast-extension.md | 14 +-
docs/release.md | 8 +-
docs/testing.md | 25 +-
docs/threat-model.md | 18 +-
integrations/raycast/README.md | 7 +-
integrations/raycast/package-lock.json | 1255 ++++++++++++-
integrations/raycast/package.json | 15 +-
integrations/raycast/raycast-env.d.ts | 4 +
.../raycast/scripts/normalize-junit.mjs | 42 -
integrations/raycast/src/actions.tsx | 251 +++
integrations/raycast/src/dashboard.tsx | 2 +-
integrations/raycast/src/format.ts | 2 +-
integrations/raycast/src/nightward.ts | 32 +
integrations/raycast/src/provider-doctor.tsx | 76 +-
integrations/raycast/src/types.ts | 31 +
integrations/raycast/test/format.test.ts | 2 +-
integrations/raycast/test/nightward.test.ts | 95 +-
integrations/raycast/vitest.config.mjs | 9 +
integrations/raycast/vitest.junit.config.mjs | 23 +
packages/npm/README.md | 15 +-
packages/npm/bin/nightward.mjs | 142 +-
packages/npm/package-lock.json | 1279 +++++++++++++
packages/npm/package.json | 6 +-
packages/npm/test/launcher.test.mjs | 97 +-
packages/npm/test/mcp-registry.test.mjs | 31 +
packages/npm/vitest.config.mjs | 9 +
scripts/check-demo-ids.mjs | 46 -
scripts/check-docs-freshness.mjs | 44 -
scripts/generate-reference-docs.mjs | 25 +-
scripts/test-release-scripts.sh | 14 +-
scripts/verify-npm-release.sh | 2 +-
...e-archive.sh => verify-release-archive.sh} | 6 +-
server.json | 21 +
site/contribute/docs-maintenance.md | 2 +-
site/guide/cli.md | 7 +-
site/guide/install.md | 6 +-
site/guide/mcp-security.md | 3 +
site/guide/privacy-model.md | 15 +-
site/guide/remediation.md | 2 +-
site/guide/tui.md | 9 +-
site/index.md | 12 +-
site/integrations/mcp-server.md | 83 +-
site/integrations/raycast.md | 13 +-
site/package-lock.json | 368 ++++
site/package.json | 4 +
site/reference/cli.md | 12 +-
site/reference/distribution.md | 4 +-
site/reference/output-surfaces.md | 9 +-
site/reference/providers.md | 7 +-
site/reference/rules.md | 4 +
site/reference/support-matrix.md | 5 +-
site/roadmap.md | 3 +-
site/security/release-verification.md | 5 +-
site/security/threat-model.md | 8 +-
site/start/audit-mcp-workstation.md | 6 +-
site/start/before-syncing-dotfiles.md | 4 +-
site/test/docs-contract.test.mjs | 147 ++
site/use/provider-execution.md | 22 +-
site/use/troubleshooting.md | 2 +-
site/vitest.config.mjs | 9 +
testdata/providers/grype.json | 18 +
testdata/providers/scorecard.json | 15 +
testdata/providers/syft.json | 17 +
94 files changed, 8643 insertions(+), 629 deletions(-)
create mode 100644 crates/nightward-core/src/actions.rs
create mode 100644 crates/nightward-core/src/state.rs
delete mode 100644 integrations/raycast/scripts/normalize-junit.mjs
create mode 100644 integrations/raycast/src/actions.tsx
create mode 100644 integrations/raycast/vitest.config.mjs
create mode 100644 integrations/raycast/vitest.junit.config.mjs
create mode 100644 packages/npm/test/mcp-registry.test.mjs
create mode 100644 packages/npm/vitest.config.mjs
delete mode 100644 scripts/check-demo-ids.mjs
delete mode 100644 scripts/check-docs-freshness.mjs
rename scripts/{smoke-release-archive.sh => verify-release-archive.sh} (88%)
mode change 100755 => 100644
create mode 100644 server.json
create mode 100644 site/test/docs-contract.test.mjs
create mode 100644 site/vitest.config.mjs
create mode 100644 testdata/providers/grype.json
create mode 100644 testdata/providers/scorecard.json
create mode 100644 testdata/providers/syft.json
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 007d515..7fb875c 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -26,7 +26,7 @@
- [ ] `trunk check --show-existing --all`
- [ ] `make gitleaks`
- [ ] `make cargo-audit`
-- [ ] `make fuzz-smoke`
+- [ ] `make fuzz-check`
- [ ] `make release-snapshot` when release/build behavior changed
## Notes
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bf7d8b1..c5005d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -128,7 +128,7 @@ jobs:
run: npm ci --ignore-scripts --no-audit
working-directory: packages/npm
- - name: Test npm launcher
+ - name: Test npm launcher and MCP Registry metadata
run: npm test
working-directory: packages/npm
@@ -158,11 +158,16 @@ jobs:
run: npm ci --ignore-scripts --no-audit
working-directory: site
+ - name: Set up Rust for docs contract tests
+ run: |
+ rustup toolchain install 1.85.0 --profile minimal
+ rustup default 1.85.0
+
- name: Audit site dependencies
run: npm audit --audit-level=moderate
working-directory: site
- - name: Check generated docs freshness
+ - name: Run documentation contract tests
run: make docs-qa
- name: Build documentation site
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index a6e6a30..ee9f651 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -34,11 +34,16 @@ jobs:
run: npm ci --ignore-scripts --no-audit
working-directory: site
+ - name: Set up Rust for docs contract tests
+ run: |
+ rustup toolchain install 1.85.0 --profile minimal
+ rustup default 1.85.0
+
- name: Audit site dependencies
run: npm audit --audit-level=moderate
working-directory: site
- - name: Check generated docs freshness
+ - name: Run documentation contract tests
run: make docs-qa
- name: Configure Pages
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cc4298d..78b8ca5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -197,8 +197,8 @@ jobs:
--generate-notes \
--title "${REF_NAME}"
- release-smoke:
- name: Release smoke
+ release-verify:
+ name: Release verification
runs-on: ubuntu-latest
needs: publish
permissions:
@@ -212,17 +212,17 @@ jobs:
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- - name: Smoke published GitHub release artifacts
+ - name: Verify published GitHub release artifacts
env:
GH_TOKEN: ${{ github.token }}
REF_NAME: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
- run: bash scripts/smoke-release-archive.sh "${REF_NAME}"
+ run: bash scripts/verify-release-archive.sh "${REF_NAME}"
npm:
name: NPM package
runs-on: ubuntu-latest
- needs: release-smoke
+ needs: release-verify
if: ${{ vars.NPM_PUBLISH == 'true' }}
environment: npm-publish
permissions:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8855fa..fd7f383 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,7 +24,7 @@ not be reused.
provider-warning, policy, SARIF, TUI, Raycast, and HTML behavior.
- Hardened release publishing with signed-tag verification, Windows-compatible
builds for non-TUI commands, scoped archive uploads, and Cosign-backed release
- smoke before npm publication.
+ verification before npm publication.
## v0.1.5
@@ -50,7 +50,7 @@ First stable Nightward release.
- Added explicit local/online-capable provider execution support with opt-in
provider gates.
- Added release-gated GitHub artifacts with checksums, SBOMs, Cosign-signed
- checksum bundles, release smoke checks, and npm trusted-publishing support for
+ checksum bundles, release archive verification, and npm trusted-publishing support for
`@jsonbored/nightward`.
- Added OpenSSF-oriented governance, security policy, threat model, DCO, CodeQL,
Scorecard, coverage, and release snapshot gates.
@@ -62,5 +62,5 @@ Superseded prerelease attempts.
- `v0.1.1` and `v0.1.2` were superseded before the final npm/install
verification path was complete.
- `v0.1.3` proved GitHub/npm publishing and provenance, but the npm launcher
- symlink smoke gap was fixed in `v0.1.4`.
+ symlink verification gap was fixed in `v0.1.4`.
- Use `v0.1.4` or newer.
diff --git a/Makefile b/Makefile
index f2113f0..7eb8016 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-smoke 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-freshness docs-qa demo-ids-check site-install 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 release-snapshot verify build install-local clean-reports
doctor:
bash scripts/dev-doctor.sh
@@ -70,8 +70,8 @@ cargo-llvm-cov:
coverage-check: cargo-llvm-cov
@if [ -f "$(REPORTS_DIR)/coverage.txt" ]; then python3 -c 'import pathlib,re,sys; text=pathlib.Path("$(REPORTS_DIR)/coverage.txt").read_text(); nums=[float(x) for x in re.findall(r"([0-9]+(?:\.[0-9]+)?)%", text)]; pct=nums[-1] if nums else 100.0; threshold=float("$(COVERAGE_THRESHOLD)"); print(f"coverage {pct:.1f}% / threshold {threshold:.1f}%"); sys.exit(0 if pct >= threshold else 1)'; fi
-fuzz-smoke:
- @PATH="$(RUST_PATH)"; if command -v cargo-fuzz >/dev/null 2>&1; then cargo fuzz run mcp_config_formats -- -runs=256 && cargo fuzz run redaction_urls_headers -- -runs=256 && cargo fuzz run filesystem_boundaries -- -runs=128; else echo "cargo-fuzz not installed; skipping fuzz smoke"; fi
+fuzz-check:
+ @PATH="$(RUST_PATH)"; if command -v cargo-fuzz >/dev/null 2>&1; then cargo fuzz run mcp_config_formats -- -runs=256 && cargo fuzz run redaction_urls_headers -- -runs=256 && cargo fuzz run filesystem_boundaries -- -runs=128; else echo "cargo-fuzz not installed; skipping fuzz check"; fi
test-junit: clean-reports cargo-test raycast-install
mkdir -p $(REPORTS_DIR)/junit
@@ -134,6 +134,9 @@ npm-package-verify: npm-package-install npm-package-test npm-package-audit npm-p
site-install:
cd $(SITE_DIR) && npm ci --ignore-scripts --no-audit
+site-test:
+ cd $(SITE_DIR) && npm test
+
site-audit:
cd $(SITE_DIR) && npm audit --audit-level=moderate
@@ -146,15 +149,9 @@ docs-reference:
docs-reference-check:
node scripts/generate-reference-docs.mjs --check
-docs-freshness:
- node scripts/check-docs-freshness.mjs
-
-docs-qa: docs-reference-check docs-freshness demo-ids-check
-
-demo-ids-check:
- node scripts/check-demo-ids.mjs
+docs-qa: docs-reference-check site-test
-site-verify: docs-qa site-install site-audit site-build
+site-verify: site-install docs-qa site-audit site-build
demo-assets:
node scripts/generate-demo-assets.mjs
diff --git a/README.md b/README.md
index 56de5c9..0d604a8 100644
--- a/README.md
+++ b/README.md
@@ -12,10 +12,10 @@ It scans common Codex, Claude, Cursor, Windsurf, VS Code, Raycast, JetBrains, Ze
Public docs and the fixture TUI walkthrough live at .
-Nightward does not mutate agent configs. It only writes explicit report/SARIF files when requested. Schedule install/remove commands are plan-only in v1.
+Nightward is read-only by default, but it can run explicit, confirmation-gated local actions such as provider installation, provider enable/disable, scheduled scan install/remove, portable config snapshots, policy ignores, and Nightward-owned report/cache cleanup.
> [!IMPORTANT]
-> Nightward is local-first by design: no telemetry, no default network calls, no cloud dashboard, and no live agent-config mutation.
+> Nightward is local-first by design: no telemetry, no default network calls, and no cloud dashboard. Write-capable actions are beta operator tools. Users must review previews, confirmations, backups, package-manager behavior, and third-party provider behavior before applying changes. Nightward is provided without warranty, and maintainers are not liable for broken configs, lost data, exposed secrets, or third-party tool side effects.
## TUI Preview
@@ -27,10 +27,10 @@ The README uses a GIF so the preview renders directly on GitHub. The docs homepa
| Surface | What it does | Default write behavior |
| --- | --- | --- |
-| TUI | Dashboard, inventory, findings, analysis, fix plan, backup preview | Read-only except explicit redacted export |
-| CLI | Scriptable scan, doctor, policy, SARIF, snapshot, schedule commands | Read-only unless explicit output/export paths are requested |
-| MCP server | Read-only stdio tools/resources for AI clients | No writes, no network listener, no online providers |
-| Raycast | macOS read-only companion commands | Clipboard/report-folder actions only |
+| TUI | Dashboard, inventory, findings, analysis, fix plan, backup preview, action queue | Read-only until a confirmed action is applied |
+| CLI | Scriptable scan, doctor, policy, SARIF, snapshot, schedule, backup, and action commands | Read-only unless explicit output/export paths or `--confirm` actions are requested |
+| MCP server | Stdio tools/resources/prompts for AI clients | Direct apply only through shared action-registry IDs with disclosure, confirmation, redaction, and audit logging |
+| Raycast | macOS companion commands plus confirmed Nightward Actions | Clipboard/report-folder actions plus confirmation-gated writes |
| GitHub Action | Workspace policy and SARIF checks | Writes only requested CI outputs |
| Trunk plugin | Local workspace policy/analyze linters | Emits SARIF to stdout |
@@ -53,9 +53,9 @@ flowchart LR
classify --> tui["TUI"]
classify --> json["redacted JSON"]
mcp --> findings["findings + analysis signals"]
- findings --> fix["plan-only fix exports"]
+ findings --> fix["fix exports + action queue"]
findings --> sarif["policy SARIF"]
- classify --> backup["dry-run backup plan"]
+ classify --> backup["backup plan + confirmed snapshot"]
```
## Why
@@ -73,25 +73,25 @@ Nightward answers the practical questions first:
## Highlights
-- OpenTUI-powered interactive app with dashboard, findings, analysis, fix plan, inventory, backup preview, and help sections.
+- OpenTUI-powered interactive app with dashboard, findings, analysis, fix plan, inventory, backup preview, action queue, and help sections.
- `nightward` canonical command plus `nw` short alias.
- Redacted JSON for automation and CI.
- HOME scanning for local machines and `--workspace` scanning for CI, Trunk, and dotfiles repos.
-- MCP findings for unpinned package execution, shell wrappers, sensitive env keys, sensitive headers, local endpoints, broad filesystem access, token paths, parse failures, and unknown server shapes.
+- MCP findings for unpinned package execution, package-name impersonation risk, remote package sources, shell wrappers, Docker/socket exposure, sensitive env keys, sensitive headers, local/private endpoints, broad filesystem access, token paths, stale configs, app-owned state, parse failures, and unknown server shapes.
- Offline analysis signals for supply-chain, secret exposure, filesystem scope, network exposure, execution risk, machine-local, and app-owned state review.
-- Optional provider framework for local and online-capable tools; CLI providers never auto-install and online-capable providers stay blocked unless explicitly enabled.
+- Optional provider framework for Gitleaks, TruffleHog, Semgrep, Trivy, OSV-Scanner, Grype, Syft, OpenSSF Scorecard, and Socket; provider installs and online-capable execution stay confirmation/opt-in gated.
- Scan summaries separate inventory buckets from finding buckets: item classification/risk/tool counts are distinct from finding severity/rule/tool counts.
-- Plan-only remediation metadata: fix kind, confidence, risk, review requirement, impact, and steps.
+- Remediation metadata: fix kind, confidence, risk, review requirement, impact, steps, and bounded action specs where Nightward can safely preview writes.
- SARIF output for GitHub code scanning.
- Importable Trunk plugin definition for `nightward-policy` and `nightward-analyze` from pinned release tags.
- Optional `.nightward.yml` policy config with reason-required ignores.
- Redacted plan-only remediation exports for parseable MCP config findings.
-- Read-only snapshot plan commands.
+- Read-only snapshot plans plus confirmed portable config snapshot creation.
- Reusable GitHub Action for scan, policy, and SARIF modes.
-- Read-only Raycast extension for Dashboard, Findings, Analysis, Provider Doctor, Explain Finding/Signal, Fix Plan/Analysis export, and report-folder access.
-- Read-only stdio MCP server for AI clients that need local scan, finding, rule, provider, policy, and fix-plan context.
-- User-level nightly scan scheduling for macOS launchd, Linux systemd user timers, and cron text fallback.
-- No telemetry, no cloud dashboard, no default network calls from Nightward runtime, and no live config mutation.
+- Raycast extension for Dashboard, Findings, Analysis, 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.
- OpenSSF-oriented project hygiene: DCO, governance docs, threat model, coverage gate, pinned CI actions, release snapshot checks, signed release configuration, and security reporting policy.
> [!TIP]
@@ -118,7 +118,7 @@ For local development from this checkout:
make install-local
```
-The npm package is intentionally a thin launcher for GitHub Release binaries. It does not run a `postinstall` script; on first execution it downloads the matching release archive, verifies the archive SHA-256 from `checksums.txt`, caches the binaries locally, and then runs `nightward` or `nw`.
+The npm package is intentionally a thin launcher for GitHub Release binaries. It does not run a `postinstall` script; on first execution it downloads the matching release archive, verifies the archive SHA-256 from `checksums.txt`, rejects unsafe archive entries before extraction, caches the binaries locally, and then runs `nightward` or `nw`. Strict environments can set `NIGHTWARD_NPM_REQUIRE_SIGSTORE=1` to require Cosign verification of `checksums.txt.sigstore.json` before trusting the checksum file.
## Quick Start
@@ -152,6 +152,12 @@ Check local assumptions:
nw doctor --json
```
+Accept the beta responsibility disclosure before write-capable actions:
+
+```sh
+nw disclosure accept
+```
+
List and explain findings:
```sh
@@ -175,7 +181,7 @@ nw analyze --workspace . --json
nw analyze package npm:@modelcontextprotocol/server-filesystem --json
nw analyze finding mcp_unpinned_package-abc123 --json
nw providers list --json
-nw providers doctor --with socket --json
+nw providers doctor --with syft,gitleaks --json
nw rules list --json
nw rules explain mcp_secret_header --json
```
@@ -183,8 +189,8 @@ nw rules explain mcp_secret_header --json
Online-capable providers remain blocked until explicitly allowed:
```sh
-nw providers doctor --with socket --online --json
-nw analyze --workspace . --with trivy,osv-scanner,socket --online --json
+nw providers doctor --with trivy,grype,scorecard,socket --online --json
+nw analyze --workspace . --with trivy,osv-scanner,grype,scorecard,socket --online --json
```
Create or explain policy config:
@@ -193,6 +199,7 @@ Create or explain policy config:
nw policy init
nw policy explain
nw policy check --config .nightward.yml --strict --json
+nw actions apply policy.ignore --finding mcp_server_review-abc123 --reason "reviewed locally" --confirm
```
Generate a dry-run backup plan:
@@ -272,7 +279,7 @@ flowchart TD
## Fix Plan Model
-Nightward does not apply fixes yet. "Autofix" currently means structured, reviewable fix plans:
+Nightward separates review guidance from apply-capable actions. Fix plans remain structured review material for high-risk MCP edits, while the shared action layer can apply bounded local operations when Nightward knows the exact write surface:
- `pin-package`: pin `npx`, `uvx`, or `pipx` package execution when the package name is parseable
- `externalize-secret`: move inline secret values out of agent config and keep only env key names or setup docs
@@ -281,7 +288,7 @@ Nightward does not apply fixes yet. "Autofix" currently means structured, review
- `manual-review`: inspect unsupported, ambiguous, or high-risk config manually
- `ignore-with-reason`: keep an advisory finding only after documenting why it is expected
-Secret values are never emitted in scan JSON, findings output, fix-plan JSON, Markdown exports, SARIF, or TUI detail text.
+Secret values are never emitted in scan JSON, findings output, fix-plan JSON, Markdown exports, SARIF, or TUI detail text. Confirmed actions write audit events under Nightward local state.
`scan --json` is pre-1.0 and may make breaking shape improvements. The current summary schema uses explicit keys such as `items_by_classification`, `items_by_risk`, `findings_by_severity`, and `findings_by_rule` so item risk is not confused with finding severity.
@@ -289,7 +296,7 @@ Secret values are never emitted in scan JSON, findings output, fix-plan JSON, Ma
`nw analyze` turns scan findings and classifications into explainable signals. It does not claim a package, server, binary, or URL is safe. It reports what Nightward can prove from local structure, why it matters, and how confident the signal is.
-Default analysis is offline and built in. Optional providers are discovered by `providers doctor`; Nightward does not install them or call online services unless a user explicitly selects providers and opts into network-capable behavior. Explicit local providers are `gitleaks`, `trufflehog`, and `semgrep`. Online-capable providers are `trivy`, `osv-scanner`, and `socket`, and they require both `--with` and `--online`. Socket support creates a remote Socket scan artifact from dependency manifest metadata; Nightward does not fetch or normalize remote Socket reports in v1.
+Default analysis is offline and built in. Optional providers are discovered by `providers doctor`; Nightward does not call online services unless a user explicitly selects providers and opts into network-capable behavior. The CLI/TUI/Raycast/MCP action layer can install known provider CLIs after confirmation. Explicit local providers are `gitleaks`, `trufflehog`, `semgrep`, and `syft`. Online-capable providers are `trivy`, `osv-scanner`, `grype`, `scorecard`, and `socket`, and they require explicit online-provider opt-in. Socket support creates a remote Socket scan artifact from dependency manifest metadata; Nightward does not fetch or normalize remote Socket reports in v1.
Provider runs use explicit skip/block/ready states, timeouts, bounded output capture, and redacted metadata only. Oversized provider stdout fails closed as a provider warning instead of being partially parsed. Semgrep execution requires a repo-local config file so Nightward does not use automatic rule discovery by default.
@@ -318,13 +325,15 @@ The default `nightward` / `nw` command opens the TUI:
- Analysis: offline risk signals and provider warning summary
- Fix Plan: safe/review/blocked remediation groups
- Backup Plan: private-dotfiles dry-run preview
+- Actions: confirmation-gated provider, policy, schedule, backup, cleanup, and setup actions
The TUI is now part of the Rust CLI binary and uses `opentui_rust` directly for the colored dashboard, filled panels, severity ribbons, and fixture-driven screenshots. Release archives and npm-downloaded binaries only need `nightward` and `nw`.
Keyboard shortcuts:
-- `1`-`7`: switch sections
+- `1`-`8`: switch sections
- arrow keys or `h`/`j`/`k`/`l`: navigate
+- `enter`: confirm selected action in the Actions view
- `/`: search findings
- `s`: cycle severity
- `x`: clear filters
@@ -334,7 +343,7 @@ Fixture-only OpenTUI demo: [TUI gallery](site/guide/tui.md), [dashboard PNG](sit
## MCP Server
-Nightward can expose local, read-only context to MCP-capable AI clients:
+Nightward can expose local context and bounded Nightward action workflows to MCP-capable AI clients:
```json
{
@@ -347,7 +356,7 @@ Nightward can expose local, read-only context to MCP-capable AI clients:
}
```
-The server supports scan, doctor, findings, finding explanation, fix-plan, policy-check, and rules tools plus latest-summary and rules resources. It uses stdio only, does not open a network listener, does not mutate config, and does not enable online-capable providers in v1.
+The server supports scan, doctor, findings, finding/signal explanation, analysis, fix-plan, policy-check, report history/diff, action list/preview/apply, rules, providers, resources, and prompts. It uses stdio only, does not open a network listener, and cannot rewrite arbitrary MCP or agent config. `nightward_action_apply` can apply only shared action-registry IDs after disclosure acceptance, `confirm: true`, action availability checks, redacted output, and audit logging.
## GitHub Action
@@ -403,40 +412,38 @@ Commands:
- `Nightward Findings`
- `Nightward Analysis`
- `Nightward Provider Doctor`
+- `Nightward Actions`
- `Explain Nightward Finding`
- `Explain Nightward Signal`
- `Export Nightward Fix Plan`
- `Export Nightward Analysis`
- `Open Nightward Reports`
-The extension shells out to `nw` or `nightward`, renders redacted output, copies explicitly requested exports, and opens the local reports folder. Provider Doctor can enable/disable provider selection for Raycast Analysis and, after confirmation, install known provider CLIs with the displayed Homebrew/npm command. It does not mutate agent configs or install schedules.
+The extension shells out to `nw` or `nightward`, renders redacted output, copies explicitly requested exports, and opens the local reports folder. Provider Doctor can enable/disable provider selection for Raycast Analysis and can preview/apply known provider installs only through the shared action registry. `Nightward Actions` uses that same registry as the CLI/TUI for confirmed provider, policy, schedule, backup, cleanup, and disclosure actions.
See [docs/raycast-extension.md](docs/raycast-extension.md) for preferences, validation, and read-only boundaries.
## Scheduling
-The plan-only `nightly` preset describes running:
+The `nightly` preset runs:
```sh
nightward scan --json
```
-Planned user-level schedule targets:
+User-level schedule targets:
- macOS: `launchd` user agent
- Linux: systemd user timer
-- Other platforms: generated cron text only
-
-Schedule plans never copy secrets, mutate dotfiles, restore files, or push to Git.
+Schedule actions install user-level jobs only. They never install root daemons, copy secrets, mutate dotfiles, restore files, or push to Git.
## Development
```sh
make test
-make test-race
make test-junit
make coverage-check
-make fuzz-smoke
+make fuzz-check
make trunk-flaky-validate
make trunk-check
make ci-scripts-test
@@ -468,7 +475,7 @@ make gitleaks
make cargo-audit
make cargo-deny
make coverage-check
-make fuzz-smoke
+make fuzz-check
make release-snapshot
```
diff --git a/ROADMAP.md b/ROADMAP.md
index b076d57..0ea13a3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -24,7 +24,7 @@ Nightward is intentionally staged. The scanner and policy model need to stay tru
## Next
- More MCP config shapes for Codex, Claude Code, and editor integrations.
-- Raycast screenshots, store metadata, and manual development-mode smoke evidence.
+- Raycast screenshots, store metadata, and manual fixture-backed development-mode evidence.
- Golden SARIF snapshots and broader no-write tests.
- OpenTUI interaction polish with richer list/table behavior, detail panes, command palette, report history, mouse support, and fixture-driven visual regression screenshots.
- Deeper provider normalization, provider-specific fixtures, and clearer skip/timeout/output-cap reporting across CLI, TUI, Raycast, SARIF, policy, and HTML.
diff --git a/SECURITY.md b/SECURITY.md
index 7bb8b25..63faa81 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -16,7 +16,7 @@ Nightward is pre-1.0. Security fixes target `main` until tagged releases exist.
- Explicit output flags may write redacted report or SARIF artifacts.
- Scheduling only writes explicit user-level scheduler files through `schedule install`.
- Policy ignores require documented reasons so suppressions are reviewable.
-- Parser and provider hardening is regression-tested with fixture-backed unit tests plus optional cargo-fuzz harnesses. Local fuzz smoke is `make fuzz-smoke`; focused runs are `cargo fuzz run mcp_config_formats`, `cargo fuzz run redaction_urls_headers`, and `cargo fuzz run filesystem_boundaries`.
+- Parser and provider hardening is regression-tested with fixture-backed unit tests plus optional cargo-fuzz harnesses. The bounded local fuzz check is `make fuzz-check`; focused runs are `cargo fuzz run mcp_config_formats`, `cargo fuzz run redaction_urls_headers`, and `cargo fuzz run filesystem_boundaries`.
## Reporting a Vulnerability
diff --git a/crates/nightward-cli/src/cli.rs b/crates/nightward-cli/src/cli.rs
index 7fcbb44..3567487 100644
--- a/crates/nightward-cli/src/cli.rs
+++ b/crates/nightward-cli/src/cli.rs
@@ -7,7 +7,8 @@ use nightward_core::inventory::{
};
use nightward_core::policy::{self, PolicyConfig};
use nightward_core::{
- backupplan, mcpserver, providers, reportdiff, reporthtml, rules, schedule, snapshot,
+ actions, backupplan, mcpserver, providers, reportdiff, reporthtml, rules, schedule, snapshot,
+ state,
};
use serde::Serialize;
use std::env;
@@ -38,7 +39,10 @@ pub fn run() -> Result<()> {
"policy" => cmd_policy(&args),
"mcp" => cmd_mcp(&args),
"snapshot" => cmd_snapshot(&args),
+ "backup" => cmd_backup(&args),
"schedule" => cmd_schedule(&args),
+ "actions" => cmd_actions(&args),
+ "disclosure" => cmd_disclosure(&args),
"help" | "--help" | "-h" => {
print_help();
Ok(())
@@ -93,8 +97,9 @@ fn cmd_doctor(args: &[String]) -> Result<()> {
let home = home_dir_from_env();
let payload = serde_json::json!({
"schema_version": 1,
- "providers": providers::statuses(&[], false),
- "schedule": schedule::status(home),
+ "providers": providers::statuses(&selected_providers_for_args(args), online_for_args(args)),
+ "schedule": schedule::status(&home),
+ "disclosure": state::disclosure_status(home),
});
if has(args, "--json") {
print_json(&payload)?;
@@ -195,15 +200,8 @@ fn cmd_analyze(args: &[String]) -> Result<()> {
let options = AnalysisOptions {
mode: scan.scan_mode.clone(),
workspace: scan.workspace.clone(),
- with: value_after(args, "--with")
- .map(|value| {
- value
- .split(',')
- .map(|part| part.trim().to_string())
- .collect()
- })
- .unwrap_or_default(),
- online: has(args, "--online"),
+ with: selected_providers_for_args(args),
+ online: online_for_args(args),
package: if args.iter().any(|arg| arg == "package") {
positional_after_command(args, "package")
.unwrap_or_default()
@@ -224,18 +222,66 @@ fn cmd_analyze(args: &[String]) -> Result<()> {
}
fn cmd_providers(args: &[String]) -> Result<()> {
- let selected: Vec = value_after(args, "--with")
- .map(|value| {
- value
- .split(',')
- .map(|part| part.trim().to_string())
- .collect()
- })
- .unwrap_or_default();
- let online = has(args, "--online");
match args.first().map(String::as_str) {
Some("list") | None => print_json(&providers::providers()),
- Some("doctor") => print_json(&providers::statuses(&selected, online)),
+ Some("doctor") => print_json(&providers::statuses(
+ &selected_providers_for_args(args),
+ online_for_args(args),
+ )),
+ Some("enable") => {
+ let name = args
+ .get(1)
+ .ok_or_else(|| anyhow!("provider name required"))?;
+ let id = format!("provider.enable.{name}");
+ if !has(args, "--confirm") {
+ return print_json(&actions::preview(home_dir_from_env(), &id)?);
+ }
+ print_json(&actions::apply(
+ home_dir_from_env(),
+ &id,
+ actions::ApplyOptions {
+ confirm: true,
+ executable: current_executable(),
+ ..Default::default()
+ },
+ )?)
+ }
+ Some("disable") => {
+ let name = args
+ .get(1)
+ .ok_or_else(|| anyhow!("provider name required"))?;
+ let id = format!("provider.disable.{name}");
+ if !has(args, "--confirm") {
+ return print_json(&actions::preview(home_dir_from_env(), &id)?);
+ }
+ print_json(&actions::apply(
+ home_dir_from_env(),
+ &id,
+ actions::ApplyOptions {
+ confirm: true,
+ executable: current_executable(),
+ ..Default::default()
+ },
+ )?)
+ }
+ Some("install") => {
+ let name = args
+ .get(1)
+ .ok_or_else(|| anyhow!("provider name required"))?;
+ let id = format!("provider.install.{name}");
+ if !has(args, "--confirm") {
+ return print_json(&actions::preview(home_dir_from_env(), &id)?);
+ }
+ print_json(&actions::apply(
+ home_dir_from_env(),
+ &id,
+ actions::ApplyOptions {
+ confirm: true,
+ executable: current_executable(),
+ ..Default::default()
+ },
+ )?)
+ }
_ => Err(anyhow!("unknown providers command")),
}
}
@@ -411,16 +457,112 @@ fn cmd_snapshot(args: &[String]) -> Result<()> {
}
}
+fn cmd_backup(args: &[String]) -> Result<()> {
+ match args.first().map(String::as_str) {
+ Some("plan") | None => print_json(&backupplan::plan(home_dir_from_env())),
+ Some("create") | Some("snapshot") => {
+ if !has(args, "--confirm") {
+ return print_json(&actions::preview(home_dir_from_env(), "backup.snapshot")?);
+ }
+ print_json(&actions::apply(
+ home_dir_from_env(),
+ "backup.snapshot",
+ actions::ApplyOptions {
+ confirm: true,
+ executable: current_executable(),
+ ..Default::default()
+ },
+ )?)
+ }
+ _ => Err(anyhow!("unknown backup command")),
+ }
+}
+
fn cmd_schedule(args: &[String]) -> Result<()> {
match args.first().map(String::as_str) {
Some("status") | None => print_json(&schedule::status(home_dir_from_env())),
- Some("plan") => print_json(&schedule::plan(true)),
- Some("install") => print_json(&schedule::plan(true)),
- Some("remove") => print_json(&schedule::plan(false)),
+ Some("plan") => print_json(&schedule::plan(
+ home_dir_from_env(),
+ true,
+ ¤t_executable(),
+ )),
+ Some("install") => {
+ if !has(args, "--confirm") {
+ return print_json(&actions::preview(home_dir_from_env(), "schedule.install")?);
+ }
+ print_json(&actions::apply(
+ home_dir_from_env(),
+ "schedule.install",
+ actions::ApplyOptions {
+ confirm: true,
+ executable: current_executable(),
+ ..Default::default()
+ },
+ )?)
+ }
+ Some("remove") => {
+ if !has(args, "--confirm") {
+ return print_json(&actions::preview(home_dir_from_env(), "schedule.remove")?);
+ }
+ print_json(&actions::apply(
+ home_dir_from_env(),
+ "schedule.remove",
+ actions::ApplyOptions {
+ confirm: true,
+ executable: current_executable(),
+ ..Default::default()
+ },
+ )?)
+ }
_ => Err(anyhow!("unknown schedule command")),
}
}
+fn cmd_actions(args: &[String]) -> Result<()> {
+ match args.first().map(String::as_str) {
+ Some("list") | None => print_json(&actions::list(home_dir_from_env())),
+ Some("preview") => {
+ let id = args.get(1).ok_or_else(|| anyhow!("action id required"))?;
+ print_json(&actions::preview(home_dir_from_env(), id)?)
+ }
+ Some("apply") => {
+ let id = args.get(1).ok_or_else(|| anyhow!("action id required"))?;
+ print_json(&actions::apply(
+ home_dir_from_env(),
+ id,
+ actions::ApplyOptions {
+ confirm: has(args, "--confirm"),
+ executable: current_executable(),
+ policy_path: value_after(args, "--policy")
+ .or_else(|| value_after(args, "--config"))
+ .unwrap_or("")
+ .to_string(),
+ finding_id: value_after(args, "--finding").unwrap_or("").to_string(),
+ rule: value_after(args, "--rule").unwrap_or("").to_string(),
+ reason: value_after(args, "--reason").unwrap_or("").to_string(),
+ },
+ )?)
+ }
+ _ => Err(anyhow!("unknown actions command")),
+ }
+}
+
+fn cmd_disclosure(args: &[String]) -> Result<()> {
+ match args.first().map(String::as_str) {
+ Some("status") | None => print_json(&state::disclosure_status(home_dir_from_env())),
+ Some("accept") => print_json(&actions::apply(
+ home_dir_from_env(),
+ "disclosure.accept",
+ actions::ApplyOptions {
+ confirm: true,
+ executable: current_executable(),
+ ..Default::default()
+ },
+ )?),
+ _ => Err(anyhow!("unknown disclosure command")),
+ }
+}
+
fn selector(args: &[String]) -> Selector {
Selector {
all: has(args, "--all") || (!has(args, "--finding") && !has(args, "--rule")),
@@ -471,6 +613,36 @@ fn has(args: &[String], key: &str) -> bool {
args.iter().any(|arg| arg == key)
}
+fn selected_providers_for_args(args: &[String]) -> Vec {
+ value_after(args, "--with")
+ .map(|value| {
+ value
+ .split(',')
+ .map(|part| part.trim().to_string())
+ .filter(|part| !part.is_empty())
+ .collect()
+ })
+ .unwrap_or_else(|| {
+ state::load_settings(home_dir_from_env())
+ .map(|settings| settings.selected_providers)
+ .unwrap_or_default()
+ })
+}
+
+fn online_for_args(args: &[String]) -> bool {
+ has(args, "--online")
+ || state::load_settings(home_dir_from_env())
+ .map(|settings| settings.allow_online_providers)
+ .unwrap_or(false)
+}
+
+fn current_executable() -> String {
+ env::current_exe()
+ .ok()
+ .map(|path| path.display().to_string())
+ .unwrap_or_else(|| "nightward".to_string())
+}
+
fn positional_after_command<'a>(args: &'a [String], command: &str) -> Option<&'a str> {
let mut iter = args
.iter()
@@ -525,7 +697,7 @@ fn version() -> &'static str {
fn print_help() {
println!(
- "Nightward audits AI agent state, MCP config, and dotfiles sync risk.\n\nUSAGE:\n nightward Open the TUI\n nightward tui --input scan.json Review a saved report in the TUI\n nightward tui --from old.json --to new.json\n nightward scan --json Scan HOME\n nightward scan --workspace . --json\n nightward analyze --all --with gitleaks --json\n nightward providers doctor --with trivy --online --json\n nightward fix plan --all --json\n nightward report html --input scan.json --output report.html\n nightward report html --from old.json --to new.json --output report.html\n nightward policy check --json\n nightward mcp serve\n\nNightward is local-first, read-only by default, and never enables online providers without --online."
+ "Nightward audits AI agent state, MCP config, and dotfiles sync risk.\n\nUSAGE:\n nightward Open the TUI\n nightward tui --input scan.json Review a saved report in the TUI\n nightward tui --from old.json --to new.json\n nightward scan --json Scan HOME\n nightward scan --workspace . --json\n nightward analyze --all --with gitleaks --json\n nightward providers doctor --with trivy --online --json\n nightward providers enable gitleaks --confirm\n nightward providers install gitleaks --confirm\n nightward disclosure accept\n nightward fix plan --all --json\n nightward backup create --confirm\n nightward schedule install --confirm\n nightward actions list --json\n nightward actions apply backup.snapshot --confirm\n nightward actions apply reports.cleanup --confirm\n nightward actions apply cache.cleanup --confirm\n nightward actions apply policy.ignore --finding --reason \"reviewed\" --confirm\n nightward report html --input scan.json --output report.html\n nightward report html --from old.json --to new.json --output report.html\n nightward policy check --json\n nightward mcp serve\n\nNightward is local-first and read-only by default. Write-capable actions require disclosure acceptance and explicit confirmation."
);
}
diff --git a/crates/nightward-cli/src/tui.rs b/crates/nightward-cli/src/tui.rs
index d928d9a..5126520 100644
--- a/crates/nightward-cli/src/tui.rs
+++ b/crates/nightward-cli/src/tui.rs
@@ -2,7 +2,9 @@ use anyhow::Result;
use nightward_core::analysis::{self, Options as AnalysisOptions};
use nightward_core::fixplan::{self, Selector};
use nightward_core::reportdiff::DiffReport;
-use nightward_core::{backupplan, max_risk, Classification, Finding, Report, RiskLevel};
+use nightward_core::{
+ actions, backupplan, max_risk, state, Classification, Finding, Report, RiskLevel,
+};
use opentui::buffer::{BoxOptions, BoxStyle, ClipRect, TitleAlign};
use opentui::input::{Event, InputParser, KeyCode};
use opentui::terminal::{enable_raw_mode, terminal_size};
@@ -12,13 +14,14 @@ use std::io::{self, Read};
use std::sync::mpsc;
use std::time::Duration;
-const VIEWS: [&str; 7] = [
+const VIEWS: [&str; 8] = [
"Overview",
"Findings",
"Analysis",
"Fix Plan",
"Inventory",
"Backup",
+ "Actions",
"Help",
];
const REPO_LABEL: &str = "github.com/JSONbored/nightward";
@@ -48,6 +51,16 @@ fn view_slug(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(['_', ' '], "-")
}
+#[cfg(not(test))]
+fn disclosure_pending_for(report: &Report) -> bool {
+ !state::disclosure_status(&report.home).accepted
+}
+
+#[cfg(test)]
+fn disclosure_pending_for(_report: &Report) -> bool {
+ false
+}
+
fn version() -> &'static str {
option_env!("NIGHTWARD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
}
@@ -194,6 +207,10 @@ struct TuiState<'a> {
report: &'a Report,
active_view: usize,
selected_finding: usize,
+ selected_action: usize,
+ pending_action: Option,
+ action_message: String,
+ disclosure_pending: bool,
severity_filter: Option,
search_query: String,
search_mode: bool,
@@ -489,6 +506,10 @@ impl<'a> TuiState<'a> {
report,
active_view: 0,
selected_finding: 0,
+ selected_action: 0,
+ pending_action: None,
+ action_message: String::new(),
+ disclosure_pending: disclosure_pending_for(report),
severity_filter: None,
search_query: String::new(),
search_mode: false,
@@ -508,6 +529,31 @@ impl<'a> TuiState<'a> {
if key.is_ctrl_c() {
return false;
}
+ if self.disclosure_pending {
+ match key.code {
+ KeyCode::Enter | KeyCode::Char('y') => {
+ self.apply_action("disclosure.accept".to_string());
+ self.disclosure_pending = false;
+ }
+ KeyCode::Char('q') | KeyCode::Esc => return false,
+ _ => {}
+ }
+ return true;
+ }
+ if let Some(action_id) = self.pending_action.clone() {
+ match key.code {
+ KeyCode::Char('y') | KeyCode::Enter => {
+ self.apply_action(action_id);
+ self.pending_action = None;
+ }
+ KeyCode::Char('n') | KeyCode::Esc => {
+ self.pending_action = None;
+ self.action_message = "action canceled".to_string();
+ }
+ _ => {}
+ }
+ return true;
+ }
if self.search_mode {
match key.code {
KeyCode::Esc | KeyCode::Enter => self.search_mode = false,
@@ -529,11 +575,12 @@ impl<'a> TuiState<'a> {
match key.code {
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => self.next_view(),
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => self.previous_view(),
- KeyCode::Char(ch @ '1'..='7') => {
+ KeyCode::Char(ch @ '1'..='8') => {
self.active_view = usize::from(ch as u8 - b'1');
}
- KeyCode::Down | KeyCode::Char('j') => self.select_next_finding(),
- KeyCode::Up | KeyCode::Char('k') => self.select_previous_finding(),
+ KeyCode::Down | KeyCode::Char('j') => self.select_next(),
+ KeyCode::Up | KeyCode::Char('k') => self.select_previous(),
+ KeyCode::Enter if self.active_view == 6 => self.confirm_selected_action(),
KeyCode::Char('/') => self.search_mode = true,
KeyCode::Char('s') => self.cycle_severity_filter(),
KeyCode::Char('x') => self.clear_filters(),
@@ -550,6 +597,78 @@ impl<'a> TuiState<'a> {
self.active_view = self.active_view.checked_sub(1).unwrap_or(VIEWS.len() - 1);
}
+ fn select_next(&mut self) {
+ if self.active_view == 6 {
+ let count = self.actions().len();
+ if count == 0 {
+ self.selected_action = 0;
+ } else {
+ self.selected_action = (self.selected_action + 1) % count;
+ }
+ } else {
+ self.select_next_finding();
+ }
+ }
+
+ fn select_previous(&mut self) {
+ if self.active_view == 6 {
+ let count = self.actions().len();
+ if count == 0 {
+ self.selected_action = 0;
+ } else {
+ self.selected_action = self.selected_action.checked_sub(1).unwrap_or(count - 1);
+ }
+ } else {
+ self.select_previous_finding();
+ }
+ }
+
+ fn actions(&self) -> Vec {
+ actions::list(&self.report.home)
+ }
+
+ fn selected_action(&self) -> Option {
+ self.actions().into_iter().nth(self.selected_action)
+ }
+
+ fn confirm_selected_action(&mut self) {
+ let Some(action) = self.selected_action() else {
+ self.action_message = "no action selected".to_string();
+ return;
+ };
+ if !action.available {
+ self.action_message = action.blocked_reason;
+ return;
+ }
+ self.pending_action = Some(action.id.clone());
+ self.action_message = format!("press y to apply {} or n to cancel", action.id);
+ }
+
+ fn apply_action(&mut self, action_id: String) {
+ match actions::apply(
+ &self.report.home,
+ &action_id,
+ actions::ApplyOptions {
+ confirm: true,
+ executable: std::env::current_exe()
+ .ok()
+ .map(|path| path.display().to_string())
+ .unwrap_or_else(|| "nightward".to_string()),
+ ..Default::default()
+ },
+ ) {
+ Ok(result) => {
+ self.action_message = result.message;
+ self.selected_action = self
+ .selected_action
+ .min(self.actions().len().saturating_sub(1));
+ }
+ Err(err) => {
+ self.action_message = format!("action failed: {err}");
+ }
+ }
+ }
+
fn select_next_finding(&mut self) {
let count = self.display_findings().len();
if count == 0 {
@@ -598,6 +717,13 @@ impl<'a> TuiState<'a> {
fn render(&self, buffer: &mut OptimizedBuffer, width: u32, height: u32) {
buffer.clear(self.palette.bg);
+ if self.disclosure_pending {
+ self.render_disclosure(
+ buffer,
+ Area::new(2, 1, width.saturating_sub(4), height.saturating_sub(2)),
+ );
+ return;
+ }
if width < 72 || height < 18 {
self.render_tiny(buffer, width, height);
return;
@@ -622,6 +748,47 @@ impl<'a> TuiState<'a> {
);
}
+ fn render_disclosure(&self, buffer: &mut OptimizedBuffer, area: Area) {
+ draw_panel(
+ buffer,
+ area,
+ "Beta Responsibility Disclosure",
+ self.palette.amber,
+ self.palette.panel,
+ );
+ let status = state::disclosure_status(&self.report.home);
+ let mut row = area.y + 2;
+ draw_text(
+ buffer,
+ area.x + 2,
+ row,
+ "Nightward can run write-capable local actions after confirmation.",
+ Style::fg(self.palette.white).with_bold(),
+ );
+ row += 2;
+ for line in wrap(&status.text, area.w.saturating_sub(4) as usize)
+ .into_iter()
+ .take(area.h.saturating_sub(8) as usize)
+ {
+ draw_text(
+ buffer,
+ area.x + 2,
+ row,
+ &line,
+ Style::fg(self.palette.white),
+ );
+ row += 1;
+ }
+ row += 1;
+ draw_text(
+ buffer,
+ area.x + 2,
+ row,
+ "Press Enter/y to accept and continue. Press q/Esc to quit.",
+ Style::fg(self.palette.cyan).with_bold(),
+ );
+ }
+
fn render_tiny(&self, buffer: &mut OptimizedBuffer, width: u32, height: u32) {
buffer.clear(self.palette.bg);
let title = "Nightward";
@@ -695,7 +862,7 @@ impl<'a> TuiState<'a> {
);
draw_hline(buffer, 1, 4, width.saturating_sub(2), self.palette.line);
- let nav = "1 Overview 2 Findings 3 Analysis 4 Fix Plan 5 Inventory 6 Backup 7 Help";
+ let nav = "1 Overview 2 Findings 3 Analysis 4 Fix Plan 5 Inventory 6 Backup 7 Actions 8 Help";
draw_text(
buffer,
1,
@@ -794,7 +961,7 @@ impl<'a> TuiState<'a> {
let compact = limit < 48;
let max_x = x + limit;
let hints = [
- ("tab/1-7", "navigate"),
+ ("tab/1-8", "navigate"),
("/", "search"),
("s", "severity"),
("x", "clear"),
@@ -966,7 +1133,7 @@ impl<'a> TuiState<'a> {
buffer,
x,
posture_y + 6,
- "tab/1-7 navigate",
+ "tab/1-8 navigate",
Style::fg(self.palette.muted),
);
draw_text(
@@ -1005,6 +1172,7 @@ impl<'a> TuiState<'a> {
3 => self.render_fix_plan(buffer, area),
4 => self.render_inventory(buffer, area),
5 => self.render_backup(buffer, area),
+ 6 => self.render_actions(buffer, area),
_ => self.render_help(buffer, area),
}
}
@@ -1862,6 +2030,165 @@ impl<'a> TuiState<'a> {
}
}
+ fn render_actions(&self, buffer: &mut OptimizedBuffer, area: Area) {
+ let actions = self.actions();
+ let left_w = responsive_width(area.w, 48, 34, 40);
+ let right_x = area.x + left_w + 2;
+ let right_w = area.w.saturating_sub(left_w + 2);
+
+ draw_panel(
+ buffer,
+ Area::new(area.x, area.y, left_w, area.h),
+ "Action Queue",
+ self.palette.amber,
+ self.palette.panel,
+ );
+ let mut row = area.y + 2;
+ for (idx, action) in actions
+ .iter()
+ .enumerate()
+ .take(area.h.saturating_sub(4) as usize / 2)
+ {
+ let selected = idx == self.selected_action;
+ let color = action_color(&self.palette, action);
+ if selected {
+ buffer.fill_rect(
+ area.x + 1,
+ row.saturating_sub(1),
+ left_w.saturating_sub(2),
+ 2,
+ self.palette.surface,
+ );
+ }
+ draw_text(
+ buffer,
+ area.x + 2,
+ row,
+ &truncate(&action.title, left_w.saturating_sub(16) as usize),
+ if selected {
+ Style::fg(color).with_bold()
+ } else {
+ Style::fg(color)
+ },
+ );
+ draw_text(
+ buffer,
+ area.x + left_w.saturating_sub(11),
+ row,
+ &truncate(&action.risk, 8),
+ Style::fg(color),
+ );
+ row += 1;
+ draw_text(
+ buffer,
+ area.x + 2,
+ row,
+ &truncate(&action.category, left_w.saturating_sub(4) as usize),
+ Style::fg(self.palette.muted),
+ );
+ row += 1;
+ }
+
+ draw_panel(
+ buffer,
+ Area::new(right_x, area.y, right_w, area.h),
+ "Preview",
+ self.palette.cyan,
+ self.palette.surface,
+ );
+ let mut row = area.y + 2;
+ if let Some(action) = self.selected_action() {
+ draw_text(
+ buffer,
+ right_x + 2,
+ row,
+ &truncate(&action.title, right_w.saturating_sub(4) as usize),
+ Style::fg(action_color(&self.palette, &action)).with_bold(),
+ );
+ row += 2;
+ for line in wrap(&action.description, right_w.saturating_sub(4) as usize)
+ .into_iter()
+ .take(3)
+ {
+ draw_text(
+ buffer,
+ right_x + 2,
+ row,
+ &line,
+ Style::fg(self.palette.white),
+ );
+ row += 1;
+ }
+ row += 1;
+ section_label(buffer, right_x + 2, row, "writes", self.palette.cyan);
+ row += 2;
+ for write in action.writes.iter().take(5) {
+ draw_text(buffer, right_x + 2, row, "•", Style::fg(self.palette.amber));
+ draw_text(
+ buffer,
+ right_x + 5,
+ row,
+ &truncate(write, right_w.saturating_sub(7) as usize),
+ Style::fg(self.palette.white),
+ );
+ row += 1;
+ }
+ if !action.command.is_empty() {
+ row += 1;
+ section_label(buffer, right_x + 2, row, "command", self.palette.cyan);
+ row += 2;
+ draw_text(
+ buffer,
+ right_x + 2,
+ row,
+ &truncate(
+ &action.command.join(" "),
+ right_w.saturating_sub(4) as usize,
+ ),
+ Style::fg(self.palette.white),
+ );
+ row += 2;
+ }
+ if !action.blocked_reason.is_empty() {
+ for line in wrap(&action.blocked_reason, right_w.saturating_sub(4) as usize)
+ .into_iter()
+ .take(3)
+ {
+ draw_text(buffer, right_x + 2, row, &line, Style::fg(self.palette.red));
+ row += 1;
+ }
+ }
+ }
+ if let Some(action) = &self.pending_action {
+ draw_text(
+ buffer,
+ right_x + 2,
+ area.y + area.h.saturating_sub(3),
+ &truncate(
+ &format!("Confirm {action}: y apply / n cancel"),
+ right_w.saturating_sub(4) as usize,
+ ),
+ Style::fg(self.palette.amber).with_bold(),
+ );
+ } else if !self.action_message.is_empty() {
+ draw_text(
+ buffer,
+ right_x + 2,
+ area.y + area.h.saturating_sub(3),
+ &truncate(&self.action_message, right_w.saturating_sub(4) as usize),
+ Style::fg(self.palette.cyan),
+ );
+ } else {
+ draw_text(
+ buffer,
+ right_x + 2,
+ area.y + area.h.saturating_sub(3),
+ "Enter preview/confirm selected action",
+ Style::fg(self.palette.muted),
+ );
+ }
+ }
+
fn render_help(&self, buffer: &mut OptimizedBuffer, area: Area) {
let left_w = responsive_width(area.w, 45, 32, 40);
let right_x = area.x + left_w + 2;
@@ -1877,9 +2204,11 @@ impl<'a> TuiState<'a> {
let mut row = area.y + 2;
for (key, label) in [
("tab / shift-tab", "switch views"),
- ("1-7", "open view"),
+ ("1-8", "open view"),
("j / down", "next finding"),
("k / up", "previous finding"),
+ ("enter", "confirm action"),
+ ("y / n", "apply/cancel"),
("q / esc", "quit"),
] {
draw_text(
@@ -1908,10 +2237,11 @@ impl<'a> TuiState<'a> {
);
let mut row = area.y + 2;
for line in [
- "No live config mutation from the TUI.",
- "Fixes are plan-only review material.",
+ "Write actions require explicit confirmation.",
+ "Beta users accept responsibility before applying changes.",
"Evidence is redacted before display.",
- "Online providers require explicit CLI flags.",
+ "Online-capable providers remain opt-in.",
+ "Backups, schedules, and provider installs are logged.",
"Use report diff/history for follow-up review.",
] {
draw_text(buffer, right_x + 2, row, "•", Style::fg(self.palette.green));
@@ -2099,10 +2429,23 @@ fn view_nav_color(palette: &Palette, idx: usize) -> Rgba {
3 => palette.amber,
4 => palette.blue,
5 => palette.green,
+ 6 => palette.amber,
_ => palette.white,
}
}
+fn action_color(palette: &Palette, action: &actions::ActionSpec) -> Rgba {
+ if !action.available {
+ return palette.muted;
+ }
+ match action.risk.as_str() {
+ "critical" | "high" => palette.red,
+ "medium" => palette.amber,
+ "low" => palette.blue,
+ _ => palette.green,
+ }
+}
+
fn severity_color(palette: &Palette, risk: RiskLevel) -> Rgba {
match risk {
RiskLevel::Critical => palette.red,
@@ -2531,7 +2874,7 @@ mod tests {
let app = TuiState::new(&report);
let text = render_text(&app, 120, 36);
- assert!(text.contains("tab/1-7"));
+ assert!(text.contains("tab/1-8"));
assert!(text.contains(&format!("v{}", version())));
assert!(text.contains(REPO_LABEL));
assert!(text.contains(STAR_CTA));
@@ -2674,7 +3017,7 @@ mod tests {
fn full_help_keyboard_text_does_not_overwrite_panel_border() {
let report = fixture_report();
let mut app = TuiState::new(&report);
- app.active_view = 6;
+ app.active_view = 7;
let mut buffer = OptimizedBuffer::new(120, 36);
app.render(&mut buffer, 120, 36);
@@ -2687,7 +3030,7 @@ mod tests {
fn full_help_keyboard_text_uses_short_labels() {
let report = fixture_report();
let mut app = TuiState::new(&report);
- app.active_view = 6;
+ app.active_view = 7;
let text = render_text(&app, 120, 36);
assert!(text.contains("switch views"));
diff --git a/crates/nightward-core/src/actions.rs b/crates/nightward-core/src/actions.rs
new file mode 100644
index 0000000..ad958ca
--- /dev/null
+++ b/crates/nightward-core/src/actions.rs
@@ -0,0 +1,1103 @@
+use crate::inventory::redact_text;
+use crate::{backupplan, policy, providers, schedule, state};
+use anyhow::{anyhow, Context, Result};
+use chrono::Utc;
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::{Component, Path, PathBuf};
+use std::process::Command;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ActionSpec {
+ pub id: String,
+ pub title: String,
+ pub description: String,
+ pub category: String,
+ pub risk: String,
+ pub available: bool,
+ pub requires_confirmation: bool,
+ pub requires_online: bool,
+ pub reversible: bool,
+ pub writes: Vec,
+ pub command: Vec,
+ pub blocked_reason: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ActionPreview {
+ pub schema_version: u32,
+ pub action: ActionSpec,
+ pub steps: Vec,
+ pub warnings: Vec,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ActionResult {
+ pub schema_version: u32,
+ pub action_id: String,
+ pub status: String,
+ pub message: String,
+ pub writes: Vec,
+ #[serde(skip_serializing_if = "String::is_empty")]
+ pub audit_path: String,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct ApplyOptions {
+ pub confirm: bool,
+ pub executable: String,
+ pub policy_path: String,
+ pub finding_id: String,
+ pub rule: String,
+ pub reason: String,
+}
+
+#[derive(Debug, Serialize)]
+struct AuditEvent {
+ schema_version: u32,
+ generated_at: chrono::DateTime,
+ action_id: String,
+ status: String,
+ message: String,
+ writes: Vec,
+}
+
+pub fn list(home: impl AsRef) -> Vec {
+ let home = home.as_ref();
+ let settings = state::load_settings(home).unwrap_or_default();
+ let disclosure_accepted = state::disclosure_status(home).accepted;
+ let mut actions = Vec::new();
+ if !disclosure_accepted {
+ actions.push(ActionSpec {
+ id: "disclosure.accept".to_string(),
+ title: "Accept responsibility disclosure".to_string(),
+ description: "Required before write-capable TUI actions run.".to_string(),
+ category: "setup".to_string(),
+ risk: "info".to_string(),
+ available: true,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: false,
+ writes: vec![state::settings_path(home).display().to_string()],
+ command: Vec::new(),
+ blocked_reason: String::new(),
+ });
+ }
+
+ let schedule_status = schedule::status(home);
+ if schedule_status.installed {
+ actions.push(ActionSpec {
+ id: "schedule.remove".to_string(),
+ title: "Disable scheduled scans".to_string(),
+ description:
+ "Remove the user-level Nightward scheduled scan job. Reports are left in place."
+ .to_string(),
+ category: "schedule".to_string(),
+ risk: "medium".to_string(),
+ available: disclosure_accepted,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: true,
+ writes: schedule::plan(home, false, "").writes,
+ command: schedule::plan(home, false, "").command,
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, ""),
+ });
+ } else {
+ let schedule_available = schedule::supports_install();
+ let schedule_blocked_reason = if schedule_available {
+ String::new()
+ } else {
+ "schedule install is only implemented for macOS launchd and Linux systemd user timers"
+ .to_string()
+ };
+ actions.push(ActionSpec {
+ id: "schedule.install".to_string(),
+ title: "Enable scheduled scans".to_string(),
+ description: "Install a user-level nightly scan job that writes redacted reports under Nightward state.".to_string(),
+ category: "schedule".to_string(),
+ risk: "medium".to_string(),
+ available: disclosure_accepted && schedule_available,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: true,
+ writes: schedule::plan(home, true, "").writes,
+ command: schedule::plan(home, true, "").command,
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, schedule_blocked_reason),
+ });
+ }
+
+ actions.push(ActionSpec {
+ id: "backup.snapshot".to_string(),
+ title: "Create portable config backup".to_string(),
+ description: "Copy portable Nightward backup candidates into a timestamped local snapshot."
+ .to_string(),
+ category: "backup".to_string(),
+ risk: "medium".to_string(),
+ available: disclosure_accepted,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: false,
+ writes: vec![snapshot_root(home).display().to_string()],
+ command: Vec::new(),
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, ""),
+ });
+
+ actions.push(ActionSpec {
+ id: "reports.cleanup".to_string(),
+ title: "Clean saved reports and logs".to_string(),
+ description:
+ "Remove Nightward-owned scheduled report and log files without touching schedules."
+ .to_string(),
+ category: "cleanup".to_string(),
+ risk: "medium".to_string(),
+ available: disclosure_accepted,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: false,
+ writes: report_cleanup_targets(home)
+ .into_iter()
+ .map(|path| path.display().to_string())
+ .collect(),
+ command: Vec::new(),
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, ""),
+ });
+
+ actions.push(ActionSpec {
+ id: "cache.cleanup".to_string(),
+ title: "Clean Nightward caches".to_string(),
+ description:
+ "Remove Nightward-owned cache directories while leaving reports and audit logs."
+ .to_string(),
+ category: "cleanup".to_string(),
+ risk: "medium".to_string(),
+ available: disclosure_accepted,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: false,
+ writes: cache_cleanup_targets(home)
+ .into_iter()
+ .map(|path| path.display().to_string())
+ .collect(),
+ command: Vec::new(),
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, ""),
+ });
+
+ let default_policy = default_policy_path(home);
+ actions.push(ActionSpec {
+ id: "policy.init".to_string(),
+ title: "Initialize Nightward policy".to_string(),
+ description: "Write a default Nightward policy file if it does not already exist."
+ .to_string(),
+ category: "policy".to_string(),
+ risk: "low".to_string(),
+ available: disclosure_accepted,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: false,
+ writes: vec![default_policy.display().to_string()],
+ command: Vec::new(),
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, ""),
+ });
+
+ actions.push(ActionSpec {
+ id: "policy.ignore".to_string(),
+ title: "Add policy ignore with reason".to_string(),
+ description:
+ "Append one reviewed finding or rule ignore to a bounded Nightward policy file."
+ .to_string(),
+ category: "policy".to_string(),
+ risk: "medium".to_string(),
+ available: disclosure_accepted,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: true,
+ writes: vec![default_policy.display().to_string()],
+ command: Vec::new(),
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, ""),
+ });
+
+ actions.push(ActionSpec {
+ id: if settings.allow_online_providers {
+ "providers.online.disable".to_string()
+ } else {
+ "providers.online.enable".to_string()
+ },
+ title: if settings.allow_online_providers {
+ "Block online-capable providers by default".to_string()
+ } else {
+ "Allow online-capable providers".to_string()
+ },
+ description: "Toggle whether configured online-capable providers may run without passing --online each time.".to_string(),
+ category: "providers".to_string(),
+ risk: "high".to_string(),
+ available: disclosure_accepted,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: true,
+ writes: vec![state::settings_path(home).display().to_string()],
+ command: Vec::new(),
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, ""),
+ });
+
+ for provider in providers::providers()
+ .into_iter()
+ .filter(|provider| !provider.default)
+ {
+ let selected = settings.selected_providers.contains(&provider.name);
+ actions.push(ActionSpec {
+ id: format!(
+ "provider.{}.{}",
+ if selected { "disable" } else { "enable" },
+ provider.name
+ ),
+ title: format!(
+ "{} {}",
+ if selected { "Disable" } else { "Enable" },
+ provider.name
+ ),
+ description: format!(
+ "{} for Nightward analysis runs.",
+ if selected {
+ "Remove this provider from the default selected set"
+ } else {
+ "Add this provider to the default selected set"
+ }
+ ),
+ category: "providers".to_string(),
+ risk: if provider.online { "high" } else { "medium" }.to_string(),
+ available: disclosure_accepted,
+ requires_confirmation: true,
+ requires_online: false,
+ reversible: true,
+ writes: vec![state::settings_path(home).display().to_string()],
+ command: Vec::new(),
+ blocked_reason: disclosure_gate_reason(disclosure_accepted, ""),
+ });
+ let provider_available = which::which(&provider.name).is_ok();
+ if !provider_available {
+ if let Some(install) = providers::install_command(&provider.name) {
+ let package_manager_available = which::which(&install.program).is_ok();
+ let package_manager_blocked_reason = if package_manager_available {
+ String::new()
+ } else {
+ format!("{} is not available on PATH", install.program)
+ };
+ actions.push(ActionSpec {
+ id: format!("provider.install.{}", provider.name),
+ title: format!("Install {}", provider.name),
+ description: format!(
+ "Install the {} provider CLI using a known package-manager command.",
+ provider.name
+ ),
+ category: "providers".to_string(),
+ risk: "high".to_string(),
+ available: disclosure_accepted && package_manager_available,
+ requires_confirmation: true,
+ requires_online: true,
+ reversible: false,
+ writes: vec![format!("package manager state via {}", install.program)],
+ command: install.command(),
+ blocked_reason: disclosure_gate_reason(
+ disclosure_accepted,
+ package_manager_blocked_reason,
+ ),
+ });
+ }
+ }
+ }
+ actions
+}
+
+fn disclosure_gate_reason(accepted: bool, fallback: impl Into) -> String {
+ if accepted {
+ fallback.into()
+ } else {
+ "accept the Nightward beta responsibility disclosure before applying write-capable actions"
+ .to_string()
+ }
+}
+
+pub fn preview(home: impl AsRef, id: &str) -> Result {
+ let action = find_action(home, id)?;
+ let mut warnings = Vec::new();
+ if action.requires_online {
+ warnings.push(
+ "This action can use the network through a package manager or third-party provider."
+ .to_string(),
+ );
+ }
+ if action.risk == "high" {
+ warnings.push(
+ "Review the command, provider behavior, and rollback path before applying.".to_string(),
+ );
+ }
+ Ok(ActionPreview {
+ schema_version: 1,
+ action,
+ steps: preview_steps(id),
+ warnings,
+ })
+}
+
+pub fn apply(home: impl AsRef, id: &str, options: ApplyOptions) -> Result {
+ let home = home.as_ref();
+ let action = find_action(home, id)?;
+ if !action.available {
+ return Err(anyhow!("{}", action.blocked_reason));
+ }
+ if action.requires_confirmation && !options.confirm {
+ return Err(anyhow!("refusing to apply {id} without --confirm"));
+ }
+ if id != "disclosure.accept" && !state::disclosure_status(home).accepted {
+ return Err(anyhow!(
+ "accept the Nightward beta responsibility disclosure before applying write-capable actions"
+ ));
+ }
+
+ let mut writes = action.writes.clone();
+ let message = match id {
+ "disclosure.accept" => {
+ state::accept_disclosure(home)?;
+ "responsibility disclosure accepted".to_string()
+ }
+ "schedule.install" => {
+ let executable = if options.executable.trim().is_empty() {
+ "nightward".to_string()
+ } else {
+ options.executable
+ };
+ let status = schedule::install(home, &executable)?;
+ writes = schedule::plan(home, true, &executable).writes;
+ format!("scheduled scans enabled for {}", status.platform)
+ }
+ "schedule.remove" => {
+ schedule::remove(home)?;
+ writes = schedule::plan(home, false, "").writes;
+ "scheduled scans disabled".to_string()
+ }
+ "backup.snapshot" => {
+ let result = create_backup_snapshot(home)?;
+ writes = result.writes;
+ result.message
+ }
+ "reports.cleanup" => {
+ let result = cleanup_owned_dirs(&report_cleanup_targets(home), "report/log")?;
+ writes = result.writes;
+ result.message
+ }
+ "cache.cleanup" => {
+ let result = cleanup_owned_dirs(&cache_cleanup_targets(home), "cache")?;
+ writes = result.writes;
+ result.message
+ }
+ "policy.init" => {
+ let path = bounded_policy_path(home, &options.policy_path)?;
+ init_policy_file(&path)?;
+ writes = vec![path.display().to_string()];
+ format!("policy initialized at {}", path.display())
+ }
+ "policy.ignore" => {
+ let result = add_policy_ignore(home, &options)?;
+ writes = result.writes;
+ result.message
+ }
+ "providers.online.enable" => {
+ state::set_online_providers_allowed(home, true)?;
+ "online-capable providers are allowed for configured default runs".to_string()
+ }
+ "providers.online.disable" => {
+ state::set_online_providers_allowed(home, false)?;
+ "online-capable providers are blocked by default".to_string()
+ }
+ value if value.starts_with("provider.enable.") => {
+ let provider = value.trim_start_matches("provider.enable.");
+ state::set_provider_selected(home, provider, true)?;
+ format!("{provider} enabled for default analysis runs")
+ }
+ value if value.starts_with("provider.disable.") => {
+ let provider = value.trim_start_matches("provider.disable.");
+ state::set_provider_selected(home, provider, false)?;
+ format!("{provider} disabled for default analysis runs")
+ }
+ value if value.starts_with("provider.install.") => {
+ let provider = value.trim_start_matches("provider.install.");
+ install_provider(provider)?
+ }
+ _ => return Err(anyhow!("unknown action {id}")),
+ };
+
+ let audit = AuditEvent {
+ schema_version: 1,
+ generated_at: Utc::now(),
+ action_id: id.to_string(),
+ status: "applied".to_string(),
+ message: message.clone(),
+ writes: writes.clone(),
+ };
+ let audit_path = state::append_audit(home, &audit)?;
+ Ok(ActionResult {
+ schema_version: 1,
+ action_id: id.to_string(),
+ status: "applied".to_string(),
+ message,
+ writes,
+ audit_path: audit_path.display().to_string(),
+ })
+}
+
+fn find_action(home: impl AsRef, id: &str) -> Result {
+ list(home)
+ .into_iter()
+ .find(|action| action.id == id)
+ .ok_or_else(|| anyhow!("unknown action {id}"))
+}
+
+fn preview_steps(id: &str) -> Vec {
+ match id {
+ "schedule.install" => vec![
+ "Create Nightward report and log directories.".to_string(),
+ "Write a local scheduled-scan runner script.".to_string(),
+ "Install a user-level launchd agent or systemd user timer.".to_string(),
+ ],
+ "schedule.remove" => vec![
+ "Disable the user-level scheduled scan job.".to_string(),
+ "Remove Nightward schedule files.".to_string(),
+ "Leave existing reports and audit logs in place.".to_string(),
+ ],
+ "backup.snapshot" => vec![
+ "Build the backup plan from portable candidates.".to_string(),
+ "Copy existing portable files into a timestamped snapshot.".to_string(),
+ "Write a manifest describing copied and skipped paths.".to_string(),
+ ],
+ "reports.cleanup" => vec![
+ "Inspect Nightward-owned scheduled report and log directories.".to_string(),
+ "Remove existing files and child directories inside those directories.".to_string(),
+ "Leave schedule, settings, policy, backup snapshots, and audit logs in place."
+ .to_string(),
+ ],
+ "cache.cleanup" => vec![
+ "Inspect Nightward-owned cache directories.".to_string(),
+ "Remove existing files and child directories inside those cache directories."
+ .to_string(),
+ "Leave reports, schedules, settings, policy, snapshots, and audit logs in place."
+ .to_string(),
+ ],
+ "policy.init" => vec![
+ "Resolve the bounded policy path under NIGHTWARD_HOME.".to_string(),
+ "Write the default policy file only if it is missing.".to_string(),
+ ],
+ "policy.ignore" => vec![
+ "Resolve the bounded policy path under NIGHTWARD_HOME.".to_string(),
+ "Require a finding ID or rule plus a non-empty reason.".to_string(),
+ "Append the ignore entry and preserve the rest of the policy.".to_string(),
+ ],
+ value if value.starts_with("provider.install.") => vec![
+ "Run the displayed package-manager command.".to_string(),
+ "Refresh provider doctor status after installation.".to_string(),
+ ],
+ value if value.starts_with("provider.") => vec![
+ "Update Nightward local settings.".to_string(),
+ "Use the setting for future analysis runs when --with is omitted.".to_string(),
+ ],
+ _ => vec!["Apply the selected local Nightward action.".to_string()],
+ }
+}
+
+fn install_provider(provider: &str) -> Result {
+ let install = providers::install_command(provider)
+ .ok_or_else(|| anyhow!("no install command is known for {provider}"))?;
+ let output = Command::new(&install.program)
+ .args(&install.args)
+ .output()
+ .with_context(|| format!("spawn {}", install.program))?;
+ if !output.status.success() {
+ return Err(anyhow!(
+ "{} failed: {}",
+ install.command().join(" "),
+ redact_text(&String::from_utf8_lossy(&output.stderr))
+ ));
+ }
+ let stdout = redact_text(&String::from_utf8_lossy(&output.stdout));
+ Ok(if stdout.trim().is_empty() {
+ format!("{provider} installed")
+ } else {
+ format!(
+ "{provider} installed: {}",
+ stdout.lines().next().unwrap_or("").trim()
+ )
+ })
+}
+
+#[derive(Debug)]
+struct SnapshotResult {
+ message: String,
+ writes: Vec,
+}
+
+fn snapshot_root(home: &Path) -> PathBuf {
+ state::state_dir(home).join("snapshots")
+}
+
+fn report_cleanup_targets(home: &Path) -> Vec {
+ vec![schedule::report_dir(home), schedule::log_dir(home)]
+}
+
+fn cache_cleanup_targets(home: &Path) -> Vec {
+ vec![state::state_dir(home).join("cache"), state::cache_dir(home)]
+}
+
+fn default_policy_path(home: &Path) -> PathBuf {
+ state::config_dir(home).join("nightward-policy.yml")
+}
+
+fn bounded_policy_path(home: &Path, requested: &str) -> Result {
+ let requested = requested.trim();
+ if requested.is_empty() {
+ return Ok(default_policy_path(home));
+ }
+ let path = Path::new(requested);
+ if path.is_absolute() {
+ return Err(anyhow!("policy path must be relative to NIGHTWARD_HOME"));
+ }
+ let parts = normal_relative_components(path).with_context(|| {
+ format!("policy path must be a clean relative path under NIGHTWARD_HOME: {requested}")
+ })?;
+ if parts.is_empty() {
+ return Err(anyhow!("policy path cannot be empty"));
+ }
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or_default();
+ let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
+ if !matches!(extension, "yml" | "yaml") {
+ return Err(anyhow!("policy path must end in .yml or .yaml"));
+ }
+ let in_nightward_config = parts.len() >= 3 && parts[0] == ".config" && parts[1] == "nightward";
+ let in_project_policy_dir = parts.iter().any(|part| part == ".nightward");
+ let named_policy_file = matches!(
+ file_name,
+ "nightward-policy.yml" | "nightward-policy.yaml" | ".nightward.yml" | ".nightward.yaml"
+ );
+ if !(in_nightward_config || in_project_policy_dir || named_policy_file) {
+ return Err(anyhow!(
+ "policy path must be a Nightward policy file or live under .nightward/"
+ ));
+ }
+ Ok(home.join(path))
+}
+
+fn add_policy_ignore(home: &Path, options: &ApplyOptions) -> Result {
+ let path = bounded_policy_path(home, &options.policy_path)?;
+ let reason = options.reason.trim();
+ if reason.is_empty() {
+ return Err(anyhow!("policy.ignore requires a non-empty reason"));
+ }
+ let finding_id = options.finding_id.trim();
+ let rule = options.rule.trim();
+ if finding_id.is_empty() && rule.is_empty() {
+ return Err(anyhow!("policy.ignore requires finding_id or rule"));
+ }
+ if !path.exists() {
+ init_policy_file(&path)?;
+ }
+ state::ensure_regular_file_or_missing(&path)?;
+ let mut config = policy::load(&path)?;
+ if !finding_id.is_empty() {
+ config
+ .ignore_findings
+ .retain(|entry| entry.id != finding_id);
+ config.ignore_findings.push(policy::IgnoreFindingEntry {
+ id: finding_id.to_string(),
+ reason: reason.to_string(),
+ });
+ } else {
+ config
+ .ignore_rules
+ .retain(|entry| entry.rule != rule && entry.id != rule);
+ config.ignore_rules.push(policy::IgnoreRuleEntry {
+ rule: rule.to_string(),
+ id: String::new(),
+ reason: reason.to_string(),
+ });
+ }
+ state::write_private_file(&path, serde_yaml::to_string(&config)?)
+ .with_context(|| format!("write {}", path.display()))?;
+ Ok(SnapshotResult {
+ message: format!("policy ignore added at {}", path.display()),
+ writes: vec![path.display().to_string()],
+ })
+}
+
+fn init_policy_file(path: &Path) -> Result<()> {
+ state::ensure_regular_file_or_missing(path)?;
+ if path.exists() {
+ return Ok(());
+ }
+ state::write_private_file(path, policy::DEFAULT_POLICY)
+}
+
+fn normal_relative_components(path: &Path) -> Result> {
+ let mut parts = Vec::new();
+ for component in path.components() {
+ match component {
+ Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
+ Component::ParentDir => {
+ return Err(anyhow!("path cannot contain parent directory components"))
+ }
+ Component::CurDir => {
+ return Err(anyhow!("path cannot contain current directory components"))
+ }
+ Component::RootDir | Component::Prefix(_) => {
+ return Err(anyhow!("path must be relative"))
+ }
+ }
+ }
+ Ok(parts)
+}
+
+fn safe_snapshot_relative_path(rel: &str) -> Result {
+ let rel = rel.trim();
+ if rel.is_empty() {
+ return Err(anyhow!("snapshot path cannot be empty"));
+ }
+ let path = Path::new(rel);
+ let parts = normal_relative_components(path)?;
+ if parts.is_empty() {
+ return Err(anyhow!("snapshot path cannot be empty"));
+ }
+ let mut scoped = PathBuf::new();
+ for part in parts {
+ scoped.push(part);
+ }
+ Ok(scoped)
+}
+
+fn cleanup_owned_dirs(targets: &[PathBuf], label: &str) -> Result {
+ let mut removed = 0usize;
+ for target in targets {
+ removed += cleanup_owned_dir(target)?;
+ }
+ Ok(SnapshotResult {
+ message: format!("removed {removed} Nightward-owned {label} entries"),
+ writes: targets
+ .iter()
+ .map(|path| path.display().to_string())
+ .collect(),
+ })
+}
+
+fn cleanup_owned_dir(target: &Path) -> Result {
+ if !target.exists() {
+ return Ok(0);
+ }
+ let metadata =
+ fs::symlink_metadata(target).with_context(|| format!("inspect {}", target.display()))?;
+ if metadata.file_type().is_symlink() {
+ return Err(anyhow!(
+ "refusing to clean symlinked Nightward directory {}",
+ target.display()
+ ));
+ }
+ if !metadata.is_dir() {
+ return Err(anyhow!(
+ "refusing to clean non-directory Nightward path {}",
+ target.display()
+ ));
+ }
+ let mut removed = 0usize;
+ for entry in fs::read_dir(target).with_context(|| format!("read {}", target.display()))? {
+ let entry = entry.with_context(|| format!("read entry in {}", target.display()))?;
+ let child = entry.path();
+ let file_type = entry
+ .file_type()
+ .with_context(|| format!("inspect {}", child.display()))?;
+ if file_type.is_dir() {
+ fs::remove_dir_all(&child).with_context(|| format!("remove {}", child.display()))?;
+ removed += 1;
+ } else if file_type.is_file() || file_type.is_symlink() {
+ fs::remove_file(&child).with_context(|| format!("remove {}", child.display()))?;
+ removed += 1;
+ }
+ }
+ Ok(removed)
+}
+
+fn create_backup_snapshot(home: &Path) -> Result {
+ let plan = backupplan::plan(home);
+ let snapshot = snapshot_root(home).join(Utc::now().format("%Y%m%dT%H%M%SZ").to_string());
+ state::create_private_dir(&snapshot)?;
+ let mut copied = Vec::new();
+ let mut skipped = Vec::new();
+ for rel in &plan.include {
+ let rel_path = match safe_snapshot_relative_path(rel) {
+ Ok(path) => path,
+ Err(error) => {
+ skipped.push(format!("{rel}: {}", error));
+ continue;
+ }
+ };
+ let source = home.join(&rel_path);
+ let metadata = match fs::symlink_metadata(&source) {
+ Ok(metadata) => metadata,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ skipped.push(format!("{rel}: missing"));
+ continue;
+ }
+ Err(error) => {
+ return Err(error).with_context(|| format!("inspect {}", source.display()))
+ }
+ };
+ if metadata.file_type().is_symlink() {
+ skipped.push(format!("{rel}: symlink"));
+ continue;
+ }
+ if !metadata.is_file() {
+ skipped.push(format!("{rel}: not a regular file"));
+ continue;
+ }
+ let destination = snapshot.join(&rel_path);
+ if let Some(parent) = destination.parent() {
+ state::create_private_dir(parent)?;
+ }
+ fs::copy(&source, &destination)
+ .with_context(|| format!("copy {} to {}", source.display(), destination.display()))?;
+ state::set_private_file_permissions(&destination)?;
+ copied.push(rel.clone());
+ }
+ let manifest = serde_json::json!({
+ "schema_version": 1,
+ "generated_at": Utc::now(),
+ "root": plan.root,
+ "snapshot": snapshot.display().to_string(),
+ "copied": copied,
+ "skipped": skipped,
+ "excluded": plan.exclude,
+ "notes": plan.notes,
+ });
+ let manifest_path = snapshot.join("manifest.json");
+ state::write_private_file(
+ &manifest_path,
+ format!("{}\n", serde_json::to_string_pretty(&manifest)?),
+ )
+ .with_context(|| format!("write {}", manifest_path.display()))?;
+ state::set_private_file_permissions(&manifest_path)?;
+ Ok(SnapshotResult {
+ message: format!("backup snapshot created at {}", snapshot.display()),
+ writes: vec![snapshot.display().to_string()],
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn backup_snapshot_action_copies_portable_candidates_and_audits() {
+ let home = tempfile::tempdir().expect("temp home");
+ let codex = home.path().join(".codex");
+ std::fs::create_dir_all(&codex).expect("codex dir");
+ std::fs::write(codex.join("config.toml"), "model = \"test\"\n").expect("config");
+ apply(
+ home.path(),
+ "disclosure.accept",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("accept disclosure");
+
+ let result = apply(
+ home.path(),
+ "backup.snapshot",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("snapshot action");
+
+ assert_eq!(result.status, "applied");
+ let snapshot = PathBuf::from(result.writes[0].clone());
+ assert!(snapshot.join(".codex/config.toml").is_file());
+ assert!(snapshot.join("manifest.json").is_file());
+ assert!(state::audit_path(home.path()).is_file());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn backup_snapshot_skips_symlinked_candidates_without_copying_targets() {
+ use std::os::unix::fs::symlink;
+
+ let home = tempfile::tempdir().expect("temp home");
+ let outside = tempfile::tempdir().expect("outside");
+ let secret = outside.path().join("secret.toml");
+ std::fs::write(&secret, "token = \"SECRET_VALUE\"\n").expect("secret");
+ let codex = home.path().join(".codex");
+ std::fs::create_dir_all(&codex).expect("codex dir");
+ symlink(&secret, codex.join("config.toml")).expect("symlink config");
+ apply(
+ home.path(),
+ "disclosure.accept",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("accept disclosure");
+
+ let result = apply(
+ home.path(),
+ "backup.snapshot",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("snapshot action");
+
+ let snapshot = PathBuf::from(result.writes[0].clone());
+ assert!(!snapshot.join(".codex/config.toml").exists());
+ let manifest = std::fs::read_to_string(snapshot.join("manifest.json")).expect("manifest");
+ assert!(manifest.contains(".codex/config.toml: symlink"));
+ assert!(!manifest.contains("SECRET_VALUE"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn disclosure_accept_rejects_symlinked_nightward_settings_dir() {
+ use std::os::unix::fs::symlink;
+
+ let home = tempfile::tempdir().expect("temp home");
+ let outside = tempfile::tempdir().expect("outside");
+ std::fs::create_dir_all(home.path().join(".config")).expect("config dir");
+ symlink(outside.path(), home.path().join(".config/nightward")).expect("settings symlink");
+
+ let error = apply(
+ home.path(),
+ "disclosure.accept",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect_err("symlinked settings dir rejected");
+
+ assert!(error.to_string().contains("symlinked Nightward directory"));
+ }
+
+ #[test]
+ fn apply_refuses_confirmation_gated_actions_without_confirm() {
+ let home = tempfile::tempdir().expect("temp home");
+ apply(
+ home.path(),
+ "disclosure.accept",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("accept disclosure");
+
+ let error = apply(
+ home.path(),
+ "backup.snapshot",
+ ApplyOptions {
+ confirm: false,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect_err("confirmation required");
+
+ assert!(error.to_string().contains("without --confirm"));
+ }
+
+ #[test]
+ fn policy_actions_initialize_and_append_reasoned_ignores() {
+ let home = tempfile::tempdir().expect("temp home");
+ apply(
+ home.path(),
+ "disclosure.accept",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("accept disclosure");
+
+ let init = apply(
+ home.path(),
+ "policy.init",
+ ApplyOptions {
+ confirm: true,
+ policy_path: "project/.nightward.yml".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("policy init");
+ let path = PathBuf::from(&init.writes[0]);
+ assert!(path.is_file());
+
+ apply(
+ home.path(),
+ "policy.ignore",
+ ApplyOptions {
+ confirm: true,
+ policy_path: "project/.nightward.yml".to_string(),
+ finding_id: "finding-123".to_string(),
+ reason: "reviewed local-only fixture".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("policy ignore");
+
+ let config = policy::load(&path).expect("policy config");
+ assert!(config.ignore_findings.iter().any(
+ |entry| entry.id == "finding-123" && entry.reason == "reviewed local-only fixture"
+ ));
+
+ let error = apply(
+ home.path(),
+ "policy.ignore",
+ ApplyOptions {
+ confirm: true,
+ finding_id: "finding-456".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect_err("reason required");
+ assert!(error.to_string().contains("non-empty reason"));
+
+ let error = apply(
+ home.path(),
+ "policy.init",
+ ApplyOptions {
+ confirm: true,
+ policy_path: "../outside.yml".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect_err("bounded path");
+ assert!(error.to_string().contains("clean relative path"));
+
+ let error = apply(
+ home.path(),
+ "policy.init",
+ ApplyOptions {
+ confirm: true,
+ policy_path: "project/not-nightward-policy.yml".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect_err("policy file name bounded");
+ assert!(error.to_string().contains("Nightward policy file"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn policy_actions_reject_symlinked_policy_files() {
+ use std::os::unix::fs::symlink;
+
+ let home = tempfile::tempdir().expect("temp home");
+ apply(
+ home.path(),
+ "disclosure.accept",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("accept disclosure");
+ let outside = tempfile::tempdir().expect("outside");
+ let target = outside.path().join("policy.yml");
+ std::fs::write(&target, "severity_threshold: low\n").expect("target policy");
+ let project = home.path().join("project");
+ std::fs::create_dir_all(&project).expect("project dir");
+ symlink(&target, project.join(".nightward.yml")).expect("policy symlink");
+
+ let error = apply(
+ home.path(),
+ "policy.ignore",
+ ApplyOptions {
+ confirm: true,
+ policy_path: "project/.nightward.yml".to_string(),
+ finding_id: "finding-123".to_string(),
+ reason: "reviewed".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect_err("symlinked policy rejected");
+
+ assert!(error.to_string().contains("symlinked Nightward path"));
+ }
+
+ #[test]
+ fn cleanup_actions_remove_only_owned_report_log_and_cache_entries() {
+ let home = tempfile::tempdir().expect("temp home");
+ apply(
+ home.path(),
+ "disclosure.accept",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("accept disclosure");
+
+ let report_dir = schedule::report_dir(home.path());
+ let log_dir = schedule::log_dir(home.path());
+ let state_cache_dir = state::state_dir(home.path()).join("cache");
+ let user_cache_dir = state::cache_dir(home.path());
+ std::fs::create_dir_all(&report_dir).expect("report dir");
+ std::fs::create_dir_all(&log_dir).expect("log dir");
+ std::fs::create_dir_all(state_cache_dir.join("nested")).expect("state cache dir");
+ std::fs::create_dir_all(&user_cache_dir).expect("user cache dir");
+ std::fs::write(report_dir.join("scan.json"), "{}\n").expect("report");
+ std::fs::write(log_dir.join("nightward.log"), "ok\n").expect("log");
+ std::fs::write(state_cache_dir.join("nested/cache.bin"), "cache\n").expect("state cache");
+ std::fs::write(user_cache_dir.join("cache.bin"), "cache\n").expect("user cache");
+
+ let reports = apply(
+ home.path(),
+ "reports.cleanup",
+ ApplyOptions {
+ confirm: true,
+ ..Default::default()
+ },
+ )
+ .expect("reports cleanup");
+ assert_eq!(reports.status, "applied");
+ assert!(report_dir.is_dir());
+ assert!(log_dir.is_dir());
+ assert!(!report_dir.join("scan.json").exists());
+ assert!(!log_dir.join("nightward.log").exists());
+ assert!(state_cache_dir.join("nested/cache.bin").exists());
+
+ let cache = apply(
+ home.path(),
+ "cache.cleanup",
+ ApplyOptions {
+ confirm: true,
+ ..Default::default()
+ },
+ )
+ .expect("cache cleanup");
+ assert_eq!(cache.status, "applied");
+ assert!(state_cache_dir.is_dir());
+ assert!(user_cache_dir.is_dir());
+ assert!(!state_cache_dir.join("nested").exists());
+ assert!(!user_cache_dir.join("cache.bin").exists());
+ assert!(state::audit_path(home.path()).is_file());
+ }
+}
diff --git a/crates/nightward-core/src/analysis.rs b/crates/nightward-core/src/analysis.rs
index 3d13ffd..5d3a3e0 100644
--- a/crates/nightward-core/src/analysis.rs
+++ b/crates/nightward-core/src/analysis.rs
@@ -264,7 +264,7 @@ fn signal_from_finding(subject: &Subject, finding: &Finding) -> Signal {
fn signal_from_item(item: &Item) -> Option<(Subject, Signal)> {
if !matches!(
item.classification,
- Classification::SecretAuth | Classification::MachineLocal
+ Classification::SecretAuth | Classification::MachineLocal | Classification::AppOwned
) {
return None;
}
@@ -285,6 +285,7 @@ fn signal_from_item(item: &Item) -> Option<(Subject, Signal)> {
category: match item.classification {
Classification::SecretAuth => SignalCategory::SecretsExposure,
Classification::MachineLocal => SignalCategory::MachineLocality,
+ Classification::AppOwned => SignalCategory::AppState,
_ => SignalCategory::Unknown,
},
subject_id: subject.id.clone(),
@@ -391,7 +392,7 @@ fn provider_recommendation(provider: &str, category: SignalCategory) -> String {
fn category_for_rule(rule: &str) -> SignalCategory {
if rule.contains("secret") || rule.contains("token") {
SignalCategory::SecretsExposure
- } else if rule.contains("package") {
+ } else if rule.contains("package") || rule.contains("typosquat") || rule.contains("docker") {
SignalCategory::SupplyChain
} else if rule.contains("filesystem") {
SignalCategory::FilesystemScope
diff --git a/crates/nightward-core/src/fixplan.rs b/crates/nightward-core/src/fixplan.rs
index 8fc4af3..6b05bc9 100644
--- a/crates/nightward-core/src/fixplan.rs
+++ b/crates/nightward-core/src/fixplan.rs
@@ -232,6 +232,11 @@ fn group_title(action: &Action) -> String {
"mcp_secret_env" | "mcp_secret_header" => "Externalize inline secrets".to_string(),
"mcp_local_endpoint" => "Review machine-local endpoints".to_string(),
"mcp_broad_filesystem" => "Narrow filesystem access".to_string(),
+ "mcp_docker_socket" => "Review Docker and host-control exposure".to_string(),
+ "mcp_typosquat_package" | "mcp_untrusted_package_source" => {
+ "Review package provenance".to_string()
+ }
+ "config_stale" => "Review stale config".to_string(),
_ => action.title.clone(),
}
}
diff --git a/crates/nightward-core/src/inventory.rs b/crates/nightward-core/src/inventory.rs
index b7b2810..2f7e61d 100644
--- a/crates/nightward-core/src/inventory.rs
+++ b/crates/nightward-core/src/inventory.rs
@@ -275,6 +275,18 @@ fn add_item_for_path(report: &mut Report, tool: &str, path: &Path) {
fn classify_path(path: &str) -> (Classification, RiskLevel, String, String) {
let lower = path.to_ascii_lowercase();
+ if lower.contains(".open-webui")
+ || lower.contains(".ollama")
+ || lower.contains("globalstorage")
+ || lower.contains("workspace storage")
+ {
+ return (
+ Classification::AppOwned,
+ RiskLevel::Medium,
+ "Path looks like app-owned runtime or extension state.".to_string(),
+ "Do not sync this as portable dotfiles; export/import only through the owning app when supported.".to_string(),
+ );
+ }
if lower.contains("auth")
|| lower.contains("credential")
|| lower.contains("id_ed25519")
@@ -347,6 +359,23 @@ fn inspect_config(report: &mut Report, tool: &str, path: &Path) {
);
return;
}
+ if let Some(mod_time) = meta.modified().ok().map(DateTime::::from) {
+ if Utc::now().signed_duration_since(mod_time).num_days() > 180 {
+ push_finding(
+ report,
+ tool,
+ path,
+ "",
+ "config_stale",
+ RiskLevel::Low,
+ "Config file has not changed in over 180 days.",
+ &format!("last_modified={}", mod_time.to_rfc3339()),
+ "Review whether this config is still active before syncing or trusting it.",
+ FixKind::ManualReview,
+ None,
+ );
+ }
+ }
}
let bytes = match fs::read(path) {
@@ -473,8 +502,55 @@ fn inspect_server(report: &mut Report, tool: &str, path: &Path, server: &str, co
}),
);
}
+ if !command.is_empty() && !known_command_shape(&command) {
+ push_finding(
+ report,
+ tool,
+ path,
+ server,
+ "mcp_unknown_command",
+ RiskLevel::Info,
+ "MCP server uses a command shape Nightward does not recognize.",
+ &evidence,
+ "Review the launcher command, installed binary source, and arguments before trusting it.",
+ FixKind::ManualReview,
+ None,
+ );
+ }
+ if docker_exposure(&command, &args, &url) {
+ push_finding(
+ report,
+ tool,
+ path,
+ server,
+ "mcp_docker_socket",
+ RiskLevel::High,
+ "MCP server appears to expose Docker or container-host control.",
+ &evidence,
+ "Avoid giving AI tools Docker socket, privileged container, or host-root bind access unless it is isolated and explicitly required.",
+ FixKind::ManualReview,
+ None,
+ );
+ }
if let Some(package) = unpinned_package(&command, &args) {
+ if let Some(package) = &package {
+ if typosquat_like_package(package) {
+ push_finding(
+ report,
+ tool,
+ path,
+ server,
+ "mcp_typosquat_package",
+ RiskLevel::Medium,
+ "MCP server package name resembles a trusted MCP package namespace but is not from the expected scope.",
+ &evidence,
+ "Verify the package publisher, repository, install counts, and source before running it.",
+ FixKind::ManualReview,
+ None,
+ );
+ }
+ }
let patch_hint = package.map(|package| PatchHint {
kind: Some(FixKind::PinPackage),
package,
@@ -502,6 +578,21 @@ fn inspect_server(report: &mut Report, tool: &str, path: &Path, server: &str, co
patch_hint,
);
}
+ if let Some(source) = remote_package_source(&args) {
+ push_finding(
+ report,
+ tool,
+ path,
+ server,
+ "mcp_untrusted_package_source",
+ RiskLevel::Medium,
+ "MCP server launches a package or script from a remote source.",
+ &format!("source={}", redact_text(&source)),
+ "Prefer reviewed package-manager releases with pinned versions over direct URL, git, or tarball sources.",
+ FixKind::ManualReview,
+ None,
+ );
+ }
for (key, value) in object_entries(config, &["env"]) {
if secret_key(&key) || secret_value(&value) {
@@ -816,6 +907,72 @@ fn shell_command(command: &str) -> bool {
)
}
+fn known_command_shape(command: &str) -> bool {
+ let base = command.rsplit('/').next().unwrap_or(command);
+ matches!(
+ base,
+ "npx"
+ | "uvx"
+ | "pipx"
+ | "pnpm"
+ | "yarn"
+ | "bunx"
+ | "node"
+ | "python"
+ | "python3"
+ | "uv"
+ | "deno"
+ | "docker"
+ | "go"
+ | "npm"
+ | "sh"
+ | "bash"
+ | "zsh"
+ | "fish"
+ | "pwsh"
+ | "powershell"
+ | "cmd"
+ ) || command.starts_with("./")
+ || command.starts_with("../")
+ || command.starts_with('/')
+}
+
+fn docker_exposure(command: &str, args: &[String], url: &str) -> bool {
+ let combined = format!("{} {} {}", command, args.join(" "), url).to_ascii_lowercase();
+ command.rsplit('/').next().unwrap_or(command) == "docker"
+ || combined.contains("docker.sock")
+ || combined.contains("/var/run/docker")
+ || combined.contains("--privileged")
+ || combined.contains("source=/")
+ || combined.contains("-v /:")
+ || combined.contains("--volume /:")
+}
+
+fn typosquat_like_package(package: &str) -> bool {
+ let lower = package.to_ascii_lowercase();
+ if lower.starts_with("@modelcontextprotocol/") {
+ return false;
+ }
+ (lower.contains("modelcontext") || lower.contains("model-context") || lower.contains("mcp"))
+ && (lower.contains("filesystem")
+ || lower.contains("server-filesystem")
+ || lower.contains("contextprotocol")
+ || lower.contains("modelcontextprotocol"))
+}
+
+fn remote_package_source(args: &[String]) -> Option {
+ args.iter()
+ .find(|arg| {
+ let lower = arg.to_ascii_lowercase();
+ lower.starts_with("git+")
+ || lower.starts_with("http://")
+ || lower.starts_with("https://")
+ || lower.ends_with(".tgz")
+ || lower.ends_with(".tar.gz")
+ })
+ .cloned()
+}
+
fn secret_key(key: &str) -> bool {
Regex::new("(?i)(token|secret|password|passwd|api[_-]?key|auth|credential|private[_-]?key)")
.expect("valid regex")
@@ -1146,6 +1303,57 @@ mod tests {
assert!(redacted.contains(path));
}
+ #[test]
+ fn detects_docker_package_provenance_and_unknown_command_shapes() {
+ let dir = tempfile::tempdir().unwrap();
+ fs::write(
+ dir.path().join(".mcp.json"),
+ r#"{
+ "mcpServers": {
+ "docker": {
+ "command": "docker",
+ "args": ["run", "-v", "/var/run/docker.sock:/var/run/docker.sock", "demo"]
+ },
+ "lookalike": {
+ "command": "npx",
+ "args": ["modelcontextprotocol-server-filesystem"]
+ },
+ "remote": {
+ "command": "npx",
+ "args": ["https://example.test/mcp-server.tgz"]
+ },
+ "custom": {
+ "command": "custom-mcp-launcher",
+ "args": []
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ let report = scan_workspace(dir.path()).unwrap();
+ let rules: BTreeSet<_> = report.findings.iter().map(|f| f.rule.as_str()).collect();
+ assert!(rules.contains("mcp_docker_socket"));
+ assert!(rules.contains("mcp_typosquat_package"));
+ assert!(rules.contains("mcp_untrusted_package_source"));
+ assert!(rules.contains("mcp_unknown_command"));
+ }
+
+ #[test]
+ fn classifies_app_owned_state_as_not_portable() {
+ let dir = tempfile::tempdir().unwrap();
+ fs::create_dir(dir.path().join(".open-webui")).unwrap();
+
+ let report = scan_home(dir.path()).unwrap();
+ let item = report
+ .items
+ .iter()
+ .find(|item| item.path.ends_with(".open-webui"))
+ .expect("open webui item");
+
+ assert_eq!(item.classification, Classification::AppOwned);
+ }
+
#[test]
fn unpinned_package_does_not_surface_secret_like_package_specs() {
let dir = tempfile::tempdir().unwrap();
diff --git a/crates/nightward-core/src/lib.rs b/crates/nightward-core/src/lib.rs
index b44a017..6a22ebe 100644
--- a/crates/nightward-core/src/lib.rs
+++ b/crates/nightward-core/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod actions;
pub mod analysis;
pub mod backupplan;
pub mod fixplan;
@@ -10,6 +11,7 @@ pub mod reporthtml;
pub mod rules;
pub mod schedule;
pub mod snapshot;
+pub mod state;
pub mod types;
pub use types::*;
diff --git a/crates/nightward-core/src/mcpserver.rs b/crates/nightward-core/src/mcpserver.rs
index e23cc70..ab2fd3b 100644
--- a/crates/nightward-core/src/mcpserver.rs
+++ b/crates/nightward-core/src/mcpserver.rs
@@ -1,14 +1,19 @@
+use crate::actions::{self, ApplyOptions};
use crate::analysis::{run as analyze, Options as AnalysisOptions};
use crate::fixplan::{plan as fix_plan, Selector};
-use crate::inventory::{home_dir_from_env, scan_home, scan_workspace};
+use crate::inventory::{home_dir_from_env, load_report, redact_text, scan_home, scan_workspace};
use crate::policy::{check as policy_check, PolicyConfig};
-use crate::rules;
-use anyhow::{anyhow, Result};
-use serde_json::{json, Value};
+use crate::{providers, reportdiff, rules, schedule, state};
+use anyhow::{anyhow, Context, Result};
+use serde::Serialize;
+use serde_json::{json, Map, Value};
+use std::fs;
use std::io::{self, BufRead, Write};
-use std::path::PathBuf;
+use std::path::{Component, Path, PathBuf};
-const PROTOCOL_VERSION: &str = "2025-06-18";
+const PROTOCOL_LATEST: &str = "2025-11-25";
+const PROTOCOL_COMPAT: &str = "2025-06-18";
+const SUPPORTED_PROTOCOLS: &[&str] = &[PROTOCOL_LATEST, PROTOCOL_COMPAT];
pub fn serve() -> Result<()> {
let stdin = io::stdin();
@@ -20,6 +25,9 @@ pub fn serve() -> Result<()> {
}
let request: Value = serde_json::from_str(&line)?;
let response = handle_request(request);
+ if response.is_null() {
+ continue;
+ }
writeln!(stdout, "{}", serde_json::to_string(&response)?)?;
stdout.flush()?;
}
@@ -27,30 +35,41 @@ pub fn serve() -> Result<()> {
}
pub fn handle_request(request: Value) -> Value {
- let id = request.get("id").cloned().unwrap_or(Value::Null);
+ handle_request_with_home(request, &home_dir_from_env())
+}
+
+fn handle_request_with_home(request: Value, home: &Path) -> Value {
+ let id = request.get("id").cloned();
let method = request
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
+
+ if id.is_none() && method.starts_with("notifications/") {
+ return Value::Null;
+ }
+
let result = match method {
- "initialize" => Ok(json!({
- "protocolVersion": PROTOCOL_VERSION,
- "capabilities": {
- "tools": {},
- "resources": {}
- },
- "serverInfo": {
- "name": "nightward",
- "version": env!("CARGO_PKG_VERSION")
- }
- })),
+ "initialize" => Ok(initialize_result(
+ request
+ .get("params")
+ .and_then(|params| params.get("protocolVersion"))
+ .and_then(Value::as_str),
+ )),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"resources/list" => Ok(json!({ "resources": resources() })),
- "resources/read" => read_resource(request.get("params").cloned().unwrap_or_default()),
- "tools/call" => call_tool(request.get("params").cloned().unwrap_or_default()),
+ "resources/read" => read_resource(request.get("params").cloned().unwrap_or_default(), home),
+ "prompts/list" => Ok(json!({ "prompts": prompts() })),
+ "prompts/get" => read_prompt(request.get("params").cloned().unwrap_or_default()),
+ "tools/call" => Ok(call_tool(
+ request.get("params").cloned().unwrap_or_default(),
+ home,
+ )),
_ => Err(anyhow!("unknown method {method}")),
};
+
+ let id = id.unwrap_or(Value::Null);
match result {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(error) => json!({
@@ -61,180 +80,1007 @@ pub fn handle_request(request: Value) -> Value {
}
}
+fn initialize_result(requested: Option<&str>) -> Value {
+ let protocol_version = requested
+ .filter(|version| SUPPORTED_PROTOCOLS.contains(version))
+ .unwrap_or(PROTOCOL_LATEST);
+ json!({
+ "protocolVersion": protocol_version,
+ "capabilities": {
+ "tools": { "listChanged": false },
+ "resources": { "subscribe": false, "listChanged": false },
+ "prompts": { "listChanged": false }
+ },
+ "serverInfo": {
+ "name": "nightward",
+ "title": "Nightward",
+ "version": env!("CARGO_PKG_VERSION"),
+ "description": "Local-first AI agent, MCP, provider, and dotfiles security posture."
+ },
+ "instructions": "Nightward returns redacted local security posture. Write-capable MCP calls are limited to the shared Nightward action registry and require disclosure acceptance plus explicit confirmation."
+ })
+}
+
fn tools() -> Vec {
vec![
tool(
"nightward_scan",
- "Scan home or workspace with Nightward read-only defaults.",
+ "Nightward Scan",
+ "Run a redacted HOME or workspace scan.",
+ schema_scan(),
+ read_only_annotations("Nightward scan", false),
+ ),
+ tool(
+ "nightward_doctor",
+ "Nightward Doctor",
+ "Return provider, schedule, disclosure, and settings posture.",
+ schema_provider_context(),
+ read_only_annotations("Nightward doctor", false),
),
- tool("nightward_doctor", "Return provider and schedule posture."),
tool(
"nightward_findings",
- "Return current findings with optional severity filtering.",
+ "Nightward Findings",
+ "Return findings with optional severity, rule, and limit filters.",
+ schema_findings(),
+ read_only_annotations("Nightward findings", false),
),
tool(
"nightward_explain_finding",
- "Explain one finding by id or prefix.",
+ "Explain Finding",
+ "Return one finding by full ID or unique prefix.",
+ schema_id_only(),
+ read_only_annotations("Explain finding", false),
+ ),
+ tool(
+ "nightward_analysis",
+ "Nightward Analysis",
+ "Run Nightward analysis with selected local or explicitly allowed online providers.",
+ schema_analysis(),
+ read_only_annotations("Nightward analysis", true),
+ ),
+ tool(
+ "nightward_explain_signal",
+ "Explain Signal",
+ "Return one analysis signal by full ID or unique prefix.",
+ schema_explain_signal(),
+ read_only_annotations("Explain signal", true),
+ ),
+ tool(
+ "nightward_policy_check",
+ "Policy Check",
+ "Run a read-only Nightward policy check.",
+ schema_policy_check(),
+ read_only_annotations("Policy check", true),
),
tool(
"nightward_fix_plan",
- "Generate a plan-only remediation preview.",
+ "Fix Plan",
+ "Generate plan-only remediation directions for all findings, one finding, or one rule.",
+ schema_fix_plan(),
+ read_only_annotations("Fix plan", false),
+ ),
+ tool(
+ "nightward_report_history",
+ "Report History",
+ "List saved scheduled report history.",
+ no_args_schema(),
+ read_only_annotations("Report history", false),
+ ),
+ tool(
+ "nightward_report_changes",
+ "Report Changes",
+ "Compare two saved report files, or the latest two saved reports when paths are omitted.",
+ schema_report_changes(),
+ read_only_annotations("Report changes", false),
+ ),
+ tool(
+ "nightward_actions_list",
+ "Actions List",
+ "List bounded Nightward actions available through the shared action registry.",
+ no_args_schema(),
+ read_only_annotations("Actions list", false),
+ ),
+ tool(
+ "nightward_action_preview",
+ "Action Preview",
+ "Preview one bounded Nightward action before applying it.",
+ schema_action_id(),
+ read_only_annotations("Action preview", false),
+ ),
+ tool(
+ "nightward_action_apply",
+ "Action Apply",
+ "Apply one bounded Nightward action after disclosure acceptance and confirm: true.",
+ schema_action_apply(),
+ write_annotations("Action apply", true, true),
+ ),
+ tool(
+ "nightward_rules",
+ "Nightward Rules",
+ "List Nightward rules and remediation metadata.",
+ no_args_schema(),
+ read_only_annotations("Rules", false),
+ ),
+ tool(
+ "nightward_providers",
+ "Nightward Providers",
+ "List provider capabilities and current status.",
+ schema_provider_context(),
+ read_only_annotations("Providers", false),
),
- tool("nightward_policy_check", "Run a read-only policy check."),
- tool("nightward_rules", "List Nightward rules."),
]
}
fn resources() -> Vec {
vec![
- json!({
- "uri": "nightward://latest-summary",
- "name": "Latest Nightward summary",
- "mimeType": "application/json"
- }),
- json!({
- "uri": "nightward://rules",
- "name": "Nightward rules",
- "mimeType": "application/json"
- }),
+ resource(
+ "nightward://latest-summary",
+ "Latest Nightward summary",
+ "Live HOME scan summary if no saved report is requested.",
+ ),
+ resource(
+ "nightward://latest-report",
+ "Latest Nightward report",
+ "Latest saved report when available, otherwise a live HOME scan report.",
+ ),
+ resource(
+ "nightward://rules",
+ "Nightward rules",
+ "Rule catalog and remediation metadata.",
+ ),
+ resource(
+ "nightward://providers",
+ "Nightward providers",
+ "Provider catalog, configured selections, and local status.",
+ ),
+ resource(
+ "nightward://schedule",
+ "Nightward schedule",
+ "User-level scheduled scan status and report paths.",
+ ),
+ resource(
+ "nightward://actions",
+ "Nightward actions",
+ "Bounded action registry with availability and risk metadata.",
+ ),
+ resource(
+ "nightward://disclosure",
+ "Nightward disclosure",
+ "Disclosure acceptance status and responsibility text.",
+ ),
+ resource(
+ "nightward://report-history",
+ "Nightward report history",
+ "Saved scheduled report history.",
+ ),
]
}
-fn tool(name: &str, description: &str) -> Value {
+fn prompts() -> Vec {
+ prompt(
+ "audit_my_ai_setup",
+ "Audit My AI Setup",
+ "Have an AI client run Nightward scan, analysis, and policy checks, then summarize local AI/MCP risk.",
+ &[],
+ )
+ .into_iter()
+ .chain(prompt(
+ "explain_top_risks",
+ "Explain Top Risks",
+ "Explain the highest-severity Nightward findings and signals in plain language.",
+ &[],
+ ))
+ .chain(prompt(
+ "fix_this_finding",
+ "Fix This Finding Safely",
+ "Generate a cautious fix plan for a specific finding without mutating raw agent config.",
+ &[("finding_id", "Finding ID or unique prefix.")],
+ ))
+ .chain(prompt(
+ "set_up_providers",
+ "Set Up Providers",
+ "Review provider status and propose bounded provider install/enable actions.",
+ &[],
+ ))
+ .chain(prompt(
+ "compare_reports",
+ "Compare Reports",
+ "Compare the last two saved Nightward reports and explain what changed.",
+ &[],
+ ))
+ .collect()
+}
+
+fn prompt(name: &str, title: &str, description: &str, arguments: &[(&str, &str)]) -> Vec {
+ vec![json!({
+ "name": name,
+ "title": title,
+ "description": description,
+ "arguments": arguments
+ .iter()
+ .map(|(name, description)| json!({
+ "name": name,
+ "description": description,
+ "required": true
+ }))
+ .collect::>()
+ })]
+}
+
+fn tool(
+ name: &str,
+ title: &str,
+ description: &str,
+ input_schema: Value,
+ annotations: Value,
+) -> Value {
json!({
"name": name,
+ "title": title,
"description": description,
- "inputSchema": {
+ "inputSchema": input_schema,
+ "outputSchema": {
"type": "object",
- "additionalProperties": true,
- "properties": {
- "workspace": { "type": "string" },
- "severity": { "type": "string" },
- "id": { "type": "string" },
- "compact": { "type": "boolean" }
- }
+ "additionalProperties": true
+ },
+ "annotations": annotations,
+ "execution": {
+ "taskSupport": "forbidden"
}
})
}
-fn read_resource(params: Value) -> Result {
+fn resource(uri: &str, name: &str, description: &str) -> Value {
+ json!({
+ "uri": uri,
+ "name": name,
+ "description": description,
+ "mimeType": "application/json"
+ })
+}
+
+fn read_only_annotations(title: &str, open_world: bool) -> Value {
+ json!({
+ "title": title,
+ "readOnlyHint": true,
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": open_world
+ })
+}
+
+fn write_annotations(title: &str, destructive: bool, open_world: bool) -> Value {
+ json!({
+ "title": title,
+ "readOnlyHint": false,
+ "destructiveHint": destructive,
+ "idempotentHint": false,
+ "openWorldHint": open_world
+ })
+}
+
+fn read_resource(params: Value, home: &Path) -> Result {
let uri = params
.get("uri")
.and_then(Value::as_str)
.unwrap_or_default();
match uri {
"nightward://latest-summary" => {
- let report = scan_home(home_dir_from_env())?;
- text_resource(uri, serde_json::to_string_pretty(&report.summary)?)
- }
- "nightward://rules" => {
- text_resource(uri, serde_json::to_string_pretty(&rules::all_rules())?)
+ let report = scan_home(home)?;
+ json_resource(uri, &report.summary)
}
+ "nightward://latest-report" => json_resource(uri, &latest_report(home)?),
+ "nightward://rules" => json_resource(uri, &rules::all_rules()),
+ "nightward://providers" => json_resource(uri, &provider_context(home, &Value::Null)),
+ "nightward://schedule" => json_resource(uri, &schedule::status(home)),
+ "nightward://actions" => json_resource(
+ uri,
+ &json!({
+ "schema_version": 1,
+ "actions": actions::list(home)
+ }),
+ ),
+ "nightward://disclosure" => json_resource(uri, &state::disclosure_status(home)),
+ "nightward://report-history" => json_resource(
+ uri,
+ &json!({
+ "schema_version": 1,
+ "history": schedule::status(home).history
+ }),
+ ),
_ => Err(anyhow!("unknown resource {uri}")),
}
}
-fn call_tool(params: Value) -> Result {
+fn read_prompt(params: Value) -> Result {
let name = params
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let args = params.get("arguments").cloned().unwrap_or_default();
- let workspace = args
- .get("workspace")
- .and_then(Value::as_str)
- .unwrap_or_default();
- let scan = if workspace.is_empty() {
- scan_home(home_dir_from_env())?
- } else {
- scan_workspace(PathBuf::from(workspace))?
+ let finding_id = string_arg(&args, "finding_id");
+ let text = match name {
+ "audit_my_ai_setup" => {
+ "Use Nightward MCP tools to run nightward_scan, nightward_analysis, and nightward_policy_check with compact output. Explain the highest-risk AI/MCP configuration issues, provider posture, and the safest next actions. Do not apply actions unless I explicitly ask and confirm them."
+ }
+ "explain_top_risks" => {
+ "Use nightward_findings and nightward_analysis to identify the top risks. Explain what can actually break or leak, what is probably just review noise, and what should be fixed first."
+ }
+ "fix_this_finding" => {
+ return Ok(prompt_result(
+ "Generate a safe Nightward fix workflow.",
+ format!(
+ "Use nightward_explain_finding and nightward_fix_plan for finding `{}`. If a bounded registry action is relevant, use nightward_action_preview first. Do not call nightward_action_apply unless I explicitly accept the disclosure and provide confirm: true.",
+ if finding_id.is_empty() {
+ ""
+ } else {
+ &finding_id
+ }
+ ),
+ ));
+ }
+ "set_up_providers" => {
+ "Use nightward_providers and nightward_actions_list to show missing, blocked, selected, and online-capable providers. Recommend provider.install/provider.enable actions only through nightward_action_preview, and call out online/network behavior before any apply."
+ }
+ "compare_reports" => {
+ "Use nightward_report_history and nightward_report_changes to compare the last two reports. Summarize new, removed, and changed findings, then recommend which changes actually matter."
+ }
+ _ => return Err(anyhow!("unknown prompt {name}")),
};
+ Ok(prompt_result(name, text.to_string()))
+}
+
+fn prompt_result(description: impl Into, text: String) -> Value {
+ json!({
+ "description": description.into(),
+ "messages": [{
+ "role": "user",
+ "content": {
+ "type": "text",
+ "text": text
+ }
+ }]
+ })
+}
+
+fn call_tool(params: Value, home: &Path) -> Value {
+ let result = call_tool_inner(params, home);
+ match result {
+ Ok(value) => value,
+ Err(error) => tool_error(error),
+ }
+}
+
+fn call_tool_inner(params: Value, home: &Path) -> Result {
+ let name = params
+ .get("name")
+ .and_then(Value::as_str)
+ .ok_or_else(|| anyhow!("tools/call requires a tool name"))?;
+ let args = validate_tool_args(
+ name,
+ params
+ .get("arguments")
+ .cloned()
+ .unwrap_or_else(|| json!({})),
+ )?;
match name {
- "nightward_scan" => text_result(serde_json::to_string_pretty(&scan)?),
- "nightward_doctor" => {
- let doctor = json!({
- "schema_version": 1,
- "providers": crate::providers::statuses(&[], false),
- "schedule": crate::schedule::status(home_dir_from_env())
- });
- text_result(serde_json::to_string_pretty(&doctor)?)
+ "nightward_scan" => {
+ let scan = scan_for_args(home, &args)?;
+ let structured = if bool_arg(&args, "compact", false) {
+ json!({
+ "schema_version": 1,
+ "summary": scan.summary,
+ "findings": limited_values(scan.findings, limit_arg(&args, 25))?
+ })
+ } else {
+ sanitized_value(&scan)?
+ };
+ tool_result(structured)
}
+ "nightward_doctor" => tool_result(json!({
+ "schema_version": 1,
+ "providers": provider_context(home, &args),
+ "schedule": schedule::status(home),
+ "disclosure": state::disclosure_status(home),
+ "actions": {
+ "available": actions::list(home).into_iter().filter(|action| action.available).count()
+ }
+ })),
"nightward_findings" => {
- let severity = args
- .get("severity")
- .and_then(Value::as_str)
- .unwrap_or_default();
+ let scan = scan_for_args(home, &args)?;
+ let severity = string_arg(&args, "severity");
+ let rule = string_arg(&args, "rule");
+ let limit = limit_arg(&args, 50);
let findings: Vec<_> = scan
.findings
- .iter()
+ .into_iter()
.filter(|finding| {
- severity.is_empty()
- || format!("{:?}", finding.severity).eq_ignore_ascii_case(severity)
+ (severity.is_empty()
+ || format!("{:?}", finding.severity).eq_ignore_ascii_case(&severity))
+ && (rule.is_empty() || finding.rule == rule)
})
- .cloned()
+ .take(limit)
.collect();
- text_result(serde_json::to_string_pretty(&findings)?)
+ tool_result(json!({
+ "schema_version": 1,
+ "count": findings.len(),
+ "findings": sanitized_value(&findings)?
+ }))
}
"nightward_explain_finding" => {
- let id = args.get("id").and_then(Value::as_str).unwrap_or_default();
- let Some(finding) = scan
+ let id = string_arg(&args, "id");
+ if id.is_empty() {
+ return Err(anyhow!("id is required"));
+ }
+ let scan = scan_for_args(home, &args)?;
+ let finding = scan
.findings
.iter()
- .find(|finding| finding.id == id || finding.id.starts_with(id))
- else {
- return Err(anyhow!("finding not found"));
- };
- text_result(serde_json::to_string_pretty(finding)?)
+ .find(|finding| finding.id == id || finding.id.starts_with(&id))
+ .ok_or_else(|| anyhow!("finding not found"))?;
+ tool_result(json!({
+ "schema_version": 1,
+ "finding": sanitized_value(finding)?
+ }))
}
- "nightward_fix_plan" => {
- let id = args.get("id").and_then(Value::as_str).unwrap_or_default();
- let selector = Selector {
- all: id.is_empty(),
- finding: id.to_string(),
- rule: String::new(),
+ "nightward_analysis" => {
+ let scan = scan_for_args(home, &args)?;
+ let report = analysis_for_args(home, &scan, &args);
+ let structured = if bool_arg(&args, "compact", false) {
+ json!({
+ "schema_version": 1,
+ "summary": report.summary,
+ "providers": report.providers,
+ "signals": limited_values(report.signals, limit_arg(&args, 25))?
+ })
+ } else {
+ sanitized_value(&report)?
};
- text_result(serde_json::to_string_pretty(&fix_plan(&scan, selector))?)
+ tool_result(structured)
+ }
+ "nightward_explain_signal" => {
+ let id = string_arg(&args, "id");
+ if id.is_empty() {
+ return Err(anyhow!("id is required"));
+ }
+ let scan = scan_for_args(home, &args)?;
+ let report = analysis_for_args(home, &scan, &args);
+ let signal = crate::analysis::explain(&report, &id)
+ .ok_or_else(|| anyhow!("analysis signal not found"))?;
+ tool_result(json!({
+ "schema_version": 1,
+ "signal": sanitized_value(&signal)?
+ }))
}
"nightward_policy_check" => {
- let analysis = analyze(
- &scan,
- AnalysisOptions {
- mode: scan.scan_mode.clone(),
- workspace: scan.workspace.clone(),
- with: Vec::new(),
- online: false,
- package: String::new(),
- finding_id: String::new(),
- },
- );
- let policy = policy_check(&scan, &PolicyConfig::default(), Some(&analysis));
- let compact = args
- .get("compact")
- .and_then(Value::as_bool)
- .unwrap_or(false);
- if compact {
- text_result(serde_json::to_string_pretty(&json!({
+ let scan = scan_for_args(home, &args)?;
+ let include_analysis = bool_arg(&args, "include_analysis", false);
+ let analysis = include_analysis.then(|| analysis_for_args(home, &scan, &args));
+ let mut config = PolicyConfig {
+ include_analysis,
+ ..PolicyConfig::default()
+ };
+ let provider_selection = selected_providers(home, &args);
+ if !provider_selection.is_empty() {
+ config.analysis_providers = provider_selection;
+ }
+ config.allow_online_providers = online_allowed(home, &args);
+ let policy = policy_check(&scan, &config, analysis.as_ref());
+ let structured = if bool_arg(&args, "compact", false) {
+ json!({
+ "schema_version": 1,
"passed": policy.passed,
- "blocking_count": policy.blocking_count,
+ "threshold": policy.threshold,
"finding_count": policy.finding_count,
+ "blocking_count": policy.blocking_count,
+ "ignored_count": policy.ignored_count,
+ "analysis_violation_count": policy.analysis_violation_count,
"max_severity": policy.max_severity,
- }))?)
+ "findings": limited_values(policy.findings, limit_arg(&args, 25))?,
+ "analysis_violations": limited_values(policy.analysis_violations, limit_arg(&args, 25))?
+ })
} else {
- text_result(serde_json::to_string_pretty(&policy)?)
+ sanitized_value(&policy)?
+ };
+ tool_result(structured)
+ }
+ "nightward_fix_plan" => {
+ let scan = scan_for_args(home, &args)?;
+ let id = string_arg(&args, "id");
+ let rule = string_arg(&args, "rule");
+ let selector = Selector {
+ all: bool_arg(&args, "all", id.is_empty() && rule.is_empty()),
+ finding: id,
+ rule,
+ };
+ tool_result(sanitized_value(&fix_plan(&scan, selector))?)
+ }
+ "nightward_report_history" => tool_result(json!({
+ "schema_version": 1,
+ "history": schedule::status(home).history
+ })),
+ "nightward_report_changes" => tool_result(sanitized_value(&report_changes(home, &args)?)?),
+ "nightward_actions_list" => tool_result(json!({
+ "schema_version": 1,
+ "actions": actions::list(home)
+ })),
+ "nightward_action_preview" => {
+ let id = string_arg(&args, "action_id");
+ if id.is_empty() {
+ return Err(anyhow!("action_id is required"));
}
+ tool_result(sanitized_value(&actions::preview(home, &id)?)?)
}
- "nightward_rules" => text_result(serde_json::to_string_pretty(&rules::all_rules())?),
+ "nightward_action_apply" => {
+ let id = string_arg(&args, "action_id");
+ if id.is_empty() {
+ return Err(anyhow!("action_id is required"));
+ }
+ let result = actions::apply(
+ home,
+ &id,
+ ApplyOptions {
+ confirm: bool_arg(&args, "confirm", false),
+ executable: string_arg(&args, "executable"),
+ policy_path: string_arg(&args, "policy_path"),
+ finding_id: string_arg(&args, "finding_id"),
+ rule: string_arg(&args, "rule"),
+ reason: string_arg(&args, "reason"),
+ },
+ )?;
+ tool_result(sanitized_value(&result)?)
+ }
+ "nightward_rules" => tool_result(json!({
+ "schema_version": 1,
+ "rules": rules::all_rules()
+ })),
+ "nightward_providers" => tool_result(provider_context(home, &args)),
_ => Err(anyhow!("unknown tool {name}")),
}
}
-fn text_result(text: String) -> Result {
+#[derive(Clone, Copy)]
+enum ToolArgKind {
+ String,
+ Bool,
+ ConfirmTrue,
+ Limit,
+ Severity,
+ StringList,
+}
+
+#[derive(Clone, Copy)]
+struct ToolArgSpec {
+ name: &'static str,
+ kind: ToolArgKind,
+ required: bool,
+}
+
+impl ToolArgSpec {
+ const fn optional(name: &'static str, kind: ToolArgKind) -> Self {
+ Self {
+ name,
+ kind,
+ required: false,
+ }
+ }
+
+ const fn required(name: &'static str, kind: ToolArgKind) -> Self {
+ Self {
+ name,
+ kind,
+ required: true,
+ }
+ }
+}
+
+fn validate_tool_args(name: &str, args: Value) -> Result {
+ let specs = tool_arg_specs(name)?;
+ let object = args
+ .as_object()
+ .ok_or_else(|| anyhow!("{name} arguments must be an object"))?;
+ for key in object.keys() {
+ if !specs.iter().any(|spec| spec.name == key) {
+ return Err(anyhow!("{name} does not accept argument `{key}`"));
+ }
+ }
+ for spec in &specs {
+ match object.get(spec.name) {
+ Some(value) => validate_arg_value(name, *spec, value)?,
+ None if spec.required => {
+ return Err(anyhow!("{name} requires argument `{}`", spec.name));
+ }
+ None => {}
+ }
+ }
+ Ok(Value::Object(object.clone()))
+}
+
+fn validate_arg_value(tool: &str, spec: ToolArgSpec, value: &Value) -> Result<()> {
+ match spec.kind {
+ ToolArgKind::String => {
+ if value.is_string() {
+ Ok(())
+ } else {
+ Err(anyhow!("{tool} argument `{}` must be a string", spec.name))
+ }
+ }
+ ToolArgKind::Bool => {
+ if value.is_boolean() {
+ Ok(())
+ } else {
+ Err(anyhow!("{tool} argument `{}` must be a boolean", spec.name))
+ }
+ }
+ ToolArgKind::ConfirmTrue => {
+ if value.as_bool() == Some(true) {
+ Ok(())
+ } else {
+ Err(anyhow!("{tool} argument `{}` must be true", spec.name))
+ }
+ }
+ ToolArgKind::Limit => {
+ let Some(value) = value.as_u64() else {
+ return Err(anyhow!(
+ "{tool} argument `{}` must be an integer",
+ spec.name
+ ));
+ };
+ if (1..=250).contains(&value) {
+ Ok(())
+ } else {
+ Err(anyhow!(
+ "{tool} argument `{}` must be between 1 and 250",
+ spec.name
+ ))
+ }
+ }
+ ToolArgKind::Severity => {
+ let Some(value) = value.as_str() else {
+ return Err(anyhow!("{tool} argument `{}` must be a string", spec.name));
+ };
+ if matches!(
+ value,
+ "info"
+ | "low"
+ | "medium"
+ | "high"
+ | "critical"
+ | "Info"
+ | "Low"
+ | "Medium"
+ | "High"
+ | "Critical"
+ ) {
+ Ok(())
+ } else {
+ Err(anyhow!(
+ "{tool} argument `{}` must be a known severity",
+ spec.name
+ ))
+ }
+ }
+ ToolArgKind::StringList => match value {
+ Value::String(_) => Ok(()),
+ Value::Array(values) if values.iter().all(Value::is_string) => Ok(()),
+ _ => Err(anyhow!(
+ "{tool} argument `{}` must be a string or array of strings",
+ spec.name
+ )),
+ },
+ }
+}
+
+fn tool_arg_specs(name: &str) -> Result> {
+ use ToolArgKind::*;
+ let specs = match name {
+ "nightward_scan" => vec![
+ ToolArgSpec::optional("workspace", String),
+ ToolArgSpec::optional("compact", Bool),
+ ToolArgSpec::optional("limit", Limit),
+ ],
+ "nightward_doctor" | "nightward_providers" => vec![
+ ToolArgSpec::optional("with", StringList),
+ ToolArgSpec::optional("online", Bool),
+ ],
+ "nightward_findings" => vec![
+ ToolArgSpec::optional("workspace", String),
+ ToolArgSpec::optional("severity", Severity),
+ ToolArgSpec::optional("rule", String),
+ ToolArgSpec::optional("limit", Limit),
+ ],
+ "nightward_explain_finding" => vec![
+ ToolArgSpec::optional("workspace", String),
+ ToolArgSpec::required("id", String),
+ ],
+ "nightward_analysis" => vec![
+ ToolArgSpec::optional("workspace", String),
+ ToolArgSpec::optional("with", StringList),
+ ToolArgSpec::optional("online", Bool),
+ ToolArgSpec::optional("package", String),
+ ToolArgSpec::optional("finding_id", String),
+ ToolArgSpec::optional("compact", Bool),
+ ToolArgSpec::optional("limit", Limit),
+ ],
+ "nightward_explain_signal" => vec![
+ ToolArgSpec::optional("workspace", String),
+ ToolArgSpec::optional("with", StringList),
+ ToolArgSpec::optional("online", Bool),
+ ToolArgSpec::optional("package", String),
+ ToolArgSpec::optional("finding_id", String),
+ ToolArgSpec::required("id", String),
+ ],
+ "nightward_policy_check" => vec![
+ ToolArgSpec::optional("workspace", String),
+ ToolArgSpec::optional("include_analysis", Bool),
+ ToolArgSpec::optional("with", StringList),
+ ToolArgSpec::optional("online", Bool),
+ ToolArgSpec::optional("compact", Bool),
+ ToolArgSpec::optional("limit", Limit),
+ ],
+ "nightward_fix_plan" => vec![
+ ToolArgSpec::optional("workspace", String),
+ ToolArgSpec::optional("id", String),
+ ToolArgSpec::optional("rule", String),
+ ToolArgSpec::optional("all", Bool),
+ ],
+ "nightward_report_history" | "nightward_actions_list" | "nightward_rules" => Vec::new(),
+ "nightward_report_changes" => vec![
+ ToolArgSpec::optional("base", String),
+ ToolArgSpec::optional("head", String),
+ ],
+ "nightward_action_preview" => vec![ToolArgSpec::required("action_id", String)],
+ "nightward_action_apply" => vec![
+ ToolArgSpec::required("action_id", String),
+ ToolArgSpec::required("confirm", ConfirmTrue),
+ ToolArgSpec::optional("executable", String),
+ ToolArgSpec::optional("policy_path", String),
+ ToolArgSpec::optional("finding_id", String),
+ ToolArgSpec::optional("rule", String),
+ ToolArgSpec::optional("reason", String),
+ ],
+ _ => return Err(anyhow!("unknown tool {name}")),
+ };
+ Ok(specs)
+}
+
+fn latest_report(home: &Path) -> Result {
+ let status = schedule::status(home);
+ let Some(path) = status.last_report else {
+ let scan = scan_home(home)?;
+ return Ok(json!({
+ "schema_version": 1,
+ "source": "live-scan",
+ "report": sanitized_value(&scan)?
+ }));
+ };
+ let report = load_report(&path)?;
Ok(json!({
- "content": [{ "type": "text", "text": text }]
+ "schema_version": 1,
+ "source": "saved-report",
+ "path": path,
+ "report": sanitized_value(&report)?
}))
}
-fn text_resource(uri: &str, text: String) -> Result {
+fn provider_context(home: &Path, args: &Value) -> Value {
+ let selected = selected_providers(home, args);
+ let online = online_allowed(home, args);
+ json!({
+ "schema_version": 1,
+ "providers": providers::providers(),
+ "statuses": providers::statuses(&selected, online),
+ "selected": selected,
+ "online_allowed": online
+ })
+}
+
+fn report_changes(home: &Path, args: &Value) -> Result {
+ let base_path = string_arg(args, "base");
+ let head_path = string_arg(args, "head");
+ if base_path.is_empty() != head_path.is_empty() {
+ return Err(anyhow!(
+ "base and head must both be provided or both omitted"
+ ));
+ }
+ if !base_path.is_empty() {
+ let base_path = scoped_mcp_existing_path(home, &base_path, ScopedPathKind::File)?;
+ let head_path = scoped_mcp_existing_path(home, &head_path, ScopedPathKind::File)?;
+ let base = load_report(&base_path)?;
+ let head = load_report(&head_path)?;
+ return Ok(reportdiff::diff(
+ base_path.display().to_string(),
+ &base,
+ head_path.display().to_string(),
+ &head,
+ ));
+ }
+
+ let history = schedule::status(home).history;
+ if history.len() < 2 {
+ return Err(anyhow!(
+ "at least two saved reports are required for nightward_report_changes"
+ ));
+ }
+ let head_entry = &history[0];
+ let base_entry = &history[1];
+ let base = load_report(&base_entry.path)
+ .with_context(|| format!("load base report {}", base_entry.path))?;
+ let head = load_report(&head_entry.path)
+ .with_context(|| format!("load head report {}", head_entry.path))?;
+ Ok(reportdiff::diff(
+ base_entry.report_name.clone(),
+ &base,
+ head_entry.report_name.clone(),
+ &head,
+ ))
+}
+
+fn scan_for_args(home: &Path, args: &Value) -> Result {
+ let workspace = string_arg(args, "workspace");
+ if workspace.is_empty() {
+ scan_home(home)
+ } else {
+ scan_workspace(scoped_mcp_existing_path(
+ home,
+ &workspace,
+ ScopedPathKind::Directory,
+ )?)
+ }
+}
+
+#[derive(Clone, Copy)]
+enum ScopedPathKind {
+ Directory,
+ File,
+}
+
+fn scoped_mcp_existing_path(home: &Path, requested: &str, kind: ScopedPathKind) -> Result {
+ let requested = requested.trim();
+ if requested.is_empty() {
+ return Err(anyhow!("path cannot be empty"));
+ }
+ let requested_path = Path::new(requested);
+ let relative = if requested_path.is_absolute() {
+ if requested_path
+ .components()
+ .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
+ {
+ return Err(anyhow!("path cannot contain relative components"));
+ }
+ requested_path
+ .strip_prefix(home)
+ .map_err(|_| anyhow!("path must stay under NIGHTWARD_HOME"))?
+ .to_path_buf()
+ } else {
+ requested_path.to_path_buf()
+ };
+ let parts = normal_mcp_relative_components(&relative)?;
+ if parts.is_empty() {
+ return Err(anyhow!("path cannot resolve to NIGHTWARD_HOME itself"));
+ }
+
+ ensure_scoped_path_component(home, true)?;
+ let mut current = home.to_path_buf();
+ for part in parts {
+ current.push(part);
+ let is_final = current == home.join(&relative);
+ ensure_scoped_path_component(
+ ¤t,
+ matches!(kind, ScopedPathKind::Directory) && is_final,
+ )?;
+ }
+
+ let metadata =
+ fs::symlink_metadata(¤t).with_context(|| format!("inspect {}", current.display()))?;
+ if metadata.file_type().is_symlink() {
+ return Err(anyhow!("path cannot be a symlink"));
+ }
+ match kind {
+ ScopedPathKind::Directory if !metadata.is_dir() => {
+ Err(anyhow!("workspace path must be an existing directory"))
+ }
+ ScopedPathKind::File if !metadata.is_file() => {
+ Err(anyhow!("report path must be an existing regular file"))
+ }
+ _ => Ok(current),
+ }
+}
+
+fn normal_mcp_relative_components(path: &Path) -> Result> {
+ let mut parts = Vec::new();
+ for component in path.components() {
+ match component {
+ Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
+ Component::ParentDir => return Err(anyhow!("path cannot contain parent directories")),
+ Component::CurDir => return Err(anyhow!("path cannot contain current directories")),
+ Component::RootDir | Component::Prefix(_) => {
+ return Err(anyhow!("path must be relative"))
+ }
+ }
+ }
+ Ok(parts)
+}
+
+fn ensure_scoped_path_component(path: &Path, final_directory: bool) -> Result<()> {
+ let metadata =
+ fs::symlink_metadata(path).with_context(|| format!("inspect {}", path.display()))?;
+ if metadata.file_type().is_symlink() {
+ return Err(anyhow!("path cannot contain symlinks"));
+ }
+ if final_directory {
+ if !metadata.is_dir() {
+ return Err(anyhow!("workspace path must be an existing directory"));
+ }
+ } else if !metadata.is_dir() && !metadata.is_file() {
+ return Err(anyhow!("path must be a regular file or directory"));
+ }
+ Ok(())
+}
+
+fn analysis_for_args(home: &Path, scan: &crate::Report, args: &Value) -> crate::analysis::Report {
+ analyze(
+ scan,
+ AnalysisOptions {
+ mode: scan.scan_mode.clone(),
+ workspace: if scan.workspace.is_empty() {
+ string_arg(args, "workspace")
+ } else {
+ scan.workspace.clone()
+ },
+ with: selected_providers(home, args),
+ online: online_allowed(home, args),
+ package: string_arg(args, "package"),
+ finding_id: string_arg(args, "finding_id"),
+ },
+ )
+}
+
+fn selected_providers(home: &Path, args: &Value) -> Vec {
+ let requested = string_array_arg(args, "with");
+ if !requested.is_empty() {
+ return requested;
+ }
+ state::load_settings(home)
+ .map(|settings| settings.selected_providers)
+ .unwrap_or_default()
+}
+
+fn online_allowed(home: &Path, args: &Value) -> bool {
+ args.get("online")
+ .and_then(Value::as_bool)
+ .unwrap_or_else(|| {
+ state::load_settings(home)
+ .map(|settings| settings.allow_online_providers)
+ .unwrap_or(false)
+ })
+}
+
+fn tool_result(structured: Value) -> Result {
+ let structured = sanitize_structured(structured);
+ let text = serde_json::to_string_pretty(&structured)?;
+ Ok(json!({
+ "content": [{ "type": "text", "text": text }],
+ "structuredContent": structured,
+ "isError": false
+ }))
+}
+
+fn tool_error(error: anyhow::Error) -> Value {
+ let message = redact_text(&error.to_string());
+ json!({
+ "content": [{ "type": "text", "text": message }],
+ "structuredContent": {
+ "schema_version": 1,
+ "error": message
+ },
+ "isError": true
+ })
+}
+
+fn json_resource(uri: &str, value: &impl Serialize) -> Result {
+ let structured = sanitized_value(value)?;
+ let text = serde_json::to_string_pretty(&structured)?;
Ok(json!({
"contents": [{
"uri": uri,
@@ -244,13 +1090,525 @@ fn text_resource(uri: &str, text: String) -> Result {
}))
}
+fn sanitized_value(value: &impl Serialize) -> Result {
+ let text = serde_json::to_string(value)?;
+ let redacted = redact_text(&text);
+ Ok(serde_json::from_str(&redacted).unwrap_or_else(|_| {
+ json!({
+ "schema_version": 1,
+ "redacted_text": redacted
+ })
+ }))
+}
+
+fn sanitize_structured(value: Value) -> Value {
+ sanitized_value(&value).unwrap_or_else(|_| {
+ json!({
+ "schema_version": 1,
+ "redacted_text": redact_text(&value.to_string())
+ })
+ })
+}
+
+fn limited_values(values: Vec, limit: usize) -> Result {
+ let values = values.into_iter().take(limit).collect::>();
+ sanitized_value(&values)
+}
+
+fn string_arg(args: &Value, name: &str) -> String {
+ args.get(name)
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .unwrap_or_default()
+ .to_string()
+}
+
+fn bool_arg(args: &Value, name: &str, default: bool) -> bool {
+ args.get(name).and_then(Value::as_bool).unwrap_or(default)
+}
+
+fn limit_arg(args: &Value, default: usize) -> usize {
+ args.get("limit")
+ .and_then(Value::as_u64)
+ .map(|value| value.clamp(1, 250) as usize)
+ .unwrap_or(default)
+}
+
+fn string_array_arg(args: &Value, name: &str) -> Vec {
+ match args.get(name) {
+ Some(Value::String(value)) => value
+ .split(',')
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToString::to_string)
+ .collect(),
+ Some(Value::Array(values)) => values
+ .iter()
+ .filter_map(Value::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToString::to_string)
+ .collect(),
+ _ => Vec::new(),
+ }
+}
+
+fn no_args_schema() -> Value {
+ json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ })
+}
+
+fn schema_object(properties: Value, required: &[&str]) -> Value {
+ let mut schema = Map::new();
+ schema.insert("type".to_string(), json!("object"));
+ schema.insert("additionalProperties".to_string(), json!(false));
+ schema.insert("properties".to_string(), properties);
+ if !required.is_empty() {
+ schema.insert("required".to_string(), json!(required));
+ }
+ Value::Object(schema)
+}
+
+fn schema_scan() -> Value {
+ schema_object(
+ json!({
+ "workspace": { "type": "string", "description": "Workspace path to scan instead of HOME." },
+ "compact": { "type": "boolean", "description": "Return summary plus bounded findings." },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 250 }
+ }),
+ &[],
+ )
+}
+
+fn schema_provider_context() -> Value {
+ schema_object(
+ json!({
+ "with": {
+ "oneOf": [
+ { "type": "array", "items": { "type": "string" } },
+ { "type": "string" }
+ ],
+ "description": "Provider names or comma-separated provider list."
+ },
+ "online": { "type": "boolean", "description": "Allow online-capable providers for this call." }
+ }),
+ &[],
+ )
+}
+
+fn schema_findings() -> Value {
+ schema_object(
+ json!({
+ "workspace": { "type": "string" },
+ "severity": { "type": "string", "enum": ["info", "low", "medium", "high", "critical", "Info", "Low", "Medium", "High", "Critical"] },
+ "rule": { "type": "string" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 250 }
+ }),
+ &[],
+ )
+}
+
+fn schema_id_only() -> Value {
+ schema_object(
+ json!({
+ "workspace": { "type": "string" },
+ "id": { "type": "string", "description": "Finding ID or unique prefix." }
+ }),
+ &["id"],
+ )
+}
+
+fn schema_analysis() -> Value {
+ schema_object(
+ json!({
+ "workspace": { "type": "string" },
+ "with": {
+ "oneOf": [
+ { "type": "array", "items": { "type": "string" } },
+ { "type": "string" }
+ ]
+ },
+ "online": { "type": "boolean" },
+ "package": { "type": "string" },
+ "finding_id": { "type": "string" },
+ "compact": { "type": "boolean" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 250 }
+ }),
+ &[],
+ )
+}
+
+fn schema_explain_signal() -> Value {
+ schema_object(
+ json!({
+ "workspace": { "type": "string" },
+ "with": {
+ "oneOf": [
+ { "type": "array", "items": { "type": "string" } },
+ { "type": "string" }
+ ]
+ },
+ "online": { "type": "boolean" },
+ "package": { "type": "string" },
+ "finding_id": { "type": "string" },
+ "id": { "type": "string", "description": "Analysis signal ID or unique prefix." }
+ }),
+ &["id"],
+ )
+}
+
+fn schema_policy_check() -> Value {
+ schema_object(
+ json!({
+ "workspace": { "type": "string" },
+ "include_analysis": { "type": "boolean" },
+ "with": {
+ "oneOf": [
+ { "type": "array", "items": { "type": "string" } },
+ { "type": "string" }
+ ]
+ },
+ "online": { "type": "boolean" },
+ "compact": { "type": "boolean" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 250 }
+ }),
+ &[],
+ )
+}
+
+fn schema_fix_plan() -> Value {
+ schema_object(
+ json!({
+ "workspace": { "type": "string" },
+ "id": { "type": "string", "description": "Finding ID or unique prefix." },
+ "rule": { "type": "string", "description": "Rule ID." },
+ "all": { "type": "boolean" }
+ }),
+ &[],
+ )
+}
+
+fn schema_report_changes() -> Value {
+ schema_object(
+ json!({
+ "base": { "type": "string", "description": "Base report JSON path." },
+ "head": { "type": "string", "description": "Head report JSON path." }
+ }),
+ &[],
+ )
+}
+
+fn schema_action_id() -> Value {
+ schema_object(
+ json!({
+ "action_id": { "type": "string" }
+ }),
+ &["action_id"],
+ )
+}
+
+fn schema_action_apply() -> Value {
+ schema_object(
+ json!({
+ "action_id": { "type": "string" },
+ "confirm": { "type": "boolean", "const": true },
+ "executable": { "type": "string", "description": "Nightward executable path for schedule install actions." },
+ "policy_path": { "type": "string", "description": "Optional policy path under NIGHTWARD_HOME for policy actions." },
+ "finding_id": { "type": "string", "description": "Finding ID for policy.ignore." },
+ "rule": { "type": "string", "description": "Rule ID for policy.ignore." },
+ "reason": { "type": "string", "description": "Required reviewed reason for policy.ignore." }
+ }),
+ &["action_id", "confirm"],
+ )
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use crate::actions::ApplyOptions;
+ use std::collections::BTreeSet;
+ use std::fs;
+
+ #[test]
+ fn initialize_negotiates_latest_and_compat_protocols() {
+ let home = tempfile::tempdir().expect("temp home");
+ let latest = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}),
+ home.path(),
+ );
+ assert_eq!(latest["result"]["protocolVersion"], "2025-11-25");
+
+ let compat = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}),
+ home.path(),
+ );
+ assert_eq!(compat["result"]["protocolVersion"], "2025-06-18");
+
+ let fallback = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}),
+ home.path(),
+ );
+ assert_eq!(fallback["result"]["protocolVersion"], "2025-11-25");
+ }
+
+ #[test]
+ fn lists_schema_backed_tools_resources_and_prompts() {
+ let home = tempfile::tempdir().expect("temp home");
+ let tools_response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}),
+ home.path(),
+ );
+ let tools = tools_response["result"]["tools"].as_array().unwrap();
+ let names: BTreeSet<_> = tools
+ .iter()
+ .map(|tool| tool["name"].as_str().unwrap())
+ .collect();
+ for name in [
+ "nightward_scan",
+ "nightward_doctor",
+ "nightward_findings",
+ "nightward_explain_finding",
+ "nightward_analysis",
+ "nightward_explain_signal",
+ "nightward_policy_check",
+ "nightward_fix_plan",
+ "nightward_report_history",
+ "nightward_report_changes",
+ "nightward_actions_list",
+ "nightward_action_preview",
+ "nightward_action_apply",
+ "nightward_rules",
+ "nightward_providers",
+ ] {
+ assert!(names.contains(name), "missing {name}");
+ }
+ assert!(tools
+ .iter()
+ .all(|tool| tool["inputSchema"]["additionalProperties"] == false));
+ assert!(tools.iter().all(|tool| tool.get("outputSchema").is_some()));
+ assert!(tools.iter().all(|tool| tool.get("annotations").is_some()));
+
+ let resources_response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":2,"method":"resources/list"}),
+ home.path(),
+ );
+ let resource_uris: BTreeSet<_> = resources_response["result"]["resources"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|resource| resource["uri"].as_str().unwrap())
+ .collect();
+ for uri in [
+ "nightward://providers",
+ "nightward://schedule",
+ "nightward://latest-report",
+ "nightward://report-history",
+ ] {
+ assert!(resource_uris.contains(uri), "missing {uri}");
+ }
+
+ let prompts_response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":3,"method":"prompts/list"}),
+ home.path(),
+ );
+ assert!(prompts_response["result"]["prompts"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|prompt| prompt["name"] == "audit_my_ai_setup"));
+ }
+
+ #[test]
+ fn prompt_get_returns_messages() {
+ let home = tempfile::tempdir().expect("temp home");
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"fix_this_finding","arguments":{"finding_id":"abc"}}}),
+ home.path(),
+ );
+ assert!(response["result"]["messages"][0]["content"]["text"]
+ .as_str()
+ .unwrap()
+ .contains("abc"));
+ }
+
+ #[test]
+ fn tool_errors_use_mcp_tool_result_errors() {
+ let home = tempfile::tempdir().expect("temp home");
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_missing","arguments":{}}}),
+ home.path(),
+ );
+ assert!(response.get("error").is_none());
+ assert_eq!(response["result"]["isError"], true);
+ assert!(response["result"]["structuredContent"]["error"]
+ .as_str()
+ .unwrap()
+ .contains("unknown tool"));
+ }
+
+ #[test]
+ fn action_apply_is_disclosure_gated_tool_result_error() {
+ let home = tempfile::tempdir().expect("temp home");
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_action_apply","arguments":{"action_id":"backup.snapshot","confirm":true}}}),
+ home.path(),
+ );
+ assert_eq!(response["result"]["isError"], true);
+ assert!(response["result"]["content"][0]["text"]
+ .as_str()
+ .unwrap()
+ .contains("disclosure"));
+ }
+
+ #[test]
+ fn tool_calls_reject_invalid_arguments_against_strict_schemas() {
+ let home = tempfile::tempdir().expect("temp home");
+ for (arguments, expected) in [
+ (
+ json!({"name":"nightward_actions_list","arguments":{"extra":true}}),
+ "does not accept argument",
+ ),
+ (
+ json!({"name":"nightward_scan","arguments":{"workspace":123}}),
+ "workspace` must be a string",
+ ),
+ (
+ json!({"name":"nightward_findings","arguments":{"limit":999}}),
+ "between 1 and 250",
+ ),
+ (
+ json!({"name":"nightward_findings","arguments":{"severity":"urgent"}}),
+ "known severity",
+ ),
+ (
+ json!({"name":"nightward_action_apply","arguments":{"action_id":"backup.snapshot","confirm":false}}),
+ "confirm` must be true",
+ ),
+ ] {
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":arguments}),
+ home.path(),
+ );
+ assert_eq!(response["result"]["isError"], true);
+ assert!(
+ response["result"]["content"][0]["text"]
+ .as_str()
+ .unwrap()
+ .contains(expected),
+ "expected {expected}, got {}",
+ response["result"]["content"][0]["text"]
+ );
+ }
+ }
+
+ #[test]
+ fn mcp_workspace_paths_must_stay_under_home_and_exist_without_symlinks() {
+ let home = tempfile::tempdir().expect("temp home");
+ let workspace = home.path().join("workspace");
+ fs::create_dir_all(&workspace).expect("workspace dir");
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_scan","arguments":{"workspace":workspace.display().to_string(),"compact":true}}}),
+ home.path(),
+ );
+ assert_eq!(response["result"]["isError"], false);
+
+ let outside = tempfile::tempdir().expect("outside");
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_scan","arguments":{"workspace":outside.path().display().to_string()}}}),
+ home.path(),
+ );
+ assert_eq!(response["result"]["isError"], true);
+ let text = response["result"]["content"][0]["text"].as_str().unwrap();
+ assert!(text.contains("NIGHTWARD_HOME"));
+ assert!(!text.contains(outside.path().to_string_lossy().as_ref()));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mcp_workspace_paths_reject_symlink_components() {
+ use std::os::unix::fs::symlink;
+
+ let home = tempfile::tempdir().expect("temp home");
+ let outside = tempfile::tempdir().expect("outside");
+ symlink(outside.path(), home.path().join("workspace")).expect("workspace symlink");
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_scan","arguments":{"workspace":"workspace"}}}),
+ home.path(),
+ );
+
+ assert_eq!(response["result"]["isError"], true);
+ assert!(response["result"]["content"][0]["text"]
+ .as_str()
+ .unwrap()
+ .contains("symlinks"));
+ }
+
+ #[test]
+ fn mcp_report_changes_paths_are_scoped_to_home() {
+ let home = tempfile::tempdir().expect("temp home");
+ let outside = tempfile::tempdir().expect("outside");
+ let base = outside.path().join("base.json");
+ let head = outside.path().join("head.json");
+ fs::write(&base, "{}").expect("base");
+ fs::write(&head, "{}").expect("head");
+
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_report_changes","arguments":{"base":base.display().to_string(),"head":head.display().to_string()}}}),
+ home.path(),
+ );
+
+ assert_eq!(response["result"]["isError"], true);
+ let text = response["result"]["content"][0]["text"].as_str().unwrap();
+ assert!(text.contains("NIGHTWARD_HOME"));
+ assert!(!text.contains(outside.path().to_string_lossy().as_ref()));
+ }
+
+ #[test]
+ fn action_apply_can_directly_apply_shared_registry_action() {
+ let home = tempfile::tempdir().expect("temp home");
+ fs::create_dir_all(home.path().join(".codex")).expect("codex dir");
+ fs::write(home.path().join(".codex/config.toml"), "model = \"test\"\n").expect("config");
+ actions::apply(
+ home.path(),
+ "disclosure.accept",
+ ApplyOptions {
+ confirm: true,
+ executable: "nightward".to_string(),
+ ..Default::default()
+ },
+ )
+ .expect("accept disclosure");
+
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_action_apply","arguments":{"action_id":"backup.snapshot","confirm":true}}}),
+ home.path(),
+ );
+
+ assert_eq!(response["result"]["isError"], false);
+ let writes = response["result"]["structuredContent"]["writes"]
+ .as_array()
+ .unwrap();
+ assert!(!writes.is_empty());
+ assert!(PathBuf::from(writes[0].as_str().unwrap())
+ .join(".codex/config.toml")
+ .is_file());
+ assert!(state::audit_path(home.path()).is_file());
+ }
#[test]
- fn lists_tools() {
- let response = handle_request(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}));
- assert!(response["result"]["tools"].as_array().unwrap().len() >= 5);
+ fn report_changes_errors_when_not_enough_history() {
+ let home = tempfile::tempdir().expect("temp home");
+ let response = handle_request_with_home(
+ json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_report_changes","arguments":{}}}),
+ home.path(),
+ );
+ assert_eq!(response["result"]["isError"], true);
+ assert!(response["result"]["content"][0]["text"]
+ .as_str()
+ .unwrap()
+ .contains("at least two saved reports"));
}
}
diff --git a/crates/nightward-core/src/policy.rs b/crates/nightward-core/src/policy.rs
index a73bf71..4bd56eb 100644
--- a/crates/nightward-core/src/policy.rs
+++ b/crates/nightward-core/src/policy.rs
@@ -8,6 +8,8 @@ use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
+pub const DEFAULT_POLICY: &str = "severity_threshold: high\nignore_findings: []\nignore_rules: []\ninclude_analysis: false\nanalysis_threshold: high\nanalysis_providers: []\nallow_online_providers: false\n";
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyConfig {
#[serde(default = "default_threshold")]
@@ -126,10 +128,7 @@ pub fn init_file(path: impl AsRef) -> Result<()> {
if let Some(parent) = path.as_ref().parent() {
fs::create_dir_all(parent)?;
}
- fs::write(
- path.as_ref(),
- "severity_threshold: high\nignore_findings: []\nignore_rules: []\ninclude_analysis: false\nanalysis_threshold: high\nanalysis_providers: []\nallow_online_providers: false\n",
- )?;
+ fs::write(path.as_ref(), DEFAULT_POLICY)?;
Ok(())
}
diff --git a/crates/nightward-core/src/providers.rs b/crates/nightward-core/src/providers.rs
index b543670..4db1811 100644
--- a/crates/nightward-core/src/providers.rs
+++ b/crates/nightward-core/src/providers.rs
@@ -3,7 +3,7 @@ use crate::inventory::redact_text;
use crate::RiskLevel;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
-use serde_json::Value;
+use serde_json::{json, Value};
use std::env;
use std::io::Read;
use std::path::{Path, PathBuf};
@@ -41,6 +41,23 @@ pub struct ProviderStatus {
pub detail: String,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ProviderInstallCommand {
+ pub provider: String,
+ pub program: String,
+ pub args: Vec,
+ pub url: String,
+ pub note: String,
+}
+
+impl ProviderInstallCommand {
+ pub fn command(&self) -> Vec {
+ std::iter::once(self.program.clone())
+ .chain(self.args.iter().cloned())
+ .collect()
+ }
+}
+
pub fn providers() -> Vec {
vec![
Provider {
@@ -60,6 +77,12 @@ pub fn providers() -> Vec {
"filesystem vulnerability, secret, and misconfig scanning",
),
online_provider("osv-scanner", "dependency vulnerability scanning"),
+ online_provider("grype", "filesystem and SBOM vulnerability scanning"),
+ local_provider("syft", "local SBOM and package inventory"),
+ online_provider(
+ "scorecard",
+ "repository trust and supply-chain score checks",
+ ),
Provider {
name: "socket".to_string(),
kind: "local-command".to_string(),
@@ -72,6 +95,73 @@ pub fn providers() -> Vec {
]
}
+pub fn install_command(name: &str) -> Option {
+ let (program, args, url, note) = match name.trim().to_ascii_lowercase().as_str() {
+ "gitleaks" => (
+ "brew",
+ vec!["install", "gitleaks"],
+ "https://github.com/gitleaks/gitleaks#installing",
+ "Local secret scanner. Homebrew is the lowest-friction macOS path.",
+ ),
+ "trufflehog" => (
+ "brew",
+ vec!["install", "trufflehog"],
+ "https://github.com/trufflesecurity/trufflehog#installation",
+ "Local secret scanner. Nightward runs it with verification disabled by default.",
+ ),
+ "semgrep" => (
+ "brew",
+ vec!["install", "semgrep"],
+ "https://semgrep.dev/docs/getting-started/",
+ "Local static analyzer. Nightward only runs Semgrep with a repo-local config.",
+ ),
+ "trivy" => (
+ "brew",
+ vec!["install", "trivy"],
+ "https://trivy.dev/latest/getting-started/installation/",
+ "Online-capable scanner. Nightward requires online-provider opt-in before use.",
+ ),
+ "osv-scanner" => (
+ "brew",
+ vec!["install", "osv-scanner"],
+ "https://google.github.io/osv-scanner/installation/",
+ "Online-capable vulnerability scanner. Nightward requires online-provider opt-in before use.",
+ ),
+ "grype" => (
+ "brew",
+ vec!["install", "grype"],
+ "https://oss.anchore.com/docs/reference/grype/quickstart/",
+ "Online-capable vulnerability scanner. Nightward requires online-provider opt-in before use.",
+ ),
+ "syft" => (
+ "brew",
+ vec!["install", "syft"],
+ "https://oss.anchore.com/docs/reference/syft/quickstart/",
+ "Local SBOM generator. Nightward uses it for package inventory signals.",
+ ),
+ "scorecard" => (
+ "go",
+ vec!["install", "github.com/ossf/scorecard/v5@latest"],
+ "https://github.com/ossf/scorecard#installation",
+ "Online repository trust scanner. Nightward requires online-provider opt-in before use.",
+ ),
+ "socket" => (
+ "npm",
+ vec!["install", "-g", "socket"],
+ "https://docs.socket.dev/docs/socket-cli",
+ "Remote scan creation provider. Nightward requires online-provider opt-in before use.",
+ ),
+ _ => return None,
+ };
+ Some(ProviderInstallCommand {
+ provider: name.trim().to_ascii_lowercase(),
+ program: program.to_string(),
+ args: args.into_iter().map(str::to_string).collect(),
+ url: url.to_string(),
+ note: note.to_string(),
+ })
+}
+
pub fn statuses(selected: &[String], online: bool) -> Vec {
let selected = selected_set(selected);
providers()
@@ -256,6 +346,9 @@ pub fn parse_provider_output(
"semgrep" => parse_semgrep(root, output),
"trivy" => parse_trivy(root, output),
"osv-scanner" => parse_osv(root, output),
+ "grype" => parse_grype(root, output),
+ "syft" => parse_syft(root, output),
+ "scorecard" => parse_scorecard(root, output),
"socket" => parse_socket(root, output),
_ => Ok(Vec::new()),
}
@@ -346,6 +439,14 @@ fn provider_args(name: &str, root: &Path) -> Result> {
.into_iter()
.map(str::to_string)
.collect(),
+ "grype" => vec![format!("dir:{root}"), "-o".to_string(), "json".to_string()],
+ "syft" => vec![format!("dir:{root}"), "-o".to_string(), "json".to_string()],
+ "scorecard" => vec![
+ "--format".to_string(),
+ "json".to_string(),
+ "--repo".to_string(),
+ scorecard_repo(Path::new(&root))?,
+ ],
"socket" => vec!["scan", "create", &root, "--json"]
.into_iter()
.map(str::to_string)
@@ -557,6 +658,124 @@ fn collect_osv_results(root: &Path, value: &Value, out: &mut Vec Result> {
+ if output.trim().is_empty() {
+ return Ok(Vec::new());
+ }
+ let value: Value = serde_json::from_str(output)?;
+ let mut out = Vec::new();
+ for item in value
+ .get("matches")
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ {
+ let id = nested_string(item, &["vulnerability", "id"])
+ .or_else(|| first_string(item, &["vulnerabilityID", "id"]))
+ .unwrap_or_else(|| "vulnerability".to_string());
+ let package = nested_string(item, &["artifact", "name"]).unwrap_or_default();
+ let path = item
+ .get("artifact")
+ .and_then(|artifact| artifact.get("locations"))
+ .and_then(Value::as_array)
+ .and_then(|locations| locations.first())
+ .and_then(|location| nested_string(location, &["path"]))
+ .unwrap_or_default();
+ out.push(ProviderFinding {
+ rule: id.clone(),
+ path: normalize_provider_path(root, &path),
+ message: redact_text(&format!("Grype reported {id} in {package}.")),
+ evidence: redact_text(&item.to_string()),
+ severity: severity_from_string(
+ nested_string(item, &["vulnerability", "severity"]).as_deref(),
+ ),
+ category: SignalCategory::SupplyChain,
+ });
+ }
+ Ok(out)
+}
+
+fn parse_syft(root: &Path, output: &str) -> Result> {
+ if output.trim().is_empty() {
+ return Ok(Vec::new());
+ }
+ let value: Value = serde_json::from_str(output)?;
+ let artifacts = value
+ .get("artifacts")
+ .and_then(Value::as_array)
+ .map(Vec::len)
+ .unwrap_or_default();
+ if artifacts == 0 {
+ return Ok(Vec::new());
+ }
+ let source = nested_string(&value, &["source", "target"])
+ .or_else(|| nested_string(&value, &["source", "name"]))
+ .unwrap_or_else(|| root.display().to_string());
+ Ok(vec![ProviderFinding {
+ rule: "sbom_inventory".to_string(),
+ path: normalize_provider_path(root, &source),
+ message: format!("Syft identified {artifacts} package artifacts for SBOM review."),
+ evidence: redact_text(&json!({ "artifacts": artifacts, "source": source }).to_string()),
+ severity: RiskLevel::Info,
+ category: SignalCategory::SupplyChain,
+ }])
+}
+
+fn parse_scorecard(root: &Path, output: &str) -> Result> {
+ if output.trim().is_empty() {
+ return Ok(Vec::new());
+ }
+ let value: Value = serde_json::from_str(output)?;
+ let mut out = Vec::new();
+ for check in value
+ .get("checks")
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ {
+ let score = first_number(check, &["score"]).unwrap_or(10.0);
+ if !(0.0..8.0).contains(&score) {
+ continue;
+ }
+ let name = first_string(check, &["name"]).unwrap_or_else(|| "scorecard_check".to_string());
+ let reason = first_string(check, &["reason"])
+ .unwrap_or_else(|| "OpenSSF Scorecard reported a lower-scoring check.".to_string());
+ out.push(ProviderFinding {
+ rule: format!("scorecard_{}", normalize_rule_label(&name)),
+ path: root.display().to_string(),
+ message: redact_text(&format!(
+ "OpenSSF Scorecard scored {name} at {score:.1}: {reason}"
+ )),
+ evidence: redact_text(&check.to_string()),
+ severity: if score < 5.0 {
+ RiskLevel::Medium
+ } else {
+ RiskLevel::Low
+ },
+ category: SignalCategory::SupplyChain,
+ });
+ }
+ if out.is_empty() {
+ if let Some(score) = first_number(&value, &["score"]) {
+ if score < 8.0 {
+ out.push(ProviderFinding {
+ rule: "scorecard_overall".to_string(),
+ path: root.display().to_string(),
+ message: format!("OpenSSF Scorecard overall score is {score:.1}."),
+ evidence: redact_text(&value.to_string()),
+ severity: if score < 5.0 {
+ RiskLevel::Medium
+ } else {
+ RiskLevel::Low
+ },
+ category: SignalCategory::SupplyChain,
+ });
+ }
+ }
+ }
+ Ok(out)
+}
+
fn parse_socket(root: &Path, output: &str) -> Result> {
if output.trim().is_empty() {
return Ok(Vec::new());
@@ -602,11 +821,11 @@ fn parse_socket(root: &Path, output: &str) -> Result> {
}
fn trivy_severity(value: &Value) -> RiskLevel {
- match first_string(value, &["Severity"])
- .unwrap_or_default()
- .to_ascii_uppercase()
- .as_str()
- {
+ severity_from_string(first_string(value, &["Severity"]).as_deref())
+}
+
+fn severity_from_string(value: Option<&str>) -> RiskLevel {
+ match value.unwrap_or_default().to_ascii_uppercase().as_str() {
"CRITICAL" => RiskLevel::Critical,
"HIGH" => RiskLevel::High,
"MEDIUM" => RiskLevel::Medium,
@@ -636,6 +855,23 @@ fn nested_string(value: &Value, keys: &[&str]) -> Option {
current.as_str().map(ToString::to_string)
}
+fn first_number(value: &Value, keys: &[&str]) -> Option {
+ for key in keys {
+ if key.contains('.') {
+ let mut current = value;
+ for part in key.split('.') {
+ current = current.get(part)?;
+ }
+ if let Some(number) = current.as_f64() {
+ return Some(number);
+ }
+ } else if let Some(number) = value.get(*key).and_then(Value::as_f64) {
+ return Some(number);
+ }
+ }
+ None
+}
+
fn normalize_provider_path(root: &Path, path: &str) -> String {
if path.is_empty() {
return root.display().to_string();
@@ -648,6 +884,54 @@ fn normalize_provider_path(root: &Path, path: &str) -> String {
}
}
+fn scorecard_repo(root: &Path) -> Result {
+ if let Ok(repo) = env::var("NIGHTWARD_SCORECARD_REPO") {
+ let repo = repo.trim();
+ if !repo.is_empty() {
+ return Ok(repo.to_string());
+ }
+ }
+ let output = Command::new("git")
+ .args([
+ "-C",
+ root.to_string_lossy().as_ref(),
+ "config",
+ "--get",
+ "remote.origin.url",
+ ])
+ .output()
+ .context("spawn git for scorecard repo discovery")?;
+ if !output.status.success() {
+ return Err(anyhow!(
+ "scorecard requires NIGHTWARD_SCORECARD_REPO or a git remote.origin.url"
+ ));
+ }
+ let repo = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if repo.is_empty() {
+ return Err(anyhow!(
+ "scorecard requires NIGHTWARD_SCORECARD_REPO or a git remote.origin.url"
+ ));
+ }
+ Ok(repo)
+}
+
+fn normalize_rule_label(value: &str) -> String {
+ value
+ .chars()
+ .map(|ch| {
+ if ch.is_ascii_alphanumeric() {
+ ch.to_ascii_lowercase()
+ } else {
+ '_'
+ }
+ })
+ .collect::()
+ .split('_')
+ .filter(|part| !part.is_empty())
+ .collect::>()
+ .join("_")
+}
+
fn provider_timeout() -> Duration {
env::var("NIGHTWARD_PROVIDER_TIMEOUT_MS")
.ok()
diff --git a/crates/nightward-core/src/rules.rs b/crates/nightward-core/src/rules.rs
index c6c3508..be6dda7 100644
--- a/crates/nightward-core/src/rules.rs
+++ b/crates/nightward-core/src/rules.rs
@@ -61,6 +61,27 @@ pub fn all_rules() -> Vec {
title: "MCP server references a local credential path",
docs_url: "https://nightward.aethereal.dev/guide/privacy-model",
},
+ Rule {
+ id: "mcp_docker_socket",
+ severity: RiskLevel::High,
+ fix_kind: FixKind::ManualReview,
+ title: "MCP server can control Docker or container host state",
+ docs_url: "https://nightward.aethereal.dev/guide/mcp-security",
+ },
+ Rule {
+ id: "mcp_typosquat_package",
+ severity: RiskLevel::Medium,
+ fix_kind: FixKind::ManualReview,
+ title: "MCP server package resembles a trusted namespace",
+ docs_url: "https://nightward.aethereal.dev/guide/mcp-security",
+ },
+ Rule {
+ id: "mcp_untrusted_package_source",
+ severity: RiskLevel::Medium,
+ fix_kind: FixKind::ManualReview,
+ title: "MCP server launches a remote package or script source",
+ docs_url: "https://nightward.aethereal.dev/guide/mcp-security",
+ },
Rule {
id: "mcp_server_review",
severity: RiskLevel::Info,
@@ -89,6 +110,13 @@ pub fn all_rules() -> Vec {
title: "Config file is a symbolic link",
docs_url: "https://nightward.aethereal.dev/guide/privacy-model",
},
+ Rule {
+ id: "config_stale",
+ severity: RiskLevel::Low,
+ fix_kind: FixKind::ManualReview,
+ title: "Config file has not changed in over 180 days",
+ docs_url: "https://nightward.aethereal.dev/guide/privacy-model",
+ },
]
}
diff --git a/crates/nightward-core/src/schedule.rs b/crates/nightward-core/src/schedule.rs
index 03f1c1c..72db00d 100644
--- a/crates/nightward-core/src/schedule.rs
+++ b/crates/nightward-core/src/schedule.rs
@@ -1,17 +1,24 @@
use crate::inventory::load_report_summary;
+use crate::state;
+use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
+use std::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct ScheduleStatus {
+ pub preset: String,
pub installed: bool,
pub platform: String,
pub report_dir: String,
+ pub log_dir: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_report: Option,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub last_run: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub last_findings: Option,
pub history: Vec,
}
@@ -29,8 +36,10 @@ pub struct SchedulePlan {
pub schema_version: u32,
pub mode: String,
pub install: bool,
+ pub preset: String,
pub platform: String,
- pub command: String,
+ pub command: Vec,
+ pub writes: Vec,
pub notes: Vec,
}
@@ -38,33 +47,308 @@ pub fn report_dir(home: impl AsRef) -> PathBuf {
home.as_ref().join(".local/state/nightward/reports")
}
+pub fn log_dir(home: impl AsRef) -> PathBuf {
+ home.as_ref().join(".local/state/nightward/logs")
+}
+
+pub fn runner_path(home: impl AsRef) -> PathBuf {
+ home.as_ref()
+ .join(".local/state/nightward/bin/nightward-scheduled-scan")
+}
+
pub fn status(home: impl AsRef) -> ScheduleStatus {
+ let home = home.as_ref();
let dir = report_dir(home);
let mut history = history(&dir);
history.sort_by(|a, b| b.mod_time.cmp(&a.mod_time));
let last = history.first().cloned();
ScheduleStatus {
- installed: false,
+ preset: "nightly".to_string(),
+ installed: schedule_files(home).iter().any(|path| path.exists()),
platform: std::env::consts::OS.to_string(),
report_dir: dir.display().to_string(),
+ log_dir: log_dir(home).display().to_string(),
last_report: last.as_ref().map(|entry| entry.path.clone()),
+ last_run: last.as_ref().map(|entry| entry.mod_time.to_rfc3339()),
last_findings: last.as_ref().map(|entry| entry.findings),
history,
}
}
-pub fn plan(install: bool) -> SchedulePlan {
+pub fn supports_install() -> bool {
+ matches!(std::env::consts::OS, "macos" | "linux")
+}
+
+pub fn plan(home: impl AsRef, install: bool, executable: &str) -> SchedulePlan {
+ let home = home.as_ref();
+ let executable = if executable.trim().is_empty() {
+ "nightward"
+ } else {
+ executable
+ };
SchedulePlan {
schema_version: 1,
- mode: "plan-only".to_string(),
+ mode: if install {
+ "install-preview"
+ } else {
+ "remove-preview"
+ }
+ .to_string(),
install,
+ preset: "nightly".to_string(),
platform: std::env::consts::OS.to_string(),
- command: "nightward scan --json".to_string(),
+ command: vec![
+ executable.to_string(),
+ "scan".to_string(),
+ "--json".to_string(),
+ ],
+ writes: schedule_files(home)
+ .into_iter()
+ .map(|path| path.display().to_string())
+ .collect(),
notes: vec![
- "Schedule changes are explicit command paths, not automatic mutation.".to_string(),
- "Review platform-specific launchd/systemd/cron output before installing.".to_string(),
+ "Schedule actions install user-level jobs only; they do not install root daemons."
+ .to_string(),
+ "Scheduled scans write redacted JSON reports and logs under ~/.local/state/nightward."
+ .to_string(),
+ ],
+ }
+}
+
+pub fn install(home: impl AsRef, executable: &str) -> Result {
+ let home = home.as_ref();
+ if !supports_install() {
+ return Err(anyhow!(
+ "schedule install is not implemented for {}",
+ std::env::consts::OS
+ ));
+ }
+ state::create_private_dir(&report_dir(home))?;
+ state::create_private_dir(&log_dir(home))?;
+ write_runner(home, executable)?;
+ match std::env::consts::OS {
+ "macos" => install_launchd(home)?,
+ "linux" => install_systemd(home)?,
+ _ => unreachable!(),
+ }
+ Ok(status(home))
+}
+
+pub fn remove(home: impl AsRef) -> Result {
+ let home = home.as_ref();
+ match std::env::consts::OS {
+ "macos" => remove_launchd(home)?,
+ "linux" => remove_systemd(home)?,
+ _ => {}
+ }
+ for path in schedule_files(home) {
+ if path.exists() {
+ fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
+ }
+ }
+ Ok(status(home))
+}
+
+fn schedule_files(home: &Path) -> Vec {
+ let mut files = vec![runner_path(home)];
+ match std::env::consts::OS {
+ "macos" => files.push(launchd_plist(home)),
+ "linux" => {
+ files.push(systemd_service(home));
+ files.push(systemd_timer(home));
+ }
+ _ => {}
+ }
+ files
+}
+
+fn write_runner(home: &Path, executable: &str) -> Result<()> {
+ let path = runner_path(home);
+ if let Some(parent) = path.parent() {
+ state::create_private_dir(parent)?;
+ }
+ let report_dir = report_dir(home);
+ let log_dir = log_dir(home);
+ let body = format!(
+ "#!/bin/sh\nset -eu\nmkdir -p '{}' '{}'\nTS=$(date -u +%Y%m%dT%H%M%SZ)\nNIGHTWARD_HOME='{}' '{}' scan --json --output \"{}/scan-$TS.json\" >> '{}/nightward-scheduled-scan.log' 2>&1\n",
+ shell_quote_path(&report_dir),
+ shell_quote_path(&log_dir),
+ shell_quote_path(home),
+ shell_quote(executable),
+ shell_quote_path(&report_dir),
+ shell_quote_path(&log_dir)
+ );
+ state::write_private_file(&path, body).with_context(|| format!("write {}", path.display()))?;
+ set_executable(&path)
+}
+
+#[cfg(unix)]
+fn set_executable(path: &Path) -> Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(path, fs::Permissions::from_mode(0o700))
+ .with_context(|| format!("chmod 700 {}", path.display()))
+}
+
+#[cfg(not(unix))]
+fn set_executable(_path: &Path) -> Result<()> {
+ Ok(())
+}
+
+fn shell_quote_path(path: &Path) -> String {
+ shell_quote(&path.display().to_string())
+}
+
+fn shell_quote(value: &str) -> String {
+ value.replace('\'', "'\\''")
+}
+
+#[cfg(target_os = "macos")]
+fn launchd_plist(home: &Path) -> PathBuf {
+ home.join("Library/LaunchAgents/dev.aethereal.nightward.plist")
+}
+
+#[cfg(not(target_os = "macos"))]
+fn launchd_plist(home: &Path) -> PathBuf {
+ home.join("Library/LaunchAgents/dev.aethereal.nightward.plist")
+}
+
+fn install_launchd(home: &Path) -> Result<()> {
+ let plist = launchd_plist(home);
+ if let Some(parent) = plist.parent() {
+ state::create_private_dir(parent)?;
+ }
+ let runner = runner_path(home);
+ let log = log_dir(home).join("launchd.log");
+ let err = log_dir(home).join("launchd.err.log");
+ let body = format!(
+ r#"
+
+
+
+ Labeldev.aethereal.nightward
+ ProgramArguments
+ {}
+ StartCalendarInterval
+ Hour2Minute0
+ StandardOutPath{}
+ StandardErrorPath{}
+
+
+"#,
+ xml_escape(&runner.display().to_string()),
+ xml_escape(&log.display().to_string()),
+ xml_escape(&err.display().to_string())
+ );
+ state::write_private_file(&plist, body)
+ .with_context(|| format!("write {}", plist.display()))?;
+ let domain = launchd_domain()?;
+ let _ = Command::new("launchctl")
+ .args(["bootout", &domain, plist.to_string_lossy().as_ref()])
+ .output();
+ run_command(
+ "launchctl",
+ &["bootstrap", &domain, plist.to_string_lossy().as_ref()],
+ )?;
+ run_command(
+ "launchctl",
+ &["enable", &format!("{domain}/dev.aethereal.nightward")],
+ )
+}
+
+fn remove_launchd(home: &Path) -> Result<()> {
+ let plist = launchd_plist(home);
+ let domain = launchd_domain()?;
+ let _ = Command::new("launchctl")
+ .args(["bootout", &domain, plist.to_string_lossy().as_ref()])
+ .output();
+ Ok(())
+}
+
+fn launchd_domain() -> Result {
+ let output = Command::new("id").arg("-u").output().context("run id -u")?;
+ if !output.status.success() {
+ return Err(anyhow!("id -u failed"));
+ }
+ Ok(format!(
+ "gui/{}",
+ String::from_utf8_lossy(&output.stdout).trim()
+ ))
+}
+
+fn systemd_dir(home: &Path) -> PathBuf {
+ home.join(".config/systemd/user")
+}
+
+fn systemd_service(home: &Path) -> PathBuf {
+ systemd_dir(home).join("nightward-scheduled-scan.service")
+}
+
+fn systemd_timer(home: &Path) -> PathBuf {
+ systemd_dir(home).join("nightward-scheduled-scan.timer")
+}
+
+fn install_systemd(home: &Path) -> Result<()> {
+ state::create_private_dir(&systemd_dir(home))?;
+ state::write_private_file(
+ &systemd_service(home),
+ format!(
+ "[Unit]\nDescription=Nightward scheduled scan\n\n[Service]\nType=oneshot\nExecStart={}\n",
+ runner_path(home).display()
+ ),
+ )?;
+ state::write_private_file(
+ &systemd_timer(home),
+ "[Unit]\nDescription=Run Nightward scheduled scan nightly\n\n[Timer]\nOnCalendar=*-*-* 02:00:00\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n",
+ )?;
+ run_command("systemctl", &["--user", "daemon-reload"])?;
+ run_command(
+ "systemctl",
+ &[
+ "--user",
+ "enable",
+ "--now",
+ "nightward-scheduled-scan.timer",
],
+ )
+}
+
+fn remove_systemd(_home: &Path) -> Result<()> {
+ let _ = Command::new("systemctl")
+ .args([
+ "--user",
+ "disable",
+ "--now",
+ "nightward-scheduled-scan.timer",
+ ])
+ .output();
+ let _ = Command::new("systemctl")
+ .args(["--user", "daemon-reload"])
+ .output();
+ Ok(())
+}
+
+fn run_command(program: &str, args: &[&str]) -> Result<()> {
+ let output = Command::new(program)
+ .args(args)
+ .output()
+ .with_context(|| format!("spawn {program}"))?;
+ if output.status.success() {
+ return Ok(());
}
+ Err(anyhow!(
+ "{} {} failed: {}",
+ program,
+ args.join(" "),
+ String::from_utf8_lossy(&output.stderr).trim()
+ ))
+}
+
+fn xml_escape(value: &str) -> String {
+ value
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
}
fn history(dir: &Path) -> Vec {
diff --git a/crates/nightward-core/src/state.rs b/crates/nightward-core/src/state.rs
new file mode 100644
index 0000000..ef02a24
--- /dev/null
+++ b/crates/nightward-core/src/state.rs
@@ -0,0 +1,362 @@
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use std::fs::{self, OpenOptions};
+use std::io::{ErrorKind, Write};
+use std::path::{Path, PathBuf};
+
+pub const DISCLOSURE_VERSION: u32 = 1;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Settings {
+ pub schema_version: u32,
+ #[serde(default)]
+ pub accepted_disclosure_version: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub accepted_disclosure_at: Option>,
+ #[serde(default)]
+ pub selected_providers: Vec,
+ #[serde(default)]
+ pub allow_online_providers: bool,
+}
+
+impl Default for Settings {
+ fn default() -> Self {
+ Self {
+ schema_version: 1,
+ accepted_disclosure_version: 0,
+ accepted_disclosure_at: None,
+ selected_providers: Vec::new(),
+ allow_online_providers: false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct DisclosureStatus {
+ pub schema_version: u32,
+ pub required_version: u32,
+ pub accepted: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub accepted_at: Option>,
+ pub text: String,
+}
+
+pub fn config_dir(home: impl AsRef) -> PathBuf {
+ home.as_ref().join(".config/nightward")
+}
+
+pub fn state_dir(home: impl AsRef) -> PathBuf {
+ home.as_ref().join(".local/state/nightward")
+}
+
+pub fn cache_dir(home: impl AsRef) -> PathBuf {
+ home.as_ref().join(".cache/nightward")
+}
+
+pub fn settings_path(home: impl AsRef) -> PathBuf {
+ config_dir(home).join("settings.json")
+}
+
+pub fn audit_path(home: impl AsRef) -> PathBuf {
+ state_dir(home).join("audit.jsonl")
+}
+
+pub fn disclosure_text() -> String {
+ [
+ "Nightward is a beta local security/devtool assistant.",
+ "It can inspect and, when explicitly confirmed, change local AI-agent, MCP, schedule, provider, and backup state.",
+ "You are responsible for reviewing previews, backups, provider behavior, and any resulting system changes.",
+ "Nightward provides no warranty and the maintainers are not liable for broken configs, lost data, exposed secrets, package-manager side effects, or third-party tool behavior.",
+ ]
+ .join(" ")
+}
+
+pub fn disclosure_status(home: impl AsRef) -> DisclosureStatus {
+ let settings = load_settings(home).unwrap_or_default();
+ DisclosureStatus {
+ schema_version: 1,
+ required_version: DISCLOSURE_VERSION,
+ accepted: settings.accepted_disclosure_version >= DISCLOSURE_VERSION,
+ accepted_at: settings.accepted_disclosure_at,
+ text: disclosure_text(),
+ }
+}
+
+pub fn accept_disclosure(home: impl AsRef) -> Result {
+ let home = home.as_ref();
+ let mut settings = load_settings(home).unwrap_or_default();
+ settings.accepted_disclosure_version = DISCLOSURE_VERSION;
+ settings.accepted_disclosure_at = Some(Utc::now());
+ save_settings(home, &settings)?;
+ Ok(disclosure_status(home))
+}
+
+pub fn load_settings(home: impl AsRef) -> Result {
+ let path = settings_path(home);
+ if !path.exists() {
+ return Ok(Settings::default());
+ }
+ let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
+ serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
+}
+
+pub fn save_settings(home: impl AsRef, settings: &Settings) -> Result<()> {
+ let path = settings_path(home);
+ write_private_file(
+ &path,
+ format!("{}\n", serde_json::to_string_pretty(settings)?),
+ )
+}
+
+pub fn set_provider_selected(
+ home: impl AsRef,
+ provider: &str,
+ selected: bool,
+) -> Result {
+ let home = home.as_ref();
+ let mut settings = load_settings(home).unwrap_or_default();
+ let normalized = provider.trim().to_ascii_lowercase();
+ settings
+ .selected_providers
+ .retain(|existing| existing != &normalized);
+ if selected && !normalized.is_empty() {
+ settings.selected_providers.push(normalized);
+ settings.selected_providers.sort();
+ }
+ save_settings(home, &settings)?;
+ Ok(settings)
+}
+
+pub fn set_online_providers_allowed(home: impl AsRef, allowed: bool) -> Result {
+ let home = home.as_ref();
+ let mut settings = load_settings(home).unwrap_or_default();
+ settings.allow_online_providers = allowed;
+ save_settings(home, &settings)?;
+ Ok(settings)
+}
+
+pub fn append_audit(home: impl AsRef, value: &impl Serialize) -> Result {
+ let path = audit_path(home);
+ if let Some(parent) = path.parent() {
+ create_private_dir(parent)?;
+ }
+ ensure_regular_file_or_missing(&path)?;
+ let mut file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&path)
+ .with_context(|| format!("open {}", path.display()))?;
+ set_private_file_permissions(&path)?;
+ writeln!(file, "{}", serde_json::to_string(value)?)
+ .with_context(|| format!("append {}", path.display()))?;
+ Ok(path)
+}
+
+pub fn create_private_dir(path: &Path) -> Result<()> {
+ if path.as_os_str().is_empty() {
+ return Ok(());
+ }
+
+ let mut missing = Vec::new();
+ let mut cursor = path;
+ loop {
+ if cursor.as_os_str().is_empty() {
+ break;
+ }
+ match fs::symlink_metadata(cursor) {
+ Ok(metadata) => {
+ ensure_private_dir_metadata(cursor, &metadata)?;
+ break;
+ }
+ Err(error) if error.kind() == ErrorKind::NotFound => {
+ missing.push(cursor.to_path_buf());
+ let Some(parent) = cursor.parent() else {
+ break;
+ };
+ cursor = parent;
+ }
+ Err(error) => {
+ return Err(error).with_context(|| format!("inspect {}", cursor.display()));
+ }
+ }
+ }
+
+ for dir in missing.iter().rev() {
+ match fs::create_dir(dir) {
+ Ok(()) => {}
+ Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
+ Err(error) => return Err(error).with_context(|| format!("create {}", dir.display())),
+ }
+ ensure_private_dir(dir)?;
+ set_private_dir_permissions(dir)?;
+ }
+
+ ensure_private_dir(path)?;
+ set_private_dir_permissions(path)?;
+ Ok(())
+}
+
+pub fn ensure_regular_file_or_missing(path: &Path) -> Result<()> {
+ match fs::symlink_metadata(path) {
+ Ok(metadata) => {
+ if metadata.file_type().is_symlink() {
+ return Err(anyhow::anyhow!(
+ "refusing to write through symlinked Nightward path {}",
+ path.display()
+ ));
+ }
+ if !metadata.is_file() {
+ return Err(anyhow::anyhow!(
+ "refusing to write non-regular Nightward path {}",
+ path.display()
+ ));
+ }
+ Ok(())
+ }
+ Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())),
+ }
+}
+
+pub fn write_private_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
+ if let Some(parent) = path.parent() {
+ create_private_dir(parent)?;
+ }
+ ensure_regular_file_or_missing(path)?;
+ let temp_path = private_temp_path(path)?;
+ let write_result = (|| -> Result<()> {
+ {
+ let mut file = OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&temp_path)
+ .with_context(|| format!("create {}", temp_path.display()))?;
+ file.write_all(contents.as_ref())
+ .with_context(|| format!("write {}", temp_path.display()))?;
+ file.sync_all()
+ .with_context(|| format!("sync {}", temp_path.display()))?;
+ }
+ set_private_file_permissions(&temp_path)?;
+ #[cfg(windows)]
+ if path.exists() {
+ ensure_regular_file_or_missing(path)?;
+ fs::remove_file(path).with_context(|| format!("remove {}", path.display()))?;
+ }
+ fs::rename(&temp_path, path)
+ .with_context(|| format!("rename {} to {}", temp_path.display(), path.display()))?;
+ set_private_file_permissions(path)?;
+ Ok(())
+ })();
+ if write_result.is_err() {
+ let _ = fs::remove_file(&temp_path);
+ }
+ write_result
+}
+
+fn private_temp_path(path: &Path) -> Result {
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .ok_or_else(|| anyhow::anyhow!("Nightward write path must include a file name"))?;
+ Ok(path.with_file_name(format!(
+ ".{file_name}.tmp-{}-{}",
+ std::process::id(),
+ Utc::now()
+ .timestamp_nanos_opt()
+ .unwrap_or_else(|| Utc::now().timestamp_micros())
+ )))
+}
+
+fn ensure_private_dir(path: &Path) -> Result<()> {
+ let metadata =
+ fs::symlink_metadata(path).with_context(|| format!("inspect {}", path.display()))?;
+ ensure_private_dir_metadata(path, &metadata)
+}
+
+fn ensure_private_dir_metadata(path: &Path, metadata: &fs::Metadata) -> Result<()> {
+ if metadata.file_type().is_symlink() {
+ return Err(anyhow::anyhow!(
+ "refusing to use symlinked Nightward directory {}",
+ path.display()
+ ));
+ }
+ if !metadata.is_dir() {
+ return Err(anyhow::anyhow!(
+ "refusing to use non-directory Nightward path {}",
+ path.display()
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+pub fn set_private_dir_permissions(path: &Path) -> Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(path, fs::Permissions::from_mode(0o700))
+ .with_context(|| format!("chmod 700 {}", path.display()))
+}
+
+#[cfg(not(unix))]
+pub fn set_private_dir_permissions(_path: &Path) -> Result<()> {
+ Ok(())
+}
+
+#[cfg(unix)]
+pub fn set_private_file_permissions(path: &Path) -> Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(path, fs::Permissions::from_mode(0o600))
+ .with_context(|| format!("chmod 600 {}", path.display()))
+}
+
+#[cfg(not(unix))]
+pub fn set_private_file_permissions(_path: &Path) -> Result<()> {
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[cfg(unix)]
+ #[test]
+ fn private_file_writes_reject_symlinked_file_and_directory_paths() {
+ use std::os::unix::fs::symlink;
+
+ let home = tempfile::tempdir().expect("home");
+ let outside = tempfile::tempdir().expect("outside");
+ let target = outside.path().join("settings.json");
+ fs::write(&target, "{}\n").expect("target");
+ let config = config_dir(home.path());
+ fs::create_dir_all(&config).expect("config dir");
+ symlink(&target, settings_path(home.path())).expect("settings symlink");
+
+ let error = save_settings(home.path(), &Settings::default()).expect_err("symlink file");
+ assert!(error.to_string().contains("symlinked Nightward path"));
+
+ fs::remove_file(settings_path(home.path())).expect("remove symlink");
+ fs::remove_dir_all(&config).expect("remove config");
+ symlink(outside.path(), &config).expect("config dir symlink");
+
+ let error = save_settings(home.path(), &Settings::default()).expect_err("symlink dir");
+ assert!(error.to_string().contains("symlinked Nightward directory"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn audit_append_rejects_symlinked_audit_file() {
+ use std::os::unix::fs::symlink;
+
+ let home = tempfile::tempdir().expect("home");
+ let outside = tempfile::tempdir().expect("outside");
+ let target = outside.path().join("audit.jsonl");
+ fs::write(&target, "{}\n").expect("target");
+ let audit = audit_path(home.path());
+ fs::create_dir_all(audit.parent().unwrap()).expect("audit dir");
+ symlink(&target, &audit).expect("audit symlink");
+
+ let error =
+ append_audit(home.path(), &serde_json::json!({"ok":true})).expect_err("symlink audit");
+ assert!(error.to_string().contains("symlinked Nightward path"));
+ }
+}
diff --git a/crates/nightward-core/tests/provider_contracts.rs b/crates/nightward-core/tests/provider_contracts.rs
index 6d08768..b8a96be 100644
--- a/crates/nightward-core/tests/provider_contracts.rs
+++ b/crates/nightward-core/tests/provider_contracts.rs
@@ -41,6 +41,14 @@ fn provider_fixtures_normalize_supported_outputs() {
1,
SignalCategory::SupplyChain,
),
+ ("grype", "grype.json", 1, SignalCategory::SupplyChain),
+ ("syft", "syft.json", 1, SignalCategory::SupplyChain),
+ (
+ "scorecard",
+ "scorecard.json",
+ 1,
+ SignalCategory::SupplyChain,
+ ),
("socket", "socket.json", 1, SignalCategory::SupplyChain),
];
diff --git a/docs/analysis.md b/docs/analysis.md
index 26cb970..2ffa064 100644
--- a/docs/analysis.md
+++ b/docs/analysis.md
@@ -27,8 +27,11 @@ The built-in `nightward` provider is always offline and enabled by default. Opti
| `gitleaks` | local command | `--with gitleaks` | Secret pattern scanning over the selected workspace. |
| `trufflehog` | local command | `--with trufflehog` | Filesystem secret scanning with verification disabled by default. |
| `semgrep` | local command | `--with semgrep` | Static analysis using only a repo-local Semgrep config. |
+| `syft` | local command | `--with syft` | SBOM and local package inventory. |
| `trivy` | online-capable command | `--with trivy --online` | Filesystem vulnerability, secret, and misconfiguration scan; Trivy may update vulnerability databases. |
| `osv-scanner` | online-capable command | `--with osv-scanner --online` | Recursive source/lockfile vulnerability scan against OSV data. |
+| `grype` | online-capable command | `--with grype --online` | Filesystem/SBOM vulnerability scanning; vulnerability DB behavior can contact upstream services. |
+| `scorecard` | online-capable command | `--with scorecard --online` | OpenSSF repository trust checks against a git remote or `NIGHTWARD_SCORECARD_REPO`. |
| `socket` | online-capable command | `--with socket --online` | Creates a remote Socket scan artifact from dependency manifest metadata. |
Check provider posture:
@@ -36,7 +39,7 @@ Check provider posture:
```sh
nw providers list --json
nw providers doctor --json
-nw providers doctor --with socket --json
+nw providers doctor --with syft,socket --json
```
Unselected optional providers report `skipped`; selected online-capable providers report `blocked` until the online gate is present.
@@ -44,18 +47,18 @@ Unselected optional providers report `skipped`; selected online-capable provider
Online-capable providers remain blocked unless explicitly allowed:
```sh
-nw providers doctor --with socket --online --json
-nw analyze --workspace . --with trivy,osv-scanner,socket --online --json
+nw providers doctor --with trivy,grype,scorecard,socket --online --json
+nw analyze --workspace . --with trivy,osv-scanner,grype,scorecard,socket --online --json
```
Supported local providers can be executed explicitly during analysis:
```sh
nw analyze --workspace . --with gitleaks --json
-nw analyze --workspace . --with gitleaks,trufflehog,semgrep --json
+nw analyze --workspace . --with gitleaks,trufflehog,semgrep,syft --json
```
-Provider runs use timeouts and bounded output capture. Oversized stdout fails closed as a provider warning instead of being partially parsed. Nightward records redacted finding metadata, not raw secret values. Online-capable providers such as `trivy`, `osv-scanner`, and `socket` stay blocked unless the user also opts into online-capable behavior. Socket support is deliberately limited to scan creation and returned JSON parsing in v1; Nightward does not fetch or normalize remote Socket reports after creating the scan.
+Provider runs use timeouts and bounded output capture. Oversized stdout fails closed as a provider warning instead of being partially parsed. Nightward records redacted finding metadata, not raw secret values. Online-capable providers such as `trivy`, `osv-scanner`, `grype`, `scorecard`, and `socket` stay blocked unless the user also opts into online-capable behavior. Socket support is deliberately limited to scan creation and returned JSON parsing in v1; Nightward does not fetch or normalize remote Socket reports after creating the scan.
`semgrep` execution is local-config only. Nightward looks for `semgrep.yml`, `semgrep.yaml`, `.semgrep.yml`, `.semgrep.yaml`, or `.semgrep/config.yml` in the scanned workspace instead of using automatic rule discovery.
diff --git a/docs/ci-security.md b/docs/ci-security.md
index 1e11fe5..45a64bd 100644
--- a/docs/ci-security.md
+++ b/docs/ci-security.md
@@ -9,7 +9,7 @@ Nightward's CI is meant to prove the project is serious about the same safety po
- `nw policy badge`: writes a local JSON status artifact for dashboards or release evidence when a workflow explicitly requests it.
- `plugin.yaml`: defines Trunk Check linters for workspace policy and analysis SARIF once release tags are available.
- `scorecard.yml`: runs OpenSSF Scorecard on PRs, `main`, branch-protection changes, and a weekly schedule. PR runs do not publish results or upload SARIF; `main` and scheduled runs upload SARIF.
-- `release.yml`: publishes signed Rust artifacts from strict `vX.Y.Z` tags, smokes published Linux archives, and can publish the npm launcher only through trusted publishing when explicitly enabled.
+- `release.yml`: publishes signed Rust artifacts from strict `vX.Y.Z` tags, verifies published archives, and can publish the npm launcher only through trusted publishing when explicitly enabled.
- `pages.yml`: builds and deploys the VitePress documentation site from `site/` to GitHub Pages.
- `renovate.json`: manages Cargo dependencies, Raycast/npm packages, pinned GitHub Actions, local tool pins, and release tooling updates.
diff --git a/docs/contributing-fixtures.md b/docs/contributing-fixtures.md
index d8f214e..3f4b31c 100644
--- a/docs/contributing-fixtures.md
+++ b/docs/contributing-fixtures.md
@@ -22,6 +22,6 @@ Nightward needs real-world config shapes, but contributions must stay synthetic
- New MCP client config shapes for Codex, Claude Code, Cursor, VS Code, Cline/Roo, Goose, OpenCode, and generic MCP tools.
- Malformed JSON/TOML/YAML cases that should produce safe parse findings.
- Huge-file, symlink, local endpoint, credential path, broad filesystem, URL/header, and redaction edge cases.
-- Provider output samples from `gitleaks`, `trufflehog`, `semgrep`, `trivy`, `osv-scanner`, and `socket` with all secret material replaced.
+- Provider output samples from `gitleaks`, `trufflehog`, `semgrep`, `trivy`, `osv-scanner`, `grype`, `syft`, `scorecard`, and `socket` with all secret material replaced.
Open an adapter or rule request issue when the fixture is not ready as a pull request.
diff --git a/docs/growth.md b/docs/growth.md
index 10366cb..616025a 100644
--- a/docs/growth.md
+++ b/docs/growth.md
@@ -4,7 +4,7 @@ Nightward's growth should come from practical trust and low-friction adoption, n
## Product Features
-- Deeper provider normalization and fixtures for `gitleaks`, `trufflehog`, `semgrep`, `trivy`, `osv-scanner`, and `socket`.
+- Deeper provider normalization and fixtures for `gitleaks`, `trufflehog`, `semgrep`, `trivy`, `osv-scanner`, `grype`, `syft`, `scorecard`, and `socket`.
- Richer static HTML reports with local filtering, diff and history views, provider-warning summaries, policy status, and stronger remediation grouping.
- First-class report-history comparison across CLI, TUI, Raycast, and HTML.
- Contributor-facing rule metadata beyond the initial `nw rules list` and `nw rules explain` commands.
@@ -31,4 +31,4 @@ Nightward's growth should come from practical trust and low-friction adoption, n
- Redacted fixture contribution guide.
- Additional TUI screenshots and sample SARIF screenshot.
- Blog post: "What your AI tools leave in dotfiles."
-- Raycast store screenshots, privacy copy, command metadata, and development-mode smoke evidence.
+- Raycast store screenshots, privacy copy, command metadata, and fixture-backed development-mode evidence.
diff --git a/docs/install.md b/docs/install.md
index df1d3cb..d60685a 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -43,6 +43,8 @@ The package is a thin launcher:
- no bundled Node implementation of Nightward
- downloads the matching GitHub Release archive on first run
- verifies the archive SHA-256 from `checksums.txt`
+- rejects absolute, parent-directory, symlink, duplicate, or unexpected archive entries before extraction
+- optionally requires Cosign verification of `checksums.txt.sigstore.json` when `NIGHTWARD_NPM_REQUIRE_SIGSTORE=1` is set
- caches extracted `nightward` and `nw` binaries locally
Windows ARM64 remains deferred until the release matrix includes a validated Windows ARM64 Rust build.
diff --git a/docs/mcp-server.md b/docs/mcp-server.md
index 2c551bf..a5ddedc 100644
--- a/docs/mcp-server.md
+++ b/docs/mcp-server.md
@@ -1,12 +1,22 @@
# Nightward MCP Server
-Nightward includes a read-only stdio MCP server:
+Nightward includes a stdio MCP server:
```sh
nw mcp serve
```
-The server follows the MCP JSON-RPC lifecycle for `initialize`, `tools/list`, `tools/call`, `resources/list`, and `resources/read`. It is intentionally narrow for v1: no HTTP listener, no telemetry, no live config mutation, no schedule install/remove, and no online-capable provider execution.
+The server is a first-class Nightward surface for AI clients. It exposes scan, analysis, policy, report, provider, rule, prompt, and bounded action workflows without requiring users to copy CLI commands out of chat.
+
+## Protocol Behavior
+
+- Negotiates MCP `2025-11-25` and remains compatible with `2025-06-18`.
+- Declares `tools`, `resources`, and `prompts` capabilities.
+- Provides strict tool input schemas with `additionalProperties: false`.
+- Enforces those input schemas server-side, including unknown-key rejection, required fields, type checks, severity enums, `confirm: true`, and integer bounds.
+- Returns `structuredContent` plus text fallback for tool results.
+- Reports tool execution failures as MCP tool results with `isError: true`.
+- Adds output schemas and tool annotations for read-only, destructive, idempotent, and open-world hints.
## Exposed Tools
@@ -14,20 +24,46 @@ The server follows the MCP JSON-RPC lifecycle for `initialize`, `tools/list`, `t
- `nightward_doctor`
- `nightward_findings`
- `nightward_explain_finding`
+- `nightward_analysis`
+- `nightward_explain_signal`
+- `nightward_policy_check`
- `nightward_fix_plan`
+- `nightward_report_history`
- `nightward_report_changes`
-- `nightward_policy_check`
+- `nightward_actions_list`
+- `nightward_action_preview`
+- `nightward_action_apply`
+- `nightward_rules`
+- `nightward_providers`
-`nightward_policy_check` accepts `compact: true` for AI-client friendly output.
-Compact mode keeps pass/fail, threshold, summary counts, and bounded violation
-metadata without returning every full finding or policy-ignore detail.
+`nightward_action_apply` is intentionally narrow. It can apply only shared Nightward action-registry IDs, such as disclosure acceptance, policy init/ignore-with-reason, schedule install/remove where supported, backup snapshot, report/cache cleanup, provider install/enable/disable, and online-provider toggles. It cannot rewrite arbitrary MCP or agent config.
+
+Apply calls require:
+
+- accepted Nightward responsibility disclosure
+- `confirm: true`
+- action availability checks
+- redacted output
+- audit logging under Nightward state
## Exposed Resources
+- `nightward://latest-summary`
+- `nightward://latest-report`
- `nightward://rules`
- `nightward://providers`
- `nightward://schedule`
-- `nightward://latest-report`
+- `nightward://actions`
+- `nightward://disclosure`
+- `nightward://report-history`
+
+## Exposed Prompts
+
+- `audit_my_ai_setup`
+- `explain_top_risks`
+- `fix_this_finding`
+- `set_up_providers`
+- `compare_reports`
## Example Client Config
@@ -42,13 +78,40 @@ metadata without returning every full finding or policy-ignore detail.
}
```
+VS Code-style clients use `servers` plus `type`:
+
+```json
+{
+ "servers": {
+ "nightward": {
+ "type": "stdio",
+ "command": "nw",
+ "args": ["mcp", "serve"]
+ }
+ }
+}
+```
+
Use an absolute `command` path if the AI client does not inherit the same `PATH` as your login shell.
+## Registry Metadata
+
+Nightward uses the existing npm launcher as the MCP Registry package target:
+
+- package: `@jsonbored/nightward`
+- registry name: `io.github.jsonbored/nightward`
+- package field: `mcpName`
+- metadata file: `server.json`
+
+CI validates that `server.json` and `packages/npm/package.json` agree before the npm package is considered release-ready.
+
## Safety Rules
-- Output is bounded before it is returned to the MCP client.
-- Nightward reports provider or tool execution errors as tool results with `isError: true`.
-- The MCP server can include offline analysis, but it does not enable online providers in v1.
-- Use `compact: true` for casual policy checks in chat clients; request the full
- policy report only when you need complete local review material.
-- Any future write-capable MCP tool must require a separate design review, confirmation UX, rollback story, and redaction tests.
+- Stdio only; no HTTP listener.
+- No telemetry.
+- No default network calls.
+- Online-capable providers remain blocked unless explicitly allowed.
+- Direct apply is limited to the shared action registry.
+- No live MCP/agent config mutation in MCP v1.
+- Workspace and explicit report-diff paths must stay under `NIGHTWARD_HOME`, exist as the expected regular file or directory type, avoid symlink components, and pass the existing bounded report-size checks.
+- Tool/resource/prompt output is bounded and redacted before it reaches the client.
diff --git a/docs/openssf-best-practices.md b/docs/openssf-best-practices.md
index c542186..53692bb 100644
--- a/docs/openssf-best-practices.md
+++ b/docs/openssf-best-practices.md
@@ -14,10 +14,10 @@ Project badge:
- Discussion and report archive:
- Vulnerability reporting:
- Build system: Rust workspace `Cargo.toml`/`Cargo.lock`, `Makefile`, Raycast and npm launcher `package-lock.json` files, and GitHub Actions.
-- Tests: `make test`, `make test-race`, `make test-junit`, `make coverage-check`, `make verify`.
+- Tests: `make test`, `make test-junit`, `make coverage-check`, `make docs-qa`, and `make verify`.
- CI: `.github/workflows/ci.yml`, `.github/workflows/nightward-policy.yml`, `.github/workflows/scorecard.yml`.
- Static analysis: `cargo fmt`, `cargo clippy`, Trunk Check, CodeQL, Gitleaks, OSV, and Nightward SARIF.
-- Dynamic analysis: automated tests, Rust tests, Raycast tests/build, and parser/property smoke tests.
+- Dynamic analysis: automated tests, Rust tests, Raycast Vitest/build checks, and parser/property fuzz checks.
- Secret scanning: Gitleaks in CI and `make gitleaks`.
- Release notes:
- Curated changelog index:
@@ -38,7 +38,7 @@ The release pipeline uses external tools for signing release checksums and SBOM
- Architecture/security model: `docs/privacy-model.md`, `docs/threat-model.md`, `docs/ci-security.md`, and `docs/remediation.md`.
- Dependency maintenance:
- DCO: `CONTRIBUTING.md` and the CI DCO sign-off job.
-- Release readiness: `make release-snapshot`, signed checksum config, SBOM planning, release workflow, release smoke, and trusted-publishing-only npm launcher package.
+- Release readiness: `make release-snapshot`, signed checksum config, SBOM planning, release workflow, release archive verification, and trusted-publishing-only npm launcher package.
- Website/docs readiness: `site/` VitePress source and `.github/workflows/pages.yml`.
- Distribution plan:
- npm provenance: `@jsonbored/nightward` publishes through trusted publishing/OIDC with provenance and no long-lived npm token.
@@ -48,7 +48,7 @@ The release pipeline uses external tools for signing release checksums and SBOM
- SLSA provenance/attestations for future release workflows.
- Reproducible-build comparison using pinned Rust toolchains, deterministic Cargo lockfiles, release archive checksums, and rebuild verification.
-- Broader fuzz/property duration beyond the current `cargo fuzz` smoke targets for MCP parsing, redaction, symlink traversal, huge files, malformed configs, and permission-denied paths.
+- Broader fuzz/property duration beyond the current bounded `cargo fuzz` targets for MCP parsing, redaction, symlink traversal, huge files, malformed configs, and permission-denied paths.
- More maintainer depth and documented two-person review coverage once the project has additional maintainers.
- Package-manager ecosystem coverage beyond npm and GitHub Releases.
diff --git a/docs/privacy-model.md b/docs/privacy-model.md
index 6572619..544eb32 100644
--- a/docs/privacy-model.md
+++ b/docs/privacy-model.md
@@ -8,12 +8,12 @@ Nightward is designed around local custody. The scanner inspects local file meta
- No Nightward runtime analytics.
- No cloud dashboard.
- No network calls from Nightward runtime.
-- Offline analysis is the default. Local provider execution happens only when the user explicitly selects providers with `--with` or policy config. Online-capable providers stay blocked unless the user explicitly passes `--online` or opts in through policy/config. `socket` is online-capable because it creates a remote Socket scan artifact from dependency manifest metadata.
-- No live backup, restore, Git push, or secret copy.
+- Offline analysis is the default. Local provider execution happens only when the user explicitly selects providers with `--with` or persisted provider settings. Online-capable providers stay blocked unless the user explicitly passes `--online` or opts in through policy/config/settings. `trivy`, `grype`, `osv-scanner`, `scorecard`, and `socket` are online-capable because vulnerability databases, repository checks, or remote scan artifacts can contact third-party services.
+- No restore, Git push, sync, or secret copy.
- No agent config mutation in scan, doctor, findings, fix, policy, or backup-plan commands.
-- The TUI can copy text, export redacted fix-plan Markdown, and open docs only after explicit keypresses.
-- The Raycast extension calls only read-only Nightward commands and explicit clipboard/report-folder actions.
-- The MCP server is stdio-only and exposes read-only tools/resources. It has no network listener, no schedule tools, no live mutation tools, and no online-provider execution in v1.
+- The TUI can apply only shared action-registry operations after disclosure acceptance and an explicit confirmation keypress.
+- The Raycast extension exposes the same shared action registry and uses Raycast confirmation prompts before applying actions.
+- The MCP server is stdio-only. It exposes read-only scan, analysis, policy, report, rule, provider, prompt, and resource context plus one write-capable path: `nightward_action_apply`, which can apply only shared action-registry operations after disclosure acceptance and `confirm: true`.
## Write Paths
@@ -22,16 +22,24 @@ Nightward writes only when explicitly asked:
- `scan --output FILE`
- `policy sarif --output FILE`
- `policy sarif --output -` writes SARIF to stdout only.
-- TUI `e` key: redacted fix-plan export under `~/.local/state/nightward/exports`
-- Raycast clipboard exports and report-folder open actions after explicit command invocation
+- `disclosure accept` or first TUI acceptance writes `~/.config/nightward/settings.json`
+- Confirmed provider settings and online-provider settings update `~/.config/nightward/settings.json`
+- Confirmed provider installs run only through the shared action registry after preview, disclosure acceptance, and confirmation.
+- Confirmed policy init/ignore actions write bounded Nightward policy files under `NIGHTWARD_HOME`; policy paths must be clean relative Nightward policy paths, and existing symlinks are rejected.
+- Confirmed schedule install/remove writes or removes user-level launchd/systemd files only.
+- Confirmed backup snapshots copy only regular portable backup candidates under `~/.local/state/nightward/snapshots`; symlinked or non-regular candidates are skipped and recorded in the manifest without following targets.
+- Confirmed cleanup actions remove only Nightward-owned report, log, or cache entries.
+- Raycast clipboard exports and report-folder open actions after explicit command invocation.
-Schedule install/remove is plan-only in v1. It describes intended launchd/systemd/cron commands, but does not write user-level scheduler files yet.
+Confirmed action writes append audit events under `~/.local/state/nightward/audit.jsonl`. Nightward still does not restore config, push to Git, sync secrets, or rewrite live MCP/agent configs.
-`nw mcp serve` is not a write path. It can run read-only scan, policy, finding, rule, report, and fix-plan context operations for an MCP client, but it cannot install schedules, mutate agent config, or enable online providers.
+Nightward-owned state writes reject symlinked directories and symlinked files before writing settings, audit logs, schedules, snapshots, or action-managed policy files.
+
+`nw mcp serve` can write only through the shared action registry. MCP clients cannot request arbitrary file edits, live MCP/agent config rewrites, restore operations, Git pushes, or secret sync. Direct apply requires disclosure acceptance, an explicit `confirm: true` argument, action availability checks, redacted output, and audit logging. MCP tool arguments are validated server-side against strict schemas, and MCP workspace/report paths must stay under `NIGHTWARD_HOME`, exist as regular files or directories as appropriate, and avoid symlink components.
The TUI docs action opens an http(s) documentation URL through the OS default opener after the user presses `o`; Nightward itself does not fetch docs content.
-The Raycast extension does not add a Nightward config write path. `Export Nightward Fix Plan` copies redacted Markdown to the clipboard after the user invokes that command. `Open Nightward Reports` opens the existing reports folder and does not create it.
+The Raycast extension's write path is limited to the shared Nightward action registry. `Export Nightward Fix Plan` copies redacted Markdown to the clipboard after the user invokes that command. `Open Nightward Reports` opens the existing reports folder and does not create it.
## Public Website Analytics
@@ -66,7 +74,7 @@ MCP argument evidence redacts secret-looking assignments and flag values, such a
Remote MCP URL evidence is structural only. Nightward records scheme and host for review, strips path/query details, and does not call the endpoint.
-Provider doctor output is intentionally about availability and privacy posture. It does not run optional scanners by default, install missing tools, or send package/file metadata to online services. Explicit provider runs use timeouts, bounded output capture, and redacted finding metadata; online-capable providers require `--online` or policy opt-in.
+Provider doctor output is intentionally about availability and privacy posture. It does not run optional scanners by default, install missing tools, or send package/file metadata to online services. Explicit provider runs use timeouts, bounded output capture, and redacted finding metadata; online-capable providers require `--online` or policy/settings opt-in. Provider install actions run only after disclosure acceptance and confirmation.
## What Still Needs Human Review
diff --git a/docs/raycast-extension.md b/docs/raycast-extension.md
index 907363d..0067d78 100644
--- a/docs/raycast-extension.md
+++ b/docs/raycast-extension.md
@@ -1,6 +1,6 @@
# Raycast Extension
-Nightward's Raycast extension is a local read-only companion for the CLI/TUI.
+Nightward's Raycast extension is a local companion for the CLI/TUI. Most commands are review-only; `Nightward Actions` can apply confirmation-gated local writes through the shared CLI action registry.
## Location
@@ -15,6 +15,7 @@ integrations/raycast
- `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.
- `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.
- `Explain Nightward Signal`: analysis signal view for a known finding ID.
- `Export Nightward Fix Plan`: copies `nw fix export --all --format markdown`.
@@ -43,7 +44,9 @@ The extension uses `execFile`, not a shell, for local Nightward commands. It cal
- `analyze finding --json`
- `providers doctor [--with providers] [--online] --json`
-It does not call schedule install/remove, backup writes, snapshot writes, restore, Git, or any config mutation command. Provider Doctor can run a displayed Homebrew/npm install command only after explicit confirmation.
+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.
+
+Nightward is beta operator tooling. Users are responsible for reviewing confirmations, write targets, provider behavior, and package-manager side effects before applying actions. The project provides no warranty.
The menu-bar command runs the same read-only scan, doctor, and built-in offline analysis commands. Provider selections affect the Analysis and Export Analysis commands only. Online-capable selections remain blocked unless the user enables `Allow Online Providers`; the extension never enables background mutation.
@@ -58,19 +61,20 @@ npm run build
npm run store-check
```
-Manual smoke:
+Fixture UI validation:
```sh
npm run dev
```
-Manual smoke must use a fixture `Home Override`, not a real local home, before screenshots or store metadata are published. Cover at least:
+Manual UI validation must use a fixture `Home Override`, not a real local home, before screenshots or store metadata are published. Cover at least:
- Dashboard loads scan counts, schedule status, adapters, and top findings.
- 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.
-- Provider Doctor shows provider status, install guidance, confirmation-gated provider CLI installation, and enable/disable controls for Raycast Analysis without running online-capable providers unless explicit opt-in is enabled.
+- 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.
- Open Reports opens only an existing reports folder.
diff --git a/docs/release.md b/docs/release.md
index 7217b60..96634ce 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -93,17 +93,17 @@ nw doctor --json
If a bad npm package is published, deprecate that version with a clear reason and publish a fixed patch release. Do not unpublish unless npm policy and the severity of the issue require it.
-## Release Smoke
+## Release Verification
-The release workflow smokes the published GitHub archive before npm publish:
+The release workflow verifies the published GitHub archive before npm publish:
```sh
-bash scripts/smoke-release-archive.sh vX.Y.Z
+bash scripts/verify-release-archive.sh vX.Y.Z
```
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 smoke:
+After npm publish, verify package metadata and launcher install behavior:
```sh
bash scripts/verify-npm-release.sh X.Y.Z
diff --git a/docs/testing.md b/docs/testing.md
index 82bf121..ef6fecc 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -59,7 +59,7 @@ make gitleaks
make cargo-audit
make cargo-deny
make coverage-check
-make fuzz-smoke
+make fuzz-check
make test-junit
make trunk-flaky-validate
make trunk-check
@@ -86,25 +86,26 @@ make verify
- Redaction tests must cover scan JSON, policy output, SARIF, Markdown exports, fix exports, and TUI text.
- Badge artifact tests must cover pass/fail shape, policy summary fields, optional SARIF URL, and no-write stdout mode.
- Golden-style tests should stay stable for JSON/SARIF shape, not timestamps or host-specific paths. Scan-summary goldens must keep item buckets separate from finding buckets.
-- MCP fixture tests should cover command servers, URL-shaped servers, sensitive headers, local endpoints, and unsupported shapes.
-- Parser fuzz harnesses live under `fuzz/` and cover MCP JSON/TOML/YAML parsing, URL/header redaction, symlink traversal, huge-file handling, and malformed config cases. Run a bounded local fuzz check with `make fuzz-smoke`; run a single target directly with `cargo fuzz run mcp_config_formats -- -runs=1024`.
-- Provider contract tests use `testdata/providers/*` fixtures for `gitleaks`, `trufflehog`, `semgrep`, `trivy`, `osv-scanner`, and `socket`.
+- MCP fixture tests should cover command servers, URL-shaped servers, sensitive headers, local/private endpoints, Docker/socket exposure, package provenance hints, stale configs, app-owned state, and unsupported shapes.
+- MCP server protocol tests should cover initialize negotiation, tools/resources/prompts lists, strict input schemas, server-side invalid-argument rejection, MCP path scoping, structured output, annotations, tool-result errors, disclosure-gated apply, successful action-registry apply, and report-change failure paths.
+- Parser fuzz harnesses live under `fuzz/` and cover MCP JSON/TOML/YAML parsing, URL/header redaction, symlink traversal, huge-file handling, and malformed config cases. Run a bounded local fuzz check with `make fuzz-check`; run a single target directly with `cargo fuzz run mcp_config_formats -- -runs=1024`.
+- Provider contract tests use `testdata/providers/*` fixtures for `gitleaks`, `trufflehog`, `semgrep`, `trivy`, `osv-scanner`, `grype`, `syft`, `scorecard`, and `socket`.
- 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 and safe command execution wrappers.
+- 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.
- `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.
- Raycast dependency audits run with `npm audit --audit-level=moderate`.
- The npm launcher tests run with `make npm-package-verify`, including unit tests, `npm audit`, and `npm pack --dry-run`.
-- `make docs-qa` verifies generated CLI/provider/rule/config references and fails on stale release-version placeholders in public docs.
+- `make docs-qa` verifies generated CLI/provider/rule/config references and runs the site Vitest docs contracts for stale copy, demo fixture IDs, and MCP tool/resource/prompt docs coverage.
## Trunk Flaky Tests
`make test-junit` writes:
-- `reports/junit/raycast.xml` from the Raycast extension Node tests
+- `reports/junit/raycast.xml` from the Raycast extension Vitest suite
`make trunk-flaky-validate` runs:
@@ -126,13 +127,14 @@ The extension has its own npm package under `integrations/raycast`.
cd integrations/raycast
npm ci
npm test
+npm run test:junit
npm run lint
npm run build
```
-`npm run dev` is the manual Raycast development path when the Raycast CLI is available. Do not run `npm run publish` unless release/publish scope is explicit.
+`npm test` runs the Raycast Vitest suite. `npm run test:junit` emits `reports/junit/raycast.xml` for Trunk flaky-test validation. `npm run dev` is the manual Raycast development path when the Raycast CLI is available. Do not run `npm run publish` unless release/publish scope is explicit.
-Manual smoke and screenshots must use fixture `Home Override` data only. Keep the evidence table in `docs/screenshots.md` current before broader promotion or Raycast store metadata work.
+Manual Raycast UI checks and screenshots must use fixture `Home Override` data only. Keep the evidence table in `docs/screenshots.md` current before broader promotion or Raycast store metadata work.
## NPM Launcher
@@ -153,13 +155,14 @@ The public docs/marketing site lives under `site/` and uses VitePress with local
```sh
cd site
npm ci
+npm test
npm audit --audit-level=moderate
npm run build
```
`make site-verify` also runs `make docs-qa` from the repository root. The site should not add analytics or third-party runtime scripts by default. If validating the public website analytics path, build once without Umami env values and confirm no tracker script exists, then build once with `NIGHTWARD_UMAMI_SCRIPT_URL` and `NIGHTWARD_UMAMI_WEBSITE_ID` set and confirm the script is scoped to `nightward.aethereal.dev`, respects Do Not Track, and excludes search/hash data.
-The launcher must remain dependency-light, avoid `postinstall`, and verify downloaded GitHub Release archives against `checksums.txt` before extraction.
+The launcher must remain dependency-light, avoid `postinstall`, verify downloaded GitHub Release archives against `checksums.txt`, reject unsafe archive entries before extraction, and support strict `NIGHTWARD_NPM_REQUIRE_SIGSTORE=1` Cosign verification when the caller requires it.
## Intentional Manual Or Post-Release Checks
@@ -168,6 +171,6 @@ Most repository checks are centralized behind `make verify` and the suite aliase
- `make demo-assets` regenerates fixture-only sample JSON, HTML, report screenshot, and social preview assets. It requires Chrome, Chromium, Brave, or `NIGHTWARD_CHROME`.
- `make tui-media` regenerates fixture-only TUI PNGs, the walkthrough GIF, and the homepage WebM loop from the embedded Rust TUI. It requires `vhs` and `ffmpeg`, uses `site/public/demo/nightward-sample-scan.json`, and must not be run against live HOME data.
- `make test-release-install VERSION=` verifies a published GitHub/npm release after artifacts exist.
-- `npm run dev` under `integrations/raycast` is the local Raycast UI smoke path and should be paired with fixture-only evidence in `docs/screenshots.md`.
+- `npm run dev` under `integrations/raycast` is the local Raycast UI development path and should be paired with fixture-only evidence in `docs/screenshots.md`.
New validation scripts should be wired into `make verify`, a suite alias, or this section with a clear reason they are manual.
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 3b5b7bc..0eb4a54 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -14,28 +14,28 @@ Nightward inspects local AI agent and devtool state, so its primary risk is acci
- Local filesystem input is untrusted. Config files may be malformed, hostile, huge, symlinked, or privacy-sensitive.
- CLI/TUI/Raycast output is a disclosure boundary. Secret values must not cross it.
-- Optional providers are execution boundaries. They are discovered but not installed automatically, unselected providers are skipped, online-capable providers are blocked until explicitly allowed, and provider timeout/output-cap failures are surfaced as warnings instead of clean results. Socket is treated as online-capable because it creates a remote scan artifact from dependency manifest metadata.
-- MCP clients are agent boundaries. `nw mcp serve` exposes read-only local context through stdio, so returned tool/resource content must stay redacted and bounded.
+- Optional providers are execution boundaries. They are discovered, selected, and installed only through explicit action paths, unselected providers are skipped, online-capable providers are blocked until explicitly allowed, and provider timeout/output-cap failures are surfaced as warnings instead of clean results. Trivy, Grype, OSV-Scanner, OpenSSF Scorecard, and Socket are treated as online-capable when their normal operation can contact external services.
+- MCP clients are agent boundaries. `nw mcp serve` exposes local context and bounded action application through stdio, so returned tool/resource/prompt content must stay redacted and writes must flow only through the action registry.
- GitHub Actions and Trunk integrations treat repository contents and PR input as untrusted.
-- Scheduler install/remove is plan-only in v1; any future scheduler writes must stay explicit and user-level.
+- Scheduler install/remove is explicit, confirmation-gated, and user-level only.
- Release automation and npm publishing are privileged publishing boundaries.
## Threats And Controls
- Secret disclosure: redact env/header values, secret-looking args, token-like strings, and Markdown/SARIF/TUI exports; test every output surface.
-- Unexpected mutation: scan, doctor, findings, fix, policy, backup-plan, snapshot, analysis, TUI, Raycast, and GitHub Action policy paths stay read-only except explicit output files.
+- Unexpected mutation: scan, doctor, findings, fix, policy, backup-plan, snapshot-plan, analysis, MCP, and GitHub Action policy paths stay read-only except explicit output files. TUI, Raycast, and CLI writes must flow through the shared action registry with disclosure acceptance and confirmation; cleanup actions are limited to Nightward-owned report, log, and cache paths.
- Unsafe portability: classify secret-auth, app-owned, runtime-cache, machine-local, and unknown state conservatively.
-- MCP execution ambiguity: flag shell wrappers, broad filesystem access, unpinned package execution, local endpoints, sensitive headers/env, token paths, and unknown shapes.
-- Supply-chain compromise: pin GitHub Actions by full SHA, use Renovate, run Gitleaks/OSV/CodeQL/Clippy/Trunk, keep release artifacts signed, and keep the npm package as a no-postinstall launcher that verifies archive checksums.
+- MCP execution ambiguity: flag shell wrappers, broad filesystem access, unpinned package execution, package-name impersonation risk, remote package sources, Docker/socket exposure, local/private endpoints, sensitive headers/env, token paths, stale configs, app-owned state, and unknown shapes.
+- Supply-chain compromise: pin GitHub Actions by full SHA, use Renovate, run Gitleaks/OSV/CodeQL/Clippy/Trunk, keep release artifacts signed, and keep the npm package as a no-postinstall launcher that verifies archive checksums, rejects unsafe archive entries, and can require Sigstore verification in strict environments.
- Malformed config denial-of-service: keep parser/fuzz coverage for MCP JSON/TOML/YAML, URL/header redaction, symlink traversal, huge-file handling, and malformed configs.
-- Agent overreach through MCP: do not expose write tools, schedule install/remove, HTTP listeners, live config mutation, or online provider execution through the MCP server in v1.
+- Agent overreach through MCP: expose write capability only through `nightward_action_apply`, not through arbitrary config mutation. Direct apply requires disclosure acceptance, `confirm: true`, registry action availability, redacted output, and audit logging. Tool inputs are validated against strict server-side schemas, and explicit workspace/report paths are scoped under `NIGHTWARD_HOME` with no-symlink regular-file/directory checks. Do not expose live MCP/agent config mutation, restore, sync, or HTTP listener behavior through MCP v1.
## Non-Goals
Nightward does not prove a tool, package, MCP server, or URL is safe. It reports local structure, known risky signals, and review guidance.
-Nightward does not back up, restore, sync, push to Git, or mutate agent configs in v1.
+Nightward can create local portable backup snapshots, but it does not restore, sync, push to Git, or mutate agent configs in v1.
## Review Triggers
-Update this model before adding live config mutation, restore, encrypted sync, online provider execution, hosted dashboards, release/npm publishing changes, MCP write tools, or new writable integrations.
+Update this model before adding live MCP/agent config mutation, restore, encrypted sync, hosted dashboards, release/npm publishing changes, MCP write tools, or new writable integrations.
diff --git a/integrations/raycast/README.md b/integrations/raycast/README.md
index 54db98b..a9e3f9e 100644
--- a/integrations/raycast/README.md
+++ b/integrations/raycast/README.md
@@ -2,7 +2,7 @@
Run Nightward scans, findings, provider checks, and redacted fix-plan exports from Raycast.
-This extension is read-only for Nightward data. It runs local `nw`/`nightward` commands, renders redacted JSON output, copies user-requested fix-plan and policy-review text, and opens the scheduled report folder. Provider Doctor can install known provider CLIs only after confirmation. It does not install schedules, edit agent configs, restore files, push to Git, or copy secret values.
+This extension is read-only until a user invokes the shared Nightward action registry. It runs local `nw`/`nightward` commands, renders redacted JSON output, copies user-requested fix-plan and policy-review text, opens the scheduled report folder, and can apply confirmed provider, policy, schedule, backup, cleanup, and setup actions. It does not edit agent configs, restore files, push to Git, or copy secret values.
## Commands
@@ -10,7 +10,8 @@ This extension is read-only for Nightward data. It runs local `nw`/`nightward` c
- `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.
-- `Nightward Provider Doctor`: provider availability, privacy posture, confirmation-gated install guidance for missing tools, and Raycast Analysis enable/disable controls.
+- `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.
- `Explain Nightward Signal`: detail view for the analysis signal attached to a finding ID.
- `Export Nightward Fix Plan`: copies `nw fix export --format markdown` output.
@@ -33,7 +34,7 @@ npm run build
npm run store-check
```
-Manual smoke testing requires the Raycast CLI:
+Manual UI validation requires the Raycast CLI:
```bash
npm run dev
diff --git a/integrations/raycast/package-lock.json b/integrations/raycast/package-lock.json
index 20cf548..abdbcba 100644
--- a/integrations/raycast/package-lock.json
+++ b/integrations/raycast/package-lock.json
@@ -15,8 +15,42 @@
"devDependencies": {
"@raycast/eslint-config": "2.1.1",
"@types/node": "22.19.17",
- "tsx": "4.21.0",
- "typescript": "6.0.3"
+ "typescript": "6.0.3",
+ "vitest": "^4.0.8"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -966,6 +1000,32 @@
}
}
},
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
"node_modules/@oclif/core": {
"version": "4.10.6",
"resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.10.6.tgz",
@@ -1037,6 +1097,16 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@oxc-project/types": {
+ "version": "0.127.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
+ "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
"node_modules/@raycast/api": {
"version": "1.104.15",
"resolved": "https://registry.npmjs.org/@raycast/api/-/api-1.104.15.tgz",
@@ -1125,6 +1195,324 @@
}
}
},
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
+ "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
+ "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
+ "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1138,8 +1526,7 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
@@ -1410,18 +1797,131 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "node_modules/@vitest/expect": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
+ "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
- "peer": true,
- "bin": {
- "acorn": "bin/acorn"
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
},
- "engines": {
- "node": ">=0.4.0"
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
+ "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
+ "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
+ "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.5",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
+ "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
+ "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
+ "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
}
},
"node_modules/acorn-jsx": {
@@ -1501,6 +2001,16 @@
"node": ">=14"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -1528,6 +2038,16 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chardet": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz",
@@ -1588,6 +2108,13 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1644,6 +2171,16 @@
"node": ">=6"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -1665,6 +2202,13 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -1910,6 +2454,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -1921,6 +2475,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2087,19 +2651,6 @@
"node": ">=8.0.0"
}
},
- "node_modules/get-tsconfig": {
- "version": "4.14.0",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
- "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "resolve-pkg-maps": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
- }
- },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2319,6 +2870,279 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2348,6 +3172,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2378,6 +3212,25 @@
"node": "^18.17.0 || >=20.5.0"
}
},
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -2385,6 +3238,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2468,6 +3332,13 @@
"node": ">=8"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2486,6 +3357,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -2534,14 +3434,38 @@
"node": ">=0.10.0"
}
},
- "node_modules/resolve-pkg-maps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
+ "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"dev": true,
"license": "MIT",
- "funding": {
- "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ "dependencies": {
+ "@oxc-project/types": "=0.127.0",
+ "@rolldown/pluginutils": "1.0.0-rc.17"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/safer-buffer": {
@@ -2587,6 +3511,13 @@
"node": ">=8"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -2599,6 +3530,30 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -2640,6 +3595,23 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+ "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -2656,6 +3628,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -2669,25 +3651,13 @@
"typescript": ">=4.8.4"
}
},
- "node_modules/tsx": {
- "version": "4.21.0",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
- "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "~0.27.0",
- "get-tsconfig": "^4.7.5"
- },
- "bin": {
- "tsx": "dist/cli.mjs"
- },
- "engines": {
- "node": ">=18.0.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- }
+ "license": "0BSD",
+ "optional": true
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -2770,6 +3740,174 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/vite": {
+ "version": "8.0.10",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
+ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.10",
+ "rolldown": "1.0.0-rc.17",
+ "tinyglobby": "^0.2.16"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
+ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.5",
+ "@vitest/mocker": "4.1.5",
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/runner": "4.1.5",
+ "@vitest/snapshot": "4.1.5",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.5",
+ "@vitest/browser-preview": "4.1.5",
+ "@vitest/browser-webdriverio": "4.1.5",
+ "@vitest/coverage-istanbul": "4.1.5",
+ "@vitest/coverage-v8": "4.1.5",
+ "@vitest/ui": "4.1.5",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -2787,6 +3925,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/widest-line": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
diff --git a/integrations/raycast/package.json b/integrations/raycast/package.json
index ea64310..fa9479b 100644
--- a/integrations/raycast/package.json
+++ b/integrations/raycast/package.json
@@ -75,6 +75,13 @@
"mode": "view",
"keywords": ["providers", "doctor", "security", "privacy"]
},
+ {
+ "name": "actions",
+ "title": "Nightward Actions",
+ "description": "Preview and apply confirmed Nightward provider, schedule, backup, and setup actions.",
+ "mode": "view",
+ "keywords": ["actions", "apply", "backup", "schedule", "providers", "fix"]
+ },
{
"name": "explain-finding",
"title": "Explain Nightward Finding",
@@ -129,8 +136,8 @@
],
"scripts": {
"dev": "ray develop",
- "test": "node --import tsx --test test/*.test.ts",
- "test:junit": "mkdir -p ../../reports/junit && node --import tsx --test --test-reporter=spec --test-reporter=junit --test-reporter-destination=stdout --test-reporter-destination=../../reports/junit/raycast.raw.xml test/*.test.ts && node scripts/normalize-junit.mjs ../../reports/junit/raycast.raw.xml ../../reports/junit/raycast.xml",
+ "test": "vitest run",
+ "test:junit": "mkdir -p ../../reports/junit && vitest run --config vitest.junit.config.mjs",
"build": "ray build -e dist",
"lint": "ray lint",
"fix-lint": "ray lint --fix",
@@ -145,7 +152,7 @@
"devDependencies": {
"@types/node": "22.19.17",
"@raycast/eslint-config": "2.1.1",
- "tsx": "4.21.0",
- "typescript": "6.0.3"
+ "typescript": "6.0.3",
+ "vitest": "^4.0.8"
}
}
diff --git a/integrations/raycast/raycast-env.d.ts b/integrations/raycast/raycast-env.d.ts
index 385eda1..8ad4782 100644
--- a/integrations/raycast/raycast-env.d.ts
+++ b/integrations/raycast/raycast-env.d.ts
@@ -30,6 +30,8 @@ declare namespace Preferences {
export type Analysis = ExtensionPreferences & {}
/** Preferences accessible in the `provider-doctor` command */
export type ProviderDoctor = ExtensionPreferences & {}
+ /** Preferences accessible in the `actions` command */
+ export type Actions = ExtensionPreferences & {}
/** Preferences accessible in the `explain-finding` command */
export type ExplainFinding = ExtensionPreferences & {}
/** Preferences accessible in the `explain-signal` command */
@@ -53,6 +55,8 @@ declare namespace Arguments {
export type Analysis = {}
/** Arguments passed to the `provider-doctor` command */
export type ProviderDoctor = {}
+ /** Arguments passed to the `actions` command */
+ export type Actions = {}
/** Arguments passed to the `explain-finding` command */
export type ExplainFinding = {
/** Finding ID */
diff --git a/integrations/raycast/scripts/normalize-junit.mjs b/integrations/raycast/scripts/normalize-junit.mjs
deleted file mode 100644
index cf36898..0000000
--- a/integrations/raycast/scripts/normalize-junit.mjs
+++ /dev/null
@@ -1,42 +0,0 @@
-import { readFileSync, writeFileSync } from "node:fs";
-
-const [, , inputPath, outputPath] = process.argv;
-
-if (!inputPath || !outputPath) {
- console.error("usage: normalize-junit.mjs