crewly 1.11.6 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/config/skills/agent/onboarding/synthesize-hierarchy/SKILL.md +65 -0
  2. package/config/skills/agent/onboarding/synthesize-hierarchy/execute.sh +61 -0
  3. package/config/skills/agent/web-search/SKILL.md +70 -0
  4. package/config/skills/agent/web-search/execute.sh +170 -0
  5. package/config/skills/agent/web-search/skill.json +23 -0
  6. package/dist/backend/backend/src/constants.d.ts +12 -0
  7. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  8. package/dist/backend/backend/src/constants.js +12 -0
  9. package/dist/backend/backend/src/constants.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +22 -0
  11. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  12. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +58 -0
  13. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  14. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +3 -1
  16. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
  17. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts +27 -0
  18. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts.map +1 -1
  19. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js +108 -0
  20. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js.map +1 -1
  21. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts +6 -2
  22. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts.map +1 -1
  23. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js +9 -3
  24. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js.map +1 -1
  25. package/dist/backend/backend/src/index.d.ts.map +1 -1
  26. package/dist/backend/backend/src/index.js +36 -2
  27. package/dist/backend/backend/src/index.js.map +1 -1
  28. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +18 -0
  29. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +24 -2
  31. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  32. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts +102 -0
  33. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -0
  34. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +167 -0
  35. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -0
  36. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts +21 -0
  37. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/fission/fission-guard.service.js +30 -0
  39. package/dist/backend/backend/src/services/fission/fission-guard.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts +4 -0
  41. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js +8 -0
  43. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -1
  44. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts +79 -58
  45. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +140 -65
  47. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
  48. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts +117 -0
  49. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts.map +1 -0
  50. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js +189 -0
  51. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js.map +1 -0
  52. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js +1 -0
  54. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js.map +1 -1
  55. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js +2 -0
  57. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js.map +1 -1
  58. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js +17 -1
  60. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js.map +1 -1
  61. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +50 -0
  62. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
  63. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +71 -0
  64. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
  65. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +18 -0
  66. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/reconciler/reconciler.service.js +75 -1
  68. package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
  69. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts +115 -0
  70. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +189 -3
  72. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
  73. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
  74. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/session/pty/pty-session.js +61 -1
  76. package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
  77. package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
  78. package/dist/backend/backend/src/services/template/template.service.js +67 -2
  79. package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
  80. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts +19 -1
  81. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts.map +1 -1
  82. package/dist/backend/backend/src/services/v3/cascade-request-status.js +39 -2
  83. package/dist/backend/backend/src/services/v3/cascade-request-status.js.map +1 -1
  84. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts +41 -0
  85. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts.map +1 -1
  86. package/dist/backend/backend/src/services/v3/escalation-router.service.js +169 -0
  87. package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
  88. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts +4 -1
  89. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js +21 -0
  91. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js.map +1 -1
  92. package/dist/backend/backend/src/types/intent-task.types.d.ts.map +1 -1
  93. package/dist/backend/backend/src/types/intent-task.types.js +8 -0
  94. package/dist/backend/backend/src/types/intent-task.types.js.map +1 -1
  95. package/dist/backend/backend/src/types/v2/request.types.d.ts +1 -1
  96. package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
  97. package/dist/backend/backend/src/types/v2/request.types.js +1 -0
  98. package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
  99. package/dist/cli/backend/src/constants.d.ts +12 -0
  100. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  101. package/dist/cli/backend/src/constants.js +12 -0
  102. package/dist/cli/backend/src/constants.js.map +1 -1
  103. package/package.json +9 -3
  104. package/packages/crewly-agent/README.md +27 -0
  105. package/packages/crewly-agent/bin/crewly-agent +33 -0
  106. package/packages/crewly-agent/package.json +39 -0
  107. package/packages/crewly-agent/src/cli.ts +168 -0
  108. package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +2355 -0
  109. package/packages/crewly-agent/src/runtime/agent-runner.service.ts +1827 -0
  110. package/packages/crewly-agent/src/runtime/agent-stream.service.test.ts +153 -0
  111. package/packages/crewly-agent/src/runtime/agent-stream.service.ts +225 -0
  112. package/packages/crewly-agent/src/runtime/agent-worker.test.ts +171 -0
  113. package/packages/crewly-agent/src/runtime/agent-worker.ts +193 -0
  114. package/packages/crewly-agent/src/runtime/api-client.ts +143 -0
  115. package/packages/crewly-agent/src/runtime/approval-queue.service.ts +307 -0
  116. package/packages/crewly-agent/src/runtime/audit-log.service.test.ts +208 -0
  117. package/packages/crewly-agent/src/runtime/audit-log.service.ts +332 -0
  118. package/packages/crewly-agent/src/runtime/audit-trail.service.test.ts +178 -0
  119. package/packages/crewly-agent/src/runtime/audit-trail.service.ts +151 -0
  120. package/packages/crewly-agent/src/runtime/auditor-tools.test.ts +274 -0
  121. package/packages/crewly-agent/src/runtime/auditor-tools.ts +311 -0
  122. package/packages/crewly-agent/src/runtime/cloud-config.ts +67 -0
  123. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.test.ts +165 -0
  124. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.ts +168 -0
  125. package/packages/crewly-agent/src/runtime/env-isolation.service.ts +246 -0
  126. package/packages/crewly-agent/src/runtime/in-process-log-buffer.test.ts +280 -0
  127. package/packages/crewly-agent/src/runtime/in-process-log-buffer.ts +317 -0
  128. package/packages/crewly-agent/src/runtime/index.ts +38 -0
  129. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.test.ts +352 -0
  130. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.ts +244 -0
  131. package/packages/crewly-agent/src/runtime/model-manager.test.ts +326 -0
  132. package/packages/crewly-agent/src/runtime/model-manager.ts +363 -0
  133. package/packages/crewly-agent/src/runtime/output-filter.service.ts +175 -0
  134. package/packages/crewly-agent/src/runtime/prompt-guard.service.ts +303 -0
  135. package/packages/crewly-agent/src/runtime/rate-limiter.test.ts +228 -0
  136. package/packages/crewly-agent/src/runtime/rate-limiter.ts +353 -0
  137. package/packages/crewly-agent/src/runtime/tool-registry.test.ts +2510 -0
  138. package/packages/crewly-agent/src/runtime/tool-registry.ts +2104 -0
  139. package/packages/crewly-agent/src/runtime/types.test.ts +519 -0
  140. package/packages/crewly-agent/src/runtime/types.ts +637 -0
  141. package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +131 -0
  142. package/packages/crewly-agent/src/runtime/web-search.tool.ts +140 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Prompt Guard Service — Prompt Injection Protection
3
+ *
4
+ * Detects and blocks prompt injection attempts that try to extract API keys,
5
+ * secrets, or sensitive environment variables through agent commands.
6
+ *
7
+ * Integrates with the tool registry to block dangerous bash commands and
8
+ * logs blocked attempts to the audit trail.
9
+ *
10
+ * @module services/agent/crewly-agent/prompt-guard.service
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const guard = new PromptGuardService();
15
+ * const result = guard.checkCommand('echo $ANTHROPIC_API_KEY');
16
+ * // { blocked: true, reason: 'Key extraction attempt: echo env var', pattern: '...' }
17
+ * ```
18
+ */
19
+
20
+ import type { AuditEntry, ToolSensitivity } from './types.js';
21
+
22
+ /**
23
+ * A pattern that detects key extraction attempts.
24
+ */
25
+ export interface GuardPattern {
26
+ /** Regex to match the dangerous command or prompt */
27
+ pattern: RegExp;
28
+ /** Human-readable reason for blocking */
29
+ reason: string;
30
+ /** Category for audit logging */
31
+ category: 'env_extraction' | 'key_dump' | 'prompt_injection' | 'file_exfiltration';
32
+ }
33
+
34
+ /**
35
+ * Result of a prompt guard check.
36
+ */
37
+ export interface GuardCheckResult {
38
+ /** Whether the command/prompt was blocked */
39
+ blocked: boolean;
40
+ /** Reason for blocking (empty if not blocked) */
41
+ reason: string;
42
+ /** Category of the threat (undefined if not blocked) */
43
+ category?: GuardPattern['category'];
44
+ /** The matched pattern source (for audit logging) */
45
+ matchedPattern?: string;
46
+ }
47
+
48
+ /**
49
+ * Patterns that detect attempts to extract API keys or secrets via bash commands.
50
+ */
51
+ export const KEY_EXTRACTION_PATTERNS: readonly GuardPattern[] = [
52
+ // Direct env var echo/print
53
+ {
54
+ pattern: /\becho\s+\$[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH)/i,
55
+ reason: 'Attempt to echo sensitive environment variable',
56
+ category: 'env_extraction',
57
+ },
58
+ {
59
+ pattern: /\bprintf\s+.*\$[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH)/i,
60
+ reason: 'Attempt to printf sensitive environment variable',
61
+ category: 'env_extraction',
62
+ },
63
+ // env/printenv with grep for secrets
64
+ {
65
+ pattern: /\benv\b.*\|\s*grep\s+.*(?:KEY|SECRET|TOKEN|PASSWORD|API|AUTH)/i,
66
+ reason: 'Attempt to grep env for secrets',
67
+ category: 'env_extraction',
68
+ },
69
+ {
70
+ pattern: /\bprintenv\b.*(?:KEY|SECRET|TOKEN|PASSWORD|API|AUTH)/i,
71
+ reason: 'Attempt to printenv sensitive variable',
72
+ category: 'env_extraction',
73
+ },
74
+ // set | grep (bash set command dumps all vars)
75
+ {
76
+ pattern: /\bset\b\s*\|\s*grep\s+.*(?:KEY|SECRET|TOKEN|PASSWORD|API|AUTH)/i,
77
+ reason: 'Attempt to dump shell variables and grep for secrets',
78
+ category: 'env_extraction',
79
+ },
80
+ // Reading sensitive config files (must come before bare env/printenv to avoid early match on "cat .env")
81
+ {
82
+ pattern: /\bcat\s+.*\.env\b/i,
83
+ reason: 'Attempt to read .env file',
84
+ category: 'file_exfiltration',
85
+ },
86
+ {
87
+ pattern: /\bcat\s+.*(?:credentials|secrets|\.aws\/credentials|\.npmrc|\.netrc|\.pgpass)/i,
88
+ reason: 'Attempt to read credentials file',
89
+ category: 'file_exfiltration',
90
+ },
91
+ {
92
+ pattern: /\bcat\s+.*\/etc\/shadow\b/i,
93
+ reason: 'Attempt to read system password file',
94
+ category: 'file_exfiltration',
95
+ },
96
+ // Direct env dump
97
+ {
98
+ pattern: /\benv\s*$/i,
99
+ reason: 'Attempt to dump all environment variables',
100
+ category: 'key_dump',
101
+ },
102
+ {
103
+ pattern: /\bprintenv\s*$/i,
104
+ reason: 'Attempt to dump all environment variables',
105
+ category: 'key_dump',
106
+ },
107
+ // compgen dumps shell vars/functions
108
+ {
109
+ pattern: /\bcompgen\s+-[ev]/i,
110
+ reason: 'Attempt to dump shell variables via compgen',
111
+ category: 'key_dump',
112
+ },
113
+ // base64 encoding secrets for exfiltration
114
+ {
115
+ pattern: /\bbase64\b.*\$[A-Z_]*(?:KEY|SECRET|TOKEN)/i,
116
+ reason: 'Attempt to base64 encode secret for exfiltration',
117
+ category: 'file_exfiltration',
118
+ },
119
+ // curl/wget exfiltration of env vars
120
+ {
121
+ pattern: /\b(?:curl|wget)\b.*\$[A-Z_]*(?:KEY|SECRET|TOKEN)/i,
122
+ reason: 'Attempt to exfiltrate secret via HTTP request',
123
+ category: 'file_exfiltration',
124
+ },
125
+ // Python/Node one-liners to access env
126
+ {
127
+ pattern: /\bpython[23]?\s+-c\s+.*os\.environ/i,
128
+ reason: 'Attempt to access env via Python subprocess',
129
+ category: 'env_extraction',
130
+ },
131
+ {
132
+ pattern: /\bnode\s+-e\s+.*process\.env/i,
133
+ reason: 'Attempt to access env via Node subprocess',
134
+ category: 'env_extraction',
135
+ },
136
+ // Specific key variable names in $() or backtick subshells
137
+ {
138
+ pattern: /\$\(.*(?:ANTHROPIC_API_KEY|OPENAI_API_KEY|GOOGLE_API_KEY|AWS_SECRET|STRIPE_SECRET)/i,
139
+ reason: 'Attempt to expand sensitive env var in subshell',
140
+ category: 'env_extraction',
141
+ },
142
+ ] as const;
143
+
144
+ /**
145
+ * Prompt-level injection patterns (detected in natural language prompts, not just commands).
146
+ */
147
+ export const PROMPT_INJECTION_PATTERNS: readonly GuardPattern[] = [
148
+ {
149
+ pattern: /(?:print|show|reveal|tell\s+me|display|output|give\s+me|share)\s+(?:(?:your|the|my|me\s+the)\s+)?(?:api[_ ]?key|secret|token|password|credentials)/i,
150
+ reason: 'Prompt injection: request to reveal API key/secret',
151
+ category: 'prompt_injection',
152
+ },
153
+ {
154
+ pattern: /(?:what\s+is|what's)\s+(?:your|the|my)\s+(?:api[_ ]?key|secret[_ ]?key|auth[_ ]?token|password)/i,
155
+ reason: 'Prompt injection: question about API key/secret',
156
+ category: 'prompt_injection',
157
+ },
158
+ {
159
+ pattern: /ignore\s+(?:previous|all|your)\s+(?:instructions|rules|safety)/i,
160
+ reason: 'Prompt injection: instruction override attempt',
161
+ category: 'prompt_injection',
162
+ },
163
+ {
164
+ pattern: /(?:override|bypass|disable|turn\s+off)\s+(?:security|safety|filter|guardrail|redaction)/i,
165
+ reason: 'Prompt injection: attempt to disable security filters',
166
+ category: 'prompt_injection',
167
+ },
168
+ ] as const;
169
+
170
+ /**
171
+ * Additional blocked command patterns for tool-registry.ts integration.
172
+ * These extend the existing BLOCKED_COMMAND_PATTERNS with key extraction blocks.
173
+ */
174
+ export const KEY_EXTRACTION_BLOCKED_COMMANDS: RegExp[] = [
175
+ /\benv\s*$/i,
176
+ /\bprintenv\s*$/i,
177
+ /\benv\b.*\|\s*grep\s+.*(?:KEY|SECRET|TOKEN|PASSWORD|API)/i,
178
+ /\bset\b\s*\|\s*grep\s+.*(?:KEY|SECRET|TOKEN|PASSWORD|API)/i,
179
+ /\bcompgen\s+-[ev]/i,
180
+ /\bcat\s+.*\.env\b/i,
181
+ /\bcat\s+.*(?:credentials|secrets|\.aws\/credentials|\.npmrc|\.netrc|\.pgpass)/i,
182
+ ];
183
+
184
+ /**
185
+ * Service that detects and blocks prompt injection and key extraction attempts.
186
+ */
187
+ export class PromptGuardService {
188
+ private readonly commandPatterns: readonly GuardPattern[];
189
+ private readonly promptPatterns: readonly GuardPattern[];
190
+
191
+ /**
192
+ * Creates a new PromptGuardService.
193
+ *
194
+ * @param additionalCommandPatterns - Extra command-level patterns
195
+ * @param additionalPromptPatterns - Extra prompt-level patterns
196
+ */
197
+ constructor(
198
+ additionalCommandPatterns?: GuardPattern[],
199
+ additionalPromptPatterns?: GuardPattern[],
200
+ ) {
201
+ this.commandPatterns = additionalCommandPatterns
202
+ ? [...KEY_EXTRACTION_PATTERNS, ...additionalCommandPatterns]
203
+ : KEY_EXTRACTION_PATTERNS;
204
+ this.promptPatterns = additionalPromptPatterns
205
+ ? [...PROMPT_INJECTION_PATTERNS, ...additionalPromptPatterns]
206
+ : PROMPT_INJECTION_PATTERNS;
207
+ }
208
+
209
+ /**
210
+ * Checks a bash command for key extraction attempts.
211
+ *
212
+ * @param command - Raw bash command string
213
+ * @returns Guard check result
214
+ */
215
+ checkCommand(command: string): GuardCheckResult {
216
+ if (!command) {
217
+ return { blocked: false, reason: '' };
218
+ }
219
+
220
+ for (const guard of this.commandPatterns) {
221
+ if (guard.pattern.test(command)) {
222
+ return {
223
+ blocked: true,
224
+ reason: guard.reason,
225
+ category: guard.category,
226
+ matchedPattern: guard.pattern.source,
227
+ };
228
+ }
229
+ }
230
+
231
+ return { blocked: false, reason: '' };
232
+ }
233
+
234
+ /**
235
+ * Checks a user/agent prompt for injection attempts targeting secrets.
236
+ *
237
+ * @param prompt - The text prompt or message
238
+ * @returns Guard check result
239
+ */
240
+ checkPrompt(prompt: string): GuardCheckResult {
241
+ if (!prompt) {
242
+ return { blocked: false, reason: '' };
243
+ }
244
+
245
+ for (const guard of this.promptPatterns) {
246
+ if (guard.pattern.test(prompt)) {
247
+ return {
248
+ blocked: true,
249
+ reason: guard.reason,
250
+ category: guard.category,
251
+ matchedPattern: guard.pattern.source,
252
+ };
253
+ }
254
+ }
255
+
256
+ return { blocked: false, reason: '' };
257
+ }
258
+
259
+ /**
260
+ * Checks both command and prompt patterns.
261
+ * Use this for comprehensive scanning of any agent input.
262
+ *
263
+ * @param text - Text to check (command or prompt)
264
+ * @returns Guard check result
265
+ */
266
+ check(text: string): GuardCheckResult {
267
+ const cmdResult = this.checkCommand(text);
268
+ if (cmdResult.blocked) return cmdResult;
269
+ return this.checkPrompt(text);
270
+ }
271
+
272
+ /**
273
+ * Creates an audit entry for a blocked attempt.
274
+ *
275
+ * @param sessionName - Agent session that triggered the block
276
+ * @param toolName - Tool that was used (e.g. 'bash_exec')
277
+ * @param command - The blocked command
278
+ * @param guardResult - The guard check result
279
+ * @returns AuditEntry suitable for the audit log
280
+ */
281
+ createAuditEntry(
282
+ sessionName: string,
283
+ toolName: string,
284
+ command: string,
285
+ guardResult: GuardCheckResult,
286
+ ): AuditEntry {
287
+ return {
288
+ timestamp: new Date().toISOString(),
289
+ sessionName,
290
+ toolName,
291
+ sensitivity: 'destructive' as ToolSensitivity,
292
+ args: {
293
+ command,
294
+ blockedReason: guardResult.reason,
295
+ category: guardResult.category || 'unknown',
296
+ matchedPattern: guardResult.matchedPattern || '',
297
+ },
298
+ success: false,
299
+ error: `Blocked by prompt guard: ${guardResult.reason}`,
300
+ durationMs: 0,
301
+ };
302
+ }
303
+ }
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { RateLimiter, RATE_LIMITER_DEFAULTS } from './rate-limiter.js';
3
+
4
+ describe('RateLimiter', () => {
5
+ let limiter: RateLimiter<string>;
6
+
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ limiter = new RateLimiter<string>({
10
+ maxRequestsPerWindow: 3,
11
+ windowMs: 10_000,
12
+ maxRetries: 2,
13
+ initialBackoffMs: 100,
14
+ backoffMultiplier: 2,
15
+ maxBackoffMs: 1000,
16
+ coalesceWindowMs: 50,
17
+ });
18
+ });
19
+
20
+ afterEach(() => {
21
+ limiter.reset();
22
+ vi.useRealTimers();
23
+ });
24
+
25
+ describe('defaults', () => {
26
+ it('should have sensible default config', () => {
27
+ const defaultLimiter = new RateLimiter<string>();
28
+ const config = defaultLimiter.getConfig();
29
+ expect(config.maxRequestsPerWindow).toBe(RATE_LIMITER_DEFAULTS.maxRequestsPerWindow);
30
+ expect(config.windowMs).toBe(RATE_LIMITER_DEFAULTS.windowMs);
31
+ expect(config.maxRetries).toBe(RATE_LIMITER_DEFAULTS.maxRetries);
32
+ defaultLimiter.reset();
33
+ });
34
+ });
35
+
36
+ describe('basic enqueue', () => {
37
+ it('should process a single message', async () => {
38
+ const handler = vi.fn<(msg: string) => Promise<string>>().mockResolvedValue('ok');
39
+ const resultP = limiter.enqueue('hello', undefined, handler);
40
+ await vi.advanceTimersByTimeAsync(100);
41
+ const result = await resultP;
42
+ expect(result).toBe('ok');
43
+ expect(handler).toHaveBeenCalledWith('hello', undefined);
44
+ });
45
+
46
+ it('should pass metadata through', async () => {
47
+ const handler = vi.fn<(msg: string, meta?: Record<string, string>) => Promise<string>>().mockResolvedValue('ok');
48
+ const meta = { channelId: 'C1' };
49
+ const resultP = limiter.enqueue('hello', meta, handler);
50
+ await vi.advanceTimersByTimeAsync(100);
51
+ await resultP;
52
+ expect(handler).toHaveBeenCalledWith('hello', meta);
53
+ });
54
+ });
55
+
56
+ describe('message coalescing', () => {
57
+ it('should coalesce messages arriving within the coalesce window', async () => {
58
+ const handler = vi.fn<(msg: string) => Promise<string>>().mockResolvedValue('ok');
59
+
60
+ // Enqueue 3 messages rapidly (within 50ms coalesce window)
61
+ const p1 = limiter.enqueue('msg1', undefined, handler);
62
+ const p2 = limiter.enqueue('msg2', undefined, handler);
63
+ const p3 = limiter.enqueue('msg3', undefined, handler);
64
+
65
+ // Advance past coalesce window
66
+ await vi.advanceTimersByTimeAsync(100);
67
+
68
+ const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
69
+
70
+ // All should get the same result
71
+ expect(r1).toBe('ok');
72
+ expect(r2).toBe('ok');
73
+ expect(r3).toBe('ok');
74
+
75
+ // Handler called only once (messages coalesced)
76
+ expect(handler).toHaveBeenCalledTimes(1);
77
+
78
+ // The coalesced message should mention all 3
79
+ const callArg = handler.mock.calls[0][0];
80
+ expect(callArg).toContain('3 messages received');
81
+ expect(callArg).toContain('msg1');
82
+ expect(callArg).toContain('msg2');
83
+ expect(callArg).toContain('msg3');
84
+ });
85
+
86
+ it('should not coalesce a single message', async () => {
87
+ const handler = vi.fn<(msg: string) => Promise<string>>().mockResolvedValue('ok');
88
+
89
+ const p = limiter.enqueue('single', undefined, handler);
90
+ await vi.advanceTimersByTimeAsync(100);
91
+ await p;
92
+
93
+ expect(handler).toHaveBeenCalledWith('single', undefined);
94
+ });
95
+ });
96
+
97
+ describe('429 retry', () => {
98
+ it('should retry on quota exceeded error', async () => {
99
+ const handler = vi.fn<(msg: string) => Promise<string>>()
100
+ .mockRejectedValueOnce(new Error('429 Too Many Requests'))
101
+ .mockResolvedValueOnce('recovered');
102
+
103
+ const resultP = limiter.enqueue('test', undefined, handler);
104
+ // Advance past coalesce window
105
+ await vi.advanceTimersByTimeAsync(100);
106
+ // Advance past backoff (100ms)
107
+ await vi.advanceTimersByTimeAsync(200);
108
+ const result = await resultP;
109
+
110
+ expect(result).toBe('recovered');
111
+ expect(handler).toHaveBeenCalledTimes(2);
112
+ });
113
+
114
+ it('should retry on quota exceeded message', async () => {
115
+ const handler = vi.fn<(msg: string) => Promise<string>>()
116
+ .mockRejectedValueOnce(new Error('You exceeded your current quota'))
117
+ .mockResolvedValueOnce('ok');
118
+
119
+ const resultP = limiter.enqueue('test', undefined, handler);
120
+ await vi.advanceTimersByTimeAsync(100);
121
+ await vi.advanceTimersByTimeAsync(200);
122
+ const result = await resultP;
123
+
124
+ expect(result).toBe('ok');
125
+ expect(handler).toHaveBeenCalledTimes(2);
126
+ });
127
+
128
+ it('should retry on RESOURCE_EXHAUSTED', async () => {
129
+ const handler = vi.fn<(msg: string) => Promise<string>>()
130
+ .mockRejectedValueOnce(new Error('RESOURCE_EXHAUSTED: quota limit reached'))
131
+ .mockResolvedValueOnce('ok');
132
+
133
+ const resultP = limiter.enqueue('test', undefined, handler);
134
+ await vi.advanceTimersByTimeAsync(100);
135
+ await vi.advanceTimersByTimeAsync(200);
136
+ const result = await resultP;
137
+
138
+ expect(result).toBe('ok');
139
+ });
140
+
141
+ it('should NOT retry on non-quota errors', async () => {
142
+ const handler = vi.fn<(msg: string) => Promise<string>>()
143
+ .mockRejectedValue(new Error('Invalid API key'));
144
+
145
+ // Attach catch immediately to prevent unhandled rejection warning
146
+ let caughtError: Error | null = null;
147
+ const resultP = limiter.enqueue('test', undefined, handler)
148
+ .catch((e: Error) => { caughtError = e; return 'caught' as string; });
149
+
150
+ for (let i = 0; i < 10; i++) {
151
+ await vi.advanceTimersByTimeAsync(100);
152
+ }
153
+ await resultP;
154
+
155
+ expect(caughtError).not.toBeNull();
156
+ expect(caughtError!.message).toBe('Invalid API key');
157
+ expect(handler).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it('should reject after max retries exhausted', async () => {
161
+ const handler = vi.fn<(msg: string) => Promise<string>>()
162
+ .mockRejectedValue(new Error('429 rate limited'));
163
+
164
+ let caughtError: Error | null = null;
165
+ const resultP = limiter.enqueue('test', undefined, handler)
166
+ .catch((e: Error) => { caughtError = e; return 'caught' as string; });
167
+
168
+ for (let i = 0; i < 20; i++) {
169
+ await vi.advanceTimersByTimeAsync(200);
170
+ }
171
+ await resultP;
172
+
173
+ expect(caughtError).not.toBeNull();
174
+ expect(caughtError!.message).toContain('Rate limit retries exhausted');
175
+ expect(handler).toHaveBeenCalledTimes(3); // initial + 2 retries
176
+ });
177
+
178
+ it('should reject all coalesced messages on retry exhaustion', async () => {
179
+ const handler = vi.fn<(msg: string) => Promise<string>>()
180
+ .mockRejectedValue(new Error('429'));
181
+
182
+ let err1: Error | null = null;
183
+ let err2: Error | null = null;
184
+ const p1 = limiter.enqueue('a', undefined, handler)
185
+ .catch((e: Error) => { err1 = e; return 'caught' as string; });
186
+ const p2 = limiter.enqueue('b', undefined, handler)
187
+ .catch((e: Error) => { err2 = e; return 'caught' as string; });
188
+
189
+ for (let i = 0; i < 20; i++) {
190
+ await vi.advanceTimersByTimeAsync(200);
191
+ }
192
+ await Promise.all([p1, p2]);
193
+
194
+ expect(err1).not.toBeNull();
195
+ expect(err1!.message).toContain('Rate limit retries exhausted');
196
+ expect(err2).not.toBeNull();
197
+ expect(err2!.message).toContain('Rate limit retries exhausted');
198
+ });
199
+ });
200
+
201
+ describe('rate limiting (window enforcement)', () => {
202
+ it('should track requests in window', async () => {
203
+ const handler = vi.fn<(msg: string) => Promise<string>>().mockResolvedValue('ok');
204
+
205
+ expect(limiter.getRequestCountInWindow()).toBe(0);
206
+
207
+ const p1 = limiter.enqueue('a', undefined, handler);
208
+ await vi.advanceTimersByTimeAsync(100);
209
+ await p1;
210
+ expect(limiter.getRequestCountInWindow()).toBe(1);
211
+ });
212
+ });
213
+
214
+ describe('reset', () => {
215
+ it('should clear all state', async () => {
216
+ const handler = vi.fn<(msg: string) => Promise<string>>().mockResolvedValue('ok');
217
+
218
+ const p = limiter.enqueue('test', undefined, handler);
219
+ await vi.advanceTimersByTimeAsync(100);
220
+ await p;
221
+
222
+ limiter.reset();
223
+ expect(limiter.getQueueLength()).toBe(0);
224
+ expect(limiter.isProcessing()).toBe(false);
225
+ expect(limiter.getRequestCountInWindow()).toBe(0);
226
+ });
227
+ });
228
+ });