principles-disciple 1.7.5 → 1.7.6
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/dist/commands/evolution-status.js +32 -44
- package/dist/config/defaults/runtime.d.ts +40 -0
- package/dist/config/defaults/runtime.js +44 -0
- package/dist/config/errors.d.ts +84 -0
- package/dist/config/errors.js +94 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +7 -0
- package/dist/constants/diagnostician.d.ts +0 -4
- package/dist/constants/diagnostician.js +0 -4
- package/dist/core/control-ui-db.d.ts +27 -0
- package/dist/core/control-ui-db.js +18 -0
- package/dist/core/path-resolver.js +2 -1
- package/dist/core/trajectory.d.ts +60 -0
- package/dist/core/trajectory.js +72 -2
- package/dist/hooks/bash-risk.d.ts +57 -0
- package/dist/hooks/bash-risk.js +137 -0
- package/dist/hooks/edit-verification.d.ts +62 -0
- package/dist/hooks/edit-verification.js +256 -0
- package/dist/hooks/gate-block-helper.d.ts +44 -0
- package/dist/hooks/gate-block-helper.js +119 -0
- package/dist/hooks/gate.d.ts +18 -0
- package/dist/hooks/gate.js +62 -751
- package/dist/hooks/gfi-gate.d.ts +40 -0
- package/dist/hooks/gfi-gate.js +112 -0
- package/dist/hooks/progressive-trust-gate.d.ts +79 -0
- package/dist/hooks/progressive-trust-gate.js +242 -0
- package/dist/hooks/prompt.js +10 -6
- package/dist/hooks/thinking-checkpoint.d.ts +37 -0
- package/dist/hooks/thinking-checkpoint.js +51 -0
- package/dist/http/principles-console-route.js +13 -3
- package/dist/service/central-database.js +2 -1
- package/dist/service/control-ui-query-service.d.ts +1 -1
- package/dist/service/control-ui-query-service.js +3 -3
- package/dist/service/evolution-query-service.d.ts +1 -1
- package/dist/service/evolution-query-service.js +5 -5
- package/dist/service/evolution-worker.d.ts +10 -0
- package/dist/service/evolution-worker.js +7 -3
- package/dist/service/phase3-input-filter.d.ts +57 -0
- package/dist/service/phase3-input-filter.js +93 -3
- package/dist/service/runtime-summary-service.d.ts +34 -0
- package/dist/service/runtime-summary-service.js +93 -1
- package/dist/types/event-types.d.ts +2 -0
- package/dist/types/runtime-summary.d.ts +54 -0
- package/dist/types/runtime-summary.js +1 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/core/trajectory.js
CHANGED
|
@@ -4,6 +4,16 @@ import path from 'path';
|
|
|
4
4
|
import crypto from 'crypto';
|
|
5
5
|
import { withLock } from '../utils/file-lock.js';
|
|
6
6
|
import { resolvePdPath } from './paths.js';
|
|
7
|
+
import { SampleNotFoundError } from '../config/index.js';
|
|
8
|
+
/**
|
|
9
|
+
* Trajectory database stores HISTORICAL and ANALYTICS data.
|
|
10
|
+
*
|
|
11
|
+
* PURPOSE: Track task outcomes, trust changes, and evolution progress over time.
|
|
12
|
+
* USAGE: Insights, trends, and Phase 3 supporting evidence (where explicitly allowed).
|
|
13
|
+
* NOT FOR: Control decisions, Phase 3 eligibility, or real-time operations.
|
|
14
|
+
*
|
|
15
|
+
* Runtime truth comes from: queue state, workspace trust scorecard, active sessions
|
|
16
|
+
*/
|
|
7
17
|
const DEFAULT_INLINE_THRESHOLD = 16 * 1024;
|
|
8
18
|
const DEFAULT_BUSY_TIMEOUT_MS = 5000;
|
|
9
19
|
const DEFAULT_ORPHAN_BLOB_GRACE_DAYS = 7;
|
|
@@ -217,6 +227,12 @@ export class TrajectoryDatabase {
|
|
|
217
227
|
`).run(input.traceId, input.taskId ?? null, input.stage, input.level ?? 'info', input.message, input.summary ?? null, safeJson(input.metadata), input.createdAt ?? nowIso());
|
|
218
228
|
});
|
|
219
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* List evolution tasks with optional filtering.
|
|
232
|
+
*
|
|
233
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
234
|
+
* Not: Runtime truth or real-time queue state.
|
|
235
|
+
*/
|
|
220
236
|
listEvolutionTasks(filters = {}) {
|
|
221
237
|
const conditions = [];
|
|
222
238
|
const values = [];
|
|
@@ -259,6 +275,12 @@ export class TrajectoryDatabase {
|
|
|
259
275
|
updatedAt: String(row.updated_at),
|
|
260
276
|
}));
|
|
261
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* List evolution events for a trace or globally.
|
|
280
|
+
*
|
|
281
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
282
|
+
* Not: Runtime truth or real-time queue state.
|
|
283
|
+
*/
|
|
262
284
|
listEvolutionEvents(traceId, filters = {}) {
|
|
263
285
|
const limit = filters.limit ?? 100;
|
|
264
286
|
const offset = filters.offset ?? 0;
|
|
@@ -292,6 +314,12 @@ export class TrajectoryDatabase {
|
|
|
292
314
|
createdAt: String(row.created_at),
|
|
293
315
|
}));
|
|
294
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Get evolution task by trace ID.
|
|
319
|
+
*
|
|
320
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
321
|
+
* Not: Runtime truth or real-time queue state.
|
|
322
|
+
*/
|
|
295
323
|
getEvolutionTaskByTraceId(traceId) {
|
|
296
324
|
const row = this.db.prepare(`
|
|
297
325
|
SELECT id, task_id, trace_id, source, reason, score, status,
|
|
@@ -318,6 +346,12 @@ export class TrajectoryDatabase {
|
|
|
318
346
|
updatedAt: String(row.updated_at),
|
|
319
347
|
};
|
|
320
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Get evolution task statistics.
|
|
351
|
+
*
|
|
352
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
353
|
+
* Not: Runtime truth or real-time queue state.
|
|
354
|
+
*/
|
|
321
355
|
getEvolutionStats() {
|
|
322
356
|
const rows = this.db.prepare(`
|
|
323
357
|
SELECT status, COUNT(*) as count FROM evolution_tasks GROUP BY status
|
|
@@ -336,6 +370,12 @@ export class TrajectoryDatabase {
|
|
|
336
370
|
}
|
|
337
371
|
return stats;
|
|
338
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* List assistant turns for a session.
|
|
375
|
+
*
|
|
376
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
377
|
+
* Not: Runtime truth or real-time queue state.
|
|
378
|
+
*/
|
|
339
379
|
listAssistantTurns(sessionId) {
|
|
340
380
|
const rows = this.db.prepare(`
|
|
341
381
|
SELECT id, session_id, run_id, provider, model, raw_text, sanitized_text, blob_ref, created_at
|
|
@@ -355,6 +395,12 @@ export class TrajectoryDatabase {
|
|
|
355
395
|
createdAt: String(row.created_at),
|
|
356
396
|
}));
|
|
357
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* List correction samples with optional review status filter.
|
|
400
|
+
*
|
|
401
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
402
|
+
* Not: Runtime truth or real-time queue state.
|
|
403
|
+
*/
|
|
358
404
|
listCorrectionSamples(status = 'pending') {
|
|
359
405
|
const rows = this.db.prepare(`
|
|
360
406
|
SELECT sample_id, session_id, bad_assistant_turn_id, user_correction_turn_id,
|
|
@@ -397,7 +443,7 @@ export class TrajectoryDatabase {
|
|
|
397
443
|
return true;
|
|
398
444
|
});
|
|
399
445
|
if (!updated) {
|
|
400
|
-
throw new
|
|
446
|
+
throw new SampleNotFoundError(sampleId);
|
|
401
447
|
}
|
|
402
448
|
const record = this.db.prepare(`
|
|
403
449
|
SELECT sample_id, session_id, bad_assistant_turn_id, user_correction_turn_id,
|
|
@@ -407,7 +453,7 @@ export class TrajectoryDatabase {
|
|
|
407
453
|
WHERE sample_id = ?
|
|
408
454
|
`).get(sampleId);
|
|
409
455
|
if (!record) {
|
|
410
|
-
throw new
|
|
456
|
+
throw new SampleNotFoundError(`${sampleId} (after update)`);
|
|
411
457
|
}
|
|
412
458
|
return {
|
|
413
459
|
sampleId: String(record.sample_id),
|
|
@@ -424,6 +470,12 @@ export class TrajectoryDatabase {
|
|
|
424
470
|
updatedAt: String(record.updated_at),
|
|
425
471
|
};
|
|
426
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Export correction samples to JSONL file.
|
|
475
|
+
*
|
|
476
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
477
|
+
* Not: Runtime truth or real-time queue state.
|
|
478
|
+
*/
|
|
427
479
|
exportCorrections(opts) {
|
|
428
480
|
const rows = this.db.prepare(`
|
|
429
481
|
SELECT cs.sample_id, cs.session_id, cs.recovery_tool_span_json, cs.diff_excerpt, cs.quality_score,
|
|
@@ -462,6 +514,12 @@ export class TrajectoryDatabase {
|
|
|
462
514
|
this.recordExportAudit('corrections', opts.mode, opts.approvedOnly, exportPath, rows.length);
|
|
463
515
|
return { filePath: exportPath, count: rows.length, mode: opts.mode };
|
|
464
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* Export analytics data to JSON file.
|
|
519
|
+
*
|
|
520
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
521
|
+
* Not: Runtime truth or real-time queue state.
|
|
522
|
+
*/
|
|
465
523
|
exportAnalytics() {
|
|
466
524
|
const payload = {
|
|
467
525
|
generatedAt: nowIso(),
|
|
@@ -476,6 +534,12 @@ export class TrajectoryDatabase {
|
|
|
476
534
|
this.recordExportAudit('analytics', 'raw', true, exportPath, Array.isArray(payload.dailyMetrics) ? payload.dailyMetrics.length : 0);
|
|
477
535
|
return { filePath: exportPath, count: Array.isArray(payload.dailyMetrics) ? payload.dailyMetrics.length : 0 };
|
|
478
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* Get trajectory database statistics.
|
|
539
|
+
*
|
|
540
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
541
|
+
* Not: Runtime truth or real-time queue state.
|
|
542
|
+
*/
|
|
479
543
|
getDataStats() {
|
|
480
544
|
const getCount = (table, where) => {
|
|
481
545
|
const sql = where ? `SELECT COUNT(*) as count FROM ${table} WHERE ${where}` : `SELECT COUNT(*) as count FROM ${table}`;
|
|
@@ -729,6 +793,12 @@ export class TrajectoryDatabase {
|
|
|
729
793
|
LEFT JOIN correction_daily ON correction_daily.day = tool_daily.day;
|
|
730
794
|
`);
|
|
731
795
|
}
|
|
796
|
+
/**
|
|
797
|
+
* Get daily metrics for analytics.
|
|
798
|
+
*
|
|
799
|
+
* Returns: Analytics data aggregated from trajectory database.
|
|
800
|
+
* Not: Runtime truth or real-time queue state.
|
|
801
|
+
*/
|
|
732
802
|
dailyMetrics() {
|
|
733
803
|
return this.db.prepare('SELECT * FROM v_daily_metrics ORDER BY day ASC').all();
|
|
734
804
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Risk Analysis Module
|
|
3
|
+
*
|
|
4
|
+
* Analyzes bash command security risks and determines command categorization.
|
|
5
|
+
*
|
|
6
|
+
* **Responsibilities:**
|
|
7
|
+
* - De-obfuscate Unicode/Cyrillic lookalike characters (security bypass prevention)
|
|
8
|
+
* - Tokenize command chains to detect multi-command bypasses
|
|
9
|
+
* - Classify commands as: 'safe', 'dangerous', or 'normal'
|
|
10
|
+
* - Pattern matching against safe/dangerous regex patterns
|
|
11
|
+
* - Fail-closed behavior (invalid regex = dangerous)
|
|
12
|
+
*
|
|
13
|
+
* **Configuration:**
|
|
14
|
+
* - Bash safe patterns from gfi_gate.bash_safe_patterns
|
|
15
|
+
* - Bash dangerous patterns from gfi_gate.bash_dangerous_patterns
|
|
16
|
+
*/
|
|
17
|
+
export interface BashRiskConfig {
|
|
18
|
+
bash_safe_patterns?: string[];
|
|
19
|
+
bash_dangerous_patterns?: string[];
|
|
20
|
+
}
|
|
21
|
+
export type BashRiskLevel = 'safe' | 'dangerous' | 'normal';
|
|
22
|
+
/**
|
|
23
|
+
* Analyzes a bash command to determine its risk level.
|
|
24
|
+
*
|
|
25
|
+
* Implements security features:
|
|
26
|
+
* - Unicode/Cyrillic de-obfuscation to detect homograph attacks
|
|
27
|
+
* - Command chain tokenization to catch multi-command bypasses
|
|
28
|
+
* - Pattern matching against safe/dangerous regex patterns
|
|
29
|
+
* - Fail-closed behavior (invalid dangerous regex = dangerous)
|
|
30
|
+
*
|
|
31
|
+
* @param command - The bash command to analyze
|
|
32
|
+
* @param safePatterns - Regex patterns that indicate safe commands
|
|
33
|
+
* @param dangerousPatterns - Regex patterns that indicate dangerous commands
|
|
34
|
+
* @param logger - Optional logger for warnings about invalid patterns
|
|
35
|
+
* @returns The risk level: 'safe', 'dangerous', or 'normal'
|
|
36
|
+
*/
|
|
37
|
+
export declare function analyzeBashCommand(command: string, safePatterns: string[], dangerousPatterns: string[], logger?: {
|
|
38
|
+
warn?: (message: string) => void;
|
|
39
|
+
}): BashRiskLevel;
|
|
40
|
+
export interface DynamicThresholdConfig {
|
|
41
|
+
large_change_lines: number;
|
|
42
|
+
trust_stage_multipliers: Record<string, number>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Calculates the dynamic GFI threshold based on trust stage and line changes.
|
|
46
|
+
*
|
|
47
|
+
* The threshold is adjusted by:
|
|
48
|
+
* 1. Trust stage multiplier (higher stages get higher thresholds)
|
|
49
|
+
* 2. Large change reduction (big edits lower the threshold to catch more issues)
|
|
50
|
+
*
|
|
51
|
+
* @param baseThreshold - The base GFI threshold (typically 50 for GFI)
|
|
52
|
+
* @param trustStage - Current trust stage (1-4)
|
|
53
|
+
* @param lineChanges - Number of lines being changed
|
|
54
|
+
* @param config - Configuration with large_change_lines and trust_stage_multipliers
|
|
55
|
+
* @returns The adjusted threshold (minimum 0)
|
|
56
|
+
*/
|
|
57
|
+
export declare function calculateDynamicThreshold(baseThreshold: number, trustStage: number, lineChanges: number, config: DynamicThresholdConfig): number;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Risk Analysis Module
|
|
3
|
+
*
|
|
4
|
+
* Analyzes bash command security risks and determines command categorization.
|
|
5
|
+
*
|
|
6
|
+
* **Responsibilities:**
|
|
7
|
+
* - De-obfuscate Unicode/Cyrillic lookalike characters (security bypass prevention)
|
|
8
|
+
* - Tokenize command chains to detect multi-command bypasses
|
|
9
|
+
* - Classify commands as: 'safe', 'dangerous', or 'normal'
|
|
10
|
+
* - Pattern matching against safe/dangerous regex patterns
|
|
11
|
+
* - Fail-closed behavior (invalid regex = dangerous)
|
|
12
|
+
*
|
|
13
|
+
* **Configuration:**
|
|
14
|
+
* - Bash safe patterns from gfi_gate.bash_safe_patterns
|
|
15
|
+
* - Bash dangerous patterns from gfi_gate.bash_dangerous_patterns
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Analyzes a bash command to determine its risk level.
|
|
19
|
+
*
|
|
20
|
+
* Implements security features:
|
|
21
|
+
* - Unicode/Cyrillic de-obfuscation to detect homograph attacks
|
|
22
|
+
* - Command chain tokenization to catch multi-command bypasses
|
|
23
|
+
* - Pattern matching against safe/dangerous regex patterns
|
|
24
|
+
* - Fail-closed behavior (invalid dangerous regex = dangerous)
|
|
25
|
+
*
|
|
26
|
+
* @param command - The bash command to analyze
|
|
27
|
+
* @param safePatterns - Regex patterns that indicate safe commands
|
|
28
|
+
* @param dangerousPatterns - Regex patterns that indicate dangerous commands
|
|
29
|
+
* @param logger - Optional logger for warnings about invalid patterns
|
|
30
|
+
* @returns The risk level: 'safe', 'dangerous', or 'normal'
|
|
31
|
+
*/
|
|
32
|
+
export function analyzeBashCommand(command, safePatterns, dangerousPatterns, logger) {
|
|
33
|
+
let normalizedCmd = command.trim().toLowerCase();
|
|
34
|
+
// Unicode de-obfuscation — convert Cyrillic/Unicode lookalikes to ASCII equivalents
|
|
35
|
+
// Common Cyrillic lookalikes that could bypass detection: аеорсух (Cyrillic) → aeopcyx (Latin)
|
|
36
|
+
const CYRILLIC_TO_LATIN = {
|
|
37
|
+
'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'у': 'y', 'х': 'x',
|
|
38
|
+
'А': 'a', 'Е': 'e', 'О': 'o', 'Р': 'p', 'С': 'c', 'У': 'y', 'Х': 'x',
|
|
39
|
+
// Additional confusable chars
|
|
40
|
+
'і': 'i', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'һ': 'h', 'ⅰ': 'i',
|
|
41
|
+
'ƚ': 'l', 'м': 'm', 'п': 'n', 'ѵ': 'v', 'ѡ': 'w', 'ᴦ': 'r', 'ꜱ': 's',
|
|
42
|
+
};
|
|
43
|
+
normalizedCmd = normalizedCmd.replace(/[а-яА-Яіјѕԁɡһⅰƚмпеꜱѵѡᴦꜱ]/g, m => CYRILLIC_TO_LATIN[m] ?? m);
|
|
44
|
+
// Zero-width character detection — detect hidden characters that could bypass pattern matching
|
|
45
|
+
// Common zero-width characters used in command injection:
|
|
46
|
+
// - Zero-width space (U+200B)
|
|
47
|
+
// - Zero-width non-joiner (U+200C)
|
|
48
|
+
// - Zero-width joiner (U+200D)
|
|
49
|
+
// - Word joiner (U+2060)
|
|
50
|
+
// - Zero-width invisible separator (U+FEFF)
|
|
51
|
+
const ZERO_WIDTH_CHARS = /[\u200B\u200C\u200D\u2060\uFEFF]/g;
|
|
52
|
+
if (ZERO_WIDTH_CHARS.test(command)) {
|
|
53
|
+
logger?.warn?.(`[PD_GATE] Bash command contains zero-width characters — blocking as dangerous`);
|
|
54
|
+
return 'dangerous'; // Fail-closed: zero-width chars are suspicious
|
|
55
|
+
}
|
|
56
|
+
// Tokenize command chain before pattern matching to catch `cmd1 && cmd2` bypasses
|
|
57
|
+
// Only split on statement separators (; && ||), NOT on pipe (|) which is part of the command
|
|
58
|
+
const tokens = normalizedCmd
|
|
59
|
+
.split(/\s*(?:;|&&|\|\|)\s*/)
|
|
60
|
+
.map(t => t.trim())
|
|
61
|
+
.filter(t => t.length > 0);
|
|
62
|
+
// If no tokens (e.g., pure pipe-only), use the original
|
|
63
|
+
const segments = tokens.length > 0 ? tokens : [normalizedCmd];
|
|
64
|
+
// Also strip outer $() and backticks from each segment, but PRESERVE inner content
|
|
65
|
+
const cleanSegments = segments.map(seg => {
|
|
66
|
+
let s = seg;
|
|
67
|
+
// Extract inner content from $() or ${} or backtick-wrapped commands
|
|
68
|
+
// IMPORTANT: Preserve the inner command for analysis, don't drop it entirely
|
|
69
|
+
s = s.replace(/^\$\(([^)]+)\)$/, '$1').replace(/^\$\{([^}]+)\}$/, '$1').replace(/^`([^`]+)`$/, '$1');
|
|
70
|
+
return s.trim();
|
|
71
|
+
}).filter(s => s.length > 0);
|
|
72
|
+
// SECURITY: If original input was non-empty but we have no analyzable content, fail closed
|
|
73
|
+
if (cleanSegments.length === 0 && normalizedCmd.trim().length > 0) {
|
|
74
|
+
logger?.warn?.(`[PD_GATE] Bash command analysis produced empty segments from non-empty input, failing closed: ${normalizedCmd.substring(0, 100)}`);
|
|
75
|
+
return 'dangerous';
|
|
76
|
+
}
|
|
77
|
+
// 1. Check dangerous patterns against each segment
|
|
78
|
+
for (const seg of cleanSegments) {
|
|
79
|
+
for (const pattern of dangerousPatterns) {
|
|
80
|
+
try {
|
|
81
|
+
if (new RegExp(pattern, 'i').test(seg)) {
|
|
82
|
+
return 'dangerous';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
logger?.warn?.(`[PD_GATE] Invalid dangerous bash regex "${pattern}": ${String(error)}. Failing closed.`);
|
|
87
|
+
return 'dangerous';
|
|
88
|
+
// Fail-closed: 无效的危险模式正则视为匹配危险命令
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 2. Check safe patterns (only if ALL segments are safe)
|
|
93
|
+
for (const seg of cleanSegments) {
|
|
94
|
+
let isSafe = false;
|
|
95
|
+
for (const pattern of safePatterns) {
|
|
96
|
+
try {
|
|
97
|
+
if (new RegExp(pattern, 'i').test(seg)) {
|
|
98
|
+
isSafe = true;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
logger?.warn?.(`[PD_GATE] Invalid safe bash regex "${pattern}": ${String(error)}. Ignoring safe override.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!isSafe) {
|
|
107
|
+
// Not all segments are safe → treat as normal
|
|
108
|
+
return 'normal';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// All segments are safe
|
|
112
|
+
return 'safe';
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Calculates the dynamic GFI threshold based on trust stage and line changes.
|
|
116
|
+
*
|
|
117
|
+
* The threshold is adjusted by:
|
|
118
|
+
* 1. Trust stage multiplier (higher stages get higher thresholds)
|
|
119
|
+
* 2. Large change reduction (big edits lower the threshold to catch more issues)
|
|
120
|
+
*
|
|
121
|
+
* @param baseThreshold - The base GFI threshold (typically 50 for GFI)
|
|
122
|
+
* @param trustStage - Current trust stage (1-4)
|
|
123
|
+
* @param lineChanges - Number of lines being changed
|
|
124
|
+
* @param config - Configuration with large_change_lines and trust_stage_multipliers
|
|
125
|
+
* @returns The adjusted threshold (minimum 0)
|
|
126
|
+
*/
|
|
127
|
+
export function calculateDynamicThreshold(baseThreshold, trustStage, lineChanges, config) {
|
|
128
|
+
// 1. Trust Stage multiplier
|
|
129
|
+
const stageMultiplier = config.trust_stage_multipliers[trustStage.toString()] || 1.0;
|
|
130
|
+
let threshold = baseThreshold * stageMultiplier;
|
|
131
|
+
// 2. Large scale modification reduces threshold
|
|
132
|
+
if (lineChanges > config.large_change_lines) {
|
|
133
|
+
const ratio = Math.min(lineChanges / 200, 0.5); // Reduce by up to 50%
|
|
134
|
+
threshold = threshold * (1 - ratio);
|
|
135
|
+
}
|
|
136
|
+
return Math.round(Math.max(threshold, 0));
|
|
137
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit Verification Module
|
|
3
|
+
*
|
|
4
|
+
* Enforces P-03 (precise verification principle) for edit tool operations.
|
|
5
|
+
*
|
|
6
|
+
* **Responsibilities:**
|
|
7
|
+
* - Verify oldText matches current file content before edit
|
|
8
|
+
* - Fuzzy matching for whitespace-agnostic comparison
|
|
9
|
+
* - File size limits and binary file detection
|
|
10
|
+
* - Automatic correction of whitespace mismatches
|
|
11
|
+
* - Detailed error messages with guidance for fix
|
|
12
|
+
*
|
|
13
|
+
* **Configuration:**
|
|
14
|
+
* - Edit verification settings from profile.edit_verification
|
|
15
|
+
* - Max file size threshold (default 10MB)
|
|
16
|
+
* - Fuzzy match threshold (default 0.8)
|
|
17
|
+
* - Skip action for large files (warn/block)
|
|
18
|
+
*/
|
|
19
|
+
import type { PluginHookBeforeToolCallEvent, PluginHookBeforeToolCallResult } from '../openclaw-sdk.js';
|
|
20
|
+
import type { WorkspaceContext } from '../core/workspace-context.js';
|
|
21
|
+
export interface EditVerificationConfig {
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
max_file_size_bytes?: number;
|
|
24
|
+
fuzzy_match_enabled?: boolean;
|
|
25
|
+
fuzzy_match_threshold?: number;
|
|
26
|
+
skip_large_file_action?: 'warn' | 'block';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a line for fuzzy matching by collapsing whitespace
|
|
30
|
+
*/
|
|
31
|
+
export declare function normalizeLine(line: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Find fuzzy match between oldText and current file content
|
|
34
|
+
* @param lines - File content split into lines
|
|
35
|
+
* @param oldLines - oldText split into lines
|
|
36
|
+
* @param threshold - Match threshold (0-1)
|
|
37
|
+
* @returns Match index or -1 if not found
|
|
38
|
+
*/
|
|
39
|
+
export declare function findFuzzyMatch(lines: string[], oldLines: string[], threshold?: number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Try to find a fuzzy match for oldText in current content
|
|
42
|
+
* @param currentContent - Current file content
|
|
43
|
+
* @param oldText - Text to match
|
|
44
|
+
* @param threshold - Match threshold (0-1)
|
|
45
|
+
* @returns Object with found status and corrected text if found
|
|
46
|
+
*/
|
|
47
|
+
export declare function tryFuzzyMatch(currentContent: string, oldText: string, threshold?: number): {
|
|
48
|
+
found: boolean;
|
|
49
|
+
correctedText?: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Generate a helpful error message for edit verification failure
|
|
53
|
+
*/
|
|
54
|
+
export declare function generateEditError(filePath: string, oldText: string, currentContent: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Handle edit tool verification before allowing operation
|
|
57
|
+
* This enforces P-03 at the tool layer
|
|
58
|
+
*/
|
|
59
|
+
export declare function handleEditVerification(event: PluginHookBeforeToolCallEvent, wctx: WorkspaceContext, ctx: {
|
|
60
|
+
logger?: any;
|
|
61
|
+
sessionId?: string;
|
|
62
|
+
}, config?: EditVerificationConfig): PluginHookBeforeToolCallResult | void;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit Verification Module
|
|
3
|
+
*
|
|
4
|
+
* Enforces P-03 (precise verification principle) for edit tool operations.
|
|
5
|
+
*
|
|
6
|
+
* **Responsibilities:**
|
|
7
|
+
* - Verify oldText matches current file content before edit
|
|
8
|
+
* - Fuzzy matching for whitespace-agnostic comparison
|
|
9
|
+
* - File size limits and binary file detection
|
|
10
|
+
* - Automatic correction of whitespace mismatches
|
|
11
|
+
* - Detailed error messages with guidance for fix
|
|
12
|
+
*
|
|
13
|
+
* **Configuration:**
|
|
14
|
+
* - Edit verification settings from profile.edit_verification
|
|
15
|
+
* - Max file size threshold (default 10MB)
|
|
16
|
+
* - Fuzzy match threshold (default 0.8)
|
|
17
|
+
* - Skip action for large files (warn/block)
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
/**
|
|
22
|
+
* Normalize a line for fuzzy matching by collapsing whitespace
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeLine(line) {
|
|
25
|
+
return line.replace(/\s+/g, ' ').trim();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Find fuzzy match between oldText and current file content
|
|
29
|
+
* @param lines - File content split into lines
|
|
30
|
+
* @param oldLines - oldText split into lines
|
|
31
|
+
* @param threshold - Match threshold (0-1)
|
|
32
|
+
* @returns Match index or -1 if not found
|
|
33
|
+
*/
|
|
34
|
+
export function findFuzzyMatch(lines, oldLines, threshold = 0.8) {
|
|
35
|
+
if (oldLines.length === 0)
|
|
36
|
+
return -1; // P2 fix: empty array boundary check
|
|
37
|
+
const normalizedLines = lines.map(normalizeLine);
|
|
38
|
+
const normalizedOldLines = oldLines.map(normalizeLine);
|
|
39
|
+
// Try to find matching sequence
|
|
40
|
+
for (let i = 0; i <= lines.length - oldLines.length; i++) {
|
|
41
|
+
let matchCount = 0;
|
|
42
|
+
for (let j = 0; j < oldLines.length; j++) {
|
|
43
|
+
if (normalizedLines[i + j] === normalizedOldLines[j]) {
|
|
44
|
+
matchCount++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Use threshold from config
|
|
48
|
+
if (matchCount >= oldLines.length * threshold) {
|
|
49
|
+
return i;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return -1;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Try to find a fuzzy match for oldText in current content
|
|
56
|
+
* @param currentContent - Current file content
|
|
57
|
+
* @param oldText - Text to match
|
|
58
|
+
* @param threshold - Match threshold (0-1)
|
|
59
|
+
* @returns Object with found status and corrected text if found
|
|
60
|
+
*/
|
|
61
|
+
export function tryFuzzyMatch(currentContent, oldText, threshold = 0.8) {
|
|
62
|
+
const lines = currentContent.split('\n');
|
|
63
|
+
const oldLines = oldText.split('\n');
|
|
64
|
+
const matchIndex = findFuzzyMatch(lines, oldLines, threshold);
|
|
65
|
+
if (matchIndex !== -1) {
|
|
66
|
+
// Found fuzzy match, extract actual text from file
|
|
67
|
+
const correctedText = lines.slice(matchIndex, matchIndex + oldLines.length).join('\n');
|
|
68
|
+
return { found: true, correctedText };
|
|
69
|
+
}
|
|
70
|
+
return { found: false };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Generate a helpful error message for edit verification failure
|
|
74
|
+
*/
|
|
75
|
+
export function generateEditError(filePath, oldText, currentContent) {
|
|
76
|
+
const expectedSnippet = oldText.split('\n').slice(0, 3).join('\n').substring(0, 200);
|
|
77
|
+
const actualSnippet = currentContent.substring(0, 200);
|
|
78
|
+
return `[P-03 Violation] Edit verification failed
|
|
79
|
+
|
|
80
|
+
File: ${filePath}
|
|
81
|
+
|
|
82
|
+
The text you're trying to replace does not match the current file content.
|
|
83
|
+
|
|
84
|
+
Expected to find:
|
|
85
|
+
${expectedSnippet}${oldText.length > 200 ? '...' : ''}
|
|
86
|
+
|
|
87
|
+
Actual file contains:
|
|
88
|
+
${actualSnippet}${currentContent.length > 200 ? '...' : ''}
|
|
89
|
+
|
|
90
|
+
Possible reasons:
|
|
91
|
+
- File has been modified by another process
|
|
92
|
+
- Whitespace characters do not match (spaces, tabs, newlines)
|
|
93
|
+
- Context compression caused outdated information
|
|
94
|
+
|
|
95
|
+
Solution:
|
|
96
|
+
1. Use 'read' tool to get current file content
|
|
97
|
+
2. Update your edit command with exact text from file
|
|
98
|
+
3. Retry edit operation
|
|
99
|
+
|
|
100
|
+
This is enforced by P-03 (精确匹配前验证原则).`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Handle edit tool verification before allowing operation
|
|
104
|
+
* This enforces P-03 at the tool layer
|
|
105
|
+
*/
|
|
106
|
+
export function handleEditVerification(event, wctx, ctx, config = {}) {
|
|
107
|
+
// Skip verification if disabled - return early without any processing or logging
|
|
108
|
+
if (config.enabled === false) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const logger = ctx.logger || console;
|
|
112
|
+
const maxSizeBytes = config.max_file_size_bytes ?? 10 * 1024 * 1024; // Default 10MB
|
|
113
|
+
const fuzzyMatchEnabled = config.fuzzy_match_enabled !== false;
|
|
114
|
+
const fuzzyMatchThreshold = config.fuzzy_match_threshold ?? 0.8;
|
|
115
|
+
const skipAction = config.skip_large_file_action ?? 'warn';
|
|
116
|
+
// 1. Extract parameters (handle both parameter naming conventions)
|
|
117
|
+
const filePath = event.params.file_path || event.params.path || event.params.file;
|
|
118
|
+
const oldText = event.params.oldText || event.params.old_string;
|
|
119
|
+
if (!filePath || !oldText) {
|
|
120
|
+
// Missing required parameters, let it fail naturally
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// 2. Resolve and read file
|
|
124
|
+
let absolutePath;
|
|
125
|
+
try {
|
|
126
|
+
absolutePath = wctx.resolve(filePath);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// Path resolution error, let it fail naturally
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// 2.5. Skip verification for binary files
|
|
133
|
+
const BINARY_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg',
|
|
134
|
+
'.pdf', '.zip', '.tar', '.gz', '.7z', '.rar',
|
|
135
|
+
'.exe', '.dll', '.so', '.dylib', '.bin',
|
|
136
|
+
'.mp3', '.mp4', '.avi', '.mov', '.wav',
|
|
137
|
+
'.ttf', '.otf', '.woff', '.woff2',
|
|
138
|
+
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'];
|
|
139
|
+
const ext = path.extname(absolutePath).toLowerCase();
|
|
140
|
+
if (BINARY_EXTENSIONS.includes(ext)) {
|
|
141
|
+
logger?.info?.(`[PD_GATE:EDIT_VERIFY] Skipping verification for binary file: ${path.basename(filePath)}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
// 2.6. Check file size before reading (P-03 improvement)
|
|
146
|
+
try {
|
|
147
|
+
const stats = fs.statSync(absolutePath);
|
|
148
|
+
const fileSizeBytes = stats.size;
|
|
149
|
+
const fileSizeMB = fileSizeBytes / (1024 * 1024);
|
|
150
|
+
if (fileSizeBytes > maxSizeBytes) {
|
|
151
|
+
const message = `[PD_GATE:EDIT_VERIFY] File size check: ${path.basename(filePath)} is ${fileSizeMB.toFixed(2)}MB (threshold: ${(maxSizeBytes / (1024 * 1024)).toFixed(2)}MB)`;
|
|
152
|
+
if (skipAction === 'block') {
|
|
153
|
+
logger?.warn?.(message + ' - BLOCKED');
|
|
154
|
+
return {
|
|
155
|
+
block: true,
|
|
156
|
+
blockReason: `${message}\n\nFile is too large for edit verification. Increase max_file_size_bytes in PROFILE.json or reduce file size.`
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
logger?.warn?.(message + ' - SKIPPING verification');
|
|
161
|
+
return; // Skip verification but allow operation
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
logger?.info?.(`[PD_GATE:EDIT_VERIFY] File size check passed: ${path.basename(filePath)} (${fileSizeMB.toFixed(2)}MB)`);
|
|
165
|
+
}
|
|
166
|
+
catch (statError) {
|
|
167
|
+
// File stat error (e.g., permission denied)
|
|
168
|
+
const errStr = statError instanceof Error ? statError.message : String(statError);
|
|
169
|
+
const errCode = statError.code;
|
|
170
|
+
if (errCode === 'EACCES' || errCode === 'EPERM') {
|
|
171
|
+
logger?.error?.(`[PD_GATE:EDIT_VERIFY] Permission denied accessing file: ${path.basename(filePath)} (${errStr})`);
|
|
172
|
+
return {
|
|
173
|
+
block: true,
|
|
174
|
+
blockReason: `[P-03 Error] Permission denied: Cannot access file ${absolutePath}\n\nError: ${errStr}\n\nSolution: Check file permissions or run with appropriate access rights.`
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
else if (errCode === 'ENOENT') {
|
|
178
|
+
logger?.warn?.(`[PD_GATE:EDIT_VERIFY] File not found: ${path.basename(filePath)} (${errStr})`);
|
|
179
|
+
// File doesn't exist - let edit operation proceed (it will create file)
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
logger?.warn?.(`[PD_GATE:EDIT_VERIFY] Stat error: ${errStr}`);
|
|
184
|
+
// Let it fail naturally on read attempt
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// 3. Read current file content with improved error handling
|
|
188
|
+
let currentContent;
|
|
189
|
+
try {
|
|
190
|
+
currentContent = fs.readFileSync(absolutePath, 'utf-8');
|
|
191
|
+
}
|
|
192
|
+
catch (readError) {
|
|
193
|
+
const errStr = readError instanceof Error ? readError.message : String(readError);
|
|
194
|
+
const errCode = readError.code;
|
|
195
|
+
if (errCode === 'EACCES' || errCode === 'EPERM') {
|
|
196
|
+
logger?.error?.(`[PD_GATE:EDIT_VERIFY] Permission denied reading file: ${path.basename(filePath)} (${errStr})`);
|
|
197
|
+
return {
|
|
198
|
+
block: true,
|
|
199
|
+
blockReason: `[P-03 Error] Permission denied: Cannot read file ${absolutePath}\n\nError: ${errStr}\n\nSolution: Check file permissions or run with appropriate access rights.`
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
else if (errCode === 'ENOENT') {
|
|
203
|
+
logger?.warn?.(`[PD_GATE:EDIT_VERIFY] File not found: ${path.basename(filePath)} (${errStr})`);
|
|
204
|
+
// File doesn't exist - let edit operation proceed
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
else if (errStr.includes('UTF-8') || errStr.includes('encoding')) {
|
|
208
|
+
logger?.error?.(`[PD_GATE:EDIT_VERIFY] Encoding error reading file: ${path.basename(filePath)} (${errStr})`);
|
|
209
|
+
return {
|
|
210
|
+
block: true,
|
|
211
|
+
blockReason: `[P-03 Error] Encoding error: Cannot read file ${absolutePath}\n\nError: ${errStr}\n\nThe file appears to use an encoding other than UTF-8. Edit verification requires UTF-8 readable text files.\n\nSolution: Ensure file is UTF-8 encoded text, or mark binary extensions to skip verification.`
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
logger?.warn?.(`[PD_GATE:EDIT_VERIFY] Read error: ${errStr}`);
|
|
216
|
+
// Let it fail naturally
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// 4. Verify oldText exists in current content
|
|
221
|
+
if (!currentContent.includes(oldText)) {
|
|
222
|
+
logger?.info?.(`[PD_GATE:EDIT_VERIFY] Exact match failed for ${path.basename(filePath)}, trying fuzzy match`);
|
|
223
|
+
// 5. Try fuzzy matching (if enabled)
|
|
224
|
+
if (fuzzyMatchEnabled) {
|
|
225
|
+
const fuzzyResult = tryFuzzyMatch(currentContent, oldText, fuzzyMatchThreshold);
|
|
226
|
+
if (fuzzyResult.found && fuzzyResult.correctedText) {
|
|
227
|
+
logger?.info?.(`[PD_GATE:EDIT_VERIFY] Fuzzy match found for ${path.basename(filePath)}, auto-correcting oldText`);
|
|
228
|
+
// Return corrected parameters
|
|
229
|
+
return {
|
|
230
|
+
params: {
|
|
231
|
+
...event.params,
|
|
232
|
+
oldText: fuzzyResult.correctedText,
|
|
233
|
+
old_string: fuzzyResult.correctedText
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// 6. No match found, block operation with helpful error
|
|
239
|
+
const errorMsg = generateEditError(absolutePath, oldText, currentContent);
|
|
240
|
+
logger?.error?.(`[PD_GATE:EDIT_VERIFY] Block edit on ${path.basename(filePath)}: oldText not found`);
|
|
241
|
+
return {
|
|
242
|
+
block: true,
|
|
243
|
+
blockReason: errorMsg
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// 7. Verification passed, allow edit to proceed
|
|
247
|
+
logger?.info?.(`[PD_GATE:EDIT_VERIFY] Verified edit on ${path.basename(filePath)}`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
// Unexpected error - let it fail naturally
|
|
252
|
+
const errorStr = error instanceof Error ? error.message : String(error);
|
|
253
|
+
logger?.warn?.(`[PD_GATE:EDIT_VERIFY] Unexpected error: ${errorStr}`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|