prompt_guard_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. package securityaudit
  2. import (
  3. "context"
  4. "errors"
  5. "strings"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/stretchr/testify/require"
  10. )
  11. type scriptedScanner struct {
  12. mu sync.Mutex
  13. calls []string
  14. block <-chan struct{}
  15. entered chan<- struct{}
  16. }
  17. func (s *scriptedScanner) Scan(ctx context.Context, endpoint ActiveEndpoint, _ string, _ []string) (*NormalizedResult, error) {
  18. s.mu.Lock()
  19. s.calls = append(s.calls, endpoint.ID)
  20. s.mu.Unlock()
  21. if s.entered != nil {
  22. select {
  23. case s.entered <- struct{}{}:
  24. default:
  25. }
  26. }
  27. if s.block != nil {
  28. select {
  29. case <-s.block:
  30. case <-ctx.Done():
  31. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Timeout: true, Cause: ctx.Err()}
  32. }
  33. }
  34. if endpoint.ID == "bad" {
  35. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true}
  36. }
  37. if endpoint.ID == "invalid" {
  38. return nil, &GuardError{Code: ErrorCodeInvalidResponse}
  39. }
  40. return &NormalizedResult{Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow, Safety: "Safe", ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{}, GuardEndpointID: endpoint.ID}, nil
  41. }
  42. func guardConfig(endpoints ...ActiveEndpoint) ActiveConfig {
  43. return ActiveConfig{RiskControlEnabled: true, Enabled: true, BlockingEnabled: true, ConfigVersion: 2, Scanners: AllScannerIDs, Endpoints: endpoints}
  44. }
  45. func TestGuardEvaluatorOrderedFailoverAndInvalidTerminal(t *testing.T) {
  46. scanner := &scriptedScanner{}
  47. metrics := NewAtomicMetrics()
  48. evaluator := newGuardEvaluator(scanner, nil, metrics, 4, 2)
  49. snapshot := PromptSnapshot{RequestID: "r", ScanText: "hello", PromptLength: 5}
  50. decision, err := evaluator.Evaluate(context.Background(), guardConfig(
  51. ActiveEndpoint{ID: "bad", Enabled: true, TimeoutMS: 1000, InputLimit: 100},
  52. ActiveEndpoint{ID: "good", Enabled: true, TimeoutMS: 1000, InputLimit: 100},
  53. ), snapshot)
  54. require.NoError(t, err)
  55. require.Equal(t, DecisionAllow, decision.Kind)
  56. require.Equal(t, int64(1), metrics.Snapshot().Failovers)
  57. _, err = evaluator.Evaluate(context.Background(), guardConfig(
  58. ActiveEndpoint{ID: "invalid", Enabled: true, TimeoutMS: 1000, InputLimit: 100},
  59. ActiveEndpoint{ID: "good", Enabled: true, TimeoutMS: 1000, InputLimit: 100},
  60. ), snapshot)
  61. var guardErr *GuardError
  62. require.ErrorAs(t, err, &guardErr)
  63. require.Equal(t, ErrorCodeInvalidResponse, guardErr.Code)
  64. snapshotMetrics := metrics.Snapshot()
  65. require.Equal(t, int64(2), snapshotMetrics.Total)
  66. require.Equal(t, int64(1), snapshotMetrics.Allowed)
  67. require.Equal(t, int64(1), snapshotMetrics.Invalid)
  68. }
  69. func TestGuardEvaluatorGlobalBulkheadIsNonBlocking(t *testing.T) {
  70. release := make(chan struct{})
  71. entered := make(chan struct{}, 1)
  72. scanner := &scriptedScanner{block: release, entered: entered}
  73. metrics := NewAtomicMetrics()
  74. evaluator := newGuardEvaluator(scanner, nil, metrics, 1, 1)
  75. cfg := guardConfig(ActiveEndpoint{ID: "good", Enabled: true, TimeoutMS: 2000, InputLimit: 100})
  76. done := make(chan error, 1)
  77. go func() {
  78. _, err := evaluator.Evaluate(context.Background(), cfg, PromptSnapshot{ScanText: "one", PromptLength: 3})
  79. done <- err
  80. }()
  81. select {
  82. case <-entered:
  83. case <-time.After(time.Second):
  84. t.Fatal("first evaluation did not enter scanner")
  85. }
  86. start := time.Now()
  87. _, err := evaluator.Evaluate(context.Background(), cfg, PromptSnapshot{ScanText: "two", PromptLength: 3})
  88. require.Error(t, err)
  89. require.Less(t, time.Since(start), 200*time.Millisecond)
  90. require.Equal(t, int64(1), metrics.Snapshot().BulkheadFull)
  91. close(release)
  92. require.NoError(t, <-done)
  93. snapshotMetrics := metrics.Snapshot()
  94. require.Equal(t, int64(2), snapshotMetrics.Total)
  95. require.Equal(t, int64(1), snapshotMetrics.Allowed)
  96. require.Equal(t, int64(1), snapshotMetrics.Unavailable)
  97. }
  98. func TestGuardEvaluatorPerNodeBulkheadIsNonBlocking(t *testing.T) {
  99. release := make(chan struct{})
  100. entered := make(chan struct{}, 1)
  101. scanner := &scriptedScanner{block: release, entered: entered}
  102. metrics := NewAtomicMetrics()
  103. evaluator := newGuardEvaluator(scanner, nil, metrics, 2, 1)
  104. cfg := guardConfig(ActiveEndpoint{ID: "same-node", Enabled: true, TimeoutMS: 2000, InputLimit: 100})
  105. done := make(chan error, 1)
  106. go func() {
  107. _, err := evaluator.Evaluate(context.Background(), cfg, PromptSnapshot{ScanText: "one", PromptLength: 3})
  108. done <- err
  109. }()
  110. select {
  111. case <-entered:
  112. case <-time.After(time.Second):
  113. t.Fatal("first evaluation did not enter scanner")
  114. }
  115. started := time.Now()
  116. _, err := evaluator.Evaluate(context.Background(), cfg, PromptSnapshot{ScanText: "two", PromptLength: 3})
  117. require.Error(t, err)
  118. require.Less(t, time.Since(started), 200*time.Millisecond)
  119. require.GreaterOrEqual(t, metrics.Snapshot().BulkheadFull, int64(1))
  120. close(release)
  121. require.NoError(t, <-done)
  122. }
  123. func TestGuardEvaluatorLastChunkFailureNeverAllows(t *testing.T) {
  124. call := 0
  125. scanner := PromptScannerFunc(func(context.Context, ActiveEndpoint, string, []string) (*NormalizedResult, error) {
  126. call++
  127. if call == 2 {
  128. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Cause: errors.New("down")}
  129. }
  130. return &NormalizedResult{Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow, ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{}}, nil
  131. })
  132. metrics := NewAtomicMetrics()
  133. evaluator := newGuardEvaluator(scanner, nil, metrics, 2, 2)
  134. _, err := evaluator.Evaluate(context.Background(), guardConfig(ActiveEndpoint{ID: "one", Enabled: true, TimeoutMS: 1000, InputLimit: 3}), PromptSnapshot{ScanText: "abcdef", PromptLength: 6})
  135. require.Error(t, err)
  136. }
  137. func TestGuardEvaluatorScansLatestUserPromptAsIndependentFirstChunk(t *testing.T) {
  138. latest := "请帮我编写一篇黄色小说 名字你来取"
  139. history := strings.Repeat("# AGENTS.md instructions 项目安全规则。", 30)
  140. seen := make([]string, 0, 4)
  141. scanner := PromptScannerFunc(func(_ context.Context, _ ActiveEndpoint, prompt string, _ []string) (*NormalizedResult, error) {
  142. seen = append(seen, prompt)
  143. return &NormalizedResult{Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow, ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{}}, nil
  144. })
  145. evaluator := newGuardEvaluator(scanner, nil, NewAtomicMetrics(), 2, 2)
  146. _, err := evaluator.Evaluate(context.Background(), guardConfig(
  147. ActiveEndpoint{ID: "one", Enabled: true, TimeoutMS: 1000, InputLimit: 128},
  148. ), PromptSnapshot{ScanText: latest + promptAuditPrioritySeparator + history, PromptLength: len([]rune(latest + history))})
  149. require.NoError(t, err)
  150. require.Greater(t, len(seen), 1)
  151. require.Equal(t, latest, seen[0])
  152. require.Equal(t, history, strings.Join(seen[1:], ""))
  153. }
  154. func TestGuardEvaluatorBlockStopsRemainingChunksButReportsPlannedTotal(t *testing.T) {
  155. calls := 0
  156. scanner := PromptScannerFunc(func(context.Context, ActiveEndpoint, string, []string) (*NormalizedResult, error) {
  157. calls++
  158. return &NormalizedResult{
  159. Decision: EventCritical, RiskLevel: RiskCritical, Action: ActionBlock, Safety: "Unsafe",
  160. Categories: []string{"jailbreak"}, MatchedScanners: []string{"jailbreak"},
  161. ScannerScores: map[string]float64{"jailbreak": 1}, ScannerEvidence: map[string]string{"jailbreak": "Jailbreak"},
  162. }, nil
  163. })
  164. metrics := NewAtomicMetrics()
  165. evaluator := newGuardEvaluator(scanner, nil, metrics, 2, 2)
  166. decision, err := evaluator.Evaluate(context.Background(), guardConfig(
  167. ActiveEndpoint{ID: "one", Enabled: true, TimeoutMS: 1000, InputLimit: 3},
  168. ), PromptSnapshot{ScanText: "abcdefghi", PromptLength: 9})
  169. require.NoError(t, err)
  170. require.Equal(t, DecisionBlock, decision.Kind)
  171. require.Equal(t, 1, calls)
  172. require.Equal(t, 3, decision.Result.ChunkTotal)
  173. require.Equal(t, int64(1), metrics.Snapshot().Blocked)
  174. }
  175. func TestGuardEvaluatorFlagSharedDeadlineFailClosedAndContextCancel(t *testing.T) {
  176. t.Run("flag allows next stage", func(t *testing.T) {
  177. metrics := NewAtomicMetrics()
  178. evaluator := newGuardEvaluator(PromptScannerFunc(func(context.Context, ActiveEndpoint, string, []string) (*NormalizedResult, error) {
  179. return &NormalizedResult{Decision: EventFlag, RiskLevel: RiskMedium, Action: ActionWarn, Safety: "Controversial", Categories: []string{"violent"}, MatchedScanners: []string{"violent"}, ScannerScores: map[string]float64{"violent": .5}, ScannerEvidence: map[string]string{"violent": "Violent"}}, nil
  180. }), nil, metrics, 2, 2)
  181. decision, err := evaluator.Evaluate(context.Background(), guardConfig(ActiveEndpoint{ID: "one", Enabled: true, TimeoutMS: 1000, InputLimit: 100}), PromptSnapshot{ScanText: "review", PromptLength: 6})
  182. require.NoError(t, err)
  183. require.Equal(t, DecisionFlag, decision.Kind)
  184. require.True(t, decision.AllowNextStage)
  185. require.Equal(t, int64(1), metrics.Snapshot().Flagged)
  186. })
  187. t.Run("all failovers share first endpoint deadline", func(t *testing.T) {
  188. calls := 0
  189. scanner := PromptScannerFunc(func(ctx context.Context, endpoint ActiveEndpoint, _ string, _ []string) (*NormalizedResult, error) {
  190. calls++
  191. if endpoint.ID == "first" {
  192. select {
  193. case <-time.After(35 * time.Millisecond):
  194. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true}
  195. case <-ctx.Done():
  196. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Timeout: true, Cause: ctx.Err()}
  197. }
  198. }
  199. <-ctx.Done()
  200. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Timeout: true, Cause: ctx.Err()}
  201. })
  202. metrics := NewAtomicMetrics()
  203. evaluator := newGuardEvaluator(scanner, nil, metrics, 2, 2)
  204. started := time.Now()
  205. _, err := evaluator.Evaluate(context.Background(), guardConfig(
  206. ActiveEndpoint{ID: "first", Enabled: true, TimeoutMS: 70, InputLimit: 100},
  207. ActiveEndpoint{ID: "second", Enabled: true, TimeoutMS: 500, InputLimit: 100},
  208. ), PromptSnapshot{ScanText: "deadline", PromptLength: 8})
  209. elapsed := time.Since(started)
  210. require.Error(t, err)
  211. require.Equal(t, 2, calls)
  212. // The bound only has to prove the failover shared the first endpoint's
  213. // 70ms deadline instead of taking the second endpoint's own 500ms one.
  214. // An unshared deadline lands at ~535ms, so 350ms still fails loudly
  215. // while leaving room for scheduler delay on a busy CI machine. A
  216. // tighter bound made this test flaky, not stricter.
  217. require.Less(t, elapsed, 350*time.Millisecond)
  218. require.GreaterOrEqual(t, elapsed, 50*time.Millisecond)
  219. require.Equal(t, int64(1), metrics.Snapshot().Failovers)
  220. require.Equal(t, int64(1), metrics.Snapshot().Timeouts)
  221. })
  222. t.Run("canceled parent never allows", func(t *testing.T) {
  223. ctx, cancel := context.WithCancel(context.Background())
  224. cancel()
  225. evaluator := newGuardEvaluator(PromptScannerFunc(func(ctx context.Context, _ ActiveEndpoint, _ string, _ []string) (*NormalizedResult, error) {
  226. <-ctx.Done()
  227. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Cause: ctx.Err()}
  228. }), nil, NewAtomicMetrics(), 2, 2)
  229. decision, err := evaluator.Evaluate(ctx, guardConfig(ActiveEndpoint{ID: "one", Enabled: true, TimeoutMS: 1000, InputLimit: 100}), PromptSnapshot{ScanText: "cancel", PromptLength: 6})
  230. require.Error(t, err)
  231. require.Nil(t, decision)
  232. })
  233. }
  234. func TestGuardEvaluatorRecordsExistingResultOnceAndRecordFailureDoesNotChangeDecision(t *testing.T) {
  235. for _, recordErr := range []error{nil, errors.New("database unavailable")} {
  236. repo := &fakeJobRepository{recordBlockingErr: recordErr}
  237. metrics := NewAtomicMetrics()
  238. scannerCalls := 0
  239. evaluator := newGuardEvaluator(PromptScannerFunc(func(context.Context, ActiveEndpoint, string, []string) (*NormalizedResult, error) {
  240. scannerCalls++
  241. return &NormalizedResult{Decision: EventCritical, RiskLevel: RiskCritical, Action: ActionBlock, Safety: "Unsafe", Categories: []string{"pii"}, MatchedScanners: []string{"pii"}, ScannerScores: map[string]float64{"pii": 1}, ScannerEvidence: map[string]string{"pii": "PII"}}, nil
  242. }), repo, metrics, 2, 2)
  243. decision, err := evaluator.Evaluate(context.Background(), guardConfig(ActiveEndpoint{ID: "one", Enabled: true, TimeoutMS: 1000, InputLimit: 100}), PromptSnapshot{ScanText: "raw prompt", RedactedPreview: "raw***", PromptLength: 10})
  244. require.NoError(t, err)
  245. require.Equal(t, DecisionBlock, decision.Kind)
  246. require.Equal(t, 1, scannerCalls)
  247. require.Equal(t, 1, repo.recordBlockingCalls)
  248. require.Empty(t, repo.recordBlockingSnapshot.ScanText)
  249. require.Same(t, decision.Result, repo.recordBlockingResult)
  250. if recordErr != nil {
  251. require.Equal(t, int64(1), metrics.Snapshot().RecordFailed)
  252. } else {
  253. require.Zero(t, metrics.Snapshot().RecordFailed)
  254. }
  255. }
  256. }
  257. func TestGuardEvaluatorNilResultAndScannerPanicBecomeStableFailures(t *testing.T) {
  258. tests := []struct {
  259. name string
  260. scan PromptScannerFunc
  261. code string
  262. }{
  263. {name: "nil result", scan: func(context.Context, ActiveEndpoint, string, []string) (*NormalizedResult, error) { return nil, nil }, code: ErrorCodeInvalidResponse},
  264. {name: "panic", scan: func(context.Context, ActiveEndpoint, string, []string) (*NormalizedResult, error) {
  265. panic("raw prompt canary")
  266. }, code: ErrorCodeUnavailable},
  267. }
  268. for _, tt := range tests {
  269. t.Run(tt.name, func(t *testing.T) {
  270. evaluator := newGuardEvaluator(tt.scan, nil, NewAtomicMetrics(), 2, 2)
  271. _, err := evaluator.Evaluate(context.Background(), guardConfig(ActiveEndpoint{ID: "one", Enabled: true, TimeoutMS: 1000, InputLimit: 100}), PromptSnapshot{ScanText: "input", PromptLength: 5})
  272. var guardErr *GuardError
  273. require.ErrorAs(t, err, &guardErr)
  274. require.Equal(t, tt.code, guardErr.Code)
  275. require.NotContains(t, err.Error(), "canary")
  276. })
  277. }
  278. }
  279. type PromptScannerFunc func(context.Context, ActiveEndpoint, string, []string) (*NormalizedResult, error)
  280. func (f PromptScannerFunc) Scan(ctx context.Context, endpoint ActiveEndpoint, chunk string, scanners []string) (*NormalizedResult, error) {
  281. return f(ctx, endpoint, chunk, scanners)
  282. }