securityclaw 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/README.zh-CN.md +135 -0
  5. package/admin/public/app.js +148 -0
  6. package/admin/public/favicon.svg +21 -0
  7. package/admin/public/index.html +31 -0
  8. package/admin/public/styles.css +2715 -0
  9. package/admin/server.ts +1053 -0
  10. package/bin/install-lib.mjs +88 -0
  11. package/bin/securityclaw.mjs +66 -0
  12. package/config/policy.default.yaml +520 -0
  13. package/index.ts +2662 -0
  14. package/install.sh +22 -0
  15. package/openclaw.plugin.json +60 -0
  16. package/package.json +69 -0
  17. package/src/admin/build.ts +113 -0
  18. package/src/admin/console_notice.ts +195 -0
  19. package/src/admin/dashboard_url_state.ts +80 -0
  20. package/src/admin/openclaw_session_catalog.ts +137 -0
  21. package/src/admin/runtime_guard.ts +51 -0
  22. package/src/admin/skill_interception_store.ts +1606 -0
  23. package/src/application/commands/approval_commands.ts +189 -0
  24. package/src/approvals/chat_approval_store.ts +433 -0
  25. package/src/config/live_config.ts +144 -0
  26. package/src/config/loader.ts +168 -0
  27. package/src/config/runtime_override.ts +66 -0
  28. package/src/config/strategy_store.ts +121 -0
  29. package/src/config/validator.ts +222 -0
  30. package/src/domain/models/resource_context.ts +31 -0
  31. package/src/domain/ports/approval_repository.ts +40 -0
  32. package/src/domain/ports/notification_port.ts +29 -0
  33. package/src/domain/ports/openclaw_adapter.ts +22 -0
  34. package/src/domain/services/account_policy_engine.ts +163 -0
  35. package/src/domain/services/approval_service.ts +336 -0
  36. package/src/domain/services/approval_subject_resolver.ts +37 -0
  37. package/src/domain/services/context_inference_service.ts +502 -0
  38. package/src/domain/services/file_rule_registry.ts +171 -0
  39. package/src/domain/services/formatting_service.ts +101 -0
  40. package/src/domain/services/path_candidate_inference.ts +111 -0
  41. package/src/domain/services/sensitive_path_registry.ts +288 -0
  42. package/src/domain/services/sensitivity_label_inference.ts +161 -0
  43. package/src/domain/services/shell_filesystem_inference.ts +360 -0
  44. package/src/engine/approval_fsm.ts +104 -0
  45. package/src/engine/decision_engine.ts +39 -0
  46. package/src/engine/dlp_engine.ts +91 -0
  47. package/src/engine/rule_engine.ts +208 -0
  48. package/src/events/emitter.ts +86 -0
  49. package/src/events/schema.ts +27 -0
  50. package/src/hooks/context_guard.ts +36 -0
  51. package/src/hooks/output_guard.ts +66 -0
  52. package/src/hooks/persist_guard.ts +69 -0
  53. package/src/hooks/policy_guard.ts +222 -0
  54. package/src/hooks/result_guard.ts +88 -0
  55. package/src/i18n/locale.ts +36 -0
  56. package/src/index.ts +255 -0
  57. package/src/infrastructure/adapters/notification_adapter.ts +173 -0
  58. package/src/infrastructure/adapters/openclaw_adapter_impl.ts +59 -0
  59. package/src/infrastructure/config/plugin_config_parser.ts +105 -0
  60. package/src/monitoring/status_store.ts +612 -0
  61. package/src/types.ts +409 -0
  62. package/src/utils.ts +97 -0
@@ -0,0 +1,1606 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+
7
+ import type { Decision } from "../types.ts";
8
+
9
+ export type SkillRiskTier = "low" | "medium" | "high" | "critical";
10
+ export type SkillOperationSeverity = "S0" | "S1" | "S2" | "S3";
11
+ export type SkillScanStatus = "ready" | "stale" | "unknown";
12
+ export type SkillSource =
13
+ | "openclaw_workspace"
14
+ | "openclaw_home"
15
+ | "codex_home"
16
+ | "custom";
17
+ export type SkillLifecycleState = "normal" | "quarantined" | "trusted";
18
+
19
+ type SkillRootDescriptor = {
20
+ path: string;
21
+ source: SkillSource;
22
+ };
23
+
24
+ type SkillMetadata = {
25
+ name: string;
26
+ version: string;
27
+ author: string;
28
+ headline: string;
29
+ sourceDetail: string;
30
+ hasChangelog: boolean;
31
+ };
32
+
33
+ type DiscoveredSkill = {
34
+ installPath: string;
35
+ skillMdPath: string;
36
+ source: SkillSource;
37
+ content: string;
38
+ metadata: SkillMetadata;
39
+ };
40
+
41
+ type RawFinding = {
42
+ code: string;
43
+ detail: string;
44
+ severity: SkillOperationSeverity;
45
+ score: number;
46
+ excerpt?: string;
47
+ };
48
+
49
+ export type SkillFinding = {
50
+ code: string;
51
+ detail: string;
52
+ severity: SkillOperationSeverity;
53
+ decision: Decision;
54
+ excerpt?: string;
55
+ };
56
+
57
+ export type SkillPolicyConfig = {
58
+ thresholds: {
59
+ medium: number;
60
+ high: number;
61
+ critical: number;
62
+ };
63
+ matrix: Record<
64
+ SkillRiskTier | "unknown",
65
+ Record<SkillOperationSeverity, Decision>
66
+ >;
67
+ defaults: {
68
+ unscanned: {
69
+ S2: Decision;
70
+ S3: Decision;
71
+ };
72
+ drifted_action: Decision;
73
+ trust_override_hours: number;
74
+ };
75
+ updated_at?: string;
76
+ };
77
+
78
+ export type SkillSummary = {
79
+ skill_id: string;
80
+ name: string;
81
+ version: string;
82
+ author: string;
83
+ headline: string;
84
+ source: SkillSource;
85
+ source_detail: string;
86
+ install_path: string;
87
+ current_hash: string;
88
+ risk_score: number;
89
+ risk_tier: SkillRiskTier;
90
+ confidence: number;
91
+ reason_codes: string[];
92
+ findings: SkillFinding[];
93
+ finding_count: number;
94
+ scan_status: SkillScanStatus;
95
+ last_seen_at: string;
96
+ first_seen_at: string;
97
+ last_scan_at?: string;
98
+ last_intercept_at?: string;
99
+ intercept_count_24h: number;
100
+ is_drifted: boolean;
101
+ is_newly_installed: boolean;
102
+ quarantined: boolean;
103
+ trust_override: boolean;
104
+ trust_override_expires_at?: string;
105
+ state: SkillLifecycleState;
106
+ };
107
+
108
+ export type SkillActivity = {
109
+ ts: string;
110
+ kind: string;
111
+ title: string;
112
+ detail: string;
113
+ severity?: SkillOperationSeverity;
114
+ decision?: Decision;
115
+ reason_codes?: string[];
116
+ };
117
+
118
+ type SkillInventoryRow = {
119
+ skill_id: string;
120
+ name: string;
121
+ version: string | null;
122
+ author: string | null;
123
+ source: string;
124
+ install_path: string;
125
+ current_hash: string;
126
+ first_seen_at: string;
127
+ last_seen_at: string;
128
+ is_present: number;
129
+ metadata_json: string;
130
+ };
131
+
132
+ type SkillLatestScanRow = {
133
+ skill_id: string;
134
+ scan_ts: string;
135
+ risk_score: number;
136
+ risk_tier: string;
137
+ confidence: number;
138
+ reason_codes_json: string;
139
+ findings_json: string;
140
+ };
141
+
142
+ type SkillOverrideRow = {
143
+ skill_id: string;
144
+ quarantined: number;
145
+ trust_override: number;
146
+ expires_at: string | null;
147
+ updated_by: string | null;
148
+ updated_at: string;
149
+ };
150
+
151
+ type SkillRuntimeEventRow = {
152
+ ts: string;
153
+ skill_id: string;
154
+ event_kind: string;
155
+ tool: string | null;
156
+ severity: string | null;
157
+ decision: string | null;
158
+ reason_codes_json: string;
159
+ trace_id: string | null;
160
+ detail: string | null;
161
+ };
162
+
163
+ type SkillInterceptAggregateRow = {
164
+ skill_id: string;
165
+ challenge_block_count: number;
166
+ last_intercept_at: string | null;
167
+ };
168
+
169
+ type SkillListFilters = {
170
+ risk?: string | null;
171
+ state?: string | null;
172
+ source?: string | null;
173
+ drift?: string | null;
174
+ intercepted?: string | null;
175
+ };
176
+
177
+ type SkillSnapshot = {
178
+ items: SkillSummary[];
179
+ policy: SkillPolicyConfig;
180
+ roots: SkillRootDescriptor[];
181
+ };
182
+
183
+ type SkillStatusPayload = {
184
+ stats: {
185
+ total: number;
186
+ high_critical: number;
187
+ challenge_block_24h: number;
188
+ drift_alerts: number;
189
+ quarantined: number;
190
+ trusted_overrides: number;
191
+ };
192
+ highlights: SkillSummary[];
193
+ policy: SkillPolicyConfig;
194
+ roots: Array<{ path: string; source: SkillSource }>;
195
+ generated_at: string;
196
+ };
197
+
198
+ type SkillListPayload = {
199
+ items: SkillSummary[];
200
+ total: number;
201
+ counts: {
202
+ total: number;
203
+ high_critical: number;
204
+ quarantined: number;
205
+ trusted: number;
206
+ drifted: number;
207
+ recent_intercepts: number;
208
+ };
209
+ filters: {
210
+ risk: string;
211
+ state: string;
212
+ source: string;
213
+ drift: string;
214
+ intercepted: string;
215
+ };
216
+ source_options: string[];
217
+ policy: SkillPolicyConfig;
218
+ };
219
+
220
+ type SkillDetailPayload = {
221
+ skill: SkillSummary;
222
+ findings: SkillFinding[];
223
+ activity: SkillActivity[];
224
+ policy: SkillPolicyConfig;
225
+ roots: Array<{ path: string; source: SkillSource }>;
226
+ };
227
+
228
+ type RefreshOptions = {
229
+ force?: boolean;
230
+ targetSkillId?: string;
231
+ auditActor?: string;
232
+ };
233
+
234
+ const SKILL_SCHEMA_SQL = `
235
+ CREATE TABLE IF NOT EXISTS skill_inventory (
236
+ skill_id TEXT PRIMARY KEY,
237
+ name TEXT NOT NULL,
238
+ version TEXT,
239
+ author TEXT,
240
+ source TEXT NOT NULL,
241
+ install_path TEXT NOT NULL UNIQUE,
242
+ current_hash TEXT NOT NULL,
243
+ first_seen_at TEXT NOT NULL,
244
+ last_seen_at TEXT NOT NULL,
245
+ is_present INTEGER NOT NULL DEFAULT 1,
246
+ metadata_json TEXT NOT NULL DEFAULT '{}'
247
+ );
248
+
249
+ CREATE TABLE IF NOT EXISTS skill_scan_results (
250
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
251
+ skill_id TEXT NOT NULL,
252
+ scan_ts TEXT NOT NULL,
253
+ risk_score INTEGER NOT NULL,
254
+ risk_tier TEXT NOT NULL,
255
+ confidence REAL NOT NULL,
256
+ reason_codes_json TEXT NOT NULL,
257
+ findings_json TEXT NOT NULL
258
+ );
259
+
260
+ CREATE INDEX IF NOT EXISTS idx_skill_scan_results_skill_id_scan_ts
261
+ ON skill_scan_results(skill_id, scan_ts DESC);
262
+
263
+ CREATE TABLE IF NOT EXISTS skill_runtime_events (
264
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
265
+ ts TEXT NOT NULL,
266
+ skill_id TEXT NOT NULL,
267
+ event_kind TEXT NOT NULL,
268
+ tool TEXT,
269
+ severity TEXT,
270
+ decision TEXT,
271
+ reason_codes_json TEXT NOT NULL,
272
+ trace_id TEXT,
273
+ detail TEXT
274
+ );
275
+
276
+ CREATE INDEX IF NOT EXISTS idx_skill_runtime_events_skill_id_ts
277
+ ON skill_runtime_events(skill_id, ts DESC);
278
+
279
+ CREATE TABLE IF NOT EXISTS skill_overrides (
280
+ skill_id TEXT PRIMARY KEY,
281
+ quarantined INTEGER NOT NULL DEFAULT 0,
282
+ trust_override INTEGER NOT NULL DEFAULT 0,
283
+ expires_at TEXT,
284
+ updated_by TEXT,
285
+ updated_at TEXT NOT NULL
286
+ );
287
+
288
+ CREATE TABLE IF NOT EXISTS skill_policy_config (
289
+ id INTEGER PRIMARY KEY CHECK (id = 1),
290
+ payload_json TEXT NOT NULL,
291
+ updated_at TEXT NOT NULL
292
+ );
293
+ `;
294
+
295
+ const DECISION_SET = new Set<Decision>(["allow", "warn", "challenge", "block"]);
296
+ const RISK_TIERS: SkillRiskTier[] = ["low", "medium", "high", "critical"];
297
+ const OPERATION_SEVERITIES: SkillOperationSeverity[] = ["S0", "S1", "S2", "S3"];
298
+ const STALE_SCAN_MS = 30 * 60 * 1000;
299
+ const REFRESH_CACHE_MS = 30 * 1000;
300
+ const NEW_INSTALL_WINDOW_MS = 24 * 60 * 60 * 1000;
301
+ const ACTIVITY_WINDOW_MS = 24 * 60 * 60 * 1000;
302
+ const MAX_SKILL_SCAN_FILES = 80;
303
+ const MAX_SKILL_SCAN_DEPTH = 4;
304
+ const MAX_SKILL_FILE_BYTES = 256 * 1024;
305
+ const DEFAULT_SKILL_POLICY_CONFIG: SkillPolicyConfig = {
306
+ thresholds: {
307
+ medium: 28,
308
+ high: 58,
309
+ critical: 82,
310
+ },
311
+ matrix: {
312
+ low: { S0: "allow", S1: "allow", S2: "warn", S3: "challenge" },
313
+ medium: { S0: "allow", S1: "warn", S2: "challenge", S3: "block" },
314
+ high: { S0: "warn", S1: "challenge", S2: "block", S3: "block" },
315
+ critical: { S0: "challenge", S1: "challenge", S2: "block", S3: "block" },
316
+ unknown: { S0: "allow", S1: "warn", S2: "challenge", S3: "block" },
317
+ },
318
+ defaults: {
319
+ unscanned: { S2: "challenge", S3: "block" },
320
+ drifted_action: "challenge",
321
+ trust_override_hours: 6,
322
+ },
323
+ };
324
+
325
+ function cloneDefaultSkillPolicyConfig(): SkillPolicyConfig {
326
+ return JSON.parse(JSON.stringify(DEFAULT_SKILL_POLICY_CONFIG)) as SkillPolicyConfig;
327
+ }
328
+
329
+ function clamp(value: number, min: number, max: number): number {
330
+ return Math.min(max, Math.max(min, value));
331
+ }
332
+
333
+ function normalizeDecision(value: unknown, fallback: Decision): Decision {
334
+ const candidate = typeof value === "string" ? value.trim() : "";
335
+ return DECISION_SET.has(candidate as Decision) ? (candidate as Decision) : fallback;
336
+ }
337
+
338
+ function decisionRank(decision: Decision): number {
339
+ if (decision === "allow") return 0;
340
+ if (decision === "warn") return 1;
341
+ if (decision === "challenge") return 2;
342
+ return 3;
343
+ }
344
+
345
+ function stricterDecision(left: Decision, right: Decision): Decision {
346
+ return decisionRank(left) >= decisionRank(right) ? left : right;
347
+ }
348
+
349
+ function safeParseJson<T>(raw: string | null | undefined, fallback: T): T {
350
+ if (!raw) {
351
+ return fallback;
352
+ }
353
+ try {
354
+ return JSON.parse(raw) as T;
355
+ } catch {
356
+ return fallback;
357
+ }
358
+ }
359
+
360
+ function normalizeText(value: unknown): string {
361
+ return typeof value === "string" ? value.trim() : "";
362
+ }
363
+
364
+ function createShortHash(value: string): string {
365
+ return createHash("sha1").update(value).digest("hex").slice(0, 8);
366
+ }
367
+
368
+ function slugify(value: string): string {
369
+ const normalized = value
370
+ .toLowerCase()
371
+ .replace(/[^a-z0-9]+/g, "-")
372
+ .replace(/^-+|-+$/g, "");
373
+ return normalized || "skill";
374
+ }
375
+
376
+ function buildSkillId(name: string, installPath: string): string {
377
+ return `${slugify(name)}-${createShortHash(path.normalize(installPath))}`;
378
+ }
379
+
380
+ function matchFirst(content: string, patterns: RegExp[]): string {
381
+ for (const pattern of patterns) {
382
+ const match = content.match(pattern);
383
+ if (match?.[1]) {
384
+ return match[1].trim();
385
+ }
386
+ }
387
+ return "";
388
+ }
389
+
390
+ function detectChangelog(content: string): boolean {
391
+ return /(^|\n)#{1,3}\s*(?:changelog|release notes|更新记录|变更说明)(?:\s|$)/i.test(content);
392
+ }
393
+
394
+ export function extractSkillMetadata(content: string, installPath: string): SkillMetadata {
395
+ const fallbackName = path.basename(installPath);
396
+ const headline = matchFirst(content, [/^#\s+(.+)$/m]) || fallbackName;
397
+ const version = matchFirst(content, [
398
+ /(?:^|\n)\s*(?:version|版本)\s*[::]\s*([^\n]+)/i,
399
+ /(?:^|\n)-\s*(?:version|版本)\s*[::]\s*([^\n]+)/i,
400
+ ]);
401
+ const author = matchFirst(content, [
402
+ /(?:^|\n)\s*(?:author|作者)\s*[::]\s*([^\n]+)/i,
403
+ /(?:^|\n)-\s*(?:author|作者)\s*[::]\s*([^\n]+)/i,
404
+ ]);
405
+ const sourceDetail = matchFirst(content, [
406
+ /(?:^|\n)\s*(?:source|来源)\s*[::]\s*([^\n]+)/i,
407
+ /(?:^|\n)-\s*(?:source|来源)\s*[::]\s*([^\n]+)/i,
408
+ ]);
409
+
410
+ return {
411
+ name: headline || fallbackName,
412
+ version,
413
+ author,
414
+ headline,
415
+ sourceDetail,
416
+ hasChangelog: detectChangelog(content),
417
+ };
418
+ }
419
+
420
+ function levenshteinDistance(left: string, right: string): number {
421
+ if (left === right) {
422
+ return 0;
423
+ }
424
+ if (!left) {
425
+ return right.length;
426
+ }
427
+ if (!right) {
428
+ return left.length;
429
+ }
430
+
431
+ const dp = Array.from({ length: right.length + 1 }, (_, index) => index);
432
+ for (let row = 1; row <= left.length; row += 1) {
433
+ let prev = row - 1;
434
+ dp[0] = row;
435
+ for (let column = 1; column <= right.length; column += 1) {
436
+ const nextPrev = dp[column];
437
+ if (left[row - 1] === right[column - 1]) {
438
+ dp[column] = prev;
439
+ } else {
440
+ dp[column] = Math.min(prev, dp[column - 1], dp[column]) + 1;
441
+ }
442
+ prev = nextPrev;
443
+ }
444
+ }
445
+ return dp[right.length];
446
+ }
447
+
448
+ function resolveSkillRoots(openClawHome: string): SkillRootDescriptor[] {
449
+ const codexHome = process.env.CODEX_HOME
450
+ ? path.resolve(process.env.CODEX_HOME)
451
+ : path.join(os.homedir(), ".codex");
452
+ const roots: SkillRootDescriptor[] = [
453
+ {
454
+ path: path.join(openClawHome, "workspace", "skills"),
455
+ source: "openclaw_workspace",
456
+ },
457
+ {
458
+ path: path.join(openClawHome, "skills"),
459
+ source: "openclaw_home",
460
+ },
461
+ {
462
+ path: path.join(codexHome, "skills"),
463
+ source: "codex_home",
464
+ },
465
+ ];
466
+
467
+ const seen = new Set<string>();
468
+ return roots
469
+ .map((entry) => ({
470
+ path: path.normalize(entry.path),
471
+ source: entry.source,
472
+ }))
473
+ .filter((entry) => {
474
+ if (!existsSync(entry.path)) {
475
+ return false;
476
+ }
477
+ try {
478
+ if (!statSync(entry.path).isDirectory()) {
479
+ return false;
480
+ }
481
+ } catch {
482
+ return false;
483
+ }
484
+ if (seen.has(entry.path)) {
485
+ return false;
486
+ }
487
+ seen.add(entry.path);
488
+ return true;
489
+ });
490
+ }
491
+
492
+ function discoverSkills(roots: SkillRootDescriptor[]): DiscoveredSkill[] {
493
+ const discovered: DiscoveredSkill[] = [];
494
+ roots.forEach((root) => {
495
+ const entries = readdirSync(root.path, { withFileTypes: true });
496
+ entries.forEach((entry) => {
497
+ const installPath = path.join(root.path, entry.name);
498
+ if (!entry.isDirectory()) {
499
+ return;
500
+ }
501
+ const skillMdPath = path.join(installPath, "SKILL.md");
502
+ if (!existsSync(skillMdPath)) {
503
+ return;
504
+ }
505
+ try {
506
+ const content = readFileSync(skillMdPath, "utf8");
507
+ discovered.push({
508
+ installPath: path.normalize(installPath),
509
+ skillMdPath,
510
+ source: root.source,
511
+ content,
512
+ metadata: extractSkillMetadata(content, installPath),
513
+ });
514
+ } catch {
515
+ discovered.push({
516
+ installPath: path.normalize(installPath),
517
+ skillMdPath,
518
+ source: root.source,
519
+ content: "",
520
+ metadata: {
521
+ name: path.basename(installPath),
522
+ version: "",
523
+ author: "",
524
+ headline: path.basename(installPath),
525
+ sourceDetail: "",
526
+ hasChangelog: false,
527
+ },
528
+ });
529
+ }
530
+ });
531
+ });
532
+ return discovered.sort((left, right) => left.metadata.name.localeCompare(right.metadata.name));
533
+ }
534
+
535
+ function collectSkillFiles(
536
+ rootDir: string,
537
+ currentDir: string,
538
+ results: string[],
539
+ depth = 0,
540
+ ): void {
541
+ if (results.length >= MAX_SKILL_SCAN_FILES || depth > MAX_SKILL_SCAN_DEPTH) {
542
+ return;
543
+ }
544
+
545
+ let entries: Array<{ name: string }> = [];
546
+ try {
547
+ entries = readdirSync(currentDir, { withFileTypes: true }) as Array<{ name: string }>;
548
+ } catch {
549
+ return;
550
+ }
551
+
552
+ entries
553
+ .slice()
554
+ .sort((left, right) => left.name.localeCompare(right.name))
555
+ .forEach((entry) => {
556
+ if (results.length >= MAX_SKILL_SCAN_FILES) {
557
+ return;
558
+ }
559
+ const absolutePath = path.join(currentDir, entry.name);
560
+ const relativePath = path.relative(rootDir, absolutePath);
561
+
562
+ try {
563
+ const stats = statSync(absolutePath);
564
+ if (stats.isDirectory()) {
565
+ collectSkillFiles(rootDir, absolutePath, results, depth + 1);
566
+ return;
567
+ }
568
+ if (stats.isFile()) {
569
+ results.push(relativePath);
570
+ }
571
+ } catch {
572
+ // Skip unreadable paths.
573
+ }
574
+ });
575
+ }
576
+
577
+ function computeSkillHash(installPath: string): string {
578
+ const files: string[] = [];
579
+ collectSkillFiles(installPath, installPath, files);
580
+ const hasher = createHash("sha256");
581
+ files.forEach((relativePath) => {
582
+ const absolutePath = path.join(installPath, relativePath);
583
+ try {
584
+ const stats = statSync(absolutePath);
585
+ hasher.update(relativePath);
586
+ hasher.update(String(stats.size));
587
+ if (stats.size <= MAX_SKILL_FILE_BYTES) {
588
+ hasher.update(readFileSync(absolutePath));
589
+ }
590
+ } catch {
591
+ hasher.update(`missing:${relativePath}`);
592
+ }
593
+ });
594
+ return hasher.digest("hex");
595
+ }
596
+
597
+ function pushFinding(
598
+ target: RawFinding[],
599
+ code: string,
600
+ detail: string,
601
+ severity: SkillOperationSeverity,
602
+ score: number,
603
+ excerpt?: string,
604
+ ): void {
605
+ if (target.some((item) => item.code === code)) {
606
+ return;
607
+ }
608
+ target.push({
609
+ code,
610
+ detail,
611
+ severity,
612
+ score,
613
+ ...(excerpt ? { excerpt } : {}),
614
+ });
615
+ }
616
+
617
+ function extractExcerpt(content: string, pattern: RegExp): string | undefined {
618
+ const match = content.match(pattern);
619
+ if (!match?.[0]) {
620
+ return undefined;
621
+ }
622
+ return match[0].trim().slice(0, 160);
623
+ }
624
+
625
+ function scoreToTier(score: number, policy: SkillPolicyConfig): SkillRiskTier {
626
+ if (score >= policy.thresholds.critical) return "critical";
627
+ if (score >= policy.thresholds.high) return "high";
628
+ if (score >= policy.thresholds.medium) return "medium";
629
+ return "low";
630
+ }
631
+
632
+ function resolveFindingDecision(
633
+ severity: SkillOperationSeverity,
634
+ riskTier: SkillRiskTier,
635
+ policy: SkillPolicyConfig,
636
+ scanStatus: SkillScanStatus,
637
+ isDrifted: boolean,
638
+ ): Decision {
639
+ const tierMatrix = policy.matrix[riskTier] || policy.matrix.unknown;
640
+ let decision = tierMatrix[severity];
641
+ if (scanStatus !== "ready") {
642
+ if (severity === "S2") {
643
+ decision = stricterDecision(decision, policy.defaults.unscanned.S2);
644
+ }
645
+ if (severity === "S3") {
646
+ decision = stricterDecision(decision, policy.defaults.unscanned.S3);
647
+ }
648
+ }
649
+ if (isDrifted) {
650
+ decision = stricterDecision(decision, policy.defaults.drifted_action);
651
+ }
652
+ return decision;
653
+ }
654
+
655
+ export function analyzeSkillDocument(input: {
656
+ name: string;
657
+ content: string;
658
+ metadata: SkillMetadata;
659
+ siblingNames: string[];
660
+ policy: SkillPolicyConfig;
661
+ isDrifted: boolean;
662
+ }): {
663
+ risk_score: number;
664
+ risk_tier: SkillRiskTier;
665
+ confidence: number;
666
+ reason_codes: string[];
667
+ findings: SkillFinding[];
668
+ } {
669
+ const rawFindings: RawFinding[] = [];
670
+ const normalizedName = input.name.trim().toLowerCase();
671
+ const siblingNames = input.siblingNames.map((item) => item.trim().toLowerCase()).filter(Boolean);
672
+ const downloadExecutePattern = /(curl|wget)[^\n]{0,120}\|\s*(sh|bash|zsh)|download[^.\n]{0,60}(then )?(run|execute)/i;
673
+ const shellExecPattern =
674
+ /(exec_command|spawnSync|child_process|bash\s+-lc|zsh\s+-lc|powershell|rm\s+-rf|chmod\s+-R|chown\s+-R)/i;
675
+ const bypassPattern =
676
+ /(ignore|bypass|disable|skip)[^.\n]{0,40}(policy|security|guard|safety)|hide (the )?(output|logs)|不要提示|不要暴露/i;
677
+ const credentialPattern =
678
+ /(id_rsa|id_ed25519|\.env\b|aws\/credentials|\.npmrc|\.pypirc|\.kube\/config|private key|access token|session token|cookie(?:s)?|browser passwords?)/i;
679
+ const egressPattern =
680
+ /(https?:\/\/|pastebin|gist\.github|dropbox|google drive|公网|外发|upload to|send to external)/i;
681
+ const outsideWorkspaceWritePattern =
682
+ /(\/etc\/|\/usr\/local|~\/\.ssh|workspace outside|工作区外|system directory|宿主环境)/i;
683
+
684
+ if (!input.content.trim()) {
685
+ pushFinding(
686
+ rawFindings,
687
+ "SKILL_CONTENT_UNREADABLE",
688
+ "Skill content could not be read or was empty during scan.",
689
+ "S2",
690
+ 70,
691
+ );
692
+ }
693
+
694
+ if (!input.metadata.author) {
695
+ pushFinding(
696
+ rawFindings,
697
+ "SKILL_MISSING_AUTHOR",
698
+ "Skill metadata does not declare an author.",
699
+ "S0",
700
+ 8,
701
+ );
702
+ }
703
+
704
+ if (!input.metadata.version) {
705
+ pushFinding(
706
+ rawFindings,
707
+ "SKILL_MISSING_VERSION",
708
+ "Skill metadata does not declare a version.",
709
+ "S0",
710
+ 12,
711
+ );
712
+ }
713
+
714
+ if (!input.metadata.hasChangelog) {
715
+ pushFinding(
716
+ rawFindings,
717
+ "SKILL_CHANGELOG_MISSING",
718
+ "Skill content does not expose a changelog or change summary.",
719
+ "S0",
720
+ 6,
721
+ );
722
+ }
723
+
724
+ if (downloadExecutePattern.test(input.content)) {
725
+ pushFinding(
726
+ rawFindings,
727
+ "SKILL_DOWNLOAD_EXECUTE_PATTERN",
728
+ "The skill contains download-then-execute behavior or equivalent remote execution instructions.",
729
+ "S3",
730
+ 44,
731
+ extractExcerpt(input.content, downloadExecutePattern),
732
+ );
733
+ }
734
+
735
+ if (shellExecPattern.test(input.content)) {
736
+ pushFinding(
737
+ rawFindings,
738
+ "SKILL_CAPABILITY_SHELL_EXEC",
739
+ "The skill includes shell execution or destructive command patterns.",
740
+ "S3",
741
+ 24,
742
+ extractExcerpt(input.content, shellExecPattern),
743
+ );
744
+ }
745
+
746
+ if (bypassPattern.test(input.content)) {
747
+ pushFinding(
748
+ rawFindings,
749
+ "SKILL_POLICY_BYPASS_LANGUAGE",
750
+ "The skill content appears to encourage bypassing policy, safety, or audit expectations.",
751
+ "S2",
752
+ 24,
753
+ extractExcerpt(input.content, bypassPattern),
754
+ );
755
+ }
756
+
757
+ if (credentialPattern.test(input.content)) {
758
+ pushFinding(
759
+ rawFindings,
760
+ "SKILL_CREDENTIAL_TARGETING",
761
+ "The skill references secrets, credential paths, tokens, or browser/session materials.",
762
+ "S2",
763
+ 24,
764
+ extractExcerpt(input.content, credentialPattern),
765
+ );
766
+ }
767
+
768
+ if (egressPattern.test(input.content)) {
769
+ pushFinding(
770
+ rawFindings,
771
+ "SKILL_PUBLIC_EGRESS_PATTERN",
772
+ "The skill references public-network uploads or external destinations.",
773
+ "S1",
774
+ 18,
775
+ extractExcerpt(input.content, egressPattern),
776
+ );
777
+ }
778
+
779
+ if (outsideWorkspaceWritePattern.test(input.content)) {
780
+ pushFinding(
781
+ rawFindings,
782
+ "SKILL_OUTSIDE_WORKSPACE_WRITE",
783
+ "The skill references writes or changes outside the workspace boundary.",
784
+ "S2",
785
+ 20,
786
+ extractExcerpt(input.content, outsideWorkspaceWritePattern),
787
+ );
788
+ }
789
+
790
+ if (
791
+ rawFindings.some((item) => item.code === "SKILL_CAPABILITY_SHELL_EXEC") &&
792
+ rawFindings.some((item) => item.code === "SKILL_PUBLIC_EGRESS_PATTERN")
793
+ ) {
794
+ pushFinding(
795
+ rawFindings,
796
+ "SKILL_CAPABILITY_COMBINATION",
797
+ "The skill combines shell execution with public egress indicators.",
798
+ "S3",
799
+ 22,
800
+ );
801
+ }
802
+
803
+ const suspiciousSibling = siblingNames.find((candidate) => {
804
+ if (!candidate || candidate === normalizedName) {
805
+ return false;
806
+ }
807
+ if (Math.min(candidate.length, normalizedName.length) < 5) {
808
+ return false;
809
+ }
810
+ return levenshteinDistance(candidate, normalizedName) <= 2;
811
+ });
812
+ if (suspiciousSibling) {
813
+ pushFinding(
814
+ rawFindings,
815
+ "SKILL_TYPOSQUAT_SUSPECTED",
816
+ `The skill name is unusually similar to another installed skill: ${suspiciousSibling}.`,
817
+ "S1",
818
+ 14,
819
+ );
820
+ }
821
+
822
+ if (input.isDrifted) {
823
+ pushFinding(
824
+ rawFindings,
825
+ "SKILL_DRIFT_DETECTED",
826
+ "The skill hash changed without a matching version change.",
827
+ "S2",
828
+ 18,
829
+ );
830
+ }
831
+
832
+ const riskScore = clamp(
833
+ rawFindings.reduce((sum, finding) => sum + finding.score, 0),
834
+ 0,
835
+ 100,
836
+ );
837
+ const riskTier = scoreToTier(riskScore, input.policy);
838
+ const findings = rawFindings.map((finding) => ({
839
+ code: finding.code,
840
+ detail: finding.detail,
841
+ severity: finding.severity,
842
+ decision: resolveFindingDecision(
843
+ finding.severity,
844
+ riskTier,
845
+ input.policy,
846
+ "ready",
847
+ input.isDrifted,
848
+ ),
849
+ ...(finding.excerpt ? { excerpt: finding.excerpt } : {}),
850
+ }));
851
+ const confidence = clamp(
852
+ 0.54 + findings.length * 0.08 + (riskTier === "high" || riskTier === "critical" ? 0.08 : 0),
853
+ 0.45,
854
+ 0.98,
855
+ );
856
+
857
+ return {
858
+ risk_score: riskScore,
859
+ risk_tier: riskTier,
860
+ confidence: Number(confidence.toFixed(2)),
861
+ reason_codes: findings.map((finding) => finding.code),
862
+ findings,
863
+ };
864
+ }
865
+
866
+ function normalizeSource(input: string): SkillSource {
867
+ return input === "openclaw_workspace" ||
868
+ input === "openclaw_home" ||
869
+ input === "codex_home" ||
870
+ input === "custom"
871
+ ? input
872
+ : "custom";
873
+ }
874
+
875
+ function normalizeScanStatus(lastScanAt: string | undefined): SkillScanStatus {
876
+ if (!lastScanAt) {
877
+ return "unknown";
878
+ }
879
+ const ts = Date.parse(lastScanAt);
880
+ if (!Number.isFinite(ts)) {
881
+ return "unknown";
882
+ }
883
+ return Date.now() - ts > STALE_SCAN_MS ? "stale" : "ready";
884
+ }
885
+
886
+ function resolveOverrideState(row: SkillOverrideRow | undefined): {
887
+ quarantined: boolean;
888
+ trustOverride: boolean;
889
+ expiresAt?: string;
890
+ } {
891
+ const now = Date.now();
892
+ const expiresAt = row?.expires_at || undefined;
893
+ const trustOverride = Boolean(
894
+ row?.trust_override &&
895
+ (!expiresAt || (Number.isFinite(Date.parse(expiresAt)) && Date.parse(expiresAt) > now)),
896
+ );
897
+ return {
898
+ quarantined: Boolean(row?.quarantined),
899
+ trustOverride,
900
+ ...(expiresAt ? { expiresAt } : {}),
901
+ };
902
+ }
903
+
904
+ function resolveLifecycleState(input: {
905
+ quarantined: boolean;
906
+ trustOverride: boolean;
907
+ }): SkillLifecycleState {
908
+ if (input.quarantined) {
909
+ return "quarantined";
910
+ }
911
+ if (input.trustOverride) {
912
+ return "trusted";
913
+ }
914
+ return "normal";
915
+ }
916
+
917
+ function maxTimestamp(...values: Array<string | undefined>): string | undefined {
918
+ const resolved = values
919
+ .map((value) => (value && Number.isFinite(Date.parse(value)) ? Date.parse(value) : NaN))
920
+ .filter((value) => Number.isFinite(value))
921
+ .sort((left, right) => right - left);
922
+ return resolved.length > 0 ? new Date(resolved[0]).toISOString() : undefined;
923
+ }
924
+
925
+ function normalizeSkillSummary(
926
+ inventory: SkillInventoryRow,
927
+ latestScan: SkillLatestScanRow | undefined,
928
+ override: SkillOverrideRow | undefined,
929
+ interceptAggregate: SkillInterceptAggregateRow | undefined,
930
+ ): SkillSummary {
931
+ const metadata = safeParseJson<Record<string, unknown>>(inventory.metadata_json, {});
932
+ const findings = safeParseJson<SkillFinding[]>(latestScan?.findings_json, []);
933
+ const reasonCodes = safeParseJson<string[]>(latestScan?.reason_codes_json, findings.map((finding) => finding.code));
934
+ const lastScanAt = latestScan?.scan_ts || undefined;
935
+ const scanStatus = normalizeScanStatus(lastScanAt);
936
+ const overrideState = resolveOverrideState(override);
937
+ const scanInterceptCount =
938
+ lastScanAt && Number.isFinite(Date.parse(lastScanAt)) && Date.now() - Date.parse(lastScanAt) <= ACTIVITY_WINDOW_MS
939
+ ? findings.filter((finding) => finding.decision === "challenge" || finding.decision === "block").length
940
+ : 0;
941
+ const runtimeInterceptCount = Number(interceptAggregate?.challenge_block_count || 0);
942
+ const lastInterceptAt = maxTimestamp(
943
+ interceptAggregate?.last_intercept_at || undefined,
944
+ scanInterceptCount > 0 ? lastScanAt : undefined,
945
+ );
946
+ const firstSeenTs = Date.parse(inventory.first_seen_at);
947
+
948
+ return {
949
+ skill_id: inventory.skill_id,
950
+ name: inventory.name,
951
+ version: inventory.version || "",
952
+ author: inventory.author || "",
953
+ headline: normalizeText(metadata.headline) || inventory.name,
954
+ source: normalizeSource(inventory.source),
955
+ source_detail: normalizeText(metadata.source_detail),
956
+ install_path: inventory.install_path,
957
+ current_hash: inventory.current_hash,
958
+ risk_score: Number(latestScan?.risk_score || 0),
959
+ risk_tier: (latestScan?.risk_tier as SkillRiskTier) || "low",
960
+ confidence: Number(latestScan?.confidence || 0),
961
+ reason_codes: reasonCodes,
962
+ findings,
963
+ finding_count: findings.length,
964
+ scan_status: scanStatus,
965
+ last_seen_at: inventory.last_seen_at,
966
+ first_seen_at: inventory.first_seen_at,
967
+ ...(lastScanAt ? { last_scan_at: lastScanAt } : {}),
968
+ ...(lastInterceptAt ? { last_intercept_at: lastInterceptAt } : {}),
969
+ intercept_count_24h: scanInterceptCount + runtimeInterceptCount,
970
+ is_drifted: Boolean(metadata.is_drifted),
971
+ is_newly_installed:
972
+ Number.isFinite(firstSeenTs) && Date.now() - firstSeenTs <= NEW_INSTALL_WINDOW_MS,
973
+ quarantined: overrideState.quarantined,
974
+ trust_override: overrideState.trustOverride,
975
+ ...(overrideState.expiresAt ? { trust_override_expires_at: overrideState.expiresAt } : {}),
976
+ state: resolveLifecycleState(overrideState),
977
+ };
978
+ }
979
+
980
+ export function normalizeSkillPolicyConfig(input: unknown): SkillPolicyConfig {
981
+ const fallback = cloneDefaultSkillPolicyConfig();
982
+ const payload =
983
+ input && typeof input === "object" && !Array.isArray(input)
984
+ ? (input as Record<string, unknown>)
985
+ : {};
986
+ const thresholds =
987
+ payload.thresholds && typeof payload.thresholds === "object" && !Array.isArray(payload.thresholds)
988
+ ? (payload.thresholds as Record<string, unknown>)
989
+ : {};
990
+ const defaults =
991
+ payload.defaults && typeof payload.defaults === "object" && !Array.isArray(payload.defaults)
992
+ ? (payload.defaults as Record<string, unknown>)
993
+ : {};
994
+
995
+ fallback.thresholds.medium = clamp(
996
+ Number.isFinite(Number(thresholds.medium)) ? Number(thresholds.medium) : fallback.thresholds.medium,
997
+ 0,
998
+ 70,
999
+ );
1000
+ fallback.thresholds.high = clamp(
1001
+ Number.isFinite(Number(thresholds.high)) ? Number(thresholds.high) : fallback.thresholds.high,
1002
+ fallback.thresholds.medium + 1,
1003
+ 90,
1004
+ );
1005
+ fallback.thresholds.critical = clamp(
1006
+ Number.isFinite(Number(thresholds.critical))
1007
+ ? Number(thresholds.critical)
1008
+ : fallback.thresholds.critical,
1009
+ fallback.thresholds.high + 1,
1010
+ 100,
1011
+ );
1012
+
1013
+ const matrixInput =
1014
+ payload.matrix && typeof payload.matrix === "object" && !Array.isArray(payload.matrix)
1015
+ ? (payload.matrix as Record<string, unknown>)
1016
+ : {};
1017
+ (["low", "medium", "high", "critical", "unknown"] as const).forEach((tier) => {
1018
+ const tierInput =
1019
+ matrixInput[tier] && typeof matrixInput[tier] === "object" && !Array.isArray(matrixInput[tier])
1020
+ ? (matrixInput[tier] as Record<string, unknown>)
1021
+ : {};
1022
+ OPERATION_SEVERITIES.forEach((severity) => {
1023
+ fallback.matrix[tier][severity] = normalizeDecision(
1024
+ tierInput[severity],
1025
+ fallback.matrix[tier][severity],
1026
+ );
1027
+ });
1028
+ });
1029
+
1030
+ const unscanned =
1031
+ defaults.unscanned && typeof defaults.unscanned === "object" && !Array.isArray(defaults.unscanned)
1032
+ ? (defaults.unscanned as Record<string, unknown>)
1033
+ : {};
1034
+ fallback.defaults.unscanned.S2 = normalizeDecision(unscanned.S2, fallback.defaults.unscanned.S2);
1035
+ fallback.defaults.unscanned.S3 = normalizeDecision(unscanned.S3, fallback.defaults.unscanned.S3);
1036
+ fallback.defaults.drifted_action = normalizeDecision(
1037
+ defaults.drifted_action,
1038
+ fallback.defaults.drifted_action,
1039
+ );
1040
+ fallback.defaults.trust_override_hours = clamp(
1041
+ Number.isFinite(Number(defaults.trust_override_hours))
1042
+ ? Number(defaults.trust_override_hours)
1043
+ : fallback.defaults.trust_override_hours,
1044
+ 1,
1045
+ 24 * 7,
1046
+ );
1047
+ if (typeof payload.updated_at === "string" && payload.updated_at.trim()) {
1048
+ fallback.updated_at = payload.updated_at.trim();
1049
+ }
1050
+ return fallback;
1051
+ }
1052
+
1053
+ export class SkillInterceptionStore {
1054
+ #db: DatabaseSync;
1055
+ #openClawHome: string;
1056
+ #lastRefreshAt = 0;
1057
+ #roots: SkillRootDescriptor[] = [];
1058
+
1059
+ constructor(dbPath: string, options: { openClawHome: string }) {
1060
+ this.#openClawHome = path.resolve(options.openClawHome);
1061
+ this.#db = new DatabaseSync(dbPath);
1062
+ this.#db.exec("PRAGMA journal_mode=WAL;");
1063
+ this.#db.exec("PRAGMA synchronous=NORMAL;");
1064
+ this.#db.exec(SKILL_SCHEMA_SQL);
1065
+ this.#ensurePolicyRow();
1066
+ }
1067
+
1068
+ close(): void {
1069
+ this.#db.close();
1070
+ }
1071
+
1072
+ readPolicyConfig(): SkillPolicyConfig {
1073
+ const row = this.#db
1074
+ .prepare("SELECT payload_json FROM skill_policy_config WHERE id = 1")
1075
+ .get() as { payload_json: string } | undefined;
1076
+ if (!row) {
1077
+ const fallback = cloneDefaultSkillPolicyConfig();
1078
+ this.#writePolicyConfig(fallback);
1079
+ return fallback;
1080
+ }
1081
+ return normalizeSkillPolicyConfig(safeParseJson(row.payload_json, {}));
1082
+ }
1083
+
1084
+ writePolicyConfig(input: unknown): SkillPolicyConfig {
1085
+ const policy = normalizeSkillPolicyConfig(input);
1086
+ policy.updated_at = new Date().toISOString();
1087
+ this.#writePolicyConfig(policy);
1088
+ return policy;
1089
+ }
1090
+
1091
+ getStatus(): SkillStatusPayload {
1092
+ const snapshot = this.#buildSnapshot();
1093
+ const highlights = snapshot.items
1094
+ .slice()
1095
+ .sort((left, right) => {
1096
+ if (Number(right.quarantined) !== Number(left.quarantined)) {
1097
+ return Number(right.quarantined) - Number(left.quarantined);
1098
+ }
1099
+ if (Number(right.intercept_count_24h > 0) !== Number(left.intercept_count_24h > 0)) {
1100
+ return Number(right.intercept_count_24h > 0) - Number(left.intercept_count_24h > 0);
1101
+ }
1102
+ if (Number(right.is_drifted) !== Number(left.is_drifted)) {
1103
+ return Number(right.is_drifted) - Number(left.is_drifted);
1104
+ }
1105
+ if (RISK_TIERS.indexOf(right.risk_tier) !== RISK_TIERS.indexOf(left.risk_tier)) {
1106
+ return RISK_TIERS.indexOf(right.risk_tier) - RISK_TIERS.indexOf(left.risk_tier);
1107
+ }
1108
+ if (right.risk_score !== left.risk_score) {
1109
+ return right.risk_score - left.risk_score;
1110
+ }
1111
+ return right.intercept_count_24h - left.intercept_count_24h;
1112
+ })
1113
+ .slice(0, 3);
1114
+ return {
1115
+ stats: {
1116
+ total: snapshot.items.length,
1117
+ high_critical: snapshot.items.filter((item) => item.risk_tier === "high" || item.risk_tier === "critical").length,
1118
+ challenge_block_24h: snapshot.items.reduce((sum, item) => sum + item.intercept_count_24h, 0),
1119
+ drift_alerts: snapshot.items.filter((item) => item.is_drifted).length,
1120
+ quarantined: snapshot.items.filter((item) => item.quarantined).length,
1121
+ trusted_overrides: snapshot.items.filter((item) => item.trust_override).length,
1122
+ },
1123
+ highlights,
1124
+ policy: snapshot.policy,
1125
+ roots: snapshot.roots.map((root) => ({ path: root.path, source: root.source })),
1126
+ generated_at: new Date().toISOString(),
1127
+ };
1128
+ }
1129
+
1130
+ listSkills(filters: SkillListFilters = {}): SkillListPayload {
1131
+ const snapshot = this.#buildSnapshot();
1132
+ const normalizedFilters = {
1133
+ risk: normalizeText(filters.risk) || "all",
1134
+ state: normalizeText(filters.state) || "all",
1135
+ source: normalizeText(filters.source) || "all",
1136
+ drift: normalizeText(filters.drift) || "all",
1137
+ intercepted: normalizeText(filters.intercepted) || "all",
1138
+ };
1139
+
1140
+ const filtered = snapshot.items.filter((item) => {
1141
+ if (normalizedFilters.risk !== "all" && item.risk_tier !== normalizedFilters.risk) {
1142
+ return false;
1143
+ }
1144
+ if (normalizedFilters.state !== "all" && item.state !== normalizedFilters.state) {
1145
+ return false;
1146
+ }
1147
+ if (normalizedFilters.source !== "all" && item.source !== normalizedFilters.source) {
1148
+ return false;
1149
+ }
1150
+ if (normalizedFilters.drift === "drifted" && !item.is_drifted) {
1151
+ return false;
1152
+ }
1153
+ if (normalizedFilters.drift === "steady" && item.is_drifted) {
1154
+ return false;
1155
+ }
1156
+ if (normalizedFilters.intercepted === "recent" && item.intercept_count_24h <= 0) {
1157
+ return false;
1158
+ }
1159
+ return true;
1160
+ });
1161
+
1162
+ return {
1163
+ items: filtered,
1164
+ total: filtered.length,
1165
+ counts: {
1166
+ total: snapshot.items.length,
1167
+ high_critical: snapshot.items.filter((item) => item.risk_tier === "high" || item.risk_tier === "critical").length,
1168
+ quarantined: snapshot.items.filter((item) => item.quarantined).length,
1169
+ trusted: snapshot.items.filter((item) => item.trust_override).length,
1170
+ drifted: snapshot.items.filter((item) => item.is_drifted).length,
1171
+ recent_intercepts: snapshot.items.filter((item) => item.intercept_count_24h > 0).length,
1172
+ },
1173
+ filters: normalizedFilters,
1174
+ source_options: Array.from(new Set(snapshot.items.map((item) => item.source))).sort((left, right) =>
1175
+ left.localeCompare(right),
1176
+ ),
1177
+ policy: snapshot.policy,
1178
+ };
1179
+ }
1180
+
1181
+ getSkill(skillId: string): SkillDetailPayload | undefined {
1182
+ const snapshot = this.#buildSnapshot();
1183
+ const skill = snapshot.items.find((item) => item.skill_id === skillId);
1184
+ if (!skill) {
1185
+ return undefined;
1186
+ }
1187
+
1188
+ const runtimeEvents = this.#db
1189
+ .prepare(
1190
+ `SELECT ts, skill_id, event_kind, tool, severity, decision, reason_codes_json, trace_id, detail
1191
+ FROM skill_runtime_events
1192
+ WHERE skill_id = ?
1193
+ ORDER BY ts DESC
1194
+ LIMIT 12`,
1195
+ )
1196
+ .all(skillId) as SkillRuntimeEventRow[];
1197
+ const scanEvents: SkillActivity[] = skill.findings.map((finding) => ({
1198
+ ts: skill.last_scan_at || skill.last_seen_at,
1199
+ kind: "finding",
1200
+ title: finding.code,
1201
+ detail: finding.detail,
1202
+ severity: finding.severity,
1203
+ decision: finding.decision,
1204
+ reason_codes: [finding.code],
1205
+ }));
1206
+ const activity = [
1207
+ ...scanEvents,
1208
+ ...runtimeEvents.map((event) => ({
1209
+ ts: event.ts,
1210
+ kind: event.event_kind,
1211
+ title: event.event_kind,
1212
+ detail: event.detail || event.tool || event.event_kind,
1213
+ ...(event.severity ? { severity: event.severity as SkillOperationSeverity } : {}),
1214
+ ...(event.decision ? { decision: event.decision as Decision } : {}),
1215
+ reason_codes: safeParseJson<string[]>(event.reason_codes_json, []),
1216
+ })),
1217
+ ]
1218
+ .sort((left, right) => Date.parse(right.ts) - Date.parse(left.ts))
1219
+ .slice(0, 16);
1220
+
1221
+ return {
1222
+ skill,
1223
+ findings: skill.findings,
1224
+ activity,
1225
+ policy: snapshot.policy,
1226
+ roots: snapshot.roots.map((root) => ({ path: root.path, source: root.source })),
1227
+ };
1228
+ }
1229
+
1230
+ rescanSkill(skillId: string, updatedBy = "admin-ui"): SkillDetailPayload | undefined {
1231
+ this.#refreshInventory({
1232
+ force: true,
1233
+ targetSkillId: skillId,
1234
+ auditActor: updatedBy,
1235
+ });
1236
+ return this.getSkill(skillId);
1237
+ }
1238
+
1239
+ setQuarantine(
1240
+ skillId: string,
1241
+ input: { quarantined: boolean; updatedBy?: string },
1242
+ ): SkillDetailPayload | undefined {
1243
+ const detail = this.getSkill(skillId);
1244
+ if (!detail) {
1245
+ return undefined;
1246
+ }
1247
+ const now = new Date().toISOString();
1248
+ this.#db
1249
+ .prepare(
1250
+ `INSERT INTO skill_overrides (skill_id, quarantined, trust_override, expires_at, updated_by, updated_at)
1251
+ VALUES (?, ?, ?, ?, ?, ?)
1252
+ ON CONFLICT(skill_id) DO UPDATE SET
1253
+ quarantined = excluded.quarantined,
1254
+ trust_override = COALESCE(skill_overrides.trust_override, excluded.trust_override),
1255
+ expires_at = COALESCE(skill_overrides.expires_at, excluded.expires_at),
1256
+ updated_by = excluded.updated_by,
1257
+ updated_at = excluded.updated_at`,
1258
+ )
1259
+ .run(
1260
+ skillId,
1261
+ input.quarantined ? 1 : 0,
1262
+ detail.skill.trust_override ? 1 : 0,
1263
+ detail.skill.trust_override_expires_at ?? null,
1264
+ input.updatedBy || "admin-ui",
1265
+ now,
1266
+ );
1267
+ this.#insertRuntimeEvent({
1268
+ skillId,
1269
+ eventKind: input.quarantined ? "quarantine_on" : "quarantine_off",
1270
+ decision: input.quarantined ? "block" : "allow",
1271
+ detail: input.quarantined
1272
+ ? "Skill quarantined from the admin dashboard."
1273
+ : "Skill quarantine removed from the admin dashboard.",
1274
+ reasonCodes: ["SKILL_QUARANTINE_OVERRIDE"],
1275
+ actor: input.updatedBy || "admin-ui",
1276
+ });
1277
+ return this.getSkill(skillId);
1278
+ }
1279
+
1280
+ setTrustOverride(
1281
+ skillId: string,
1282
+ input: { enabled: boolean; updatedBy?: string; hours?: number },
1283
+ ): SkillDetailPayload | undefined {
1284
+ const detail = this.getSkill(skillId);
1285
+ if (!detail) {
1286
+ return undefined;
1287
+ }
1288
+ const policy = this.readPolicyConfig();
1289
+ const durationHours = clamp(
1290
+ Number.isFinite(Number(input.hours))
1291
+ ? Number(input.hours)
1292
+ : policy.defaults.trust_override_hours,
1293
+ 1,
1294
+ 24 * 7,
1295
+ );
1296
+ const now = new Date().toISOString();
1297
+ const expiresAt = input.enabled
1298
+ ? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
1299
+ : null;
1300
+
1301
+ this.#db
1302
+ .prepare(
1303
+ `INSERT INTO skill_overrides (skill_id, quarantined, trust_override, expires_at, updated_by, updated_at)
1304
+ VALUES (?, ?, ?, ?, ?, ?)
1305
+ ON CONFLICT(skill_id) DO UPDATE SET
1306
+ quarantined = COALESCE(skill_overrides.quarantined, excluded.quarantined),
1307
+ trust_override = excluded.trust_override,
1308
+ expires_at = excluded.expires_at,
1309
+ updated_by = excluded.updated_by,
1310
+ updated_at = excluded.updated_at`,
1311
+ )
1312
+ .run(
1313
+ skillId,
1314
+ detail.skill.quarantined ? 1 : 0,
1315
+ input.enabled ? 1 : 0,
1316
+ expiresAt,
1317
+ input.updatedBy || "admin-ui",
1318
+ now,
1319
+ );
1320
+ this.#insertRuntimeEvent({
1321
+ skillId,
1322
+ eventKind: input.enabled ? "trust_override_on" : "trust_override_off",
1323
+ decision: input.enabled ? "warn" : "allow",
1324
+ detail: input.enabled
1325
+ ? `Temporary trust override applied for ${durationHours}h.`
1326
+ : "Trust override removed from the admin dashboard.",
1327
+ reasonCodes: ["SKILL_TRUST_OVERRIDE_APPLIED"],
1328
+ actor: input.updatedBy || "admin-ui",
1329
+ });
1330
+ return this.getSkill(skillId);
1331
+ }
1332
+
1333
+ #ensurePolicyRow(): void {
1334
+ const row = this.#db
1335
+ .prepare("SELECT COUNT(1) AS count FROM skill_policy_config WHERE id = 1")
1336
+ .get() as { count: number };
1337
+ if (Number(row.count) > 0) {
1338
+ return;
1339
+ }
1340
+ this.#writePolicyConfig(cloneDefaultSkillPolicyConfig());
1341
+ }
1342
+
1343
+ #writePolicyConfig(policy: SkillPolicyConfig): void {
1344
+ const updatedAt = policy.updated_at || new Date().toISOString();
1345
+ this.#db
1346
+ .prepare(
1347
+ `INSERT INTO skill_policy_config (id, payload_json, updated_at)
1348
+ VALUES (1, ?, ?)
1349
+ ON CONFLICT(id) DO UPDATE SET
1350
+ payload_json = excluded.payload_json,
1351
+ updated_at = excluded.updated_at`,
1352
+ )
1353
+ .run(JSON.stringify(policy), updatedAt);
1354
+ }
1355
+
1356
+ #refreshIfStale(force = false): void {
1357
+ if (!force && Date.now() - this.#lastRefreshAt < REFRESH_CACHE_MS) {
1358
+ return;
1359
+ }
1360
+ this.#refreshInventory({ force });
1361
+ }
1362
+
1363
+ #refreshInventory(options: RefreshOptions = {}): void {
1364
+ this.#roots = resolveSkillRoots(this.#openClawHome);
1365
+ const discovered = discoverSkills(this.#roots);
1366
+ const policy = this.readPolicyConfig();
1367
+ const existingRows = this.#db
1368
+ .prepare(
1369
+ `SELECT skill_id, name, version, author, source, install_path, current_hash,
1370
+ first_seen_at, last_seen_at, is_present, metadata_json
1371
+ FROM skill_inventory`,
1372
+ )
1373
+ .all() as SkillInventoryRow[];
1374
+ const existingByPath = new Map(existingRows.map((row) => [path.normalize(row.install_path), row]));
1375
+ const latestScans = this.#db
1376
+ .prepare(
1377
+ `SELECT s.skill_id, s.scan_ts, s.risk_score, s.risk_tier, s.confidence, s.reason_codes_json, s.findings_json
1378
+ FROM skill_scan_results s
1379
+ JOIN (
1380
+ SELECT skill_id, MAX(id) AS latest_id
1381
+ FROM skill_scan_results
1382
+ GROUP BY skill_id
1383
+ ) latest ON latest.latest_id = s.id`,
1384
+ )
1385
+ .all() as SkillLatestScanRow[];
1386
+ const latestScanBySkillId = new Map(latestScans.map((row) => [row.skill_id, row]));
1387
+ const siblingNames = discovered.map((item) => item.metadata.name);
1388
+ const seenSkillIds = new Set<string>();
1389
+ let targetFound = false;
1390
+
1391
+ discovered.forEach((skill) => {
1392
+ const existing = existingByPath.get(skill.installPath);
1393
+ const currentHash = computeSkillHash(skill.installPath);
1394
+ const skillId = existing?.skill_id || buildSkillId(skill.metadata.name, skill.installPath);
1395
+ const existingHash = existing?.current_hash || "";
1396
+ const existingVersion = existing?.version || "";
1397
+ const isDrifted =
1398
+ Boolean(existingHash) &&
1399
+ existingHash !== currentHash &&
1400
+ normalizeText(existingVersion) === normalizeText(skill.metadata.version) &&
1401
+ Boolean(skill.metadata.version);
1402
+ const analysis = analyzeSkillDocument({
1403
+ name: skill.metadata.name,
1404
+ content: skill.content,
1405
+ metadata: skill.metadata,
1406
+ siblingNames: siblingNames.filter((item) => item !== skill.metadata.name),
1407
+ policy,
1408
+ isDrifted,
1409
+ });
1410
+ const now = new Date().toISOString();
1411
+ const metadataPayload = {
1412
+ headline: skill.metadata.headline,
1413
+ source_detail: skill.metadata.sourceDetail,
1414
+ has_changelog: skill.metadata.hasChangelog,
1415
+ is_drifted: isDrifted,
1416
+ skill_md_path: skill.skillMdPath,
1417
+ };
1418
+
1419
+ this.#db
1420
+ .prepare(
1421
+ `INSERT INTO skill_inventory (
1422
+ skill_id, name, version, author, source, install_path, current_hash,
1423
+ first_seen_at, last_seen_at, is_present, metadata_json
1424
+ )
1425
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
1426
+ ON CONFLICT(skill_id) DO UPDATE SET
1427
+ name = excluded.name,
1428
+ version = excluded.version,
1429
+ author = excluded.author,
1430
+ source = excluded.source,
1431
+ install_path = excluded.install_path,
1432
+ current_hash = excluded.current_hash,
1433
+ last_seen_at = excluded.last_seen_at,
1434
+ is_present = 1,
1435
+ metadata_json = excluded.metadata_json`,
1436
+ )
1437
+ .run(
1438
+ skillId,
1439
+ skill.metadata.name,
1440
+ skill.metadata.version || null,
1441
+ skill.metadata.author || null,
1442
+ skill.source,
1443
+ skill.installPath,
1444
+ currentHash,
1445
+ existing?.first_seen_at || now,
1446
+ now,
1447
+ JSON.stringify(metadataPayload),
1448
+ );
1449
+
1450
+ const latestScan = latestScanBySkillId.get(skillId);
1451
+ const latestFindings = safeParseJson<SkillFinding[]>(latestScan?.findings_json, []);
1452
+ const shouldInsertScan =
1453
+ options.force ||
1454
+ !latestScan ||
1455
+ latestScan.scan_ts.length === 0 ||
1456
+ Date.now() - Date.parse(latestScan.scan_ts) > STALE_SCAN_MS / 2 ||
1457
+ latestScan.risk_score !== analysis.risk_score ||
1458
+ JSON.stringify(latestFindings) !== JSON.stringify(analysis.findings);
1459
+ if (shouldInsertScan) {
1460
+ this.#db
1461
+ .prepare(
1462
+ `INSERT INTO skill_scan_results (
1463
+ skill_id, scan_ts, risk_score, risk_tier, confidence, reason_codes_json, findings_json
1464
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
1465
+ )
1466
+ .run(
1467
+ skillId,
1468
+ now,
1469
+ analysis.risk_score,
1470
+ analysis.risk_tier,
1471
+ analysis.confidence,
1472
+ JSON.stringify(analysis.reason_codes),
1473
+ JSON.stringify(analysis.findings),
1474
+ );
1475
+ }
1476
+
1477
+ if (options.targetSkillId && skillId === options.targetSkillId) {
1478
+ targetFound = true;
1479
+ this.#insertRuntimeEvent({
1480
+ skillId,
1481
+ eventKind: "rescan",
1482
+ decision: analysis.findings.some((finding) => finding.decision === "block")
1483
+ ? "block"
1484
+ : analysis.findings.some((finding) => finding.decision === "challenge")
1485
+ ? "challenge"
1486
+ : "allow",
1487
+ detail: `Manual rescan completed with risk score ${analysis.risk_score}.`,
1488
+ reasonCodes: analysis.reason_codes,
1489
+ actor: options.auditActor || "admin-ui",
1490
+ });
1491
+ } else if (isDrifted) {
1492
+ this.#insertRuntimeEvent({
1493
+ skillId,
1494
+ eventKind: "drift_detected",
1495
+ decision: policy.defaults.drifted_action,
1496
+ detail: "Skill drift detected during inventory refresh.",
1497
+ reasonCodes: ["SKILL_DRIFT_DETECTED"],
1498
+ });
1499
+ }
1500
+
1501
+ seenSkillIds.add(skillId);
1502
+ });
1503
+
1504
+ const missingSkillIds = existingRows
1505
+ .map((row) => row.skill_id)
1506
+ .filter((skillId) => !seenSkillIds.has(skillId));
1507
+ if (missingSkillIds.length > 0) {
1508
+ const placeholders = missingSkillIds.map(() => "?").join(", ");
1509
+ this.#db
1510
+ .prepare(`UPDATE skill_inventory SET is_present = 0 WHERE skill_id IN (${placeholders})`)
1511
+ .run(...missingSkillIds);
1512
+ }
1513
+
1514
+ if (options.targetSkillId && !targetFound) {
1515
+ this.#lastRefreshAt = Date.now();
1516
+ return;
1517
+ }
1518
+ this.#lastRefreshAt = Date.now();
1519
+ }
1520
+
1521
+ #buildSnapshot(): SkillSnapshot {
1522
+ this.#refreshIfStale();
1523
+ const inventoryRows = this.#db
1524
+ .prepare(
1525
+ `SELECT skill_id, name, version, author, source, install_path, current_hash,
1526
+ first_seen_at, last_seen_at, is_present, metadata_json
1527
+ FROM skill_inventory
1528
+ WHERE is_present = 1
1529
+ ORDER BY name COLLATE NOCASE ASC`,
1530
+ )
1531
+ .all() as SkillInventoryRow[];
1532
+ const latestScans = this.#db
1533
+ .prepare(
1534
+ `SELECT s.skill_id, s.scan_ts, s.risk_score, s.risk_tier, s.confidence, s.reason_codes_json, s.findings_json
1535
+ FROM skill_scan_results s
1536
+ JOIN (
1537
+ SELECT skill_id, MAX(id) AS latest_id
1538
+ FROM skill_scan_results
1539
+ GROUP BY skill_id
1540
+ ) latest ON latest.latest_id = s.id`,
1541
+ )
1542
+ .all() as SkillLatestScanRow[];
1543
+ const overrides = this.#db
1544
+ .prepare(
1545
+ `SELECT skill_id, quarantined, trust_override, expires_at, updated_by, updated_at
1546
+ FROM skill_overrides`,
1547
+ )
1548
+ .all() as SkillOverrideRow[];
1549
+ const interceptAggregates = this.#db
1550
+ .prepare(
1551
+ `SELECT skill_id,
1552
+ COUNT(1) AS challenge_block_count,
1553
+ MAX(ts) AS last_intercept_at
1554
+ FROM skill_runtime_events
1555
+ WHERE decision IN ('challenge', 'block') AND ts >= ?
1556
+ GROUP BY skill_id`,
1557
+ )
1558
+ .all(new Date(Date.now() - ACTIVITY_WINDOW_MS).toISOString()) as SkillInterceptAggregateRow[];
1559
+
1560
+ const latestScanBySkillId = new Map(latestScans.map((row) => [row.skill_id, row]));
1561
+ const overrideBySkillId = new Map(overrides.map((row) => [row.skill_id, row]));
1562
+ const interceptBySkillId = new Map(interceptAggregates.map((row) => [row.skill_id, row]));
1563
+ const items = inventoryRows.map((inventory) =>
1564
+ normalizeSkillSummary(
1565
+ inventory,
1566
+ latestScanBySkillId.get(inventory.skill_id),
1567
+ overrideBySkillId.get(inventory.skill_id),
1568
+ interceptBySkillId.get(inventory.skill_id),
1569
+ ),
1570
+ );
1571
+ return {
1572
+ items,
1573
+ policy: this.readPolicyConfig(),
1574
+ roots: this.#roots,
1575
+ };
1576
+ }
1577
+
1578
+ #insertRuntimeEvent(input: {
1579
+ skillId: string;
1580
+ eventKind: string;
1581
+ decision: Decision;
1582
+ detail: string;
1583
+ reasonCodes?: string[];
1584
+ actor?: string;
1585
+ }): void {
1586
+ const reasonCodes = input.reasonCodes ?? [];
1587
+ const detail = input.actor ? `${input.detail} (${input.actor})` : input.detail;
1588
+ this.#db
1589
+ .prepare(
1590
+ `INSERT INTO skill_runtime_events (
1591
+ ts, skill_id, event_kind, tool, severity, decision, reason_codes_json, trace_id, detail
1592
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1593
+ )
1594
+ .run(
1595
+ new Date().toISOString(),
1596
+ input.skillId,
1597
+ input.eventKind,
1598
+ input.eventKind,
1599
+ null,
1600
+ input.decision,
1601
+ JSON.stringify(reasonCodes),
1602
+ `skill-${Date.now()}`,
1603
+ detail,
1604
+ );
1605
+ }
1606
+ }