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
104 changes: 104 additions & 0 deletions action_hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package handshake

import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"sync"
"time"

"github.com/pilot-protocol/common/actionhook"
"github.com/pilot-protocol/common/decision"
)

const handshakeActionAdapterID = "pilot.handshake"

// ActionHook is the common optional before/after action boundary. Keeping the
// alias here gives embedders one stable handshake-facing type without a
// handshake-specific policy language.
type ActionHook = actionhook.Hook

type trustActionAttempt struct {
hook actionhook.Hook
envelope actionhook.Envelope
preflight actionhook.Preflight
once sync.Once
}

// SetActionHook attaches an explicitly configured hook. Passing nil restores
// unmanaged behavior. Composition roots should call this before Start.
func (hm *Manager) SetActionHook(hook ActionHook) {
hm.mu.Lock()
hm.actionHook = hook
hm.mu.Unlock()
}

// prepareTrustAction must be called without hm.mu held because a managed hook
// may perform bounded network I/O. It returns (nil, nil) when no hook is
// attached, which is the exact legacy path.
func (hm *Manager) prepareTrustAction(action string, peerNodeID uint32, direction, reason string, automatic bool, hasJustification bool) (*trustActionAttempt, error) {
hm.mu.RLock()
hook := hm.actionHook
hm.mu.RUnlock()
if hook == nil {
return nil, nil
}
attributes := map[string]string{
"peer_node_id": strconv.FormatUint(uint64(peerNodeID), 10),
"direction": direction,
"reason": reason,
"automatic": strconv.FormatBool(automatic),
"has_justification": strconv.FormatBool(hasJustification),
}
envelope, err := actionhook.NewEnvelope(
action, fmt.Sprintf("agent:%d", peerNodeID), actionhook.HashMetadata(attributes),
handshakeActionAdapterID, attributes, time.Now(),
)
if err != nil {
return nil, err
}
envelope.ResumeToken = fmt.Sprintf("%s:%s:%d", action, direction, peerNodeID)
if err := envelope.Validate(); err != nil {
return nil, err
}
preflight, err := hook.BeforeAction(context.Background(), envelope)
if err != nil {
return nil, fmt.Errorf("handshake: %s preflight for node %d: %w", action, peerNodeID, err)
}
attempt := &trustActionAttempt{hook: hook, envelope: envelope, preflight: preflight}
if err := preflight.RequireUnconstrained(); err != nil {
status := actionhook.StatusFailed
var blocked *actionhook.BlockedError
if errors.As(err, &blocked) {
switch blocked.Outcome {
case decision.Deny:
status = actionhook.StatusDenied
case decision.ApprovalRequired:
status = actionhook.StatusApprovalPending
}
}
attempt.complete(status, "preflight_blocked", nil)
return nil, fmt.Errorf("handshake: %s for node %d: %w", action, peerNodeID, err)
}
return attempt, nil
}

func (attempt *trustActionAttempt) complete(status actionhook.ObservedStatus, errorCode string, attributes map[string]string) {
if attempt == nil {
return
}
attempt.once.Do(func() {
result := actionhook.ObservedResult{
Status: status, ObservedAt: time.Now().Unix(), ErrorCode: errorCode, Attributes: attributes,
}
if err := attempt.hook.AfterAction(context.Background(), attempt.envelope, attempt.preflight, result); err != nil {
// Post-hooks are evidence-only. Never repeat or roll back a trust
// transition because evidence export failed.
slog.Error("handshake action post-hook failed", "action", attempt.envelope.Action, "action_id", attempt.envelope.ID, "error", err)
}
})
}
165 changes: 165 additions & 0 deletions enterprise_action_hook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package handshake

import (
"context"
"errors"
"sync"
"testing"
"time"

"github.com/pilot-protocol/common/actionhook"
"github.com/pilot-protocol/common/decision"
)

type trustHookCall struct {
envelope actionhook.Envelope
result actionhook.ObservedResult
}

type trustHookStub struct {
mu sync.Mutex
outcomes map[string]decision.Outcome
before []actionhook.Envelope
after []trustHookCall
afterErr error
}

func (hook *trustHookStub) BeforeAction(_ context.Context, envelope actionhook.Envelope) (actionhook.Preflight, error) {
hook.mu.Lock()
defer hook.mu.Unlock()
hook.before = append(hook.before, envelope)
outcome := decision.Allow
if configured, exists := hook.outcomes[envelope.Action]; exists {
outcome = configured
}
return actionhook.Preflight{Outcome: outcome, Reference: actionhook.DecisionReference{DecisionID: "test-decision"}}, nil
}

func (hook *trustHookStub) AfterAction(_ context.Context, envelope actionhook.Envelope, _ actionhook.Preflight, result actionhook.ObservedResult) error {
hook.mu.Lock()
defer hook.mu.Unlock()
hook.after = append(hook.after, trustHookCall{envelope: envelope, result: result})
return hook.afterErr
}

func (hook *trustHookStub) observed(action string, status actionhook.ObservedStatus) bool {
hook.mu.Lock()
defer hook.mu.Unlock()
for _, call := range hook.after {
if call.envelope.Action == action && call.result.Status == status {
return true
}
}
return false
}

func TestActionHookDenyTurnsEveryAutomaticTrustPathIntoPending(t *testing.T) {
for _, relayed := range []bool{false, true} {
t.Run(map[bool]string{false: "direct", true: "relayed"}[relayed], func(t *testing.T) {
runtime := newTestRuntime()
runtime.trustAutoApprove = true
manager := NewManager(runtime)
t.Cleanup(manager.Stop)
hook := &trustHookStub{outcomes: map[string]decision.Outcome{"trust.auto_accept": decision.Deny}}
manager.SetActionHook(hook)

if relayed {
manager.processRelayedRequest(71, "join")
} else {
manager.handleRequest(nil, &HandshakeMsg{NodeID: 71, PublicKey: "peer-key", Justification: "join"}, false)
}
manager.mu.RLock()
_, trusted := manager.trusted[71]
_, pending := manager.pending[71]
manager.mu.RUnlock()
if trusted || !pending {
t.Fatalf("denied auto-accept trusted=%v pending=%v", trusted, pending)
}
if !hook.observed("trust.auto_accept", actionhook.StatusDenied) {
t.Fatal("denied automatic action was not sent to the post-hook")
}
})
}
}

func TestActionHookDenyCoversManualAndOutgoingTrustGrantPaths(t *testing.T) {
hook := &trustHookStub{outcomes: map[string]decision.Outcome{"trust.accept": decision.Deny}}
manager := newTestHM(t, "")
t.Cleanup(manager.Stop)
manager.SetActionHook(hook)

manager.pending[80] = &PendingHandshake{NodeID: 80, Justification: "manual"}
if err := manager.ApproveHandshake(80); err == nil {
t.Fatal("manual approval bypassed trust.accept deny")
}
manager.outgoing[81] = time.Now()
manager.handleAccept(&HandshakeMsg{NodeID: 81, PublicKey: "peer-key"})
manager.outgoing[82] = time.Now()
manager.processRelayedApproval(82)

manager.mu.RLock()
defer manager.mu.RUnlock()
for _, peer := range []uint32{80, 81, 82} {
if _, trusted := manager.trusted[peer]; trusted {
t.Fatalf("peer %d became trusted despite trust.accept deny", peer)
}
}
if _, pending := manager.pending[80]; !pending {
t.Fatal("manual request must remain pending after denial")
}
if _, outgoing := manager.outgoing[81]; !outgoing {
t.Fatal("direct outgoing request must remain available after denial")
}
if _, outgoing := manager.outgoing[82]; !outgoing {
t.Fatal("relayed outgoing request must remain available after denial")
}
}

func TestActionHookApprovalRequiredSuspendsBeforeMutation(t *testing.T) {
hook := &trustHookStub{outcomes: map[string]decision.Outcome{"trust.accept": decision.ApprovalRequired}}
manager := newTestHM(t, "")
t.Cleanup(manager.Stop)
manager.SetActionHook(hook)
manager.pending[90] = &PendingHandshake{NodeID: 90}

err := manager.ApproveHandshake(90)
var blocked *actionhook.BlockedError
if !errors.As(err, &blocked) || blocked.Outcome != decision.ApprovalRequired {
t.Fatalf("expected approval-required block, got %v", err)
}
if _, trusted := manager.trusted[90]; trusted {
t.Fatal("approval-required action mutated trust")
}
if !hook.observed("trust.accept", actionhook.StatusApprovalPending) {
t.Fatal("approval suspension was not evidenced")
}
}

func TestActionHookDenyBlocksTrustRequestBeforeOutgoingState(t *testing.T) {
hook := &trustHookStub{outcomes: map[string]decision.Outcome{"trust.request": decision.Deny}}
manager := newTestHM(t, "")
t.Cleanup(manager.Stop)
manager.SetActionHook(hook)
if err := manager.SendRequest(101, "connect"); err == nil {
t.Fatal("denied trust request returned success")
}
if _, exists := manager.outgoing[101]; exists {
t.Fatal("denied trust request created outgoing state")
}
}

func TestPostHookFailureCannotUndoGrantedTrust(t *testing.T) {
hook := &trustHookStub{outcomes: map[string]decision.Outcome{"trust.accept": decision.Allow}, afterErr: errors.New("journal unavailable")}
manager := newTestHM(t, "")
t.Cleanup(manager.Stop)
manager.SetActionHook(hook)
manager.pending[111] = &PendingHandshake{NodeID: 111}
if err := manager.ApproveHandshake(111); err != nil {
t.Fatalf("post-hook failure changed action result: %v", err)
}
if _, trusted := manager.trusted[111]; !trusted {
t.Fatal("post-hook failure rolled back trust")
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/pilot-protocol/handshake
go 1.25.12

require (
github.com/pilot-protocol/common v0.5.11
github.com/pilot-protocol/common v0.5.12
github.com/pilot-protocol/pilotprotocol v1.13.4
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/pilot-protocol/common v0.5.11 h1:gaPOT2v3/FUAx61lqPw7yRsrzmDSRWL4+LRuQh8mrqs=
github.com/pilot-protocol/common v0.5.11/go.mod h1:Ybc6f1A37s3ShoEh1nBMVL9DPyYlxvkqPTvtbxaNWg4=
github.com/pilot-protocol/common v0.5.12 h1:ZQ7v8oX0VYtEcluraQZvqrYPMvRIxjixSZtNz8Xo5Uc=
github.com/pilot-protocol/common v0.5.12/go.mod h1:Ybc6f1A37s3ShoEh1nBMVL9DPyYlxvkqPTvtbxaNWg4=
github.com/pilot-protocol/pilotprotocol v1.13.4 h1:gX6KN/eDAmnUMSTtRSWGzeBkmYtgsFtD73WM394raNc=
github.com/pilot-protocol/pilotprotocol v1.13.4/go.mod h1:frjiZWZ0/c1AC0tVIS0+O6cE26N57+TFYVjLleafMqY=
github.com/pilot-protocol/rendezvous v0.2.5 h1:PvApwKHU2DnvK8pA6K9RAohUomZNu6vX6ccIEoTogvo=
Expand Down
Loading
Loading