Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions cmd/mcp/agent_upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
Expand All @@ -13,18 +14,20 @@ import (
"golang.org/x/term"

"github.com/danieljustus/OpenPass/internal/agentskill"
"github.com/danieljustus/OpenPass/internal/authguard"
configpkg "github.com/danieljustus/OpenPass/internal/config"
auth "github.com/danieljustus/OpenPass/internal/mcp/auth"
)

var (
agentUpgradeTier string
agentUpgradeDryRun bool
agentUpgradeYes bool
agentUpgradeReason string
agentUpgradeRotate bool
agentUpgradeValidTiers = map[string]bool{"safe": true, "read-only": true, "standard": true, "admin": true}
agentUpgradeTierAlias = map[string]string{"safe": "read-only", "read-only": "read-only", "standard": "standard", "admin": "admin"}
agentUpgradeTier string
agentUpgradeDryRun bool
agentUpgradeYes bool
agentUpgradeReason string
agentUpgradeRotate bool
agentUpgradeNoBiometric bool
agentUpgradeValidTiers = map[string]bool{"safe": true, "read-only": true, "standard": true, "admin": true}
agentUpgradeTierAlias = map[string]string{"safe": "read-only", "read-only": "read-only", "standard": "standard", "admin": "admin"}
)

type tierDiff struct {
Expand Down Expand Up @@ -155,6 +158,27 @@ func printTierDiff(diffs []tierDiff) {
}
}

func requireBiometricForUpgrade(ctx context.Context, agentName, targetTier string) error {
challenger := authguard.DefaultChallenger()
if !challenger.Available() {
if agentUpgradeYes {
return fmt.Errorf(
"biometric verification is required for non-interactive tier upgrades on this platform.\n" +
"Re-run with --no-biometric to bypass (not recommended)",
)
}
fmt.Fprintf(os.Stderr, "\u26a0 Biometric verification is not available on this platform.\n")
fmt.Fprintf(os.Stderr, " The upgrade will proceed after interactive confirmation.\n\n")
return nil
}

reason := fmt.Sprintf("Upgrade OpenPass agent %q to %q tier", agentName, targetTier)
if err := challenger.Challenge(ctx, authguard.OpTierUpgrade, reason); err != nil {
return fmt.Errorf("biometric verification required for tier upgrade: %w", err)
}
return nil
}

func confirmUpgrade(agentName, targetTier string) bool {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return false
Expand Down Expand Up @@ -249,6 +273,12 @@ an audit trail.`,
return nil
}

if !agentUpgradeNoBiometric {
if err := requireBiometricForUpgrade(cmd.Context(), agentName, agentUpgradeTier); err != nil {
return err
}
}

if !agentUpgradeYes && !confirmUpgrade(agentName, agentUpgradeTier) {
fmt.Fprintln(os.Stderr, "Upgrade canceled.")
return nil
Expand Down Expand Up @@ -318,6 +348,7 @@ func init() {
agentUpgradeCmd.Flags().BoolVar(&agentUpgradeYes, "yes", false, "Non-interactive mode (requires --reason)")
agentUpgradeCmd.Flags().StringVar(&agentUpgradeReason, "reason", "", "Audit reason for the upgrade (required with --yes)")
agentUpgradeCmd.Flags().BoolVar(&agentUpgradeRotate, "rotate-token", false, "Rotate the agent's MCP token on upgrade")
agentUpgradeCmd.Flags().BoolVar(&agentUpgradeNoBiometric, "no-biometric", false, "Skip biometric verification (not recommended)")

agentCmd.AddCommand(agentUpgradeCmd)
}
115 changes: 115 additions & 0 deletions internal/authguard/challenge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Package authguard provides identity verification challenges for critical
// configuration operations. It integrates the existing biometric (Touch ID /
// Face ID) authenticator from internal/session and provides a clear upgrade
// path for platforms that do not support biometrics.
package authguard

import (
"context"
"errors"
"fmt"

"github.com/danieljustus/OpenPass/internal/session"
)

// ErrBiometryRequired is returned by policy evaluation when a rule matches
// with ActionRequireBiometry. Callers in the tool dispatch layer should catch
// this error, trigger a biometric challenge, and only proceed on success.
var ErrBiometryRequired = errors.New("biometric verification required by policy")

// OperationType classifies a critical configuration operation.
type OperationType string

const (
OpTierUpgrade OperationType = "agent_tier_upgrade"
OpAuthMethodSet OperationType = "set_auth_method"
)

// CriticalMCPTools is the set of MCP tool names that perform critical
// configuration changes and therefore require biometric verification.
var CriticalMCPTools = map[string]bool{
"set_auth_method": true,
}

// IsCriticalMCPTool reports whether toolName is a known critical-config MCP tool.
func IsCriticalMCPTool(toolName string) bool {
return CriticalMCPTools[toolName]
}

// Challenger verifies the user's identity before a critical operation.
// It delegates to the platform's BiometricAuthenticator (Touch ID on macOS,
// noop on all other platforms).
type Challenger struct {
// authenticator returns the current BiometricAuthenticator. Exposed as a
// field (rather than hardcoding session.DefaultBiometricAuthenticator) so
// tests can inject mocks.
Authenticator func() session.BiometricAuthenticator
}

// DefaultChallenger returns a production-ready Challenger wired to the
// platform's real biometric authenticator.
func DefaultChallenger() *Challenger {
return &Challenger{
Authenticator: session.DefaultBiometricAuthenticator,
}
}

// Available reports whether biometric verification is possible on this platform.
func (c *Challenger) Available() bool {
if c == nil || c.Authenticator == nil {
return false
}
return c.Authenticator().IsAvailable()
}

// Challenge triggers a biometric prompt and blocks until the user succeeds,
// fails, or cancels. It returns nil on success, or an error describing why
// verification could not be completed.
//
// The reason string is shown in the Touch ID system dialog on macOS. Keep it
// concise (≤ 128 chars) and include the specific operation details.
func (c *Challenger) Challenge(ctx context.Context, op OperationType, reason string) error {
if c == nil || c.Authenticator == nil {
return fmt.Errorf("biometric challenger not initialized")
}

auth := c.Authenticator()
if !auth.IsAvailable() {
return fmt.Errorf("%w: biometric authentication is not available on this platform", session.ErrBiometricNotAvailable)
}

if err := auth.Authenticate(ctx, reason); err != nil {
return fmt.Errorf("biometric verification for %s failed: %w", op, err)
}

return nil
}

// String returns a short, user-visible label for an operation type.
func (op OperationType) String() string {
switch op {
case OpTierUpgrade:
return "agent tier upgrade"
case OpAuthMethodSet:
return "auth method change"
default:
return string(op)
}
}

// VerifyIdentity is a convenience helper that attempts biometric verification
// and returns an error explaining how to bypass it when biometric is unavailable.
// Callers should check the return value; on success (nil) the operation can
// proceed. On error the returned message is suitable for display to the user.
func VerifyIdentity(ctx context.Context, op OperationType, reason string) error {
c := DefaultChallenger()
if !c.Available() {
return fmt.Errorf(
"biometric verification is not available on this platform for %s.\n"+
"Re-run with --no-biometric to bypass (not recommended for automated use).\n"+
"For interactive use, re-enter your vault passphrase when prompted",
op,
)
}
return c.Challenge(ctx, op, reason)
}
164 changes: 164 additions & 0 deletions internal/authguard/challenge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package authguard

import (
"context"
"errors"
"testing"

"github.com/danieljustus/OpenPass/internal/session"
)

type mockBioAuth struct {
available bool
authErr error
}

func (m *mockBioAuth) Authenticate(_ context.Context, _ string) error {
return m.authErr
}

func (m *mockBioAuth) IsAvailable() bool {
return m.available
}

func TestChallenger_Available(t *testing.T) {
tests := []struct {
name string
available bool
want bool
}{
{"available", true, true},
{"unavailable", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Challenger{
Authenticator: func() session.BiometricAuthenticator {
return &mockBioAuth{available: tt.available}
},
}
if got := c.Available(); got != tt.want {
t.Errorf("Available() = %v, want %v", got, tt.want)
}
})
}
}

func TestChallenger_Available_NilChallenger(t *testing.T) {
var c *Challenger
if c.Available() {
t.Error("nil Challenger should not be available")
}
}

func TestChallenger_Challenge_Success(t *testing.T) {
c := &Challenger{
Authenticator: func() session.BiometricAuthenticator {
return &mockBioAuth{available: true}
},
}
if err := c.Challenge(context.Background(), OpTierUpgrade, "test reason"); err != nil {
t.Errorf("Challenge() unexpected error: %v", err)
}
}

func TestChallenger_Challenge_NotAvailable(t *testing.T) {
c := &Challenger{
Authenticator: func() session.BiometricAuthenticator {
return &mockBioAuth{available: false}
},
}
err := c.Challenge(context.Background(), OpTierUpgrade, "test reason")
if err == nil {
t.Fatal("Challenge() expected error when biometric not available")
}
if !errors.Is(err, session.ErrBiometricNotAvailable) {
t.Errorf("Challenge() error should wrap ErrBiometricNotAvailable, got: %v", err)
}
}

func TestChallenger_Challenge_AuthFails(t *testing.T) {
c := &Challenger{
Authenticator: func() session.BiometricAuthenticator {
return &mockBioAuth{available: true, authErr: errors.New("user canceled")}
},
}
err := c.Challenge(context.Background(), OpAuthMethodSet, "change auth method")
if err == nil {
t.Fatal("Challenge() expected error when auth fails")
}
}

func TestChallenger_Challenge_NilChallenger(t *testing.T) {
var c *Challenger
err := c.Challenge(context.Background(), OpTierUpgrade, "reason")
if err == nil {
t.Fatal("nil Challenger Challenge() should return error")
}
}

func TestChallenger_Challenge_NilAuthenticator(t *testing.T) {
c := &Challenger{Authenticator: nil}
err := c.Challenge(context.Background(), OpTierUpgrade, "reason")
if err == nil {
t.Fatal("nil Authenticator Challenge() should return error")
}
}

func TestIsCriticalMCPTool(t *testing.T) {
tests := []struct {
toolName string
want bool
}{
{"set_auth_method", true},
{"list_entries", false},
{"get_entry", false},
{"get_entry_value", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.toolName, func(t *testing.T) {
if got := IsCriticalMCPTool(tt.toolName); got != tt.want {
t.Errorf("IsCriticalMCPTool(%q) = %v, want %v", tt.toolName, got, tt.want)
}
})
}
}

func TestVerifyIdentity_Available(t *testing.T) {
session.SetBiometricAuthenticator(&mockBioAuth{available: true})
defer session.SetBiometricAuthenticator(nil)

err := VerifyIdentity(context.Background(), OpTierUpgrade, "test")
if err != nil {
t.Errorf("VerifyIdentity() unexpected error: %v", err)
}
}

func TestVerifyIdentity_NotAvailable(t *testing.T) {
session.SetBiometricAuthenticator(&mockBioAuth{available: false})
defer session.SetBiometricAuthenticator(nil)

err := VerifyIdentity(context.Background(), OpTierUpgrade, "test")
if err == nil {
t.Fatal("VerifyIdentity() expected error when not available")
}
}

func TestOperationType_String(t *testing.T) {
tests := []struct {
op OperationType
want string
}{
{OpTierUpgrade, "agent tier upgrade"},
{OpAuthMethodSet, "auth method change"},
{OperationType("unknown"), "unknown"},
}
for _, tt := range tests {
t.Run(string(tt.op), func(t *testing.T) {
if got := tt.op.String(); got != tt.want {
t.Errorf("OperationType.String() = %q, want %q", got, tt.want)
}
})
}
}
Loading
Loading