thumbgate 1.30.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +105 -10
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +168 -31
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/claim-verifiers.example.json +42 -0
- package/config/gates/claim-verifiers.json +25 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +233 -206
- package/config/model-tiers.json +7 -2
- package/glama.json +6 -0
- package/hooks/hooks.json +1 -1
- package/package.json +69 -12
- package/public/assets/diagrams/before-after.svg +17 -16
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +19 -13
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/evaluations.html +1 -1
- package/public/index.html +142 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/agent-readiness.js +110 -0
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/auto-wire-hooks.js +20 -8
- package/scripts/cli-schema.js +14 -0
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +356 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-loop.js +115 -7
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/feedback-schema.js +3 -0
- package/scripts/file-ledger-lock.js +130 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/financial-control-plane.js +1514 -0
- package/scripts/gates-engine.js +202 -7
- package/scripts/gemini-embedding-policy.js +1 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +15 -3
- package/scripts/hook-stop-anti-claim.js +63 -3
- package/scripts/human-escalation.js +353 -41
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +305 -29
- package/scripts/lesson-search.js +22 -8
- package/scripts/llm-client.js +304 -15
- package/scripts/model-tier-router.js +593 -0
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/provider-action-normalizer.js +11 -4
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-registry.js +141 -5
- package/scripts/universal-claim-evaluator.js +767 -0
- package/scripts/vector-store.js +154 -17
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/scripts/workflow-sentinel.js +77 -11
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +196 -13
|
@@ -165,6 +165,95 @@ function summarizePermissionTier(profileName = getActiveMcpProfile()) {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
|
|
169
|
+
function detectStopHookRegistered(projectRoot, existsSync, readFileSync) {
|
|
170
|
+
try {
|
|
171
|
+
const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
|
|
172
|
+
if (!existsSync(settingsPath)) return false;
|
|
173
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
174
|
+
const stopHooks = settings?.hooks?.Stop || [];
|
|
175
|
+
const flat = Array.isArray(stopHooks)
|
|
176
|
+
? stopHooks.flatMap((entry) => entry?.hooks || [entry])
|
|
177
|
+
: [];
|
|
178
|
+
return flat.some((hook) => String(hook?.command || '').includes('hook-stop-anti-claim'));
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function recommendationForClaimState({
|
|
185
|
+
evaluatorReady,
|
|
186
|
+
configLoadFailed,
|
|
187
|
+
verifierCount,
|
|
188
|
+
stopHookRegistered,
|
|
189
|
+
configSource,
|
|
190
|
+
loadErrorMessage,
|
|
191
|
+
}) {
|
|
192
|
+
if (!evaluatorReady) {
|
|
193
|
+
return 'Universal claim evaluator module is missing from this install.';
|
|
194
|
+
}
|
|
195
|
+
if (configLoadFailed) {
|
|
196
|
+
return loadErrorMessage;
|
|
197
|
+
}
|
|
198
|
+
if (verifierCount === 0) {
|
|
199
|
+
return 'No claim verifiers configured. Copy config/gates/claim-verifiers.example.json to .thumbgate/claim-verifiers.json and point subjects at your sources of truth.';
|
|
200
|
+
}
|
|
201
|
+
if (!stopHookRegistered) {
|
|
202
|
+
return 'Claim verifiers are present, but the Claude Stop anti-claim hook is not registered in .claude/settings.json.';
|
|
203
|
+
}
|
|
204
|
+
return `Factual claim recheck is ready (${verifierCount} verifier(s) from ${configSource}).`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function summarizeClaimVerification(projectRoot = PROJECT_ROOT, deps = {}) {
|
|
208
|
+
const resolveEvaluator = deps.resolveEvaluator
|
|
209
|
+
|| (() => require.resolve('./universal-claim-evaluator'));
|
|
210
|
+
const loadVerifierConfig = deps.loadVerifierConfig
|
|
211
|
+
|| (() => require('./universal-claim-evaluator').loadVerifierConfig);
|
|
212
|
+
const readFileSync = deps.readFileSync || fs.readFileSync;
|
|
213
|
+
const existsSync = deps.existsSync || fs.existsSync;
|
|
214
|
+
|
|
215
|
+
let evaluatorReady = false;
|
|
216
|
+
try {
|
|
217
|
+
resolveEvaluator();
|
|
218
|
+
evaluatorReady = true;
|
|
219
|
+
} catch {
|
|
220
|
+
evaluatorReady = false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let verifierCount = 0;
|
|
224
|
+
let configSource = 'none';
|
|
225
|
+
let configLoadFailed = false;
|
|
226
|
+
let loadErrorMessage = 'Install ThumbGate and configure claim verifiers under .thumbgate/claim-verifiers.json.';
|
|
227
|
+
try {
|
|
228
|
+
const loaded = loadVerifierConfig()({ cwd: projectRoot });
|
|
229
|
+
verifierCount = Array.isArray(loaded.verifiers) ? loaded.verifiers.length : 0;
|
|
230
|
+
configSource = loaded.source || 'none';
|
|
231
|
+
} catch (error) {
|
|
232
|
+
configLoadFailed = true;
|
|
233
|
+
loadErrorMessage = `Claim verifier config failed to load: ${error?.message || 'unknown error'}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const stopHookRegistered = detectStopHookRegistered(projectRoot, existsSync, readFileSync);
|
|
237
|
+
const recommendation = recommendationForClaimState({
|
|
238
|
+
evaluatorReady,
|
|
239
|
+
configLoadFailed,
|
|
240
|
+
verifierCount,
|
|
241
|
+
stopHookRegistered,
|
|
242
|
+
configSource,
|
|
243
|
+
loadErrorMessage,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
ready: evaluatorReady && verifierCount > 0 && stopHookRegistered && !configLoadFailed,
|
|
248
|
+
evaluatorReady,
|
|
249
|
+
verifierCount,
|
|
250
|
+
configSource,
|
|
251
|
+
stopHookRegistered,
|
|
252
|
+
configLoadFailed,
|
|
253
|
+
recommendation,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
168
257
|
function generateAgentReadinessReport({
|
|
169
258
|
projectRoot = PROJECT_ROOT,
|
|
170
259
|
mcpProfile = null,
|
|
@@ -172,11 +261,18 @@ function generateAgentReadinessReport({
|
|
|
172
261
|
const runtime = detectRuntimeIsolation();
|
|
173
262
|
const bootstrap = collectBootstrapFiles(projectRoot);
|
|
174
263
|
const permissions = summarizePermissionTier(mcpProfile || getActiveMcpProfile());
|
|
264
|
+
const claimVerification = summarizeClaimVerification(projectRoot);
|
|
175
265
|
|
|
176
266
|
const warnings = [];
|
|
177
267
|
if (!runtime.isolated) warnings.push(runtime.recommendation);
|
|
178
268
|
if (!bootstrap.ready) warnings.push(bootstrap.recommendation);
|
|
179
269
|
if (!permissions.ready) warnings.push(permissions.recommendation);
|
|
270
|
+
// Missing operator verifiers is advisory (not every project asserts SQL row counts).
|
|
271
|
+
// A missing evaluator module or a broken claim-verifier config is not advisory —
|
|
272
|
+
// both make factual recheck fail closed at runtime and must surface here.
|
|
273
|
+
if (!claimVerification.evaluatorReady || claimVerification.configLoadFailed) {
|
|
274
|
+
warnings.push(claimVerification.recommendation);
|
|
275
|
+
}
|
|
180
276
|
|
|
181
277
|
return {
|
|
182
278
|
generatedAt: new Date().toISOString(),
|
|
@@ -185,10 +281,12 @@ function generateAgentReadinessReport({
|
|
|
185
281
|
runtime,
|
|
186
282
|
bootstrap,
|
|
187
283
|
permissions,
|
|
284
|
+
claimVerification,
|
|
188
285
|
articleAlignment: {
|
|
189
286
|
runtimeIsolation: runtime.isolated,
|
|
190
287
|
contextConditioning: bootstrap.ready,
|
|
191
288
|
permissionEnvelope: permissions.ready,
|
|
289
|
+
factualClaimRecheck: claimVerification.ready,
|
|
192
290
|
},
|
|
193
291
|
warnings,
|
|
194
292
|
};
|
|
@@ -208,6 +306,15 @@ function reportToText(report) {
|
|
|
208
306
|
lines.push(`Permissions: ${report.permissions.profile} (${report.permissions.tier})`);
|
|
209
307
|
lines.push(` Write-capable tools: ${report.permissions.writeCapableTools.length}`);
|
|
210
308
|
lines.push(` Recommendation: ${report.permissions.recommendation}`);
|
|
309
|
+
if (report.claimVerification) {
|
|
310
|
+
lines.push(
|
|
311
|
+
`Claim verification: ${report.claimVerification.ready ? 'ready' : 'needs_attention'}`,
|
|
312
|
+
` Evaluator: ${report.claimVerification.evaluatorReady ? 'present' : 'missing'}`,
|
|
313
|
+
` Verifiers: ${report.claimVerification.verifierCount} (${report.claimVerification.configSource})`,
|
|
314
|
+
` Stop hook: ${report.claimVerification.stopHookRegistered ? 'registered' : 'missing'}`,
|
|
315
|
+
` Recommendation: ${report.claimVerification.recommendation}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
211
318
|
|
|
212
319
|
if (report.warnings.length > 0) {
|
|
213
320
|
lines.push('');
|
|
@@ -226,6 +333,9 @@ module.exports = {
|
|
|
226
333
|
detectRuntimeIsolation,
|
|
227
334
|
collectBootstrapFiles,
|
|
228
335
|
summarizePermissionTier,
|
|
336
|
+
summarizeClaimVerification,
|
|
337
|
+
recommendationForClaimState,
|
|
338
|
+
detectStopHookRegistered,
|
|
229
339
|
generateAgentReadinessReport,
|
|
230
340
|
reportToText,
|
|
231
341
|
};
|
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
+
const {
|
|
7
|
+
claimSupportScore,
|
|
8
|
+
evaluateAnswerQuality,
|
|
9
|
+
queryCoverage,
|
|
10
|
+
splitAnswerClaims,
|
|
11
|
+
} = require('./rag-structured-output');
|
|
6
12
|
|
|
7
13
|
const DEFAULT_THRESHOLDS = {
|
|
8
14
|
faithfulness: 0.72,
|
|
9
15
|
answerRelevance: 0.45,
|
|
10
16
|
contextPrecision: 0.5,
|
|
17
|
+
groundedness: 0.75,
|
|
18
|
+
citationPrecision: 1,
|
|
11
19
|
};
|
|
12
20
|
|
|
13
21
|
function tokenize(value) {
|
|
@@ -30,10 +38,7 @@ function overlapScore(left, right) {
|
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
function splitClaims(response) {
|
|
33
|
-
return
|
|
34
|
-
.split(/(?:[.!?]\s+|\n+)/)
|
|
35
|
-
.map((claim) => claim.trim())
|
|
36
|
-
.filter((claim) => claim.length > 0);
|
|
41
|
+
return splitAnswerClaims(response);
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
function normalizeContexts(contexts) {
|
|
@@ -44,11 +49,10 @@ function normalizeContexts(contexts) {
|
|
|
44
49
|
|
|
45
50
|
function scoreFaithfulness(response, contexts) {
|
|
46
51
|
const claims = splitClaims(response);
|
|
47
|
-
const
|
|
52
|
+
const contextItems = normalizeContexts(contexts);
|
|
48
53
|
if (claims.length === 0) return { score: 0, supportedClaims: 0, totalClaims: 0 };
|
|
49
54
|
const supportedClaims = claims.filter((claim) => {
|
|
50
|
-
|
|
51
|
-
return contextText.toLowerCase().includes(normalized) || overlapScore(claim, contextText) >= 0.58;
|
|
55
|
+
return contextItems.some((context) => claimSupportScore(claim, context) >= 0.4);
|
|
52
56
|
}).length;
|
|
53
57
|
return {
|
|
54
58
|
score: Number((supportedClaims / claims.length).toFixed(4)),
|
|
@@ -58,7 +62,7 @@ function scoreFaithfulness(response, contexts) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
function scoreAnswerRelevance(question, response) {
|
|
61
|
-
const score =
|
|
65
|
+
const score = queryCoverage(question, response);
|
|
62
66
|
return {
|
|
63
67
|
score: Number(score.toFixed(4)),
|
|
64
68
|
matchedQuestionTerms: unique(tokenize(question).filter((token) => tokenize(response).includes(token))),
|
|
@@ -97,14 +101,29 @@ function evaluateGeneration(testCase, options = {}) {
|
|
|
97
101
|
contexts,
|
|
98
102
|
testCase.reference || testCase.groundTruth || ''
|
|
99
103
|
);
|
|
104
|
+
const answerQuality = evaluateAnswerQuality({
|
|
105
|
+
query: testCase.question || testCase.user_input,
|
|
106
|
+
answer: testCase.response || testCase.answer,
|
|
107
|
+
referenceAnswer: testCase.reference || testCase.groundTruth || '',
|
|
108
|
+
citations: testCase.citations,
|
|
109
|
+
contexts: contexts.map((text, index) => ({ id: `context-${index + 1}`, text })),
|
|
110
|
+
}, {
|
|
111
|
+
minFaithfulness: thresholds.faithfulness,
|
|
112
|
+
minGroundedness: thresholds.groundedness,
|
|
113
|
+
minAnswerRelevance: thresholds.answerRelevance,
|
|
114
|
+
});
|
|
100
115
|
const scores = {
|
|
101
116
|
faithfulness: faithfulness.score,
|
|
102
117
|
answerRelevance: answerRelevance.score,
|
|
103
118
|
contextPrecision: contextPrecision.score,
|
|
119
|
+
groundedness: answerQuality.metrics.groundedness,
|
|
120
|
+
citationPrecision: answerQuality.metrics.citationPrecision,
|
|
104
121
|
};
|
|
105
122
|
const passed = scores.faithfulness >= thresholds.faithfulness
|
|
106
123
|
&& scores.answerRelevance >= thresholds.answerRelevance
|
|
107
|
-
&& scores.contextPrecision >= thresholds.contextPrecision
|
|
124
|
+
&& scores.contextPrecision >= thresholds.contextPrecision
|
|
125
|
+
&& scores.groundedness >= thresholds.groundedness
|
|
126
|
+
&& scores.citationPrecision >= thresholds.citationPrecision;
|
|
108
127
|
|
|
109
128
|
return {
|
|
110
129
|
id: String(testCase.id || testCase.traceId || 'case'),
|
|
@@ -116,6 +135,7 @@ function evaluateGeneration(testCase, options = {}) {
|
|
|
116
135
|
faithfulness,
|
|
117
136
|
answerRelevance,
|
|
118
137
|
contextPrecision,
|
|
138
|
+
answerQuality,
|
|
119
139
|
},
|
|
120
140
|
};
|
|
121
141
|
}
|
|
@@ -155,6 +175,8 @@ function buildEvalReport(cases, options = {}) {
|
|
|
155
175
|
faithfulness: average(results.map((result) => result.scores.faithfulness)),
|
|
156
176
|
answerRelevance: average(results.map((result) => result.scores.answerRelevance)),
|
|
157
177
|
contextPrecision: average(results.map((result) => result.scores.contextPrecision)),
|
|
178
|
+
groundedness: average(results.map((result) => result.scores.groundedness)),
|
|
179
|
+
citationPrecision: average(results.map((result) => result.scores.citationPrecision)),
|
|
158
180
|
};
|
|
159
181
|
|
|
160
182
|
return {
|
|
@@ -166,7 +188,7 @@ function buildEvalReport(cases, options = {}) {
|
|
|
166
188
|
passRate: results.length === 0 ? 0 : Number(((passed / results.length) * 100).toFixed(2)),
|
|
167
189
|
aggregate,
|
|
168
190
|
passedThreshold: failed === 0,
|
|
169
|
-
metrics: ['faithfulness', 'answerRelevance', 'contextPrecision'],
|
|
191
|
+
metrics: ['faithfulness', 'answerRelevance', 'contextPrecision', 'groundedness', 'citationPrecision'],
|
|
170
192
|
sinks: {
|
|
171
193
|
ci: true,
|
|
172
194
|
langsmithCompatible: true,
|
|
@@ -228,7 +250,10 @@ module.exports = {
|
|
|
228
250
|
scoreFaithfulness,
|
|
229
251
|
};
|
|
230
252
|
|
|
231
|
-
|
|
253
|
+
const invokedDirectly = Boolean(process.argv[1])
|
|
254
|
+
&& path.resolve(process.argv[1]) === __filename;
|
|
255
|
+
|
|
256
|
+
if (invokedDirectly) {
|
|
232
257
|
main().catch((err) => {
|
|
233
258
|
console.error(err.stack || err.message);
|
|
234
259
|
process.exitCode = 1;
|
package/scripts/audit-trail.js
CHANGED
|
@@ -61,7 +61,18 @@ function recordAuditEvent(params = {}) {
|
|
|
61
61
|
source: params.source || 'gates-engine',
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// Safe stringify: never let circular/toxic tool inputs crash the gate path
|
|
65
|
+
// (Antithesis-style invariant: evaluation + audit must not throw).
|
|
66
|
+
let line;
|
|
67
|
+
try {
|
|
68
|
+
line = JSON.stringify(record);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
line = JSON.stringify({
|
|
71
|
+
...record,
|
|
72
|
+
toolInput: { _unserializable: true, reason: String(err.message || err).slice(0, 120) },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
fs.appendFileSync(logPath, `${line}\n`);
|
|
65
76
|
try {
|
|
66
77
|
const { trainAndPersistInterventionPolicy } = require('./intervention-policy');
|
|
67
78
|
trainAndPersistInterventionPolicy(path.dirname(logPath));
|
|
@@ -73,10 +84,13 @@ function recordAuditEvent(params = {}) {
|
|
|
73
84
|
|
|
74
85
|
/**
|
|
75
86
|
* Strip secrets and large payloads from tool input before audit storage.
|
|
87
|
+
* Drop circular references so JSON.stringify never throws mid-gate evaluation.
|
|
76
88
|
*/
|
|
77
89
|
function sanitizeToolInput(toolInput) {
|
|
90
|
+
if (!toolInput || typeof toolInput !== 'object') return {};
|
|
78
91
|
const safe = {};
|
|
79
92
|
const MAX_VALUE_LEN = 200;
|
|
93
|
+
const seen = new WeakSet();
|
|
80
94
|
|
|
81
95
|
for (const [key, value] of Object.entries(toolInput)) {
|
|
82
96
|
if (typeof value === 'string') {
|
|
@@ -88,6 +102,17 @@ function sanitizeToolInput(toolInput) {
|
|
|
88
102
|
? value.slice(0, MAX_VALUE_LEN) + '...'
|
|
89
103
|
: value;
|
|
90
104
|
}
|
|
105
|
+
} else if (value && typeof value === 'object') {
|
|
106
|
+
if (seen.has(value)) {
|
|
107
|
+
safe[key] = '[Circular]';
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
seen.add(value);
|
|
111
|
+
try {
|
|
112
|
+
safe[key] = JSON.parse(JSON.stringify(value, getCircularReplacer()));
|
|
113
|
+
} catch {
|
|
114
|
+
safe[key] = `[unserializable:${typeof value}]`;
|
|
115
|
+
}
|
|
91
116
|
} else {
|
|
92
117
|
safe[key] = value;
|
|
93
118
|
}
|
|
@@ -95,6 +120,17 @@ function sanitizeToolInput(toolInput) {
|
|
|
95
120
|
return safe;
|
|
96
121
|
}
|
|
97
122
|
|
|
123
|
+
function getCircularReplacer() {
|
|
124
|
+
const seen = new WeakSet();
|
|
125
|
+
return function circularReplacer(_key, value) {
|
|
126
|
+
if (value && typeof value === 'object') {
|
|
127
|
+
if (seen.has(value)) return '[Circular]';
|
|
128
|
+
seen.add(value);
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
98
134
|
// ---------------------------------------------------------------------------
|
|
99
135
|
// Auto-feedback from audit events
|
|
100
136
|
// ---------------------------------------------------------------------------
|
|
@@ -84,8 +84,32 @@ function regressionCheck(gate, options = {}) {
|
|
|
84
84
|
let matchesGate;
|
|
85
85
|
try { ({ matchesGate } = require('./gates-engine')); } catch { return null; }
|
|
86
86
|
if (typeof matchesGate !== 'function') return null;
|
|
87
|
-
|
|
87
|
+
let allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
|
|
88
88
|
if (!allowed.length) return null;
|
|
89
|
+
|
|
90
|
+
// The command we just learned to block was, by definition, ALLOWED before we
|
|
91
|
+
// learned it — that prior allow IS the incident the operator thumbs-downed.
|
|
92
|
+
// Counting it as a false block quarantines every gate learned from a real
|
|
93
|
+
// failure, which is the normal path (run it, get burned, 👎 it). Exclude the
|
|
94
|
+
// originating contexts so the check only measures collateral damage to
|
|
95
|
+
// genuinely unrelated actions.
|
|
96
|
+
// Match on normalized command EQUALITY, not substring containment: a longer,
|
|
97
|
+
// genuinely different command that merely quotes the incident text (e.g.
|
|
98
|
+
// `notify-team --dry-run "<incident>"`) is real collateral damage and must
|
|
99
|
+
// still count toward quarantine.
|
|
100
|
+
const incidentSignatures = new Set(
|
|
101
|
+
(options.incidentContexts || [])
|
|
102
|
+
.map((c) => normalizeCommandSignature(String(c || '')))
|
|
103
|
+
.filter(Boolean),
|
|
104
|
+
);
|
|
105
|
+
if (incidentSignatures.size > 0) {
|
|
106
|
+
allowed = allowed.filter((e) => {
|
|
107
|
+
const cmd = (e.toolInput && (e.toolInput.command || e.toolInput.pattern)) || '';
|
|
108
|
+
return !incidentSignatures.has(normalizeCommandSignature(String(cmd)));
|
|
109
|
+
});
|
|
110
|
+
if (!allowed.length) return { falseBlocks: 0, allowSampleSize: 0 };
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
let falseBlocks = 0;
|
|
90
114
|
for (const e of allowed) {
|
|
91
115
|
try {
|
|
@@ -163,14 +187,43 @@ function normalizeCommandSignature(input) {
|
|
|
163
187
|
return tokens.join(' ').trim();
|
|
164
188
|
}
|
|
165
189
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Prefer an executable action we can match at PreToolUse time.
|
|
192
|
+
* Tag-only or pure prose feedback is useful memory — not an enforcement pattern.
|
|
193
|
+
*/
|
|
194
|
+
function extractExecutableAction(entry) {
|
|
195
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
196
|
+
const fromTool =
|
|
197
|
+
(entry.toolInput && (entry.toolInput.command || entry.toolInput.pattern))
|
|
198
|
+
|| (entry.tool_input && (entry.tool_input.command || entry.tool_input.pattern))
|
|
199
|
+
|| entry.command
|
|
200
|
+
|| entry.failedCommand
|
|
201
|
+
|| null;
|
|
202
|
+
if (fromTool && String(fromTool).trim().length >= 4) {
|
|
203
|
+
return String(fromTool).trim();
|
|
204
|
+
}
|
|
170
205
|
|
|
171
|
-
const ctx = (entry.context || entry.whatWentWrong || '').trim();
|
|
172
|
-
if (ctx.length <
|
|
173
|
-
|
|
206
|
+
const ctx = String(entry.context || entry.whatWentWrong || '').trim();
|
|
207
|
+
if (ctx.length < 4) return null;
|
|
208
|
+
|
|
209
|
+
// Looks like a shell / CLI invocation (not free-form prose).
|
|
210
|
+
const looksExecutable = /^(?:sudo\s+)?(?:~\/|\.\/|\/)?(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]+(?:\s|$)/.test(ctx)
|
|
211
|
+
&& /\s|^[a-z0-9._+-]+(?:\s|$)/i.test(ctx)
|
|
212
|
+
&& !/\s+(?:broke|failed|wrong|should|never|please|the agent)\b/i.test(ctx.slice(0, 80));
|
|
213
|
+
// Strong signal: known tool prefixes
|
|
214
|
+
const known = /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|mv|cp|chmod|chown|psql|mysql|mongo|terraform|pulumi|aws|gcloud|az|helm|ssh|scp|rsync|make|cargo|go|ruby|perl|bash|sh|zsh)\b/i.test(ctx);
|
|
215
|
+
if (known || (looksExecutable && /[\s-]/.test(ctx) && ctx.length <= 200)) {
|
|
216
|
+
return ctx;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractPatternKey(entry) {
|
|
222
|
+
// Enforcement groups by executable action only. Tags remain diagnostic metadata
|
|
223
|
+
// and must not create hard-block thresholds for a single unrelated latest command.
|
|
224
|
+
const action = extractExecutableAction(entry);
|
|
225
|
+
if (!action) return null;
|
|
226
|
+
return normalizeCommandSignature(action).slice(0, 100);
|
|
174
227
|
}
|
|
175
228
|
|
|
176
229
|
function extractDiagnosticKeys(entry) {
|
|
@@ -203,27 +256,29 @@ function groupNegativeFeedback(entries, windowDays) {
|
|
|
203
256
|
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
204
257
|
if (ts < cutoff) continue;
|
|
205
258
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
259
|
+
// Enforcement groups ONLY by executable action. Tag/diagnosis metadata is
|
|
260
|
+
// useful for memory and dashboards, but must not create hard-block thresholds
|
|
261
|
+
// that attach to an unrelated latest command.
|
|
262
|
+
const key = extractPatternKey(entry);
|
|
263
|
+
if (!key) continue;
|
|
264
|
+
|
|
265
|
+
if (!groups[key]) {
|
|
266
|
+
groups[key] = {
|
|
267
|
+
key,
|
|
268
|
+
count: 0,
|
|
269
|
+
entries: [],
|
|
270
|
+
latestContext: '',
|
|
271
|
+
latestTimestamp: '',
|
|
272
|
+
latestExecutable: '',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
groups[key].count++;
|
|
276
|
+
groups[key].entries.push(entry);
|
|
277
|
+
if (!groups[key].latestTimestamp || (entry.timestamp && entry.timestamp > groups[key].latestTimestamp)) {
|
|
278
|
+
groups[key].latestTimestamp = entry.timestamp || '';
|
|
279
|
+
const action = extractExecutableAction(entry);
|
|
280
|
+
groups[key].latestContext = action || entry.context || entry.whatWentWrong || '';
|
|
281
|
+
groups[key].latestExecutable = action || '';
|
|
227
282
|
}
|
|
228
283
|
}
|
|
229
284
|
|
|
@@ -234,15 +289,48 @@ function patternToGateId(key) {
|
|
|
234
289
|
return 'auto-' + key.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50).toLowerCase();
|
|
235
290
|
}
|
|
236
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Turn a captured context string into a pattern the gates engine can actually
|
|
294
|
+
* match. `gates-engine.js` compiles `gate.pattern` with `new RegExp(...)` and
|
|
295
|
+
* tests it against the tool-call text, so the pattern MUST be regex-safe text
|
|
296
|
+
* drawn from the command itself.
|
|
297
|
+
*
|
|
298
|
+
* It must NOT be the group key: keys are frequently tag-derived
|
|
299
|
+
* ("entity:Customer+entity:Funnel"), which is both meaningless against a command
|
|
300
|
+
* string and actively hazardous as a regex ('+' is a quantifier). Grouping by tag
|
|
301
|
+
* is correct — reusing that key as the match pattern is not.
|
|
302
|
+
*/
|
|
303
|
+
function contextToPattern(context) {
|
|
304
|
+
const raw = String(context || '').trim();
|
|
305
|
+
if (raw.length < 4) return null;
|
|
306
|
+
// Escape every regex metacharacter: the captured command is literal text.
|
|
307
|
+
return raw.slice(0, 120).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A gate that cannot match the very context that produced it is inert — it
|
|
312
|
+
* shows up in the dashboard as an active blocking rule while enforcing nothing.
|
|
313
|
+
* That failure mode is worse than no gate at all, so callers drop these.
|
|
314
|
+
*/
|
|
315
|
+
function gateMatchesOwnContext(gate, context) {
|
|
316
|
+
if (!gate || !gate.pattern) return false;
|
|
317
|
+
try {
|
|
318
|
+
return new RegExp(gate.pattern).test(String(context || ''));
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
237
324
|
function buildGateRule(group, actionOverride) {
|
|
238
325
|
const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
|
|
239
326
|
const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
|
|
240
|
-
const
|
|
327
|
+
const executable = (group.latestExecutable || extractExecutableAction({ context: group.latestContext }) || group.latestContext || '').slice(0, 120);
|
|
328
|
+
const context = executable;
|
|
241
329
|
const kind = group.key.startsWith('diagnosis:')
|
|
242
330
|
? 'repeated diagnosis'
|
|
243
331
|
: group.key.startsWith('constraint:')
|
|
244
332
|
? 'repeated constraint violation'
|
|
245
|
-
: 'repeated
|
|
333
|
+
: 'repeated executable action';
|
|
246
334
|
|
|
247
335
|
const occurrencesText = group.count === 'MANUAL' ? 'manual' : `${group.count} occurrences`;
|
|
248
336
|
const suggestedMessage = `Auto-promoted ${kind}: "${context}" (${occurrencesText} in ${WINDOW_DAYS} days)`;
|
|
@@ -257,7 +345,8 @@ function buildGateRule(group, actionOverride) {
|
|
|
257
345
|
return {
|
|
258
346
|
id: patternToGateId(group.key),
|
|
259
347
|
trigger: `auto:${group.key}`,
|
|
260
|
-
|
|
348
|
+
// Derived from the executable action, NOT from tag keys — see contextToPattern.
|
|
349
|
+
pattern: contextToPattern(executable),
|
|
261
350
|
action,
|
|
262
351
|
message: suggestedMessage,
|
|
263
352
|
severity,
|
|
@@ -393,6 +482,16 @@ function promote(feedbackLogPath, options) {
|
|
|
393
482
|
|
|
394
483
|
const gateId = patternToGateId(group.key);
|
|
395
484
|
|
|
485
|
+
// Contexts that produced this gate. Their prior "allow" decisions are the
|
|
486
|
+
// incident the operator thumbs-downed, not false positives — both the
|
|
487
|
+
// new-gate and the warn->block upgrade path must exclude them from the
|
|
488
|
+
// regression check, or every gate learned from a real failure is held at warn.
|
|
489
|
+
const incidentContexts = [
|
|
490
|
+
group.latestContext,
|
|
491
|
+
...(group.entries || []).map((e) => e && (e.context || e.whatWentWrong)),
|
|
492
|
+
].filter(Boolean);
|
|
493
|
+
const regressionOpts = { ...opts, incidentContexts };
|
|
494
|
+
|
|
396
495
|
// Check for existing gate — possibly upgrade
|
|
397
496
|
const existingIdx = data.gates.findIndex((g) => g.id === gateId);
|
|
398
497
|
if (existingIdx !== -1) {
|
|
@@ -400,7 +499,7 @@ function promote(feedbackLogPath, options) {
|
|
|
400
499
|
const newAction = group.count >= BLOCK_THRESHOLD ? 'block' : 'warn';
|
|
401
500
|
if (existing.action !== newAction && newAction === 'block') {
|
|
402
501
|
// Self-Harness stage 3: regression-test before upgrading warn -> block.
|
|
403
|
-
const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'),
|
|
502
|
+
const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), regressionOpts);
|
|
404
503
|
if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
|
|
405
504
|
// Would block prior safe actions — hold at warn instead of upgrading.
|
|
406
505
|
promotions.push({ type: 'upgrade-quarantined', gateId, from: existing.action, occurrences: group.count, falseBlocks: regression.falseBlocks });
|
|
@@ -418,12 +517,25 @@ function promote(feedbackLogPath, options) {
|
|
|
418
517
|
// New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
|
|
419
518
|
const gate = buildGateRule(group, opts.gateAction);
|
|
420
519
|
|
|
520
|
+
// Never persist a gate that cannot match the context that produced it. Such a
|
|
521
|
+
// gate renders in the dashboard as an active blocking rule while enforcing
|
|
522
|
+
// nothing, which reads as "the agent learned" when it did not.
|
|
523
|
+
if (!gateMatchesOwnContext(gate, group.latestContext)) {
|
|
524
|
+
promotions.push({
|
|
525
|
+
type: 'skipped-unmatchable',
|
|
526
|
+
gateId: gate.id,
|
|
527
|
+
reason: 'derived pattern does not match originating context',
|
|
528
|
+
occurrences: group.count,
|
|
529
|
+
});
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
421
533
|
// Self-Harness stage 3: before a feedback rule goes live as a hard block,
|
|
422
534
|
// regression-test it against prior allowed actions. If it would have blocked
|
|
423
535
|
// safe actions, quarantine it to `warn` instead of `block`.
|
|
424
536
|
let regression = null;
|
|
425
537
|
if (gate.action === 'block' && !opts.gateAction && !opts.skipRegression) {
|
|
426
|
-
regression = safeRegressionCheck(gate,
|
|
538
|
+
regression = safeRegressionCheck(gate, regressionOpts);
|
|
427
539
|
if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
|
|
428
540
|
gate.action = 'warn';
|
|
429
541
|
gate.severity = 'medium';
|
|
@@ -506,10 +618,13 @@ module.exports = {
|
|
|
506
618
|
groupNegativeFeedback,
|
|
507
619
|
patternToGateId,
|
|
508
620
|
buildGateRule,
|
|
621
|
+
contextToPattern,
|
|
622
|
+
gateMatchesOwnContext,
|
|
509
623
|
regressionCheck,
|
|
510
624
|
getAuditTrailPath,
|
|
511
625
|
REGRESSION_FALSE_BLOCK_LIMIT,
|
|
512
626
|
extractPatternKey,
|
|
627
|
+
extractExecutableAction,
|
|
513
628
|
normalizeCommandSignature,
|
|
514
629
|
isNegative,
|
|
515
630
|
expireGates,
|