diff --git a/src/commands/firewall/helpers.go b/src/commands/firewall/helpers.go index 97f4257..d7e0bde 100644 --- a/src/commands/firewall/helpers.go +++ b/src/commands/firewall/helpers.go @@ -349,7 +349,50 @@ func (c *CheckRemoteTrust) Run() error { return nil } -// PromptTrust asks whether to trust a module after a self-update. +// promptTimeoutExit is returned when the trust prompt gets no answer +// (toast timeout or no console). Distinct from 1 (explicit decline). +const promptTimeoutExit = 2 + +// afterTrustHook re-signs launchers and reshims after TrustedModules is updated. +// Windows sets this in init; other platforms leave it as a no-op. +var afterTrustHook = func(entries []string) {} + +func launcherStem(entry string) string { + e := strings.TrimSpace(entry) + if e == "" { + return "" + } + upper := strings.ToUpper(e) + if upper == "ALL" || strings.HasPrefix(upper, "NOT ") || strings.HasPrefix(e, "!") { + return "" + } + if strings.Contains(e, "://") || strings.ContainsAny(e, "*?") { + return "" + } + if i := strings.LastIndexAny(e, `/\`); i >= 0 { + e = e[i+1:] + } + return strings.TrimSpace(e) +} + +// trustNoAnswerMessage is the console and silent-toast text when a prompt +// times out or there is no console. +func trustNoAnswerMessage(name, why string) string { + name = strings.TrimSpace(name) + why = strings.TrimSpace(why) + if why == "" { + why = "no answer" + } + return fmt.Sprintf("No trust answer for '%s' (%s). NVM moved on. If you trust this: nvm firewall trust module %s", name, why, name) +} + +func notifyTrustNoAnswer(name, why string) { + msg := trustNoAnswerMessage(name, why) + fmt.Fprintln(os.Stderr, msg) + _ = notify.Send(settings.AppId, "NVM Firewall", msg) +} + +// PromptTrust asks whether to trust a module on a blocked launch. // Foreground console: interactive y/N only (no toast). // Background: native toast with Trust / Cancel (protocol actions); wait for answer. type PromptTrust struct { @@ -403,13 +446,13 @@ func logUntrustedModuleChanged(module, outcome, handler, via string) { } func promptTrustConsole(name string) error { - msg := fmt.Sprintf("Untrusted module '%s' changed after running. Do you trust this module?", name) + msg := fmt.Sprintf("Untrusted module '%s' changed. Do you trust this module?", name) fmt.Fprintf(os.Stderr, "NVM Firewall: %s [y/N]: ", msg) reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { - fmt.Fprintf(os.Stderr, "%s is not trusted\n", name) - os.Exit(1) + notifyTrustNoAnswer(name, "no console") + os.Exit(promptTimeoutExit) return nil } line = strings.TrimSpace(line) @@ -436,7 +479,7 @@ func promptTrustToast(name string) error { } defer os.Remove(rspPath) - msg := fmt.Sprintf("Untrusted module '%s' changed after running. Do you trust this module?", name) + msg := fmt.Sprintf("Untrusted module '%s' changed. Do you trust this module?", name) _ = notify.Send( settings.AppId, "NVM Firewall", @@ -467,8 +510,8 @@ func promptTrustToast(name string) error { time.Sleep(400 * time.Millisecond) } - fmt.Fprintf(os.Stderr, "Trust prompt timed out for %s; reshim skipped.\n", name) - os.Exit(1) + notifyTrustNoAnswer(name, "toast timeout") + os.Exit(promptTimeoutExit) return nil } diff --git a/src/commands/firewall/root.go b/src/commands/firewall/root.go index eacb74b..75f7004 100644 --- a/src/commands/firewall/root.go +++ b/src/commands/firewall/root.go @@ -96,12 +96,12 @@ type DenyModule struct { type TrustModule struct { List TrustModuleList `cmd:"list" aliases:"ls" help:"List trusted modules."` - Add TrustModuleAdd `cmd:"add" default:"withargs" help:"Add modules to TrustedModules (self-update auto-reshim allow list)."` + Add TrustModuleAdd `cmd:"add" default:"withargs" help:"Add modules to TrustedModules and re-sign their launchers."` } type TrustModuleAdd struct { Machine bool `name:"machine" help:"Trust modules for the entire machine."` - Entries []string `arg:"" name:"entry" help:"Module patterns to trust for auto-reshim."` + Entries []string `arg:"" name:"entry" help:"Module patterns to trust. Re-signs matching launchers; a separate nvm reshim is not required."` } type TrustModuleList struct { @@ -573,7 +573,11 @@ func (t *TrustModuleAdd) Run() error { if len(t.Entries) == 0 { return fmt.Errorf("entry required (or use: nvm firewall trust module list)") } - return appendTrusted(t.Entries, t.Machine) + if err := appendTrusted(t.Entries, t.Machine); err != nil { + return err + } + afterTrustHook(t.Entries) + return nil } func (t *TrustModuleList) Run() error { diff --git a/src/commands/firewall/trust_after_windows.go b/src/commands/firewall/trust_after_windows.go new file mode 100644 index 0000000..bfafda8 --- /dev/null +++ b/src/commands/firewall/trust_after_windows.go @@ -0,0 +1,85 @@ +//go:build windows + +package firewall + +import ( + "common/settings" + "common/verifycache" + "fmt" + "os" + "path/filepath" + "strings" + + "nvm/bootstrap" +) + +func init() { + afterTrustHook = resignTrustedLaunchersAndReshim +} + +func resignTrustedLaunchersAndReshim(entries []string) { + if err := resignTrustedLaunchers(entries); err != nil { + fmt.Fprintf(os.Stderr, "NVM Firewall: trusted, but could not re-sign launchers: %v\n", err) + } + if err := bootstrap.RunReshim("--silent"); err != nil { + fmt.Fprintf(os.Stderr, "NVM Firewall: trusted, but reshim failed: %v\nRun nvm reshim after the files unlock.\n", err) + } +} + +func resignTrustedLaunchers(entries []string) error { + var first error + for _, entry := range entries { + name := launcherStem(entry) + if name == "" { + continue + } + for _, path := range findModuleLaunchers(name) { + if err := verifycache.SignScript(path); err != nil && first == nil { + first = err + } + } + } + return first +} + +func findModuleLaunchers(name string) []string { + seen := map[string]struct{}{} + var out []string + add := func(path string) { + path = filepath.Clean(path) + key := strings.ToLower(path) + if _, ok := seen[key]; ok { + return + } + if _, err := os.Stat(path); err != nil { + return + } + seen[key] = struct{}{} + out = append(out, path) + } + + root := settings.Expand(settings.Global().Root) + if root != "" && root != "." { + entries, err := os.ReadDir(root) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + n := entry.Name() + if len(n) < 2 || (n[0] != 'v' && n[0] != 'V') { + continue + } + dir := filepath.Join(root, n) + add(filepath.Join(dir, name+".cmd")) + add(filepath.Join(dir, name+".bat")) + } + } + } + + if appdata, err := os.UserConfigDir(); err == nil { + add(filepath.Join(appdata, "npm", name+".cmd")) + add(filepath.Join(appdata, "npm", name+".bat")) + } + return out +} diff --git a/src/commands/firewall/trust_test.go b/src/commands/firewall/trust_test.go index 697d64f..ce3d890 100644 --- a/src/commands/firewall/trust_test.go +++ b/src/commands/firewall/trust_test.go @@ -37,6 +37,34 @@ func TestEntryMatchesRemove(t *testing.T) { } } +func TestTrustNoAnswerMessage(t *testing.T) { + got := trustNoAnswerMessage("opencode", "toast timeout") + want := "No trust answer for 'opencode' (toast timeout). NVM moved on. If you trust this: nvm firewall trust module opencode" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestLauncherStem(t *testing.T) { + tests := []struct { + in, want string + }{ + {"opencode", "opencode"}, + {" opencode ", "opencode"}, + {"ALL", ""}, + {"NOT ALL", ""}, + {"!opencode", ""}, + {"https://policy.example/trust", ""}, + {"@org/*", ""}, + {`C:\tools\opencode`, "opencode"}, + } + for _, tt := range tests { + if got := launcherStem(tt.in); got != tt.want { + t.Fatalf("launcherStem(%q)=%q, want %q", tt.in, got, tt.want) + } + } +} + func TestStripNegation(t *testing.T) { if got := stripNegation("NOT eslint"); got != "eslint" { t.Fatalf("got %q", got)