thumbgate 1.20.0 → 1.21.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/.claude-plugin/marketplace.json +40 -12
- package/.claude-plugin/plugin.json +15 -6
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +28 -8
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +15 -5
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +119 -2
- package/bin/postinstall.js +19 -13
- package/config/merge-quality-checks.json +0 -1
- package/config/post-deploy-marketing-pages.json +46 -0
- package/package.json +74 -60
- package/public/agent-manager.html +139 -0
- package/public/compare.html +1 -1
- package/public/dashboard.html +3 -3
- package/public/guide.html +23 -0
- package/public/index.html +79 -133
- package/public/learn.html +16 -0
- package/public/lessons.html +22 -0
- package/public/numbers.html +2 -2
- package/public/pricing.html +345 -0
- package/scripts/auto-promote-gates.js +7 -6
- package/scripts/billing.js +64 -0
- package/scripts/context-manager.js +42 -2
- package/scripts/feedback-loop.js +2 -1
- package/scripts/gates-engine.js +133 -7
- package/scripts/license.js +0 -1
- package/scripts/rate-limiter.js +36 -1
- package/scripts/tool-registry.js +28 -0
- package/scripts/verify-marketing-pages-deployed.js +195 -0
- package/scripts/workflow-sentinel.js +6 -1
- package/src/api/server.js +514 -142
package/scripts/gates-engine.js
CHANGED
|
@@ -1485,9 +1485,17 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1485
1485
|
}
|
|
1486
1486
|
|
|
1487
1487
|
if (gate.action === 'approve') {
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1488
|
+
const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
1489
|
+
if (approvalEnabled) {
|
|
1490
|
+
recordStat(gate.id, 'approve', gate);
|
|
1491
|
+
const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
|
|
1492
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1493
|
+
auditToFeedback(auditRecord);
|
|
1494
|
+
return result;
|
|
1495
|
+
}
|
|
1496
|
+
recordStat(gate.id, 'warn', gate);
|
|
1497
|
+
const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
|
|
1498
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1491
1499
|
auditToFeedback(auditRecord);
|
|
1492
1500
|
return result;
|
|
1493
1501
|
}
|
|
@@ -1639,9 +1647,17 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1639
1647
|
}
|
|
1640
1648
|
|
|
1641
1649
|
if (gate.action === 'approve') {
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1650
|
+
const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
1651
|
+
if (approvalEnabled) {
|
|
1652
|
+
recordStat(gate.id, 'approve', gate);
|
|
1653
|
+
const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
|
|
1654
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1655
|
+
auditToFeedback(auditRecord);
|
|
1656
|
+
return result;
|
|
1657
|
+
}
|
|
1658
|
+
recordStat(gate.id, 'warn', gate);
|
|
1659
|
+
const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
|
|
1660
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1645
1661
|
auditToFeedback(auditRecord);
|
|
1646
1662
|
return result;
|
|
1647
1663
|
}
|
|
@@ -1811,6 +1827,10 @@ function evaluateSecretGuard(input = {}) {
|
|
|
1811
1827
|
}
|
|
1812
1828
|
|
|
1813
1829
|
// ---------------------------------------------------------------------------
|
|
1830
|
+
function isApprovalGatesEnabled() {
|
|
1831
|
+
return process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1814
1834
|
// PreToolUse hook interface (stdin/stdout JSON)
|
|
1815
1835
|
// ---------------------------------------------------------------------------
|
|
1816
1836
|
|
|
@@ -1849,6 +1869,18 @@ function formatOutput(result, behavioralContext) {
|
|
|
1849
1869
|
});
|
|
1850
1870
|
}
|
|
1851
1871
|
|
|
1872
|
+
if (result.decision === 'approve') {
|
|
1873
|
+
const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
|
|
1874
|
+
const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
|
|
1875
|
+
return JSON.stringify({
|
|
1876
|
+
hookSpecificOutput: {
|
|
1877
|
+
...reminder,
|
|
1878
|
+
permissionDecision: 'deny',
|
|
1879
|
+
permissionDecisionReason: `[GATE:${result.gate}] APPROVAL REQUIRED: ${result.message} — Ask the human to confirm this action before proceeding.${reasoningSuffix}${reminderSuffix}`,
|
|
1880
|
+
},
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1852
1884
|
if (result.decision === 'warn') {
|
|
1853
1885
|
const extra = behavioralContext ? `\n${behavioralContext}` : '';
|
|
1854
1886
|
const context = `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`;
|
|
@@ -2223,7 +2255,84 @@ function registerClaimGate(claimPattern, requiredActions, blockMessage) {
|
|
|
2223
2255
|
return entry;
|
|
2224
2256
|
}
|
|
2225
2257
|
|
|
2226
|
-
function
|
|
2258
|
+
function normalizeStringArray(value) {
|
|
2259
|
+
if (!Array.isArray(value)) return [];
|
|
2260
|
+
return Array.from(new Set(
|
|
2261
|
+
value
|
|
2262
|
+
.map((item) => String(item || '').trim())
|
|
2263
|
+
.filter(Boolean)
|
|
2264
|
+
));
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function normalizeGoalContract(goalContract) {
|
|
2268
|
+
if (!goalContract || typeof goalContract !== 'object' || Array.isArray(goalContract)) {
|
|
2269
|
+
return null;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const goal = String(goalContract.goal || '').trim();
|
|
2273
|
+
const doneWhen = normalizeStringArray(goalContract.doneWhen);
|
|
2274
|
+
const requiredActions = normalizeStringArray(goalContract.proveBy);
|
|
2275
|
+
const mustNotChange = normalizeStringArray(goalContract.mustNotChange);
|
|
2276
|
+
const handoff = {
|
|
2277
|
+
workerAgent: String(goalContract.workerAgent || '').trim() || null,
|
|
2278
|
+
reviewerAgent: String(goalContract.reviewerAgent || '').trim() || null,
|
|
2279
|
+
orchestratorAgent: String(goalContract.orchestratorAgent || '').trim() || null,
|
|
2280
|
+
};
|
|
2281
|
+
|
|
2282
|
+
const matched = Boolean(
|
|
2283
|
+
goal ||
|
|
2284
|
+
doneWhen.length > 0 ||
|
|
2285
|
+
requiredActions.length > 0 ||
|
|
2286
|
+
mustNotChange.length > 0 ||
|
|
2287
|
+
handoff.workerAgent ||
|
|
2288
|
+
handoff.reviewerAgent ||
|
|
2289
|
+
handoff.orchestratorAgent
|
|
2290
|
+
);
|
|
2291
|
+
|
|
2292
|
+
if (!matched) return null;
|
|
2293
|
+
|
|
2294
|
+
return {
|
|
2295
|
+
goal: goal || null,
|
|
2296
|
+
doneWhen,
|
|
2297
|
+
requiredActions,
|
|
2298
|
+
mustNotChange,
|
|
2299
|
+
handoff,
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function evaluateGoalContract(goalContract, actions = loadSessionActions()) {
|
|
2304
|
+
const normalized = normalizeGoalContract(goalContract);
|
|
2305
|
+
if (!normalized) {
|
|
2306
|
+
return {
|
|
2307
|
+
matched: false,
|
|
2308
|
+
passed: true,
|
|
2309
|
+
goal: null,
|
|
2310
|
+
doneWhen: [],
|
|
2311
|
+
requiredActions: [],
|
|
2312
|
+
missingActions: [],
|
|
2313
|
+
mustNotChange: [],
|
|
2314
|
+
handoff: {
|
|
2315
|
+
workerAgent: null,
|
|
2316
|
+
reviewerAgent: null,
|
|
2317
|
+
orchestratorAgent: null,
|
|
2318
|
+
},
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const missingActions = normalized.requiredActions.filter((actionId) => !actions[actionId]);
|
|
2323
|
+
return {
|
|
2324
|
+
matched: true,
|
|
2325
|
+
passed: missingActions.length === 0,
|
|
2326
|
+
goal: normalized.goal,
|
|
2327
|
+
doneWhen: normalized.doneWhen,
|
|
2328
|
+
requiredActions: normalized.requiredActions,
|
|
2329
|
+
missingActions,
|
|
2330
|
+
mustNotChange: normalized.mustNotChange,
|
|
2331
|
+
handoff: normalized.handoff,
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function verifyClaimEvidence(claimText, options = {}) {
|
|
2227
2336
|
const normalizedClaimText = String(claimText || '').trim();
|
|
2228
2337
|
if (!normalizedClaimText) {
|
|
2229
2338
|
throw new Error('claimText is required');
|
|
@@ -2251,9 +2360,23 @@ function verifyClaimEvidence(claimText) {
|
|
|
2251
2360
|
});
|
|
2252
2361
|
}
|
|
2253
2362
|
|
|
2363
|
+
const goalContract = evaluateGoalContract(options.goalContract, actions);
|
|
2364
|
+
if (goalContract.matched) {
|
|
2365
|
+
checks.push({
|
|
2366
|
+
claim: 'goal_contract',
|
|
2367
|
+
passed: goalContract.passed,
|
|
2368
|
+
missing: goalContract.missingActions,
|
|
2369
|
+
message: goalContract.passed
|
|
2370
|
+
? 'Goal contract evidence present'
|
|
2371
|
+
: `Goal contract requires evidence: ${goalContract.missingActions.join(', ')}`,
|
|
2372
|
+
goalContract,
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2254
2376
|
return {
|
|
2255
2377
|
verified: checks.every((check) => check.passed),
|
|
2256
2378
|
checks,
|
|
2379
|
+
goalContract,
|
|
2257
2380
|
};
|
|
2258
2381
|
}
|
|
2259
2382
|
|
|
@@ -2289,6 +2412,7 @@ module.exports = {
|
|
|
2289
2412
|
evaluateGatesAsync,
|
|
2290
2413
|
computeExecutableHash,
|
|
2291
2414
|
formatOutput,
|
|
2415
|
+
isApprovalGatesEnabled,
|
|
2292
2416
|
run,
|
|
2293
2417
|
runAsync,
|
|
2294
2418
|
trackAction,
|
|
@@ -2297,6 +2421,8 @@ module.exports = {
|
|
|
2297
2421
|
clearSessionActions,
|
|
2298
2422
|
loadClaimGates,
|
|
2299
2423
|
registerClaimGate,
|
|
2424
|
+
normalizeGoalContract,
|
|
2425
|
+
evaluateGoalContract,
|
|
2300
2426
|
verifyClaimEvidence,
|
|
2301
2427
|
DEFAULT_CONFIG_PATH,
|
|
2302
2428
|
DEFAULT_CLAIM_GATES_PATH,
|
package/scripts/license.js
CHANGED
package/scripts/rate-limiter.js
CHANGED
|
@@ -39,9 +39,39 @@ const PAYWALL_MESSAGES = {
|
|
|
39
39
|
default: 'This feature requires Pro. Start Pro — card required; billed today.',
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
const TRIAL_DAYS = 14;
|
|
43
|
+
|
|
44
|
+
function getInstallAgeDays() {
|
|
45
|
+
try {
|
|
46
|
+
const { INSTALL_ID_PATH } = require('./cli-telemetry');
|
|
47
|
+
if (!fs.existsSync(INSTALL_ID_PATH)) return null;
|
|
48
|
+
const created = fs.statSync(INSTALL_ID_PATH).birthtimeMs || fs.statSync(INSTALL_ID_PATH).mtimeMs;
|
|
49
|
+
if (!Number.isFinite(created) || created <= 0) return null;
|
|
50
|
+
return (Date.now() - created) / (1000 * 60 * 60 * 24);
|
|
51
|
+
} catch (_) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isInTrialPeriod() {
|
|
57
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS) return false;
|
|
58
|
+
if (process.env.THUMBGATE_NO_TRIAL === '1') return false;
|
|
59
|
+
const age = getInstallAgeDays();
|
|
60
|
+
if (age === null) return false;
|
|
61
|
+
if (age < 0.0007) return false; // <1 minute old — just-created install, not a real trial
|
|
62
|
+
return age < TRIAL_DAYS;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function trialDaysRemaining() {
|
|
66
|
+
const age = getInstallAgeDays();
|
|
67
|
+
if (age === null) return 0;
|
|
68
|
+
return Math.max(0, Math.ceil(TRIAL_DAYS - age));
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
function isProTier(authContext) {
|
|
43
72
|
if (authContext && authContext.tier === 'pro') return true;
|
|
44
|
-
if (process.env.THUMBGATE_API_KEY
|
|
73
|
+
if (process.env.THUMBGATE_API_KEY) return true;
|
|
74
|
+
if (process.env.THUMBGATE_NO_RATE_LIMIT === '1') return true;
|
|
45
75
|
// Creator/dogfooding bypass: when the owner has the dev secret + bypass
|
|
46
76
|
// configured (env or ~/.config/thumbgate/dev.json), treat the install as Pro
|
|
47
77
|
// so marketing nudges and rate limits stop firing on the maintainer's own
|
|
@@ -57,6 +87,8 @@ function isProTier(authContext) {
|
|
|
57
87
|
const { isProLicensed } = require('./license');
|
|
58
88
|
if (isProLicensed()) return true;
|
|
59
89
|
} catch (_) {}
|
|
90
|
+
// 14-day reverse trial: new installs get full Pro access
|
|
91
|
+
if (isInTrialPeriod()) return true;
|
|
60
92
|
return false;
|
|
61
93
|
}
|
|
62
94
|
|
|
@@ -180,11 +212,14 @@ module.exports = {
|
|
|
180
212
|
checkLimit,
|
|
181
213
|
getUsage,
|
|
182
214
|
isProTier,
|
|
215
|
+
isInTrialPeriod,
|
|
216
|
+
trialDaysRemaining,
|
|
183
217
|
loadUsage,
|
|
184
218
|
saveUsage,
|
|
185
219
|
todayKey,
|
|
186
220
|
FREE_TIER_LIMITS,
|
|
187
221
|
FREE_TIER_MAX_GATES,
|
|
222
|
+
TRIAL_DAYS,
|
|
188
223
|
UPGRADE_MESSAGE,
|
|
189
224
|
PAYWALL_MESSAGES,
|
|
190
225
|
USAGE_FILE,
|
package/scripts/tool-registry.js
CHANGED
|
@@ -19,6 +19,32 @@ function destructiveTool(tool) {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const GOAL_CONTRACT_SCHEMA = {
|
|
23
|
+
type: 'object',
|
|
24
|
+
description: 'Optional agent handoff contract. Use this when a worker/orchestrator/reviewer loop needs explicit done criteria before a done/fixed/shipped claim is allowed.',
|
|
25
|
+
properties: {
|
|
26
|
+
goal: { type: 'string', description: 'The operator-visible goal this claim is trying to close.' },
|
|
27
|
+
doneWhen: {
|
|
28
|
+
type: 'array',
|
|
29
|
+
items: { type: 'string' },
|
|
30
|
+
description: 'Human-readable acceptance criteria for the task.',
|
|
31
|
+
},
|
|
32
|
+
proveBy: {
|
|
33
|
+
type: 'array',
|
|
34
|
+
items: { type: 'string' },
|
|
35
|
+
description: 'Tracked action ids required before the claim can pass, such as tests_passed, review_completed, ci_green, deploy_verified, or operator_approved.',
|
|
36
|
+
},
|
|
37
|
+
mustNotChange: {
|
|
38
|
+
type: 'array',
|
|
39
|
+
items: { type: 'string' },
|
|
40
|
+
description: 'Protected areas or constraints the agents must preserve while completing the goal.',
|
|
41
|
+
},
|
|
42
|
+
workerAgent: { type: 'string', description: 'Agent responsible for implementation.' },
|
|
43
|
+
reviewerAgent: { type: 'string', description: 'Agent responsible for independent verification.' },
|
|
44
|
+
orchestratorAgent: { type: 'string', description: 'Agent responsible for routing and deciding whether done can be claimed.' },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
22
48
|
const TOOLS = [
|
|
23
49
|
readOnlyTool({
|
|
24
50
|
name: 'capture_feedback',
|
|
@@ -843,6 +869,7 @@ const TOOLS = [
|
|
|
843
869
|
required: ['claim'],
|
|
844
870
|
properties: {
|
|
845
871
|
claim: { type: 'string', description: 'The claim text to verify' },
|
|
872
|
+
goalContract: GOAL_CONTRACT_SCHEMA,
|
|
846
873
|
},
|
|
847
874
|
},
|
|
848
875
|
}),
|
|
@@ -1291,6 +1318,7 @@ const TOOLS = [
|
|
|
1291
1318
|
claim: { type: 'string', description: 'The completion claim text to verify (e.g. "Fix shipped", "Tests passing")' },
|
|
1292
1319
|
mode: { type: 'string', enum: ['blocking', 'advisory'], description: 'blocking (default) returns blocking=true when evidence missing; advisory returns blocking=false' },
|
|
1293
1320
|
sessionId: { type: 'string', description: 'Optional session id to associate with the gate decision' },
|
|
1321
|
+
goalContract: GOAL_CONTRACT_SCHEMA,
|
|
1294
1322
|
},
|
|
1295
1323
|
},
|
|
1296
1324
|
}),
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* verify-marketing-pages-deployed.js — post-deploy probe for top-level
|
|
6
|
+
* marketing pages.
|
|
7
|
+
*
|
|
8
|
+
* Reads `config/post-deploy-marketing-pages.json` and curls every entry
|
|
9
|
+
* against the live production URL (default
|
|
10
|
+
* https://thumbgate-production.up.railway.app, overridable via
|
|
11
|
+
* THUMBGATE_PROD_URL env or --prod-url=…). Each response body must
|
|
12
|
+
* contain the configured sentinel string. Any mismatch fails the run
|
|
13
|
+
* and the route is included in the failure summary.
|
|
14
|
+
*
|
|
15
|
+
* Intended to run as a workflow step in .github/workflows/deploy-verify.yml
|
|
16
|
+
* after the version sentinel check, so the marketing surface is verified
|
|
17
|
+
* in the same gate as /health and /dashboard. Adding a new marketing
|
|
18
|
+
* page? Add it to the JSON manifest — no workflow edit required.
|
|
19
|
+
*
|
|
20
|
+
* Modes:
|
|
21
|
+
* --json machine-readable report on stdout, suitable for piping
|
|
22
|
+
* into the GitHub Actions PR comment step.
|
|
23
|
+
* --quiet suppress per-route lines; only print the final summary.
|
|
24
|
+
*
|
|
25
|
+
* Exit code is 0 when every page passes, 1 if any sentinel is missing
|
|
26
|
+
* or any route returns non-200.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
|
|
32
|
+
const DEFAULT_PROD_URL = 'https://thumbgate-production.up.railway.app';
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 12000;
|
|
34
|
+
const DEFAULT_MANIFEST_PATH = path.resolve(__dirname, '..', 'config', 'post-deploy-marketing-pages.json');
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv = []) {
|
|
37
|
+
const out = {
|
|
38
|
+
prodUrl: process.env.THUMBGATE_PROD_URL || DEFAULT_PROD_URL,
|
|
39
|
+
manifestPath: DEFAULT_MANIFEST_PATH,
|
|
40
|
+
json: false,
|
|
41
|
+
quiet: false,
|
|
42
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
43
|
+
};
|
|
44
|
+
for (const arg of argv) {
|
|
45
|
+
if (arg === '--json') out.json = true;
|
|
46
|
+
else if (arg === '--quiet') out.quiet = true;
|
|
47
|
+
else if (arg.startsWith('--prod-url=')) out.prodUrl = arg.slice('--prod-url='.length);
|
|
48
|
+
else if (arg.startsWith('--manifest=')) out.manifestPath = arg.slice('--manifest='.length);
|
|
49
|
+
else if (arg.startsWith('--timeout-ms=')) {
|
|
50
|
+
const n = Number(arg.slice('--timeout-ms='.length));
|
|
51
|
+
if (Number.isFinite(n) && n > 0) out.timeoutMs = n;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadManifest(manifestPath) {
|
|
58
|
+
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (!Array.isArray(parsed.pages) || parsed.pages.length === 0) {
|
|
61
|
+
throw new Error(`Manifest has no pages: ${manifestPath}`);
|
|
62
|
+
}
|
|
63
|
+
for (const entry of parsed.pages) {
|
|
64
|
+
if (typeof entry.route !== 'string' || !entry.route.startsWith('/')) {
|
|
65
|
+
throw new Error(`Invalid route in manifest: ${JSON.stringify(entry)}`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof entry.sentinel !== 'string' || entry.sentinel.length === 0) {
|
|
68
|
+
throw new Error(`Invalid sentinel for route ${entry.route}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function probePage({ prodUrl, route, sentinel, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
75
|
+
if (typeof fetchImpl !== 'function') {
|
|
76
|
+
return { route, ok: false, error: 'fetch_unavailable' };
|
|
77
|
+
}
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
80
|
+
try {
|
|
81
|
+
const url = `${prodUrl.replace(/\/$/, '')}${route}`;
|
|
82
|
+
const res = await fetchImpl(url, {
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
headers: {
|
|
85
|
+
// A real browser-shaped UA so any bot-deflection interstitials
|
|
86
|
+
// do NOT trigger; we are probing the real visitor's surface.
|
|
87
|
+
'User-Agent': 'thumbgate-deploy-verify/1.0 (+https://github.com/IgorGanapolsky/ThumbGate)',
|
|
88
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const body = await res.text().catch(() => '');
|
|
92
|
+
const sentinelPresent = body.includes(sentinel);
|
|
93
|
+
return {
|
|
94
|
+
route,
|
|
95
|
+
url,
|
|
96
|
+
status: res.status,
|
|
97
|
+
ok: res.ok && sentinelPresent,
|
|
98
|
+
sentinelPresent,
|
|
99
|
+
bytes: body.length,
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
route,
|
|
104
|
+
ok: false,
|
|
105
|
+
error: error?.name === 'AbortError' ? `timeout_after_${timeoutMs}ms` : (error?.message || String(error)),
|
|
106
|
+
};
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runVerification({ prodUrl, manifestPath, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
113
|
+
const manifest = loadManifest(manifestPath);
|
|
114
|
+
const results = [];
|
|
115
|
+
// Sequential is fine — manifest has <20 entries; parallelism would
|
|
116
|
+
// be over-engineered and would risk masking a per-route rate-limit signal.
|
|
117
|
+
for (const entry of manifest.pages) {
|
|
118
|
+
// eslint-disable-next-line no-await-in-loop
|
|
119
|
+
const result = await probePage({
|
|
120
|
+
prodUrl,
|
|
121
|
+
route: entry.route,
|
|
122
|
+
sentinel: entry.sentinel,
|
|
123
|
+
fetchImpl,
|
|
124
|
+
timeoutMs,
|
|
125
|
+
});
|
|
126
|
+
results.push({ ...result, sentinel: entry.sentinel, description: entry.description });
|
|
127
|
+
}
|
|
128
|
+
const passed = results.filter((r) => r.ok);
|
|
129
|
+
const failed = results.filter((r) => !r.ok);
|
|
130
|
+
return {
|
|
131
|
+
generatedAt: new Date().toISOString(),
|
|
132
|
+
prodUrl,
|
|
133
|
+
manifestVersion: manifest.version,
|
|
134
|
+
totalRoutes: results.length,
|
|
135
|
+
passedCount: passed.length,
|
|
136
|
+
failedCount: failed.length,
|
|
137
|
+
verdict: failed.length === 0 ? 'pass' : 'fail',
|
|
138
|
+
results,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderHuman(report, { quiet = false } = {}) {
|
|
143
|
+
const lines = [];
|
|
144
|
+
if (!quiet) {
|
|
145
|
+
for (const r of report.results) {
|
|
146
|
+
if (r.ok) {
|
|
147
|
+
lines.push(`✅ ${r.route.padEnd(20)} HTTP ${r.status} bytes=${r.bytes} sentinel-OK`);
|
|
148
|
+
} else if (r.error) {
|
|
149
|
+
lines.push(`❌ ${r.route.padEnd(20)} ERROR ${r.error}`);
|
|
150
|
+
} else if (!r.sentinelPresent) {
|
|
151
|
+
lines.push(`❌ ${r.route.padEnd(20)} HTTP ${r.status} sentinel MISSING (expected: ${JSON.stringify(r.sentinel)})`);
|
|
152
|
+
} else {
|
|
153
|
+
lines.push(`❌ ${r.route.padEnd(20)} HTTP ${r.status}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
lines.push(`\nSummary: ${report.passedCount}/${report.totalRoutes} pages passed against ${report.prodUrl} (verdict: ${report.verdict.toUpperCase()})`);
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function main(argv) {
|
|
162
|
+
const args = parseArgs(argv);
|
|
163
|
+
let report;
|
|
164
|
+
try {
|
|
165
|
+
report = await runVerification({
|
|
166
|
+
prodUrl: args.prodUrl,
|
|
167
|
+
manifestPath: args.manifestPath,
|
|
168
|
+
timeoutMs: args.timeoutMs,
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
process.stderr.write(`verify-marketing-pages-deployed FAILED: ${error.message}\n`);
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
if (args.json) {
|
|
175
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
176
|
+
} else {
|
|
177
|
+
process.stdout.write(`${renderHuman(report, { quiet: args.quiet })}\n`);
|
|
178
|
+
}
|
|
179
|
+
process.exitCode = report.verdict === 'pass' ? 0 : 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
DEFAULT_PROD_URL,
|
|
184
|
+
DEFAULT_TIMEOUT_MS,
|
|
185
|
+
DEFAULT_MANIFEST_PATH,
|
|
186
|
+
parseArgs,
|
|
187
|
+
loadManifest,
|
|
188
|
+
probePage,
|
|
189
|
+
runVerification,
|
|
190
|
+
renderHuman,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
|
|
194
|
+
main(process.argv.slice(2));
|
|
195
|
+
}
|
|
@@ -1178,10 +1178,15 @@ function chooseDecision({ riskScore, integrity, memoryGuard, learnedPolicy, blas
|
|
|
1178
1178
|
if (lowRiskHandoff) {
|
|
1179
1179
|
return 'allow';
|
|
1180
1180
|
}
|
|
1181
|
+
// Background customer-system actions checkpoint (warn), never hard-deny.
|
|
1182
|
+
// The checkpoint IS the mitigation — blocking outright prevents legitimate work.
|
|
1183
|
+
if (backgroundAgent && customerSystemAction) {
|
|
1184
|
+
return 'warn';
|
|
1185
|
+
}
|
|
1181
1186
|
if (destructiveBypass || learnedHardStop || repeatedHighBlast || (hasOperationalBlockers && riskScore >= 0.72) || riskScore >= 0.86) {
|
|
1182
1187
|
return 'deny';
|
|
1183
1188
|
}
|
|
1184
|
-
if (economicAction || (backgroundAgent &&
|
|
1189
|
+
if (economicAction || (backgroundAgent && riskScore >= 0.3)) {
|
|
1185
1190
|
return 'warn';
|
|
1186
1191
|
}
|
|
1187
1192
|
if ((workflowControl && workflowControl.mode === 'warn') || (costControl && costControl.mode === 'warn') || riskScore >= 0.45 || (learnedWarning && riskScore >= 0.3) || (learnedRecall && riskScore >= 0.34)) {
|