principles-disciple 1.40.0 → 1.41.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/esbuild.config.js +32 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/compile-principles.mjs +94 -0
- package/scripts/sync-plugin.mjs +96 -281
- package/src/core/principle-compiler/code-validator.ts +120 -0
- package/src/core/principle-compiler/compiler.ts +242 -0
- package/src/core/principle-compiler/index.ts +10 -0
- package/src/core/principle-compiler/ledger-registrar.ts +107 -0
- package/src/core/principle-compiler/template-generator.ts +108 -0
- package/src/core/reflection/reflection-context.ts +228 -0
- package/tests/core/code-validator.test.ts +197 -0
- package/tests/core/ledger-registrar.test.ts +232 -0
- package/tests/core/principle-compiler.test.ts +348 -0
- package/tests/core/reflection-context.test.ts +356 -0
- package/tests/core/template-generator.test.ts +101 -0
- package/tests/integration/principle-compiler-e2e.test.ts +335 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Validator — Validates LLM-generated rule implementation code
|
|
3
|
+
*
|
|
4
|
+
* PURPOSE: Ensure generated code is safe, syntactically correct, and exports
|
|
5
|
+
* the expected shape before it is stored as a rule implementation.
|
|
6
|
+
*
|
|
7
|
+
* CHECKS:
|
|
8
|
+
* 1. Syntax: code parses without errors
|
|
9
|
+
* 2. Forbidden patterns: no require, import, fetch, eval, Function, process, globalThis
|
|
10
|
+
* 3. Export check: sandbox loads and exports evaluate + meta
|
|
11
|
+
* 4. Return shape: evaluate(mockInput) returns { matched: boolean }
|
|
12
|
+
*
|
|
13
|
+
* Reuses loadRuleImplementationModule for sandbox execution (node:vm isolation).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { nodeVm } from '../../utils/node-vm-polyfill.js';
|
|
17
|
+
import { loadRuleImplementationModule } from '../rule-implementation-runtime.js';
|
|
18
|
+
|
|
19
|
+
export interface ValidationResult {
|
|
20
|
+
valid: boolean;
|
|
21
|
+
errors: string[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FORBIDDEN_PATTERNS: { pattern: RegExp; label: string }[] = [
|
|
26
|
+
{ pattern: /\brequire\s*\(/, label: 'require' },
|
|
27
|
+
{ pattern: /\bimport\s+/, label: 'import' },
|
|
28
|
+
{ pattern: /\bfetch\s*\(/, label: 'fetch' },
|
|
29
|
+
{ pattern: /\beval\s*\(/, label: 'eval' },
|
|
30
|
+
{ pattern: /\bFunction\s*\(/, label: 'Function' },
|
|
31
|
+
{ pattern: /\bprocess\b/, label: 'process' },
|
|
32
|
+
{ pattern: /\bglobalThis\b/, label: 'globalThis' },
|
|
33
|
+
{ pattern: /\bglobal\b/, label: 'global' },
|
|
34
|
+
{ pattern: /\bReflect\b/, label: 'Reflect' },
|
|
35
|
+
{ pattern: /\bProxy\b/, label: 'Proxy' },
|
|
36
|
+
{ pattern: /\bconstructor\b/, label: 'constructor' },
|
|
37
|
+
{ pattern: /\bBuffer\b/, label: 'Buffer' },
|
|
38
|
+
{ pattern: /\bsetTimeout\b/, label: 'setTimeout' },
|
|
39
|
+
{ pattern: /\bsetInterval\b/, label: 'setInterval' },
|
|
40
|
+
// Bracket notation access to globals
|
|
41
|
+
{ pattern: /\[\s*['"](require|import|fetch|eval|process|globalThis|global|Reflect|Proxy|Buffer|Function)\s*['"]\s*\]/, label: 'bracket access to forbidden global' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const MOCK_INPUT = {
|
|
45
|
+
action: {
|
|
46
|
+
toolName: 'bash',
|
|
47
|
+
normalizedPath: '/tmp/test.ts',
|
|
48
|
+
paramsSummary: { command: 'echo test' },
|
|
49
|
+
},
|
|
50
|
+
workspace: { isRiskPath: false, planStatus: 'NONE', hasPlanFile: false },
|
|
51
|
+
session: { sessionId: 'test', currentGfi: 0, recentThinking: false },
|
|
52
|
+
evolution: { epTier: 0 },
|
|
53
|
+
derived: { estimatedLineChanges: 0, bashRisk: 'safe' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function validateGeneratedCode(code: string): ValidationResult {
|
|
57
|
+
const errors: string[] = [];
|
|
58
|
+
const warnings: string[] = [];
|
|
59
|
+
|
|
60
|
+
// --- Check 1: Syntax ---
|
|
61
|
+
// Normalize export keywords so vm.Script can parse ES module source
|
|
62
|
+
const normalized = code
|
|
63
|
+
.replace(/export\s+const\s+/g, 'const ')
|
|
64
|
+
.replace(/export\s+function\s+/g, 'function ');
|
|
65
|
+
try {
|
|
66
|
+
new nodeVm.Script(normalized, { filename: 'code-validator-syntax.js' });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
errors.push(`Syntax error: ${(err as Error).message}`);
|
|
69
|
+
return { valid: false, errors, warnings };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Check 2: Forbidden patterns ---
|
|
73
|
+
for (const { pattern, label } of FORBIDDEN_PATTERNS) {
|
|
74
|
+
if (pattern.test(code)) {
|
|
75
|
+
errors.push(`Forbidden pattern: ${label}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (errors.length > 0) {
|
|
80
|
+
return { valid: false, errors, warnings };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Check 3: Sandbox load + export check ---
|
|
84
|
+
let moduleExports: { meta?: unknown; evaluate?: unknown };
|
|
85
|
+
try {
|
|
86
|
+
moduleExports = loadRuleImplementationModule(code, 'code-validator-candidate.js');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
errors.push(`Sandbox compilation error: ${(err as Error).message}`);
|
|
89
|
+
return { valid: false, errors, warnings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!moduleExports.meta || typeof moduleExports.meta !== 'object') {
|
|
93
|
+
errors.push('Missing export: meta');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof moduleExports.evaluate !== 'function') {
|
|
97
|
+
errors.push('Missing export: evaluate');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (errors.length > 0) {
|
|
101
|
+
return { valid: false, errors, warnings };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Check 4: Return shape ---
|
|
105
|
+
try {
|
|
106
|
+
const result = (moduleExports.evaluate as (input: unknown) => unknown)(MOCK_INPUT);
|
|
107
|
+
if (!result || typeof result !== 'object') {
|
|
108
|
+
errors.push('evaluate must return an object');
|
|
109
|
+
} else if (typeof (result as Record<string, unknown>).matched !== 'boolean') {
|
|
110
|
+
errors.push('evaluate must return { matched: boolean }');
|
|
111
|
+
}
|
|
112
|
+
} catch (evalWarning) {
|
|
113
|
+
// evaluate throwing on mock input is acceptable — the function exists and
|
|
114
|
+
// has the right signature, it just can't handle our generic mock data.
|
|
115
|
+
// Track as a non-blocking warning so operators know the rule may be fragile.
|
|
116
|
+
warnings.push(`evaluate() threw on mock input: ${(evalWarning as Error).message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
120
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrincipleCompiler — Orchestrator (Task 5)
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the full compilation flow:
|
|
5
|
+
* ReflectionContextCollector.collect() → extract patterns → generateFromTemplate()
|
|
6
|
+
* → validateGeneratedCode() → registerCompiledRule()
|
|
7
|
+
*
|
|
8
|
+
* DESIGN DECISIONS:
|
|
9
|
+
* - extractPatterns infers toolName from pain event reasons and session tool calls
|
|
10
|
+
* - Groups by toolName into PainPattern objects
|
|
11
|
+
* - If no patterns can be extracted, returns a 'no patterns' failure
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ReflectionContextCollector } from '../reflection/reflection-context.js';
|
|
15
|
+
import { validateGeneratedCode } from './code-validator.js';
|
|
16
|
+
import { generateFromTemplate, type PainPattern } from './template-generator.js';
|
|
17
|
+
import { registerCompiledRule } from './ledger-registrar.js';
|
|
18
|
+
import { createImplementationAssetDir } from '../code-implementation-storage.js';
|
|
19
|
+
import type { TrajectoryDatabase } from '../trajectory.js';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface CompileResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
principleId: string;
|
|
28
|
+
ruleId?: string;
|
|
29
|
+
implementationId?: string;
|
|
30
|
+
code?: string;
|
|
31
|
+
reason?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** Tool names to look for when scanning text for tool references */
|
|
39
|
+
const KNOWN_TOOLS = ['bash', 'write', 'edit', 'read', 'grep', 'glob', 'mcp'] as const;
|
|
40
|
+
|
|
41
|
+
/** Regex to extract file paths from reason text */
|
|
42
|
+
const PATH_REGEX = /(?:\/[\w.-]+){2,}/;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Pattern Extraction
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract PainPatterns from a ReflectionContext.
|
|
50
|
+
*
|
|
51
|
+
* Strategy:
|
|
52
|
+
* 1. Scan pain event reasons for known tool names
|
|
53
|
+
* 2. Extract file paths from reason text as pathRegex candidates
|
|
54
|
+
* 3. Cross-reference with sessionSnapshot toolCalls for failed tool calls
|
|
55
|
+
* 4. Group by toolName into PainPattern objects
|
|
56
|
+
*/
|
|
57
|
+
function extractPatterns(context: {
|
|
58
|
+
painEvents: Array<{ reason: string | null; source: string }>;
|
|
59
|
+
sessionSnapshot: {
|
|
60
|
+
toolCalls: Array<{
|
|
61
|
+
toolName: string;
|
|
62
|
+
outcome: string;
|
|
63
|
+
filePath: string | null;
|
|
64
|
+
errorType: string | null;
|
|
65
|
+
}>;
|
|
66
|
+
} | null;
|
|
67
|
+
}): PainPattern[] {
|
|
68
|
+
const toolNameMap = new Map<string, PainPattern>();
|
|
69
|
+
|
|
70
|
+
// 1. Extract from pain event reasons
|
|
71
|
+
for (const pe of context.painEvents) {
|
|
72
|
+
const text = pe.reason ?? pe.source ?? '';
|
|
73
|
+
const toolName = inferToolName(text);
|
|
74
|
+
if (!toolName) continue;
|
|
75
|
+
|
|
76
|
+
const pathRegex = extractPathRegex(text);
|
|
77
|
+
|
|
78
|
+
if (!toolNameMap.has(toolName)) {
|
|
79
|
+
toolNameMap.set(toolName, { toolName });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const pattern = toolNameMap.get(toolName)!;
|
|
83
|
+
if (pathRegex && !pattern.pathRegex) {
|
|
84
|
+
pattern.pathRegex = pathRegex;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Extract from session snapshot tool calls (failed ones)
|
|
89
|
+
if (context.sessionSnapshot?.toolCalls) {
|
|
90
|
+
for (const tc of context.sessionSnapshot.toolCalls) {
|
|
91
|
+
// Focus on failed/blocked tool calls as they indicate pain
|
|
92
|
+
if (tc.outcome !== 'failure' && tc.outcome !== 'blocked') continue;
|
|
93
|
+
|
|
94
|
+
const toolName = tc.toolName;
|
|
95
|
+
if (!toolNameMap.has(toolName)) {
|
|
96
|
+
const pattern: PainPattern = { toolName };
|
|
97
|
+
if (tc.errorType) {
|
|
98
|
+
pattern.errorType = tc.errorType;
|
|
99
|
+
}
|
|
100
|
+
if (tc.filePath) {
|
|
101
|
+
pattern.pathRegex = escapeRegex(tc.filePath);
|
|
102
|
+
}
|
|
103
|
+
toolNameMap.set(toolName, pattern);
|
|
104
|
+
} else {
|
|
105
|
+
const existing = toolNameMap.get(toolName)!;
|
|
106
|
+
if (tc.errorType && !existing.errorType) {
|
|
107
|
+
existing.errorType = tc.errorType;
|
|
108
|
+
}
|
|
109
|
+
if (tc.filePath && !existing.pathRegex) {
|
|
110
|
+
existing.pathRegex = escapeRegex(tc.filePath);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Array.from(toolNameMap.values());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Infer tool name from text by checking for known tool names.
|
|
121
|
+
* Returns the first matching known tool name, or null if none found.
|
|
122
|
+
*/
|
|
123
|
+
function inferToolName(text: string): string | null {
|
|
124
|
+
const lower = text.toLowerCase();
|
|
125
|
+
for (const tool of KNOWN_TOOLS) {
|
|
126
|
+
// Match as a standalone word to avoid false positives
|
|
127
|
+
// e.g., "bash" in "bash" or "bash command" but not in "ambush"
|
|
128
|
+
const regex = new RegExp(`\\b${tool}\\b`);
|
|
129
|
+
if (regex.test(lower)) {
|
|
130
|
+
return tool;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract a file path from text and return it as an escaped regex pattern.
|
|
138
|
+
* Returns the first path found, or null.
|
|
139
|
+
*/
|
|
140
|
+
function extractPathRegex(text: string): string | null {
|
|
141
|
+
const match = PATH_REGEX.exec(text);
|
|
142
|
+
if (match) {
|
|
143
|
+
return escapeRegex(match[0]);
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Escape special regex characters in a string.
|
|
150
|
+
*/
|
|
151
|
+
function escapeRegex(str: string): string {
|
|
152
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// PrincipleCompiler
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export class PrincipleCompiler {
|
|
160
|
+
private readonly stateDir: string;
|
|
161
|
+
private readonly collector: ReflectionContextCollector;
|
|
162
|
+
|
|
163
|
+
constructor(stateDir: string, trajectory: TrajectoryDatabase) {
|
|
164
|
+
this.stateDir = stateDir;
|
|
165
|
+
this.collector = new ReflectionContextCollector(stateDir, trajectory);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compile a single principle into an auto-generated rule.
|
|
170
|
+
*
|
|
171
|
+
* Flow:
|
|
172
|
+
* 1. Collect reflection context
|
|
173
|
+
* 2. Extract pain patterns
|
|
174
|
+
* 3. Generate code from template
|
|
175
|
+
* 4. Validate generated code
|
|
176
|
+
* 5. Register in ledger
|
|
177
|
+
*/
|
|
178
|
+
compileOne(principleId: string): CompileResult {
|
|
179
|
+
// Step 1: Collect context
|
|
180
|
+
const context = this.collector.collect(principleId);
|
|
181
|
+
if (!context) {
|
|
182
|
+
return { success: false, principleId, reason: 'no context' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Step 2: Extract patterns
|
|
186
|
+
const patterns = extractPatterns({
|
|
187
|
+
painEvents: context.painEvents,
|
|
188
|
+
sessionSnapshot: context.sessionSnapshot,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Step 3: Generate code
|
|
192
|
+
const coversCondition = context.principle.triggerPattern || context.principle.text;
|
|
193
|
+
const code = generateFromTemplate(principleId, coversCondition, patterns);
|
|
194
|
+
if (!code) {
|
|
195
|
+
return { success: false, principleId, reason: 'no patterns' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Step 4: Validate
|
|
199
|
+
const validation = validateGeneratedCode(code);
|
|
200
|
+
if (!validation.valid) {
|
|
201
|
+
return {
|
|
202
|
+
success: false,
|
|
203
|
+
principleId,
|
|
204
|
+
reason: `validation failed: ${validation.errors.join('; ')}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Step 5: Register
|
|
209
|
+
const registration = registerCompiledRule(this.stateDir, {
|
|
210
|
+
principleId,
|
|
211
|
+
codeContent: code,
|
|
212
|
+
coversCondition,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Step 6: Persist code to disk so RuleHost can load it
|
|
216
|
+
createImplementationAssetDir(this.stateDir, registration.implementationId, '1', {
|
|
217
|
+
entrySource: code,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
success: true,
|
|
222
|
+
principleId,
|
|
223
|
+
ruleId: registration.ruleId,
|
|
224
|
+
implementationId: registration.implementationId,
|
|
225
|
+
code,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Compile all eligible principles (those with derivedFromPainIds).
|
|
231
|
+
*/
|
|
232
|
+
compileAll(): CompileResult[] {
|
|
233
|
+
const contexts = this.collector.collectBatch();
|
|
234
|
+
return contexts.map((ctx) => {
|
|
235
|
+
try {
|
|
236
|
+
return this.compileOne(ctx.principle.id);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return { success: false, principleId: ctx.principle.id, reason: `unhandled: ${(e as Error).message}` };
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principle Compiler — Barrel Export
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all principle-compiler components for convenient importing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { PrincipleCompiler, type CompileResult } from './compiler.js';
|
|
8
|
+
export { validateGeneratedCode, type ValidationResult } from './code-validator.js';
|
|
9
|
+
export { generateFromTemplate, type PainPattern } from './template-generator.js';
|
|
10
|
+
export { registerCompiledRule, type RegisterInput, type RegisterResult } from './ledger-registrar.js';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger Registrar (Task 4)
|
|
3
|
+
*
|
|
4
|
+
* Registers a compiled rule into the principle tree ledger:
|
|
5
|
+
* 1. Creates a LedgerRule with type 'gate', enforcement 'block', status 'proposed'
|
|
6
|
+
* 2. Creates an Implementation with type 'code', lifecycleState 'candidate'
|
|
7
|
+
*
|
|
8
|
+
* IDEMPOTENCY: If the rule already exists, returns existing registration.
|
|
9
|
+
* ROLLBACK: If implementation creation fails after rule creation, attempts cleanup.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRule, createImplementation, loadLedger, deleteRule, type LedgerRule } from '../principle-tree-ledger.js';
|
|
13
|
+
|
|
14
|
+
export interface RegisterInput {
|
|
15
|
+
principleId: string;
|
|
16
|
+
codeContent: string;
|
|
17
|
+
coversCondition: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RegisterResult {
|
|
21
|
+
success: boolean;
|
|
22
|
+
ruleId: string;
|
|
23
|
+
implementationId: string;
|
|
24
|
+
codePath: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a compiled rule for a principle in the ledger.
|
|
29
|
+
*
|
|
30
|
+
* Idempotent: if rule already exists, returns existing registration.
|
|
31
|
+
* Atomic: if implementation creation fails, rolls back the rule.
|
|
32
|
+
*/
|
|
33
|
+
export function registerCompiledRule(stateDir: string, input: RegisterInput): RegisterResult {
|
|
34
|
+
const { principleId, codeContent, coversCondition } = input;
|
|
35
|
+
|
|
36
|
+
const ruleId = `R_${principleId}_auto`;
|
|
37
|
+
const implementationId = `IMPL_${principleId}_auto`;
|
|
38
|
+
const codePath = `compiled-rules/${principleId}/rule.ts`;
|
|
39
|
+
|
|
40
|
+
// Idempotency: skip if rule already exists
|
|
41
|
+
const existingLedger = loadLedger(stateDir);
|
|
42
|
+
if (existingLedger.tree.rules[ruleId]) {
|
|
43
|
+
const existingRule = existingLedger.tree.rules[ruleId];
|
|
44
|
+
return {
|
|
45
|
+
success: true,
|
|
46
|
+
ruleId,
|
|
47
|
+
implementationId: existingRule.implementationIds[0] ?? implementationId,
|
|
48
|
+
codePath,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
|
|
54
|
+
// Step 1: Create the rule
|
|
55
|
+
const rule: LedgerRule = {
|
|
56
|
+
id: ruleId,
|
|
57
|
+
version: 1,
|
|
58
|
+
name: `Auto-compiled rule for ${principleId}`,
|
|
59
|
+
description: `Automatically compiled gate rule generated from principle ${principleId}`,
|
|
60
|
+
type: 'gate',
|
|
61
|
+
triggerCondition: coversCondition,
|
|
62
|
+
enforcement: 'block',
|
|
63
|
+
action: codeContent,
|
|
64
|
+
principleId,
|
|
65
|
+
status: 'proposed',
|
|
66
|
+
coverageRate: 0,
|
|
67
|
+
falsePositiveRate: 0,
|
|
68
|
+
implementationIds: [],
|
|
69
|
+
createdAt: now,
|
|
70
|
+
updatedAt: now,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
createRule(stateDir, rule);
|
|
74
|
+
|
|
75
|
+
// Step 2: Create the implementation (with rollback on failure)
|
|
76
|
+
try {
|
|
77
|
+
const implementation = {
|
|
78
|
+
id: implementationId,
|
|
79
|
+
ruleId,
|
|
80
|
+
type: 'code' as const,
|
|
81
|
+
path: codePath,
|
|
82
|
+
version: '1',
|
|
83
|
+
coversCondition,
|
|
84
|
+
coveragePercentage: 100,
|
|
85
|
+
lifecycleState: 'active' as const,
|
|
86
|
+
createdAt: now,
|
|
87
|
+
updatedAt: now,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
createImplementation(stateDir, implementation);
|
|
91
|
+
} catch (implError) {
|
|
92
|
+
// Rollback: remove the orphaned rule
|
|
93
|
+
try {
|
|
94
|
+
deleteRule(stateDir, ruleId);
|
|
95
|
+
} catch {
|
|
96
|
+
// Best-effort rollback — log but don't mask the original error
|
|
97
|
+
}
|
|
98
|
+
throw implError;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
ruleId,
|
|
104
|
+
implementationId,
|
|
105
|
+
codePath,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Generator for Principle Compiler
|
|
3
|
+
*
|
|
4
|
+
* Generates RuleHost sandbox code from PainPattern descriptors.
|
|
5
|
+
* Produces self-contained JS modules with `export const meta` and
|
|
6
|
+
* `export function evaluate(input)` that can be loaded at runtime.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: All interpolated values use JSON.stringify or safe helpers
|
|
9
|
+
* to prevent code injection through principleId, coversCondition, or regex patterns.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PainPattern {
|
|
13
|
+
toolName: string;
|
|
14
|
+
pathRegex?: string;
|
|
15
|
+
commandRegex?: string;
|
|
16
|
+
contentRegex?: string;
|
|
17
|
+
errorType?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Derives the auto-rule display name from a principle ID.
|
|
22
|
+
*/
|
|
23
|
+
function toAutoName(principleId: string): string {
|
|
24
|
+
return `Auto_${principleId}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Derives the auto-rule ID from a principle ID.
|
|
29
|
+
* Must match ledger-registrar convention: "P_066" => "R_P_066_auto"
|
|
30
|
+
*/
|
|
31
|
+
function toAutoRuleId(principleId: string): string {
|
|
32
|
+
return `R_${principleId}_auto`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds a single `if` branch for a pain pattern.
|
|
37
|
+
*
|
|
38
|
+
* SECURITY: Uses `new RegExp(JSON.stringify(...))` instead of regex literals
|
|
39
|
+
* to prevent code injection through pathRegex/commandRegex/contentRegex values.
|
|
40
|
+
* principleId in reason uses JSON.stringify to prevent string breakout.
|
|
41
|
+
*/
|
|
42
|
+
function buildBranch(principleId: string, pattern: PainPattern): string {
|
|
43
|
+
const conditions: string[] = [];
|
|
44
|
+
|
|
45
|
+
conditions.push(`input.action.toolName === ${JSON.stringify(pattern.toolName)}`);
|
|
46
|
+
|
|
47
|
+
if (pattern.pathRegex) {
|
|
48
|
+
conditions.push(`new RegExp(${JSON.stringify(pattern.pathRegex)}).test(input.action.normalizedPath || '')`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (pattern.commandRegex) {
|
|
52
|
+
conditions.push(`new RegExp(${JSON.stringify(pattern.commandRegex)}).test(input.action.paramsSummary.command || '')`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (pattern.contentRegex) {
|
|
56
|
+
conditions.push(
|
|
57
|
+
`new RegExp(${JSON.stringify(pattern.contentRegex)}).test(input.action.paramsSummary.content || input.action.paramsSummary.new_string || '')`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const guard = conditions.join(' && ');
|
|
62
|
+
const reason = `[${principleId}] Blocked by auto-generated rule`;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
` if (${guard}) {\n` +
|
|
66
|
+
` return { decision: 'block', matched: true, reason: ${JSON.stringify(reason)} };\n` +
|
|
67
|
+
` }`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generates sandbox-ready JS code from a principle ID and pain patterns.
|
|
73
|
+
*
|
|
74
|
+
* Returns `null` when `patterns` is empty.
|
|
75
|
+
*/
|
|
76
|
+
export function generateFromTemplate(
|
|
77
|
+
principleId: string,
|
|
78
|
+
coversCondition: string,
|
|
79
|
+
patterns: PainPattern[],
|
|
80
|
+
): string | null {
|
|
81
|
+
if (patterns.length === 0) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const name = toAutoName(principleId);
|
|
86
|
+
const ruleId = toAutoRuleId(principleId);
|
|
87
|
+
const compiledAt = new Date().toISOString();
|
|
88
|
+
|
|
89
|
+
const branches = patterns
|
|
90
|
+
.map((p) => buildBranch(principleId, p))
|
|
91
|
+
.join('\n');
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
`// Auto-generated by Principle Compiler\n` +
|
|
95
|
+
`export const meta = {\n` +
|
|
96
|
+
` name: ${JSON.stringify(name)},\n` +
|
|
97
|
+
` version: '1.0.0',\n` +
|
|
98
|
+
` ruleId: ${JSON.stringify(ruleId)},\n` +
|
|
99
|
+
` coversCondition: ${JSON.stringify(coversCondition)},\n` +
|
|
100
|
+
` compiledAt: ${JSON.stringify(compiledAt)},\n` +
|
|
101
|
+
` sourcePrincipleId: ${JSON.stringify(principleId)},\n` +
|
|
102
|
+
`};\n\n` +
|
|
103
|
+
`export function evaluate(input) {\n` +
|
|
104
|
+
`${branches}\n` +
|
|
105
|
+
` return { matched: false };\n` +
|
|
106
|
+
`}\n`
|
|
107
|
+
);
|
|
108
|
+
}
|