diff --git a/.nightward.example.yml b/.nightward.example.yml index d95a366..e9deeef 100644 --- a/.nightward.example.yml +++ b/.nightward.example.yml @@ -15,4 +15,4 @@ sarif: tool_name: Nightward category: nightward information_uri: https://github.com/JSONbored/nightward - semantic_version: 0.1.4 + semantic_version: 0.1.11 diff --git a/README.md b/README.md index e0919f4..b094e9b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Nightward is read-only by default, but it can run explicit, confirmation-gated l [![Scrubbed Nightward OpenTUI walkthrough showing overview, findings, analysis, fix plan, inventory, backup, and help screens](site/public/demo/nightward-opentui.gif)](site/guide/tui.md) -The README uses a GIF so the preview renders directly on GitHub. The docs homepage uses the lighter [WebM loop](site/public/demo/tui/nightward-opentui.webm), and the [TUI guide](site/guide/tui.md) keeps the full seven-screen gallery. +The README uses a GIF so the preview renders directly on GitHub. The docs homepage uses the lighter [WebM loop](site/public/demo/tui/nightward-opentui.webm), and the [TUI guide](site/guide/tui.md) keeps the full section gallery. ## At A Glance @@ -29,7 +29,7 @@ The README uses a GIF so the preview renders directly on GitHub. The docs homepa | --- | --- | --- | | 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 | Read-only; can list/preview actions, but writes must be applied in CLI/TUI/Raycast | +| MCP server | Stdio tools/resources/prompts for AI clients | Can request local action approvals; applies only the locally approved action once | | 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 | @@ -296,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 call online services unless a user explicitly selects providers and opts into network-capable behavior. The CLI/TUI/Raycast action layer can install known provider CLIs after confirmation. MCP can list and preview those actions, but cannot apply local writes. 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. +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 action layer can install known provider CLIs after confirmation. MCP can list and preview those actions, request a local approval ticket, and apply only the exact ticket after it is approved outside the MCP request. 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. @@ -326,6 +326,7 @@ The default `nightward` / `nw` command opens the TUI: - 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 +- MCP Approvals: approve or deny exact MCP-requested action tickets 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`. @@ -333,7 +334,7 @@ Keyboard shortcuts: - `1`-`8`: switch sections - arrow keys or `h`/`j`/`k`/`l`: navigate -- `enter`: confirm selected action in the Actions view +- `enter`: confirm selected action in the Actions view or review a pending MCP approval - `/`: search findings - `s`: cycle severity - `x`: clear filters @@ -356,14 +357,14 @@ Nightward can expose local context and bounded Nightward action workflows to MCP } ``` -The server supports scan, doctor, findings, finding/signal explanation, analysis, fix-plan, policy-check, report history/diff, action list/preview, rules, providers, resources, and prompts. It uses stdio only, does not open a network listener, and cannot rewrite arbitrary MCP or agent config. MCP clients cannot apply local writes because tool-call arguments are not an out-of-band local confirmation channel; use the CLI, TUI, or Raycast extension to apply previewed actions. +The server supports scan, doctor, findings, finding/signal explanation, analysis, fix-plan, policy-check, report history/diff, action list/preview/request/status/apply-approved, rules, providers, resources, and prompts. It uses stdio only, does not open a network listener, and cannot rewrite arbitrary MCP or agent config. MCP clients cannot self-confirm writes because tool-call arguments are not an out-of-band local confirmation channel; they can request a bounded action approval, then apply only the exact one-time ticket after the user approves it in the CLI, TUI, or Raycast extension. Cached `nightward_action_apply` calls remain blocked. ## GitHub Action Nightward can run as a local GitHub Action in scan, policy, or SARIF mode: ```yaml -- uses: JSONbored/nightward@v0.1.4 +- uses: JSONbored/nightward@v0.1.11 with: mode: sarif output: nightward.sarif @@ -388,7 +389,7 @@ See [docs/website.md](docs/website.md) for the page map, custom-domain notes, an Nightward includes an in-repo `plugin.yaml` for Trunk Check. Import a pinned release tag and enable repo/workspace policy scans: ```sh -trunk plugins add --id nightward https://github.com/JSONbored/nightward v0.1.4 +trunk plugins add --id nightward https://github.com/JSONbored/nightward v0.1.11 trunk check enable nightward-policy ``` @@ -419,7 +420,7 @@ Commands: - `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 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. +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. `Nightward MCP Approvals` lets the user approve or deny exact MCP-requested action tickets. See [docs/raycast-extension.md](docs/raycast-extension.md) for preferences, validation, and read-only boundaries. diff --git a/crates/nightward-cli/src/cli.rs b/crates/nightward-cli/src/cli.rs index 3567487..9298a6f 100644 --- a/crates/nightward-cli/src/cli.rs +++ b/crates/nightward-cli/src/cli.rs @@ -7,8 +7,8 @@ use nightward_core::inventory::{ }; use nightward_core::policy::{self, PolicyConfig}; use nightward_core::{ - actions, backupplan, mcpserver, providers, reportdiff, reporthtml, rules, schedule, snapshot, - state, + actions, approvals, backupplan, mcpserver, providers, reportdiff, reporthtml, rules, schedule, + snapshot, state, }; use serde::Serialize; use std::env; @@ -42,6 +42,7 @@ pub fn run() -> Result<()> { "backup" => cmd_backup(&args), "schedule" => cmd_schedule(&args), "actions" => cmd_actions(&args), + "approvals" => cmd_approvals(&args), "disclosure" => cmd_disclosure(&args), "help" | "--help" | "-h" => { print_help(); @@ -547,6 +548,52 @@ fn cmd_actions(args: &[String]) -> Result<()> { } } +fn cmd_approvals(args: &[String]) -> Result<()> { + let home = home_dir_from_env(); + match args.first().map(String::as_str) { + Some("list") | None => print_json(&approvals::list(&home)?), + Some("show") | Some("status") => { + let id = args.get(1).ok_or_else(|| anyhow!("approval id required"))?; + print_json(&approvals::status(&home, id)?) + } + Some("request") => { + let action_id = args.get(1).ok_or_else(|| anyhow!("action id required"))?; + print_json(&approvals::request( + &home, + approvals::ApprovalRequestOptions { + action_id: action_id.to_string(), + action_options: approval_options_from_args(action_id, args), + requested_by: value_after(args, "--client") + .unwrap_or("nightward-cli") + .to_string(), + }, + )?) + } + Some("approve") => { + let id = args.get(1).ok_or_else(|| anyhow!("approval id required"))?; + print_json(&approvals::approve( + &home, + id, + value_after(args, "--reason").unwrap_or("approved locally"), + )?) + } + Some("deny") => { + let id = args.get(1).ok_or_else(|| anyhow!("approval id required"))?; + print_json(&approvals::deny( + &home, + id, + value_after(args, "--reason").unwrap_or("denied locally"), + )?) + } + Some("apply") => { + let id = args.get(1).ok_or_else(|| anyhow!("approval id required"))?; + print_json(&approvals::apply_approved(&home, id)?) + } + Some("cleanup") => print_json(&approvals::cleanup(&home)?), + _ => Err(anyhow!("unknown approvals 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())), @@ -563,6 +610,26 @@ fn cmd_disclosure(args: &[String]) -> Result<()> { } } +fn approval_options_from_args( + action_id: &str, + args: &[String], +) -> approvals::ApprovalActionOptions { + approvals::ApprovalActionOptions { + executable: if action_id == "schedule.install" { + current_executable() + } else { + String::new() + }, + 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(), + } +} + fn selector(args: &[String]) -> Selector { Selector { all: has(args, "--all") || (!has(args, "--finding") && !has(args, "--rule")), @@ -676,6 +743,9 @@ fn option_takes_value(option: &str) -> bool { | "--to" | "--finding" | "--rule" + | "--reason" + | "--policy" + | "--client" | "--format" | "--input" ) @@ -697,7 +767,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 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." + "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 approvals list --json\n nightward approvals approve --reason \"reviewed\"\n nightward approvals apply \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. Approval commands do not take --confirm: approve is the local confirmation step, and apply only consumes an already-approved one-time ticket." ); } diff --git a/crates/nightward-cli/src/tui.rs b/crates/nightward-cli/src/tui.rs index 5126520..1acbbc1 100644 --- a/crates/nightward-cli/src/tui.rs +++ b/crates/nightward-cli/src/tui.rs @@ -3,7 +3,7 @@ use nightward_core::analysis::{self, Options as AnalysisOptions}; use nightward_core::fixplan::{self, Selector}; use nightward_core::reportdiff::DiffReport; use nightward_core::{ - actions, backupplan, max_risk, state, Classification, Finding, Report, RiskLevel, + actions, approvals, backupplan, max_risk, state, Classification, Finding, Report, RiskLevel, }; use opentui::buffer::{BoxOptions, BoxStyle, ClipRect, TitleAlign}; use opentui::input::{Event, InputParser, KeyCode}; @@ -14,7 +14,7 @@ use std::io::{self, Read}; use std::sync::mpsc; use std::time::Duration; -const VIEWS: [&str; 8] = [ +const VIEWS: [&str; 9] = [ "Overview", "Findings", "Analysis", @@ -22,6 +22,7 @@ const VIEWS: [&str; 8] = [ "Inventory", "Backup", "Actions", + "MCP Approvals", "Help", ]; const REPO_LABEL: &str = "github.com/JSONbored/nightward"; @@ -208,7 +209,9 @@ struct TuiState<'a> { active_view: usize, selected_finding: usize, selected_action: usize, + selected_approval: usize, pending_action: Option, + pending_approval: Option, action_message: String, disclosure_pending: bool, severity_filter: Option, @@ -507,7 +510,9 @@ impl<'a> TuiState<'a> { active_view: 0, selected_finding: 0, selected_action: 0, + selected_approval: 0, pending_action: None, + pending_approval: None, action_message: String::new(), disclosure_pending: disclosure_pending_for(report), severity_filter: None, @@ -554,6 +559,24 @@ impl<'a> TuiState<'a> { } return true; } + if let Some(approval_id) = self.pending_approval.clone() { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + self.approve_mcp_action(approval_id); + self.pending_approval = None; + } + KeyCode::Char('n') => { + self.deny_mcp_action(approval_id); + self.pending_approval = None; + } + KeyCode::Esc => { + self.pending_approval = None; + self.action_message = "approval review canceled".to_string(); + } + _ => {} + } + return true; + } if self.search_mode { match key.code { KeyCode::Esc | KeyCode::Enter => self.search_mode = false, @@ -575,12 +598,16 @@ 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'..='8') => { - self.active_view = usize::from(ch as u8 - b'1'); + KeyCode::Char(ch @ '1'..='9') => { + let view = usize::from(ch as u8 - b'1'); + if view < VIEWS.len() { + self.active_view = view; + } } 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::Enter if self.active_view == 7 => self.confirm_selected_approval(), KeyCode::Char('/') => self.search_mode = true, KeyCode::Char('s') => self.cycle_severity_filter(), KeyCode::Char('x') => self.clear_filters(), @@ -605,6 +632,13 @@ impl<'a> TuiState<'a> { } else { self.selected_action = (self.selected_action + 1) % count; } + } else if self.active_view == 7 { + let count = self.approvals().len(); + if count == 0 { + self.selected_approval = 0; + } else { + self.selected_approval = (self.selected_approval + 1) % count; + } } else { self.select_next_finding(); } @@ -618,6 +652,13 @@ impl<'a> TuiState<'a> { } else { self.selected_action = self.selected_action.checked_sub(1).unwrap_or(count - 1); } + } else if self.active_view == 7 { + let count = self.approvals().len(); + if count == 0 { + self.selected_approval = 0; + } else { + self.selected_approval = self.selected_approval.checked_sub(1).unwrap_or(count - 1); + } } else { self.select_previous_finding(); } @@ -631,6 +672,16 @@ impl<'a> TuiState<'a> { self.actions().into_iter().nth(self.selected_action) } + fn approvals(&self) -> Vec { + approvals::list(&self.report.home) + .map(|list| list.approvals) + .unwrap_or_default() + } + + fn selected_approval(&self) -> Option { + self.approvals().into_iter().nth(self.selected_approval) + } + fn confirm_selected_action(&mut self) { let Some(action) = self.selected_action() else { self.action_message = "no action selected".to_string(); @@ -644,6 +695,27 @@ impl<'a> TuiState<'a> { self.action_message = format!("press y to apply {} or n to cancel", action.id); } + fn confirm_selected_approval(&mut self) { + let Some(approval) = self.selected_approval() else { + self.action_message = "no MCP approval selected".to_string(); + return; + }; + match approval.status.as_str() { + "pending" => { + self.pending_approval = Some(approval.approval_id.clone()); + self.action_message = + format!("press y to approve {} or n to deny", approval.approval_id); + } + "approved" => { + self.action_message = + "approval already granted; MCP can apply the exact ticket once".to_string(); + } + other => { + self.action_message = format!("approval is {other}"); + } + } + } + fn apply_action(&mut self, action_id: String) { match actions::apply( &self.report.home, @@ -669,6 +741,34 @@ impl<'a> TuiState<'a> { } } + fn approve_mcp_action(&mut self, approval_id: String) { + match approvals::approve(&self.report.home, &approval_id, "approved in Nightward TUI") { + Ok(_) => { + self.action_message = format!("{approval_id} approved for one MCP apply"); + self.selected_approval = self + .selected_approval + .min(self.approvals().len().saturating_sub(1)); + } + Err(err) => { + self.action_message = format!("approval failed: {err}"); + } + } + } + + fn deny_mcp_action(&mut self, approval_id: String) { + match approvals::deny(&self.report.home, &approval_id, "denied in Nightward TUI") { + Ok(_) => { + self.action_message = format!("{approval_id} denied"); + self.selected_approval = self + .selected_approval + .min(self.approvals().len().saturating_sub(1)); + } + Err(err) => { + self.action_message = format!("deny failed: {err}"); + } + } + } + fn select_next_finding(&mut self) { let count = self.display_findings().len(); if count == 0 { @@ -961,7 +1061,7 @@ impl<'a> TuiState<'a> { let compact = limit < 48; let max_x = x + limit; let hints = [ - ("tab/1-8", "navigate"), + ("tab/1-9", "navigate"), ("/", "search"), ("s", "severity"), ("x", "clear"), @@ -1133,7 +1233,7 @@ impl<'a> TuiState<'a> { buffer, x, posture_y + 6, - "tab/1-8 navigate", + "tab/1-9 navigate", Style::fg(self.palette.muted), ); draw_text( @@ -1173,6 +1273,7 @@ impl<'a> TuiState<'a> { 4 => self.render_inventory(buffer, area), 5 => self.render_backup(buffer, area), 6 => self.render_actions(buffer, area), + 7 => self.render_mcp_approvals(buffer, area), _ => self.render_help(buffer, area), } } @@ -2189,6 +2290,161 @@ impl<'a> TuiState<'a> { } } + fn render_mcp_approvals(&self, buffer: &mut OptimizedBuffer, area: Area) { + let approvals = self.approvals(); + let left_w = responsive_width(area.w, 50, 34, 42); + 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), + "MCP Approval Queue", + self.palette.amber, + self.palette.panel, + ); + let mut row = area.y + 2; + for (idx, approval) in approvals + .iter() + .enumerate() + .take(area.h.saturating_sub(4) as usize / 2) + { + let selected = idx == self.selected_approval; + let color = approval_color(&self.palette, &approval.status); + 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(&approval.action_id, 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(12), + row, + &truncate(&approval.status, 10), + Style::fg(color), + ); + row += 1; + draw_text( + buffer, + area.x + 2, + row, + &truncate(&approval.approval_id, 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), + "Approval Detail", + self.palette.cyan, + self.palette.surface, + ); + let mut row = area.y + 2; + if let Some(approval) = self.selected_approval() { + draw_text( + buffer, + right_x + 2, + row, + &truncate( + &approval.preview.action.title, + right_w.saturating_sub(4) as usize, + ), + Style::fg(approval_color(&self.palette, &approval.status)).with_bold(), + ); + row += 2; + let expires = approval.expires_at.to_rfc3339(); + for (label, value) in [ + ("status", approval.status.as_str()), + ("requested by", approval.requested_by.as_str()), + ("approval", approval.approval_id.as_str()), + ("expires", expires.as_str()), + ] { + section_label(buffer, right_x + 2, row, label, self.palette.cyan); + draw_text( + buffer, + right_x + 18, + row, + &truncate(value, right_w.saturating_sub(20) as usize), + Style::fg(self.palette.white), + ); + row += 2; + } + section_label(buffer, right_x + 2, row, "writes", self.palette.cyan); + row += 2; + for write in approval.preview.action.writes.iter().take(4) { + 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 !approval.preview.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( + &approval.preview.action.command.join(" "), + right_w.saturating_sub(4) as usize, + ), + Style::fg(self.palette.white), + ); + } + } + if let Some(approval_id) = &self.pending_approval { + draw_text( + buffer, + right_x + 2, + area.y + area.h.saturating_sub(3), + &truncate( + &format!("Approve {approval_id}: y approve / n deny"), + 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), + "Press Enter to review selected MCP approval", + 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; @@ -2204,7 +2460,7 @@ impl<'a> TuiState<'a> { let mut row = area.y + 2; for (key, label) in [ ("tab / shift-tab", "switch views"), - ("1-8", "open view"), + ("1-9", "open view"), ("j / down", "next finding"), ("k / up", "previous finding"), ("enter", "confirm action"), @@ -2446,6 +2702,17 @@ fn action_color(palette: &Palette, action: &actions::ActionSpec) -> Rgba { } } +fn approval_color(palette: &Palette, status: &str) -> Rgba { + match status { + "pending" => palette.amber, + "approved" => palette.green, + "denied" | "failed" | "invalidated" => palette.red, + "applied" => palette.blue, + "expired" => palette.muted, + _ => palette.white, + } +} + fn severity_color(palette: &Palette, risk: RiskLevel) -> Rgba { match risk { RiskLevel::Critical => palette.red, @@ -2874,7 +3141,7 @@ mod tests { let app = TuiState::new(&report); let text = render_text(&app, 120, 36); - assert!(text.contains("tab/1-8")); + assert!(text.contains("tab/1-9")); assert!(text.contains(&format!("v{}", version()))); assert!(text.contains(REPO_LABEL)); assert!(text.contains(STAR_CTA)); @@ -3017,7 +3284,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 = 7; + app.active_view = 8; let mut buffer = OptimizedBuffer::new(120, 36); app.render(&mut buffer, 120, 36); @@ -3030,7 +3297,7 @@ mod tests { fn full_help_keyboard_text_uses_short_labels() { let report = fixture_report(); let mut app = TuiState::new(&report); - app.active_view = 7; + app.active_view = 8; 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 index ad958ca..f0c1c80 100644 --- a/crates/nightward-core/src/actions.rs +++ b/crates/nightward-core/src/actions.rs @@ -23,7 +23,7 @@ pub struct ActionSpec { pub blocked_reason: String, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionPreview { pub schema_version: u32, pub action: ActionSpec, @@ -31,7 +31,7 @@ pub struct ActionPreview { pub warnings: Vec, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionResult { pub schema_version: u32, pub action_id: String, @@ -42,7 +42,7 @@ pub struct ActionResult { pub audit_path: String, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ApplyOptions { pub confirm: bool, pub executable: String, diff --git a/crates/nightward-core/src/approvals.rs b/crates/nightward-core/src/approvals.rs new file mode 100644 index 0000000..518aefa --- /dev/null +++ b/crates/nightward-core/src/approvals.rs @@ -0,0 +1,748 @@ +use crate::actions::{self, ActionPreview, ActionResult, ApplyOptions}; +use crate::{inventory::redact_text, state}; +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; + +const APPROVAL_SCHEMA_VERSION: u32 = 1; +const DEFAULT_TTL_SECONDS: i64 = 15 * 60; +const MAX_APPROVAL_FILES: usize = 64; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ApprovalActionOptions { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub executable: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub policy_path: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub finding_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub rule: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, +} + +impl ApprovalActionOptions { + pub fn from_apply_options(options: ApplyOptions) -> Self { + Self { + executable: options.executable, + policy_path: options.policy_path, + finding_id: options.finding_id, + rule: options.rule, + reason: options.reason, + } + } + + fn into_apply_options(self) -> ApplyOptions { + ApplyOptions { + confirm: true, + executable: self.executable, + policy_path: self.policy_path, + finding_id: self.finding_id, + rule: self.rule, + reason: self.reason, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalRequestOptions { + pub action_id: String, + #[serde(default)] + pub action_options: ApprovalActionOptions, + #[serde(default = "default_requested_by")] + pub requested_by: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionApproval { + pub schema_version: u32, + pub approval_id: String, + pub status: String, + pub action_id: String, + #[serde(default)] + pub action_options: ApprovalActionOptions, + pub preview_digest: String, + pub preview: ActionPreview, + pub requested_by: String, + pub requested_at: DateTime, + pub expires_at: DateTime, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub decision_reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decided_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied_at: Option>, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub result_message: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub action_audit_path: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ApprovalList { + pub schema_version: u32, + pub approvals: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ApprovedActionResult { + pub schema_version: u32, + pub approval: ActionApproval, + pub action_result: ActionResult, +} + +#[derive(Debug, Serialize)] +struct ApprovalAuditEvent { + schema_version: u32, + generated_at: DateTime, + event: String, + approval_id: String, + action_id: String, + status: String, + message: String, +} + +#[derive(Serialize)] +struct DigestMaterial<'a> { + schema_version: u32, + action_id: &'a str, + action_options: &'a ApprovalActionOptions, + preview: &'a ActionPreview, +} + +pub fn request(home: impl AsRef, options: ApprovalRequestOptions) -> Result { + request_with_ttl(home, options, DEFAULT_TTL_SECONDS) +} + +fn request_with_ttl( + home: impl AsRef, + options: ApprovalRequestOptions, + ttl_seconds: i64, +) -> Result { + let home = home.as_ref(); + cleanup_terminal(home)?; + enforce_queue_limit(home)?; + let action_id = options.action_id.trim(); + if action_id.is_empty() { + return Err(anyhow!("action_id is required")); + } + if action_id == "disclosure.accept" { + return Err(anyhow!( + "MCP approvals cannot accept the Nightward beta responsibility disclosure; accept it in the Nightward CLI, TUI, or Raycast extension" + )); + } + if !state::disclosure_status(home).accepted { + return Err(anyhow!( + "accept the Nightward beta responsibility disclosure in the Nightward CLI, TUI, or Raycast extension before requesting write-capable MCP actions" + )); + } + validate_options_for_action(action_id, &options.action_options)?; + let preview = actions::preview(home, action_id)?; + if !preview.action.available { + return Err(anyhow!("{}", preview.action.blocked_reason)); + } + let digest = preview_digest(action_id, &options.action_options, &preview)?; + let now = Utc::now(); + let record = ActionApproval { + schema_version: APPROVAL_SCHEMA_VERSION, + approval_id: approval_id(action_id, &digest, now), + status: "pending".to_string(), + action_id: action_id.to_string(), + action_options: options.action_options, + preview_digest: digest, + preview, + requested_by: safe_requested_by(&options.requested_by), + requested_at: now, + expires_at: now + Duration::seconds(ttl_seconds.max(1)), + decision_reason: String::new(), + decided_at: None, + applied_at: None, + result_message: String::new(), + action_audit_path: String::new(), + }; + save_record(home, &record)?; + append_approval_audit(home, &record, "requested", "approval requested")?; + Ok(record) +} + +pub fn list(home: impl AsRef) -> Result { + let home = home.as_ref(); + cleanup_terminal(home)?; + let mut approvals = load_records(home)?; + approvals.sort_by(|left, right| right.requested_at.cmp(&left.requested_at)); + Ok(ApprovalList { + schema_version: APPROVAL_SCHEMA_VERSION, + approvals: approvals.into_iter().map(mark_expired).collect(), + }) +} + +pub fn status(home: impl AsRef, approval_id: &str) -> Result { + let home = home.as_ref(); + let mut record = load_record(home, approval_id)?; + if is_expired(&record) { + record.status = "expired".to_string(); + save_record(home, &record)?; + append_approval_audit(home, &record, "expired", "approval expired")?; + } + Ok(record) +} + +pub fn approve( + home: impl AsRef, + approval_id: &str, + decision_reason: impl Into, +) -> Result { + let home = home.as_ref(); + let mut record = load_record(home, approval_id)?; + ensure_pending(&record)?; + record.status = "approved".to_string(); + record.decided_at = Some(Utc::now()); + record.decision_reason = redact_text(&decision_reason.into()); + save_record(home, &record)?; + append_approval_audit(home, &record, "approved", "approval granted locally")?; + Ok(record) +} + +pub fn deny( + home: impl AsRef, + approval_id: &str, + decision_reason: impl Into, +) -> Result { + let home = home.as_ref(); + let mut record = load_record(home, approval_id)?; + ensure_pending(&record)?; + record.status = "denied".to_string(); + record.decided_at = Some(Utc::now()); + record.decision_reason = redact_text(&decision_reason.into()); + save_record(home, &record)?; + append_approval_audit(home, &record, "denied", "approval denied locally")?; + Ok(record) +} + +pub fn apply_approved(home: impl AsRef, approval_id: &str) -> Result { + let home = home.as_ref(); + let mut record = load_record(home, approval_id)?; + if is_expired(&record) { + record.status = "expired".to_string(); + save_record(home, &record)?; + append_approval_audit(home, &record, "expired", "approval expired before apply")?; + return Err(anyhow!("approval {approval_id} expired")); + } + if record.status != "approved" { + return Err(anyhow!( + "approval {approval_id} is {}, not approved", + record.status + )); + } + + let current_preview = actions::preview(home, &record.action_id)?; + if !current_preview.action.available { + record.status = "invalidated".to_string(); + save_record(home, &record)?; + append_approval_audit( + home, + &record, + "invalidated", + "approved action is no longer available", + )?; + return Err(anyhow!("approved action is no longer available")); + } + let current_digest = + preview_digest(&record.action_id, &record.action_options, ¤t_preview)?; + if current_digest != record.preview_digest { + record.status = "invalidated".to_string(); + save_record(home, &record)?; + append_approval_audit( + home, + &record, + "invalidated", + "approved action no longer matches current preview", + )?; + return Err(anyhow!("approved action no longer matches current preview")); + } + + match actions::apply( + home, + &record.action_id, + record.action_options.clone().into_apply_options(), + ) { + Ok(result) => { + record.status = "applied".to_string(); + record.applied_at = Some(Utc::now()); + record.result_message = redact_text(&result.message); + record.action_audit_path = result.audit_path.clone(); + save_record(home, &record)?; + append_approval_audit(home, &record, "applied", "approved action applied")?; + Ok(ApprovedActionResult { + schema_version: APPROVAL_SCHEMA_VERSION, + approval: record, + action_result: result, + }) + } + Err(error) => { + record.status = "failed".to_string(); + record.applied_at = Some(Utc::now()); + record.result_message = redact_text(&error.to_string()); + save_record(home, &record)?; + append_approval_audit(home, &record, "failed", "approved action failed")?; + Err(error) + } + } +} + +pub fn cleanup(home: impl AsRef) -> Result { + cleanup_terminal(home.as_ref())?; + list(home) +} + +fn approval_dir(home: &Path) -> PathBuf { + state::state_dir(home).join("action-approvals") +} + +fn approval_path(home: &Path, approval_id: &str) -> Result { + if approval_id.is_empty() + || approval_id.len() > 96 + || !approval_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return Err(anyhow!("invalid approval_id")); + } + Ok(approval_dir(home).join(format!("{approval_id}.json"))) +} + +fn load_records(home: &Path) -> Result> { + let dir = approval_dir(home); + match fs::symlink_metadata(&dir) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(anyhow!( + "refusing to read symlinked approval directory {}", + dir.display() + )); + } + if !metadata.is_dir() { + return Err(anyhow!( + "approval path is not a directory: {}", + dir.display() + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error).with_context(|| format!("inspect {}", dir.display())), + } + match fs::read_dir(&dir) { + Ok(entries) => entries + .map(|entry| -> Result> { + let entry = entry.with_context(|| format!("read {}", dir.display()))?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("inspect {}", path.display()))?; + if metadata.file_type().is_symlink() { + return Err(anyhow!( + "refusing to read symlinked approval file {}", + path.display() + )); + } + if !metadata.is_file() + || path.extension().and_then(|ext| ext.to_str()) != Some("json") + { + return Ok(None); + } + Ok(Some(load_record_path(&path)?)) + }) + .filter_map(|result| result.transpose()) + .collect(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(error).with_context(|| format!("read {}", dir.display())), + } +} + +fn load_record(home: &Path, approval_id: &str) -> Result { + load_record_path(&approval_path(home, approval_id)?) +} + +fn load_record_path(path: &Path) -> Result { + let metadata = + fs::symlink_metadata(path).with_context(|| format!("inspect {}", path.display()))?; + if metadata.file_type().is_symlink() { + return Err(anyhow!( + "refusing to read symlinked approval file {}", + path.display() + )); + } + if !metadata.is_file() { + return Err(anyhow!("approval path is not a regular file")); + } + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +fn save_record(home: &Path, record: &ActionApproval) -> Result<()> { + let path = approval_path(home, &record.approval_id)?; + state::write_private_file( + &path, + format!("{}\n", serde_json::to_string_pretty(record)?), + ) +} + +fn cleanup_terminal(home: &Path) -> Result<()> { + let now = Utc::now(); + let dir = approval_dir(home); + let records = load_records(home)?; + for mut record in records { + if is_expired(&record) { + record.status = "expired".to_string(); + save_record(home, &record)?; + append_approval_audit(home, &record, "expired", "approval expired")?; + } + let terminal = matches!( + record.status.as_str(), + "denied" | "expired" | "applied" | "failed" | "invalidated" + ); + if terminal && record.expires_at + Duration::hours(24) < now { + let path = approval_path(home, &record.approval_id)?; + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("remove {}", path.display())) + } + } + } + } + state::create_private_dir(&dir)?; + Ok(()) +} + +fn enforce_queue_limit(home: &Path) -> Result<()> { + let active = load_records(home)? + .into_iter() + .filter(|record| { + !is_expired(record) && matches!(record.status.as_str(), "pending" | "approved") + }) + .count(); + if active >= MAX_APPROVAL_FILES { + return Err(anyhow!("too many active Nightward action approvals")); + } + Ok(()) +} + +fn append_approval_audit( + home: &Path, + record: &ActionApproval, + event: &str, + message: &str, +) -> Result { + state::append_audit( + home, + &ApprovalAuditEvent { + schema_version: APPROVAL_SCHEMA_VERSION, + generated_at: Utc::now(), + event: format!("approval.{event}"), + approval_id: record.approval_id.clone(), + action_id: record.action_id.clone(), + status: record.status.clone(), + message: message.to_string(), + }, + ) +} + +fn ensure_pending(record: &ActionApproval) -> Result<()> { + if is_expired(record) { + return Err(anyhow!("approval {} expired", record.approval_id)); + } + if record.status != "pending" { + return Err(anyhow!( + "approval {} is {}, not pending", + record.approval_id, + record.status + )); + } + Ok(()) +} + +fn mark_expired(mut record: ActionApproval) -> ActionApproval { + if is_expired(&record) { + record.status = "expired".to_string(); + } + record +} + +fn is_expired(record: &ActionApproval) -> bool { + matches!(record.status.as_str(), "pending" | "approved") && Utc::now() > record.expires_at +} + +fn preview_digest( + action_id: &str, + action_options: &ApprovalActionOptions, + preview: &ActionPreview, +) -> Result { + let material = DigestMaterial { + schema_version: APPROVAL_SCHEMA_VERSION, + action_id, + action_options, + preview, + }; + let bytes = serde_json::to_vec(&material)?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +fn approval_id(action_id: &str, digest: &str, now: DateTime) -> String { + let mut hasher = Sha256::new(); + hasher.update(action_id.as_bytes()); + hasher.update([0]); + hasher.update(digest.as_bytes()); + hasher.update([0]); + hasher.update(now.timestamp_nanos_opt().unwrap_or_default().to_le_bytes()); + let hex = hex::encode(hasher.finalize()); + format!("appr-{}", &hex[..24]) +} + +fn validate_options_for_action(action_id: &str, options: &ApprovalActionOptions) -> Result<()> { + let policy_action = matches!(action_id, "policy.init" | "policy.ignore"); + let schedule_action = action_id == "schedule.install"; + if !schedule_action && !options.executable.trim().is_empty() { + return Err(anyhow!("executable is only accepted for schedule.install")); + } + if !policy_action && !options.policy_path.trim().is_empty() { + return Err(anyhow!("policy_path is only accepted for policy actions")); + } + if action_id != "policy.ignore" + && (!options.finding_id.trim().is_empty() + || !options.rule.trim().is_empty() + || !options.reason.trim().is_empty()) + { + return Err(anyhow!( + "finding_id, rule, and reason are only accepted for policy.ignore" + )); + } + Ok(()) +} + +fn safe_requested_by(value: &str) -> String { + let value = redact_text(value.trim()); + if value.is_empty() { + default_requested_by() + } else { + value.chars().take(120).collect() + } +} + +fn default_requested_by() -> String { + "mcp-client".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::ApplyOptions; + + #[test] + fn request_requires_out_of_band_disclosure() { + let home = tempfile::tempdir().expect("home"); + let error = request( + home.path(), + ApprovalRequestOptions { + action_id: "backup.snapshot".to_string(), + ..default_request() + }, + ) + .expect_err("disclosure required"); + assert!(error.to_string().contains("accept the Nightward beta")); + } + + #[test] + fn request_rejects_disclosure_self_accept() { + let home = accepted_home(); + let error = request( + home.path(), + ApprovalRequestOptions { + action_id: "disclosure.accept".to_string(), + ..default_request() + }, + ) + .expect_err("self accept blocked"); + assert!(error.to_string().contains("cannot accept")); + } + + #[test] + fn approval_must_be_local_before_apply_and_is_one_time() { + let home = accepted_home(); + let approval = request( + home.path(), + ApprovalRequestOptions { + action_id: "backup.snapshot".to_string(), + ..default_request() + }, + ) + .expect("request"); + let error = + apply_approved(home.path(), &approval.approval_id).expect_err("pending blocked"); + assert!(error.to_string().contains("not approved")); + approve(home.path(), &approval.approval_id, "reviewed locally").expect("approve"); + let result = apply_approved(home.path(), &approval.approval_id).expect("apply"); + assert_eq!(result.approval.status, "applied"); + let replay = + apply_approved(home.path(), &approval.approval_id).expect_err("replay blocked"); + assert!(replay.to_string().contains("not approved")); + } + + #[test] + fn denied_and_expired_approvals_do_not_apply() { + let home = accepted_home(); + let denied = request( + home.path(), + ApprovalRequestOptions { + action_id: "backup.snapshot".to_string(), + ..default_request() + }, + ) + .expect("request"); + deny(home.path(), &denied.approval_id, "no").expect("deny"); + let error = apply_approved(home.path(), &denied.approval_id).expect_err("denied"); + assert!(error.to_string().contains("not approved")); + + let expired = request_with_ttl( + home.path(), + ApprovalRequestOptions { + action_id: "backup.snapshot".to_string(), + ..default_request() + }, + 1, + ) + .expect("request"); + let mut record = load_record(home.path(), &expired.approval_id).expect("load"); + record.expires_at = Utc::now() - Duration::seconds(1); + save_record(home.path(), &record).expect("save"); + approve(home.path(), &expired.approval_id, "late").expect_err("expired"); + } + + #[test] + fn digest_mismatch_invalidates_approval() { + let home = accepted_home(); + let approval = request( + home.path(), + ApprovalRequestOptions { + action_id: "backup.snapshot".to_string(), + ..default_request() + }, + ) + .expect("request"); + approve(home.path(), &approval.approval_id, "reviewed").expect("approve"); + let mut record = load_record(home.path(), &approval.approval_id).expect("load"); + record.preview_digest = "bad-digest".to_string(); + save_record(home.path(), &record).expect("save"); + let error = apply_approved(home.path(), &approval.approval_id).expect_err("mismatch"); + assert!(error.to_string().contains("no longer matches")); + assert_eq!( + load_record(home.path(), &approval.approval_id) + .expect("load") + .status, + "invalidated" + ); + } + + #[test] + fn cleanup_persists_expiry_and_prunes_stale_terminal_records() { + let home = accepted_home(); + let expired = request_with_ttl( + home.path(), + ApprovalRequestOptions { + action_id: "backup.snapshot".to_string(), + ..default_request() + }, + 1, + ) + .expect("request"); + let mut record = load_record(home.path(), &expired.approval_id).expect("load"); + record.expires_at = Utc::now() - Duration::seconds(1); + save_record(home.path(), &record).expect("save"); + + cleanup(home.path()).expect("cleanup"); + assert_eq!( + load_record(home.path(), &expired.approval_id) + .expect("load expired") + .status, + "expired" + ); + + let stale = request( + home.path(), + ApprovalRequestOptions { + action_id: "backup.snapshot".to_string(), + ..default_request() + }, + ) + .expect("request"); + deny(home.path(), &stale.approval_id, "no").expect("deny"); + let mut record = load_record(home.path(), &stale.approval_id).expect("load"); + record.expires_at = Utc::now() - Duration::hours(25); + save_record(home.path(), &record).expect("save"); + + cleanup(home.path()).expect("cleanup"); + load_record(home.path(), &stale.approval_id).expect_err("stale terminal record pruned"); + } + + #[cfg(unix)] + #[test] + fn approval_storage_rejects_symlinked_files() { + use std::os::unix::fs::symlink; + + let home = accepted_home(); + let approval = request( + home.path(), + ApprovalRequestOptions { + action_id: "backup.snapshot".to_string(), + ..default_request() + }, + ) + .expect("request"); + let outside = tempfile::NamedTempFile::new().expect("outside"); + let path = approval_path(home.path(), &approval.approval_id).expect("path"); + fs::remove_file(&path).expect("remove"); + symlink(outside.path(), &path).expect("symlink"); + let error = status(home.path(), &approval.approval_id).expect_err("symlink rejected"); + assert!(error.to_string().contains("symlinked approval")); + } + + #[cfg(unix)] + #[test] + fn approval_storage_rejects_symlinked_directory() { + use std::os::unix::fs::symlink; + + let home = accepted_home(); + let outside = tempfile::tempdir().expect("outside"); + let dir = approval_dir(home.path()); + fs::create_dir_all(dir.parent().unwrap()).expect("parent"); + symlink(outside.path(), &dir).expect("approval dir symlink"); + let error = list(home.path()).expect_err("symlink dir rejected"); + assert!(error.to_string().contains("symlinked approval directory")); + } + + fn accepted_home() -> tempfile::TempDir { + let home = tempfile::tempdir().expect("home"); + crate::actions::apply( + home.path(), + "disclosure.accept", + ApplyOptions { + confirm: true, + ..Default::default() + }, + ) + .expect("accept disclosure"); + home + } + + fn default_request() -> ApprovalRequestOptions { + ApprovalRequestOptions { + action_id: String::new(), + action_options: ApprovalActionOptions::default(), + requested_by: "test-client".to_string(), + } + } +} diff --git a/crates/nightward-core/src/lib.rs b/crates/nightward-core/src/lib.rs index 6a22ebe..7897a8a 100644 --- a/crates/nightward-core/src/lib.rs +++ b/crates/nightward-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod actions; pub mod analysis; +pub mod approvals; pub mod backupplan; pub mod fixplan; pub mod inventory; diff --git a/crates/nightward-core/src/mcpserver.rs b/crates/nightward-core/src/mcpserver.rs index 16cc4d5..153577b 100644 --- a/crates/nightward-core/src/mcpserver.rs +++ b/crates/nightward-core/src/mcpserver.rs @@ -3,7 +3,7 @@ use crate::analysis::{run as analyze, Options as AnalysisOptions}; use crate::fixplan::{plan as fix_plan, Selector}; 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::{providers, reportdiff, rules, schedule, state}; +use crate::{approvals, providers, reportdiff, rules, schedule, state}; use anyhow::{anyhow, Context, Result}; use serde::Serialize; use serde_json::{json, Map, Value}; @@ -97,7 +97,7 @@ fn initialize_result(requested: Option<&str>) -> Value { "version": env!("CARGO_PKG_VERSION"), "description": "Local-first AI agent, MCP, provider, and dotfiles security posture." }, - "instructions": "Nightward returns redacted local security posture. MCP is read-only: it can list and preview bounded actions, but local writes must be applied out-of-band in the Nightward CLI, TUI, or Raycast extension." + "instructions": "Nightward returns redacted local security posture. MCP can request bounded action approvals, but local writes require an out-of-band Nightward approval from the CLI, TUI, or Raycast extension before MCP can apply the exact approved ticket." }) } @@ -187,6 +187,27 @@ fn tools() -> Vec { schema_action_id(), read_only_annotations("Action preview", false), ), + tool( + "nightward_action_request", + "Action Request", + "Request local approval for one exact bounded Nightward action. This only writes Nightward approval state.", + schema_action_request(), + write_annotations("Action request", false, false), + ), + tool( + "nightward_action_status", + "Action Status", + "Read one Nightward action approval request status.", + schema_approval_id(), + read_only_annotations("Action approval status", false), + ), + tool( + "nightward_action_apply_approved", + "Apply Approved Action", + "Apply an already-approved, unexpired, one-time Nightward action ticket.", + schema_approval_id(), + write_annotations("Apply approved action", true, false), + ), tool( "nightward_rules", "Nightward Rules", @@ -241,6 +262,11 @@ fn resources() -> Vec { "Nightward disclosure", "Disclosure acceptance status and responsibility text.", ), + resource( + "nightward://action-approvals", + "Nightward action approvals", + "Pending and recent MCP action approval requests.", + ), resource( "nightward://report-history", "Nightward report history", @@ -342,6 +368,16 @@ fn read_only_annotations(title: &str, open_world: bool) -> Value { }) } +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") @@ -364,6 +400,7 @@ fn read_resource(params: Value, home: &Path) -> Result { }), ), "nightward://disclosure" => json_resource(uri, &state::disclosure_status(home)), + "nightward://action-approvals" => json_resource(uri, &approvals::list(home)?), "nightward://report-history" => json_resource( uri, &json!({ @@ -384,7 +421,7 @@ fn read_prompt(params: Value) -> Result { 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. MCP is read-only, so preview any relevant action and tell me the CLI/TUI/Raycast path for applying it." + "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. Preview any relevant action, request local approval only when a bounded registry action is clearly useful, then apply only the exact approved ticket." } "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." @@ -393,7 +430,7 @@ fn read_prompt(params: Value) -> Result { 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. MCP cannot apply local writes, so tell me how to apply the previewed action in the Nightward CLI, TUI, or Raycast extension.", + "Use nightward_explain_finding and nightward_fix_plan for finding `{}`. If a bounded registry action is relevant, use nightward_action_preview first, then nightward_action_request. Apply only after the user approves the exact ticket locally.", if finding_id.is_empty() { "" } else { @@ -403,7 +440,7 @@ fn read_prompt(params: Value) -> Result { )); } "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, call out online/network behavior, and tell me to apply writes in the Nightward CLI, TUI, or Raycast extension." + "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 nightward_action_request, call out online/network behavior, and apply only an exact locally approved ticket." } "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." @@ -599,6 +636,35 @@ fn call_tool_inner(params: Value, home: &Path) -> Result { } tool_result(sanitized_value(&actions::preview(home, &id)?)?) } + "nightward_action_request" => { + let id = string_arg(&args, "action_id"); + if id.is_empty() { + return Err(anyhow!("action_id is required")); + } + let requested = approvals::request( + home, + approvals::ApprovalRequestOptions { + action_id: id.clone(), + action_options: approval_options_from_mcp(&id, &args), + requested_by: string_arg(&args, "client"), + }, + )?; + tool_result(sanitized_value(&requested)?) + } + "nightward_action_status" => { + let id = string_arg(&args, "approval_id"); + if id.is_empty() { + return Err(anyhow!("approval_id is required")); + } + tool_result(sanitized_value(&approvals::status(home, &id)?)?) + } + "nightward_action_apply_approved" => { + let id = string_arg(&args, "approval_id"); + if id.is_empty() { + return Err(anyhow!("approval_id is required")); + } + tool_result(sanitized_value(&approvals::apply_approved(home, &id)?)?) + } "nightward_rules" => tool_result(json!({ "schema_version": 1, "rules": rules::all_rules() @@ -791,6 +857,17 @@ fn tool_arg_specs(name: &str) -> Result> { ToolArgSpec::optional("head", String), ], "nightward_action_preview" => vec![ToolArgSpec::required("action_id", String)], + "nightward_action_request" => vec![ + ToolArgSpec::required("action_id", String), + ToolArgSpec::optional("client", String), + ToolArgSpec::optional("policy_path", String), + ToolArgSpec::optional("finding_id", String), + ToolArgSpec::optional("rule", String), + ToolArgSpec::optional("reason", String), + ], + "nightward_action_status" | "nightward_action_apply_approved" => { + vec![ToolArgSpec::required("approval_id", String)] + } _ => return Err(anyhow!("unknown tool {name}")), }; Ok(specs) @@ -827,6 +904,23 @@ fn provider_context(home: &Path, args: &Value) -> Value { }) } +fn approval_options_from_mcp(action_id: &str, args: &Value) -> approvals::ApprovalActionOptions { + approvals::ApprovalActionOptions { + executable: if action_id == "schedule.install" { + std::env::current_exe() + .ok() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "nightward".to_string()) + } else { + String::new() + }, + policy_path: string_arg(args, "policy_path"), + finding_id: string_arg(args, "finding_id"), + rule: string_arg(args, "rule"), + reason: string_arg(args, "reason"), + } +} + fn report_changes(home: &Path, args: &Value) -> Result { let base_path = string_arg(args, "base"); let head_path = string_arg(args, "head"); @@ -1262,6 +1356,29 @@ fn schema_action_id() -> Value { ) } +fn schema_action_request() -> Value { + schema_object( + json!({ + "action_id": { "type": "string" }, + "client": { "type": "string", "description": "Optional local client/session label for the approval queue." }, + "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": "Reviewed reason for policy.ignore." } + }), + &["action_id"], + ) +} + +fn schema_approval_id() -> Value { + schema_object( + json!({ + "approval_id": { "type": "string" } + }), + &["approval_id"], + ) +} + #[cfg(test)] mod tests { use super::*; @@ -1316,6 +1433,9 @@ mod tests { "nightward_report_changes", "nightward_actions_list", "nightward_action_preview", + "nightward_action_request", + "nightward_action_status", + "nightward_action_apply_approved", "nightward_rules", "nightward_providers", ] { @@ -1327,12 +1447,25 @@ mod tests { .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())); - assert!(tools + let request_tool = tools .iter() - .all(|tool| tool["annotations"]["readOnlyHint"] == true)); + .find(|tool| tool["name"] == "nightward_action_request") + .unwrap(); + assert_eq!(request_tool["annotations"]["readOnlyHint"], false); + assert_eq!(request_tool["annotations"]["destructiveHint"], false); + let apply_tool = tools + .iter() + .find(|tool| tool["name"] == "nightward_action_apply_approved") + .unwrap(); + assert_eq!(apply_tool["annotations"]["readOnlyHint"], false); + assert_eq!(apply_tool["annotations"]["destructiveHint"], true); assert!(tools .iter() - .all(|tool| tool["annotations"]["destructiveHint"] == false)); + .filter(|tool| !matches!( + tool["name"].as_str(), + Some("nightward_action_request" | "nightward_action_apply_approved") + )) + .all(|tool| tool["annotations"]["readOnlyHint"] == true)); let resources_response = handle_request_with_home( json!({"jsonrpc":"2.0","id":2,"method":"resources/list"}), @@ -1348,6 +1481,7 @@ mod tests { "nightward://providers", "nightward://schedule", "nightward://latest-report", + "nightward://action-approvals", "nightward://report-history", ] { assert!(resource_uris.contains(uri), "missing {uri}"); @@ -1408,6 +1542,96 @@ mod tests { assert!(!state::settings_path(home.path()).exists()); } + #[test] + fn mcp_action_request_requires_local_disclosure_and_cannot_self_confirm() { + let home = tempfile::tempdir().expect("temp home"); + let blocked = handle_request_with_home( + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_action_request","arguments":{"action_id":"backup.snapshot","confirm":true}}}), + home.path(), + ); + assert_eq!(blocked["result"]["isError"], true); + assert!(blocked["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("does not accept argument `confirm`")); + + let no_disclosure = handle_request_with_home( + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_action_request","arguments":{"action_id":"backup.snapshot"}}}), + home.path(), + ); + assert_eq!(no_disclosure["result"]["isError"], true); + assert!(no_disclosure["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("accept the Nightward beta")); + + let self_accept = handle_request_with_home( + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_action_request","arguments":{"action_id":"disclosure.accept"}}}), + home.path(), + ); + assert_eq!(self_accept["result"]["isError"], true); + assert!(!state::disclosure_status(home.path()).accepted); + } + + #[test] + fn mcp_can_request_and_apply_only_after_local_approval_once() { + 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 requested = handle_request_with_home( + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nightward_action_request","arguments":{"action_id":"backup.snapshot","client":"test-mcp"}}}), + home.path(), + ); + assert_eq!(requested["result"]["isError"], false); + let approval_id = requested["result"]["structuredContent"]["approval_id"] + .as_str() + .unwrap() + .to_string(); + + let pending_apply = handle_request_with_home( + json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"nightward_action_apply_approved","arguments":{"approval_id":approval_id}}}), + home.path(), + ); + assert_eq!(pending_apply["result"]["isError"], true); + assert!(pending_apply["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("not approved")); + + approvals::approve(home.path(), &approval_id, "reviewed in test").expect("approve"); + let applied = handle_request_with_home( + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nightward_action_apply_approved","arguments":{"approval_id":approval_id}}}), + home.path(), + ); + assert_eq!(applied["result"]["isError"], false); + assert_eq!( + applied["result"]["structuredContent"]["approval"]["status"], + "applied" + ); + assert!(state::state_dir(home.path()).join("snapshots").exists()); + + let replay = handle_request_with_home( + json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nightward_action_apply_approved","arguments":{"approval_id":approval_id}}}), + home.path(), + ); + assert_eq!(replay["result"]["isError"], true); + assert!(replay["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("not approved")); + } + #[test] fn tool_calls_reject_invalid_arguments_against_strict_schemas() { let home = tempfile::tempdir().expect("temp home"); diff --git a/docs/action.md b/docs/action.md index 68a9ea1..7ef495f 100644 --- a/docs/action.md +++ b/docs/action.md @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - uses: JSONbored/nightward@v0.1.4 + - uses: JSONbored/nightward@v0.1.11 with: mode: sarif output: nightward.sarif @@ -41,7 +41,7 @@ The action runs Nightward locally. It does not upload findings unless your workf For repository CI, the action defaults to `GITHUB_WORKSPACE`; pass `workspace` explicitly when you want to scan a narrower checkout path: ```yaml -- uses: JSONbored/nightward@v0.1.4 +- uses: JSONbored/nightward@v0.1.11 with: mode: sarif workspace: ${{ github.workspace }} @@ -52,7 +52,7 @@ For repository CI, the action defaults to `GITHUB_WORKSPACE`; pass `workspace` e To publish a small badge JSON artifact alongside SARIF: ```yaml -- uses: JSONbored/nightward@v0.1.4 +- uses: JSONbored/nightward@v0.1.11 with: mode: badge workspace: ${{ github.workspace }} diff --git a/docs/ci-security.md b/docs/ci-security.md index 45a64bd..c09ab89 100644 --- a/docs/ci-security.md +++ b/docs/ci-security.md @@ -44,7 +44,7 @@ The in-repo plugin exposes: Users should import a pinned Nightward tag, not a moving branch: ```sh -trunk plugins add --id nightward https://github.com/JSONbored/nightward v0.1.4 +trunk plugins add --id nightward https://github.com/JSONbored/nightward v0.1.11 trunk check enable nightward-policy ``` diff --git a/docs/demo/nightward-tui.tape b/docs/demo/nightward-tui.tape index 2313eb4..907dc00 100644 --- a/docs/demo/nightward-tui.tape +++ b/docs/demo/nightward-tui.tape @@ -38,6 +38,10 @@ Type "6" Sleep 2600ms Type "7" Sleep 2800ms +Type "8" +Sleep 2400ms +Type "9" +Sleep 2200ms Type "1" Sleep 1400ms Type "x" diff --git a/docs/distribution.md b/docs/distribution.md index d115639..743fbe5 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -4,8 +4,8 @@ Nightward distribution should optimize for trust first, then convenience. ## Order -1. GitHub Releases with signed checksums and SBOMs. Shipped in `v0.1.4`. -2. Scoped npm launcher `@jsonbored/nightward` with trusted publishing and provenance. Shipped in `v0.1.4`. +1. GitHub Releases with signed checksums and SBOMs. Shipped. +2. Scoped npm launcher `@jsonbored/nightward` with trusted publishing and provenance. Shipped. 3. Source builds with `make install-local`. Development-only. 4. Trunk plugin import from a release tag. Shipped. 5. GitHub Action release tags. Shipped. diff --git a/docs/growth.md b/docs/growth.md index 616025a..f243c05 100644 --- a/docs/growth.md +++ b/docs/growth.md @@ -11,7 +11,7 @@ Nightward's growth should come from practical trust and low-friction adoption, n - Contributor fixture templates for adapters and rules. - Richer Nightward badge examples for CI users. - Polished sample reports and before-syncing-dotfiles walkthroughs. -- Read-only MCP resources/tools for AI-client assisted local audits. +- MCP resources/tools for AI-client assisted local audits plus local-approval-gated action tickets. - Read-only local report browser after static reports are mature. - Public JSON schemas and generated docs contracts for scan, analysis, policy badge, report diff/history, provider status, rules, and adapters. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index cdf7acb..8506e27 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -6,7 +6,7 @@ Nightward includes a stdio MCP server: nw mcp serve ``` -The server is a first-class Nightward surface for AI clients. It exposes scan, analysis, policy, report, provider, rule, prompt, and bounded action preview workflows without granting AI clients local write access. +The server is a first-class Nightward surface for AI clients. It exposes scan, analysis, policy, report, provider, rule, prompt, bounded action preview, and approval-ticket workflows without letting MCP clients self-confirm local writes. ## Protocol Behavior @@ -32,10 +32,15 @@ The server is a first-class Nightward surface for AI clients. It exposes scan, a - `nightward_report_changes` - `nightward_actions_list` - `nightward_action_preview` +- `nightward_action_request` +- `nightward_action_status` +- `nightward_action_apply_approved` - `nightward_rules` - `nightward_providers` -MCP is read-only. It can show the shared action registry and preview exact write targets, command previews, risk levels, and blocked reasons. Applying those actions must happen out-of-band through the Nightward CLI, TUI, or Raycast extension, where Nightward can receive a local user confirmation that did not come from the MCP client. Cached or manual `nightward_action_apply` calls return an MCP tool-result error before the action registry is reached. +MCP can list and preview shared action registry actions. To run one, the client calls `nightward_action_request`, then waits for the user to approve the exact one-time ticket in the Nightward CLI, TUI, or Raycast extension. Clients can call `nightward_action_status` while waiting to check whether the ticket is pending, approved, denied, expired, or already applied. + +`nightward_action_apply_approved` consumes only that approved exact one-time ticket. Legacy `nightward_action_apply` is intentionally blocked for MCP clients; direct calls return a tool-result error before the action registry is reached. ## Exposed Resources @@ -46,6 +51,7 @@ MCP is read-only. It can show the shared action registry and preview exact write - `nightward://schedule` - `nightward://actions` - `nightward://disclosure` +- `nightward://action-approvals` - `nightward://report-history` ## Exposed Prompts @@ -102,7 +108,10 @@ CI validates that `server.json` and `packages/npm/package.json` agree before the - No telemetry. - No default network calls. - Online-capable providers remain blocked unless explicitly allowed. -- MCP cannot apply local writes; action application is limited to CLI/TUI/Raycast surfaces with local confirmation. +- MCP cannot self-confirm local writes. +- MCP cannot accept the beta responsibility disclosure (Nightward's local one-time acknowledgement that write-capable beta actions are user-authorized). +- MCP can request approvals, read approval status, and apply only exact tickets already approved outside the MCP request. - No live MCP/agent config mutation in MCP v1. +- Approval request files live under Nightward-owned state, expire, reject symlinked storage paths, are redacted before MCP output, and are audited alongside final action results. - 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/privacy-model.md b/docs/privacy-model.md index 11a1322..43319ce 100644 --- a/docs/privacy-model.md +++ b/docs/privacy-model.md @@ -11,9 +11,9 @@ Nightward is designed around local custody. The scanner inspects local file meta - 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 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 and read-only. It exposes scan, analysis, policy, report, rule, provider, prompt, resource, action-list, and action-preview context, but cannot apply local writes. +- The TUI can apply only shared action registry operations after disclosure acceptance and an explicit confirmation keypress. It can also approve or deny exact one-time MCP action tickets. +- The Raycast extension exposes the same shared action registry and uses Raycast confirmation prompts before applying actions or approving exact one-time MCP action tickets. +- The MCP server is stdio-only. All tools are read-only except `nightward_action_request`, which writes Nightward-owned approval state, and `nightward_action_apply_approved`, which consumes a locally approved exact one-time ticket before running the exact shared-registry action. An exact one-time ticket is unique, expires, and is consumed after one apply attempt. ## Write Paths @@ -29,13 +29,14 @@ Nightward writes only when explicitly asked: - 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. +- MCP approval requests write bounded approval files under `~/.local/state/nightward/action-approvals`; approved apply operations consume one exact one-time ticket and then run the shared action registry. +- Raycast clipboard exports and report-folder open actions occur only after explicit command invocation. -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. +Confirmed action writes and approval lifecycle changes 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. 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` cannot apply local writes. MCP clients cannot accept the Nightward disclosure, request arbitrary file edits, apply shared registry actions, live MCP/agent config rewrites, restore operations, Git pushes, or secret sync. MCP can list and preview shared action-registry operations so the user can apply them out-of-band in the CLI, TUI, or Raycast extension. 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. +`nw mcp serve` cannot self-confirm local writes. MCP clients cannot accept the Nightward beta responsibility disclosure, request arbitrary file edits, mutate live MCP/agent config, restore operations, push to Git, or sync secrets. MCP can list and preview shared action registry operations, request a local approval, read approval status, and apply only the exact one-time ticket after CLI/TUI/Raycast approval. 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. diff --git a/docs/raycast-extension.md b/docs/raycast-extension.md index 0067d78..508d825 100644 --- a/docs/raycast-extension.md +++ b/docs/raycast-extension.md @@ -16,6 +16,7 @@ integrations/raycast - `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. +- `Nightward MCP Approvals`: approve or deny exact Model Context Protocol (MCP)-requested action tickets. - `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`. @@ -44,7 +45,7 @@ The extension uses `execFile`, not a shell, for local Nightward commands. It cal - `analyze finding --json` - `providers doctor [--with providers] [--online] --json` -Write-capable calls are limited to `actions apply --confirm` through the shared action registry. Provider Doctor previews `provider.install.` and applies that registry action after explicit confirmation; it no longer runs package-manager commands through a shell. No Raycast command runs restore, Git, or hidden shell mutation. +Write-capable calls are limited to `actions apply --confirm` through the shared action registry and `approvals approve|deny ` through the Nightward approval queue, the human-reviewed queue for approve/deny decisions on MCP-requested actions. Provider Doctor previews `provider.install.` and applies that registry action only after explicit confirmation; it no longer runs package-manager commands through a shell. MCP Approvals can approve a one-time ticket for MCP to run the specific approved registry action later, but it cannot approve disclosure, exfiltrate secrets, expose environment variables, read private keys, authorize arbitrary edits, perform bulk code changes, run raw shell commands, restore files, push Git state, or mutate live agent config outside the approved registry action. 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. @@ -75,6 +76,7 @@ Manual UI validation must use a fixture `Home Override`, not a real local home, - Analysis renders built-in signals, selected provider output, provider warnings, and blocked-online-provider state. - 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. +- Nightward MCP Approvals lists approval IDs, requested actions, status, expiry, writes, and commands; approve/deny requires a Raycast confirmation prompt. - Export commands copy redacted Markdown and do not mutate local config. - Open Reports opens only an existing reports folder. diff --git a/docs/testing.md b/docs/testing.md index 943d004..6b2150a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,7 +46,7 @@ make test-prepush After a package is published, verify the install path explicitly: ```sh -make test-release-install VERSION=0.1.4 +make test-release-install VERSION=0.1.11 ``` The lower-level targets remain available for focused iteration: @@ -87,19 +87,19 @@ make verify - 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/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, disabled MCP action apply, disclosure self-accept rejection, out-of-band disclosure still not enabling MCP writes, and report-change failure paths. +- 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, disabled legacy MCP action apply, disclosure self-accept rejection, approval request/status/apply-approved flow, pending/denied/expired/replayed ticket blocking, digest mismatch blocking, 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. +- TUI tests cover fixed terminal rendering behavior, redaction boundaries, action confirmation, MCP approval review, and embedded OpenTUI layout helpers. - Scheduler tests cover report history ordering, finding counts, non-report filtering, and symlink skipping without installing timers. -- Raycast extension tests cover pure redaction/formatting helpers, safe command execution wrappers, and Provider Doctor install routing through the shared action registry instead of direct shell execution. +- Raycast extension tests cover pure redaction/formatting helpers, safe command execution wrappers, Provider Doctor install routing through the shared action registry instead of direct shell execution, and MCP approval list/approve/deny command routing. - `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 runs the site Vitest docs contracts for stale copy, demo fixture IDs, and MCP tool/resource/prompt docs coverage. +- `make docs-qa` verifies generated CLI/provider/rule/config references and runs the site Vitest docs contracts for stale copy, demo fixture IDs, stale release tags, CLI help parity, and MCP tool/resource/prompt docs coverage. ## Trunk Flaky Tests diff --git a/docs/threat-model.md b/docs/threat-model.md index 1c6cae6..2ec2738 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -15,7 +15,7 @@ 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, 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 previews through stdio, so returned tool/resource/prompt content must stay redacted and the server must not perform local writes. +- MCP clients are agent boundaries. `nw mcp serve` exposes local context, bounded action previews, approval requests, and approved-ticket apply through stdio, so returned tool/resource/prompt content must stay redacted and MCP clients must never be able to self-confirm writes. - GitHub Actions and Trunk integrations treat repository contents and PR input as untrusted. - Scheduler install/remove is explicit, confirmation-gated, and user-level only. - Release automation and npm publishing are privileged publishing boundaries. @@ -23,12 +23,12 @@ Nightward inspects local AI agent and devtool state, so its primary risk is acci ## 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-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. +- Unexpected mutation: scan, doctor, findings, fix, policy, backup-plan, snapshot-plan, analysis, and GitHub Action policy paths stay read-only except explicit output files. TUI, Raycast, CLI, and MCP approved-ticket writes must flow through the shared action registry with disclosure acceptance and local approval/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 as non-portable. Unknown state means entries Nightward cannot validate through a known schema, known portable path pattern, recognized file type, or provenance signal, including unrecognized config shapes, runtime artifacts without source metadata, and dynamically generated secret-like files. Implementations detect it through schema validation, path allowlists/denylists, file-type checks, and provenance checks. Exclude those entries from portability and snapshot exports by default; Nightward v1 has no auto-include override, and any future override must require explicit user confirmation plus an audit event. - 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: keep MCP read-only. It can list and preview registry actions, but it cannot accept the responsibility disclosure or apply local writes because MCP tool arguments are not an out-of-band user confirmation channel. 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, HTTP listener behavior, or local write apply through MCP v1. +- Agent overreach through MCP: MCP can request bounded action approvals, but it cannot accept the responsibility disclosure, approve its own request, change approved arguments, replay tickets, or apply local writes without a local CLI/TUI/Raycast approval. Tool inputs are validated against strict server-side schemas, approval records are exact-preview digested and one-time, 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, HTTP listener behavior, or arbitrary local writes through MCP v1. ## Non-Goals @@ -38,4 +38,4 @@ Nightward can create local portable backup snapshots, but it does not restore, s ## Review Triggers -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. +Update this model before adding live MCP/agent config mutation, restore, encrypted sync, hosted dashboards, release/npm publishing changes, direct MCP writes without local approval, or new writable integrations. diff --git a/docs/website.md b/docs/website.md index 9aeb2bd..f03cf95 100644 --- a/docs/website.md +++ b/docs/website.md @@ -28,7 +28,7 @@ TUI media is generated separately because it needs VHS and ffmpeg: make tui-media ``` -That target uses the scrubbed sample scan plus `NIGHTWARD_TUI_VIEW` to write seven gallery PNGs under `site/public/demo/tui/`, refresh the legacy TUI PNG/GIF, and build `site/public/demo/tui/nightward-opentui.webm` for the homepage animation. Review generated frames for `/Users`, username, hostname, private MCP names, real project paths, and secret-looking values before committing. +That target uses the scrubbed sample scan plus `NIGHTWARD_TUI_VIEW` to write gallery PNGs under `site/public/demo/tui/`, refresh the legacy TUI PNG/GIF, and build `site/public/demo/tui/nightward-opentui.webm` for the homepage animation. Review generated frames for `/Users`, username, hostname, private MCP names, real project paths, and secret-looking values before committing. ## Site Goals @@ -38,7 +38,7 @@ That target uses the scrubbed sample scan plus `NIGHTWARD_TUI_VIEW` to write sev - Show the local-first privacy stance clearly. - Use fixture-only terminal media on the homepage, with a reduced-motion static fallback. - Document CLI, TUI, policy, integrations, security, and release verification. -- Document the read-only MCP server as an AI-client integration, not a write/control surface. +- Document MCP as an AI-client integration where agents can request approval tickets, but only locally approved exact tickets can write. Exact tickets must match the canonical action preview digest for the operation, arguments, write targets, command preview, risk, and availability; parameter substitution, extra fields, and wildcarding are rejected. Example: a locally approved `provider.install.socket` ticket can only apply that same provider install preview, not `provider.install.trivy` or the same action with extra arguments. - Avoid runtime analytics, telemetry, or hosted-docs dependencies by default. - Allow the deployed public website to use explicitly configured, self-hosted Umami for aggregate visitor analytics. diff --git a/integrations/raycast/README.md b/integrations/raycast/README.md index a9e3f9e..06e463f 100644 --- a/integrations/raycast/README.md +++ b/integrations/raycast/README.md @@ -12,6 +12,7 @@ This extension is read-only until a user invokes the shared Nightward action reg - `Nightward Analysis`: built-in offline analysis plus any providers explicitly selected in Provider Doctor. - `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. +- `Nightward MCP Approvals`: approve or deny specific one-time MCP (Model Context Protocol) action tickets after reviewing file, configuration, and system changes, plus command preview, risk, and expiry. - `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. diff --git a/integrations/raycast/package.json b/integrations/raycast/package.json index fa9479b..3b5d35e 100644 --- a/integrations/raycast/package.json +++ b/integrations/raycast/package.json @@ -82,6 +82,13 @@ "mode": "view", "keywords": ["actions", "apply", "backup", "schedule", "providers", "fix"] }, + { + "name": "approvals", + "title": "Nightward MCP Approvals", + "description": "Review and approve or deny pending Nightward MCP action requests.", + "mode": "view", + "keywords": ["mcp", "approval", "actions", "security", "confirm"] + }, { "name": "explain-finding", "title": "Explain Nightward Finding", diff --git a/integrations/raycast/raycast-env.d.ts b/integrations/raycast/raycast-env.d.ts index 8ad4782..7906b07 100644 --- a/integrations/raycast/raycast-env.d.ts +++ b/integrations/raycast/raycast-env.d.ts @@ -32,6 +32,8 @@ declare namespace Preferences { export type ProviderDoctor = ExtensionPreferences & {} /** Preferences accessible in the `actions` command */ export type Actions = ExtensionPreferences & {} + /** Preferences accessible in the `approvals` command */ + export type Approvals = ExtensionPreferences & {} /** Preferences accessible in the `explain-finding` command */ export type ExplainFinding = ExtensionPreferences & {} /** Preferences accessible in the `explain-signal` command */ @@ -57,6 +59,8 @@ declare namespace Arguments { export type ProviderDoctor = {} /** Arguments passed to the `actions` command */ export type Actions = {} + /** Arguments passed to the `approvals` command */ + export type Approvals = {} /** Arguments passed to the `explain-finding` command */ export type ExplainFinding = { /** Finding ID */ diff --git a/integrations/raycast/src/approvals.tsx b/integrations/raycast/src/approvals.tsx new file mode 100644 index 0000000..37ec189 --- /dev/null +++ b/integrations/raycast/src/approvals.tsx @@ -0,0 +1,343 @@ +import { + Action, + ActionPanel, + Alert, + Color, + Icon, + List, + Toast, + confirmAlert, + getPreferenceValues, + showToast, +} from "@raycast/api"; +import { usePromise } from "@raycast/utils"; +import { + approveApproval, + denyApproval, + listApprovals, + normalizePreferences, +} from "./nightward"; +import type { NightwardApproval } from "./types"; + +export default function Command() { + const runtime = normalizePreferences(getPreferenceValues()); + const { data, error, isLoading, revalidate } = usePromise(async () => + listApprovals(runtime), + ); + + if (error) { + return ( + + + + ); + } + + const approvals = data?.approvals ?? []; + return ( + + {approvals.length === 0 && !isLoading ? ( + + ) : null} + {[ + "pending", + "approved", + "applied", + "denied", + "expired", + "failed", + "invalidated", + ].map((status) => ( + + {approvals + .filter((approval) => approval.status === status) + .map((approval) => ( + + ))} + + ))} + + ); +} + +function ApprovalItem({ + approval, + onRefresh, + runtime, +}: { + approval: NightwardApproval; + onRefresh: () => void; + runtime: ReturnType; +}) { + const action = approval.preview.action; + return ( + + + + + + + + + + + + } + /> + } + actions={ + + + {approval.status === "pending" && ( + <> + + void approveSelectedApproval(runtime, approval, onRefresh) + } + /> + + void denySelectedApproval(runtime, approval, onRefresh) + } + /> + + )} + + + + + + + + + + + } + /> + ); +} + +async function approveSelectedApproval( + runtime: ReturnType, + approval: NightwardApproval, + onRefresh: () => void, +) { + if (approval.status !== "pending") { + await showToast({ + style: Toast.Style.Failure, + title: "Approval is not pending", + message: approval.status, + }); + return; + } + const action = approval.preview.action; + const confirmed = await confirmAlert({ + title: `Approve ${action.title}?`, + message: [ + "This lets the MCP client apply this exact one-time ticket. It does not approve any other action.", + action.description, + action.requires_online ? "This action may use the network." : "", + action.command.length > 0 ? `Command: ${action.command.join(" ")}` : "", + action.writes.length > 0 ? `Writes: ${action.writes.join(", ")}` : "", + ] + .filter(Boolean) + .join("\n\n"), + primaryAction: { + title: "Approve", + style: + action.risk === "high" || action.risk === "critical" + ? Alert.ActionStyle.Destructive + : Alert.ActionStyle.Default, + }, + }); + if (!confirmed) return; + const applied = await mutateApproval("Approving MCP action", () => + approveApproval(runtime, approval.approval_id), + ); + if (applied) onRefresh(); +} + +async function denySelectedApproval( + runtime: ReturnType, + approval: NightwardApproval, + onRefresh: () => void, +) { + if (approval.status !== "pending") { + await showToast({ + style: Toast.Style.Failure, + title: "Approval is not pending", + message: approval.status, + }); + return; + } + const confirmed = await confirmAlert({ + title: `Deny ${approval.preview.action.title}?`, + message: "The MCP client will not be able to apply this ticket.", + primaryAction: { + title: "Deny", + style: Alert.ActionStyle.Destructive, + }, + }); + if (!confirmed) return; + const denied = await mutateApproval("Denying MCP action", () => + denyApproval(runtime, approval.approval_id), + ); + if (denied) onRefresh(); +} + +async function mutateApproval( + title: string, + apply: () => Promise, +): Promise { + const toast = await showToast({ style: Toast.Style.Animated, title }); + try { + const result = await apply(); + toast.style = Toast.Style.Success; + toast.title = "Approval updated"; + toast.message = `${result.action_id}: ${result.status}`; + return true; + } catch (error) { + toast.style = Toast.Style.Failure; + toast.title = "Approval failed"; + toast.message = error instanceof Error ? error.message : String(error); + return false; + } +} + +function approvalMarkdown(approval: NightwardApproval): string { + const action = approval.preview.action; + return [ + `# ${action.title}`, + "", + `Status: \`${approval.status}\``, + `Approval: \`${approval.approval_id}\``, + `Requested by: \`${approval.requested_by}\``, + `Expires: \`${approval.expires_at}\``, + "", + "Approving this request lets the MCP client apply this exact ticket once. It does not approve disclosure acceptance, hidden config edits, or any other action.", + "", + action.description, + "", + action.command.length > 0 ? "## Command" : "", + action.command.length > 0 ? `\`${action.command.join(" ")}\`` : "", + action.writes.length > 0 ? "## Writes" : "", + ...action.writes.map((write) => `- \`${write}\``), + approval.decision_reason ? "## Decision" : "", + approval.decision_reason ?? "", + ] + .filter((line) => line !== undefined && line !== null) + .join("\n"); +} + +function sectionTitle(status: string): string { + return status.replace(/^\w/, (letter) => letter.toUpperCase()); +} + +function statusColor(status: string): Color { + switch (status) { + case "pending": + return Color.Yellow; + case "approved": + return Color.Green; + case "applied": + return Color.Blue; + case "denied": + case "failed": + case "invalidated": + return Color.Red; + case "expired": + return Color.SecondaryText; + default: + return Color.SecondaryText; + } +} + +function riskColor(risk: string): Color { + switch (risk) { + case "high": + case "critical": + return Color.Red; + case "medium": + return Color.Orange; + case "low": + return Color.Blue; + case "none": + case "safe": + return Color.Green; + default: + return Color.SecondaryText; + } +} diff --git a/integrations/raycast/src/nightward.ts b/integrations/raycast/src/nightward.ts index c2fd0d2..b409a36 100644 --- a/integrations/raycast/src/nightward.ts +++ b/integrations/raycast/src/nightward.ts @@ -15,6 +15,8 @@ import type { ProviderStatus, ScanReport, NightwardAction, + NightwardApproval, + NightwardApprovalList, NightwardActionPreview, NightwardActionResult, } from "./types"; @@ -181,6 +183,37 @@ export async function applyAction( ); } +export async function listApprovals( + options: RuntimeOptions, +): Promise { + return runNightwardJSON( + ["approvals", "list", "--json"], + options, + ); +} + +export async function approveApproval( + options: RuntimeOptions, + approvalId: string, + reason = "approved in Raycast", +): Promise { + return runNightwardJSON( + ["approvals", "approve", approvalId, "--reason", reason, "--json"], + options, + ); +} + +export async function denyApproval( + options: RuntimeOptions, + approvalId: string, + reason = "denied in Raycast", +): Promise { + return runNightwardJSON( + ["approvals", "deny", approvalId, "--reason", reason, "--json"], + options, + ); +} + export async function reportDiff( options: RuntimeOptions, base: string, diff --git a/integrations/raycast/src/types.ts b/integrations/raycast/src/types.ts index 744d747..f721887 100644 --- a/integrations/raycast/src/types.ts +++ b/integrations/raycast/src/types.ts @@ -308,3 +308,25 @@ export type NightwardActionResult = { writes: string[]; audit_path?: string; }; + +export type NightwardApproval = { + schema_version?: number; + approval_id: string; + status: string; + action_id: string; + preview_digest: string; + preview: NightwardActionPreview; + requested_by: string; + requested_at: string; + expires_at: string; + decision_reason?: string; + decided_at?: string; + applied_at?: string; + result_message?: string; + action_audit_path?: string; +}; + +export type NightwardApprovalList = { + schema_version?: number; + approvals: NightwardApproval[]; +}; diff --git a/integrations/raycast/test/format.test.ts b/integrations/raycast/test/format.test.ts index a39fee0..419286b 100644 --- a/integrations/raycast/test/format.test.ts +++ b/integrations/raycast/test/format.test.ts @@ -201,7 +201,7 @@ test("menu bar status summarizes risk and schedule state", () => { }; const doctor: DoctorReport = { generated_at: "2026-05-01T00:00:00Z", - version: "0.1.4", + version: "0.1.11", home: "/tmp/nightward-home", executable: "/tmp/nw", checks: [], @@ -284,7 +284,7 @@ test("menu bar status title ignores provider warnings when there are no findings }; const doctor: DoctorReport = { generated_at: "2026-05-01T00:00:00Z", - version: "0.1.4", + version: "0.1.11", home: "/tmp/nightward-home", executable: "/tmp/nw", checks: [], diff --git a/integrations/raycast/test/nightward.test.ts b/integrations/raycast/test/nightward.test.ts index 36e04c5..f330eb8 100644 --- a/integrations/raycast/test/nightward.test.ts +++ b/integrations/raycast/test/nightward.test.ts @@ -8,6 +8,9 @@ import { exportFixPlanMarkdown, fixPlan, applyAction, + approveApproval, + denyApproval, + listApprovals, listActions, normalizePreferences, previewAction, @@ -317,6 +320,62 @@ test("action helpers call the shared CLI action surface", async () => { ]); }); +test("approval helpers call the shared CLI approval queue", async () => { + const observed: string[][] = []; + const options: RuntimeOptions = { + executable: "nightward", + allowOnlineProviders: false, + timeoutMs: 1000, + execFileImpl: (_file, args, _options, callback) => { + observed.push(args); + if (args.includes("list")) { + callback(null, JSON.stringify({ schema_version: 1, approvals: [] }), ""); + return; + } + callback( + null, + JSON.stringify({ + approval_id: "appr-test", + status: args.includes("approve") ? "approved" : "denied", + action_id: "backup.snapshot", + preview_digest: "digest", + preview: { + action: { id: "backup.snapshot", writes: [], command: [] }, + steps: [], + warnings: [], + }, + requested_by: "mcp-client", + requested_at: "2026-05-06T00:00:00Z", + expires_at: "2026-05-06T00:15:00Z", + }), + "", + ); + }, + }; + + await listApprovals(options); + await approveApproval(options, "appr-test", "reviewed"); + await denyApproval(options, "appr-test", "rejected"); + + assert.deepEqual(observed[0], ["approvals", "list", "--json"]); + assert.deepEqual(observed[1], [ + "approvals", + "approve", + "appr-test", + "--reason", + "reviewed", + "--json", + ]); + assert.deepEqual(observed[2], [ + "approvals", + "deny", + "appr-test", + "--reason", + "rejected", + "--json", + ]); +}); + test("explain signal passes finding id before flags", async () => { let observedArgs: string[] = []; const options: RuntimeOptions = { diff --git a/scripts/generate-tui-media.mjs b/scripts/generate-tui-media.mjs index f0a4a49..a13f901 100755 --- a/scripts/generate-tui-media.mjs +++ b/scripts/generate-tui-media.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { copyFileSync, existsSync, @@ -11,7 +11,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir, userInfo } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -23,6 +23,7 @@ const videoOutput = join(outputDir, "nightward-opentui.webm"); const posterOutput = join(outputDir, "poster.png"); const tapePath = join(repoRoot, "docs", "demo", "nightward-tui.tape"); const tempDir = mkdtempSync(join(tmpdir(), "nightward-tui-media-")); +let mediaHome = join(tempDir, "home"); const toolPath = `${process.env.HOME}/.cargo/bin:${process.env.HOME}/go/bin:/opt/homebrew/bin:${process.env.PATH || ""}`; const views = [ @@ -32,6 +33,8 @@ const views = [ ["fix-plan", "fix-plan", "Fix Plan"], ["inventory", "inventory", "Inventory"], ["backup", "backup", "Backup"], + ["actions", "actions", "Actions"], + ["mcp-approvals", "mcp-approvals", "MCP Approvals"], ["help", "help", "Help"], ]; @@ -43,6 +46,23 @@ function run(command, args, options = {}) { }); } +function runChecked(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + env: { ...process.env, PATH: toolPath, ...options.env }, + encoding: "utf8", + stdio: "pipe", + }); + if (result.error || result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + if (output) { + console.error(output); + } + throw result.error ?? new Error(`${command} ${args.join(" ")} failed with ${result.status}`); + } + return result; +} + function requireTool(command, args = ["--version"]) { try { execFileSync(command, args, { @@ -55,6 +75,14 @@ function requireTool(command, args = ["--version"]) { } } +function shellQuote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +function nightwardEnvPrefix() { + return `NIGHTWARD_HOME=${shellQuote(mediaHome)}`; +} + function writeStillTape(view, outputGif) { const tape = join(tempDir, `${view}.tape`); writeFileSync( @@ -76,15 +104,52 @@ Set Theme "TokyoNight" Hide Type "stty rows 36 cols 120" Enter -Type "NIGHTWARD_TUI_CAPTURE=1 NIGHTWARD_TUI_CAPTURE_HOLD_MS=2600 NIGHTWARD_TUI_VIEW=${view} target/debug/nw tui --input site/public/demo/nightward-sample-scan.json" +Type "${nightwardEnvPrefix()} NIGHTWARD_TUI_CAPTURE=1 NIGHTWARD_TUI_CAPTURE_HOLD_MS=5000 NIGHTWARD_TUI_VIEW=${view} target/debug/nw tui --input site/public/demo/nightward-sample-scan.json" Enter Show -Sleep 3000ms +Sleep 5600ms `, ); return tape; } +function writeWalkthroughTape() { + const tape = join(tempDir, "nightward-tui-walkthrough.tape"); + const original = readFileSync(tapePath, "utf8"); + const command = + 'Type "target/debug/nw tui --input site/public/demo/nightward-sample-scan.json"'; + if (!original.includes(command)) { + throw new Error(`walkthrough tape missing expected command pattern: ${command}`); + } + const text = original.replace( + command, + `Type "${nightwardEnvPrefix()} target/debug/nw tui --input site/public/demo/nightward-sample-scan.json"`, + ); + writeFileSync(tape, text); + return tape; +} + +function resetMediaHomeState() { + const resolvedMediaHome = resolve(mediaHome); + const resolvedTempDir = resolve(tempDir); + const tempRelative = relative(resolvedTempDir, resolvedMediaHome); + const isTempChild = + (tempRelative.length === 0 || + (!tempRelative.startsWith("..") && !isAbsolute(tempRelative))); + const allowed = + resolvedMediaHome === resolve("/tmp/nightward-fixture-home") || isTempChild; + if (!allowed) { + throw new Error(`refusing to reset unexpected media home: ${mediaHome}`); + } + for (const rel of [ + [".config", "nightward"], + [".local", "state", "nightward"], + [".cache", "nightward"], + ]) { + rmSync(join(resolvedMediaHome, ...rel), { recursive: true, force: true }); + } +} + function extractBestPng(inputGif, outputPng) { const durationRaw = execFileSync("ffprobe", [ "-v", @@ -96,9 +161,10 @@ function extractBestPng(inputGif, outputPng) { inputGif, ]).toString("utf8"); const duration = Number.parseFloat(durationRaw); - const stamps = Number.isFinite(duration) && duration > 0 - ? [0.18, 0.28, 0.38, 0.48, 0.58].map((pct) => (duration * pct).toFixed(2)) - : ["0.80", "1.10", "1.40", "1.70", "2.00"]; + const stamps = + Number.isFinite(duration) && duration > 0 + ? [0.42, 0.52, 0.62, 0.72].map((pct) => (duration * pct).toFixed(2)) + : ["2.10", "2.40", "2.70", "3.00"]; const candidates = []; for (const stamp of stamps) { const candidate = join(tempDir, `${Date.now()}-${stamp}.png`); @@ -128,8 +194,7 @@ function extractBestPng(inputGif, outputPng) { if (candidates.length === 0) { throw new Error(`failed to extract a still frame from ${inputGif}`); } - const largest = candidates.sort((a, b) => statSync(b).size - statSync(a).size)[0]; - copyFileSync(largest, outputPng); + copyFileSync(candidates[0], outputPng); } function assertScrubbed(label, path) { @@ -155,16 +220,29 @@ try { if (!existsSync(fixtureScan)) { throw new Error("missing scrubbed sample scan; run `make demo-assets` first"); } + mediaHome = JSON.parse(readFileSync(fixtureScan, "utf8")).home || mediaHome; mkdirSync(outputDir, { recursive: true }); + resetMediaHomeState(); + mkdirSync(mediaHome, { recursive: true }); run("cargo", ["build", "-p", "nightward-cli", "--bin", "nw"]); + runChecked("target/debug/nw", ["disclosure", "accept", "--json"], { + env: { NIGHTWARD_HOME: mediaHome }, + }); + runChecked( + "target/debug/nw", + ["approvals", "request", "backup.snapshot", "--client", "demo-mcp", "--json"], + { + env: { NIGHTWARD_HOME: mediaHome }, + }, + ); for (const [slug, view, label] of views) { const gif = join(tempDir, `${slug}.gif`); const png = join(outputDir, `${slug}.png`); const tape = writeStillTape(view, gif); console.log(`capturing ${label}`); - run("vhs", [tape]); + run("vhs", [tape], { env: { NIGHTWARD_HOME: mediaHome } }); extractBestPng(gif, png); assertScrubbed(`${label} PNG`, png); } @@ -173,7 +251,7 @@ try { copyFileSync(join(outputDir, "overview.png"), posterOutput); console.log("capturing walkthrough GIF"); - run("vhs", [tapePath]); + run("vhs", [writeWalkthroughTape()], { env: { NIGHTWARD_HOME: mediaHome } }); assertScrubbed("walkthrough GIF", legacyGif); run("ffmpeg", [ "-hide_banner", diff --git a/site/.vitepress/config.mts b/site/.vitepress/config.mts index 0482c02..c2daa49 100644 --- a/site/.vitepress/config.mts +++ b/site/.vitepress/config.mts @@ -19,10 +19,10 @@ const umamiDomains = process.env.NIGHTWARD_UMAMI_DOMAINS ?? "nightward.aethereal const pageDescriptions: Record = { "": "Nightward audits AI-agent configs, MCP servers, and dotfiles sync risk locally, with redacted reports and an OpenTUI review flow.", "guide/install": "Install Nightward with the npm launcher, signed GitHub Release binaries, or a local Rust source build.", - "guide/tui": "Explore Nightward's OpenTUI dashboard, findings, analysis, fix-plan, inventory, backup, and help screens from scrubbed fixture media.", + "guide/tui": "Explore Nightward's OpenTUI dashboard, findings, analysis, fix-plan, inventory, backup, actions, MCP approvals, and help screens from scrubbed fixture media.", "guide/privacy-model": "Understand Nightward's local-first privacy model, write paths, redaction rules, optional providers, and website analytics boundary.", "reference/cli": "Generated Nightward CLI reference for scan, analyze, provider, policy, report, TUI, and MCP server commands.", - "integrations/raycast": "Use Nightward's read-only Raycast extension for menu-bar status, findings, analysis, provider doctor, and redacted exports.", + "integrations/raycast": "Use Nightward's Raycast extension for menu-bar status, findings, analysis, provider doctor, actions, MCP approvals, and redacted exports.", "integrations/github-action": "Run Nightward in GitHub Actions for workspace scans, policy checks, SARIF upload, and release-gated CI review.", "security/release-verification": "Verify Nightward signed releases, checksums, npm launcher behavior, and installed binaries before trusting a release.", }; diff --git a/site/guide/mcp-security.md b/site/guide/mcp-security.md index c8febf6..6ecbd85 100644 --- a/site/guide/mcp-security.md +++ b/site/guide/mcp-security.md @@ -34,3 +34,14 @@ nw findings explain nw analyze finding --json nw fix plan --finding ``` + +## MCP action approvals + +Nightward MCP clients can request bounded action tickets, but they cannot approve their own requests or accept the beta responsibility disclosure, Nightward's local one-time acknowledgement that write-capable beta actions are user-authorized. Review the exact action, command, writes, risk, and expiry in the TUI, Raycast, or CLI: + +```sh +nw approvals list --json +nw approvals approve --reason "reviewed locally" +``` + +After local approval, the MCP client can apply only that exact one-time ticket once. If the action preview changes, the ticket expires, or the client tries to replay it, Nightward blocks the application. diff --git a/site/guide/privacy-model.md b/site/guide/privacy-model.md index 6a6609d..47143d8 100644 --- a/site/guide/privacy-model.md +++ b/site/guide/privacy-model.md @@ -21,14 +21,15 @@ Expected write paths: - Explicit redacted exports from the TUI/Raycast flows. - Explicit clipboard/report-folder actions from Raycast. - Confirmed action writes such as action-registry provider installs, provider settings, scheduled scan install/remove, bounded policy updates, and local backup snapshots. +- MCP (Model Context Protocol) approval requests under Nightward-owned state, followed by a single approved apply operation only after CLI/TUI/Raycast approval. Schedule install/remove uses user-level launchd or systemd user jobs where supported. Cleanup actions remove only Nightward-owned report, log, or cache entries. Action-managed writes reject symlinked Nightward-owned state paths, and backup snapshots skip symlinked or non-regular candidates without following their targets. These actions do not install root daemons, push to Git, or copy secrets. -The MCP server validates tool inputs on the server side and scopes explicit workspace/report paths under `NIGHTWARD_HOME` with regular-file/directory and no-symlink checks. +The MCP server validates tool inputs on the server side and scopes explicit workspace/report paths under `NIGHTWARD_HOME` with regular-file/directory and no-symlink checks. It cannot accept tickets that would disclose sensitive information or bypass user approval for write operations. ## Responsibility disclosure -Nightward is beta local operator tooling. Before TUI write actions run, users must accept that they are responsible for reviewing previews, confirmations, backups, provider behavior, and any resulting system changes. Nightward provides no warranty, and maintainers are not liable for broken configs, lost data, exposed secrets, package-manager side effects, or third-party tool behavior. +Nightward is beta local operator tooling. Before write-capable actions run, users must accept that they are responsible for reviewing previews, confirmations, MCP approval tickets, backups, provider behavior, and any resulting system changes. Nightward provides no warranty, and maintainers are not liable for broken configs, lost data, exposed secrets, package-manager side effects, or third-party tool behavior. ## Optional providers diff --git a/site/guide/tui.md b/site/guide/tui.md index a44c0ca..43fc483 100644 --- a/site/guide/tui.md +++ b/site/guide/tui.md @@ -21,7 +21,7 @@ nw tui --input scan.json

Fixture walkthrough

-

Seven screens from one scrubbed report.

+

Nine sections from one scrubbed report.

The homepage loop and gallery below are generated from `site/public/demo/nightward-sample-scan.json`, not from a live workstation scan.

Backup. Dry-run portable candidates and never-sync exclusions from the fixture home model.
+
+ Nightward TUI fixture actions +
Actions. Shared-registry actions with exact writes, commands, risk, and confirmation state.
+
+
+ Nightward TUI fixture MCP approvals +
MCP Approvals. Pending or recent MCP-requested action tickets for local approval or denial.
+
Nightward TUI fixture help
Help. Keyboard controls and the confirmed-action safety model shown inside the app.
@@ -78,25 +86,26 @@ nw tui --input scan.json - Inventory: discovered AI-tool paths by tool, classification, and risk. - Backup: dry-run dotfiles backup choices. - Actions: preview and confirm bounded provider, policy, schedule, backup, cleanup, and setup actions. +- MCP Approvals: approve or deny exact MCP-requested action tickets; MCP can only apply an approved ticket once. - Help: key bindings and safety reminders. The Rust CLI is the source of truth. The TUI uses embedded `opentui_rust` rendering for the colored dashboard; there is no Bun package or `nightward-tui` sidecar. ## Shortcuts -- `1`-`8`: switch sections. +- `1`-`9`: switch sections. - `tab`, `right`, or `l`: next section. - `left` or `h`: previous section. - `up`, `down`, `j`, or `k`: move selection. -- `enter`: confirm the selected action in the Actions section. -- `y` / `n`: apply or cancel the pending action. +- `enter`: confirm the selected action or review the selected MCP approval. +- `y` / `n`: apply/cancel a pending action or approve/deny a pending MCP ticket. - `/`: search. - `s`: cycle severity. - `x`: clear filters. - `q` or `esc`: quit. > [!NOTE] -> The TUI is read-only until the user accepts the beta responsibility disclosure and confirms a specific action. High-risk MCP edits remain review-first; bounded provider, policy, schedule, backup, cleanup, and setup actions can be applied from the Actions section. +> The TUI is read-only until the user accepts the beta responsibility disclosure and confirms a specific action. High-risk MCP edits remain review-first; bounded provider, policy, schedule, backup, cleanup, and setup actions can be applied from the Actions section. MCP-requested writes require a separate local approval in MCP Approvals before the MCP client can apply the exact ticket once. ## Local Development @@ -107,4 +116,4 @@ make demo-assets make tui-media ``` -Use fixture media for public docs; do not capture a real workstation. `make tui-media` requires `vhs` and `ffmpeg`, writes the seven gallery PNGs under `site/public/demo/tui/`, refreshes the legacy TUI PNG/GIF, and builds the homepage WebM loop. +Use fixture media for public docs; do not capture a real workstation. `make tui-media` requires `vhs` and `ffmpeg`, writes the gallery PNGs under `site/public/demo/tui/`, refreshes the legacy TUI PNG/GIF, and builds the homepage WebM loop. diff --git a/site/index.md b/site/index.md index fe68526..359149e 100644 --- a/site/index.md +++ b/site/index.md @@ -92,7 +92,7 @@ The sample report below is generated from the committed `testdata/homes/policy` | Report history | Compare scan JSON files, inspect latest-report status, render filterable diff-aware HTML, and generate a static local report index. | | Policy and CI | Reason-required ignores, policy badges, SARIF output, GitHub Action mode, and Trunk plugin support. | | Providers | Local [Gitleaks](https://github.com/gitleaks/gitleaks), [TruffleHog](https://github.com/trufflesecurity/trufflehog), [Semgrep](https://semgrep.dev/), and [Syft](https://oss.anchore.com/syft/); online-gated [Trivy](https://trivy.dev/), [OSV-Scanner](https://google.github.io/osv-scanner/), [Grype](https://oss.anchore.com/grype/), [OpenSSF Scorecard](https://github.com/ossf/scorecard), and remote [Socket](https://socket.dev/) scan creation. | -| MCP server | Stdio tools/resources/prompts for local AI clients; read-only action list/preview with local writes applied through CLI/TUI/Raycast. | +| MCP server | Exposes tools, resources, and prompts over stdio for local AI clients; includes action previews, approval workflows, and controlled application of approved actions through the shared local action registry. | ## Trust Posture diff --git a/site/integrations/github-action.md b/site/integrations/github-action.md index 3d80409..acd67f9 100644 --- a/site/integrations/github-action.md +++ b/site/integrations/github-action.md @@ -3,7 +3,7 @@ Nightward ships a composite [GitHub Action](https://docs.github.com/en/actions) for repository policy checks. ```yaml -- uses: JSONbored/nightward@v0.1.4 +- uses: JSONbored/nightward@v0.1.11 with: mode: sarif workspace: . diff --git a/site/integrations/mcp-server.md b/site/integrations/mcp-server.md index f1fc69f..d2eac7e 100644 --- a/site/integrations/mcp-server.md +++ b/site/integrations/mcp-server.md @@ -2,13 +2,13 @@ -Nightward ships a stdio [Model Context Protocol](https://modelcontextprotocol.io/) server so AI clients can scan, explain, compare, plan, and preview bounded Nightward actions. +Nightward ships a stdio [Model Context Protocol](https://modelcontextprotocol.io/) server so AI clients can scan, explain, compare, plan, preview Nightward's bounded actions, and request local approval for exact write tickets. ```sh nw mcp serve ``` -The MCP surface is read-only. It can list and preview the shared Nightward action registry, but local writes must be applied out-of-band in the Nightward CLI, TUI, or Raycast extension. +Most MCP tools are read-only. For writes, MCP can request a bounded action approval; the user approves or denies it locally in the Nightward CLI, TUI, or Raycast extension; then MCP can apply only that exact approved one-time ticket. MCP cannot accept the beta responsibility disclosure. That disclosure is Nightward's local one-time acknowledgement that write-capable beta actions are user-authorized, and MCP cannot self-confirm writes. ## Client Setup @@ -32,7 +32,7 @@ VS Code-style clients use a different key: } ``` -Restart or reload the AI client after editing its MCP config. A useful first prompt is: "Audit my AI setup with Nightward, explain the top risks, and preview any relevant actions without applying writes." +Restart or reload the AI client after editing its MCP config. A useful first prompt is: "Audit my AI setup with Nightward, explain the top risks, preview relevant actions, and request approval before applying any write action." ## Tools @@ -50,6 +50,9 @@ Restart or reload the AI client after editing its MCP config. A useful first pro | `nightward_report_changes` | Compare two report files or the latest two saved reports. | Read-only. | | `nightward_actions_list` | List bounded registry actions. | Read-only. | | `nightward_action_preview` | Preview one registry action. | Read-only. | +| `nightward_action_request` | Request local approval for one exact registry action. | Writes Nightward approval state only. | +| `nightward_action_status` | Read one approval ticket status. | Read-only. | +| `nightward_action_apply_approved` | Apply one approved, unexpired, one-time ticket. | Destructive only after local approval. | | `nightward_rules` | List rules and remediation metadata. | Read-only. | | `nightward_providers` | List provider capabilities and status. | Read-only. | @@ -74,6 +77,7 @@ Compact mode keeps pass/fail, threshold, summary counts, and bounded finding or - `nightward://schedule` - `nightward://actions` - `nightward://disclosure` +- `nightward://action-approvals` - `nightward://report-history` ## Prompts @@ -84,7 +88,7 @@ Compact mode keeps pass/fail, threshold, summary counts, and bounded finding or - `set_up_providers` - `compare_reports` -These prompts are workflow starters for clients that expose MCP prompts. They are deliberately cautious: they tell the assistant to preview registry actions and send actual writes through the CLI, TUI, or Raycast extension. +These prompts are workflow starters for clients that expose MCP prompts. They tell the assistant to preview registry actions, request approval when a bounded action is useful, and only apply exact tickets already approved locally. ## Safety Model @@ -94,12 +98,14 @@ These prompts are workflow starters for clients that expose MCP prompts. They ar - Strict tool input schemas, server-side invalid-argument rejection, and structured output. - Tool execution failures return `isError: true`, not protocol crashes. - Online-capable providers stay blocked unless explicitly allowed. -- MCP cannot apply local writes; action application is limited to CLI/TUI/Raycast surfaces with local confirmation. +- MCP cannot self-confirm local writes. +- MCP cannot accept the beta responsibility disclosure, Nightward's local one-time acknowledgement that write-capable beta actions are user-authorized. +- MCP approval requests record only Nightward-owned approval state. Applying an approved action consumes one exact one-time ticket and is audited. - No arbitrary MCP/agent config mutation in MCP v1. - Explicit workspace and report-diff paths must stay under `NIGHTWARD_HOME`, exist as the expected regular file or directory type, and avoid symlink components. - Preview output is redacted and exposes write targets before any out-of-band apply. -Use `nightward_action_preview` before applying an action in the CLI, TUI, or Raycast extension. For package-manager provider installs, read the command, provider privacy boundary, and rollback expectations before confirming outside MCP. +Use `nightward_action_preview` before `nightward_action_request`. For provider installs that run a package manager such as Homebrew, Go, or npm, review the exact command, provider privacy boundary, and rollback expectations before approving the pending ticket in the CLI, TUI, or Raycast extension. ## Registry Package diff --git a/site/integrations/raycast.md b/site/integrations/raycast.md index 8af6c20..38d804d 100644 --- a/site/integrations/raycast.md +++ b/site/integrations/raycast.md @@ -1,6 +1,6 @@ # Raycast -Nightward’s [Raycast](https://www.raycast.com/) extension is a macOS companion for AI-agent, MCP, provider, and dotfiles risk review. It shells out to `nw` or `nightward`, renders redacted output, and uses the shared Nightward action registry for confirmation-gated local writes. +Nightward’s [Raycast](https://www.raycast.com/) extension is a macOS companion for AI-agent, Model Context Protocol (MCP), provider, and dotfiles risk review. It shells out to `nw` or `nightward`, renders redacted output, and uses the shared Nightward action registry for confirmation-gated local writes. ## Command Surface @@ -12,6 +12,7 @@ Nightward’s [Raycast](https://www.raycast.com/) extension is a macOS companion | Nightward Analysis | Browse built-in and selected-provider analysis signals. | No | | Nightward Provider Doctor | Check provider availability, choose providers for Raycast Analysis, and preview/apply known provider install actions. | Raycast preference or confirmed action-registry provider install | | Nightward Actions | Preview and apply confirmed provider, policy, schedule, backup, cleanup, and setup actions. | Confirmation-gated local writes | +| Nightward MCP Approvals | Approve or deny exact MCP-requested action tickets. | Approval state only; MCP applies the approved ticket | | Explain Finding / Explain Signal | Jump directly to one known ID. | No | | Export Fix Plan / Export Analysis | Copy redacted Markdown for review. | Clipboard only | | Open Nightward Reports | Open the local report folder in Finder. | Finder open only | @@ -26,11 +27,11 @@ The menu-bar title stays intentionally small: icon plus the current finding coun | `Home Override` | Typed `NIGHTWARD_HOME` path for fixture homes, QA profiles, or demos. | | `Allow Online Providers` | Allows selected online-capable providers in Raycast Analysis. Leave off for local-only behavior. | -Provider selection is separate from execution. If a provider is missing, Provider Doctor and Nightward Actions offer confirmation-gated install actions for known package-manager provider CLIs through the shared Nightward action registry, not through ad hoc shell execution. +Provider selection is separate from execution. If a provider is missing, Provider Doctor and Nightward Actions offer confirmation-gated install actions for known package-manager provider CLIs through the shared Nightward action registry, not through ad hoc shell execution. MCP Approvals shows pending tickets from AI clients; approving one ticket does not approve disclosure, hidden edits, or any other action. ## Responsibility -Nightward is beta operator tooling. Actions can change local package-manager state, scheduled jobs, settings, backup files, or Nightward-owned report/cache files. Review every confirmation and write target before applying. Nightward is provided without warranty; maintainers are not liable for broken configs, lost data, exposed secrets, or third-party tool side effects. +Nightward is beta operator tooling. Actions can change local package-manager state, scheduled jobs, settings, backup files, or Nightward-owned report/cache files. MCP approval tickets can let an AI client apply the exact approved action once. Review every confirmation, approval, command, and write target before applying. Nightward is provided without warranty; maintainers are not liable for broken configs, lost data, exposed secrets, or third-party tool side effects. ## Providers diff --git a/site/integrations/trunk.md b/site/integrations/trunk.md index 00eb92d..eb4b50a 100644 --- a/site/integrations/trunk.md +++ b/site/integrations/trunk.md @@ -3,7 +3,7 @@ Nightward includes a [Trunk](https://trunk.io/) plugin definition so teams can run policy and SARIF checks through Trunk Code Quality. ```sh -trunk plugins add --id nightward https://github.com/JSONbored/nightward v0.1.4 +trunk plugins add --id nightward https://github.com/JSONbored/nightward v0.1.11 trunk check enable nightward-policy ``` diff --git a/site/public/demo/nightward-opentui.gif b/site/public/demo/nightward-opentui.gif index e051a99..19acfae 100644 Binary files a/site/public/demo/nightward-opentui.gif and b/site/public/demo/nightward-opentui.gif differ diff --git a/site/public/demo/nightward-opentui.png b/site/public/demo/nightward-opentui.png index 9e90ea4..c982cac 100644 Binary files a/site/public/demo/nightward-opentui.png and b/site/public/demo/nightward-opentui.png differ diff --git a/site/public/demo/tui/actions.png b/site/public/demo/tui/actions.png new file mode 100644 index 0000000..31a78a0 Binary files /dev/null and b/site/public/demo/tui/actions.png differ diff --git a/site/public/demo/tui/analysis.png b/site/public/demo/tui/analysis.png index ce130a3..37f1902 100644 Binary files a/site/public/demo/tui/analysis.png and b/site/public/demo/tui/analysis.png differ diff --git a/site/public/demo/tui/backup.png b/site/public/demo/tui/backup.png index 1098aa3..8e06dc9 100644 Binary files a/site/public/demo/tui/backup.png and b/site/public/demo/tui/backup.png differ diff --git a/site/public/demo/tui/findings.png b/site/public/demo/tui/findings.png index 1692a0a..50dc00e 100644 Binary files a/site/public/demo/tui/findings.png and b/site/public/demo/tui/findings.png differ diff --git a/site/public/demo/tui/fix-plan.png b/site/public/demo/tui/fix-plan.png index 3b90c36..de06e3c 100644 Binary files a/site/public/demo/tui/fix-plan.png and b/site/public/demo/tui/fix-plan.png differ diff --git a/site/public/demo/tui/help.png b/site/public/demo/tui/help.png index ef241c3..02a52d0 100644 Binary files a/site/public/demo/tui/help.png and b/site/public/demo/tui/help.png differ diff --git a/site/public/demo/tui/inventory.png b/site/public/demo/tui/inventory.png index b4cb3da..a0fbdfe 100644 Binary files a/site/public/demo/tui/inventory.png and b/site/public/demo/tui/inventory.png differ diff --git a/site/public/demo/tui/mcp-approvals.png b/site/public/demo/tui/mcp-approvals.png new file mode 100644 index 0000000..24b87d5 Binary files /dev/null and b/site/public/demo/tui/mcp-approvals.png differ diff --git a/site/public/demo/tui/nightward-opentui.webm b/site/public/demo/tui/nightward-opentui.webm index aaa187e..9df24db 100644 Binary files a/site/public/demo/tui/nightward-opentui.webm and b/site/public/demo/tui/nightward-opentui.webm differ diff --git a/site/public/demo/tui/overview.png b/site/public/demo/tui/overview.png index 9e90ea4..c982cac 100644 Binary files a/site/public/demo/tui/overview.png and b/site/public/demo/tui/overview.png differ diff --git a/site/public/demo/tui/poster.png b/site/public/demo/tui/poster.png index 9e90ea4..c982cac 100644 Binary files a/site/public/demo/tui/poster.png and b/site/public/demo/tui/poster.png differ diff --git a/site/reference/cli.md b/site/reference/cli.md index 41a71ae..cd6b0d3 100644 --- a/site/reference/cli.md +++ b/site/reference/cli.md @@ -24,10 +24,13 @@ USAGE: nightward actions apply reports.cleanup --confirm nightward actions apply cache.cleanup --confirm nightward actions apply policy.ignore --finding --reason "reviewed" --confirm + nightward approvals list --json + nightward approvals approve --reason "reviewed" + nightward approvals apply nightward report html --input scan.json --output report.html nightward report html --from old.json --to new.json --output report.html nightward policy check --json nightward mcp serve -Nightward is local-first and read-only by default. Write-capable actions require disclosure acceptance and explicit confirmation. +Nightward is local-first and read-only by default. Write-capable actions require disclosure acceptance and explicit confirmation. Approval commands do not take --confirm: approve is the local confirmation step, and apply only consumes an already-approved one-time ticket. ``` diff --git a/site/reference/distribution.md b/site/reference/distribution.md index dfe8131..45cbcc1 100644 --- a/site/reference/distribution.md +++ b/site/reference/distribution.md @@ -1,6 +1,6 @@ # Distribution -Nightward v0.1.4 is distributed through signed GitHub Releases and the npm launcher. +Nightward is distributed through signed GitHub Releases and the npm launcher. ## Current Channels @@ -12,7 +12,7 @@ Nightward v0.1.4 is distributed through signed GitHub Releases and the npm launc | [GitHub Action](/integrations/github-action) | Shipped | Uses release tags for CI policy/SARIF workflows. | | [Trunk plugin import](/integrations/trunk) | Shipped | Imports the in-repo plugin from release tags. | | [Raycast extension](/integrations/raycast) | Development-ready | Local Raycast extension commands and menu-bar status; store PR still pending. | -| [MCP server](/integrations/mcp-server) | Shipped in CLI | Stdio tools/resources/prompts plus bounded read-only action list/preview. Registry metadata lives in `server.json`. | +| [MCP server](/integrations/mcp-server) | Shipped in CLI | Stdio tools/resources/prompts plus bounded action preview, approval request/status, and approved-ticket apply. Registry metadata lives in `server.json`. | ## Later Channels diff --git a/site/reference/json-output.md b/site/reference/json-output.md index 1203c26..e0ab038 100644 --- a/site/reference/json-output.md +++ b/site/reference/json-output.md @@ -2,7 +2,7 @@ Nightward emits redacted JSON for automation. -Public JSON objects include `schema_version` unless they predate the v0.1.4 schema contract. Pre-1.0 schema changes should stay additive whenever possible. +Public JSON objects include `schema_version` unless they predate the schema contract documented in `CHANGELOG.md`. Pre-1.0 schema changes should stay additive whenever possible. ## Scan diff --git a/site/reference/output-surfaces.md b/site/reference/output-surfaces.md index ce91a67..e6c86fd 100644 --- a/site/reference/output-surfaces.md +++ b/site/reference/output-surfaces.md @@ -15,7 +15,8 @@ | Fix export | `nw fix export --format markdown` | stdout only | | Actions list/preview | `nw actions list --json`, `nw actions preview --json` | stdout only | | Actions apply | `nw actions apply --confirm` | disclosure-accepted, confirmation-gated provider, policy, schedule, backup, or settings writes | -| MCP server | `nw mcp serve` | stdio JSON-RPC only; read tools plus shared action-registry list/preview, no local writes | +| Approvals | `nw approvals list`, `nw approvals approve `, `nw approvals apply ` | approval ticket state writes; applying approval tickets runs exact actions from the shared Nightward action registry | +| MCP server | `nw mcp serve` | stdio JSON-RPC only; read tools plus approval ticket request/status/apply | | Schedule install/remove | `nw schedule install --confirm`, `nw schedule remove --confirm` | user-level launchd/systemd files only | | Backup snapshot | `nw backup create --confirm` | local snapshot under Nightward state | @@ -26,5 +27,5 @@ Labels used in docs: - `online-capable`: can invoke provider behavior that contacts a network service. - `plan-only`: generates review material without mutating live config. - `confirmed action`: mutates only after explicit preview and confirmation. -- `mcp action preview`: shows shared Nightward action write targets and risks without applying local writes. +- `mcp action approval`: lets MCP request a bounded write, but only CLI/TUI/Raycast can approve the exact one-time ticket. - `future/not shipped`: documented as roadmap, not a current interface. diff --git a/site/reference/support-matrix.md b/site/reference/support-matrix.md index 9838f38..360abe0 100644 --- a/site/reference/support-matrix.md +++ b/site/reference/support-matrix.md @@ -46,7 +46,7 @@ Run `nw adapters list --json` or `nw adapters explain ` for the exact path | --- | --- | --- | | CLI/TUI | Shipped | Local read-only scan/report flows plus explicit output/export files | | [Raycast](/integrations/raycast) | Shipped in-repo | Read-only commands, menu-bar status, clipboard/report-folder actions | -| [MCP server](/integrations/mcp-server) | Shipped | Stdio-only tools/resources/prompts; read-only action list/preview, with writes applied through CLI/TUI/Raycast | +| [MCP server](/integrations/mcp-server) | Shipped | Stdio-only tools/resources/prompts; action list/preview, approval request/status, and controlled application of approved actions through the shared local action registry | | [GitHub Action](/integrations/github-action) | Shipped | CI policy/SARIF output against repository fixtures/workspaces | | [Trunk](/integrations/trunk) | Shipped | Repo-owned plugin definition; users pin to a Nightward tag or SHA | diff --git a/site/roadmap.md b/site/roadmap.md index 80206d7..eb4ac66 100644 --- a/site/roadmap.md +++ b/site/roadmap.md @@ -12,7 +12,7 @@ Nightward’s roadmap is intentionally conservative. The next releases should ma - Explicit provider execution for local providers and online-gated provider runs. - Confirmation-gated provider setup/settings, user-level schedule install/remove, and local portable backup snapshots through the shared action registry. - Static HTML reports with local finding filters, report diffs, report history, and sample fixture assets. -- Read-only stdio MCP server for local AI-client integration. +- Stdio MCP server for local AI-client integration with local-approval-gated action tickets. - Embedded Rust OpenTUI dashboard with colored severity panels, findings, analysis, fix plan, inventory, backup preview, and help sections. - OpenSSF-oriented governance, coverage, DCO, threat model, and release hardening. - Generated CLI, provider, rule, and config reference pages. diff --git a/site/security/threat-model.md b/site/security/threat-model.md index 40e3f36..37111a3 100644 --- a/site/security/threat-model.md +++ b/site/security/threat-model.md @@ -8,6 +8,7 @@ Nightward's primary asset is local AI/devtool state: config files, MCP server de - Config files may be malformed, hostile, huge, symlinked, or privacy-sensitive. - Optional providers may execute local tools and, if explicitly allowed, contact external services. Trivy, Grype, OSV-Scanner, OpenSSF Scorecard, and Socket are online-capable. - GitHub Actions and Trunk integrations treat repository contents and PR input as untrusted. +- MCP clients are agent boundaries: they can request approvals but cannot approve their own writes. - Release automation and npm publishing are privileged publishing boundaries. ## Key mitigations @@ -16,7 +17,7 @@ Nightward's primary asset is local AI/devtool state: config files, MCP server de - Redaction across JSON, SARIF, Markdown, TUI, and Raycast output. - No default network calls. - Explicit online-provider opt-in. -- MCP is read-only: it can list and preview registry actions, but cannot accept disclosure or apply local writes. +- MCP can list/preview registry actions, request local approval tickets, and apply only exact one-time tickets approved outside MCP. It cannot accept the beta responsibility disclosure, approve itself, replay tickets, or mutate arbitrary config. - MCP tool inputs are validated server-side, and explicit workspace/report paths are scoped under `NIGHTWARD_HOME` with no-symlink regular-file/directory checks. - GitHub Actions pinned by full SHA. - Signed release checksums and SBOMs. diff --git a/site/start/run-in-ci.md b/site/start/run-in-ci.md index 93f3283..011d204 100644 --- a/site/start/run-in-ci.md +++ b/site/start/run-in-ci.md @@ -5,7 +5,7 @@ Use workspace mode in CI so Nightward scans the repository checkout, not the run ## GitHub Action ```yaml -- uses: JSONbored/nightward@v0.1.4 +- uses: JSONbored/nightward@v0.1.11 with: mode: sarif output: nightward.sarif diff --git a/site/test/docs-contract.test.mjs b/site/test/docs-contract.test.mjs index c5bd016..6c14443 100644 --- a/site/test/docs-contract.test.mjs +++ b/site/test/docs-contract.test.mjs @@ -16,7 +16,11 @@ test("public docs do not contain stale release placeholders", () => { /Trusted npm publishing/i, /uses:\s*JSONbored\/nightward@v0\.1\.0/i, /trunk .*v0\.1\.0/i, + /v0\.1\.(?:[0-9]|10)\b/i, /semantic_version:\s*0\.1\.0/i, + /MCP is read-only/i, + /MCP cannot apply local writes/i, + /read-only action list\/preview/i, /Static HTML report export before any self-hosted dashboard/i, /Broader provider execution beyond the first explicit local/i, /Rules list\/explain commands and contributor fixture templates/i, @@ -106,14 +110,21 @@ test("MCP docs list every runtime tool, resource, and prompt", { timeout: 60000 const missing = [...tools, ...resources, ...prompts].filter((value) => !docs.includes(value)); assert.deepEqual(missing, []); - assert.equal(tools.length, 14); - assert.equal(resources.length, 8); + assert.equal(tools.length, 17); + assert.equal(resources.length, 9); assert.equal(prompts.length, 5); } finally { rmSync(home, { recursive: true, force: true }); } }); +test("generated CLI reference includes approval commands", () => { + const cliReference = readFileSync(join(repoRoot, "site/reference/cli.md"), "utf8"); + assert.match(cliReference, /nightward approvals list --json/); + assert.match(cliReference, /nightward approvals approve /); + assert.match(cliReference, /nightward approvals apply /); +}); + function gitTrackedDocs() { return execFileSync( "git", diff --git a/site/use/provider-execution.md b/site/use/provider-execution.md index cf013c5..62288ac 100644 --- a/site/use/provider-execution.md +++ b/site/use/provider-execution.md @@ -18,7 +18,7 @@ nw analyze --workspace . --with gitleaks,trufflehog,semgrep,syft --json Nightward discovers providers on `PATH`, marks unselected optional providers as `skipped`, runs bounded commands only when selected, parses supported JSON shapes, and redacts provider-derived evidence before emitting JSON, SARIF, TUI, Raycast, MCP, policy, badge, or HTML output. Timeout and output-cap failures are provider warnings, not clean results. -Known provider installs are available through the shared action registry and require disclosure acceptance plus `--confirm`, TUI confirmation, or Raycast confirmation: +Known provider installs are available through the shared action registry and require disclosure acceptance plus `--confirm`, TUI confirmation, Raycast confirmation, or an MCP approval ticket that was approved locally: ```sh nw providers install gitleaks --confirm @@ -52,3 +52,13 @@ The Raycast Provider Doctor mirrors this model: - show install commands and upstream docs when a provider is missing. Raycast provider actions use the same action registry and confirmation prompts as the CLI/TUI. + +## MCP Provider Actions + +MCP clients can preview provider install/enable actions and request approval, but cannot self-confirm package-manager execution: + +```json +{ "action_id": "provider.install.gitleaks", "client": "my-ai-client" } +``` + +The request response includes an `approval_id`, which is separate from `action_id`; `action_id` names the registry action, while `approval_id` names the one-time approval ticket. You can also find the ticket in the TUI, Raycast approvals list, or `nw approvals list --json`. Approve the exact ticket in the TUI, Raycast, or via CLI: `nw approvals approve `. The MCP client can then apply only that approved ticket once.