Skip to content

Commit 3a00ecb

Browse files
authored
feat(mcp): add local approval queue for actions (#47)
Adds a Nightward-owned approval queue for MCP action requests, local CLI/TUI/Raycast approval surfaces, and docs/site parity for the secure write flow. Also keeps legacy direct MCP apply blocked and expands tests/media around the approval UX. Signed-off-by: JSONbored <49853598+JSONbored@users.noreply.github.com>
1 parent e4a506e commit 3a00ecb

60 files changed

Lines changed: 2038 additions & 110 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.nightward.example.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ sarif:
1515
tool_name: Nightward
1616
category: nightward
1717
information_uri: https://github.com/JSONbored/nightward
18-
semantic_version: 0.1.4
18+
semantic_version: 0.1.11

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ Nightward is read-only by default, but it can run explicit, confirmation-gated l
2121

2222
[![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)
2323

24-
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.
24+
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.
2525

2626
## At A Glance
2727

2828
| Surface | What it does | Default write behavior |
2929
| --- | --- | --- |
3030
| TUI | Dashboard, inventory, findings, analysis, fix plan, backup preview, action queue | Read-only until a confirmed action is applied |
3131
| CLI | Scriptable scan, doctor, policy, SARIF, snapshot, schedule, backup, and action commands | Read-only unless explicit output/export paths or `--confirm` actions are requested |
32-
| MCP server | Stdio tools/resources/prompts for AI clients | Read-only; can list/preview actions, but writes must be applied in CLI/TUI/Raycast |
32+
| MCP server | Stdio tools/resources/prompts for AI clients | Can request local action approvals; applies only the locally approved action once |
3333
| Raycast | macOS companion commands plus confirmed Nightward Actions | Clipboard/report-folder actions plus confirmation-gated writes |
3434
| GitHub Action | Workspace policy and SARIF checks | Writes only requested CI outputs |
3535
| 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
296296

297297
`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.
298298

299-
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.
299+
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.
300300

301301
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.
302302

@@ -326,14 +326,15 @@ The default `nightward` / `nw` command opens the TUI:
326326
- Fix Plan: safe/review/blocked remediation groups
327327
- Backup Plan: private-dotfiles dry-run preview
328328
- Actions: confirmation-gated provider, policy, schedule, backup, cleanup, and setup actions
329+
- MCP Approvals: approve or deny exact MCP-requested action tickets
329330

330331
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`.
331332

332333
Keyboard shortcuts:
333334

334335
- `1`-`8`: switch sections
335336
- arrow keys or `h`/`j`/`k`/`l`: navigate
336-
- `enter`: confirm selected action in the Actions view
337+
- `enter`: confirm selected action in the Actions view or review a pending MCP approval
337338
- `/`: search findings
338339
- `s`: cycle severity
339340
- `x`: clear filters
@@ -356,14 +357,14 @@ Nightward can expose local context and bounded Nightward action workflows to MCP
356357
}
357358
```
358359

359-
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.
360+
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.
360361

361362
## GitHub Action
362363

363364
Nightward can run as a local GitHub Action in scan, policy, or SARIF mode:
364365

365366
```yaml
366-
- uses: JSONbored/nightward@v0.1.4
367+
- uses: JSONbored/nightward@v0.1.11
367368
with:
368369
mode: sarif
369370
output: nightward.sarif
@@ -388,7 +389,7 @@ See [docs/website.md](docs/website.md) for the page map, custom-domain notes, an
388389
Nightward includes an in-repo `plugin.yaml` for Trunk Check. Import a pinned release tag and enable repo/workspace policy scans:
389390

390391
```sh
391-
trunk plugins add --id nightward https://github.com/JSONbored/nightward v0.1.4
392+
trunk plugins add --id nightward https://github.com/JSONbored/nightward v0.1.11
392393
trunk check enable nightward-policy
393394
```
394395

@@ -419,7 +420,7 @@ Commands:
419420
- `Export Nightward Analysis`
420421
- `Open Nightward Reports`
421422

422-
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.
423+
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.
423424

424425
See [docs/raycast-extension.md](docs/raycast-extension.md) for preferences, validation, and read-only boundaries.
425426

crates/nightward-cli/src/cli.rs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ use nightward_core::inventory::{
77
};
88
use nightward_core::policy::{self, PolicyConfig};
99
use nightward_core::{
10-
actions, backupplan, mcpserver, providers, reportdiff, reporthtml, rules, schedule, snapshot,
11-
state,
10+
actions, approvals, backupplan, mcpserver, providers, reportdiff, reporthtml, rules, schedule,
11+
snapshot, state,
1212
};
1313
use serde::Serialize;
1414
use std::env;
@@ -42,6 +42,7 @@ pub fn run() -> Result<()> {
4242
"backup" => cmd_backup(&args),
4343
"schedule" => cmd_schedule(&args),
4444
"actions" => cmd_actions(&args),
45+
"approvals" => cmd_approvals(&args),
4546
"disclosure" => cmd_disclosure(&args),
4647
"help" | "--help" | "-h" => {
4748
print_help();
@@ -547,6 +548,52 @@ fn cmd_actions(args: &[String]) -> Result<()> {
547548
}
548549
}
549550

551+
fn cmd_approvals(args: &[String]) -> Result<()> {
552+
let home = home_dir_from_env();
553+
match args.first().map(String::as_str) {
554+
Some("list") | None => print_json(&approvals::list(&home)?),
555+
Some("show") | Some("status") => {
556+
let id = args.get(1).ok_or_else(|| anyhow!("approval id required"))?;
557+
print_json(&approvals::status(&home, id)?)
558+
}
559+
Some("request") => {
560+
let action_id = args.get(1).ok_or_else(|| anyhow!("action id required"))?;
561+
print_json(&approvals::request(
562+
&home,
563+
approvals::ApprovalRequestOptions {
564+
action_id: action_id.to_string(),
565+
action_options: approval_options_from_args(action_id, args),
566+
requested_by: value_after(args, "--client")
567+
.unwrap_or("nightward-cli")
568+
.to_string(),
569+
},
570+
)?)
571+
}
572+
Some("approve") => {
573+
let id = args.get(1).ok_or_else(|| anyhow!("approval id required"))?;
574+
print_json(&approvals::approve(
575+
&home,
576+
id,
577+
value_after(args, "--reason").unwrap_or("approved locally"),
578+
)?)
579+
}
580+
Some("deny") => {
581+
let id = args.get(1).ok_or_else(|| anyhow!("approval id required"))?;
582+
print_json(&approvals::deny(
583+
&home,
584+
id,
585+
value_after(args, "--reason").unwrap_or("denied locally"),
586+
)?)
587+
}
588+
Some("apply") => {
589+
let id = args.get(1).ok_or_else(|| anyhow!("approval id required"))?;
590+
print_json(&approvals::apply_approved(&home, id)?)
591+
}
592+
Some("cleanup") => print_json(&approvals::cleanup(&home)?),
593+
_ => Err(anyhow!("unknown approvals command")),
594+
}
595+
}
596+
550597
fn cmd_disclosure(args: &[String]) -> Result<()> {
551598
match args.first().map(String::as_str) {
552599
Some("status") | None => print_json(&state::disclosure_status(home_dir_from_env())),
@@ -563,6 +610,26 @@ fn cmd_disclosure(args: &[String]) -> Result<()> {
563610
}
564611
}
565612

613+
fn approval_options_from_args(
614+
action_id: &str,
615+
args: &[String],
616+
) -> approvals::ApprovalActionOptions {
617+
approvals::ApprovalActionOptions {
618+
executable: if action_id == "schedule.install" {
619+
current_executable()
620+
} else {
621+
String::new()
622+
},
623+
policy_path: value_after(args, "--policy")
624+
.or_else(|| value_after(args, "--config"))
625+
.unwrap_or("")
626+
.to_string(),
627+
finding_id: value_after(args, "--finding").unwrap_or("").to_string(),
628+
rule: value_after(args, "--rule").unwrap_or("").to_string(),
629+
reason: value_after(args, "--reason").unwrap_or("").to_string(),
630+
}
631+
}
632+
566633
fn selector(args: &[String]) -> Selector {
567634
Selector {
568635
all: has(args, "--all") || (!has(args, "--finding") && !has(args, "--rule")),
@@ -676,6 +743,9 @@ fn option_takes_value(option: &str) -> bool {
676743
| "--to"
677744
| "--finding"
678745
| "--rule"
746+
| "--reason"
747+
| "--policy"
748+
| "--client"
679749
| "--format"
680750
| "--input"
681751
)
@@ -697,7 +767,7 @@ fn version() -> &'static str {
697767

698768
fn print_help() {
699769
println!(
700-
"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 <id> --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."
770+
"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 <id> --reason \"reviewed\" --confirm\n nightward approvals list --json\n nightward approvals approve <approval-id> --reason \"reviewed\"\n nightward approvals apply <approval-id>\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."
701771
);
702772
}
703773

0 commit comments

Comments
 (0)