diff --git a/cmd/cmd_reg_test.go b/cmd/cmd_reg_test.go index e685d25a..0f88911b 100644 --- a/cmd/cmd_reg_test.go +++ b/cmd/cmd_reg_test.go @@ -255,7 +255,7 @@ func TestUnlockVaultWithEnvVar(t *testing.T) { vaultFlag.Changed = false } - _, err := unlockVault(vaultDir, false) + _, err := cli.UnlockVault(vaultDir, false) if err != nil { t.Errorf("unlockVault with env var failed: %v", err) } @@ -285,7 +285,7 @@ func TestUnlockVaultNoPassphrase(t *testing.T) { vaultFlag.Changed = false } - _, err := unlockVault(vaultDir, false) + _, err := cli.UnlockVault(vaultDir, false) if err == nil { t.Error("expected error when no passphrase available") } @@ -317,7 +317,7 @@ func TestUnlockVaultWrongPassphrase(t *testing.T) { vaultFlag.Changed = false } - _, err := unlockVault(vaultDir, false) + _, err := cli.UnlockVault(vaultDir, false) if err == nil { t.Error("expected error for wrong passphrase") } @@ -336,12 +336,12 @@ func TestVaultPathWithEnvVarOpenPassVault(t *testing.T) { defer func() { vault = origVault }() vault = "~/should-not-be-used" - path, err := vaultPath() + path, err := cli.VaultPath() if err != nil { - t.Fatalf("vaultPath() error = %v", err) + t.Fatalf("cli.VaultPath() error = %v", err) } if path != "/test/vault" { - t.Errorf("vaultPath() = %q, want %q", path, "/test/vault") + t.Errorf("cli.VaultPath() = %q, want %q", path, "/test/vault") } } @@ -371,7 +371,7 @@ func TestUnlockVaultSavesToKeyring(t *testing.T) { vaultFlag.Changed = false } - v, err := unlockVault(vaultDir, false) + v, err := cli.UnlockVault(vaultDir, false) if err != nil { t.Fatalf("unlockVault failed: %v", err) } diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 963e037e..5cd19375 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -37,13 +37,13 @@ func TestExpandVaultDir(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - got, err := expandVaultDir(tt.input) + got, err := cli.ExpandVaultDir(tt.input) if (err != nil) != tt.wantErr { - t.Errorf("expandVaultDir() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("cli.ExpandVaultDir() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.expected { - t.Errorf("expandVaultDir() = %q, want %q", got, tt.expected) + t.Errorf("cli.ExpandVaultDir() = %q, want %q", got, tt.expected) } }) } @@ -147,12 +147,12 @@ func TestVaultPathWithTilde(t *testing.T) { _ = os.Setenv("HOME", "/custom/home") cli.Vault = "~/my-vault" - got, _ := vaultPath() + got, _ := cli.VaultPath() home, _ := os.UserHomeDir() expected := filepath.Join(home, "my-vault") if got != expected { - t.Errorf("vaultPath() = %q, want %q", got, expected) + t.Errorf("cli.VaultPath() = %q, want %q", got, expected) } } @@ -161,21 +161,21 @@ func TestVaultPathWithAbsolute(t *testing.T) { t.Skip("skipping on windows: path format differs") } cli.Vault = "/absolute/path" - got, _ := vaultPath() + got, _ := cli.VaultPath() if got != "/absolute/path" { - t.Errorf("vaultPath() = %q, want %q", got, "/absolute/path") + t.Errorf("cli.VaultPath() = %q, want %q", got, "/absolute/path") } } func TestVaultPathWithTildeOnly(t *testing.T) { home, _ := os.UserHomeDir() cli.Vault = "~" - got, _ := vaultPath() + got, _ := cli.VaultPath() expected := home if got != expected { - t.Errorf("vaultPath() = %q, want %q", got, expected) + t.Errorf("cli.VaultPath() = %q, want %q", got, expected) } } @@ -207,7 +207,7 @@ func TestUnlockVaultLocked(t *testing.T) { _ = os.Setenv("OPENPASS_VAULT", vaultDir) defer func() { _ = os.Unsetenv("OPENPASS_VAULT") }() - _, err := unlockVault(vaultDir, false) + _, err := cli.UnlockVault(vaultDir, false) if err == nil { t.Error("expected error for locked vault") } diff --git a/cmd/device.go b/cmd/device.go index 8db6e5ec..cfb82d53 100644 --- a/cmd/device.go +++ b/cmd/device.go @@ -54,12 +54,12 @@ you must run 'openpass device accept ' to re-encrypt all entries for the new device.`, Example: ` openpass device pair`, RunE: func(cmd *cobra.Command, args []string) error { - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } - v, err := unlockVault(vaultDir, true) + v, err := cli.UnlockVault(vaultDir, true) if err != nil { return err } @@ -114,7 +114,7 @@ to re-encrypt all entries for this new device.`, remoteURL := args[0] token := strings.TrimSpace(args[1]) - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } @@ -144,7 +144,7 @@ to re-encrypt all entries for this new device.`, fmt.Fprintf(os.Stderr, "Pairing with device (public key: %s)\n", truncatePubkey(pf.PublicKey)) - passphrase, err := readHiddenInput("Enter passphrase for this device (minimum 12 characters): ", nil) + passphrase, err := cli.ReadHiddenInput("Enter passphrase for this device (minimum 12 characters): ", nil) if err != nil { return fmt.Errorf("read passphrase: %w", err) } @@ -243,12 +243,12 @@ can decrypt them.`, RunE: func(cmd *cobra.Command, args []string) error { token := strings.TrimSpace(args[0]) - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } - v, err := unlockVault(vaultDir, true) + v, err := cli.UnlockVault(vaultDir, true) if err != nil { return err } @@ -306,7 +306,7 @@ any registered device (unmanaged recipients).`, Example: ` openpass device list openpass device list --output json`, RunE: func(cmd *cobra.Command, args []string) error { - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } @@ -426,7 +426,7 @@ request so the first device can accept it.`, raw := strings.TrimSpace(args[0]) - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } @@ -448,7 +448,7 @@ request so the first device can accept it.`, return fmt.Errorf("invalid public key in pairing data: expected age1... format") } - passphrase, err := readHiddenInput("Enter passphrase for this device (minimum 12 characters): ", nil) + passphrase, err := cli.ReadHiddenInput("Enter passphrase for this device (minimum 12 characters): ", nil) if err != nil { return fmt.Errorf("read passphrase: %w", err) } @@ -547,7 +547,7 @@ access to all vault entries.`, RunE: func(cmd *cobra.Command, args []string) error { deviceName := args[0] - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } @@ -558,7 +558,7 @@ access to all vault entries.`, errorspkg.ErrVaultNotInitialized) } - v, err := unlockVault(vaultDir, true) + v, err := cli.UnlockVault(vaultDir, true) if err != nil { return err } diff --git a/cmd/dynamic.go b/cmd/dynamic.go index 9024df3b..5d116f4c 100644 --- a/cmd/dynamic.go +++ b/cmd/dynamic.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + cli "github.com/danieljustus/OpenPass/internal/cli" "github.com/danieljustus/OpenPass/internal/dynamicsecret" vaultsvc "github.com/danieljustus/OpenPass/internal/vaultsvc" ) @@ -41,7 +42,7 @@ var dynamicGenerateCmd = &cobra.Command{ # Generate AWS STS credentials for a specific role openpass dynamic generate --engine aws-sts --role arn:aws:iam::123456789012:role/MyRole --ttl 30m`, RunE: func(cmd *cobra.Command, args []string) error { - return withVault(func(svc vaultsvc.Service) error { + return cli.WithVault(func(svc vaultsvc.Service) error { ctx := context.Background() mgr := dynamicsecret.NewManager(svc) diff --git a/cmd/generate.go b/cmd/generate.go index 8f5fb35a..f345b343 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -41,7 +41,7 @@ var generateCmd = &cobra.Command{ } if genStore != "" { - return withVaultRaw(func(v *vaultpkg.Vault) error { + return cli.WithVaultRaw(func(v *vaultpkg.Vault) error { entryPath := vaultpkg.EntryPath(v, genStore) if _, err := vaultpkg.ReadEntry(v.Dir, genStore, v.Identity); err == nil { if _, err := vaultpkg.MergeEntryWithRecipients(v.Dir, genStore, map[string]any{"password": password}, v.Identity); err != nil { diff --git a/cmd/git.go b/cmd/git.go index c0749324..211a9d9f 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" + cli "github.com/danieljustus/OpenPass/internal/cli" errorspkg "github.com/danieljustus/OpenPass/internal/errors" "github.com/danieljustus/OpenPass/internal/git" vaultpkg "github.com/danieljustus/OpenPass/internal/vault" @@ -24,7 +25,7 @@ var gitCmd = &cobra.Command{ action := args[0] if action == "log" { - return withVaultRaw(func(v *vaultpkg.Vault) error { + return cli.WithVaultRaw(func(v *vaultpkg.Vault) error { path := "" if len(args) > 1 { path = args[1] @@ -41,7 +42,7 @@ var gitCmd = &cobra.Command{ }) } - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } diff --git a/cmd/policy.go b/cmd/policy.go index 25f443ec..8552e415 100644 --- a/cmd/policy.go +++ b/cmd/policy.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + cli "github.com/danieljustus/OpenPass/internal/cli" "github.com/danieljustus/OpenPass/internal/policy" ) @@ -105,7 +106,7 @@ Example: return err } - vaultDir, _ := vaultPath() + vaultDir, _ := cli.VaultPath() policiesDir := filepath.Join(vaultDir, "policies") _ = os.MkdirAll(policiesDir, 0750) @@ -133,7 +134,7 @@ var policyListCmd = &cobra.Command{ requiresVaultAnnotation: "false", }, RunE: func(cmd *cobra.Command, args []string) error { - vaultDir, _ := vaultPath() + vaultDir, _ := cli.VaultPath() policiesDir := filepath.Join(vaultDir, "policies") entries, err := os.ReadDir(policiesDir) @@ -169,7 +170,7 @@ var policyRemoveCmd = &cobra.Command{ requiresVaultAnnotation: "false", }, RunE: func(cmd *cobra.Command, args []string) error { - vaultDir, _ := vaultPath() + vaultDir, _ := cli.VaultPath() policiesDir := filepath.Join(vaultDir, "policies") policyPath := filepath.Join(policiesDir, args[0]) diff --git a/cmd/recipients.go b/cmd/recipients.go index 0d55c25f..1b93b59d 100644 --- a/cmd/recipients.go +++ b/cmd/recipients.go @@ -35,7 +35,7 @@ var recipientsListCmd = &cobra.Command{ Long: `List all recipients from the recipients.txt file.`, Example: ` openpass recipients list`, RunE: func(cmd *cobra.Command, args []string) error { - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } @@ -98,7 +98,7 @@ Once added, all new entries will be encrypted for this recipient.`, Example: ` openpass recipients add age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return withVaultRaw(func(v *vaultpkg.Vault) error { + return cli.WithVaultRaw(func(v *vaultpkg.Vault) error { recipient := args[0] rm := vaultpkg.NewRecipientsManager(v.Dir) @@ -130,7 +130,7 @@ Use --yes to skip confirmation (useful for scripts).`, Example: ` openpass recipients remove age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return withVaultRaw(func(v *vaultpkg.Vault) error { + return cli.WithVaultRaw(func(v *vaultpkg.Vault) error { recipient := args[0] confirmed, err := confirmInteractive(fmt.Sprintf("Remove recipient %s", recipient), confirmRemove) diff --git a/cmd/remote.go b/cmd/remote.go index 5a884920..f48773ae 100644 --- a/cmd/remote.go +++ b/cmd/remote.go @@ -92,7 +92,7 @@ func runRemoteInit(cmd *cobra.Command, args []string) error { return errorspkg.NewCLIError(errorspkg.ExitGeneralError, "ssh-target must not be empty", nil) } - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } @@ -157,7 +157,7 @@ func runRemoteInit(cmd *cobra.Command, args []string) error { } func runRemoteStatus(cmd *cobra.Command, args []string) error { - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index aab3ed2c..369a96f7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,18 +30,6 @@ var ( var rootCmd = cli.RootCmd -// Aliases for functions that moved to internal/cli/ but are still used by staying cmd/ files -var ( - vaultPath = cli.VaultPath - unlockVault = cli.UnlockVault - readHiddenInput = cli.ReadHiddenInput - expandVaultDir = cli.ExpandVaultDir - defaultSessionTTL = cli.DefaultSessionTTL - withVault = cli.WithVault - withVaultRaw = cli.WithVaultRaw - Version = cli.AppVersion -) - func Execute() { cli.Execute() } diff --git a/cmd/root_test.go b/cmd/root_test.go index 96c8cedc..23a049cf 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -93,9 +93,9 @@ func TestVaultPathErrorWhenHomeDirNotAvailable(t *testing.T) { cli.Vault = "~/.openpass" // Call vaultPath and verify it returns an error - path, err := vaultPath() + path, err := cli.VaultPath() if err == nil { - t.Errorf("vaultPath() should return error when home directory is unavailable, got path: %s", path) + t.Errorf("cli.VaultPath() should return error when home directory is unavailable, got path: %s", path) } } @@ -130,12 +130,12 @@ func TestVaultPathSuccessWithTildeExpansion(t *testing.T) { cli.Vault = "~/.openpass" // Call vaultPath and verify it returns the expanded path - path, err := vaultPath() + path, err := cli.VaultPath() if err != nil { - t.Errorf("vaultPath() should not return error, got: %v", err) + t.Errorf("cli.VaultPath() should not return error, got: %v", err) } if path != "/Users/testuser/.openpass" { - t.Errorf("vaultPath() = %s, want /Users/testuser/.openpass", path) + t.Errorf("cli.VaultPath() = %s, want /Users/testuser/.openpass", path) } } @@ -170,12 +170,12 @@ func TestVaultPathSuccessWithoutTilde(t *testing.T) { cli.Vault = "/custom/vault/path" // Call vaultPath and verify it returns the path as-is - path, err := vaultPath() + path, err := cli.VaultPath() if err != nil { - t.Errorf("vaultPath() should not return error, got: %v", err) + t.Errorf("cli.VaultPath() should not return error, got: %v", err) } if path != "/custom/vault/path" { - t.Errorf("vaultPath() = %s, want /custom/vault/path", path) + t.Errorf("cli.VaultPath() = %s, want /custom/vault/path", path) } } @@ -191,12 +191,12 @@ func TestVaultPathUsesEnvWhenFlagUnchanged(t *testing.T) { cli.Vault = "~/.openpass" _ = os.Setenv("OPENPASS_VAULT", "/env/vault") - path, err := vaultPath() + path, err := cli.VaultPath() if err != nil { - t.Fatalf("vaultPath() unexpected error = %v", err) + t.Fatalf("cli.VaultPath() unexpected error = %v", err) } if path != "/env/vault" { - t.Fatalf("vaultPath() = %s, want /env/vault", path) + t.Fatalf("cli.VaultPath() = %s, want /env/vault", path) } } @@ -216,12 +216,12 @@ func TestVaultPathPrefersExplicitFlagOverEnv(t *testing.T) { vaultFlag.Changed = true cli.Vault = "/flag/vault" - path, err := vaultPath() + path, err := cli.VaultPath() if err != nil { - t.Fatalf("vaultPath() unexpected error = %v", err) + t.Fatalf("cli.VaultPath() unexpected error = %v", err) } if path != "/flag/vault" { - t.Fatalf("vaultPath() = %s, want /flag/vault", path) + t.Fatalf("cli.VaultPath() = %s, want /flag/vault", path) } } @@ -290,7 +290,7 @@ func TestUnlockVault_InteractivePrompt(t *testing.T) { restore := pipeStdin(t, string(passphrase)+"\n") defer restore() - v, err := unlockVault(vaultDir, true) + v, err := cli.UnlockVault(vaultDir, true) if err != nil { t.Fatalf("unlockVault interactive: %v", err) } @@ -323,7 +323,7 @@ func TestUnlockVault_SavesPassphrase(t *testing.T) { restore := pipeStdin(t, string(passphrase)+"\n") defer restore() - v, err := unlockVault(vaultDir, true) + v, err := cli.UnlockVault(vaultDir, true) if err != nil { t.Fatalf("unlockVault interactive: %v", err) } @@ -364,7 +364,7 @@ func TestUnlockVault_UsesConfiguredSessionTimeout(t *testing.T) { restoreStdin := pipeStdin(t, string(passphrase)+"\n") defer restoreStdin() - v, err := unlockVault(vaultDir, true) + v, err := cli.UnlockVault(vaultDir, true) if err != nil { t.Fatalf("unlockVault interactive: %v", err) } @@ -411,7 +411,7 @@ func TestUnlockCommand_UsesConfiguredSessionTimeoutByDefault(t *testing.T) { checkFlag := auth.AuthUnlockCmd.Flags().Lookup("check") origCheckChanged := checkFlag.Changed origCheckValue := checkFlag.Value.String() - _ = ttlFlag.Value.Set(defaultSessionTTL().String()) + _ = ttlFlag.Value.Set(cli.DefaultSessionTTL().String()) ttlFlag.Changed = false _ = checkFlag.Value.Set("false") checkFlag.Changed = false @@ -465,7 +465,7 @@ func TestUnlockVault_InteractivePrompt_ReadError(t *testing.T) { _ = r.Close() }() - _, err := unlockVault(vaultDir, true) + _, err := cli.UnlockVault(vaultDir, true) if err == nil { t.Fatal("unlockVault should fail with empty stdin") } @@ -489,7 +489,7 @@ func TestUnlockVault_NonInteractive_NoSession(t *testing.T) { } }() - _, err := unlockVault(vaultDir, false) + _, err := cli.UnlockVault(vaultDir, false) if err == nil { t.Fatal("unlockVault should fail when interactive=false and no passphrase available") } @@ -506,7 +506,7 @@ func TestUnlockVault_EnvVar(t *testing.T) { _ = os.Setenv("OPENPASS_PASSPHRASE", string(passphrase)) defer func() { _ = os.Unsetenv("OPENPASS_PASSPHRASE") }() - v, err := unlockVault(vaultDir, false) + v, err := cli.UnlockVault(vaultDir, false) if err != nil { t.Fatalf("unlockVault with env var: %v", err) } @@ -524,7 +524,7 @@ func TestUnlockVault_WrongPassphrase(t *testing.T) { _ = os.Setenv("OPENPASS_PASSPHRASE", "wrong-passphrase") defer func() { _ = os.Unsetenv("OPENPASS_PASSPHRASE") }() - _, err := unlockVault(vaultDir, false) + _, err := cli.UnlockVault(vaultDir, false) if err == nil { t.Fatal("unlockVault should fail with wrong passphrase") } @@ -557,7 +557,7 @@ func TestUnlockVault_HiddenInput(t *testing.T) { restore := pipeStdin(t, string(passphrase)+"\n") defer restore() - v, err := unlockVault(vaultDir, true) + v, err := cli.UnlockVault(vaultDir, true) if err != nil { t.Fatalf("unlockVault interactive: %v", err) } @@ -599,16 +599,16 @@ func TestConfiguredSessionTTL_WithVaultConfig(t *testing.T) { func TestConfiguredSessionTTL_NilVault(t *testing.T) { ttl := cli.ConfiguredSessionTTL(nil, 0) - if ttl != defaultSessionTTL() { - t.Fatalf("cli.ConfiguredSessionTTL() = %v, want %v", ttl, defaultSessionTTL()) + if ttl != cli.DefaultSessionTTL() { + t.Fatalf("cli.ConfiguredSessionTTL() = %v, want %v", ttl, cli.DefaultSessionTTL()) } } func TestConfiguredSessionTTL_VaultWithNilConfig(t *testing.T) { v := &vaultpkg.Vault{} ttl := cli.ConfiguredSessionTTL(v, 0) - if ttl != defaultSessionTTL() { - t.Fatalf("cli.ConfiguredSessionTTL() = %v, want %v", ttl, defaultSessionTTL()) + if ttl != cli.DefaultSessionTTL() { + t.Fatalf("cli.ConfiguredSessionTTL() = %v, want %v", ttl, cli.DefaultSessionTTL()) } } @@ -627,8 +627,8 @@ func TestConfiguredSessionTTL_ZeroSessionTimeout(t *testing.T) { } ttl := cli.ConfiguredSessionTTL(v, 0) - if ttl != defaultSessionTTL() { - t.Fatalf("cli.ConfiguredSessionTTL() = %v, want %v", ttl, defaultSessionTTL()) + if ttl != cli.DefaultSessionTTL() { + t.Fatalf("cli.ConfiguredSessionTTL() = %v, want %v", ttl, cli.DefaultSessionTTL()) } } @@ -644,7 +644,7 @@ func TestReadHiddenInput_TerminalEOF(t *testing.T) { isTerminalFunc = oldIsTerminal }() - _, err := readHiddenInput("Password: ", nil) + _, err := cli.ReadHiddenInput("Password: ", nil) if err == nil { t.Fatal("expected error for terminal EOF") } @@ -665,7 +665,7 @@ func TestReadHiddenInput_TerminalInterrupt(t *testing.T) { isTerminalFunc = oldIsTerminal }() - _, err := readHiddenInput("Password: ", nil) + _, err := cli.ReadHiddenInput("Password: ", nil) if err == nil { t.Fatal("expected error for terminal interrupt") } @@ -676,7 +676,7 @@ func TestReadHiddenInput_TerminalInterrupt(t *testing.T) { func TestReadHiddenInput_ReaderEOF(t *testing.T) { reader := bufio.NewReader(strings.NewReader("")) - _, err := readHiddenInput("Password: ", reader) + _, err := cli.ReadHiddenInput("Password: ", reader) if err == nil { t.Fatal("expected error for reader EOF") } @@ -695,7 +695,7 @@ func TestReadHiddenInput_StdinEOF(t *testing.T) { _ = r.Close() }() - _, err := readHiddenInput("Password: ", nil) + _, err := cli.ReadHiddenInput("Password: ", nil) if err == nil { t.Fatal("expected error for stdin EOF") } diff --git a/cmd/run.go b/cmd/run.go index c52445ef..2408e162 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" + cli "github.com/danieljustus/OpenPass/internal/cli" errorspkg "github.com/danieljustus/OpenPass/internal/errors" "github.com/danieljustus/OpenPass/internal/secrets" vaultsvc "github.com/danieljustus/OpenPass/internal/vaultsvc" @@ -33,7 +34,7 @@ var runCmd = &cobra.Command{ --workdir /tmp/job -- ./deploy.sh`, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return withVault(func(svc vaultsvc.Service) error { + return cli.WithVault(func(svc vaultsvc.Service) error { // Parse --env flags: each is "ENV_NAME=path.field" envMap := make(map[string]string) for _, envFlag := range runEnvFlags { diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 9c613d29..6efe4784 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -119,7 +119,7 @@ func runHTTPServerAsyncWithFactory(ctx context.Context, t *testing.T, bind strin wg.Add(1) go func() { defer wg.Done() - vaultDir, _ := vaultPath() + vaultDir, _ := cli.VaultPath() var err error if listener != nil { err = serverbootstrap.RunHTTPServerOnListener(ctx, listener, v, vaultDir, "dev", factory) @@ -163,7 +163,7 @@ func runHTTPServerAsync(ctx context.Context, t *testing.T, port int, v *vaultpkg func testMCPToken(t *testing.T) string { t.Helper() - vaultDir, _ := vaultPath() + vaultDir, _ := cli.VaultPath() // Try the legacy token file first; if it was migrated and deleted, // fall back to the token stored in testTokens. tokenBytes, err := os.ReadFile(filepath.Join(vaultDir, "mcp-token")) diff --git a/cmd/share.go b/cmd/share.go index a5f8fe56..4197ab03 100644 --- a/cmd/share.go +++ b/cmd/share.go @@ -39,7 +39,7 @@ Examples: openpass share list --path github.password openpass share list --output json`, RunE: func(cmd *cobra.Command, args []string) error { - vDir, err := vaultPath() + vDir, err := cli.VaultPath() if err != nil { return err } @@ -126,7 +126,7 @@ var shareRevokeCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { grantID := args[0] - vDir, err := vaultPath() + vDir, err := cli.VaultPath() if err != nil { return err } diff --git a/cmd/sync.go b/cmd/sync.go index 9932e7ef..96c5f0e1 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + cli "github.com/danieljustus/OpenPass/internal/cli" errorspkg "github.com/danieljustus/OpenPass/internal/errors" "github.com/danieljustus/OpenPass/internal/git" vaultpkg "github.com/danieljustus/OpenPass/internal/vault" @@ -29,7 +30,7 @@ var syncCmd = &cobra.Command{ openpass sync --force`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - vaultDir, err := vaultPath() + vaultDir, err := cli.VaultPath() if err != nil { return err } diff --git a/cmd/template.go b/cmd/template.go index 68bdb94c..21b82e19 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -50,7 +50,7 @@ var templateGenerateCmd = &cobra.Command{ # Dry-run to preview without real values openpass template generate --type env --name myapp --dry-run`, RunE: func(cmd *cobra.Command, args []string) error { - return withVault(func(svc vaultsvc.Service) error { + return cli.WithVault(func(svc vaultsvc.Service) error { ctx := context.Background() engine := template.NewEngine(svc) diff --git a/cmd/test_state_test.go b/cmd/test_state_test.go index f71dbca7..743cabb4 100644 --- a/cmd/test_state_test.go +++ b/cmd/test_state_test.go @@ -45,10 +45,10 @@ func resetCommandTestState() { return serverbootstrap.RunStdioServer(ctx, vault, agentName, server.New) } mcpcmd.RunHTTPServerFunc = func(ctx context.Context, bind string, port int, vault *vaultpkg.Vault) error { - vaultDir, _ := vaultPath() - return serverbootstrap.RunHTTPServer(ctx, bind, port, vault, vaultDir, Version, server.New) + vaultDir, _ := cli.VaultPath() + return serverbootstrap.RunHTTPServer(ctx, bind, port, vault, vaultDir, cli.AppVersion, server.New) } - mcpcmd.ServeUnlockVault = unlockVault + mcpcmd.ServeUnlockVault = cli.UnlockVault } func resetCommandFlagGlobals() { diff --git a/cmd/ui.go b/cmd/ui.go index 07caf65a..4ff77ac6 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" + cli "github.com/danieljustus/OpenPass/internal/cli" "github.com/danieljustus/OpenPass/internal/ui" "github.com/danieljustus/OpenPass/internal/ui/cliout" vaultsvc "github.com/danieljustus/OpenPass/internal/vaultsvc" @@ -42,7 +43,7 @@ Inside the TUI: fmt.Print(tbl.Render()) return nil } - return withVault(func(svc vaultsvc.Service) error { + return cli.WithVault(func(svc vaultsvc.Service) error { if err := ui.Run(svc); err != nil { return fmt.Errorf("ui failed: %w", err) } diff --git a/config.yaml.example b/config.yaml.example index 9d08db04..d92115fc 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -93,15 +93,15 @@ mcp: # Rate limit for MCP HTTP requests (requests per minute). Set to 0 to disable. # rate_limit: 60 -# Environment variable whitelist for subprocess execution. +# Environment variable allowlist for subprocess execution. # Only these vars are passed to child processes (git, gpg, editors, etc.), # preventing leakage of sensitive env vars (API keys, tokens, etc.). -# The default whitelist includes PATH, HOME, TMPDIR, TEMP, TMP, USER, +# The default allowlist includes PATH, HOME, TMPDIR, TEMP, TMP, USER, # LOGNAME, LANG, LC_ALL, SHELL, TERM, COLORTERM, DISPLAY, XAUTHORITY, # GIT_ASKPASS, GIT_SSH, GIT_SSH_COMMAND, SSH_AUTH_SOCK, # SSH_AGENT_LAUNCHER, and GNUPGHOME. # Use this list to add additional vars your workflow requires. -# envWhitelist: +# envAllowlist: # - MY_CUSTOM_VAR # - ANOTHER_VAR diff --git a/internal/config/config.go b/internal/config/config.go index 20ef2f73..1d957f30 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,8 +35,10 @@ type Config struct { UseTouchID *bool `yaml:"useTouchID,omitempty"` Profiles map[string]*Profile `yaml:"profiles,omitempty"` DefaultProfile string `yaml:"defaultProfile,omitempty"` - EnvWhitelist []string `yaml:"envWhitelist,omitempty"` - ScanPatterns []CustomPattern `yaml:"scan_patterns,omitempty"` + EnvAllowlist []string `yaml:"envAllowlist,omitempty"` + // EnvWhitelist is the deprecated name for EnvAllowlist; kept for backward compatibility. + EnvWhitelist []string `yaml:"envWhitelist,omitempty"` + ScanPatterns []CustomPattern `yaml:"scan_patterns,omitempty"` } type AgentProfile struct { diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 31daea44..83997df9 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -171,6 +171,10 @@ func Load(path string) (*Config, error) { return nil, err } + if raw.EnvWhitelist != nil { + fmt.Fprintln(os.Stderr, "Warning: envWhitelist is deprecated and will be removed in a future version; use envAllowlist instead") + } + mergeTopLevel(cfg, raw) mergeAgentProfiles(cfg, raw, agentFields) if err := validateAgents(cfg.Agents); err != nil { diff --git a/internal/config/config_merge.go b/internal/config/config_merge.go index 5d8d3b4c..b3fa493c 100644 --- a/internal/config/config_merge.go +++ b/internal/config/config_merge.go @@ -78,8 +78,11 @@ func mergeTopLevel(cfg *Config, raw Config) { if raw.DefaultProfile != "" { cfg.DefaultProfile = raw.DefaultProfile } + if raw.EnvAllowlist != nil { + cfg.EnvAllowlist = append([]string(nil), raw.EnvAllowlist...) + } if raw.EnvWhitelist != nil { - cfg.EnvWhitelist = append([]string(nil), raw.EnvWhitelist...) + cfg.EnvAllowlist = append([]string(nil), raw.EnvWhitelist...) } if raw.ScanPatterns != nil { cfg.ScanPatterns = append([]CustomPattern(nil), raw.ScanPatterns...) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 537fb7d8..604d0546 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -125,6 +125,34 @@ func TestLoadRejectsInvalidYAML(t *testing.T) { } } +func TestLoad_EnvAllowlist(t *testing.T) { + t.Parallel() + + path := writeTempFile(t, []byte("envAllowlist:\n - HOME\n - PATH\n")) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + want := []string{"HOME", "PATH"} + if !reflect.DeepEqual(cfg.EnvAllowlist, want) { + t.Errorf("EnvAllowlist = %v, want %v", cfg.EnvAllowlist, want) + } +} + +func TestLoad_EnvWhitelistBackwardCompat(t *testing.T) { + t.Parallel() + + path := writeTempFile(t, []byte("envWhitelist:\n - HOME\n - PATH\n")) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + want := []string{"HOME", "PATH"} + if !reflect.DeepEqual(cfg.EnvAllowlist, want) { + t.Errorf("EnvAllowlist = %v, want %v (from deprecated envWhitelist)", cfg.EnvAllowlist, want) + } +} + func TestSaveWritesToDefaultConfigPath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("skipping on windows: HOME env behavior differs") @@ -2641,7 +2669,7 @@ func TestRoundTrip_AllFieldsSet(t *testing.T) { SessionTimeout: 10 * time.Minute, AuthMethod: "touchid", UseTouchID: bptr(true), - EnvWhitelist: []string{"HOME", "PATH"}, + EnvAllowlist: []string{"HOME", "PATH"}, DefaultProfile: "work", Profiles: map[string]*Profile{ "work": {VaultPath: "~/.openpass-work"}, diff --git a/internal/config/dottedpath.go b/internal/config/dottedpath.go index 98023f0b..b49ecaee 100644 --- a/internal/config/dottedpath.go +++ b/internal/config/dottedpath.go @@ -24,6 +24,7 @@ func KnownConfigKeys() []string { "authMethod", "useTouchID", "defaultProfile", + "envAllowlist", "envWhitelist", "scan_patterns", diff --git a/internal/mcp/apitemplates/template.go b/internal/mcp/apitemplates/template.go index e8c2236e..6c35d66c 100644 --- a/internal/mcp/apitemplates/template.go +++ b/internal/mcp/apitemplates/template.go @@ -6,6 +6,8 @@ package apitemplates import ( "embed" "fmt" + "net" + "net/url" "os" "path/filepath" "strings" @@ -56,6 +58,7 @@ type templateFile struct { AllowedEndpoints []string `yaml:"allowed_endpoints"` AllowedMethods []string `yaml:"allowed_methods"` DefaultHeaders map[string]string `yaml:"default_headers"` + AllowPrivate bool `yaml:"allow_private"` } // Load loads a template by name. It checks the user's template directory @@ -127,6 +130,52 @@ func loadFromFile(name, path string) (*APITemplate, error) { return parseTemplate(name, data) } +var blockedPrivateRanges = func() []*net.IPNet { + ranges := []string{ + "127.0.0.0/8", // Loopback IPv4 + "::1/128", // Loopback IPv6 + "169.254.0.0/16", // Link-local IPv4 + "fe80::/10", // Link-local IPv6 + "10.0.0.0/8", // RFC 1918 private + "172.16.0.0/12", // RFC 1918 private + "192.168.0.0/16", // RFC 1918 private + } + var nets []*net.IPNet + for _, cidr := range ranges { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + panic(fmt.Sprintf("invalid CIDR %q: %v", cidr, err)) + } + nets = append(nets, ipnet) + } + return nets +}() + +// isPrivateHost reports whether the given host resolves to a loopback, +// link-local, or RFC 1918 private address. It accepts IP literals and +// hostnames; hostnames are checked against known loopback names. +func isPrivateHost(host string) bool { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + + if host == "localhost" || host == "localhost.localdomain" { + return true + } + + ip := net.ParseIP(host) + if ip == nil { + return false + } + + for _, blocked := range blockedPrivateRanges { + if blocked.Contains(ip) { + return true + } + } + return false +} + // parseTemplate parses YAML data into an APITemplate. func parseTemplate(name string, data []byte) (*APITemplate, error) { var tf templateFile @@ -137,6 +186,13 @@ func parseTemplate(name string, data []byte) (*APITemplate, error) { if tf.BaseURL == "" { return nil, fmt.Errorf("template %q: base_url is required", name) } + parsedURL, err := url.Parse(tf.BaseURL) + if err != nil { + return nil, fmt.Errorf("template %q: invalid base_url: %w", name, err) + } + if isPrivateHost(parsedURL.Host) && !tf.AllowPrivate { + return nil, fmt.Errorf("template %q: base_url host %q resolves to a private/internal address; set allow_private: true to override", name, parsedURL.Host) + } if tf.AuthType == "" { return nil, fmt.Errorf("template %q: auth_type is required", name) } diff --git a/internal/mcp/apitemplates/template_test.go b/internal/mcp/apitemplates/template_test.go new file mode 100644 index 00000000..8427ab9b --- /dev/null +++ b/internal/mcp/apitemplates/template_test.go @@ -0,0 +1,114 @@ +package apitemplates + +import ( + "testing" +) + +func TestIsPrivateHost_LoopbackIPv4(t *testing.T) { + if !isPrivateHost("127.0.0.1") { + t.Error("expected 127.0.0.1 to be private") + } +} + +func TestIsPrivateHost_LoopbackIPv6(t *testing.T) { + if !isPrivateHost("::1") { + t.Error("expected ::1 to be private") + } +} + +func TestIsPrivateHost_LinkLocalIPv4(t *testing.T) { + if !isPrivateHost("169.254.169.254") { + t.Error("expected 169.254.169.254 to be private") + } +} + +func TestIsPrivateHost_RFC1918_10(t *testing.T) { + if !isPrivateHost("10.0.0.1") { + t.Error("expected 10.0.0.1 to be private") + } +} + +func TestIsPrivateHost_RFC1918_172(t *testing.T) { + if !isPrivateHost("172.16.0.1") { + t.Error("expected 172.16.0.1 to be private") + } +} + +func TestIsPrivateHost_RFC1918_192(t *testing.T) { + if !isPrivateHost("192.168.1.1") { + t.Error("expected 192.168.1.1 to be private") + } +} + +func TestIsPrivateHost_Localhost(t *testing.T) { + if !isPrivateHost("localhost") { + t.Error("expected localhost to be private") + } +} + +func TestIsPrivateHost_LocalhostWithPort(t *testing.T) { + if !isPrivateHost("localhost:8080") { + t.Error("expected localhost:8080 to be private") + } +} + +func TestIsPrivateHost_PublicHost(t *testing.T) { + if isPrivateHost("api.github.com") { + t.Error("expected api.github.com to not be private") + } +} + +func TestIsPrivateHost_PublicIP(t *testing.T) { + if isPrivateHost("8.8.8.8") { + t.Error("expected 8.8.8.8 to not be private") + } +} + +func TestParseTemplate_BlocksPrivateBaseURL(t *testing.T) { + yaml := []byte(` +base_url: http://169.254.169.254 +auth_type: bearer +entry_ref: op://vault/item +`) + _, err := parseTemplate("test", yaml) + if err == nil { + t.Fatal("expected error for private base_url") + } +} + +func TestParseTemplate_BlocksLocalhost(t *testing.T) { + yaml := []byte(` +base_url: http://localhost:8080 +auth_type: bearer +entry_ref: op://vault/item +`) + _, err := parseTemplate("test", yaml) + if err == nil { + t.Fatal("expected error for localhost base_url") + } +} + +func TestParseTemplate_AllowsPublicBaseURL(t *testing.T) { + yaml := []byte(` +base_url: https://api.github.com +auth_type: bearer +entry_ref: op://vault/item +`) + _, err := parseTemplate("test", yaml) + if err != nil { + t.Fatalf("unexpected error for public base_url: %v", err) + } +} + +func TestParseTemplate_AllowsPrivateWithOverride(t *testing.T) { + yaml := []byte(` +base_url: http://localhost:8080 +auth_type: bearer +entry_ref: op://vault/item +allow_private: true +`) + _, err := parseTemplate("test", yaml) + if err != nil { + t.Fatalf("unexpected error with allow_private: %v", err) + } +} diff --git a/internal/mcp/server/tools_execute_api_request_test.go b/internal/mcp/server/tools_execute_api_request_test.go index 3b78421e..fb79b8d5 100644 --- a/internal/mcp/server/tools_execute_api_request_test.go +++ b/internal/mcp/server/tools_execute_api_request_test.go @@ -316,6 +316,7 @@ allowed_endpoints: - /* allowed_methods: - GET +allow_private: true `, ts.URL)) req := mcp.CallToolRequest{ @@ -381,6 +382,7 @@ allowed_endpoints: - /* allowed_methods: - POST +allow_private: true `, ts.URL)) req := mcp.CallToolRequest{ @@ -474,6 +476,7 @@ allowed_endpoints: - /v1/* allowed_methods: - GET +allow_private: true `, endpoint: "/admin/delete", method: "GET", @@ -489,6 +492,7 @@ allowed_endpoints: - /* allowed_methods: - GET +allow_private: true `, endpoint: "/test", method: "DELETE", @@ -641,6 +645,7 @@ allowed_endpoints: - /* allowed_methods: - GET +allow_private: true `, caseTS.URL)) req := mcp.CallToolRequest{ @@ -700,6 +705,7 @@ allowed_endpoints: - /* allowed_methods: - GET +allow_private: true `, ts.URL)) req := mcp.CallToolRequest{ diff --git a/internal/mcp/server/tools_template.go b/internal/mcp/server/tools_template.go index 24d592ea..5c5e21e5 100644 --- a/internal/mcp/server/tools_template.go +++ b/internal/mcp/server/tools_template.go @@ -6,6 +6,8 @@ import ( "fmt" "log/slog" "os" + "path/filepath" + "strings" mcp "github.com/danieljustus/OpenPass/internal/mcp" "github.com/danieljustus/OpenPass/internal/template" @@ -51,6 +53,10 @@ func (s *Server) handleGenerateTemplate(ctx context.Context, req mcp.CallToolReq s.logAudit(ctx, "write_denied", outputPath, false) return toolError("agent does not have write permission"), nil } + if err := s.validateOutputPath(outputPath); err != nil { + s.logAudit(ctx, "write_denied", outputPath, false) + return toolError(fmt.Sprintf("invalid output_path: %v", err)), nil + } if err := os.WriteFile(outputPath, []byte(output), 0600); err != nil { return toolError(fmt.Sprintf("write file: %v", err)), nil } @@ -69,3 +75,51 @@ func (s *Server) handleGenerateTemplate(ctx context.Context, req mcp.CallToolReq s.logAudit(ctx, "template_generated", templateType, true) return mcp.NewToolResultText(EmbedAsData("rendered_template", output)), nil } + +// validateOutputPath ensures the requested output path is confined to the +// vault directory. It resolves both paths to absolute form and rejects any +// path that escapes the vault root via .. segments or absolute paths outside +// the vault. +func (s *Server) validateOutputPath(outputPath string) error { + if s.vault == nil || s.vault.Dir == "" { + return fmt.Errorf("no vault directory configured") + } + + vaultDir, err := filepath.Abs(s.vault.Dir) + if err != nil { + return fmt.Errorf("resolve vault directory: %w", err) + } + + absPath, err := filepath.Abs(outputPath) + if err != nil { + return fmt.Errorf("resolve output path: %w", err) + } + + rel, err := filepath.Rel(vaultDir, absPath) + if err != nil { + return fmt.Errorf("compute relative path: %w", err) + } + + if rel == ".." || rel == "." { + return fmt.Errorf("output path must be inside the vault directory") + } + + if filepath.IsAbs(rel) { + return fmt.Errorf("output path must be inside the vault directory") + } + + // Check for traversal: if any component is "..", the path escapes. + for _, part := range strings.Split(rel, string(filepath.Separator)) { + if part == ".." { + return fmt.Errorf("output path escapes vault directory") + } + } + + // Also verify the cleaned path doesn't start with ".." + cleaned := filepath.Clean(rel) + if cleaned == ".." || (len(cleaned) >= 3 && cleaned[:3] == "../") || (len(cleaned) >= 3 && cleaned[:3] == "..\\") { + return fmt.Errorf("output path escapes vault directory") + } + + return nil +} diff --git a/internal/mcp/server/tools_template_test.go b/internal/mcp/server/tools_template_test.go new file mode 100644 index 00000000..54a99988 --- /dev/null +++ b/internal/mcp/server/tools_template_test.go @@ -0,0 +1,111 @@ +package server + +import ( + "context" + "path/filepath" + "testing" + + "github.com/danieljustus/OpenPass/internal/config" + mcp "github.com/danieljustus/OpenPass/internal/mcp" +) + +func TestValidateOutputPath_ValidInVault(t *testing.T) { + vaultDir := t.TempDir() + srv := newTestServerWithVault(t, config.AgentProfile{ + Name: "test", + AllowedPaths: []string{"*"}, + CanWrite: config.BoolPtr(true), + ApprovalMode: config.StrPtr("none"), + }, "stdio", vaultDir) + + validPath := filepath.Join(vaultDir, "test.txt") + if err := srv.validateOutputPath(validPath); err != nil { + t.Errorf("validateOutputPath(%q) error = %v, want nil", validPath, err) + } +} + +func TestValidateOutputPath_EscapesViaDotDot(t *testing.T) { + vaultDir := t.TempDir() + srv := newTestServerWithVault(t, config.AgentProfile{ + Name: "test", + AllowedPaths: []string{"*"}, + CanWrite: config.BoolPtr(true), + ApprovalMode: config.StrPtr("none"), + }, "stdio", vaultDir) + + escapedPath := filepath.Join(vaultDir, "..", "outside.txt") + if err := srv.validateOutputPath(escapedPath); err == nil { + t.Errorf("validateOutputPath(%q) = nil, want error", escapedPath) + } +} + +func TestValidateOutputPath_AbsoluteOutsideVault(t *testing.T) { + vaultDir := t.TempDir() + srv := newTestServerWithVault(t, config.AgentProfile{ + Name: "test", + AllowedPaths: []string{"*"}, + CanWrite: config.BoolPtr(true), + ApprovalMode: config.StrPtr("none"), + }, "stdio", vaultDir) + + outsidePath := "/tmp/outside.txt" + if err := srv.validateOutputPath(outsidePath); err == nil { + t.Errorf("validateOutputPath(%q) = nil, want error", outsidePath) + } +} + +func TestValidateOutputPath_DotDotInMiddle(t *testing.T) { + vaultDir := t.TempDir() + srv := newTestServerWithVault(t, config.AgentProfile{ + Name: "test", + AllowedPaths: []string{"*"}, + CanWrite: config.BoolPtr(true), + ApprovalMode: config.StrPtr("none"), + }, "stdio", vaultDir) + + escapedPath := filepath.Join(vaultDir, "subdir", "..", "..", "outside.txt") + if err := srv.validateOutputPath(escapedPath); err == nil { + t.Errorf("validateOutputPath(%q) = nil, want error", escapedPath) + } +} + +func TestValidateOutputPath_NoVault(t *testing.T) { + srv := newTestServerWithVault(t, config.AgentProfile{ + Name: "test", + AllowedPaths: []string{"*"}, + CanWrite: config.BoolPtr(true), + ApprovalMode: config.StrPtr("none"), + }, "stdio", "") + + if err := srv.validateOutputPath("/tmp/test.txt"); err == nil { + t.Error("validateOutputPath with no vault dir = nil, want error") + } +} + +func TestHandleGenerateTemplate_RespectsPathValidation(t *testing.T) { + vaultDir := t.TempDir() + srv := newTestServerWithVault(t, config.AgentProfile{ + Name: "test", + AllowedPaths: []string{"*"}, + CanWrite: config.BoolPtr(true), + ApprovalMode: config.StrPtr("none"), + }, "stdio", vaultDir) + + req := mcp.CallToolRequest{ + Arguments: map[string]any{ + "template_type": "app", + "output_path": filepath.Join(vaultDir, "..", "outside.txt"), + }, + } + + result, err := srv.handleGenerateTemplate(context.Background(), req) + if err != nil { + t.Fatalf("handleGenerateTemplate() error = %v", err) + } + if result == nil { + t.Fatal("handleGenerateTemplate() returned nil result") + } + if !result.IsError { + t.Fatal("handleGenerateTemplate() should have returned error for escaped path") + } +}