Skip to content

Commit fcc7fc2

Browse files
author
Sorra
authored
Merge pull request #184 from TheWizardsCode/ge-hch.5.18/implement
feat(validation): expand policy rules and sanitization
2 parents cdc7f87 + f6d5f19 commit fcc7fc2

7 files changed

Lines changed: 1125 additions & 325 deletions

File tree

.beads/issues.jsonl

Lines changed: 3 additions & 2 deletions
Large diffs are not rendered by default.

docs/dev/m2-design/policy-ruleset.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,45 @@ This document defines the **automated policy rules** that the validation pipelin
1414

1515
## Rule Categories
1616

17+
## Ruleset Configuration & Overrides
18+
19+
The validation pipeline is **configurable** at runtime. The default ruleset is merged with any overrides provided via:
20+
21+
1. **Browser runtime overrides**
22+
- `window.PolicyRuleset`
23+
- `window.DirectorConfig.policyRuleset`
24+
- `window.DirectorConfig.validationRuleset`
25+
26+
2. **Validator options**
27+
- `validateProposal(proposal, { ruleset })`
28+
- `quickValidate(proposal, { ruleset })`
29+
- `validateProposal(proposal, { rulesetPath })` (Node-only JSON file path)
30+
31+
Overrides are merged on top of the default ruleset (deep merge). Only the provided keys are overridden, so you can change a single rule without redefining the entire ruleset. The resulting ruleset version is included in the validation report metadata.
32+
33+
Example (browser override):
34+
35+
```js
36+
window.PolicyRuleset = {
37+
version: 'v1.0.1-test',
38+
rules: {
39+
profanity_filter: { action: 'reject' }
40+
}
41+
};
42+
```
43+
44+
Example (per-call override):
45+
46+
```js
47+
ProposalValidator.validateProposal(proposal, {
48+
ruleset: {
49+
rules: {
50+
length_limit_check: { maxLength: 1600, warnLength: 1200 }
51+
}
52+
}
53+
});
54+
```
55+
1756
### 1. Content Safety (Critical)
1857

1958
These rules check for harmful, explicit, or offensive content. **Violations trigger auto-rejection.**

tests/unit/proposal-validator.test.js

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('proposal-validator', () => {
149149
expect(result.valid).toBe(true);
150150
});
151151

152-
it('blocks profanity', () => {
152+
it('sanitizes profanity in choice text', () => {
153153
const proposal = {
154154
choice_text: 'A damn good choice',
155155
content: {
@@ -159,11 +159,11 @@ describe('proposal-validator', () => {
159159
};
160160

161161
const result = ProposalValidator.quickValidate(proposal);
162-
expect(result.valid).toBe(false);
163-
expect(result.reason).toContain('safety filter');
162+
expect(result.valid).toBe(true);
163+
expect(result.sanitizedProposal.choice_text).toContain('[expletive]');
164164
});
165165

166-
it('blocks profanity in content', () => {
166+
it('sanitizes profanity in content', () => {
167167
const proposal = {
168168
choice_text: 'Clean choice',
169169
content: {
@@ -173,6 +173,34 @@ describe('proposal-validator', () => {
173173
};
174174

175175
const result = ProposalValidator.quickValidate(proposal);
176+
expect(result.valid).toBe(true);
177+
expect(result.sanitizedProposal.content.text).toContain('[expletive]');
178+
});
179+
180+
it('fails on explicit content', () => {
181+
const proposal = {
182+
choice_text: 'Look closer',
183+
content: {
184+
text: 'The chamber was a torture chamber filled with gore.',
185+
return_path: 'campfire'
186+
}
187+
};
188+
189+
const result = ProposalValidator.quickValidate(proposal);
190+
expect(result.valid).toBe(false);
191+
expect(result.reason).toContain('Explicit content');
192+
});
193+
194+
it('fails on ending return path', () => {
195+
const proposal = {
196+
choice_text: 'End it',
197+
content: {
198+
text: 'The story ends here.',
199+
return_path: 'rescue_end'
200+
}
201+
};
202+
203+
const result = ProposalValidator.quickValidate(proposal, { validReturnPaths: [] });
176204
expect(result.valid).toBe(false);
177205
});
178206
});
@@ -198,6 +226,31 @@ describe('proposal-validator', () => {
198226

199227
expect(result.valid).toBe(true);
200228
expect(result.errors).toHaveLength(0);
229+
expect(result.report).toBeTruthy();
230+
});
231+
232+
it('reports sanitization transforms', () => {
233+
const proposal = {
234+
choice_text: 'A damn good choice',
235+
content: {
236+
branch_type: 'narrative_delta',
237+
text: 'Line 1\n\n\nLine 2 with <b>markup</b>.',
238+
return_path: 'campfire'
239+
},
240+
metadata: {
241+
confidence_score: 0.7
242+
}
243+
};
244+
245+
const result = ProposalValidator.validateProposal(proposal, {
246+
validReturnPaths: ['campfire']
247+
});
248+
249+
expect(result.valid).toBe(true);
250+
expect(result.report.status).toBe('rejected_with_sanitization');
251+
expect(result.report.rules.some(rule => rule.result === 'sanitized')).toBe(true);
252+
expect(result.sanitizedProposal.content.text).not.toContain('<b>');
253+
expect(result.sanitizedProposal.choice_text).toContain('[expletive]');
201254
});
202255
});
203256
});

web/demo/config/director-config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@
1717
},
1818
"pacingToleranceFactor": 0.6,
1919
"placeholderDefault": 0.3,
20-
"riskThreshold": 0.8
21-
}
20+
"riskThreshold": 0.4
21+
}

web/demo/js/director.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,9 @@ async function evaluate(proposal, storyContext = {}, config = {}) {
340340
// Step 1: validation (schema + quick safety)
341341
try {
342342
const validation = (typeof ProposalValidator !== 'undefined' && ProposalValidator.quickValidate)
343-
? ProposalValidator.quickValidate(proposal)
343+
? ProposalValidator.quickValidate(proposal, {
344+
validReturnPaths: storyContext && storyContext.validReturnPaths
345+
})
344346
: { valid: true };
345347

346348
if (!validation || !validation.valid) {
@@ -349,6 +351,10 @@ async function evaluate(proposal, storyContext = {}, config = {}) {
349351
emitDecisionTelemetry(result);
350352
return result;
351353
}
354+
355+
if (validation.sanitizedProposal) {
356+
proposal = validation.sanitizedProposal;
357+
}
352358
} catch (e) {
353359
const latencyMs = Math.max(0, perf.now() - start);
354360
const result = { decision: 'reject', reason: 'Validation error', riskScore: 1.0, latencyMs, writerMs: 0, directorMs: latencyMs, totalMs: latencyMs };

web/demo/js/inkrunner.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,22 @@
358358
}
359359

360360
// Step 5: Validate proposal
361-
const validation = window.ProposalValidator.quickValidate(proposal);
361+
const validation = window.ProposalValidator.quickValidate(proposal, {
362+
validReturnPaths,
363+
storyThemes: lore.game_state?.story_themes || [],
364+
narrativePhase: lore.game_state?.narrative_phase || null
365+
});
362366
if (!validation.valid) {
363367
console.warn('[inkrunner] AI proposal failed validation:', validation.reason);
364368
return null;
365369
}
370+
371+
if (validation.sanitizedProposal) {
372+
proposal.choice_text = validation.sanitizedProposal.choice_text || proposal.choice_text;
373+
if (validation.sanitizedProposal.content?.text) {
374+
proposal.content.text = validation.sanitizedProposal.content.text;
375+
}
376+
}
366377

367378
// Add metadata
368379
proposal.id = window.LLMAdapter.generateProposalId();

0 commit comments

Comments
 (0)