@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.
- package/CHANGELOG.md +210 -210
- package/LICENSE +21 -21
- package/README.md +297 -184
- package/bin/guardian.js +2242 -2221
- package/config/README.md +59 -59
- package/config/guardian.config.json +54 -54
- package/config/guardian.policy.json +12 -12
- package/config/profiles/docs.yaml +18 -18
- package/config/profiles/ecommerce.yaml +17 -17
- package/config/profiles/landing-demo.yaml +16 -16
- package/config/profiles/marketing.yaml +18 -18
- package/config/profiles/saas.yaml +21 -21
- package/flows/example-login-flow.json +36 -36
- package/flows/example-signup-flow.json +44 -44
- package/package.json +124 -116
- package/policies/enterprise.json +12 -12
- package/policies/landing-demo.json +22 -22
- package/policies/saas.json +12 -12
- package/policies/startup.json +12 -12
- package/src/enterprise/audit-logger.js +166 -166
- package/src/enterprise/pdf-exporter.js +267 -267
- package/src/enterprise/rbac-gate.js +142 -142
- package/src/enterprise/rbac.js +239 -239
- package/src/enterprise/site-manager.js +180 -180
- package/src/founder/feedback-system.js +156 -156
- package/src/founder/founder-tracker.js +213 -213
- package/src/founder/usage-signals.js +141 -141
- package/src/guardian/action-hints.js +439 -439
- package/src/guardian/alert-ledger.js +121 -121
- package/src/guardian/artifact-sanitizer.js +56 -56
- package/src/guardian/attempt-engine.js +1069 -1029
- package/src/guardian/attempt-registry.js +267 -267
- package/src/guardian/attempt-relevance.js +106 -106
- package/src/guardian/attempt-reporter.js +513 -507
- package/src/guardian/attempt.js +274 -273
- package/src/guardian/attempts-filter.js +63 -63
- package/src/guardian/auto-attempt-builder.js +283 -283
- package/src/guardian/baseline-registry.js +177 -177
- package/src/guardian/baseline-reporter.js +143 -143
- package/src/guardian/baseline-storage.js +285 -285
- package/src/guardian/baseline.js +535 -534
- package/src/guardian/behavioral-signals.js +261 -261
- package/src/guardian/breakage-intelligence.js +224 -224
- package/src/guardian/browser-pool.js +131 -131
- package/src/guardian/browser.js +119 -119
- package/src/guardian/canonical-truth.js +308 -308
- package/src/guardian/ci-cli.js +121 -121
- package/src/guardian/ci-gate.js +96 -96
- package/src/guardian/ci-mode.js +15 -15
- package/src/guardian/ci-output.js +55 -38
- package/src/guardian/cli-summary.js +102 -102
- package/src/guardian/confidence-signals.js +251 -251
- package/src/guardian/config-loader.js +161 -161
- package/src/guardian/config-validator.js +285 -283
- package/src/guardian/coverage-model.js +239 -239
- package/src/guardian/coverage-packs.js +58 -58
- package/src/guardian/crawler.js +142 -142
- package/src/guardian/data-guardian-detector.js +189 -189
- package/src/guardian/decision-authority.js +746 -725
- package/src/guardian/detection-layers.js +271 -271
- package/src/guardian/determinism.js +146 -146
- package/src/guardian/discovery-engine.js +661 -661
- package/src/guardian/drift-detector.js +100 -100
- package/src/guardian/enhanced-html-reporter.js +522 -522
- package/src/guardian/env-guard.js +128 -127
- package/src/guardian/error-clarity.js +399 -399
- package/src/guardian/export-contract.js +196 -196
- package/src/guardian/fail-safe.js +212 -212
- package/src/guardian/failure-intelligence.js +173 -173
- package/src/guardian/failure-taxonomy.js +169 -169
- package/src/guardian/final-outcome.js +206 -206
- package/src/guardian/first-run-profile.js +89 -89
- package/src/guardian/first-run.js +65 -67
- package/src/guardian/flag-validator.js +111 -111
- package/src/guardian/flow-executor.js +641 -639
- package/src/guardian/flow-registry.js +67 -67
- package/src/guardian/honesty.js +394 -394
- package/src/guardian/html-reporter.js +416 -416
- package/src/guardian/human-intent-resolver.js +296 -296
- package/src/guardian/human-interaction-model.js +351 -351
- package/src/guardian/human-journey-context.js +184 -184
- package/src/guardian/human-navigator.js +544 -544
- package/src/guardian/human-reporter.js +435 -431
- package/src/guardian/index.js +226 -221
- package/src/guardian/init-command.js +143 -143
- package/src/guardian/intent-detector.js +148 -146
- package/src/guardian/journey-definitions.js +132 -132
- package/src/guardian/journey-scan-cli.js +142 -145
- package/src/guardian/journey-scanner.js +583 -583
- package/src/guardian/junit-reporter.js +281 -281
- package/src/guardian/language-detection.js +99 -99
- package/src/guardian/live-alert.js +56 -56
- package/src/guardian/live-baseline-compare.js +146 -146
- package/src/guardian/live-cli.js +95 -95
- package/src/guardian/live-guardian.js +210 -210
- package/src/guardian/live-scheduler-runner.js +137 -137
- package/src/guardian/live-scheduler-state.js +167 -168
- package/src/guardian/live-scheduler.js +146 -146
- package/src/guardian/live-state.js +110 -110
- package/src/guardian/market-criticality.js +335 -335
- package/src/guardian/market-reporter.js +577 -577
- package/src/guardian/network-trace.js +178 -178
- package/src/guardian/obs-logger.js +110 -110
- package/src/guardian/observed-capabilities.js +427 -427
- package/src/guardian/output-contract.js +154 -0
- package/src/guardian/output-readability.js +264 -264
- package/src/guardian/parallel-executor.js +116 -116
- package/src/guardian/path-safety.js +56 -56
- package/src/guardian/pattern-analyzer.js +348 -348
- package/src/guardian/policy.js +432 -434
- package/src/guardian/prelaunch-gate.js +193 -193
- package/src/guardian/prerequisite-checker.js +101 -101
- package/src/guardian/preset-loader.js +152 -157
- package/src/guardian/profile-loader.js +96 -96
- package/src/guardian/reality.js +3025 -2826
- package/src/guardian/realworld-scenarios.js +94 -94
- package/src/guardian/reporter.js +167 -167
- package/src/guardian/retry-policy.js +123 -123
- package/src/guardian/root-cause-analysis.js +171 -171
- package/src/guardian/rules-engine.js +558 -558
- package/src/guardian/run-artifacts.js +212 -212
- package/src/guardian/run-cleanup.js +207 -207
- package/src/guardian/run-export.js +522 -522
- package/src/guardian/run-latest.js +90 -90
- package/src/guardian/run-list.js +211 -211
- package/src/guardian/run-summary.js +20 -20
- package/src/guardian/runtime-root.js +246 -246
- package/src/guardian/safety.js +248 -248
- package/src/guardian/scan-presets.js +133 -149
- package/src/guardian/screenshot.js +152 -152
- package/src/guardian/secret-hygiene.js +44 -44
- package/src/guardian/selector-fallbacks.js +394 -394
- package/src/guardian/semantic-contact-detection.js +255 -255
- package/src/guardian/semantic-contact-finder.js +201 -201
- package/src/guardian/semantic-targets.js +234 -234
- package/src/guardian/site-intelligence.js +588 -588
- package/src/guardian/site-introspection.js +257 -257
- package/src/guardian/sitemap.js +225 -225
- package/src/guardian/smoke.js +283 -258
- package/src/guardian/snapshot-schema.js +177 -290
- package/src/guardian/snapshot.js +430 -397
- package/src/guardian/stability-scorer.js +169 -169
- package/src/guardian/success-evaluator.js +214 -214
- package/src/guardian/template-command.js +184 -184
- package/src/guardian/text-formatters.js +426 -426
- package/src/guardian/timeout-profiles.js +57 -57
- package/src/guardian/truth/attempt.contract.js +158 -0
- package/src/guardian/truth/decision.contract.js +275 -0
- package/src/guardian/truth/snapshot.contract.js +363 -0
- package/src/guardian/validators.js +323 -323
- package/src/guardian/verdict-card.js +474 -474
- package/src/guardian/verdict-clarity.js +298 -298
- package/src/guardian/verdict-policy.js +363 -363
- package/src/guardian/verdict.js +333 -333
- package/src/guardian/verdicts.js +79 -74
- package/src/guardian/visual-diff.js +247 -247
- package/src/guardian/wait-for-outcome.js +119 -119
- package/src/guardian/watch-runner.js +181 -181
- package/src/guardian/watchdog-diff.js +167 -167
- package/src/guardian/webhook.js +206 -206
- package/src/payments/stripe-checkout.js +169 -169
- package/src/plans/plan-definitions.js +148 -148
- package/src/plans/plan-manager.js +211 -211
- package/src/plans/usage-tracker.js +210 -210
- package/src/recipes/recipe-engine.js +188 -188
- package/src/recipes/recipe-failure-analysis.js +159 -159
- package/src/recipes/recipe-registry.js +134 -134
- package/src/recipes/recipe-runtime.js +507 -507
- package/src/recipes/recipe-store.js +410 -410
- package/SECURITY.md +0 -77
- package/VERSIONING.md +0 -100
- 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
|
+
};
|