@odavl/guardian 2.0.0 → 2.0.1

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.
Files changed (172) hide show
  1. package/CHANGELOG.md +210 -210
  2. package/LICENSE +21 -21
  3. package/README.md +297 -184
  4. package/bin/guardian.js +2242 -2221
  5. package/config/README.md +59 -59
  6. package/config/guardian.config.json +54 -54
  7. package/config/guardian.policy.json +12 -12
  8. package/config/profiles/docs.yaml +18 -18
  9. package/config/profiles/ecommerce.yaml +17 -17
  10. package/config/profiles/landing-demo.yaml +16 -16
  11. package/config/profiles/marketing.yaml +18 -18
  12. package/config/profiles/saas.yaml +21 -21
  13. package/flows/example-login-flow.json +36 -36
  14. package/flows/example-signup-flow.json +44 -44
  15. package/package.json +124 -116
  16. package/policies/enterprise.json +12 -12
  17. package/policies/landing-demo.json +22 -22
  18. package/policies/saas.json +12 -12
  19. package/policies/startup.json +12 -12
  20. package/src/enterprise/audit-logger.js +166 -166
  21. package/src/enterprise/pdf-exporter.js +267 -267
  22. package/src/enterprise/rbac-gate.js +142 -142
  23. package/src/enterprise/rbac.js +239 -239
  24. package/src/enterprise/site-manager.js +180 -180
  25. package/src/founder/feedback-system.js +156 -156
  26. package/src/founder/founder-tracker.js +213 -213
  27. package/src/founder/usage-signals.js +141 -141
  28. package/src/guardian/action-hints.js +439 -439
  29. package/src/guardian/alert-ledger.js +121 -121
  30. package/src/guardian/artifact-sanitizer.js +56 -56
  31. package/src/guardian/attempt-engine.js +1069 -1029
  32. package/src/guardian/attempt-registry.js +267 -267
  33. package/src/guardian/attempt-relevance.js +106 -106
  34. package/src/guardian/attempt-reporter.js +513 -507
  35. package/src/guardian/attempt.js +274 -273
  36. package/src/guardian/attempts-filter.js +63 -63
  37. package/src/guardian/auto-attempt-builder.js +283 -283
  38. package/src/guardian/baseline-registry.js +177 -177
  39. package/src/guardian/baseline-reporter.js +143 -143
  40. package/src/guardian/baseline-storage.js +285 -285
  41. package/src/guardian/baseline.js +535 -534
  42. package/src/guardian/behavioral-signals.js +261 -261
  43. package/src/guardian/breakage-intelligence.js +224 -224
  44. package/src/guardian/browser-pool.js +131 -131
  45. package/src/guardian/browser.js +119 -119
  46. package/src/guardian/canonical-truth.js +308 -308
  47. package/src/guardian/ci-cli.js +121 -121
  48. package/src/guardian/ci-gate.js +96 -96
  49. package/src/guardian/ci-mode.js +15 -15
  50. package/src/guardian/ci-output.js +55 -38
  51. package/src/guardian/cli-summary.js +102 -102
  52. package/src/guardian/confidence-signals.js +251 -251
  53. package/src/guardian/config-loader.js +161 -161
  54. package/src/guardian/config-validator.js +285 -283
  55. package/src/guardian/coverage-model.js +239 -239
  56. package/src/guardian/coverage-packs.js +58 -58
  57. package/src/guardian/crawler.js +142 -142
  58. package/src/guardian/data-guardian-detector.js +189 -189
  59. package/src/guardian/decision-authority.js +746 -725
  60. package/src/guardian/detection-layers.js +271 -271
  61. package/src/guardian/determinism.js +146 -146
  62. package/src/guardian/discovery-engine.js +661 -661
  63. package/src/guardian/drift-detector.js +100 -100
  64. package/src/guardian/enhanced-html-reporter.js +522 -522
  65. package/src/guardian/env-guard.js +128 -127
  66. package/src/guardian/error-clarity.js +399 -399
  67. package/src/guardian/export-contract.js +196 -196
  68. package/src/guardian/fail-safe.js +212 -212
  69. package/src/guardian/failure-intelligence.js +173 -173
  70. package/src/guardian/failure-taxonomy.js +169 -169
  71. package/src/guardian/final-outcome.js +206 -206
  72. package/src/guardian/first-run-profile.js +89 -89
  73. package/src/guardian/first-run.js +65 -67
  74. package/src/guardian/flag-validator.js +111 -111
  75. package/src/guardian/flow-executor.js +641 -639
  76. package/src/guardian/flow-registry.js +67 -67
  77. package/src/guardian/honesty.js +394 -394
  78. package/src/guardian/html-reporter.js +416 -416
  79. package/src/guardian/human-intent-resolver.js +296 -296
  80. package/src/guardian/human-interaction-model.js +351 -351
  81. package/src/guardian/human-journey-context.js +184 -184
  82. package/src/guardian/human-navigator.js +544 -544
  83. package/src/guardian/human-reporter.js +435 -431
  84. package/src/guardian/index.js +226 -221
  85. package/src/guardian/init-command.js +143 -143
  86. package/src/guardian/intent-detector.js +148 -146
  87. package/src/guardian/journey-definitions.js +132 -132
  88. package/src/guardian/journey-scan-cli.js +142 -145
  89. package/src/guardian/journey-scanner.js +583 -583
  90. package/src/guardian/junit-reporter.js +281 -281
  91. package/src/guardian/language-detection.js +99 -99
  92. package/src/guardian/live-alert.js +56 -56
  93. package/src/guardian/live-baseline-compare.js +146 -146
  94. package/src/guardian/live-cli.js +95 -95
  95. package/src/guardian/live-guardian.js +210 -210
  96. package/src/guardian/live-scheduler-runner.js +137 -137
  97. package/src/guardian/live-scheduler-state.js +167 -168
  98. package/src/guardian/live-scheduler.js +146 -146
  99. package/src/guardian/live-state.js +110 -110
  100. package/src/guardian/market-criticality.js +335 -335
  101. package/src/guardian/market-reporter.js +577 -577
  102. package/src/guardian/network-trace.js +178 -178
  103. package/src/guardian/obs-logger.js +110 -110
  104. package/src/guardian/observed-capabilities.js +427 -427
  105. package/src/guardian/output-contract.js +154 -0
  106. package/src/guardian/output-readability.js +264 -264
  107. package/src/guardian/parallel-executor.js +116 -116
  108. package/src/guardian/path-safety.js +56 -56
  109. package/src/guardian/pattern-analyzer.js +348 -348
  110. package/src/guardian/policy.js +432 -434
  111. package/src/guardian/prelaunch-gate.js +193 -193
  112. package/src/guardian/prerequisite-checker.js +101 -101
  113. package/src/guardian/preset-loader.js +152 -157
  114. package/src/guardian/profile-loader.js +96 -96
  115. package/src/guardian/reality.js +3025 -2826
  116. package/src/guardian/realworld-scenarios.js +94 -94
  117. package/src/guardian/reporter.js +167 -167
  118. package/src/guardian/retry-policy.js +123 -123
  119. package/src/guardian/root-cause-analysis.js +171 -171
  120. package/src/guardian/rules-engine.js +558 -558
  121. package/src/guardian/run-artifacts.js +212 -212
  122. package/src/guardian/run-cleanup.js +207 -207
  123. package/src/guardian/run-export.js +522 -522
  124. package/src/guardian/run-latest.js +90 -90
  125. package/src/guardian/run-list.js +211 -211
  126. package/src/guardian/run-summary.js +20 -20
  127. package/src/guardian/runtime-root.js +246 -246
  128. package/src/guardian/safety.js +248 -248
  129. package/src/guardian/scan-presets.js +133 -149
  130. package/src/guardian/screenshot.js +152 -152
  131. package/src/guardian/secret-hygiene.js +44 -44
  132. package/src/guardian/selector-fallbacks.js +394 -394
  133. package/src/guardian/semantic-contact-detection.js +255 -255
  134. package/src/guardian/semantic-contact-finder.js +201 -201
  135. package/src/guardian/semantic-targets.js +234 -234
  136. package/src/guardian/site-intelligence.js +588 -588
  137. package/src/guardian/site-introspection.js +257 -257
  138. package/src/guardian/sitemap.js +225 -225
  139. package/src/guardian/smoke.js +283 -258
  140. package/src/guardian/snapshot-schema.js +177 -290
  141. package/src/guardian/snapshot.js +430 -397
  142. package/src/guardian/stability-scorer.js +169 -169
  143. package/src/guardian/success-evaluator.js +214 -214
  144. package/src/guardian/template-command.js +184 -184
  145. package/src/guardian/text-formatters.js +426 -426
  146. package/src/guardian/timeout-profiles.js +57 -57
  147. package/src/guardian/truth/attempt.contract.js +158 -0
  148. package/src/guardian/truth/decision.contract.js +275 -0
  149. package/src/guardian/truth/snapshot.contract.js +363 -0
  150. package/src/guardian/validators.js +323 -323
  151. package/src/guardian/verdict-card.js +474 -474
  152. package/src/guardian/verdict-clarity.js +298 -298
  153. package/src/guardian/verdict-policy.js +363 -363
  154. package/src/guardian/verdict.js +333 -333
  155. package/src/guardian/verdicts.js +79 -74
  156. package/src/guardian/visual-diff.js +247 -247
  157. package/src/guardian/wait-for-outcome.js +119 -119
  158. package/src/guardian/watch-runner.js +181 -181
  159. package/src/guardian/watchdog-diff.js +167 -167
  160. package/src/guardian/webhook.js +206 -206
  161. package/src/payments/stripe-checkout.js +169 -169
  162. package/src/plans/plan-definitions.js +148 -148
  163. package/src/plans/plan-manager.js +211 -211
  164. package/src/plans/usage-tracker.js +210 -210
  165. package/src/recipes/recipe-engine.js +188 -188
  166. package/src/recipes/recipe-failure-analysis.js +159 -159
  167. package/src/recipes/recipe-registry.js +134 -134
  168. package/src/recipes/recipe-runtime.js +507 -507
  169. package/src/recipes/recipe-store.js +410 -410
  170. package/SECURITY.md +0 -77
  171. package/VERSIONING.md +0 -100
  172. package/guardian-contract-v1.md +0 -502
@@ -1,558 +1,558 @@
1
- /**
2
- * Guardian Rules Engine
3
- *
4
- * A minimal, production-grade rule evaluation engine for Guardian.
5
- * Supports:
6
- * - JSON-based rule definitions with schema validation
7
- * - Deterministic rule evaluation (stable ordering)
8
- * - Composable conditions (comparisons, boolean checks, string matching)
9
- * - Verdict overrides and minimum verdict floors
10
- * - Priority-based conflict resolution
11
- *
12
- * NO placeholders, NO fake rules, NO optional behavior.
13
- * This is the authoritative rules engine for Guardian verdicts.
14
- */
15
-
16
- const fs = require('fs');
17
- const path = require('path');
18
-
19
- /**
20
- * @typedef {Object} Rule
21
- * @property {string} id - Unique rule identifier (e.g., "failed_attempts")
22
- * @property {string} description - Human-readable rule description
23
- * @property {number} priority - Evaluation priority (lower number = higher priority). Determines order when multiple rules trigger.
24
- * @property {Object} when - Conditions that must be met for rule to trigger
25
- * @property {Object} then - Action to take when rule triggers
26
- * @property {string} [category] - Categorization for reporting (TRUST, REVENUE, COMPLIANCE, PERFORMANCE)
27
- * @property {boolean} [disabled] - If true, skip this rule during evaluation
28
- */
29
-
30
- /**
31
- * @typedef {Object} PolicyDecision
32
- * @property {string} finalVerdict - Canonical verdict: READY, FRICTION, or DO_NOT_LAUNCH
33
- * @property {number} exitCode - Exit code (0=READY, 1=FRICTION, 2=DO_NOT_LAUNCH)
34
- * @property {string[]} triggeredRuleIds - IDs of rules that triggered during evaluation
35
- * @property {Object[]} reasons - Array of {ruleId, message, data} objects explaining decision
36
- * @property {boolean} isBaseline - Whether verdict is from policy override vs baseline
37
- * @property {Object} policySignals - Raw signals passed to rule evaluator (for audit)
38
- */
39
-
40
- /**
41
- * Verdict hierarchy (cannot go backwards):
42
- * READY (best) > FRICTION (middle) > DO_NOT_LAUNCH (worst)
43
- */
44
- const VERDICT_HIERARCHY = {
45
- READY: 0,
46
- FRICTION: 1,
47
- DO_NOT_LAUNCH: 2
48
- };
49
-
50
- /**
51
- * Standard rule schema validator
52
- * Validates that a rule object matches the expected structure
53
- */
54
- function validateRuleSchema(rule, ruleId = '') {
55
- const errors = [];
56
- const prefix = ruleId ? `Rule "${ruleId}"` : 'Rule';
57
-
58
- if (!rule || typeof rule !== 'object') {
59
- return {
60
- valid: false,
61
- errors: [`${prefix} must be an object`]
62
- };
63
- }
64
-
65
- // Required fields
66
- if (!rule.id || typeof rule.id !== 'string') {
67
- errors.push(`${prefix}: id is required and must be a string`);
68
- }
69
- if (!rule.description || typeof rule.description !== 'string') {
70
- errors.push(`${prefix}: description is required and must be a string`);
71
- }
72
- if (typeof rule.priority !== 'number') {
73
- errors.push(`${prefix}: priority is required and must be a number`);
74
- }
75
- if (!rule.when || typeof rule.when !== 'object') {
76
- errors.push(`${prefix}: when (conditions) is required and must be an object`);
77
- }
78
- if (!rule.then || typeof rule.then !== 'object') {
79
- errors.push(`${prefix}: then (action) is required and must be an object`);
80
- }
81
-
82
- // Validate 'then' verdict if specified
83
- if (rule.then.verdict) {
84
- const validVerdicts = Object.keys(VERDICT_HIERARCHY);
85
- if (!validVerdicts.includes(rule.then.verdict)) {
86
- errors.push(`${prefix}: then.verdict must be one of: ${validVerdicts.join(', ')}`);
87
- }
88
- }
89
-
90
- // Validate 'then.minVerdictFloor' if specified
91
- if (rule.then.minVerdictFloor) {
92
- const validVerdicts = Object.keys(VERDICT_HIERARCHY);
93
- if (!validVerdicts.includes(rule.then.minVerdictFloor)) {
94
- errors.push(`${prefix}: then.minVerdictFloor must be one of: ${validVerdicts.join(', ')}`);
95
- }
96
- }
97
-
98
- // Optional: category validation
99
- if (rule.category) {
100
- const validCategories = ['TRUST', 'REVENUE', 'COMPLIANCE', 'PERFORMANCE', 'CONTENT', 'UX'];
101
- if (!validCategories.includes(rule.category)) {
102
- errors.push(`${prefix}: category should be one of: ${validCategories.join(', ')}`);
103
- }
104
- }
105
-
106
- return {
107
- valid: errors.length === 0,
108
- errors
109
- };
110
- }
111
-
112
- /**
113
- * Load rules from a JSON file or return built-in default rules
114
- * @param {string} [rulesPath] - Path to rules JSON file
115
- * @returns {Array} Array of rule objects
116
- */
117
- function loadRules(rulesPath = null) {
118
- // Default built-in rules - minimal but real
119
- const defaultRules = [
120
- {
121
- id: 'failed_attempts_exist',
122
- description: 'Fail if any attempts resulted in FAILURE',
123
- priority: 10,
124
- category: 'TRUST',
125
- when: {
126
- field: 'failedCount',
127
- operator: 'greaterThan',
128
- value: 0
129
- },
130
- then: {
131
- verdict: 'DO_NOT_LAUNCH',
132
- reason: 'Critical flows failed during execution'
133
- }
134
- },
135
- {
136
- id: 'no_executed_attempts',
137
- description: 'Downgrade to FRICTION if no attempts were executed',
138
- priority: 20,
139
- category: 'COMPLIANCE',
140
- when: {
141
- field: 'executedCount',
142
- operator: 'equals',
143
- value: 0
144
- },
145
- then: {
146
- verdict: 'FRICTION',
147
- reason: 'No attempts were executed; unable to validate real-world behavior'
148
- }
149
- },
150
- {
151
- id: 'near_success_with_no_failures',
152
- description: 'Mark FRICTION if near-success attempts exist but no outright failures',
153
- priority: 30,
154
- category: 'UX',
155
- when: {
156
- conditions: [
157
- { field: 'nearSuccessCount', operator: 'greaterThan', value: 0 },
158
- { field: 'failedCount', operator: 'equals', value: 0 }
159
- ],
160
- logic: 'AND'
161
- },
162
- then: {
163
- verdict: 'FRICTION',
164
- reason: 'One or more flows nearly succeeded but did not complete; user confusion likely'
165
- }
166
- },
167
- {
168
- id: 'goal_not_reached_no_failures',
169
- description: 'Mark FRICTION if goal was not reached despite no failures',
170
- priority: 35,
171
- category: 'UX',
172
- when: {
173
- conditions: [
174
- { field: 'goalReached', operator: 'equals', value: false },
175
- { field: 'failedCount', operator: 'equals', value: 0 }
176
- ],
177
- logic: 'AND'
178
- },
179
- then: {
180
- verdict: 'FRICTION',
181
- reason: 'Primary goal not achieved; business objective not met'
182
- }
183
- },
184
- {
185
- id: 'sensitive_domain_with_failures',
186
- description: 'Escalate to DO_NOT_LAUNCH for payment/checkout domains with failures',
187
- priority: 15,
188
- category: 'REVENUE',
189
- when: {
190
- conditions: [
191
- { field: 'domain', operator: 'matches', pattern: '(checkout|payment|cart|billing)' },
192
- { field: 'failedCount', operator: 'greaterThan', value: 0 }
193
- ],
194
- logic: 'AND'
195
- },
196
- then: {
197
- verdict: 'DO_NOT_LAUNCH',
198
- reason: 'Revenue flow failures detected on checkout/payment domain'
199
- }
200
- },
201
- {
202
- id: 'baseline_regression',
203
- description: 'Set to DO_NOT_LAUNCH if baseline regressions detected',
204
- priority: 25,
205
- category: 'COMPLIANCE',
206
- when: {
207
- field: 'hasRegressions',
208
- operator: 'equals',
209
- value: true
210
- },
211
- then: {
212
- verdict: 'DO_NOT_LAUNCH',
213
- reason: 'Baseline regressions detected; behavior has degraded'
214
- }
215
- },
216
- {
217
- id: 'all_goals_reached',
218
- description: 'Allow READY only if goal reached and no failures',
219
- priority: 50,
220
- category: 'COMPLIANCE',
221
- when: {
222
- conditions: [
223
- { field: 'goalReached', operator: 'equals', value: true },
224
- { field: 'failedCount', operator: 'equals', value: 0 },
225
- { field: 'nearSuccessCount', operator: 'equals', value: 0 }
226
- ],
227
- logic: 'AND'
228
- },
229
- then: {
230
- verdict: 'READY',
231
- reason: 'All critical flows executed successfully and goals reached'
232
- }
233
- }
234
- ];
235
-
236
- // If no path specified, try to find rules file in standard locations
237
- if (!rulesPath) {
238
- const candidates = [
239
- 'config/guardian.rules.json',
240
- 'guardian.rules.json',
241
- '.odavl-guardian/rules.json',
242
- '.odavl-guardian/guardian.rules.json'
243
- ];
244
-
245
- for (const candidate of candidates) {
246
- if (fs.existsSync(candidate)) {
247
- rulesPath = candidate;
248
- break;
249
- }
250
- }
251
- }
252
-
253
- // If no rules file found, use defaults
254
- if (!rulesPath || !fs.existsSync(rulesPath)) {
255
- return defaultRules;
256
- }
257
-
258
- try {
259
- const json = fs.readFileSync(rulesPath, 'utf8');
260
- const loaded = JSON.parse(json);
261
-
262
- // Validate each loaded rule
263
- const rules = Array.isArray(loaded) ? loaded : [loaded];
264
- for (const rule of rules) {
265
- const validation = validateRuleSchema(rule, rule.id);
266
- if (!validation.valid) {
267
- throw new Error(`Invalid rule schema for "${rule.id}": ${validation.errors.join('; ')}`);
268
- }
269
- if (rule.disabled) {
270
- // Mark disabled rules but keep them for audit trail
271
- rule._disabled = true;
272
- }
273
- }
274
-
275
- return rules;
276
- } catch (e) {
277
- throw new Error(`Failed to load rules from ${rulesPath}: ${e.message}`);
278
- }
279
- }
280
-
281
- /**
282
- * Evaluate a single condition within a rule
283
- * Supports: equals, greaterThan, lessThan, matches (regex), contains
284
- */
285
- function evaluateCondition(condition, signals) {
286
- const { field, operator, value, pattern } = condition;
287
-
288
- // Get value from signals
289
- const fieldValue = signals[field];
290
-
291
- switch (operator) {
292
- case 'equals':
293
- return fieldValue === value;
294
-
295
- case 'greaterThan':
296
- return typeof fieldValue === 'number' && fieldValue > value;
297
-
298
- case 'lessThan':
299
- return typeof fieldValue === 'number' && fieldValue < value;
300
-
301
- case 'matches':
302
- if (typeof pattern !== 'string') {
303
- throw new Error(`Condition "${field} matches" requires a 'pattern' string`);
304
- }
305
- try {
306
- const regex = new RegExp(pattern, 'i');
307
- return regex.test(String(fieldValue));
308
- } catch (e) {
309
- throw new Error(`Invalid regex pattern in condition: ${e.message}`);
310
- }
311
-
312
- case 'contains':
313
- if (Array.isArray(fieldValue)) {
314
- return fieldValue.includes(value);
315
- }
316
- return String(fieldValue).includes(String(value));
317
-
318
- default:
319
- throw new Error(`Unknown condition operator: ${operator}`);
320
- }
321
- }
322
-
323
- /**
324
- * Evaluate the 'when' (conditions) part of a rule
325
- * Returns true if conditions are met, false otherwise
326
- */
327
- function evaluateWhenConditions(whenClause, signals) {
328
- if (!whenClause) return false;
329
-
330
- // Single condition case
331
- if (whenClause.field) {
332
- return evaluateCondition(whenClause, signals);
333
- }
334
-
335
- // Multi-condition case (AND/OR logic)
336
- if (Array.isArray(whenClause.conditions)) {
337
- const logic = whenClause.logic || 'AND';
338
- const results = whenClause.conditions.map(cond => evaluateCondition(cond, signals));
339
-
340
- if (logic === 'AND') {
341
- return results.every(r => r === true);
342
- } else if (logic === 'OR') {
343
- return results.some(r => r === true);
344
- } else {
345
- throw new Error(`Unknown logic operator: ${logic}`);
346
- }
347
- }
348
-
349
- return false;
350
- }
351
-
352
- /**
353
- * Merge two verdicts respecting hierarchy (higher severity wins)
354
- */
355
- function mergeVerdicts(v1, v2) {
356
- const h1 = VERDICT_HIERARCHY[v1] ?? -1;
357
- const h2 = VERDICT_HIERARCHY[v2] ?? -1;
358
- return h1 > h2 ? v1 : v2;
359
- }
360
-
361
- /**
362
- * Evaluate a set of rules against scan signals
363
- * Returns a PolicyDecision object with final verdict and triggered rules
364
- */
365
- function evaluateRules(rules, policySignals) {
366
- if (!Array.isArray(rules)) {
367
- throw new Error('Rules must be an array');
368
- }
369
-
370
- if (!policySignals || typeof policySignals !== 'object') {
371
- throw new Error('policySignals must be an object with scan data');
372
- }
373
-
374
- const decision = {
375
- finalVerdict: 'READY', // Start optimistic
376
- exitCode: 0,
377
- triggeredRuleIds: [],
378
- reasons: [],
379
- isBaseline: false,
380
- policySignals: policySignals
381
- };
382
-
383
- // Sort rules by priority (lower number = higher priority = evaluated first)
384
- const sortedRules = rules
385
- .filter(r => !r.disabled && !r._disabled)
386
- .sort((a, b) => a.priority - b.priority);
387
-
388
- // Evaluate each rule
389
- for (const rule of sortedRules) {
390
- try {
391
- const conditionsMet = evaluateWhenConditions(rule.when, policySignals);
392
-
393
- if (conditionsMet) {
394
- // Rule triggered
395
- decision.triggeredRuleIds.push(rule.id);
396
-
397
- // Apply verdict override or floor
398
- if (rule.then.verdict) {
399
- const newVerdict = rule.then.verdict;
400
- decision.finalVerdict = mergeVerdicts(decision.finalVerdict, newVerdict);
401
- }
402
-
403
- // Apply minimum verdict floor (ensure we don't go better than this)
404
- if (rule.then.minVerdictFloor) {
405
- const currentHierarchy = VERDICT_HIERARCHY[decision.finalVerdict];
406
- const floorHierarchy = VERDICT_HIERARCHY[rule.then.minVerdictFloor];
407
- if (currentHierarchy < floorHierarchy) {
408
- decision.finalVerdict = rule.then.minVerdictFloor;
409
- }
410
- }
411
-
412
- // Record reason
413
- decision.reasons.push({
414
- ruleId: rule.id,
415
- message: rule.then.reason || rule.description,
416
- category: rule.category || 'GENERAL',
417
- priority: rule.priority
418
- });
419
- }
420
- } catch (e) {
421
- throw new Error(`Error evaluating rule "${rule.id}": ${e.message}`);
422
- }
423
- }
424
-
425
- // Map final verdict to exit code
426
- decision.exitCode = mapVerdictToExitCode(decision.finalVerdict);
427
-
428
- // Sort reasons by priority for consistent output
429
- decision.reasons.sort((a, b) => a.priority - b.priority || a.ruleId.localeCompare(b.ruleId));
430
-
431
- return decision;
432
- }
433
-
434
- /**
435
- * Map canonical verdict to exit code
436
- * READY (0) > FRICTION (1) > DO_NOT_LAUNCH (2)
437
- */
438
- function mapVerdictToExitCode(verdict) {
439
- switch (verdict) {
440
- case 'READY':
441
- return 0;
442
- case 'FRICTION':
443
- return 1;
444
- case 'DO_NOT_LAUNCH':
445
- return 2;
446
- default:
447
- return 2; // Fail safe
448
- }
449
- }
450
-
451
- /**
452
- * Build policy signals object from scan results
453
- * This is the input data that rules operate on
454
- */
455
- function buildPolicySignals(scanResult) {
456
- if (!scanResult || typeof scanResult !== 'object') {
457
- throw new Error('scanResult must be an object');
458
- }
459
-
460
- const attempts = scanResult.attempts || [];
461
- const executed = attempts.filter(a => a.executed);
462
- const failed = executed.filter(a => a.outcome === 'FAILURE');
463
- const nearSuccess = executed.filter(a => {
464
- // Near-success: execution occurred but goal not reached without explicit failure
465
- return a.outcome === 'FRICTION' || (a.outcome !== 'SUCCESS' && a.outcome !== 'FAILURE');
466
- });
467
-
468
- return {
469
- // Basic counts
470
- executedCount: executed.length,
471
- failedCount: failed.length,
472
- nearSuccessCount: nearSuccess.length,
473
- successCount: executed.filter(a => a.outcome === 'SUCCESS').length,
474
- frictionCount: executed.filter(a => a.outcome === 'FRICTION').length,
475
- skippedCount: attempts.filter(a => !a.executed).length,
476
-
477
- // Boolean flags
478
- goalReached: scanResult.goalReached === true || (scanResult.meta?.goalReached === true),
479
- hasScreenshots: (scanResult.evidence?.screenshots?.length || 0) > 0,
480
- hasTraces: (scanResult.evidence?.traces?.length || 0) > 0,
481
- hasRegressions: !!(scanResult.baseline?.diffResult?.regressions && Object.keys(scanResult.baseline.diffResult.regressions).length > 0),
482
-
483
- // Domain/URL matching
484
- domain: extractDomain(scanResult.url || scanResult.baseUrl),
485
- url: scanResult.url || scanResult.baseUrl,
486
-
487
- // Preset/policy name
488
- preset: scanResult.preset || scanResult.policy,
489
-
490
- // Raw counts for advanced rules
491
- attemptTotal: attempts.length,
492
- executionCoverage: attempts.length > 0 ? executed.length / attempts.length : 0
493
- };
494
- }
495
-
496
- /**
497
- * Extract domain from URL for pattern matching
498
- */
499
- function extractDomain(urlString) {
500
- if (!urlString) return '';
501
- try {
502
- const url = new URL(urlString);
503
- return url.hostname;
504
- } catch {
505
- return '';
506
- }
507
- }
508
-
509
- /**
510
- * Create a default rules file in standard location
511
- */
512
- function createDefaultRulesFile(outputPath = 'config/guardian.rules.json') {
513
- const defaultRules = [
514
- {
515
- id: 'failed_attempts_exist',
516
- description: 'Fail if any attempts resulted in FAILURE',
517
- priority: 10,
518
- category: 'TRUST',
519
- when: { field: 'failedCount', operator: 'greaterThan', value: 0 },
520
- then: { verdict: 'DO_NOT_LAUNCH', reason: 'Critical flows failed during execution' }
521
- },
522
- {
523
- id: 'no_executed_attempts',
524
- description: 'Downgrade to FRICTION if no attempts were executed',
525
- priority: 20,
526
- category: 'COMPLIANCE',
527
- when: { field: 'executedCount', operator: 'equals', value: 0 },
528
- then: { verdict: 'FRICTION', reason: 'No attempts were executed; unable to validate real-world behavior' }
529
- }
530
- ];
531
-
532
- const dir = path.dirname(outputPath);
533
- if (!fs.existsSync(dir)) {
534
- fs.mkdirSync(dir, { recursive: true });
535
- }
536
-
537
- fs.writeFileSync(outputPath, JSON.stringify(defaultRules, null, 2), 'utf8');
538
- return outputPath;
539
- }
540
-
541
- module.exports = {
542
- // Core functions
543
- loadRules,
544
- evaluateRules,
545
- buildPolicySignals,
546
- evaluateWhenConditions,
547
- evaluateCondition,
548
- mergeVerdicts,
549
-
550
- // Utilities
551
- validateRuleSchema,
552
- createDefaultRulesFile,
553
- mapVerdictToExitCode,
554
- extractDomain,
555
-
556
- // Constants
557
- VERDICT_HIERARCHY
558
- };
1
+ /**
2
+ * Guardian Rules Engine
3
+ *
4
+ * A minimal, production-grade rule evaluation engine for Guardian.
5
+ * Supports:
6
+ * - JSON-based rule definitions with schema validation
7
+ * - Deterministic rule evaluation (stable ordering)
8
+ * - Composable conditions (comparisons, boolean checks, string matching)
9
+ * - Verdict overrides and minimum verdict floors
10
+ * - Priority-based conflict resolution
11
+ *
12
+ * NO placeholders, NO fake rules, NO optional behavior.
13
+ * This is the authoritative rules engine for Guardian verdicts.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ /**
20
+ * @typedef {Object} Rule
21
+ * @property {string} id - Unique rule identifier (e.g., "failed_attempts")
22
+ * @property {string} description - Human-readable rule description
23
+ * @property {number} priority - Evaluation priority (lower number = higher priority). Determines order when multiple rules trigger.
24
+ * @property {Object} when - Conditions that must be met for rule to trigger
25
+ * @property {Object} then - Action to take when rule triggers
26
+ * @property {string} [category] - Categorization for reporting (TRUST, REVENUE, COMPLIANCE, PERFORMANCE)
27
+ * @property {boolean} [disabled] - If true, skip this rule during evaluation
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} PolicyDecision
32
+ * @property {string} finalVerdict - Canonical verdict: READY, FRICTION, or DO_NOT_LAUNCH
33
+ * @property {number} exitCode - Exit code (0=READY, 1=FRICTION, 2=DO_NOT_LAUNCH)
34
+ * @property {string[]} triggeredRuleIds - IDs of rules that triggered during evaluation
35
+ * @property {Object[]} reasons - Array of {ruleId, message, data} objects explaining decision
36
+ * @property {boolean} isBaseline - Whether verdict is from policy override vs baseline
37
+ * @property {Object} policySignals - Raw signals passed to rule evaluator (for audit)
38
+ */
39
+
40
+ /**
41
+ * Verdict hierarchy (cannot go backwards):
42
+ * READY (best) > FRICTION (middle) > DO_NOT_LAUNCH (worst)
43
+ */
44
+ const VERDICT_HIERARCHY = {
45
+ READY: 0,
46
+ FRICTION: 1,
47
+ DO_NOT_LAUNCH: 2
48
+ };
49
+
50
+ /**
51
+ * Standard rule schema validator
52
+ * Validates that a rule object matches the expected structure
53
+ */
54
+ function validateRuleSchema(rule, ruleId = '') {
55
+ const errors = [];
56
+ const prefix = ruleId ? `Rule "${ruleId}"` : 'Rule';
57
+
58
+ if (!rule || typeof rule !== 'object') {
59
+ return {
60
+ valid: false,
61
+ errors: [`${prefix} must be an object`]
62
+ };
63
+ }
64
+
65
+ // Required fields
66
+ if (!rule.id || typeof rule.id !== 'string') {
67
+ errors.push(`${prefix}: id is required and must be a string`);
68
+ }
69
+ if (!rule.description || typeof rule.description !== 'string') {
70
+ errors.push(`${prefix}: description is required and must be a string`);
71
+ }
72
+ if (typeof rule.priority !== 'number') {
73
+ errors.push(`${prefix}: priority is required and must be a number`);
74
+ }
75
+ if (!rule.when || typeof rule.when !== 'object') {
76
+ errors.push(`${prefix}: when (conditions) is required and must be an object`);
77
+ }
78
+ if (!rule.then || typeof rule.then !== 'object') {
79
+ errors.push(`${prefix}: then (action) is required and must be an object`);
80
+ }
81
+
82
+ // Validate 'then' verdict if specified
83
+ if (rule.then.verdict) {
84
+ const validVerdicts = Object.keys(VERDICT_HIERARCHY);
85
+ if (!validVerdicts.includes(rule.then.verdict)) {
86
+ errors.push(`${prefix}: then.verdict must be one of: ${validVerdicts.join(', ')}`);
87
+ }
88
+ }
89
+
90
+ // Validate 'then.minVerdictFloor' if specified
91
+ if (rule.then.minVerdictFloor) {
92
+ const validVerdicts = Object.keys(VERDICT_HIERARCHY);
93
+ if (!validVerdicts.includes(rule.then.minVerdictFloor)) {
94
+ errors.push(`${prefix}: then.minVerdictFloor must be one of: ${validVerdicts.join(', ')}`);
95
+ }
96
+ }
97
+
98
+ // Optional: category validation
99
+ if (rule.category) {
100
+ const validCategories = ['TRUST', 'REVENUE', 'COMPLIANCE', 'PERFORMANCE', 'CONTENT', 'UX'];
101
+ if (!validCategories.includes(rule.category)) {
102
+ errors.push(`${prefix}: category should be one of: ${validCategories.join(', ')}`);
103
+ }
104
+ }
105
+
106
+ return {
107
+ valid: errors.length === 0,
108
+ errors
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Load rules from a JSON file or return built-in default rules
114
+ * @param {string} [rulesPath] - Path to rules JSON file
115
+ * @returns {Array} Array of rule objects
116
+ */
117
+ function loadRules(rulesPath = null) {
118
+ // Default built-in rules - minimal but real
119
+ const defaultRules = [
120
+ {
121
+ id: 'failed_attempts_exist',
122
+ description: 'Fail if any attempts resulted in FAILURE',
123
+ priority: 10,
124
+ category: 'TRUST',
125
+ when: {
126
+ field: 'failedCount',
127
+ operator: 'greaterThan',
128
+ value: 0
129
+ },
130
+ then: {
131
+ verdict: 'DO_NOT_LAUNCH',
132
+ reason: 'Critical flows failed during execution'
133
+ }
134
+ },
135
+ {
136
+ id: 'no_executed_attempts',
137
+ description: 'Downgrade to FRICTION if no attempts were executed',
138
+ priority: 20,
139
+ category: 'COMPLIANCE',
140
+ when: {
141
+ field: 'executedCount',
142
+ operator: 'equals',
143
+ value: 0
144
+ },
145
+ then: {
146
+ verdict: 'FRICTION',
147
+ reason: 'No attempts were executed; unable to validate real-world behavior'
148
+ }
149
+ },
150
+ {
151
+ id: 'near_success_with_no_failures',
152
+ description: 'Mark FRICTION if near-success attempts exist but no outright failures',
153
+ priority: 30,
154
+ category: 'UX',
155
+ when: {
156
+ conditions: [
157
+ { field: 'nearSuccessCount', operator: 'greaterThan', value: 0 },
158
+ { field: 'failedCount', operator: 'equals', value: 0 }
159
+ ],
160
+ logic: 'AND'
161
+ },
162
+ then: {
163
+ verdict: 'FRICTION',
164
+ reason: 'One or more flows nearly succeeded but did not complete; user confusion likely'
165
+ }
166
+ },
167
+ {
168
+ id: 'goal_not_reached_no_failures',
169
+ description: 'Mark FRICTION if goal was not reached despite no failures',
170
+ priority: 35,
171
+ category: 'UX',
172
+ when: {
173
+ conditions: [
174
+ { field: 'goalReached', operator: 'equals', value: false },
175
+ { field: 'failedCount', operator: 'equals', value: 0 }
176
+ ],
177
+ logic: 'AND'
178
+ },
179
+ then: {
180
+ verdict: 'FRICTION',
181
+ reason: 'Primary goal not achieved; business objective not met'
182
+ }
183
+ },
184
+ {
185
+ id: 'sensitive_domain_with_failures',
186
+ description: 'Escalate to DO_NOT_LAUNCH for payment/checkout domains with failures',
187
+ priority: 15,
188
+ category: 'REVENUE',
189
+ when: {
190
+ conditions: [
191
+ { field: 'domain', operator: 'matches', pattern: '(checkout|payment|cart|billing)' },
192
+ { field: 'failedCount', operator: 'greaterThan', value: 0 }
193
+ ],
194
+ logic: 'AND'
195
+ },
196
+ then: {
197
+ verdict: 'DO_NOT_LAUNCH',
198
+ reason: 'Revenue flow failures detected on checkout/payment domain'
199
+ }
200
+ },
201
+ {
202
+ id: 'baseline_regression',
203
+ description: 'Set to DO_NOT_LAUNCH if baseline regressions detected',
204
+ priority: 25,
205
+ category: 'COMPLIANCE',
206
+ when: {
207
+ field: 'hasRegressions',
208
+ operator: 'equals',
209
+ value: true
210
+ },
211
+ then: {
212
+ verdict: 'DO_NOT_LAUNCH',
213
+ reason: 'Baseline regressions detected; behavior has degraded'
214
+ }
215
+ },
216
+ {
217
+ id: 'all_goals_reached',
218
+ description: 'Allow READY only if goal reached and no failures',
219
+ priority: 50,
220
+ category: 'COMPLIANCE',
221
+ when: {
222
+ conditions: [
223
+ { field: 'goalReached', operator: 'equals', value: true },
224
+ { field: 'failedCount', operator: 'equals', value: 0 },
225
+ { field: 'nearSuccessCount', operator: 'equals', value: 0 }
226
+ ],
227
+ logic: 'AND'
228
+ },
229
+ then: {
230
+ verdict: 'READY',
231
+ reason: 'All critical flows executed successfully and goals reached'
232
+ }
233
+ }
234
+ ];
235
+
236
+ // If no path specified, try to find rules file in standard locations
237
+ if (!rulesPath) {
238
+ const candidates = [
239
+ 'config/guardian.rules.json',
240
+ 'guardian.rules.json',
241
+ '.odavl-guardian/rules.json',
242
+ '.odavl-guardian/guardian.rules.json'
243
+ ];
244
+
245
+ for (const candidate of candidates) {
246
+ if (fs.existsSync(candidate)) {
247
+ rulesPath = candidate;
248
+ break;
249
+ }
250
+ }
251
+ }
252
+
253
+ // If no rules file found, use defaults
254
+ if (!rulesPath || !fs.existsSync(rulesPath)) {
255
+ return defaultRules;
256
+ }
257
+
258
+ try {
259
+ const json = fs.readFileSync(rulesPath, 'utf8');
260
+ const loaded = JSON.parse(json);
261
+
262
+ // Validate each loaded rule
263
+ const rules = Array.isArray(loaded) ? loaded : [loaded];
264
+ for (const rule of rules) {
265
+ const validation = validateRuleSchema(rule, rule.id);
266
+ if (!validation.valid) {
267
+ throw new Error(`Invalid rule schema for "${rule.id}": ${validation.errors.join('; ')}`);
268
+ }
269
+ if (rule.disabled) {
270
+ // Mark disabled rules but keep them for audit trail
271
+ rule._disabled = true;
272
+ }
273
+ }
274
+
275
+ return rules;
276
+ } catch (e) {
277
+ throw new Error(`Failed to load rules from ${rulesPath}: ${e.message}`);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Evaluate a single condition within a rule
283
+ * Supports: equals, greaterThan, lessThan, matches (regex), contains
284
+ */
285
+ function evaluateCondition(condition, signals) {
286
+ const { field, operator, value, pattern } = condition;
287
+
288
+ // Get value from signals
289
+ const fieldValue = signals[field];
290
+
291
+ switch (operator) {
292
+ case 'equals':
293
+ return fieldValue === value;
294
+
295
+ case 'greaterThan':
296
+ return typeof fieldValue === 'number' && fieldValue > value;
297
+
298
+ case 'lessThan':
299
+ return typeof fieldValue === 'number' && fieldValue < value;
300
+
301
+ case 'matches':
302
+ if (typeof pattern !== 'string') {
303
+ throw new Error(`Condition "${field} matches" requires a 'pattern' string`);
304
+ }
305
+ try {
306
+ const regex = new RegExp(pattern, 'i');
307
+ return regex.test(String(fieldValue));
308
+ } catch (e) {
309
+ throw new Error(`Invalid regex pattern in condition: ${e.message}`);
310
+ }
311
+
312
+ case 'contains':
313
+ if (Array.isArray(fieldValue)) {
314
+ return fieldValue.includes(value);
315
+ }
316
+ return String(fieldValue).includes(String(value));
317
+
318
+ default:
319
+ throw new Error(`Unknown condition operator: ${operator}`);
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Evaluate the 'when' (conditions) part of a rule
325
+ * Returns true if conditions are met, false otherwise
326
+ */
327
+ function evaluateWhenConditions(whenClause, signals) {
328
+ if (!whenClause) return false;
329
+
330
+ // Single condition case
331
+ if (whenClause.field) {
332
+ return evaluateCondition(whenClause, signals);
333
+ }
334
+
335
+ // Multi-condition case (AND/OR logic)
336
+ if (Array.isArray(whenClause.conditions)) {
337
+ const logic = whenClause.logic || 'AND';
338
+ const results = whenClause.conditions.map(cond => evaluateCondition(cond, signals));
339
+
340
+ if (logic === 'AND') {
341
+ return results.every(r => r === true);
342
+ } else if (logic === 'OR') {
343
+ return results.some(r => r === true);
344
+ } else {
345
+ throw new Error(`Unknown logic operator: ${logic}`);
346
+ }
347
+ }
348
+
349
+ return false;
350
+ }
351
+
352
+ /**
353
+ * Merge two verdicts respecting hierarchy (higher severity wins)
354
+ */
355
+ function mergeVerdicts(v1, v2) {
356
+ const h1 = VERDICT_HIERARCHY[v1] ?? -1;
357
+ const h2 = VERDICT_HIERARCHY[v2] ?? -1;
358
+ return h1 > h2 ? v1 : v2;
359
+ }
360
+
361
+ /**
362
+ * Evaluate a set of rules against scan signals
363
+ * Returns a PolicyDecision object with final verdict and triggered rules
364
+ */
365
+ function evaluateRules(rules, policySignals) {
366
+ if (!Array.isArray(rules)) {
367
+ throw new Error('Rules must be an array');
368
+ }
369
+
370
+ if (!policySignals || typeof policySignals !== 'object') {
371
+ throw new Error('policySignals must be an object with scan data');
372
+ }
373
+
374
+ const decision = {
375
+ finalVerdict: 'READY', // Start optimistic
376
+ exitCode: 0,
377
+ triggeredRuleIds: [],
378
+ reasons: [],
379
+ isBaseline: false,
380
+ policySignals: policySignals
381
+ };
382
+
383
+ // Sort rules by priority (lower number = higher priority = evaluated first)
384
+ const sortedRules = rules
385
+ .filter(r => !r.disabled && !r._disabled)
386
+ .sort((a, b) => a.priority - b.priority);
387
+
388
+ // Evaluate each rule
389
+ for (const rule of sortedRules) {
390
+ try {
391
+ const conditionsMet = evaluateWhenConditions(rule.when, policySignals);
392
+
393
+ if (conditionsMet) {
394
+ // Rule triggered
395
+ decision.triggeredRuleIds.push(rule.id);
396
+
397
+ // Apply verdict override or floor
398
+ if (rule.then.verdict) {
399
+ const newVerdict = rule.then.verdict;
400
+ decision.finalVerdict = mergeVerdicts(decision.finalVerdict, newVerdict);
401
+ }
402
+
403
+ // Apply minimum verdict floor (ensure we don't go better than this)
404
+ if (rule.then.minVerdictFloor) {
405
+ const currentHierarchy = VERDICT_HIERARCHY[decision.finalVerdict];
406
+ const floorHierarchy = VERDICT_HIERARCHY[rule.then.minVerdictFloor];
407
+ if (currentHierarchy < floorHierarchy) {
408
+ decision.finalVerdict = rule.then.minVerdictFloor;
409
+ }
410
+ }
411
+
412
+ // Record reason
413
+ decision.reasons.push({
414
+ ruleId: rule.id,
415
+ message: rule.then.reason || rule.description,
416
+ category: rule.category || 'GENERAL',
417
+ priority: rule.priority
418
+ });
419
+ }
420
+ } catch (e) {
421
+ throw new Error(`Error evaluating rule "${rule.id}": ${e.message}`);
422
+ }
423
+ }
424
+
425
+ // Map final verdict to exit code
426
+ decision.exitCode = mapVerdictToExitCode(decision.finalVerdict);
427
+
428
+ // Sort reasons by priority for consistent output
429
+ decision.reasons.sort((a, b) => a.priority - b.priority || a.ruleId.localeCompare(b.ruleId));
430
+
431
+ return decision;
432
+ }
433
+
434
+ /**
435
+ * Map canonical verdict to exit code
436
+ * READY (0) > FRICTION (1) > DO_NOT_LAUNCH (2)
437
+ */
438
+ function mapVerdictToExitCode(verdict) {
439
+ switch (verdict) {
440
+ case 'READY':
441
+ return 0;
442
+ case 'FRICTION':
443
+ return 1;
444
+ case 'DO_NOT_LAUNCH':
445
+ return 2;
446
+ default:
447
+ return 2; // Fail safe
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Build policy signals object from scan results
453
+ * This is the input data that rules operate on
454
+ */
455
+ function buildPolicySignals(scanResult) {
456
+ if (!scanResult || typeof scanResult !== 'object') {
457
+ throw new Error('scanResult must be an object');
458
+ }
459
+
460
+ const attempts = scanResult.attempts || [];
461
+ const executed = attempts.filter(a => a.executed);
462
+ const failed = executed.filter(a => a.outcome === 'FAILURE');
463
+ const nearSuccess = executed.filter(a => {
464
+ // Near-success: execution occurred but goal not reached without explicit failure
465
+ return a.outcome === 'FRICTION' || (a.outcome !== 'SUCCESS' && a.outcome !== 'FAILURE');
466
+ });
467
+
468
+ return {
469
+ // Basic counts
470
+ executedCount: executed.length,
471
+ failedCount: failed.length,
472
+ nearSuccessCount: nearSuccess.length,
473
+ successCount: executed.filter(a => a.outcome === 'SUCCESS').length,
474
+ frictionCount: executed.filter(a => a.outcome === 'FRICTION').length,
475
+ skippedCount: attempts.filter(a => !a.executed).length,
476
+
477
+ // Boolean flags
478
+ goalReached: scanResult.goalReached === true || (scanResult.meta?.goalReached === true),
479
+ hasScreenshots: (scanResult.evidence?.screenshots?.length || 0) > 0,
480
+ hasTraces: (scanResult.evidence?.traces?.length || 0) > 0,
481
+ hasRegressions: !!(scanResult.baseline?.diffResult?.regressions && Object.keys(scanResult.baseline.diffResult.regressions).length > 0),
482
+
483
+ // Domain/URL matching
484
+ domain: extractDomain(scanResult.url || scanResult.baseUrl),
485
+ url: scanResult.url || scanResult.baseUrl,
486
+
487
+ // Preset/policy name
488
+ preset: scanResult.preset || scanResult.policy,
489
+
490
+ // Raw counts for advanced rules
491
+ attemptTotal: attempts.length,
492
+ executionCoverage: attempts.length > 0 ? executed.length / attempts.length : 0
493
+ };
494
+ }
495
+
496
+ /**
497
+ * Extract domain from URL for pattern matching
498
+ */
499
+ function extractDomain(urlString) {
500
+ if (!urlString) return '';
501
+ try {
502
+ const url = new URL(urlString);
503
+ return url.hostname;
504
+ } catch {
505
+ return '';
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Create a default rules file in standard location
511
+ */
512
+ function createDefaultRulesFile(outputPath = 'config/guardian.rules.json') {
513
+ const defaultRules = [
514
+ {
515
+ id: 'failed_attempts_exist',
516
+ description: 'Fail if any attempts resulted in FAILURE',
517
+ priority: 10,
518
+ category: 'TRUST',
519
+ when: { field: 'failedCount', operator: 'greaterThan', value: 0 },
520
+ then: { verdict: 'DO_NOT_LAUNCH', reason: 'Critical flows failed during execution' }
521
+ },
522
+ {
523
+ id: 'no_executed_attempts',
524
+ description: 'Downgrade to FRICTION if no attempts were executed',
525
+ priority: 20,
526
+ category: 'COMPLIANCE',
527
+ when: { field: 'executedCount', operator: 'equals', value: 0 },
528
+ then: { verdict: 'FRICTION', reason: 'No attempts were executed; unable to validate real-world behavior' }
529
+ }
530
+ ];
531
+
532
+ const dir = path.dirname(outputPath);
533
+ if (!fs.existsSync(dir)) {
534
+ fs.mkdirSync(dir, { recursive: true });
535
+ }
536
+
537
+ fs.writeFileSync(outputPath, JSON.stringify(defaultRules, null, 2), 'utf8');
538
+ return outputPath;
539
+ }
540
+
541
+ module.exports = {
542
+ // Core functions
543
+ loadRules,
544
+ evaluateRules,
545
+ buildPolicySignals,
546
+ evaluateWhenConditions,
547
+ evaluateCondition,
548
+ mergeVerdicts,
549
+
550
+ // Utilities
551
+ validateRuleSchema,
552
+ createDefaultRulesFile,
553
+ mapVerdictToExitCode,
554
+ extractDomain,
555
+
556
+ // Constants
557
+ VERDICT_HIERARCHY
558
+ };