@strav/brain 0.3.32 → 0.4.0
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
|
+
"version": "0.4.0",
|
|
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.
|
|
18
|
+
"@strav/kernel": "0.4.0"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@strav/workflow": "0.
|
|
21
|
+
"@strav/workflow": "0.4.0",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/retry.ts
CHANGED
|
@@ -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)
|