@strav/brain 0.3.30 → 0.3.33

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/brain",
3
- "version": "0.3.30",
3
+ "version": "0.3.33",
4
4
  "type": "module",
5
5
  "description": "AI module for the Strav framework",
6
6
  "license": "MIT",
@@ -15,10 +15,10 @@
15
15
  "CHANGELOG.md"
16
16
  ],
17
17
  "peerDependencies": {
18
- "@strav/kernel": "0.3.30"
18
+ "@strav/kernel": "0.3.33"
19
19
  },
20
20
  "dependencies": {
21
- "@strav/workflow": "0.3.30",
21
+ "@strav/workflow": "0.3.33",
22
22
  "zod": "^3.25 || ^4.0"
23
23
  },
24
24
  "scripts": {
package/src/helpers.ts CHANGED
@@ -2,6 +2,7 @@ import BrainManager from './brain_manager.ts'
2
2
  import { Agent } from './agent.ts'
3
3
  import { Workflow } from './workflow.ts'
4
4
  import { zodToJsonSchema } from './utils/schema.ts'
5
+ import { interpolateInstructions } from './utils/prompt.ts'
5
6
  import { MemoryManager } from './memory/memory_manager.ts'
6
7
  import { ContextBudget } from './memory/context_budget.ts'
7
8
  import type { MemoryConfig, SerializedMemoryThread, Fact } from './memory/types.ts'
@@ -335,12 +336,12 @@ export class AgentRunner<T extends Agent = Agent> {
335
336
  }
336
337
  }
337
338
 
338
- // Build system prompt with context interpolation
339
+ // Build system prompt with context interpolation. interpolateInstructions
340
+ // warns when a context value looks like a prompt-injection attempt — see
341
+ // packages/brain/CLAUDE.md ("Prompt-injection threat model").
339
342
  let system: string | undefined = agent.instructions || undefined
340
343
  if (system) {
341
- for (const [key, value] of Object.entries(this._context)) {
342
- system = system.replaceAll(`{{${key}}}`, String(value))
343
- }
344
+ system = interpolateInstructions(system, this._context)
344
345
  }
345
346
 
346
347
  // Prepare structured output schema
@@ -483,9 +484,7 @@ export class AgentRunner<T extends Agent = Agent> {
483
484
 
484
485
  let system: string | undefined = agent.instructions || undefined
485
486
  if (system) {
486
- for (const [key, value] of Object.entries(this._context)) {
487
- system = system.replaceAll(`{{${key}}}`, String(value))
488
- }
487
+ system = interpolateInstructions(system, this._context)
489
488
  }
490
489
 
491
490
  let schema: JsonSchema | undefined
@@ -1,6 +1,7 @@
1
1
  import { parseSSE } from '../utils/sse_parser.ts'
2
2
  import { retryableFetch, type RetryOptions } from '../utils/retry.ts'
3
3
  import { ExternalServiceError } from '@strav/kernel'
4
+ import { scrubProviderError } from '../utils/error_scrub.ts'
4
5
  import type {
5
6
  AIProvider,
6
7
  CompletionRequest,
@@ -150,7 +151,8 @@ export class OpenAIResponsesProvider implements AIProvider {
150
151
 
151
152
  // ── Error ─────────────────────────────────────────────────────
152
153
  if (eventType === 'error') {
153
- throw new ExternalServiceError('OpenAI', undefined, data.message ?? JSON.stringify(data))
154
+ const message = typeof data.message === 'string' ? data.message : JSON.stringify(data)
155
+ throw new ExternalServiceError('OpenAI', undefined, scrubProviderError(message))
154
156
  }
155
157
  }
156
158
  }
@@ -0,0 +1,5 @@
1
+ // Re-export the shared kernel helper so the same scrubber is used across
2
+ // every package that wraps upstream-provider errors. Keeping this thin
3
+ // re-export avoids a breaking import-path change for callers that
4
+ // already pulled `scrubProviderError` from `@strav/brain/utils/error_scrub`.
5
+ export { scrubProviderError } from '@strav/kernel'
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Heuristic detector for prompt-injection markers in untrusted strings
3
+ * destined for the system prompt. The interpolation in
4
+ * `agent.instructions` does naïve `replaceAll` of `{{key}}` placeholders
5
+ * with string values — any user-controlled value flowing through is a
6
+ * prompt-injection vector against the LLM.
7
+ *
8
+ * We can't fully solve this at the template layer (the proper fix is to
9
+ * pass values as structured user-role messages, not interpolate them
10
+ * into the system role). What we can do is detect the easy cases and
11
+ * warn the developer that a value looks suspicious. Detection here is
12
+ * deliberately loose — false positives are cheap, missed cases let
13
+ * exploits through silently.
14
+ */
15
+ const INJECTION_PATTERNS: readonly RegExp[] = [
16
+ /ignore\s+(?:[\w\s]{0,30}\s+)?(?:instructions?|prompts?|messages?)/i,
17
+ /disregard\s+(?:[\w\s]{0,30}\s+)?(?:instructions?|prompts?|messages?)/i,
18
+ /(?:^|\n)\s*system\s*[:>]/i,
19
+ /(?:^|\n)\s*assistant\s*[:>]/i,
20
+ /\bsystem\s*:\s*\S/i,
21
+ /you\s+are\s+now\s+(?:a|an|the)/i,
22
+ /act\s+as\s+(?:a|an|the)\s+(?:different|new)/i,
23
+ /\[INST\]|\[\/INST\]/i,
24
+ /<\|im_(?:start|end)\|>/i,
25
+ /<\|system\|>|<\|user\|>|<\|assistant\|>/i,
26
+ /###\s*(?:system|instruction|new\s+instruction)/i,
27
+ ]
28
+
29
+ /** Return true if the string contains a known prompt-injection marker. */
30
+ export function looksLikePromptInjection(value: string): boolean {
31
+ if (!value || typeof value !== 'string') return false
32
+ return INJECTION_PATTERNS.some(re => re.test(value))
33
+ }
34
+
35
+ /**
36
+ * Substitute `{{key}}` placeholders in a system-prompt template with
37
+ * values from `context`. Emits a `console.warn` when a value matches
38
+ * `looksLikePromptInjection()` so developers notice when untrusted
39
+ * input is reaching the system prompt.
40
+ *
41
+ * The replacement still happens — the warning is informational. Callers
42
+ * who need hard rejection should validate context themselves before
43
+ * calling. The framework cannot decide whether a given context value is
44
+ * trusted; only the application can.
45
+ */
46
+ export function interpolateInstructions(
47
+ template: string,
48
+ context: Record<string, unknown>
49
+ ): string {
50
+ let out = template
51
+ for (const [key, rawValue] of Object.entries(context)) {
52
+ const stringValue = String(rawValue)
53
+ if (looksLikePromptInjection(stringValue)) {
54
+ console.warn(
55
+ `[brain] Possible prompt-injection in agent context.${key} — ` +
56
+ `the value contains markers commonly used to override system ` +
57
+ `instructions. Treat untrusted user input as user-role messages, ` +
58
+ `not as interpolated system-prompt context. ` +
59
+ `See packages/brain/CLAUDE.md ("Prompt-injection threat model").`
60
+ )
61
+ }
62
+ out = out.replaceAll(`{{${key}}}`, stringValue)
63
+ }
64
+ return out
65
+ }
@@ -1,4 +1,5 @@
1
1
  import { ExternalServiceError } from '@strav/kernel'
2
+ import { scrubProviderError } from './error_scrub.ts'
2
3
 
3
4
  export interface RetryOptions {
4
5
  maxRetries?: number
@@ -36,12 +37,14 @@ export async function retryableFetch(
36
37
  try {
37
38
  response = await fetch(url, init)
38
39
  } catch (err) {
39
- // Network error (DNS, connection refused, etc.)
40
+ // Network error (DNS, connection refused, etc.). Some Bun/Node
41
+ // network errors include the URL — scrub before surfacing in
42
+ // case it carries credentials in query params.
40
43
  if (attempt === maxRetries) {
41
44
  throw new ExternalServiceError(
42
45
  service,
43
46
  undefined,
44
- err instanceof Error ? err.message : String(err)
47
+ scrubProviderError(err instanceof Error ? err.message : String(err))
45
48
  )
46
49
  }
47
50
  await sleep(backoffDelay(attempt, baseDelay, maxDelay))
@@ -50,16 +53,17 @@ export async function retryableFetch(
50
53
 
51
54
  if (response.ok) return response
52
55
 
53
- // Non-retryable status — fail immediately
56
+ // Non-retryable status — fail immediately. Provider response bodies
57
+ // can echo request headers or other context; scrub before wrapping.
54
58
  if (!retryable.includes(response.status)) {
55
59
  const text = await response.text()
56
- throw new ExternalServiceError(service, response.status, text)
60
+ throw new ExternalServiceError(service, response.status, scrubProviderError(text))
57
61
  }
58
62
 
59
63
  // Retryable status — wait and retry (unless last attempt)
60
64
  if (attempt === maxRetries) {
61
65
  const text = await response.text()
62
- throw new ExternalServiceError(service, response.status, text)
66
+ throw new ExternalServiceError(service, response.status, scrubProviderError(text))
63
67
  }
64
68
 
65
69
  const delay = parseRetryAfter(response) ?? backoffDelay(attempt, baseDelay, maxDelay)