From 16aeeb4540a4627a2dd076192833ec58b86659b1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 22 May 2026 12:40:19 +0200 Subject: [PATCH 1/3] Add biometric auth guard for critical config changes Integrate Touch ID / Face ID verification before critical configuration operations: agent tier upgrades and auth method changes. On macOS, the system biometric dialog prompts before applying the change. On platforms without biometric support, automated use requires an explicit --no-biometric flag. - New internal/authguard package with Challenger API wrapping the existing BiometricAuthenticator for re-auth prompts - agent upgrade CLI: biometric challenge before tier changes; --no-biometric flag to bypass for automation - MCP set_auth_method: biometric challenge before changing auth method when Touch ID is available - Policy ActionRequireBiometry: wired into server dispatch so biometric-requiring rules trigger a real challenge - Tests: authguard unit tests covering availability, authentication failure, and critical tool detection Closes #191 --- cmd/mcp/agent_upgrade.go | 45 +++++- internal/authguard/challenge.go | 115 +++++++++++++++ internal/authguard/challenge_test.go | 164 ++++++++++++++++++++++ internal/mcp/server/server.go | 32 +++-- internal/mcp/server/server_authorize.go | 3 +- internal/mcp/server/server_dispatch.go | 43 +++++- internal/mcp/server/tools_auth.go | 18 ++- internal/mcp/server/tools_test_helpers.go | 13 ++ 8 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 internal/authguard/challenge.go create mode 100644 internal/authguard/challenge_test.go diff --git a/cmd/mcp/agent_upgrade.go b/cmd/mcp/agent_upgrade.go index 8da8149..e4db546 100644 --- a/cmd/mcp/agent_upgrade.go +++ b/cmd/mcp/agent_upgrade.go @@ -2,6 +2,7 @@ package mcp import ( "bufio" + "context" "fmt" "os" "path/filepath" @@ -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 { @@ -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 @@ -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 @@ -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) } diff --git a/internal/authguard/challenge.go b/internal/authguard/challenge.go new file mode 100644 index 0000000..551afae --- /dev/null +++ b/internal/authguard/challenge.go @@ -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 +} + +// OperationDescription 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) +} diff --git a/internal/authguard/challenge_test.go b/internal/authguard/challenge_test.go new file mode 100644 index 0000000..0deff68 --- /dev/null +++ b/internal/authguard/challenge_test.go @@ -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 cancelled")} + }, + } + 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) + } + }) + } +} diff --git a/internal/mcp/server/server.go b/internal/mcp/server/server.go index 67afaa9..e42604c 100644 --- a/internal/mcp/server/server.go +++ b/internal/mcp/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/danieljustus/OpenPass/internal/anomaly" "github.com/danieljustus/OpenPass/internal/audit" + "github.com/danieljustus/OpenPass/internal/authguard" "github.com/danieljustus/OpenPass/internal/config" transport "github.com/danieljustus/OpenPass/internal/mcp/transport" "github.com/danieljustus/OpenPass/internal/notify" @@ -85,6 +86,8 @@ type Server struct { hookRegistry *HookRegistry sessionID string anomalyDetector *anomaly.AnomalyDetector + + biometricChallenger *authguard.Challenger } // SessionID returns the server's unique session identifier. @@ -164,15 +167,16 @@ func New(v *vault.Vault, agentName string, transport string) (*Server, error) { ) srv := &Server{ - vault: v, - agent: &agent, - auditLog: auditLog, - transport: transport, - policyEngine: policyEngine, - approvalCache: newApprovalCache(), - hookRegistry: NewHookRegistry(), - sessionID: sessionID, - anomalyDetector: detector, + vault: v, + agent: &agent, + auditLog: auditLog, + transport: transport, + policyEngine: policyEngine, + approvalCache: newApprovalCache(), + hookRegistry: NewHookRegistry(), + sessionID: sessionID, + anomalyDetector: detector, + biometricChallenger: authguard.DefaultChallenger(), } // Register hooks specified in the agent's config profile @@ -277,3 +281,13 @@ func (s *Server) Close() error { } return s.auditLog.Close() } + +// getBiometricChallenger returns the server's biometric challenger, falling +// back to the default if none is configured. Tests may set biometryChallenger +// to a mock to avoid real system prompts. +func (s *Server) getBiometricChallenger() *authguard.Challenger { + if s != nil && s.biometricChallenger != nil { + return s.biometricChallenger + } + return authguard.DefaultChallenger() +} diff --git a/internal/mcp/server/server_authorize.go b/internal/mcp/server/server_authorize.go index d606c36..fa80a66 100644 --- a/internal/mcp/server/server_authorize.go +++ b/internal/mcp/server/server_authorize.go @@ -11,6 +11,7 @@ import ( "time" "github.com/danieljustus/OpenPass/internal/audit" + "github.com/danieljustus/OpenPass/internal/authguard" mcp "github.com/danieljustus/OpenPass/internal/mcp" auth "github.com/danieljustus/OpenPass/internal/mcp/auth" "github.com/danieljustus/OpenPass/internal/metrics" @@ -117,7 +118,7 @@ func (s *Server) checkPolicy(ctx context.Context, path, actionType string) error case policy.ActionRequireBiometry: s.logAudit(ctx, "policy_biometry", path, false) metrics.RecordAuthDenial("policy_biometry", s.agent.Name) - return fmt.Errorf("policy requires biometry by rule %q", result.RuleName) + return fmt.Errorf("%w by rule %q", authguard.ErrBiometryRequired, result.RuleName) default: return nil } diff --git a/internal/mcp/server/server_dispatch.go b/internal/mcp/server/server_dispatch.go index c2b6739..8f1d778 100644 --- a/internal/mcp/server/server_dispatch.go +++ b/internal/mcp/server/server_dispatch.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "time" @@ -11,6 +12,7 @@ import ( "go.opentelemetry.io/otel/codes" "github.com/danieljustus/OpenPass/internal/anomaly" + "github.com/danieljustus/OpenPass/internal/authguard" mcp "github.com/danieljustus/OpenPass/internal/mcp" auth "github.com/danieljustus/OpenPass/internal/mcp/auth" "github.com/danieljustus/OpenPass/internal/metrics" @@ -135,9 +137,18 @@ func (s *Server) executeTool(ctx context.Context, name string, args json.RawMess // Evaluate declarative policies before tool execution if entryPath != "" { if policyErr := s.checkPolicy(ctx, entryPath, toolActionType(name)); policyErr != nil { - span.SetStatus(codes.Error, policyErr.Error()) - metrics.RecordMCPRequest(name, agentName, "error", time.Since(start)) - return nil, policyErr + if errors.Is(policyErr, authguard.ErrBiometryRequired) { + if bioErr := s.challengeBiometric(ctx, name, policyErr); bioErr != nil { + span.SetStatus(codes.Error, bioErr.Error()) + metrics.RecordMCPRequest(name, agentName, "error", time.Since(start)) + return nil, bioErr + } + s.logAudit(ctx, "policy_biometry_passed", entryPath, true) + } else { + span.SetStatus(codes.Error, policyErr.Error()) + metrics.RecordMCPRequest(name, agentName, "error", time.Since(start)) + return nil, policyErr + } } } @@ -252,3 +263,29 @@ func (s *Server) detectAnomalyAsync(_ context.Context, toolName, entryPath, reqI s.invalidateApprovalCache() }() } + +// challengeBiometric performs a biometric identity verification challenge in +// response to a policy rule that requires biometry. On success it returns nil; +// on failure it returns an error suitable for surfacing to the MCP client. +func (s *Server) challengeBiometric(ctx context.Context, toolName string, policyErr error) error { + agentName := "" + if s.agent != nil { + agentName = s.agent.Name + } + + challenger := s.getBiometricChallenger() + if !challenger.Available() { + s.logAudit(ctx, "policy_biometry_unavailable", toolName, false) + return fmt.Errorf( + "policy requires biometric verification for tool %q, but biometric authentication is not available on this platform. "+ + "Agent %q cannot execute this tool.", toolName, agentName, + ) + } + + reason := fmt.Sprintf("OpenPass agent %q is requesting tool %q", agentName, toolName) + if err := challenger.Challenge(ctx, authguard.OperationType(toolName), reason); err != nil { + s.logAudit(ctx, "policy_biometry_failed", toolName, false) + return fmt.Errorf("biometric verification failed for tool %q: %w", toolName, err) + } + return nil +} diff --git a/internal/mcp/server/tools_auth.go b/internal/mcp/server/tools_auth.go index 5db7f78..15e4a11 100644 --- a/internal/mcp/server/tools_auth.go +++ b/internal/mcp/server/tools_auth.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" + "github.com/danieljustus/OpenPass/internal/authguard" "github.com/danieljustus/OpenPass/internal/config" "github.com/danieljustus/OpenPass/internal/crypto" mcp "github.com/danieljustus/OpenPass/internal/mcp" @@ -62,8 +63,21 @@ func (s *Server) handleSetAuthMethod(ctx context.Context, req mcp.CallToolReques if err := session.SaveBiometricPassphrase(ctx, s.vault.Dir, passphrase); err != nil { return nil, fmt.Errorf("save Touch ID unlock item: %w", err) } - } else if err := session.ClearBiometricPassphrase(s.vault.Dir); err != nil { - return nil, fmt.Errorf("clear Touch ID unlock item: %w", err) + } + + challenger := s.getBiometricChallenger() + if challenger.Available() { + reason := fmt.Sprintf("Change OpenPass auth method to %s", method) + if err := challenger.Challenge(ctx, authguard.OpAuthMethodSet, reason); err != nil { + s.logAudit(ctx, "auth_method_biometric_failed", "", false) + return mcp.NewToolResultError(fmt.Sprintf("biometric verification required: %v", err)), nil + } + } + + if method != config.AuthMethodTouchID { + if err := session.ClearBiometricPassphrase(s.vault.Dir); err != nil { + return nil, fmt.Errorf("clear Touch ID unlock item: %w", err) + } } if err := s.vault.Config.SetAuthMethod(method); err != nil { diff --git a/internal/mcp/server/tools_test_helpers.go b/internal/mcp/server/tools_test_helpers.go index 4957aeb..5c6b957 100644 --- a/internal/mcp/server/tools_test_helpers.go +++ b/internal/mcp/server/tools_test_helpers.go @@ -1,12 +1,15 @@ package server import ( + "context" "testing" "filippo.io/age" "github.com/danieljustus/OpenPass/internal/audit" + "github.com/danieljustus/OpenPass/internal/authguard" "github.com/danieljustus/OpenPass/internal/config" + "github.com/danieljustus/OpenPass/internal/session" "github.com/danieljustus/OpenPass/internal/vault" ) @@ -36,9 +39,19 @@ func newTestServerWithVault(t *testing.T, profile config.AgentProfile, transport auditLog: auditLog, transport: transport, hookRegistry: NewHookRegistry(), + biometricChallenger: &authguard.Challenger{ + Authenticator: func() session.BiometricAuthenticator { + return &noopTestBiometricAuth{} + }, + }, } } +type noopTestBiometricAuth struct{} + +func (n *noopTestBiometricAuth) Authenticate(_ context.Context, _ string) error { return nil } +func (n *noopTestBiometricAuth) IsAvailable() bool { return false } + // mockVault creates a temp vault directory with entries for testing func mockVault(t *testing.T) (string, *age.X25519Identity) { t.Helper() From 65638f3fc342db04d245da08980136797ada9b16 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 22 May 2026 12:49:34 +0200 Subject: [PATCH 2/3] Fix Go formatting in agent_upgrade and tools_test_helpers Refs PR #192 --- cmd/mcp/agent_upgrade.go | 18 +++++++++--------- internal/mcp/server/tools_test_helpers.go | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/mcp/agent_upgrade.go b/cmd/mcp/agent_upgrade.go index e4db546..53aba8b 100644 --- a/cmd/mcp/agent_upgrade.go +++ b/cmd/mcp/agent_upgrade.go @@ -20,14 +20,14 @@ import ( ) var ( - 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"} + 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 { @@ -163,7 +163,7 @@ func requireBiometricForUpgrade(ctx context.Context, agentName, targetTier strin if !challenger.Available() { if agentUpgradeYes { return fmt.Errorf( - "biometric verification is required for non-interactive tier upgrades on this platform.\n"+ + "biometric verification is required for non-interactive tier upgrades on this platform.\n" + "Re-run with --no-biometric to bypass (not recommended).", ) } diff --git a/internal/mcp/server/tools_test_helpers.go b/internal/mcp/server/tools_test_helpers.go index 5c6b957..b29d367 100644 --- a/internal/mcp/server/tools_test_helpers.go +++ b/internal/mcp/server/tools_test_helpers.go @@ -50,7 +50,7 @@ func newTestServerWithVault(t *testing.T, profile config.AgentProfile, transport type noopTestBiometricAuth struct{} func (n *noopTestBiometricAuth) Authenticate(_ context.Context, _ string) error { return nil } -func (n *noopTestBiometricAuth) IsAvailable() bool { return false } +func (n *noopTestBiometricAuth) IsAvailable() bool { return false } // mockVault creates a temp vault directory with entries for testing func mockVault(t *testing.T) (string, *age.X25519Identity) { From 11eb514ad1e1bdbb7f6c3f6b411feea7bb5c0738 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 22 May 2026 12:52:59 +0200 Subject: [PATCH 3/3] Fix lint issues: spelling, error format, unused param - Fix misspelling of 'canceled' in authguard test - Remove trailing punctuation from error messages - Fix String() method doc comment - Remove unused policyErr parameter from challengeBiometric Refs PR #192 --- cmd/mcp/agent_upgrade.go | 2 +- internal/authguard/challenge.go | 4 ++-- internal/authguard/challenge_test.go | 2 +- internal/mcp/server/server_dispatch.go | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/mcp/agent_upgrade.go b/cmd/mcp/agent_upgrade.go index 53aba8b..9fe6adc 100644 --- a/cmd/mcp/agent_upgrade.go +++ b/cmd/mcp/agent_upgrade.go @@ -164,7 +164,7 @@ func requireBiometricForUpgrade(ctx context.Context, agentName, targetTier strin 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).", + "Re-run with --no-biometric to bypass (not recommended)", ) } fmt.Fprintf(os.Stderr, "\u26a0 Biometric verification is not available on this platform.\n") diff --git a/internal/authguard/challenge.go b/internal/authguard/challenge.go index 551afae..a0d245b 100644 --- a/internal/authguard/challenge.go +++ b/internal/authguard/challenge.go @@ -85,7 +85,7 @@ func (c *Challenger) Challenge(ctx context.Context, op OperationType, reason str return nil } -// OperationDescription returns a short, user-visible label for an operation type. +// String returns a short, user-visible label for an operation type. func (op OperationType) String() string { switch op { case OpTierUpgrade: @@ -107,7 +107,7 @@ func VerifyIdentity(ctx context.Context, op OperationType, reason string) error 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.", + "For interactive use, re-enter your vault passphrase when prompted", op, ) } diff --git a/internal/authguard/challenge_test.go b/internal/authguard/challenge_test.go index 0deff68..802dd27 100644 --- a/internal/authguard/challenge_test.go +++ b/internal/authguard/challenge_test.go @@ -80,7 +80,7 @@ func TestChallenger_Challenge_NotAvailable(t *testing.T) { func TestChallenger_Challenge_AuthFails(t *testing.T) { c := &Challenger{ Authenticator: func() session.BiometricAuthenticator { - return &mockBioAuth{available: true, authErr: errors.New("user cancelled")} + return &mockBioAuth{available: true, authErr: errors.New("user canceled")} }, } err := c.Challenge(context.Background(), OpAuthMethodSet, "change auth method") diff --git a/internal/mcp/server/server_dispatch.go b/internal/mcp/server/server_dispatch.go index 8f1d778..4119491 100644 --- a/internal/mcp/server/server_dispatch.go +++ b/internal/mcp/server/server_dispatch.go @@ -138,7 +138,7 @@ func (s *Server) executeTool(ctx context.Context, name string, args json.RawMess if entryPath != "" { if policyErr := s.checkPolicy(ctx, entryPath, toolActionType(name)); policyErr != nil { if errors.Is(policyErr, authguard.ErrBiometryRequired) { - if bioErr := s.challengeBiometric(ctx, name, policyErr); bioErr != nil { + if bioErr := s.challengeBiometric(ctx, name); bioErr != nil { span.SetStatus(codes.Error, bioErr.Error()) metrics.RecordMCPRequest(name, agentName, "error", time.Since(start)) return nil, bioErr @@ -267,7 +267,7 @@ func (s *Server) detectAnomalyAsync(_ context.Context, toolName, entryPath, reqI // challengeBiometric performs a biometric identity verification challenge in // response to a policy rule that requires biometry. On success it returns nil; // on failure it returns an error suitable for surfacing to the MCP client. -func (s *Server) challengeBiometric(ctx context.Context, toolName string, policyErr error) error { +func (s *Server) challengeBiometric(ctx context.Context, toolName string) error { agentName := "" if s.agent != nil { agentName = s.agent.Name @@ -278,7 +278,7 @@ func (s *Server) challengeBiometric(ctx context.Context, toolName string, policy s.logAudit(ctx, "policy_biometry_unavailable", toolName, false) return fmt.Errorf( "policy requires biometric verification for tool %q, but biometric authentication is not available on this platform. "+ - "Agent %q cannot execute this tool.", toolName, agentName, + "Agent %q cannot execute this tool", toolName, agentName, ) }