Skip to content

Commit 2fff3d0

Browse files
FEATURE: Add context hardening: trim, allow/deny regex, and onContextOverflow handling
Add context hardening: trim, allow/deny regex, and onContextOverflow handling
2 parents ded7def + 63a354c commit 2fff3d0

15 files changed

Lines changed: 492 additions & 17 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ Renders a prompt for a specific provider. Returns `{ resolved, request, warnings
474474
| `source` | `string` | Inline prompt source (alternative to path) |
475475
| `provider` | `string` | `'openai'`, `'anthropic'`, `'gemini'`, `'openrouter'` |
476476
| `variables` | `Record<string, string>` | Template variables |
477+
| `onContextOverflow` | `(info) => string` | Optional callback to transform oversized context values before rendering |
477478
| `environment` | `string` | Environment override name |
478479
| `tier` | `string` | Tier override name |
479480
| `history` | `Array<{ role, content }>` | Conversation history |
@@ -506,7 +507,7 @@ Prompt files use YAML front matter with these fields:
506507
| `response` | `object` | `{ format, stream }` |
507508
| `tools` | `array` | Tool references (string names or inline definitions) |
508509
| `mcp` | `object` | MCP server references |
509-
| `context` | `object` | `{ inputs, history }` — declare expected variables, with optional per-input `max_size` budgets |
510+
| `context` | `object` | `{ inputs, history }` — declare expected variables, with optional per-input `max_size`, `trim`, and `allow_regex`/`deny_regex` controls |
510511
| `includes` | `string[]` | Paths to included prompt files |
511512
| `environments` | `object` | Named environment overrides |
512513
| `tiers` | `object` | Named tier overrides |

SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ the fields required by that specific file:
6666
| `response` | object | no | `{ format: text|json|markdown, stream: boolean }` |
6767
| `tools` | array | no | Tool names (strings) or inline definitions with `{ name, description, input_schema }` |
6868
| `mcp` | object | no | `{ servers: [string | { name, config }] }` |
69-
| `context.inputs` | `Array<string | { name, max_size? }>` | no | Declared variable names used in templates, with optional size budgets |
69+
| `context.inputs` | `Array<string | { name, max_size?, trim?, allow_regex?, deny_regex? }>` | no | Declared variable names used in templates, with optional size budgets and runtime hardening controls |
7070
| `context.history` | object | no | `{ max_items: number }` |
7171
| `includes` | string[] | no | Relative paths to other prompt files to include |
7272
| `environments` | object | no | Per-environment overrides (see Overrides) |
@@ -100,6 +100,8 @@ Rules:
100100
- Before finishing a new prompt file, scan the body for every `{{ variable }}` and
101101
ensure each exact variable name appears in `context.inputs`
102102
- Use object-form inputs with `max_size` when a variable is likely to grow large and should trigger early warnings
103+
- Use `trim` to enforce byte budgets before interpolation when `max_size` is set
104+
- Use `allow_regex` for allowlist checks and `deny_regex` for blocklist checks on risky inputs
103105
- Escape literal braces with `\{{` and `\}}`
104106
- In strict mode, missing variables throw an error
105107
- In permissive mode, unresolved placeholders are left intact
@@ -115,6 +117,7 @@ context:
115117
```
116118
117119
If a rendered value exceeds `max_size`, `renderPrompt()` emits a non-blocking `POK030` warning.
120+
At render time, callers can also pass `onContextOverflow` to transform oversized values before warnings/rendering.
118121

119122
Example: this is the minimal valid shape for a prompt that references
120123
`{{ pull_request }}` even when provider/model are inherited from defaults:

docs/api-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const result = await kit.renderPrompt({
6565
| `source` | `string` | Inline prompt source (alternative to `path`) |
6666
| `provider` | `string` | `'openai'`, `'anthropic'`, `'gemini'`, `'openrouter'` (required) |
6767
| `variables` | `Record<string, string>` | Template variables |
68+
| `onContextOverflow` | `(info) => string` | Optional callback to transform an oversized context value before rendering |
6869
| `environment` | `string` | Environment override name |
6970
| `tier` | `string` | Tier override name |
7071
| `history` | `Array<{ role, content }>` | Conversation history |
@@ -240,7 +241,7 @@ const request = adapter.render(resolvedAsset, {
240241
});
241242
```
242243

243-
`RuntimeRenderOptions` for direct adapter rendering supports `environment`, `tier`, `runtime`, `variables`, `history`, `toolRegistry`, and `strict`.
244+
`RuntimeRenderOptions` for direct adapter rendering supports `environment`, `tier`, `runtime`, `variables`, `onContextOverflow`, `history`, `toolRegistry`, and `strict`.
244245

245246
## Standalone `renderPrompt`
246247

docs/prompt-format.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,27 @@ Each entry can be either a string variable name or an object with:
168168
169169
- `name` — the template variable name
170170
- `max_size` — optional UTF-8 byte limit for the injected value
171+
- `trim` — optional trim-to-budget (`true`/`end` keeps first bytes, `start` keeps trailing bytes) applied when `max_size` is set
172+
- `allow_regex` — optional allowlist regex; input must match (throws `POK031` on mismatch)
173+
- `deny_regex` — optional blocklist regex; input must not match (throws `POK032` on match)
171174

172175
The validator warns about:
173176
- Variables used in templates but not declared in `context.inputs`
174177
- Variables declared in `context.inputs` but never used
175178

176179
At render time, PromptOpsKit also emits a non-blocking `POK030` warning when a provided variable exceeds its declared `max_size`. In source and auto modes, the warning is also written to `console.warn` to make local development issues visible early.
177180

181+
Example hardened input definition:
182+
183+
```yaml
184+
context:
185+
inputs:
186+
- name: user_id
187+
trim: true
188+
max_size: 24
189+
allow_regex: "^user_[a-z0-9]+$"
190+
```
191+
178192
## Minimal example
179193

180194
The simplest valid prompt:

docs/schema.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ context:
139139

140140
| Field | Type | Description |
141141
|-------|------|-------------|
142-
| `inputs` | `Array<string | { name, max_size? }>` | Expected variable names, optionally with a UTF-8 byte budget for render-time warnings |
142+
| `inputs` | `Array<string | { name, max_size?, trim?, allow_regex?, deny_regex? }>` | Expected variable names, optionally with size and runtime sanitization constraints |
143143
| `history` | `object` | History settings |
144144
| `history.max_items` | `number` | Maximum history items |
145145

@@ -152,7 +152,12 @@ context:
152152
- account_summary
153153
```
154154

155-
Object-form inputs add optional `max_size`, which is checked during `renderPrompt()` and can produce a `POK030` warning when the injected value exceeds the declared budget.
155+
Object-form inputs add optional controls:
156+
157+
- `max_size`: checked during `renderPrompt()` and can produce `POK030` warnings.
158+
- `trim`: trims incoming values to the `max_size` budget before interpolation (`true`/`end` keeps leading bytes, `start` keeps trailing bytes).
159+
- `allow_regex`: allowlist validation before interpolation; non-matches throw `POK031`.
160+
- `deny_regex`: blocklist validation before interpolation; matches throw `POK032`.
156161

157162
## `includes`
158163

docs/validation.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const result = await kit.validatePrompt('support/reply');
3434
| `POK010` | Warning | Unknown front matter key (with "did you mean?" suggestion) |
3535
| `POK011` | Warning | Variable used in template but not declared in `context.inputs` |
3636
| `POK012` | Warning | Variable declared in `context.inputs` but never used |
37+
| `POK013` | Error | Invalid context regex pattern (`allow_regex` or `deny_regex`) |
38+
| `POK014` | Warning | `trim` configured without `max_size` (trim-to-budget skipped) |
3739
| `POK020` | Error | Include resolution failed (missing file) |
3840
| `POK021` | Error | Circular include detected |
3941

@@ -75,6 +77,36 @@ context:
7577
7678
If `account_summary` is rendered with a value larger than 4096 UTF-8 bytes, `renderPrompt()` returns a `POK030` warning. In source and auto modes, PromptOpsKit also writes the warning to `console.warn` so oversized context is visible during local development.
7779

80+
If you want to transform oversized values before warnings/rendering (for example, summarize or redact), pass `onContextOverflow` at render time:
81+
82+
```typescript
83+
const result = await kit.renderPrompt({
84+
path: 'support/reply',
85+
provider: 'openai',
86+
variables: { account_summary: veryLargeText },
87+
onContextOverflow: ({ variable, value, maxSize }) =>
88+
`${variable} truncated to fit ${maxSize} bytes: ${value.slice(0, 50)}...`,
89+
});
90+
```
91+
92+
You can also add basic input hardening directly in `context.inputs`:
93+
94+
```yaml
95+
context:
96+
inputs:
97+
- name: user_id
98+
trim: true
99+
allow_regex: "^user_[a-z0-9]+$"
100+
- name: user_message
101+
deny_regex: "([Ii]gnore previous instructions|[Ss]ystem:)"
102+
```
103+
104+
- `trim` trims values to the `max_size` byte budget before interpolation.
105+
- `allow_regex` enforces an allowlist pattern before interpolation and throws `POK031` when a value fails validation.
106+
- `deny_regex` enforces a blocklist pattern before interpolation and throws `POK032` when a value matches.
107+
- During static validation, malformed `allow_regex` or `deny_regex` patterns are reported as `POK013`.
108+
- During static validation, `trim` without `max_size` returns a `POK014` warning.
109+
78110
You can override that behavior at the kit level:
79111

80112
```typescript

src/context.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import type { PromptAsset, ResolvedPromptAsset, ContextInputDefinition } from '.
33
export interface NormalizedContextInput {
44
name: string;
55
max_size?: number;
6+
trim?: boolean | 'start' | 'end' | 'both';
7+
allow_regex?: string;
8+
deny_regex?: string;
69
}
710

811
export interface ContextSizeWarning {
@@ -11,6 +14,18 @@ export interface ContextSizeWarning {
1114
actualSize: number;
1215
}
1316

17+
export interface ContextOverflowInfo {
18+
promptId: string;
19+
variable: string;
20+
value: string;
21+
maxSize: number;
22+
actualSize: number;
23+
}
24+
25+
export interface SanitizeContextOptions {
26+
onContextOverflow?: (info: ContextOverflowInfo) => string;
27+
}
28+
1429
const textEncoder = new TextEncoder();
1530

1631
export function getContextInputs(
@@ -33,9 +48,125 @@ export function normalizeContextInput(input: ContextInputDefinition): Normalized
3348
return {
3449
name: input.name,
3550
max_size: input.max_size,
51+
trim: input.trim,
52+
allow_regex: input.allow_regex,
53+
deny_regex: input.deny_regex,
3654
};
3755
}
3856

57+
type TrimMode = boolean | 'start' | 'end' | 'both';
58+
59+
function isTrimEnabled(mode: TrimMode | undefined): mode is true | 'start' | 'end' | 'both' {
60+
return mode === true || mode === 'start' || mode === 'end' || mode === 'both';
61+
}
62+
63+
function normalizeTrimMode(mode: TrimMode): 'start' | 'end' {
64+
if (mode === 'start') {
65+
return 'start';
66+
}
67+
return 'end';
68+
}
69+
70+
function trimToMaxSize(
71+
value: string,
72+
maxSize: number,
73+
mode: TrimMode,
74+
): string {
75+
const measured = measureContextValueSize(value);
76+
if (measured <= maxSize) {
77+
return value;
78+
}
79+
80+
const characters = Array.from(value);
81+
const normalizedMode = normalizeTrimMode(mode);
82+
83+
if (normalizedMode === 'start') {
84+
let collected = '';
85+
let size = 0;
86+
for (let i = characters.length - 1; i >= 0; i -= 1) {
87+
const next = characters[i];
88+
const charSize = measureContextValueSize(next);
89+
if (size + charSize > maxSize) {
90+
break;
91+
}
92+
collected = next + collected;
93+
size += charSize;
94+
}
95+
return collected;
96+
}
97+
98+
let collected = '';
99+
let size = 0;
100+
for (const char of characters) {
101+
const charSize = measureContextValueSize(char);
102+
if (size + charSize > maxSize) {
103+
break;
104+
}
105+
collected += char;
106+
size += charSize;
107+
}
108+
return collected;
109+
}
110+
111+
export function sanitizeContextVariables(
112+
asset: Pick<PromptAsset | ResolvedPromptAsset, 'context' | 'id'>,
113+
variables: Record<string, string> = {},
114+
options: SanitizeContextOptions = {},
115+
): Record<string, string> {
116+
const { onContextOverflow } = options;
117+
const sanitized = { ...variables };
118+
119+
for (const input of getContextInputs(asset)) {
120+
const value = sanitized[input.name];
121+
if (value === undefined) {
122+
continue;
123+
}
124+
125+
let candidate = value;
126+
127+
if (input.max_size !== undefined) {
128+
const actualSize = measureContextValueSize(candidate);
129+
if (actualSize > input.max_size && onContextOverflow) {
130+
candidate = onContextOverflow({
131+
promptId: asset.id,
132+
variable: input.name,
133+
value: candidate,
134+
maxSize: input.max_size,
135+
actualSize,
136+
});
137+
}
138+
}
139+
140+
if (isTrimEnabled(input.trim) && input.max_size !== undefined) {
141+
candidate = trimToMaxSize(candidate, input.max_size, input.trim);
142+
}
143+
144+
sanitized[input.name] = candidate;
145+
146+
if (input.allow_regex) {
147+
const candidate = sanitized[input.name];
148+
const matcher = new RegExp(input.allow_regex);
149+
if (!matcher.test(candidate)) {
150+
throw new Error(
151+
`POK031: Context variable "${input.name}" failed allow_regex validation for prompt "${asset.id}".`,
152+
);
153+
}
154+
}
155+
156+
if (input.deny_regex) {
157+
const candidate = sanitized[input.name];
158+
const matcher = new RegExp(input.deny_regex);
159+
if (matcher.test(candidate)) {
160+
throw new Error(
161+
`POK032: Context variable "${input.name}" matched deny_regex for prompt "${asset.id}".`,
162+
);
163+
}
164+
}
165+
}
166+
167+
return sanitized;
168+
}
169+
39170
export function measureContextValueSize(value: string): number {
40171
return textEncoder.encode(value).length;
41172
}
@@ -67,4 +198,4 @@ export function collectContextSizeWarnings(
67198
}
68199

69200
return warnings;
70-
}
201+
}

src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { renderSections } from './renderer/index.js';
88
import { getAdapter } from './providers/index.js';
99
import { validateAsset, validateAssetWithIncludes } from './validation/index.js';
1010
import { PromptCache } from './cache.js';
11-
import { collectContextSizeWarnings } from './context.js';
11+
import { collectContextSizeWarnings, sanitizeContextVariables } from './context.js';
1212
import {
1313
DEFAULT_PROMPTS_DIR,
1414
loadPromptAsset,
@@ -112,6 +112,8 @@ export interface RenderPromptOptions {
112112
runtime?: Partial<PromptAssetOverrides>;
113113
/** Variables for interpolation */
114114
variables?: Record<string, string>;
115+
/** Optional callback to transform oversized context values before warnings/rendering */
116+
onContextOverflow?: RuntimeRenderOptions['onContextOverflow'];
115117
/** Conversation history */
116118
history?: Array<{ role: string; content: string }>;
117119
/** Tool registry for resolving tool references */
@@ -230,7 +232,11 @@ export class PromptOpsKit {
230232
);
231233
}
232234

233-
const contextSizeWarnings = collectContextSizeWarnings(resolved, options.variables).map((warning) =>
235+
const sanitizedVariables = sanitizeContextVariables(resolved, options.variables, {
236+
onContextOverflow: options.onContextOverflow,
237+
});
238+
239+
const contextSizeWarnings = collectContextSizeWarnings(resolved, sanitizedVariables).map((warning) =>
234240
formatContextSizeWarning(resolved, warning),
235241
);
236242

@@ -243,7 +249,7 @@ export class PromptOpsKit {
243249
}
244250

245251
const request = adapter.render(resolved, {
246-
variables: options.variables,
252+
variables: sanitizedVariables,
247253
history: options.history,
248254
toolRegistry: options.toolRegistry,
249255
strict: options.strict,

src/providers/prompt-input.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { resolveInlinePromptSource, resolvePromptAsset } from '../prompt-resolution.js';
22
import type { ResolvedPromptAsset } from '../schema/index.js';
3+
import { sanitizeContextVariables } from '../context.js';
34
import type {
45
ProviderAdapter,
56
ProviderPromptInput,
@@ -41,12 +42,18 @@ export function withPromptInputSupport(adapter: SyncProviderAdapter): ProviderAd
4142

4243
const renderPrompt: RenderPromptMethod = async (input, runtime) => {
4344
const resolved = await resolveProviderPromptInput(input, runtime);
44-
return adapter.render(resolved, runtime);
45+
const variables = sanitizeContextVariables(resolved, runtime.variables, {
46+
onContextOverflow: runtime.onContextOverflow,
47+
});
48+
return adapter.render(resolved, {
49+
...runtime,
50+
variables,
51+
});
4552
};
4653

4754
return {
4855
...adapter,
4956
validatePrompt,
5057
renderPrompt,
5158
};
52-
}
59+
}

0 commit comments

Comments
 (0)