safeclaw 0.1.4 → 0.1.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/main.js CHANGED
@@ -8,15 +8,16 @@ var __export = (target, all) => {
8
8
  import { Command } from "commander";
9
9
 
10
10
  // src/lib/version.ts
11
- var VERSION = "0.1.3";
11
+ var VERSION = "0.1.6";
12
12
 
13
13
  // src/server/index.ts
14
14
  import Fastify from "fastify";
15
15
  import fastifyStatic from "@fastify/static";
16
16
  import fastifyCors from "@fastify/cors";
17
- import fs7 from "fs";
17
+ import fs10 from "fs";
18
18
 
19
19
  // src/lib/paths.ts
20
+ import fs from "fs";
20
21
  import path from "path";
21
22
  import os from "os";
22
23
  var HOME = os.homedir();
@@ -31,9 +32,12 @@ var OPENCLAW_IDENTITY_DIR = path.join(OPENCLAW_DIR, "identity");
31
32
  var OPENCLAW_DEVICE_JSON = path.join(OPENCLAW_IDENTITY_DIR, "device.json");
32
33
  var OPENCLAW_DEVICE_AUTH_JSON = path.join(OPENCLAW_IDENTITY_DIR, "device-auth.json");
33
34
  var OPENCLAW_EXEC_APPROVALS_PATH = path.join(OPENCLAW_DIR, "exec-approvals.json");
35
+ var SRT_SETTINGS_PATH = path.join(HOME, ".srt-settings.json");
34
36
  function getPublicDir() {
35
37
  const currentDir = path.dirname(new URL(import.meta.url).pathname);
36
- return path.resolve(currentDir, "..", "..", "public");
38
+ const bundledPath = path.resolve(currentDir, "..", "public");
39
+ const devPath = path.resolve(currentDir, "..", "..", "public");
40
+ return fs.existsSync(bundledPath) ? bundledPath : devPath;
37
41
  }
38
42
 
39
43
  // src/server/socket.ts
@@ -44,7 +48,7 @@ import Database from "better-sqlite3";
44
48
  import { drizzle } from "drizzle-orm/better-sqlite3";
45
49
 
46
50
  // src/lib/config.ts
47
- import fs from "fs";
51
+ import fs2 from "fs";
48
52
 
49
53
  // ../../packages/shared/dist/schemas.js
50
54
  import { z } from "zod";
@@ -81,8 +85,12 @@ var safeClawConfigSchema = z.object({
81
85
  port: z.number().int().min(1024).max(65535).default(54335),
82
86
  autoOpenBrowser: z.boolean().default(true),
83
87
  premium: z.boolean().default(false),
84
- userId: z.string().nullable().default(null)
85
- });
88
+ userId: z.string().nullable().default(null),
89
+ srt: z.object({
90
+ enabled: z.boolean(),
91
+ settingsPath: z.string().optional()
92
+ }).optional()
93
+ }).passthrough();
86
94
  var activityTypeSchema = z.enum([
87
95
  "file_read",
88
96
  "file_write",
@@ -260,7 +268,7 @@ var execApprovalEntrySchema = z.object({
260
268
  requestedAt: z.string(),
261
269
  expiresAt: z.string(),
262
270
  decision: execDecisionSchema.nullable(),
263
- decidedBy: z.enum(["user", "auto-deny"]).nullable(),
271
+ decidedBy: z.enum(["user", "auto-deny", "auto-approve", "access-control"]).nullable(),
264
272
  decidedAt: z.string().nullable()
265
273
  });
266
274
  var allowlistPatternSchema = z.object({
@@ -269,6 +277,104 @@ var allowlistPatternSchema = z.object({
269
277
  var allowlistStateSchema = z.object({
270
278
  patterns: z.array(allowlistPatternSchema)
271
279
  });
280
+ var skillScanCategoryIdSchema = z.enum([
281
+ "SK-HID",
282
+ "SK-INJ",
283
+ "SK-EXE",
284
+ "SK-EXF",
285
+ "SK-SEC",
286
+ "SK-SFA",
287
+ "SK-MEM",
288
+ "SK-SUP",
289
+ "SK-B64",
290
+ "SK-IMG",
291
+ "SK-SYS",
292
+ "SK-ARG",
293
+ "SK-XTL",
294
+ "SK-PRM",
295
+ "SK-STR"
296
+ ]);
297
+ var skillScanRequestSchema = z.object({
298
+ content: z.string().min(1).max(5e5)
299
+ });
300
+ var skillScanFindingSchema = z.object({
301
+ categoryId: skillScanCategoryIdSchema,
302
+ categoryName: z.string(),
303
+ severity: threatLevelSchema,
304
+ reason: z.string(),
305
+ evidence: z.string().optional(),
306
+ owaspRef: z.string().optional(),
307
+ remediation: z.string().optional(),
308
+ lineNumber: z.number().optional()
309
+ });
310
+ var skillScanResultSchema = z.object({
311
+ overallSeverity: threatLevelSchema,
312
+ findings: z.array(skillScanFindingSchema),
313
+ summary: z.object({
314
+ critical: z.number(),
315
+ high: z.number(),
316
+ medium: z.number(),
317
+ low: z.number()
318
+ }),
319
+ scannedAt: z.string(),
320
+ contentLength: z.number(),
321
+ scanDurationMs: z.number()
322
+ });
323
+ var skillCleanResultSchema = z.object({
324
+ cleanedContent: z.string(),
325
+ removedCount: z.number()
326
+ });
327
+ var securityLayerStatusSchema = z.enum([
328
+ "configured",
329
+ "partial",
330
+ "unconfigured",
331
+ "error"
332
+ ]);
333
+ var securityCheckSchema = z.object({
334
+ id: z.string(),
335
+ label: z.string(),
336
+ passed: z.boolean(),
337
+ detail: z.string(),
338
+ severity: z.enum(["info", "warning", "critical"])
339
+ }).passthrough();
340
+ var securityLayerSchema = z.object({
341
+ id: z.string(),
342
+ name: z.string(),
343
+ status: securityLayerStatusSchema,
344
+ checks: z.array(securityCheckSchema),
345
+ passedCount: z.number(),
346
+ totalCount: z.number()
347
+ }).passthrough();
348
+ var securityPostureSchema = z.object({
349
+ layers: z.array(securityLayerSchema),
350
+ overallScore: z.number(),
351
+ configuredLayers: z.number(),
352
+ partialLayers: z.number(),
353
+ unconfiguredLayers: z.number(),
354
+ totalLayers: z.number(),
355
+ checkedAt: z.string()
356
+ }).passthrough();
357
+ var srtNetworkConfigSchema = z.object({
358
+ allowedDomains: z.array(z.string()),
359
+ deniedDomains: z.array(z.string()),
360
+ allowLocalBinding: z.boolean()
361
+ }).passthrough();
362
+ var srtFilesystemConfigSchema = z.object({
363
+ denyRead: z.array(z.string()),
364
+ allowWrite: z.array(z.string()),
365
+ denyWrite: z.array(z.string())
366
+ }).passthrough();
367
+ var srtSettingsSchema = z.object({
368
+ network: srtNetworkConfigSchema,
369
+ filesystem: srtFilesystemConfigSchema
370
+ }).passthrough();
371
+ var srtStatusSchema = z.object({
372
+ installed: z.boolean(),
373
+ version: z.string().nullable(),
374
+ enabled: z.boolean(),
375
+ settingsPath: z.string(),
376
+ settings: srtSettingsSchema.nullable()
377
+ }).passthrough();
272
378
 
273
379
  // src/lib/config.ts
274
380
  var DEFAULT_CONFIG = {
@@ -276,28 +382,31 @@ var DEFAULT_CONFIG = {
276
382
  port: 54335,
277
383
  autoOpenBrowser: true,
278
384
  premium: false,
279
- userId: null
385
+ userId: null,
386
+ srt: {
387
+ enabled: false
388
+ }
280
389
  };
281
390
  function ensureDataDir() {
282
- if (!fs.existsSync(SAFECLAW_DIR)) {
283
- fs.mkdirSync(SAFECLAW_DIR, { recursive: true });
391
+ if (!fs2.existsSync(SAFECLAW_DIR)) {
392
+ fs2.mkdirSync(SAFECLAW_DIR, { recursive: true });
284
393
  }
285
- if (!fs.existsSync(LOGS_DIR)) {
286
- fs.mkdirSync(LOGS_DIR, { recursive: true });
394
+ if (!fs2.existsSync(LOGS_DIR)) {
395
+ fs2.mkdirSync(LOGS_DIR, { recursive: true });
287
396
  }
288
397
  }
289
398
  function readConfig() {
290
399
  ensureDataDir();
291
- if (!fs.existsSync(CONFIG_PATH)) {
292
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
400
+ if (!fs2.existsSync(CONFIG_PATH)) {
401
+ fs2.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
293
402
  return DEFAULT_CONFIG;
294
403
  }
295
- const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
404
+ const raw = JSON.parse(fs2.readFileSync(CONFIG_PATH, "utf-8"));
296
405
  return safeClawConfigSchema.parse(raw);
297
406
  }
298
407
  function writeConfig(config) {
299
408
  ensureDataDir();
300
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
409
+ fs2.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
301
410
  }
302
411
  function resetConfig() {
303
412
  writeConfig(DEFAULT_CONFIG);
@@ -400,7 +509,7 @@ var execApprovals = sqliteTable("exec_approvals", {
400
509
  enum: ["allow-once", "allow-always", "deny"]
401
510
  }),
402
511
  decidedBy: text("decided_by", {
403
- enum: ["user", "auto-deny"]
512
+ enum: ["user", "auto-deny", "auto-approve", "access-control"]
404
513
  }),
405
514
  decidedAt: text("decided_at"),
406
515
  matchedPattern: text("matched_pattern")
@@ -423,9 +532,9 @@ import { eq as eq4, desc as desc3 } from "drizzle-orm";
423
532
 
424
533
  // src/lib/logger.ts
425
534
  import pino from "pino";
426
- import fs2 from "fs";
427
- if (!fs2.existsSync(LOGS_DIR)) {
428
- fs2.mkdirSync(LOGS_DIR, { recursive: true });
535
+ import fs3 from "fs";
536
+ if (!fs3.existsSync(LOGS_DIR)) {
537
+ fs3.mkdirSync(LOGS_DIR, { recursive: true });
429
538
  }
430
539
  function createLogger(consoleLevel = "info") {
431
540
  return pino({
@@ -454,20 +563,21 @@ function setVerbose(verbose) {
454
563
  }
455
564
 
456
565
  // src/lib/openclaw-config.ts
457
- import fs3 from "fs";
566
+ import fs4 from "fs";
458
567
  function readOpenClawConfig() {
459
- if (!fs3.existsSync(OPENCLAW_CONFIG_PATH)) {
568
+ if (!fs4.existsSync(OPENCLAW_CONFIG_PATH)) {
460
569
  return null;
461
570
  }
462
- const raw = JSON.parse(fs3.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
571
+ const raw = JSON.parse(fs4.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
463
572
  return openClawConfigSchema.parse(raw);
464
573
  }
465
574
  function writeOpenClawConfig(updates) {
466
575
  const current = readOpenClawConfig();
467
576
  if (!current) return null;
468
577
  const merged = deepMerge(current, updates);
469
- fs3.writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(merged, null, 2));
470
- return merged;
578
+ const cleaned = stripNulls(merged);
579
+ fs4.writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(cleaned, null, 2));
580
+ return cleaned;
471
581
  }
472
582
  function deepMerge(target, source) {
473
583
  const result = { ...target };
@@ -482,15 +592,30 @@ function deepMerge(target, source) {
482
592
  }
483
593
  return result;
484
594
  }
595
+ function stripNulls(obj) {
596
+ const result = {};
597
+ for (const [key, val] of Object.entries(obj)) {
598
+ if (val === null) continue;
599
+ if (typeof val === "object" && !Array.isArray(val)) {
600
+ const cleaned = stripNulls(val);
601
+ if (Object.keys(cleaned).length > 0) {
602
+ result[key] = cleaned;
603
+ }
604
+ } else {
605
+ result[key] = val;
606
+ }
607
+ }
608
+ return result;
609
+ }
485
610
 
486
611
  // src/lib/openclaw-client.ts
487
612
  import { EventEmitter } from "events";
488
613
  import crypto from "crypto";
489
- import fs4 from "fs";
614
+ import fs5 from "fs";
490
615
  import { WebSocket } from "ws";
491
616
  function readDeviceIdentity() {
492
617
  try {
493
- const raw = fs4.readFileSync(OPENCLAW_DEVICE_JSON, "utf-8");
618
+ const raw = fs5.readFileSync(OPENCLAW_DEVICE_JSON, "utf-8");
494
619
  const data = JSON.parse(raw);
495
620
  if (!data.deviceId || !data.publicKeyPem || !data.privateKeyPem) return null;
496
621
  return {
@@ -1008,7 +1133,7 @@ var OpenClawClient = class extends EventEmitter {
1008
1133
 
1009
1134
  // src/services/session-watcher.ts
1010
1135
  import { EventEmitter as EventEmitter2 } from "events";
1011
- import fs5 from "fs";
1136
+ import fs6 from "fs";
1012
1137
  import path2 from "path";
1013
1138
  var MAX_CONTENT_PREVIEW = 10 * 1024;
1014
1139
  var SessionWatcher = class extends EventEmitter2 {
@@ -1027,7 +1152,7 @@ var SessionWatcher = class extends EventEmitter2 {
1027
1152
  start() {
1028
1153
  if (this.destroyed) return;
1029
1154
  const agentsDir = path2.join(OPENCLAW_DIR, "agents");
1030
- if (!fs5.existsSync(agentsDir)) {
1155
+ if (!fs6.existsSync(agentsDir)) {
1031
1156
  logger.debug("OpenClaw agents directory not found, will retry");
1032
1157
  }
1033
1158
  this.scanForSessions();
@@ -1035,8 +1160,8 @@ var SessionWatcher = class extends EventEmitter2 {
1035
1160
  if (!this.destroyed) this.scanForSessions();
1036
1161
  }, 1e4);
1037
1162
  try {
1038
- if (fs5.existsSync(agentsDir)) {
1039
- this.agentsDirWatcher = fs5.watch(agentsDir, { recursive: true }, () => {
1163
+ if (fs6.existsSync(agentsDir)) {
1164
+ this.agentsDirWatcher = fs6.watch(agentsDir, { recursive: true }, () => {
1040
1165
  if (!this.destroyed) this.scanForSessions();
1041
1166
  });
1042
1167
  }
@@ -1064,10 +1189,10 @@ var SessionWatcher = class extends EventEmitter2 {
1064
1189
  }
1065
1190
  scanForSessions() {
1066
1191
  const agentsDir = path2.join(OPENCLAW_DIR, "agents");
1067
- if (!fs5.existsSync(agentsDir)) return;
1192
+ if (!fs6.existsSync(agentsDir)) return;
1068
1193
  try {
1069
- const agentNames = fs5.readdirSync(agentsDir).filter((name) => {
1070
- const stat = fs5.statSync(path2.join(agentsDir, name));
1194
+ const agentNames = fs6.readdirSync(agentsDir).filter((name) => {
1195
+ const stat = fs6.statSync(path2.join(agentsDir, name));
1071
1196
  return stat.isDirectory();
1072
1197
  });
1073
1198
  for (const agentName of agentNames) {
@@ -1079,11 +1204,11 @@ var SessionWatcher = class extends EventEmitter2 {
1079
1204
  }
1080
1205
  discoverSessionFiles(agentName) {
1081
1206
  const sessionsDir = path2.join(OPENCLAW_DIR, "agents", agentName, "sessions");
1082
- if (!fs5.existsSync(sessionsDir)) return;
1207
+ if (!fs6.existsSync(sessionsDir)) return;
1083
1208
  const sessionsJsonPath = path2.join(sessionsDir, "sessions.json");
1084
- if (fs5.existsSync(sessionsJsonPath)) {
1209
+ if (fs6.existsSync(sessionsJsonPath)) {
1085
1210
  try {
1086
- const raw = fs5.readFileSync(sessionsJsonPath, "utf-8");
1211
+ const raw = fs6.readFileSync(sessionsJsonPath, "utf-8");
1087
1212
  const sessionsData = JSON.parse(raw);
1088
1213
  const sessions2 = sessionsData.sessions ?? [];
1089
1214
  for (const session of sessions2) {
@@ -1095,7 +1220,7 @@ var SessionWatcher = class extends EventEmitter2 {
1095
1220
  }
1096
1221
  }
1097
1222
  try {
1098
- const files = fs5.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
1223
+ const files = fs6.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
1099
1224
  for (const file of files) {
1100
1225
  const filePath = path2.join(sessionsDir, file);
1101
1226
  const sessionId = path2.basename(file, ".jsonl");
@@ -1106,8 +1231,8 @@ var SessionWatcher = class extends EventEmitter2 {
1106
1231
  }
1107
1232
  watchSessionFile(filePath, sessionId, agentName) {
1108
1233
  if (this.watchedFiles.has(filePath)) return;
1109
- if (!fs5.existsSync(filePath)) return;
1110
- const stat = fs5.statSync(filePath);
1234
+ if (!fs6.existsSync(filePath)) return;
1235
+ const stat = fs6.statSync(filePath);
1111
1236
  const watched = {
1112
1237
  path: filePath,
1113
1238
  position: stat.size,
@@ -1117,7 +1242,7 @@ var SessionWatcher = class extends EventEmitter2 {
1117
1242
  agentName
1118
1243
  };
1119
1244
  try {
1120
- watched.watcher = fs5.watch(filePath, () => {
1245
+ watched.watcher = fs6.watch(filePath, () => {
1121
1246
  if (!this.destroyed) {
1122
1247
  this.readNewEntries(watched);
1123
1248
  }
@@ -1131,13 +1256,13 @@ var SessionWatcher = class extends EventEmitter2 {
1131
1256
  }
1132
1257
  readNewEntries(watched) {
1133
1258
  try {
1134
- const stat = fs5.statSync(watched.path);
1259
+ const stat = fs6.statSync(watched.path);
1135
1260
  if (stat.size <= watched.position) return;
1136
- const fd = fs5.openSync(watched.path, "r");
1261
+ const fd = fs6.openSync(watched.path, "r");
1137
1262
  const bufferSize = stat.size - watched.position;
1138
1263
  const buffer = Buffer.alloc(bufferSize);
1139
- fs5.readSync(fd, buffer, 0, bufferSize, watched.position);
1140
- fs5.closeSync(fd);
1264
+ fs6.readSync(fd, buffer, 0, bufferSize, watched.position);
1265
+ fs6.closeSync(fd);
1141
1266
  watched.position = stat.size;
1142
1267
  const text2 = buffer.toString("utf-8");
1143
1268
  const lines = text2.split("\n").filter((l) => l.trim());
@@ -1308,8 +1433,228 @@ var SessionWatcher = class extends EventEmitter2 {
1308
1433
  };
1309
1434
 
1310
1435
  // src/services/exec-approval-service.ts
1311
- import { eq, desc, sql as sql2 } from "drizzle-orm";
1436
+ import { eq as eq2, desc, sql as sql3 } from "drizzle-orm";
1312
1437
  import path3 from "path";
1438
+
1439
+ // src/services/access-control.ts
1440
+ import { eq, and, sql as sql2 } from "drizzle-orm";
1441
+ var TOOL_GROUP_MAP = {
1442
+ filesystem: "group:fs",
1443
+ system_commands: "group:runtime",
1444
+ network: "group:web"
1445
+ };
1446
+ function deriveMcpServerStates(config, denyList) {
1447
+ const pluginEntries = config.plugins?.entries ?? {};
1448
+ return Object.keys(pluginEntries).map((name) => {
1449
+ const pluginEnabled = pluginEntries[name].enabled !== false;
1450
+ const toolsDenyBlocked = denyList.includes(`mcp__${name}`);
1451
+ return {
1452
+ name,
1453
+ pluginEnabled,
1454
+ toolsDenyBlocked,
1455
+ effectivelyEnabled: pluginEnabled && !toolsDenyBlocked
1456
+ };
1457
+ });
1458
+ }
1459
+ function deriveAccessState() {
1460
+ const config = readOpenClawConfig();
1461
+ if (!config) {
1462
+ return {
1463
+ toggles: [
1464
+ { category: "filesystem", enabled: true },
1465
+ { category: "mcp_servers", enabled: true },
1466
+ { category: "network", enabled: true },
1467
+ { category: "system_commands", enabled: true }
1468
+ ],
1469
+ mcpServers: [],
1470
+ openclawConfigAvailable: false
1471
+ };
1472
+ }
1473
+ const denyList = config.tools?.deny ?? [];
1474
+ const filesystemEnabled = !denyList.includes("group:fs");
1475
+ const systemCommandsEnabled = !denyList.includes("group:runtime");
1476
+ const networkEnabled = !denyList.includes("group:web") && config.browser?.enabled !== false;
1477
+ const pluginEntries = config.plugins?.entries ?? {};
1478
+ const pluginNames = Object.keys(pluginEntries);
1479
+ const mcpEnabled = pluginNames.length === 0 || pluginNames.some((name) => pluginEntries[name].enabled !== false);
1480
+ const toggles = [
1481
+ { category: "filesystem", enabled: filesystemEnabled },
1482
+ { category: "mcp_servers", enabled: mcpEnabled },
1483
+ { category: "network", enabled: networkEnabled },
1484
+ { category: "system_commands", enabled: systemCommandsEnabled }
1485
+ ];
1486
+ const mcpServers = deriveMcpServerStates(config, denyList);
1487
+ return { toggles, mcpServers, openclawConfigAvailable: true };
1488
+ }
1489
+ async function applyAccessToggle(category, enabled) {
1490
+ const config = readOpenClawConfig();
1491
+ if (!config) {
1492
+ throw new Error("OpenClaw config not found");
1493
+ }
1494
+ if (category === "mcp_servers") {
1495
+ await applyMcpToggle(config, enabled);
1496
+ } else if (category === "network") {
1497
+ applyNetworkToggle(config, enabled);
1498
+ } else {
1499
+ applyToolGroupToggle(config, category, enabled);
1500
+ }
1501
+ await updateAuditDb(category, enabled);
1502
+ return deriveAccessState();
1503
+ }
1504
+ async function applyMcpServerToggle(serverName, enabled) {
1505
+ const config = readOpenClawConfig();
1506
+ if (!config) {
1507
+ throw new Error("OpenClaw config not found");
1508
+ }
1509
+ const denyPattern = `mcp__${serverName}`;
1510
+ const currentDeny = [...config.tools?.deny ?? []];
1511
+ if (enabled) {
1512
+ const filtered = currentDeny.filter((entry) => entry !== denyPattern);
1513
+ writeOpenClawConfig({ tools: { deny: filtered } });
1514
+ } else {
1515
+ if (!currentDeny.includes(denyPattern)) {
1516
+ currentDeny.push(denyPattern);
1517
+ }
1518
+ writeOpenClawConfig({ tools: { deny: currentDeny } });
1519
+ }
1520
+ await updateAuditDb(`mcp_server:${serverName}`, enabled);
1521
+ return deriveAccessState();
1522
+ }
1523
+ function applyToolGroupToggle(config, category, enabled) {
1524
+ const groupName = TOOL_GROUP_MAP[category];
1525
+ if (!groupName) return;
1526
+ const currentDeny = [...config.tools?.deny ?? []];
1527
+ if (enabled) {
1528
+ const filtered = currentDeny.filter((entry) => entry !== groupName);
1529
+ writeOpenClawConfig({ tools: { deny: filtered } });
1530
+ } else {
1531
+ if (!currentDeny.includes(groupName)) {
1532
+ currentDeny.push(groupName);
1533
+ }
1534
+ writeOpenClawConfig({ tools: { deny: currentDeny } });
1535
+ }
1536
+ }
1537
+ function applyNetworkToggle(config, enabled) {
1538
+ const currentDeny = [...config.tools?.deny ?? []];
1539
+ const groupName = "group:web";
1540
+ if (enabled) {
1541
+ const filtered = currentDeny.filter((entry) => entry !== groupName);
1542
+ writeOpenClawConfig({
1543
+ tools: { deny: filtered },
1544
+ browser: { enabled: true }
1545
+ });
1546
+ } else {
1547
+ if (!currentDeny.includes(groupName)) {
1548
+ currentDeny.push(groupName);
1549
+ }
1550
+ writeOpenClawConfig({
1551
+ tools: { deny: currentDeny },
1552
+ browser: { enabled: false }
1553
+ });
1554
+ }
1555
+ }
1556
+ async function applyMcpToggle(config, enabled) {
1557
+ const pluginEntries = config.plugins?.entries ?? {};
1558
+ const pluginNames = Object.keys(pluginEntries);
1559
+ if (pluginNames.length === 0) return;
1560
+ const currentDeny = [...config.tools?.deny ?? []];
1561
+ if (!enabled) {
1562
+ const stateMap = {};
1563
+ for (const name of pluginNames) {
1564
+ stateMap[name] = pluginEntries[name].enabled !== false;
1565
+ }
1566
+ await savePreviousPluginState(stateMap);
1567
+ const disabledEntries = {};
1568
+ for (const name of pluginNames) {
1569
+ disabledEntries[name] = { enabled: false };
1570
+ }
1571
+ for (const name of pluginNames) {
1572
+ const denyPattern = `mcp__${name}`;
1573
+ if (!currentDeny.includes(denyPattern)) {
1574
+ currentDeny.push(denyPattern);
1575
+ }
1576
+ }
1577
+ writeOpenClawConfig({
1578
+ plugins: { entries: disabledEntries },
1579
+ tools: { deny: currentDeny }
1580
+ });
1581
+ } else {
1582
+ const previousState = await loadPreviousPluginState();
1583
+ const restoredEntries = {};
1584
+ for (const name of pluginNames) {
1585
+ restoredEntries[name] = {
1586
+ enabled: previousState?.[name] ?? true
1587
+ };
1588
+ }
1589
+ const mcpDenyPatterns = new Set(pluginNames.map((n) => `mcp__${n}`));
1590
+ const filteredDeny = currentDeny.filter(
1591
+ (entry) => !mcpDenyPatterns.has(entry)
1592
+ );
1593
+ writeOpenClawConfig({
1594
+ plugins: { entries: restoredEntries },
1595
+ tools: { deny: filteredDeny }
1596
+ });
1597
+ }
1598
+ }
1599
+ async function savePreviousPluginState(stateMap) {
1600
+ const db = getDb();
1601
+ const existing = await db.select().from(schema_exports.accessConfig).where(
1602
+ and(
1603
+ eq(schema_exports.accessConfig.category, "mcp_servers"),
1604
+ eq(schema_exports.accessConfig.key, "previous_plugin_state")
1605
+ )
1606
+ );
1607
+ if (existing.length > 0) {
1608
+ await db.update(schema_exports.accessConfig).set({
1609
+ value: JSON.stringify(stateMap),
1610
+ updatedAt: sql2`datetime('now')`
1611
+ }).where(
1612
+ and(
1613
+ eq(schema_exports.accessConfig.category, "mcp_servers"),
1614
+ eq(schema_exports.accessConfig.key, "previous_plugin_state")
1615
+ )
1616
+ );
1617
+ } else {
1618
+ await db.insert(schema_exports.accessConfig).values({
1619
+ category: "mcp_servers",
1620
+ key: "previous_plugin_state",
1621
+ value: JSON.stringify(stateMap)
1622
+ });
1623
+ }
1624
+ }
1625
+ async function loadPreviousPluginState() {
1626
+ const db = getDb();
1627
+ const rows = await db.select().from(schema_exports.accessConfig).where(
1628
+ and(
1629
+ eq(schema_exports.accessConfig.category, "mcp_servers"),
1630
+ eq(schema_exports.accessConfig.key, "previous_plugin_state")
1631
+ )
1632
+ );
1633
+ if (rows.length === 0) return null;
1634
+ try {
1635
+ return JSON.parse(rows[0].value);
1636
+ } catch {
1637
+ return null;
1638
+ }
1639
+ }
1640
+ async function updateAuditDb(category, enabled) {
1641
+ const db = getDb();
1642
+ try {
1643
+ await db.update(schema_exports.accessConfig).set({
1644
+ value: enabled ? "true" : "false",
1645
+ updatedAt: sql2`datetime('now')`
1646
+ }).where(
1647
+ and(
1648
+ eq(schema_exports.accessConfig.category, category),
1649
+ eq(schema_exports.accessConfig.key, "enabled")
1650
+ )
1651
+ );
1652
+ } catch (err) {
1653
+ logger.warn({ err, category, enabled }, "Failed to update audit DB");
1654
+ }
1655
+ }
1656
+
1657
+ // src/services/exec-approval-service.ts
1313
1658
  var DEFAULT_TIMEOUT_MS = 6e5;
1314
1659
  var instance = null;
1315
1660
  function matchesPattern(command, pattern) {
@@ -1317,6 +1662,37 @@ function matchesPattern(command, pattern) {
1317
1662
  const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
1318
1663
  return new RegExp(regexStr, "i").test(command);
1319
1664
  }
1665
+ var NETWORK_BINARIES = /* @__PURE__ */ new Set([
1666
+ "curl",
1667
+ "wget",
1668
+ "httpie",
1669
+ "http",
1670
+ "ssh",
1671
+ "scp",
1672
+ "sftp",
1673
+ "nc",
1674
+ "ncat",
1675
+ "netcat",
1676
+ "dig",
1677
+ "nslookup",
1678
+ "host",
1679
+ "ping",
1680
+ "traceroute",
1681
+ "tracepath",
1682
+ "mtr",
1683
+ "telnet",
1684
+ "ftp",
1685
+ "lftp",
1686
+ "rsync",
1687
+ "socat",
1688
+ "nmap"
1689
+ ]);
1690
+ function isNetworkCommand(command) {
1691
+ const firstToken = command.trim().split(/\s+/)[0];
1692
+ if (!firstToken) return false;
1693
+ const basename = firstToken.includes("/") ? firstToken.split("/").pop() : firstToken;
1694
+ return NETWORK_BINARIES.has(basename.toLowerCase());
1695
+ }
1320
1696
  var ExecApprovalService = class {
1321
1697
  client;
1322
1698
  io;
@@ -1357,11 +1733,62 @@ var ExecApprovalService = class {
1357
1733
  handleRequest(request) {
1358
1734
  const matchedPattern = this.findMatchingPattern(request.command);
1359
1735
  if (!matchedPattern) {
1360
- this.resolveToGateway(request.id, "allow-once");
1361
- logger.debug(
1362
- { command: request.command },
1363
- "Command auto-approved (not restricted)"
1364
- );
1736
+ if (isNetworkCommand(request.command)) {
1737
+ try {
1738
+ const accessState = deriveAccessState();
1739
+ const networkToggle = accessState.toggles.find(
1740
+ (t) => t.category === "network"
1741
+ );
1742
+ if (networkToggle && !networkToggle.enabled) {
1743
+ this.resolveToGateway(request.id, "deny");
1744
+ const now3 = /* @__PURE__ */ new Date();
1745
+ const entry3 = {
1746
+ id: request.id,
1747
+ command: request.command,
1748
+ cwd: request.cwd,
1749
+ security: request.security,
1750
+ sessionKey: request.sessionKey,
1751
+ requestedAt: now3.toISOString(),
1752
+ expiresAt: now3.toISOString(),
1753
+ decision: "deny",
1754
+ decidedBy: "access-control",
1755
+ decidedAt: now3.toISOString()
1756
+ };
1757
+ this.persistApproval(entry3, null);
1758
+ this.io.emit("safeclaw:execApprovalResolved", entry3);
1759
+ logger.info(
1760
+ { command: request.command },
1761
+ "Network command auto-denied (network toggle OFF)"
1762
+ );
1763
+ return;
1764
+ }
1765
+ } catch {
1766
+ logger.debug(
1767
+ { command: request.command },
1768
+ "Could not read access state for network check, falling through"
1769
+ );
1770
+ }
1771
+ }
1772
+ this.resolveToGateway(request.id, "allow-once");
1773
+ const now2 = /* @__PURE__ */ new Date();
1774
+ const entry2 = {
1775
+ id: request.id,
1776
+ command: request.command,
1777
+ cwd: request.cwd,
1778
+ security: request.security,
1779
+ sessionKey: request.sessionKey,
1780
+ requestedAt: now2.toISOString(),
1781
+ expiresAt: now2.toISOString(),
1782
+ decision: "allow-once",
1783
+ decidedBy: "auto-approve",
1784
+ decidedAt: now2.toISOString()
1785
+ };
1786
+ this.persistApproval(entry2, null);
1787
+ this.io.emit("safeclaw:execApprovalResolved", entry2);
1788
+ logger.debug(
1789
+ { command: request.command },
1790
+ "Command auto-approved (not restricted)"
1791
+ );
1365
1792
  return;
1366
1793
  }
1367
1794
  const now = /* @__PURE__ */ new Date();
@@ -1476,7 +1903,7 @@ var ExecApprovalService = class {
1476
1903
  decision: entry.decision,
1477
1904
  decidedBy: entry.decidedBy,
1478
1905
  decidedAt: entry.decidedAt
1479
- }).where(eq(schema_exports.execApprovals.id, entry.id)).run();
1906
+ }).where(eq2(schema_exports.execApprovals.id, entry.id)).run();
1480
1907
  } catch (err) {
1481
1908
  logger.error(
1482
1909
  { err, id: entry.id },
@@ -1525,7 +1952,7 @@ var ExecApprovalService = class {
1525
1952
  );
1526
1953
  try {
1527
1954
  const db = getDb();
1528
- db.delete(schema_exports.restrictedPatterns).where(eq(schema_exports.restrictedPatterns.pattern, pattern)).run();
1955
+ db.delete(schema_exports.restrictedPatterns).where(eq2(schema_exports.restrictedPatterns.pattern, pattern)).run();
1529
1956
  } catch (err) {
1530
1957
  logger.error({ err, pattern }, "Failed to remove restricted pattern from database");
1531
1958
  }
@@ -1628,7 +2055,7 @@ var ExecApprovalService = class {
1628
2055
  getHistory(limit = 50) {
1629
2056
  try {
1630
2057
  const db = getDb();
1631
- const rows = db.select().from(schema_exports.execApprovals).where(sql2`${schema_exports.execApprovals.decision} IS NOT NULL`).orderBy(desc(schema_exports.execApprovals.decidedAt)).limit(limit).all();
2058
+ const rows = db.select().from(schema_exports.execApprovals).where(sql3`${schema_exports.execApprovals.decision} IS NOT NULL`).orderBy(desc(schema_exports.execApprovals.decidedAt)).limit(limit).all();
1632
2059
  return rows.map((r) => ({
1633
2060
  id: r.id,
1634
2061
  command: r.command,
@@ -1691,11 +2118,11 @@ function createExecApprovalService(client, io2, timeoutMs) {
1691
2118
  }
1692
2119
 
1693
2120
  // src/lib/exec-approvals-config.ts
1694
- import fs6 from "fs";
2121
+ import fs7 from "fs";
1695
2122
  function readExecApprovalsConfig() {
1696
2123
  try {
1697
- if (!fs6.existsSync(OPENCLAW_EXEC_APPROVALS_PATH)) return null;
1698
- const raw = fs6.readFileSync(OPENCLAW_EXEC_APPROVALS_PATH, "utf-8");
2124
+ if (!fs7.existsSync(OPENCLAW_EXEC_APPROVALS_PATH)) return null;
2125
+ const raw = fs7.readFileSync(OPENCLAW_EXEC_APPROVALS_PATH, "utf-8");
1699
2126
  return JSON.parse(raw);
1700
2127
  } catch (err) {
1701
2128
  logger.warn({ err }, "Failed to read exec-approvals.json");
@@ -1703,7 +2130,7 @@ function readExecApprovalsConfig() {
1703
2130
  }
1704
2131
  }
1705
2132
  function writeExecApprovalsConfig(config) {
1706
- fs6.writeFileSync(
2133
+ fs7.writeFileSync(
1707
2134
  OPENCLAW_EXEC_APPROVALS_PATH,
1708
2135
  JSON.stringify(config, null, 2)
1709
2136
  );
@@ -1789,7 +2216,7 @@ var SECRET_PATTERNS = [
1789
2216
  function scanForSecrets(content) {
1790
2217
  if (!content) return { types: [], maxSeverity: "NONE" };
1791
2218
  const found = /* @__PURE__ */ new Set();
1792
- let maxSeverity = "NONE";
2219
+ let maxSeverity2 = "NONE";
1793
2220
  const severityOrder = {
1794
2221
  NONE: 0,
1795
2222
  LOW: 1,
@@ -1800,12 +2227,12 @@ function scanForSecrets(content) {
1800
2227
  for (const { pattern, type, severity } of SECRET_PATTERNS) {
1801
2228
  if (pattern.test(content)) {
1802
2229
  found.add(type);
1803
- if (severityOrder[severity] > severityOrder[maxSeverity]) {
1804
- maxSeverity = severity;
2230
+ if (severityOrder[severity] > severityOrder[maxSeverity2]) {
2231
+ maxSeverity2 = severity;
1805
2232
  }
1806
2233
  }
1807
2234
  }
1808
- return { types: Array.from(found), maxSeverity };
2235
+ return { types: Array.from(found), maxSeverity: maxSeverity2 };
1809
2236
  }
1810
2237
 
1811
2238
  // src/lib/threat-patterns.ts
@@ -2442,7 +2869,7 @@ function analyzeActivityThreat(activityType, detail, targetPath, contentPreview,
2442
2869
  }
2443
2870
 
2444
2871
  // src/services/openclaw-monitor.ts
2445
- import { eq as eq2, ne, desc as desc2, and, sql as sql3 } from "drizzle-orm";
2872
+ import { eq as eq3, ne, desc as desc2, and as and2, sql as sql4 } from "drizzle-orm";
2446
2873
  var instance2 = null;
2447
2874
  var OpenClawMonitor = class {
2448
2875
  client;
@@ -2507,7 +2934,7 @@ var OpenClawMonitor = class {
2507
2934
  parsed.toolName
2508
2935
  );
2509
2936
  const db = getDb();
2510
- const existingSession = await db.select().from(schema_exports.openclawSessions).where(eq2(schema_exports.openclawSessions.id, parsed.openclawSessionId)).limit(1);
2937
+ const existingSession = await db.select().from(schema_exports.openclawSessions).where(eq3(schema_exports.openclawSessions.id, parsed.openclawSessionId)).limit(1);
2511
2938
  if (existingSession.length === 0) {
2512
2939
  await db.insert(schema_exports.openclawSessions).values({
2513
2940
  id: parsed.openclawSessionId,
@@ -2596,7 +3023,7 @@ var OpenClawMonitor = class {
2596
3023
  }
2597
3024
  async handleSessionStart(sessionId, model) {
2598
3025
  const db = getDb();
2599
- const existing = await db.select().from(schema_exports.openclawSessions).where(eq2(schema_exports.openclawSessions.id, sessionId)).limit(1);
3026
+ const existing = await db.select().from(schema_exports.openclawSessions).where(eq3(schema_exports.openclawSessions.id, sessionId)).limit(1);
2600
3027
  if (existing.length === 0) {
2601
3028
  await db.insert(schema_exports.openclawSessions).values({
2602
3029
  id: sessionId,
@@ -2604,7 +3031,7 @@ var OpenClawMonitor = class {
2604
3031
  model: model ?? null
2605
3032
  });
2606
3033
  } else {
2607
- await db.update(schema_exports.openclawSessions).set({ status: "ACTIVE", model: model ?? null }).where(eq2(schema_exports.openclawSessions.id, sessionId));
3034
+ await db.update(schema_exports.openclawSessions).set({ status: "ACTIVE", model: model ?? null }).where(eq3(schema_exports.openclawSessions.id, sessionId));
2608
3035
  }
2609
3036
  const session = await this.buildSessionPayload(sessionId);
2610
3037
  if (session) {
@@ -2615,8 +3042,8 @@ var OpenClawMonitor = class {
2615
3042
  const db = getDb();
2616
3043
  await db.update(schema_exports.openclawSessions).set({
2617
3044
  status: "ENDED",
2618
- endedAt: sql3`datetime('now')`
2619
- }).where(eq2(schema_exports.openclawSessions.id, sessionId));
3045
+ endedAt: sql4`datetime('now')`
3046
+ }).where(eq3(schema_exports.openclawSessions.id, sessionId));
2620
3047
  const session = await this.buildSessionPayload(sessionId);
2621
3048
  if (session) {
2622
3049
  this.io.emit("safeclaw:openclawSessionUpdate", session);
@@ -2624,9 +3051,9 @@ var OpenClawMonitor = class {
2624
3051
  }
2625
3052
  async buildSessionPayload(sessionId) {
2626
3053
  const db = getDb();
2627
- const [row] = await db.select().from(schema_exports.openclawSessions).where(eq2(schema_exports.openclawSessions.id, sessionId));
3054
+ const [row] = await db.select().from(schema_exports.openclawSessions).where(eq3(schema_exports.openclawSessions.id, sessionId));
2628
3055
  if (!row) return null;
2629
- const activities = await db.select().from(schema_exports.agentActivities).where(eq2(schema_exports.agentActivities.openclawSessionId, sessionId));
3056
+ const activities = await db.select().from(schema_exports.agentActivities).where(eq3(schema_exports.agentActivities.openclawSessionId, sessionId));
2630
3057
  const threatSummary = {
2631
3058
  NONE: 0,
2632
3059
  LOW: 0,
@@ -2688,8 +3115,8 @@ var OpenClawMonitor = class {
2688
3115
  await db.update(schema_exports.agentActivities).set({
2689
3116
  resolved: resolved ? 1 : 0,
2690
3117
  resolvedAt: resolved ? (/* @__PURE__ */ new Date()).toISOString() : null
2691
- }).where(eq2(schema_exports.agentActivities.id, activityId));
2692
- const [row] = await db.select().from(schema_exports.agentActivities).where(eq2(schema_exports.agentActivities.id, activityId));
3118
+ }).where(eq3(schema_exports.agentActivities.id, activityId));
3119
+ const [row] = await db.select().from(schema_exports.agentActivities).where(eq3(schema_exports.agentActivities.id, activityId));
2693
3120
  if (!row) return null;
2694
3121
  return this.mapRowToActivity(row);
2695
3122
  }
@@ -2697,19 +3124,19 @@ var OpenClawMonitor = class {
2697
3124
  const db = getDb();
2698
3125
  const conditions = [ne(schema_exports.agentActivities.threatLevel, "NONE")];
2699
3126
  if (severity) {
2700
- conditions.push(eq2(schema_exports.agentActivities.threatLevel, severity));
3127
+ conditions.push(eq3(schema_exports.agentActivities.threatLevel, severity));
2701
3128
  }
2702
3129
  if (resolved !== void 0) {
2703
- conditions.push(eq2(schema_exports.agentActivities.resolved, resolved ? 1 : 0));
3130
+ conditions.push(eq3(schema_exports.agentActivities.resolved, resolved ? 1 : 0));
2704
3131
  }
2705
- const rows = await db.select().from(schema_exports.agentActivities).where(and(...conditions)).orderBy(desc2(schema_exports.agentActivities.id)).limit(limit);
3132
+ const rows = await db.select().from(schema_exports.agentActivities).where(and2(...conditions)).orderBy(desc2(schema_exports.agentActivities.id)).limit(limit);
2706
3133
  return rows.map((r) => this.mapRowToActivity(r));
2707
3134
  }
2708
3135
  async getActivities(sessionId, limit = 50) {
2709
3136
  const db = getDb();
2710
3137
  let rows;
2711
3138
  if (sessionId) {
2712
- rows = await db.select().from(schema_exports.agentActivities).where(eq2(schema_exports.agentActivities.openclawSessionId, sessionId)).orderBy(desc2(schema_exports.agentActivities.id)).limit(limit);
3139
+ rows = await db.select().from(schema_exports.agentActivities).where(eq3(schema_exports.agentActivities.openclawSessionId, sessionId)).orderBy(desc2(schema_exports.agentActivities.id)).limit(limit);
2713
3140
  } else {
2714
3141
  rows = await db.select().from(schema_exports.agentActivities).orderBy(desc2(schema_exports.agentActivities.id)).limit(limit);
2715
3142
  }
@@ -2764,222 +3191,130 @@ function getOpenClawMonitor() {
2764
3191
  return instance2;
2765
3192
  }
2766
3193
 
2767
- // src/services/access-control.ts
2768
- import { eq as eq3, and as and2, sql as sql4 } from "drizzle-orm";
2769
- var TOOL_GROUP_MAP = {
2770
- filesystem: "group:fs",
2771
- system_commands: "group:runtime",
2772
- network: "group:web"
3194
+ // src/lib/srt-config.ts
3195
+ import fs8 from "fs";
3196
+ import { execSync } from "child_process";
3197
+ var DEFAULT_SRT_SETTINGS = {
3198
+ network: {
3199
+ allowedDomains: [],
3200
+ deniedDomains: [],
3201
+ allowLocalBinding: false
3202
+ },
3203
+ filesystem: {
3204
+ denyRead: [],
3205
+ allowWrite: [],
3206
+ denyWrite: []
3207
+ }
2773
3208
  };
2774
- function deriveMcpServerStates(config, denyList) {
2775
- const pluginEntries = config.plugins?.entries ?? {};
2776
- return Object.keys(pluginEntries).map((name) => {
2777
- const pluginEnabled = pluginEntries[name].enabled !== false;
2778
- const toolsDenyBlocked = denyList.includes(`mcp__${name}`);
2779
- return {
2780
- name,
2781
- pluginEnabled,
2782
- toolsDenyBlocked,
2783
- effectivelyEnabled: pluginEnabled && !toolsDenyBlocked
2784
- };
2785
- });
3209
+ var srtInstalledCache = null;
3210
+ function getSrtSettingsPath() {
3211
+ const config = readConfig();
3212
+ return config.srt?.settingsPath ?? SRT_SETTINGS_PATH;
2786
3213
  }
2787
- function deriveAccessState() {
2788
- const config = readOpenClawConfig();
2789
- if (!config) {
2790
- return {
2791
- toggles: [
2792
- { category: "filesystem", enabled: true },
2793
- { category: "mcp_servers", enabled: true },
2794
- { category: "network", enabled: true },
2795
- { category: "system_commands", enabled: true }
2796
- ],
2797
- mcpServers: [],
2798
- openclawConfigAvailable: false
2799
- };
3214
+ function isSrtInstalled() {
3215
+ if (srtInstalledCache?.installed) return srtInstalledCache;
3216
+ try {
3217
+ const output = execSync("srt --version", { timeout: 3e3, encoding: "utf-8" }).trim();
3218
+ const version = output.replace(/^srt\s*/i, "").trim() || output;
3219
+ srtInstalledCache = { installed: true, version };
3220
+ return srtInstalledCache;
3221
+ } catch {
3222
+ return { installed: false, version: null };
2800
3223
  }
2801
- const denyList = config.tools?.deny ?? [];
2802
- const filesystemEnabled = !denyList.includes("group:fs");
2803
- const systemCommandsEnabled = !denyList.includes("group:runtime");
2804
- const networkEnabled = !denyList.includes("group:web") && config.browser?.enabled !== false;
2805
- const pluginEntries = config.plugins?.entries ?? {};
2806
- const pluginNames = Object.keys(pluginEntries);
2807
- const mcpEnabled = pluginNames.length === 0 || pluginNames.some((name) => pluginEntries[name].enabled !== false);
2808
- const toggles = [
2809
- { category: "filesystem", enabled: filesystemEnabled },
2810
- { category: "mcp_servers", enabled: mcpEnabled },
2811
- { category: "network", enabled: networkEnabled },
2812
- { category: "system_commands", enabled: systemCommandsEnabled }
2813
- ];
2814
- const mcpServers = deriveMcpServerStates(config, denyList);
2815
- return { toggles, mcpServers, openclawConfigAvailable: true };
2816
3224
  }
2817
- async function applyAccessToggle(category, enabled) {
2818
- const config = readOpenClawConfig();
2819
- if (!config) {
2820
- throw new Error("OpenClaw config not found");
2821
- }
2822
- if (category === "mcp_servers") {
2823
- await applyMcpToggle(config, enabled);
2824
- } else if (category === "network") {
2825
- applyNetworkToggle(config, enabled);
2826
- } else {
2827
- applyToolGroupToggle(config, category, enabled);
3225
+ function readSrtSettings() {
3226
+ const settingsPath = getSrtSettingsPath();
3227
+ if (!fs8.existsSync(settingsPath)) return null;
3228
+ try {
3229
+ const raw = JSON.parse(fs8.readFileSync(settingsPath, "utf-8"));
3230
+ return srtSettingsSchema.parse(raw);
3231
+ } catch {
3232
+ return null;
2828
3233
  }
2829
- await updateAuditDb(category, enabled);
2830
- return deriveAccessState();
2831
3234
  }
2832
- async function applyMcpServerToggle(serverName, enabled) {
2833
- const config = readOpenClawConfig();
2834
- if (!config) {
2835
- throw new Error("OpenClaw config not found");
2836
- }
2837
- const denyPattern = `mcp__${serverName}`;
2838
- const currentDeny = [...config.tools?.deny ?? []];
2839
- if (enabled) {
2840
- const filtered = currentDeny.filter((entry) => entry !== denyPattern);
2841
- writeOpenClawConfig({ tools: { deny: filtered } });
2842
- } else {
2843
- if (!currentDeny.includes(denyPattern)) {
2844
- currentDeny.push(denyPattern);
2845
- }
2846
- writeOpenClawConfig({ tools: { deny: currentDeny } });
2847
- }
2848
- await updateAuditDb(`mcp_server:${serverName}`, enabled);
2849
- return deriveAccessState();
3235
+ function writeSrtSettings(settings) {
3236
+ const settingsPath = getSrtSettingsPath();
3237
+ fs8.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
2850
3238
  }
2851
- function applyToolGroupToggle(config, category, enabled) {
2852
- const groupName = TOOL_GROUP_MAP[category];
2853
- if (!groupName) return;
2854
- const currentDeny = [...config.tools?.deny ?? []];
2855
- if (enabled) {
2856
- const filtered = currentDeny.filter((entry) => entry !== groupName);
2857
- writeOpenClawConfig({ tools: { deny: filtered } });
2858
- } else {
2859
- if (!currentDeny.includes(groupName)) {
2860
- currentDeny.push(groupName);
2861
- }
2862
- writeOpenClawConfig({ tools: { deny: currentDeny } });
2863
- }
3239
+ function ensureSrtSettings() {
3240
+ const existing = readSrtSettings();
3241
+ if (existing) return existing;
3242
+ writeSrtSettings(DEFAULT_SRT_SETTINGS);
3243
+ return DEFAULT_SRT_SETTINGS;
2864
3244
  }
2865
- function applyNetworkToggle(config, enabled) {
2866
- const currentDeny = [...config.tools?.deny ?? []];
2867
- const groupName = "group:web";
3245
+ function getSrtStatus() {
3246
+ const { installed, version } = isSrtInstalled();
3247
+ const config = readConfig();
3248
+ const enabled = config.srt?.enabled ?? false;
3249
+ const settingsPath = getSrtSettingsPath();
3250
+ const settings = readSrtSettings();
3251
+ return { installed, version, enabled, settingsPath, settings };
3252
+ }
3253
+ function toggleSrt(enabled) {
3254
+ const config = readConfig();
3255
+ writeConfig({
3256
+ ...config,
3257
+ srt: { ...config.srt, enabled }
3258
+ });
2868
3259
  if (enabled) {
2869
- const filtered = currentDeny.filter((entry) => entry !== groupName);
2870
- writeOpenClawConfig({
2871
- tools: { deny: filtered },
2872
- browser: { enabled: true }
2873
- });
2874
- } else {
2875
- if (!currentDeny.includes(groupName)) {
2876
- currentDeny.push(groupName);
2877
- }
2878
- writeOpenClawConfig({
2879
- tools: { deny: currentDeny },
2880
- browser: { enabled: false }
2881
- });
3260
+ ensureSrtSettings();
2882
3261
  }
3262
+ return getSrtStatus();
2883
3263
  }
2884
- async function applyMcpToggle(config, enabled) {
2885
- const pluginEntries = config.plugins?.entries ?? {};
2886
- const pluginNames = Object.keys(pluginEntries);
2887
- if (pluginNames.length === 0) return;
2888
- const currentDeny = [...config.tools?.deny ?? []];
2889
- if (!enabled) {
2890
- const stateMap = {};
2891
- for (const name of pluginNames) {
2892
- stateMap[name] = pluginEntries[name].enabled !== false;
2893
- }
2894
- await savePreviousPluginState(stateMap);
2895
- const disabledEntries = {};
2896
- for (const name of pluginNames) {
2897
- disabledEntries[name] = { enabled: false };
2898
- }
2899
- for (const name of pluginNames) {
2900
- const denyPattern = `mcp__${name}`;
2901
- if (!currentDeny.includes(denyPattern)) {
2902
- currentDeny.push(denyPattern);
2903
- }
2904
- }
2905
- writeOpenClawConfig({
2906
- plugins: { entries: disabledEntries },
2907
- tools: { deny: currentDeny }
2908
- });
2909
- } else {
2910
- const previousState = await loadPreviousPluginState();
2911
- const restoredEntries = {};
2912
- for (const name of pluginNames) {
2913
- restoredEntries[name] = {
2914
- enabled: previousState?.[name] ?? true
2915
- };
2916
- }
2917
- const mcpDenyPatterns = new Set(pluginNames.map((n) => `mcp__${n}`));
2918
- const filteredDeny = currentDeny.filter(
2919
- (entry) => !mcpDenyPatterns.has(entry)
2920
- );
2921
- writeOpenClawConfig({
2922
- plugins: { entries: restoredEntries },
2923
- tools: { deny: filteredDeny }
2924
- });
3264
+ function addAllowedDomain(domain) {
3265
+ const settings = ensureSrtSettings();
3266
+ const normalized = domain.trim().toLowerCase();
3267
+ if (!normalized) return settings;
3268
+ settings.network.deniedDomains = settings.network.deniedDomains.filter(
3269
+ (d) => d !== normalized
3270
+ );
3271
+ if (!settings.network.allowedDomains.includes(normalized)) {
3272
+ settings.network.allowedDomains.push(normalized);
2925
3273
  }
3274
+ writeSrtSettings(settings);
3275
+ return settings;
2926
3276
  }
2927
- async function savePreviousPluginState(stateMap) {
2928
- const db = getDb();
2929
- const existing = await db.select().from(schema_exports.accessConfig).where(
2930
- and2(
2931
- eq3(schema_exports.accessConfig.category, "mcp_servers"),
2932
- eq3(schema_exports.accessConfig.key, "previous_plugin_state")
2933
- )
3277
+ function removeAllowedDomain(domain) {
3278
+ const settings = ensureSrtSettings();
3279
+ const normalized = domain.trim().toLowerCase();
3280
+ settings.network.allowedDomains = settings.network.allowedDomains.filter(
3281
+ (d) => d !== normalized
2934
3282
  );
2935
- if (existing.length > 0) {
2936
- await db.update(schema_exports.accessConfig).set({
2937
- value: JSON.stringify(stateMap),
2938
- updatedAt: sql4`datetime('now')`
2939
- }).where(
2940
- and2(
2941
- eq3(schema_exports.accessConfig.category, "mcp_servers"),
2942
- eq3(schema_exports.accessConfig.key, "previous_plugin_state")
2943
- )
2944
- );
2945
- } else {
2946
- await db.insert(schema_exports.accessConfig).values({
2947
- category: "mcp_servers",
2948
- key: "previous_plugin_state",
2949
- value: JSON.stringify(stateMap)
2950
- });
2951
- }
3283
+ writeSrtSettings(settings);
3284
+ return settings;
2952
3285
  }
2953
- async function loadPreviousPluginState() {
2954
- const db = getDb();
2955
- const rows = await db.select().from(schema_exports.accessConfig).where(
2956
- and2(
2957
- eq3(schema_exports.accessConfig.category, "mcp_servers"),
2958
- eq3(schema_exports.accessConfig.key, "previous_plugin_state")
2959
- )
3286
+ function addDeniedDomain(domain) {
3287
+ const settings = ensureSrtSettings();
3288
+ const normalized = domain.trim().toLowerCase();
3289
+ if (!normalized) return settings;
3290
+ settings.network.allowedDomains = settings.network.allowedDomains.filter(
3291
+ (d) => d !== normalized
2960
3292
  );
2961
- if (rows.length === 0) return null;
2962
- try {
2963
- return JSON.parse(rows[0].value);
2964
- } catch {
2965
- return null;
3293
+ if (!settings.network.deniedDomains.includes(normalized)) {
3294
+ settings.network.deniedDomains.push(normalized);
2966
3295
  }
3296
+ writeSrtSettings(settings);
3297
+ return settings;
2967
3298
  }
2968
- async function updateAuditDb(category, enabled) {
2969
- const db = getDb();
2970
- try {
2971
- await db.update(schema_exports.accessConfig).set({
2972
- value: enabled ? "true" : "false",
2973
- updatedAt: sql4`datetime('now')`
2974
- }).where(
2975
- and2(
2976
- eq3(schema_exports.accessConfig.category, category),
2977
- eq3(schema_exports.accessConfig.key, "enabled")
2978
- )
2979
- );
2980
- } catch (err) {
2981
- logger.warn({ err, category, enabled }, "Failed to update audit DB");
3299
+ function removeDeniedDomain(domain) {
3300
+ const settings = ensureSrtSettings();
3301
+ const normalized = domain.trim().toLowerCase();
3302
+ settings.network.deniedDomains = settings.network.deniedDomains.filter(
3303
+ (d) => d !== normalized
3304
+ );
3305
+ writeSrtSettings(settings);
3306
+ return settings;
3307
+ }
3308
+ function updateSrtSettings(updates) {
3309
+ const settings = ensureSrtSettings();
3310
+ if (updates.network) {
3311
+ settings.network = { ...settings.network, ...updates.network };
2982
3312
  }
3313
+ if (updates.filesystem) {
3314
+ settings.filesystem = { ...settings.filesystem, ...updates.filesystem };
3315
+ }
3316
+ writeSrtSettings(settings);
3317
+ return settings;
2983
3318
  }
2984
3319
 
2985
3320
  // src/server/socket.ts
@@ -3234,6 +3569,38 @@ function setupSocketIO(httpServer) {
3234
3569
  logger.error({ err, pattern }, "Failed to remove restricted pattern");
3235
3570
  }
3236
3571
  });
3572
+ socket.on("safeclaw:getSrtStatus", () => {
3573
+ socket.emit("safeclaw:srtStatus", getSrtStatus());
3574
+ });
3575
+ socket.on("safeclaw:toggleSrt", ({ enabled }) => {
3576
+ const status = toggleSrt(enabled);
3577
+ logger.info(`SRT toggled: enabled=${enabled}`);
3578
+ io.emit("safeclaw:srtStatus", status);
3579
+ });
3580
+ socket.on("safeclaw:updateSrtDomains", ({ list, action, domain }) => {
3581
+ try {
3582
+ if (list === "allow") {
3583
+ if (action === "add") addAllowedDomain(domain);
3584
+ else removeAllowedDomain(domain);
3585
+ } else {
3586
+ if (action === "add") addDeniedDomain(domain);
3587
+ else removeDeniedDomain(domain);
3588
+ }
3589
+ logger.info({ list, action, domain }, "SRT domain updated");
3590
+ io.emit("safeclaw:srtStatus", getSrtStatus());
3591
+ } catch (err) {
3592
+ logger.error({ err, list, action, domain }, "Failed to update SRT domain");
3593
+ }
3594
+ });
3595
+ socket.on("safeclaw:updateSrtSettings", (updates) => {
3596
+ try {
3597
+ updateSrtSettings(updates);
3598
+ logger.info({ updates }, "SRT settings updated");
3599
+ io.emit("safeclaw:srtStatus", getSrtStatus());
3600
+ } catch (err) {
3601
+ logger.error({ err }, "Failed to update SRT settings");
3602
+ }
3603
+ });
3237
3604
  socket.on("disconnect", () => {
3238
3605
  logger.info(`Client disconnected: ${socket.id}`);
3239
3606
  });
@@ -3242,7 +3609,1054 @@ function setupSocketIO(httpServer) {
3242
3609
  }
3243
3610
 
3244
3611
  // src/server/routes.ts
3245
- import { desc as desc4, eq as eq5, and as and4, sql as sql6 } from "drizzle-orm";
3612
+ import { desc as desc4, eq as eq6, and as and5, sql as sql7 } from "drizzle-orm";
3613
+
3614
+ // src/lib/skill-scanner-patterns.ts
3615
+ var HIDDEN_CONTENT_PATTERNS = [
3616
+ // HTML comments with instructional content
3617
+ { pattern: /<!--[\s\S]*?(?:instruction|command|execute|ignore|override|system|prompt|inject|do not|must|should|always|never)[\s\S]*?-->/i, label: "HTML comment with instructions", severity: "CRITICAL" },
3618
+ { pattern: /<!--[\s\S]{50,}?-->/s, label: "Large HTML comment (>50 chars)", severity: "HIGH" },
3619
+ // Zero-width Unicode characters
3620
+ { pattern: /[\u200B\u200C\u200D\u200E\u200F]/, label: "Zero-width Unicode character (U+200B-200F)", severity: "CRITICAL" },
3621
+ { pattern: /[\u2060\u2061\u2062\u2063\u2064]/, label: "Invisible Unicode separator (U+2060-2064)", severity: "CRITICAL" },
3622
+ { pattern: /[\uFEFF]/, label: "Zero-width no-break space (BOM)", severity: "HIGH" },
3623
+ // CSS hiding
3624
+ { pattern: /display\s*:\s*none/i, label: "CSS display:none", severity: "HIGH" },
3625
+ { pattern: /opacity\s*:\s*0(?:[;\s]|$)/i, label: "CSS opacity:0", severity: "HIGH" },
3626
+ { pattern: /visibility\s*:\s*hidden/i, label: "CSS visibility:hidden", severity: "HIGH" },
3627
+ // Bidi overrides
3628
+ { pattern: /[\u202A\u202B\u202C\u202D\u202E]/, label: "Bidi override character", severity: "CRITICAL" },
3629
+ { pattern: /[\u2066\u2067\u2068\u2069]/, label: "Bidi isolate character", severity: "CRITICAL" }
3630
+ ];
3631
+ var PROMPT_INJECTION_PATTERNS = [
3632
+ { pattern: /\b(?:ignore|disregard|forget|override)\s+(?:all\s+)?(?:previous|prior|above|your)\s+(?:instructions?|rules?|prompts?|guidelines?|constraints?)/i, label: "Override previous instructions", severity: "CRITICAL" },
3633
+ { pattern: /\b(?:you\s+(?:are|must|should|will)\s+(?:now|henceforth|from\s+now)\s+(?:be|act|behave|respond)\s+(?:as|like))/i, label: "Role reassignment", severity: "CRITICAL" },
3634
+ { pattern: /\bnew\s+instructions?\s*:/i, label: "New instructions directive", severity: "CRITICAL" },
3635
+ { pattern: /\[INST\]|\[\/INST\]|<\|im_start\|>|<\|im_end\|>/i, label: "Special model tokens", severity: "CRITICAL" },
3636
+ { pattern: /\bsystem\s*prompt\s*(?:override|injection|:)/i, label: "System prompt manipulation", severity: "CRITICAL" },
3637
+ { pattern: /\bdo\s+not\s+(?:follow|obey|listen\s+to)\s+(?:the|your|any)\s+(?:original|previous|prior)/i, label: "Instruction disobedience", severity: "CRITICAL" },
3638
+ { pattern: /\b(?:IMPORTANT|CRITICAL|URGENT)\s*:\s*(?:ignore|override|disregard|you must)/i, label: "Urgent override phrasing", severity: "HIGH" },
3639
+ { pattern: /\b(?:pretend|imagine|suppose)\s+(?:you\s+are|that\s+you)/i, label: "Persona manipulation", severity: "MEDIUM" },
3640
+ { pattern: /\bact\s+as\s+(?:a|an|if)\b/i, label: "Act-as directive", severity: "MEDIUM" }
3641
+ ];
3642
+ var SHELL_EXECUTION_PATTERNS = [
3643
+ { pattern: /(?:curl|wget)\s+[^\n]*\|\s*(?:sh|bash|zsh|node|python)/i, label: "Download and execute (curl|bash)", severity: "CRITICAL" },
3644
+ { pattern: /\beval\s*\(/, label: "eval() call", severity: "CRITICAL" },
3645
+ { pattern: /\bexec\s*\(/, label: "exec() call", severity: "HIGH" },
3646
+ { pattern: /\bnpx\s+-y\s+/, label: "npx -y (auto-confirm remote package)", severity: "HIGH" },
3647
+ { pattern: /\/dev\/tcp\//, label: "Reverse shell via /dev/tcp", severity: "CRITICAL" },
3648
+ { pattern: /\bnc\s+.*-e\s+/, label: "Netcat with exec (nc -e)", severity: "CRITICAL" },
3649
+ { pattern: /\bmkfifo\b.*\bnc\b/s, label: "Named pipe + netcat (reverse shell)", severity: "CRITICAL" },
3650
+ { pattern: /python[23]?\s+-c\s+.*(?:socket|subprocess|os\.system)/i, label: "Python one-liner with system access", severity: "CRITICAL" },
3651
+ { pattern: /\bphp\s+-r\s+.*(?:exec|system|passthru|shell_exec)/i, label: "PHP exec one-liner", severity: "CRITICAL" },
3652
+ { pattern: /\bperl\s+-e\s+.*(?:socket|exec|system)/i, label: "Perl exec one-liner", severity: "CRITICAL" },
3653
+ { pattern: /\bruby\s+-e\s+.*(?:exec|system|spawn)/i, label: "Ruby exec one-liner", severity: "CRITICAL" },
3654
+ { pattern: /\bchmod\s+\+x\b/, label: "Make file executable", severity: "MEDIUM" }
3655
+ ];
3656
+ var DATA_EXFILTRATION_PATTERNS = [
3657
+ { pattern: /pastebin\.com/, label: "pastebin.com", severity: "HIGH" },
3658
+ { pattern: /paste\.ee/, label: "paste.ee", severity: "HIGH" },
3659
+ { pattern: /transfer\.sh/, label: "transfer.sh", severity: "HIGH" },
3660
+ { pattern: /ngrok\.io/, label: "ngrok.io", severity: "HIGH" },
3661
+ { pattern: /requestbin/i, label: "requestbin", severity: "HIGH" },
3662
+ { pattern: /webhook\.site/, label: "webhook.site", severity: "HIGH" },
3663
+ { pattern: /pipedream\.net/, label: "pipedream.net", severity: "HIGH" },
3664
+ { pattern: /hookbin\.com/, label: "hookbin.com", severity: "HIGH" },
3665
+ { pattern: /beeceptor\.com/, label: "beeceptor.com", severity: "HIGH" },
3666
+ { pattern: /postb\.in/, label: "postb.in", severity: "HIGH" },
3667
+ { pattern: /https?:\/\/hooks\.slack\.com\/services\//, label: "Slack webhook URL", severity: "HIGH" },
3668
+ { pattern: /https?:\/\/discord(?:app)?\.com\/api\/webhooks\//, label: "Discord webhook URL", severity: "HIGH" },
3669
+ { pattern: /https?:\/\/api\.telegram\.org\/bot/, label: "Telegram bot API URL", severity: "HIGH" },
3670
+ { pattern: /https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/, label: "Raw IP URL", severity: "HIGH" }
3671
+ ];
3672
+ var MEMORY_POISONING_PATTERNS = [
3673
+ { pattern: /\bSOUL\.md\b/, label: "SOUL.md reference", severity: "CRITICAL" },
3674
+ { pattern: /\bMEMORY\.md\b/, label: "MEMORY.md reference", severity: "CRITICAL" },
3675
+ { pattern: /\.claude\//, label: ".claude/ directory reference", severity: "CRITICAL" },
3676
+ { pattern: /\bCLAUDE\.md\b/, label: "CLAUDE.md reference", severity: "CRITICAL" },
3677
+ { pattern: /\.cursorrules\b/, label: ".cursorrules reference", severity: "CRITICAL" },
3678
+ { pattern: /\.windsurfrules\b/, label: ".windsurfrules reference", severity: "CRITICAL" },
3679
+ { pattern: /\.clinerules\b/, label: ".clinerules reference", severity: "HIGH" },
3680
+ { pattern: /\bCODEX\.md\b/, label: "CODEX.md reference", severity: "HIGH" },
3681
+ { pattern: /(?:write|modify|edit|append|create|overwrite)\s+(?:to\s+)?(?:the\s+)?(?:SOUL|MEMORY|CLAUDE|CODEX)\.md/i, label: "Instruction to modify agent config file", severity: "CRITICAL" },
3682
+ { pattern: /(?:write|modify|edit|append|create|overwrite)\s+(?:to\s+)?(?:the\s+)?\.(?:cursorrules|windsurfrules|clinerules)/i, label: "Instruction to modify IDE rules file", severity: "CRITICAL" }
3683
+ ];
3684
+ var SUPPLY_CHAIN_PATTERNS = [
3685
+ { pattern: /https?:\/\/raw\.githubusercontent\.com\/[^\s]+\.(?:sh|py|js|rb|pl)\b/, label: "Raw GitHub script URL", severity: "HIGH" },
3686
+ { pattern: /https?:\/\/[^\s]+\.(?:sh|py|js|rb|pl)(?:\s|$)/, label: "External script URL", severity: "HIGH" },
3687
+ { pattern: /\bnpm\s+install\s+(?:-g\s+)?[a-zA-Z@]/, label: "npm install command", severity: "HIGH" },
3688
+ { pattern: /\bpip\s+install\s+[a-zA-Z]/, label: "pip install command", severity: "HIGH" },
3689
+ { pattern: /\bgem\s+install\s+/, label: "gem install command", severity: "HIGH" },
3690
+ { pattern: /\bcargo\s+install\s+/, label: "cargo install command", severity: "MEDIUM" },
3691
+ { pattern: /\bgo\s+install\s+/, label: "go install command", severity: "MEDIUM" },
3692
+ { pattern: /\bbrew\s+install\s+/, label: "brew install command", severity: "MEDIUM" }
3693
+ ];
3694
+ var ENCODED_PAYLOAD_PATTERNS = [
3695
+ { pattern: /[A-Za-z0-9+/]{40,}={0,2}/, label: "Base64 string (>40 chars)", severity: "HIGH" },
3696
+ { pattern: /\batob\s*\(/, label: "atob() call (base64 decode)", severity: "HIGH" },
3697
+ { pattern: /\bbtoa\s*\(/, label: "btoa() call (base64 encode)", severity: "MEDIUM" },
3698
+ { pattern: /\bBuffer\.from\s*\([^)]*,\s*['"]base64['"]\)/, label: "Buffer.from base64", severity: "HIGH" },
3699
+ { pattern: /(?:\\x[0-9a-fA-F]{2}){8,}/, label: "Hex escape sequence (8+ bytes)", severity: "HIGH" },
3700
+ { pattern: /String\.fromCharCode\s*\((?:\s*\d+\s*,?\s*){5,}\)/, label: "String.fromCharCode (5+ codes)", severity: "HIGH" },
3701
+ { pattern: /\|\s*base64\s+(?:-d|--decode)/, label: "Piped base64 decode", severity: "CRITICAL" }
3702
+ ];
3703
+ var IMAGE_EXFIL_PATTERNS = [
3704
+ // URL-based exfiltration (original 5)
3705
+ { pattern: /!\[.*?\]\(https?:\/\/[^\s)]*(?:\?|&)(?:data|content|file|token|secret|key|password|env)=[^\s)]*\)/i, label: "Image URL with exfil query params", severity: "CRITICAL" },
3706
+ { pattern: /!\[.*?\]\(https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}[^\s)]*\)/, label: "Image from raw IP address", severity: "CRITICAL" },
3707
+ { pattern: /!\[.*?\]\([^)]*\$\{[^}]+\}[^)]*\)/, label: "Variable interpolation in image URL", severity: "CRITICAL" },
3708
+ { pattern: /!\[.*?\]\([^)]*\$\([^)]+\)[^)]*\)/, label: "Command substitution in image URL", severity: "CRITICAL" },
3709
+ { pattern: /<img[^>]+src\s*=\s*["'][^"']*\$\{[^}]+\}[^"']*["']/i, label: "Variable interpolation in img src", severity: "CRITICAL" },
3710
+ // Group A: Data URI image payloads
3711
+ { pattern: /!\[.*?\]\(data:image\/[^\s)]+\)/i, label: "Data URI image in markdown", severity: "CRITICAL" },
3712
+ { pattern: /<img[^>]+src\s*=\s*["']data:image\/[^"']+["']/i, label: "Data URI image in HTML img tag", severity: "CRITICAL" },
3713
+ { pattern: /data:image\/[^;]+;base64,[A-Za-z0-9+/]{200,}/, label: "Large base64 data URI (steganographic carrier)", severity: "CRITICAL" },
3714
+ // Group B: SVG embedded scripts & data
3715
+ { pattern: /<svg[\s>][\s\S]*?<script[\s>]/i, label: "SVG with embedded script tag", severity: "CRITICAL" },
3716
+ { pattern: /<svg[\s>][\s\S]*?\bon(?:load|error|click|mouseover)\s*=/i, label: "SVG with event handler", severity: "CRITICAL" },
3717
+ { pattern: /<svg[\s>][\s\S]*?<foreignObject[\s>]/i, label: "SVG with foreignObject (arbitrary HTML embed)", severity: "HIGH" },
3718
+ { pattern: /data:image\/svg\+xml[^"'\s)]+/i, label: "SVG data URI (inline payload + script risk)", severity: "CRITICAL" },
3719
+ // Group C: Web beacons / tracking pixels
3720
+ { pattern: /<img[^>]+(?:width\s*=\s*["']?1["']?[^>]+height\s*=\s*["']?1["']?|height\s*=\s*["']?1["']?[^>]+width\s*=\s*["']?1["']?)/i, label: "1x1 tracking pixel (web beacon)", severity: "HIGH" },
3721
+ { pattern: /<img[^>]+style\s*=\s*["'][^"']*(?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0|width\s*:\s*0|height\s*:\s*0)/i, label: "CSS-hidden image beacon", severity: "HIGH" },
3722
+ // Group D: Steganography tool references
3723
+ { pattern: /\b(?:steghide|stegano|openstego|zsteg|stegsolve|stegdetect|steganograph(?:y|ic)?|outguess|pixelknot|deepsteg|stegpy)\b/i, label: "Steganography tool/library reference", severity: "HIGH" },
3724
+ // Group E: Canvas pixel manipulation
3725
+ { pattern: /\b(?:getImageData|putImageData|createImageData|toDataURL|drawImage)\s*\(/i, label: "Canvas API pixel manipulation", severity: "HIGH" },
3726
+ // Group F: Double extension disguise
3727
+ { pattern: /\.(?:png|jpe?g|gif|bmp|webp|svg|ico|tiff?)\.(?:exe|sh|bat|cmd|ps1|py|rb|pl|js|vbs|com|scr|msi)\b/i, label: "Double file extension (executable disguised as image)", severity: "CRITICAL" },
3728
+ // Group G: Obfuscated image URLs
3729
+ { pattern: /!\[.*?\]\([^)]*(?:%[0-9a-fA-F]{2}){5,}[^)]*\)/, label: "Excessive URL encoding in image URL", severity: "MEDIUM" }
3730
+ ];
3731
+ var SYSTEM_PROMPT_EXTRACTION_PATTERNS = [
3732
+ { pattern: /\b(?:reveal|show|print|output|display|repeat|echo)\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions|rules|guidelines)/i, label: "System prompt reveal request", severity: "HIGH" },
3733
+ { pattern: /\brepeat\s+(?:the\s+)?(?:words?|text|everything)\s+above\b/i, label: "Repeat words above", severity: "HIGH" },
3734
+ { pattern: /\bprint\s+everything\s+above\b/i, label: "Print everything above", severity: "HIGH" },
3735
+ { pattern: /\bwhat\s+(?:are|were)\s+your\s+(?:original\s+)?(?:instructions|directives|rules)\b/i, label: "Ask for original instructions", severity: "MEDIUM" },
3736
+ { pattern: /\btell\s+me\s+your\s+(?:system\s+)?prompt\b/i, label: "Ask for system prompt", severity: "HIGH" }
3737
+ ];
3738
+ var ARGUMENT_INJECTION_PATTERNS = [
3739
+ { pattern: /\$\([^)]+\)/, label: "Command substitution $()", severity: "CRITICAL" },
3740
+ { pattern: /\$\{[^}]+\}/, label: "Variable expansion ${}", severity: "HIGH" },
3741
+ { pattern: /`[^`]+`/, label: "Backtick command substitution", severity: "HIGH" },
3742
+ { pattern: /;\s*(?:rm|cat|curl|wget|nc|python|perl|ruby|sh|bash)\b/, label: "Shell metachar chained command", severity: "CRITICAL" },
3743
+ { pattern: /\|\s*(?:sh|bash|zsh|python|perl|ruby|node)\b/, label: "Pipe to interpreter", severity: "CRITICAL" },
3744
+ { pattern: /&&\s*(?:rm|curl|wget|nc|python|perl|ruby)\b/, label: "AND-chained dangerous command", severity: "HIGH" },
3745
+ // GTFOBINS exploitation flags
3746
+ { pattern: /\b(?:tar|zip|find|vim|less|more|man|nmap)\b.*--(?:exec|checkpoint-action|to-command)/i, label: "GTFOBINS exploitation flags", severity: "CRITICAL" }
3747
+ ];
3748
+ var CROSS_TOOL_PATTERNS = [
3749
+ { pattern: /(?:read|cat|view)\s+(?:the\s+)?(?:file|content)[\s\S]{0,100}(?:send|post|upload|transmit|exfiltrate)/is, label: "Read-then-exfiltrate pattern", severity: "HIGH" },
3750
+ { pattern: /(?:first|step\s*1)[\s\S]{0,200}(?:then|step\s*2|next|after\s+that)[\s\S]{0,200}(?:then|step\s*3|finally)/is, label: "Multi-step tool invocation", severity: "HIGH" },
3751
+ { pattern: /\b(?:use_mcp_tool|call_tool|execute_tool|run_tool|invoke_tool)\b/i, label: "Direct tool reference", severity: "MEDIUM" },
3752
+ { pattern: /\b(?:read_file|write_file|execute_command|list_directory|search_files)\s*\(/i, label: "Tool function call syntax", severity: "MEDIUM" }
3753
+ ];
3754
+ var EXCESSIVE_PERMISSION_PATTERNS = [
3755
+ { pattern: /\bunrestricted\s+access\b/i, label: "Unrestricted access request", severity: "HIGH" },
3756
+ { pattern: /\bbypass\s+(?:security|restrictions?|permissions?|safeguards?|protections?|filters?)\b/i, label: "Security bypass request", severity: "HIGH" },
3757
+ { pattern: /\bno\s+restrictions?\b/i, label: "No restrictions request", severity: "HIGH" },
3758
+ { pattern: /\b(?:root|admin(?:istrator)?|superuser)\s+(?:access|privileges?|permissions?)\b/i, label: "Root/admin access request", severity: "HIGH" },
3759
+ { pattern: /\bdisable\s+(?:all\s+)?(?:safety|security|restrictions?|protections?|checks?|filters?)\b/i, label: "Disable safety request", severity: "HIGH" },
3760
+ { pattern: /\bfull\s+(?:system\s+)?(?:access|control|permissions?)\b/i, label: "Full access request", severity: "MEDIUM" }
3761
+ ];
3762
+
3763
+ // src/lib/skill-scanner.ts
3764
+ var SEVERITY_ORDER2 = {
3765
+ NONE: 0,
3766
+ LOW: 1,
3767
+ MEDIUM: 2,
3768
+ HIGH: 3,
3769
+ CRITICAL: 4
3770
+ };
3771
+ function getLineNumber(content, index) {
3772
+ let line = 1;
3773
+ for (let i = 0; i < index && i < content.length; i++) {
3774
+ if (content[i] === "\n") line++;
3775
+ }
3776
+ return line;
3777
+ }
3778
+ function maxSeverity(a, b) {
3779
+ return SEVERITY_ORDER2[a] >= SEVERITY_ORDER2[b] ? a : b;
3780
+ }
3781
+ function runPatternScan(content, patterns, categoryId, categoryName, owaspRef, remediation) {
3782
+ const findings = [];
3783
+ const seen = /* @__PURE__ */ new Set();
3784
+ for (const { pattern, label, severity } of patterns) {
3785
+ const globalPattern = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g");
3786
+ let match;
3787
+ while ((match = globalPattern.exec(content)) !== null) {
3788
+ const key = `${categoryId}:${label}:${match.index}`;
3789
+ if (seen.has(key)) break;
3790
+ seen.add(key);
3791
+ const evidence = match[0].length > 120 ? match[0].slice(0, 120) + "..." : match[0];
3792
+ findings.push({
3793
+ categoryId,
3794
+ categoryName,
3795
+ severity,
3796
+ reason: label,
3797
+ evidence,
3798
+ owaspRef,
3799
+ remediation,
3800
+ lineNumber: getLineNumber(content, match.index)
3801
+ });
3802
+ break;
3803
+ }
3804
+ }
3805
+ return findings;
3806
+ }
3807
+ function scanHiddenContent(content) {
3808
+ return runPatternScan(
3809
+ content,
3810
+ HIDDEN_CONTENT_PATTERNS,
3811
+ "SK-HID",
3812
+ "Hidden Content",
3813
+ "LLM01",
3814
+ "Remove all hidden content. HTML comments, zero-width characters, and CSS hiding can conceal malicious instructions from human reviewers."
3815
+ );
3816
+ }
3817
+ function scanPromptInjection(content) {
3818
+ return runPatternScan(
3819
+ content,
3820
+ PROMPT_INJECTION_PATTERNS,
3821
+ "SK-INJ",
3822
+ "Prompt Injection",
3823
+ "LLM01",
3824
+ "Remove prompt injection vectors. These phrases attempt to override the agent's instructions and redirect its behavior."
3825
+ );
3826
+ }
3827
+ function scanShellExecution(content) {
3828
+ return runPatternScan(
3829
+ content,
3830
+ SHELL_EXECUTION_PATTERNS,
3831
+ "SK-EXE",
3832
+ "Shell Execution",
3833
+ "LLM06",
3834
+ "Remove dangerous shell commands. Skill definitions should not contain executable code, reverse shells, or remote code execution patterns."
3835
+ );
3836
+ }
3837
+ function scanDataExfiltration(content) {
3838
+ const findings = runPatternScan(
3839
+ content,
3840
+ DATA_EXFILTRATION_PATTERNS,
3841
+ "SK-EXF",
3842
+ "Data Exfiltration",
3843
+ "LLM02",
3844
+ "Remove or replace exfiltration URLs. These destinations are commonly used to steal data from compromised systems."
3845
+ );
3846
+ for (const { pattern, label } of EXFILTRATION_URLS) {
3847
+ const globalPattern = new RegExp(pattern.source, "gi");
3848
+ const match = globalPattern.exec(content);
3849
+ if (match) {
3850
+ const alreadyFound = findings.some((f) => f.evidence?.includes(match[0]));
3851
+ if (!alreadyFound) {
3852
+ findings.push({
3853
+ categoryId: "SK-EXF",
3854
+ categoryName: "Data Exfiltration",
3855
+ severity: "HIGH",
3856
+ reason: `Exfiltration URL: ${label}`,
3857
+ evidence: match[0],
3858
+ owaspRef: "LLM02",
3859
+ remediation: "Remove or replace exfiltration URLs.",
3860
+ lineNumber: getLineNumber(content, match.index)
3861
+ });
3862
+ }
3863
+ }
3864
+ }
3865
+ return findings;
3866
+ }
3867
+ function scanEmbeddedSecrets(content) {
3868
+ const { types } = scanForSecrets(content);
3869
+ if (types.length === 0) return [];
3870
+ const findings = [];
3871
+ for (const secretType of types) {
3872
+ findings.push({
3873
+ categoryId: "SK-SEC",
3874
+ categoryName: "Embedded Secrets",
3875
+ severity: "CRITICAL",
3876
+ reason: `Embedded credential: ${secretType}`,
3877
+ owaspRef: "LLM02",
3878
+ remediation: "Remove all hardcoded credentials. Use environment variables or a secrets manager instead."
3879
+ });
3880
+ }
3881
+ return findings;
3882
+ }
3883
+ function scanSensitiveFileRefs(content) {
3884
+ const findings = [];
3885
+ const seen = /* @__PURE__ */ new Set();
3886
+ for (const { pattern, label, readSeverity } of SENSITIVE_PATH_RULES) {
3887
+ const globalPattern = new RegExp(pattern.source, "g");
3888
+ const match = globalPattern.exec(content);
3889
+ if (match && !seen.has(label)) {
3890
+ seen.add(label);
3891
+ findings.push({
3892
+ categoryId: "SK-SFA",
3893
+ categoryName: "Sensitive File References",
3894
+ severity: readSeverity,
3895
+ reason: `Reference to sensitive path: ${label}`,
3896
+ evidence: match[0],
3897
+ owaspRef: "LLM02",
3898
+ remediation: "Remove references to sensitive files and directories. Skills should not instruct agents to access credentials, keys, or system auth files.",
3899
+ lineNumber: getLineNumber(content, match.index)
3900
+ });
3901
+ }
3902
+ }
3903
+ return findings;
3904
+ }
3905
+ function scanMemoryPoisoning(content) {
3906
+ return runPatternScan(
3907
+ content,
3908
+ MEMORY_POISONING_PATTERNS,
3909
+ "SK-MEM",
3910
+ "Memory/Config Poisoning",
3911
+ "LLM05",
3912
+ "Remove instructions that modify agent memory or configuration files. This is a persistence technique that can compromise future sessions."
3913
+ );
3914
+ }
3915
+ function scanSupplyChainRisk(content) {
3916
+ return runPatternScan(
3917
+ content,
3918
+ SUPPLY_CHAIN_PATTERNS,
3919
+ "SK-SUP",
3920
+ "Supply Chain Risk",
3921
+ "LLM03",
3922
+ "Verify all external dependencies and script URLs. Prefer pinned versions and checksums over arbitrary remote scripts."
3923
+ );
3924
+ }
3925
+ function scanEncodedPayloads(content) {
3926
+ return runPatternScan(
3927
+ content,
3928
+ ENCODED_PAYLOAD_PATTERNS,
3929
+ "SK-B64",
3930
+ "Encoded Payloads",
3931
+ "LLM01",
3932
+ "Decode and inspect encoded content. Base64 and hex encoding is commonly used to evade pattern detection in malicious skills."
3933
+ );
3934
+ }
3935
+ function scanImageExfiltration(content) {
3936
+ return runPatternScan(
3937
+ content,
3938
+ IMAGE_EXFIL_PATTERNS,
3939
+ "SK-IMG",
3940
+ "Image Exfiltration",
3941
+ "LLM02",
3942
+ "Remove image tags with dynamic, suspicious, or embedded content. Images can exfiltrate data via query parameters, inline data URIs, SVG scripts, tracking pixels, or steganographic encoding. Avoid data: URIs, SVG images with scripts, and references to steganography tools."
3943
+ );
3944
+ }
3945
+ function scanSystemPromptExtraction(content) {
3946
+ return runPatternScan(
3947
+ content,
3948
+ SYSTEM_PROMPT_EXTRACTION_PATTERNS,
3949
+ "SK-SYS",
3950
+ "System Prompt Extraction",
3951
+ "LLM01",
3952
+ "Remove instructions that attempt to extract system prompts. This information can be used to craft more effective attacks."
3953
+ );
3954
+ }
3955
+ function scanArgumentInjection(content) {
3956
+ return runPatternScan(
3957
+ content,
3958
+ ARGUMENT_INJECTION_PATTERNS,
3959
+ "SK-ARG",
3960
+ "Argument Injection",
3961
+ "LLM01",
3962
+ "Remove shell metacharacters and command substitution patterns. These can be used to inject arbitrary commands via tool arguments."
3963
+ );
3964
+ }
3965
+ function scanCrossToolChaining(content) {
3966
+ return runPatternScan(
3967
+ content,
3968
+ CROSS_TOOL_PATTERNS,
3969
+ "SK-XTL",
3970
+ "Cross-Tool Chaining",
3971
+ "LLM05",
3972
+ "Review multi-step tool invocation instructions carefully. Attackers chain legitimate tools to achieve malicious outcomes."
3973
+ );
3974
+ }
3975
+ function scanExcessivePermissions(content) {
3976
+ return runPatternScan(
3977
+ content,
3978
+ EXCESSIVE_PERMISSION_PATTERNS,
3979
+ "SK-PRM",
3980
+ "Excessive Permissions",
3981
+ "LLM01",
3982
+ "Remove requests for unrestricted access or security bypasses. Skills should operate with minimal required permissions."
3983
+ );
3984
+ }
3985
+ function scanSuspiciousStructure(content) {
3986
+ const findings = [];
3987
+ if (content.length > 1e4) {
3988
+ findings.push({
3989
+ categoryId: "SK-STR",
3990
+ categoryName: "Suspicious Structure",
3991
+ severity: "MEDIUM",
3992
+ reason: `Unusually large skill definition (${content.length.toLocaleString()} characters)`,
3993
+ remediation: "Large skill definitions have more surface area for hidden threats. Consider splitting into smaller, focused skills."
3994
+ });
3995
+ }
3996
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
3997
+ if (lines.length > 10) {
3998
+ const imperativePattern = /^\s*(?:you\s+(?:must|should|will|need\s+to)|always|never|do\s+not|ensure|make\s+sure|immediately|execute|run|create|write|read|send|post|upload|download|install|delete|remove|modify|change|update)/i;
3999
+ const imperativeCount = lines.filter((l) => imperativePattern.test(l)).length;
4000
+ const ratio = imperativeCount / lines.length;
4001
+ if (ratio > 0.3) {
4002
+ findings.push({
4003
+ categoryId: "SK-STR",
4004
+ categoryName: "Suspicious Structure",
4005
+ severity: "MEDIUM",
4006
+ reason: `High imperative instruction density (${Math.round(ratio * 100)}% of lines are directives)`,
4007
+ remediation: "Skills with a high density of imperative instructions may be attempting to control agent behavior beyond their stated purpose."
4008
+ });
4009
+ }
4010
+ }
4011
+ return findings;
4012
+ }
4013
+ function scanSkillDefinition(content) {
4014
+ const startTime = performance.now();
4015
+ const allFindings = [
4016
+ ...scanHiddenContent(content),
4017
+ ...scanPromptInjection(content),
4018
+ ...scanShellExecution(content),
4019
+ ...scanDataExfiltration(content),
4020
+ ...scanEmbeddedSecrets(content),
4021
+ ...scanSensitiveFileRefs(content),
4022
+ ...scanMemoryPoisoning(content),
4023
+ ...scanSupplyChainRisk(content),
4024
+ ...scanEncodedPayloads(content),
4025
+ ...scanImageExfiltration(content),
4026
+ ...scanSystemPromptExtraction(content),
4027
+ ...scanArgumentInjection(content),
4028
+ ...scanCrossToolChaining(content),
4029
+ ...scanExcessivePermissions(content),
4030
+ ...scanSuspiciousStructure(content)
4031
+ ];
4032
+ const scanDurationMs = Math.round(performance.now() - startTime);
4033
+ const summary = { critical: 0, high: 0, medium: 0, low: 0 };
4034
+ let overallSeverity = "NONE";
4035
+ for (const f of allFindings) {
4036
+ if (f.severity === "CRITICAL") summary.critical++;
4037
+ else if (f.severity === "HIGH") summary.high++;
4038
+ else if (f.severity === "MEDIUM") summary.medium++;
4039
+ else if (f.severity === "LOW") summary.low++;
4040
+ overallSeverity = maxSeverity(overallSeverity, f.severity);
4041
+ }
4042
+ allFindings.sort((a, b) => SEVERITY_ORDER2[b.severity] - SEVERITY_ORDER2[a.severity]);
4043
+ return {
4044
+ overallSeverity,
4045
+ findings: allFindings,
4046
+ summary,
4047
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
4048
+ contentLength: content.length,
4049
+ scanDurationMs
4050
+ };
4051
+ }
4052
+ function collectRanges(content, pattern) {
4053
+ const ranges = [];
4054
+ const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
4055
+ const re = new RegExp(pattern.source, flags);
4056
+ let m;
4057
+ while ((m = re.exec(content)) !== null) {
4058
+ ranges.push({ start: m.index, end: m.index + m[0].length });
4059
+ if (m[0].length === 0) break;
4060
+ }
4061
+ return ranges;
4062
+ }
4063
+ function cleanSkillDefinition(content) {
4064
+ const ranges = [];
4065
+ const allScanPatterns = [
4066
+ ...HIDDEN_CONTENT_PATTERNS,
4067
+ ...PROMPT_INJECTION_PATTERNS,
4068
+ ...SHELL_EXECUTION_PATTERNS,
4069
+ ...DATA_EXFILTRATION_PATTERNS,
4070
+ ...MEMORY_POISONING_PATTERNS,
4071
+ ...SUPPLY_CHAIN_PATTERNS,
4072
+ ...ENCODED_PAYLOAD_PATTERNS,
4073
+ ...IMAGE_EXFIL_PATTERNS,
4074
+ ...SYSTEM_PROMPT_EXTRACTION_PATTERNS,
4075
+ ...ARGUMENT_INJECTION_PATTERNS,
4076
+ ...CROSS_TOOL_PATTERNS,
4077
+ ...EXCESSIVE_PERMISSION_PATTERNS
4078
+ ];
4079
+ for (const { pattern } of allScanPatterns) {
4080
+ ranges.push(...collectRanges(content, pattern));
4081
+ }
4082
+ for (const { pattern } of EXFILTRATION_URLS) {
4083
+ ranges.push(...collectRanges(content, pattern));
4084
+ }
4085
+ for (const { pattern } of SENSITIVE_PATH_RULES) {
4086
+ ranges.push(...collectRanges(content, pattern));
4087
+ }
4088
+ for (const { pattern } of SECRET_PATTERNS) {
4089
+ ranges.push(...collectRanges(content, pattern));
4090
+ }
4091
+ if (ranges.length === 0) return { cleanedContent: content, removedCount: 0 };
4092
+ ranges.sort((a, b) => a.start - b.start);
4093
+ const merged = [{ ...ranges[0] }];
4094
+ for (let i = 1; i < ranges.length; i++) {
4095
+ const last = merged[merged.length - 1];
4096
+ if (ranges[i].start <= last.end) {
4097
+ last.end = Math.max(last.end, ranges[i].end);
4098
+ } else {
4099
+ merged.push({ ...ranges[i] });
4100
+ }
4101
+ }
4102
+ let cleaned = content;
4103
+ for (let i = merged.length - 1; i >= 0; i--) {
4104
+ cleaned = cleaned.slice(0, merged[i].start) + cleaned.slice(merged[i].end);
4105
+ }
4106
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim() + "\n";
4107
+ return { cleanedContent: cleaned, removedCount: ranges.length };
4108
+ }
4109
+
4110
+ // src/services/security-posture.ts
4111
+ import fs9 from "fs";
4112
+ import { eq as eq5, ne as ne2, and as and4, sql as sql6 } from "drizzle-orm";
4113
+ function buildLayer(id, name, checks) {
4114
+ const passedCount = checks.filter((c) => c.passed).length;
4115
+ const totalCount = checks.length;
4116
+ let status;
4117
+ if (passedCount === totalCount) {
4118
+ status = "configured";
4119
+ } else if (passedCount === 0) {
4120
+ status = "unconfigured";
4121
+ } else {
4122
+ status = "partial";
4123
+ }
4124
+ return { id, name, status, checks, passedCount, totalCount };
4125
+ }
4126
+ function checkSandboxLayer() {
4127
+ const config = readOpenClawConfig();
4128
+ const sandbox = config?.agents?.defaults?.sandbox;
4129
+ const mode = sandbox?.mode;
4130
+ const workspaceAccess = sandbox?.workspaceAccess;
4131
+ const dockerNetwork = sandbox?.docker?.network;
4132
+ const checks = [
4133
+ {
4134
+ id: "sandbox-mode",
4135
+ label: "Sandbox mode enabled",
4136
+ passed: mode === "all" || mode === "non-main",
4137
+ detail: mode ? `Sandbox mode is "${mode}"` : "Sandbox mode is not configured",
4138
+ severity: "critical"
4139
+ },
4140
+ {
4141
+ id: "sandbox-workspace",
4142
+ label: "Workspace access restricted",
4143
+ passed: workspaceAccess === "ro" || workspaceAccess === "none",
4144
+ detail: workspaceAccess ? `Workspace access is "${workspaceAccess}"` : "Workspace access not configured (defaults to rw)",
4145
+ severity: "warning"
4146
+ },
4147
+ {
4148
+ id: "sandbox-network",
4149
+ label: "Docker network isolated",
4150
+ passed: dockerNetwork != null && dockerNetwork !== "host",
4151
+ detail: dockerNetwork ? `Docker network set to "${dockerNetwork}"` : "Docker network not configured",
4152
+ severity: "info"
4153
+ }
4154
+ ];
4155
+ return buildLayer("sandbox", "Sandbox Isolation", checks);
4156
+ }
4157
+ function checkFilesystemLayer() {
4158
+ const accessState = deriveAccessState();
4159
+ const fsToggle = accessState.toggles.find((t) => t.category === "filesystem");
4160
+ const config = readOpenClawConfig();
4161
+ const workspace = config?.agents?.defaults?.workspace;
4162
+ const workspaceAccess = config?.agents?.defaults?.sandbox?.workspaceAccess;
4163
+ const checks = [
4164
+ {
4165
+ id: "fs-toggle",
4166
+ label: "Filesystem access controlled",
4167
+ passed: fsToggle != null && !fsToggle.enabled,
4168
+ detail: fsToggle?.enabled === false ? "Filesystem tool group is disabled" : "Filesystem tool group is enabled (agent has file access)",
4169
+ severity: "warning"
4170
+ },
4171
+ {
4172
+ id: "fs-workspace",
4173
+ label: "Workspace path configured",
4174
+ passed: workspace != null && workspace.length > 0,
4175
+ detail: workspace ? `Workspace: ${workspace}` : "No explicit workspace path configured",
4176
+ severity: "info"
4177
+ },
4178
+ {
4179
+ id: "fs-workspace-restriction",
4180
+ label: "Workspace access restricted",
4181
+ passed: workspaceAccess === "ro" || workspaceAccess === "none",
4182
+ detail: workspaceAccess ? `Workspace access level: "${workspaceAccess}"` : "Workspace access not restricted (defaults to rw)",
4183
+ severity: "warning"
4184
+ }
4185
+ ];
4186
+ return buildLayer("filesystem", "Filesystem Access", checks);
4187
+ }
4188
+ function checkNetworkLayer() {
4189
+ const accessState = deriveAccessState();
4190
+ const netToggle = accessState.toggles.find((t) => t.category === "network");
4191
+ const config = readOpenClawConfig();
4192
+ const browserEnabled = config?.browser?.enabled;
4193
+ const sandbox = config?.agents?.defaults?.sandbox;
4194
+ const dockerNetwork = sandbox?.docker?.network;
4195
+ const sandboxMode = sandbox?.mode;
4196
+ const checks = [
4197
+ {
4198
+ id: "net-toggle",
4199
+ label: "Network access controlled",
4200
+ passed: netToggle != null && !netToggle.enabled,
4201
+ detail: netToggle?.enabled === false ? "Network tool group is disabled" : "Network tool group is enabled",
4202
+ severity: "warning"
4203
+ },
4204
+ {
4205
+ id: "net-browser",
4206
+ label: "Browser disabled",
4207
+ passed: browserEnabled === false,
4208
+ detail: browserEnabled === false ? "Browser is disabled" : "Browser is enabled (agent can browse web)",
4209
+ severity: "info"
4210
+ },
4211
+ {
4212
+ id: "net-docker-isolation",
4213
+ label: "Network isolated in sandbox",
4214
+ passed: (sandboxMode === "all" || sandboxMode === "non-main") && dockerNetwork != null && dockerNetwork !== "host",
4215
+ detail: sandboxMode === "all" || sandboxMode === "non-main" ? dockerNetwork && dockerNetwork !== "host" ? `Sandboxed with isolated network "${dockerNetwork}"` : "Sandboxed but no network isolation configured" : "Not sandboxed \u2014 no network isolation",
4216
+ severity: "critical"
4217
+ }
4218
+ ];
4219
+ return buildLayer("network", "Network & Egress Control", checks);
4220
+ }
4221
+ async function checkCommandExecLayer() {
4222
+ const accessState = deriveAccessState();
4223
+ const sysToggle = accessState.toggles.find(
4224
+ (t) => t.category === "system_commands"
4225
+ );
4226
+ const config = readOpenClawConfig();
4227
+ const execSecurity = config?.tools?.exec?.security;
4228
+ const db = getDb();
4229
+ const patternRows = await db.select().from(schema_exports.restrictedPatterns);
4230
+ const patternCount = patternRows.length;
4231
+ const patternTexts = patternRows.map((r) => r.pattern.toLowerCase());
4232
+ const criticalPatterns = ["sudo", "rm -rf", "chmod", "curl"];
4233
+ const hasCritical = criticalPatterns.some(
4234
+ (cp) => patternTexts.some((pt) => pt.includes(cp))
4235
+ );
4236
+ const checks = [
4237
+ {
4238
+ id: "exec-toggle",
4239
+ label: "System commands controlled",
4240
+ passed: sysToggle != null && !sysToggle.enabled,
4241
+ detail: sysToggle?.enabled === false ? "System commands tool group is disabled" : "System commands tool group is enabled",
4242
+ severity: "warning"
4243
+ },
4244
+ {
4245
+ id: "exec-security-mode",
4246
+ label: "Exec security mode restrictive",
4247
+ passed: execSecurity === "deny" || execSecurity === "allowlist",
4248
+ detail: execSecurity ? `Exec security mode: "${execSecurity}"` : "Exec security mode not configured",
4249
+ severity: "critical"
4250
+ },
4251
+ {
4252
+ id: "exec-patterns",
4253
+ label: "Restricted patterns configured",
4254
+ passed: patternCount > 0,
4255
+ detail: patternCount > 0 ? `${patternCount} restricted pattern(s) in blocklist` : "No restricted patterns \u2014 all commands pass through",
4256
+ severity: "warning"
4257
+ },
4258
+ {
4259
+ id: "exec-critical-patterns",
4260
+ label: "Critical command patterns blocked",
4261
+ passed: hasCritical,
4262
+ detail: hasCritical ? "Critical patterns (sudo, rm -rf, chmod, curl|bash) present" : "No critical patterns found \u2014 consider adding sudo, rm -rf, chmod",
4263
+ severity: "critical"
4264
+ }
4265
+ ];
4266
+ return buildLayer("exec", "Command Execution Controls", checks);
4267
+ }
4268
+ function checkMcpLayer() {
4269
+ const accessState = deriveAccessState();
4270
+ const mcpToggle = accessState.toggles.find(
4271
+ (t) => t.category === "mcp_servers"
4272
+ );
4273
+ const servers = accessState.mcpServers;
4274
+ const config = readOpenClawConfig();
4275
+ const denyList = config?.tools?.deny ?? [];
4276
+ const mcpDenyEntries = denyList.filter((d) => d.startsWith("mcp__"));
4277
+ const totalServers = servers.length;
4278
+ const enabledServers = servers.filter((s) => s.effectivelyEnabled).length;
4279
+ const disabledServers = totalServers - enabledServers;
4280
+ const checks = [
4281
+ {
4282
+ id: "mcp-toggle",
4283
+ label: "MCP servers controlled",
4284
+ passed: mcpToggle != null && accessState.openclawConfigAvailable,
4285
+ detail: !accessState.openclawConfigAvailable ? "OpenClaw config unavailable" : mcpToggle?.enabled ? "MCP servers toggle is enabled" : "MCP servers toggle is disabled (all servers blocked)",
4286
+ severity: "info"
4287
+ },
4288
+ {
4289
+ id: "mcp-server-review",
4290
+ label: "Servers individually reviewed",
4291
+ passed: totalServers === 0 || disabledServers > 0 || mcpDenyEntries.length > 0,
4292
+ detail: totalServers === 0 ? "No MCP servers configured" : disabledServers > 0 ? `${disabledServers}/${totalServers} server(s) disabled` : "All servers enabled \u2014 consider reviewing each server",
4293
+ severity: "warning"
4294
+ },
4295
+ {
4296
+ id: "mcp-tools-deny",
4297
+ label: "MCP tools in deny list",
4298
+ passed: mcpDenyEntries.length > 0,
4299
+ detail: mcpDenyEntries.length > 0 ? `${mcpDenyEntries.length} MCP tool deny entr(ies) configured` : "No MCP-specific deny entries",
4300
+ severity: "info"
4301
+ }
4302
+ ];
4303
+ return buildLayer("mcp", "MCP Server Security", checks);
4304
+ }
4305
+ async function checkSecretLayer() {
4306
+ const db = getDb();
4307
+ const secretActivities = await db.select().from(schema_exports.agentActivities).where(
4308
+ and4(
4309
+ eq5(schema_exports.agentActivities.resolved, 0),
4310
+ sql6`${schema_exports.agentActivities.threatFindings} LIKE '%TC-SEC%'`
4311
+ )
4312
+ );
4313
+ const sfaActivities = await db.select().from(schema_exports.agentActivities).where(
4314
+ and4(
4315
+ eq5(schema_exports.agentActivities.resolved, 0),
4316
+ sql6`${schema_exports.agentActivities.threatFindings} LIKE '%TC-SFA%'`
4317
+ )
4318
+ );
4319
+ const checks = [
4320
+ {
4321
+ id: "secret-scanner",
4322
+ label: "Secret scanner active",
4323
+ passed: true,
4324
+ detail: "Built-in secret scanner is always active",
4325
+ severity: "info"
4326
+ },
4327
+ {
4328
+ id: "secret-exposure",
4329
+ label: "No unresolved secret exposures",
4330
+ passed: secretActivities.length === 0,
4331
+ detail: secretActivities.length === 0 ? "No unresolved secret exposure threats" : `${secretActivities.length} unresolved secret exposure threat(s)`,
4332
+ severity: "critical"
4333
+ },
4334
+ {
4335
+ id: "secret-file-access",
4336
+ label: "No unresolved sensitive file access",
4337
+ passed: sfaActivities.length === 0,
4338
+ detail: sfaActivities.length === 0 ? "No unresolved sensitive file access threats" : `${sfaActivities.length} unresolved sensitive file access threat(s)`,
4339
+ severity: "warning"
4340
+ }
4341
+ ];
4342
+ return buildLayer("secrets", "Secret & Credential Protection", checks);
4343
+ }
4344
+ async function checkThreatMonitoringLayer() {
4345
+ const monitor = getOpenClawMonitor();
4346
+ const connectionStatus = monitor?.getStatus() ?? "not_configured";
4347
+ const config = readOpenClawConfig();
4348
+ const db = getDb();
4349
+ const activeSessions = await db.select({ count: sql6`count(*)` }).from(schema_exports.openclawSessions).where(eq5(schema_exports.openclawSessions.status, "ACTIVE"));
4350
+ const totalThreats = await db.select({ count: sql6`count(*)` }).from(schema_exports.agentActivities).where(ne2(schema_exports.agentActivities.threatLevel, "NONE"));
4351
+ const resolvedThreats = await db.select({ count: sql6`count(*)` }).from(schema_exports.agentActivities).where(
4352
+ and4(
4353
+ ne2(schema_exports.agentActivities.threatLevel, "NONE"),
4354
+ eq5(schema_exports.agentActivities.resolved, 1)
4355
+ )
4356
+ );
4357
+ const total = totalThreats[0]?.count ?? 0;
4358
+ const resolved = resolvedThreats[0]?.count ?? 0;
4359
+ const resolutionRate = total > 0 ? Math.round(resolved / total * 100) : 100;
4360
+ const checks = [
4361
+ {
4362
+ id: "monitor-connection",
4363
+ label: "OpenClaw connected",
4364
+ passed: connectionStatus === "connected",
4365
+ detail: connectionStatus === "connected" ? "Connected to OpenClaw gateway" : config ? `Connection status: ${connectionStatus}` : "OpenClaw config not found",
4366
+ severity: "critical"
4367
+ },
4368
+ {
4369
+ id: "monitor-sessions",
4370
+ label: "Session tracking active",
4371
+ passed: (activeSessions[0]?.count ?? 0) > 0 || connectionStatus === "connected",
4372
+ detail: (activeSessions[0]?.count ?? 0) > 0 ? `${activeSessions[0].count} active session(s)` : connectionStatus === "connected" ? "Connected, no active sessions" : "No active sessions (disconnected)",
4373
+ severity: "info"
4374
+ },
4375
+ {
4376
+ id: "monitor-resolution",
4377
+ label: "Threats being resolved",
4378
+ passed: resolutionRate >= 50 || total === 0,
4379
+ detail: total === 0 ? "No threats detected yet" : `${resolved}/${total} threats resolved (${resolutionRate}%)`,
4380
+ severity: "warning"
4381
+ }
4382
+ ];
4383
+ return buildLayer("monitoring", "Threat Monitoring", checks);
4384
+ }
4385
+ async function checkHumanInLoopLayer() {
4386
+ const monitor = getOpenClawMonitor();
4387
+ const db = getDb();
4388
+ const patternRows = await db.select().from(schema_exports.restrictedPatterns);
4389
+ const patternCount = patternRows.length;
4390
+ const totalApprovals = await db.select({ count: sql6`count(*)` }).from(schema_exports.execApprovals);
4391
+ const timedOut = await db.select({ count: sql6`count(*)` }).from(schema_exports.execApprovals).where(eq5(schema_exports.execApprovals.decidedBy, "auto-deny"));
4392
+ const total = totalApprovals[0]?.count ?? 0;
4393
+ const timedOutCount = timedOut[0]?.count ?? 0;
4394
+ const timeoutRate = total > 0 ? Math.round(timedOutCount / total * 100) : 0;
4395
+ const checks = [
4396
+ {
4397
+ id: "hitl-active",
4398
+ label: "Exec approval system active",
4399
+ passed: monitor != null,
4400
+ detail: monitor ? "Exec approval system is running" : "Exec approval system not initialized",
4401
+ severity: "critical"
4402
+ },
4403
+ {
4404
+ id: "hitl-timeout-rate",
4405
+ label: "Approval timeout rate acceptable",
4406
+ passed: timeoutRate < 20 || total === 0,
4407
+ detail: total === 0 ? "No approval requests yet" : `${timedOutCount}/${total} approvals timed out (${timeoutRate}%)`,
4408
+ severity: "warning"
4409
+ },
4410
+ {
4411
+ id: "hitl-patterns",
4412
+ label: "Restricted patterns configured",
4413
+ passed: patternCount > 0,
4414
+ detail: patternCount > 0 ? `${patternCount} restricted pattern(s) for interception` : "No restricted patterns \u2014 nothing to intercept",
4415
+ severity: "warning"
4416
+ }
4417
+ ];
4418
+ return buildLayer("human-in-loop", "Human-in-the-Loop Controls", checks);
4419
+ }
4420
+ async function checkEgressProxyLayer() {
4421
+ const proxyConfigured = !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy);
4422
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
4423
+ const proxyBypassed = noProxy.trim() === "*";
4424
+ const srtStatus = getSrtStatus();
4425
+ const srtActive = srtStatus.enabled && srtStatus.installed;
4426
+ const srtHasDomainRules = srtStatus.settings != null && (srtStatus.settings.network.allowedDomains.length > 0 || srtStatus.settings.network.deniedDomains.length > 0);
4427
+ const db = getDb();
4428
+ const exfilActivities = await db.select({ count: sql6`count(*)` }).from(schema_exports.agentActivities).where(
4429
+ and4(
4430
+ eq5(schema_exports.agentActivities.resolved, 0),
4431
+ sql6`${schema_exports.agentActivities.threatFindings} LIKE '%TC-EXF%'`
4432
+ )
4433
+ );
4434
+ const netActivities = await db.select({ count: sql6`count(*)` }).from(schema_exports.agentActivities).where(
4435
+ and4(
4436
+ eq5(schema_exports.agentActivities.resolved, 0),
4437
+ sql6`${schema_exports.agentActivities.threatFindings} LIKE '%TC-NET%'`
4438
+ )
4439
+ );
4440
+ const exfilCount = exfilActivities[0]?.count ?? 0;
4441
+ const netCount = netActivities[0]?.count ?? 0;
4442
+ const checks = [
4443
+ {
4444
+ id: "egress-filtering-configured",
4445
+ label: "Egress filtering configured",
4446
+ passed: proxyConfigured || srtActive,
4447
+ detail: srtActive ? "Sandbox Runtime (srt) is active \u2014 network egress is filtered" : proxyConfigured ? "HTTP/HTTPS proxy environment variables are set" : "No egress filtering \u2014 configure srt or set HTTP_PROXY/HTTPS_PROXY",
4448
+ severity: "warning"
4449
+ },
4450
+ {
4451
+ id: "egress-domain-rules",
4452
+ label: "Domain filtering rules configured",
4453
+ passed: srtHasDomainRules,
4454
+ detail: srtHasDomainRules ? `srt domain rules: ${srtStatus.settings.network.allowedDomains.length} allowed, ${srtStatus.settings.network.deniedDomains.length} denied` : srtActive ? "srt is active but no domain allow/deny rules configured" : "No domain filtering rules \u2014 enable srt and add allowed domains",
4455
+ severity: "warning"
4456
+ },
4457
+ {
4458
+ id: "egress-no-proxy-safe",
4459
+ label: "Proxy not globally bypassed",
4460
+ passed: !proxyBypassed,
4461
+ detail: proxyBypassed ? "NO_PROXY is set to '*' \u2014 all proxy filtering is bypassed" : noProxy ? `NO_PROXY exceptions: ${noProxy}` : "No NO_PROXY exceptions set",
4462
+ severity: "critical"
4463
+ },
4464
+ {
4465
+ id: "egress-exfiltration-clean",
4466
+ label: "No unresolved exfiltration threats",
4467
+ passed: exfilCount === 0,
4468
+ detail: exfilCount === 0 ? "No unresolved data exfiltration threats" : `${exfilCount} unresolved exfiltration threat(s)`,
4469
+ severity: "critical"
4470
+ },
4471
+ {
4472
+ id: "egress-network-threats-clean",
4473
+ label: "No unresolved network threats",
4474
+ passed: netCount === 0,
4475
+ detail: netCount === 0 ? "No unresolved network threats" : `${netCount} unresolved network threat(s)`,
4476
+ severity: "warning"
4477
+ }
4478
+ ];
4479
+ return buildLayer("egress-proxy", "Egress Proxy & Domain Filtering", checks);
4480
+ }
4481
+ function checkGatewaySecurityLayer() {
4482
+ const config = readOpenClawConfig();
4483
+ const deviceExists = fs9.existsSync(OPENCLAW_DEVICE_JSON);
4484
+ const authMode = config?.gateway ? config.gateway?.auth ? config.gateway.auth?.mode : void 0 : void 0;
4485
+ const gwBind = config?.gateway ? config.gateway?.bind : void 0;
4486
+ const bindLocal = gwBind === void 0 || gwBind === null || gwBind === "127.0.0.1" || gwBind === "localhost" || gwBind === "loopback" || gwBind === "::1";
4487
+ const channels = config?.channels;
4488
+ const whatsapp = channels?.whatsapp;
4489
+ const allowFrom = whatsapp?.allowFrom;
4490
+ const channelRestricted = !whatsapp || Array.isArray(allowFrom) && allowFrom.length > 0;
4491
+ const checks = [
4492
+ {
4493
+ id: "gw-device-identity",
4494
+ label: "Device identity configured",
4495
+ passed: deviceExists,
4496
+ detail: deviceExists ? "Ed25519 device identity file exists" : "No device identity found \u2014 gateway authentication unavailable",
4497
+ severity: "critical"
4498
+ },
4499
+ {
4500
+ id: "gw-auth-mode",
4501
+ label: "Gateway authentication enabled",
4502
+ passed: authMode != null && authMode !== "",
4503
+ detail: authMode ? `Gateway auth mode: "${authMode}"` : "Gateway auth mode not configured",
4504
+ severity: "critical"
4505
+ },
4506
+ {
4507
+ id: "gw-bind-local",
4508
+ label: "Gateway bound to localhost",
4509
+ passed: bindLocal,
4510
+ detail: bindLocal ? `Gateway bind: ${gwBind ?? "default (localhost)"}` : `Gateway bound to ${gwBind} \u2014 accessible from network`,
4511
+ severity: "warning"
4512
+ },
4513
+ {
4514
+ id: "gw-channel-restricted",
4515
+ label: "External channels restricted",
4516
+ passed: channelRestricted,
4517
+ detail: channelRestricted ? whatsapp ? `WhatsApp allowFrom has ${allowFrom?.length ?? 0} entry/entries` : "No external channels configured" : "WhatsApp is open to all senders \u2014 restrict with allowFrom",
4518
+ severity: "info"
4519
+ }
4520
+ ];
4521
+ return buildLayer("gateway", "Gateway & Inbound Security", checks);
4522
+ }
4523
+ async function checkSupplyChainLayer() {
4524
+ const db = getDb();
4525
+ const supplyThreats = await db.select({ count: sql6`count(*)` }).from(schema_exports.agentActivities).where(
4526
+ and4(
4527
+ eq5(schema_exports.agentActivities.resolved, 0),
4528
+ sql6`${schema_exports.agentActivities.threatFindings} LIKE '%TC-SUP%'`
4529
+ )
4530
+ );
4531
+ const patternRows = await db.select().from(schema_exports.restrictedPatterns);
4532
+ const patternTexts = patternRows.map((r) => r.pattern.toLowerCase());
4533
+ const supplyKeywords = ["npm install", "pip install", "brew install", "curl"];
4534
+ const hasSupplyPattern = supplyKeywords.some(
4535
+ (kw) => patternTexts.some((pt) => pt.includes(kw))
4536
+ );
4537
+ const config = readOpenClawConfig();
4538
+ const execSecurity = config?.tools?.exec?.security;
4539
+ const supplyCount = supplyThreats[0]?.count ?? 0;
4540
+ const checks = [
4541
+ {
4542
+ id: "supply-chain-threats-clean",
4543
+ label: "No unresolved supply chain threats",
4544
+ passed: supplyCount === 0,
4545
+ detail: supplyCount === 0 ? "No unresolved supply chain threats" : `${supplyCount} unresolved supply chain threat(s)`,
4546
+ severity: "critical"
4547
+ },
4548
+ {
4549
+ id: "supply-exec-restricted",
4550
+ label: "Package install commands restricted",
4551
+ passed: hasSupplyPattern,
4552
+ detail: hasSupplyPattern ? "Restricted patterns cover package install commands" : "No restricted patterns for npm install, pip install, curl, etc.",
4553
+ severity: "warning"
4554
+ },
4555
+ {
4556
+ id: "supply-exec-mode",
4557
+ label: "Exec security prevents blind installs",
4558
+ passed: execSecurity === "deny" || execSecurity === "allowlist",
4559
+ detail: execSecurity ? `Exec security mode: "${execSecurity}"` : "Exec security mode not configured",
4560
+ severity: "warning"
4561
+ }
4562
+ ];
4563
+ return buildLayer("supply-chain", "Supply Chain Protection", checks);
4564
+ }
4565
+ async function checkInputOutputLayer() {
4566
+ const db = getDb();
4567
+ const injectionThreats = await db.select({ count: sql6`count(*)` }).from(schema_exports.agentActivities).where(
4568
+ and4(
4569
+ eq5(schema_exports.agentActivities.resolved, 0),
4570
+ sql6`${schema_exports.agentActivities.threatFindings} LIKE '%TC-INJ%'`
4571
+ )
4572
+ );
4573
+ const mcpPoisoning = await db.select({ count: sql6`count(*)` }).from(schema_exports.agentActivities).where(
4574
+ and4(
4575
+ eq5(schema_exports.agentActivities.resolved, 0),
4576
+ sql6`${schema_exports.agentActivities.threatFindings} LIKE '%TC-MCP%'`
4577
+ )
4578
+ );
4579
+ const injCount = injectionThreats[0]?.count ?? 0;
4580
+ const mcpCount = mcpPoisoning[0]?.count ?? 0;
4581
+ const checks = [
4582
+ {
4583
+ id: "io-injection-clean",
4584
+ label: "No unresolved prompt injection threats",
4585
+ passed: injCount === 0,
4586
+ detail: injCount === 0 ? "No unresolved prompt injection threats" : `${injCount} unresolved prompt injection threat(s)`,
4587
+ severity: "critical"
4588
+ },
4589
+ {
4590
+ id: "io-mcp-poisoning-clean",
4591
+ label: "No unresolved MCP poisoning threats",
4592
+ passed: mcpCount === 0,
4593
+ detail: mcpCount === 0 ? "No unresolved MCP tool poisoning threats" : `${mcpCount} unresolved MCP tool poisoning threat(s)`,
4594
+ severity: "critical"
4595
+ },
4596
+ {
4597
+ id: "io-content-scanner-active",
4598
+ label: "Content threat scanner active",
4599
+ passed: true,
4600
+ detail: "Built-in 10-category threat classifier is always active",
4601
+ severity: "info"
4602
+ },
4603
+ {
4604
+ id: "io-skill-scanner-available",
4605
+ label: "Skill definition scanner available",
4606
+ passed: true,
4607
+ detail: "Built-in SK-* skill scanner is available for MCP auditing",
4608
+ severity: "info"
4609
+ }
4610
+ ];
4611
+ return buildLayer("input-output", "Input/Output Validation", checks);
4612
+ }
4613
+ async function computeSecurityPosture() {
4614
+ const [
4615
+ execLayer,
4616
+ egressLayer,
4617
+ secretLayer,
4618
+ supplyChainLayer,
4619
+ inputOutputLayer,
4620
+ monitoringLayer,
4621
+ humanLayer
4622
+ ] = await Promise.all([
4623
+ checkCommandExecLayer(),
4624
+ checkEgressProxyLayer(),
4625
+ checkSecretLayer(),
4626
+ checkSupplyChainLayer(),
4627
+ checkInputOutputLayer(),
4628
+ checkThreatMonitoringLayer(),
4629
+ checkHumanInLoopLayer()
4630
+ ]);
4631
+ const layers = [
4632
+ checkSandboxLayer(),
4633
+ checkFilesystemLayer(),
4634
+ checkNetworkLayer(),
4635
+ egressLayer,
4636
+ execLayer,
4637
+ checkMcpLayer(),
4638
+ checkGatewaySecurityLayer(),
4639
+ secretLayer,
4640
+ supplyChainLayer,
4641
+ inputOutputLayer,
4642
+ monitoringLayer,
4643
+ humanLayer
4644
+ ];
4645
+ const totalChecks = layers.reduce((sum, l) => sum + l.totalCount, 0);
4646
+ const passedChecks = layers.reduce((sum, l) => sum + l.passedCount, 0);
4647
+ const overallScore = totalChecks > 0 ? Math.round(passedChecks / totalChecks * 100) : 0;
4648
+ return {
4649
+ layers,
4650
+ overallScore,
4651
+ configuredLayers: layers.filter((l) => l.status === "configured").length,
4652
+ partialLayers: layers.filter((l) => l.status === "partial").length,
4653
+ unconfiguredLayers: layers.filter((l) => l.status === "unconfigured").length,
4654
+ totalLayers: layers.length,
4655
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
4656
+ };
4657
+ }
4658
+
4659
+ // src/server/routes.ts
3246
4660
  async function registerRoutes(app) {
3247
4661
  app.get("/api/health", async () => {
3248
4662
  return { status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() };
@@ -3277,11 +4691,11 @@ async function registerRoutes(app) {
3277
4691
  const db = getDb();
3278
4692
  await db.update(schema_exports.accessConfig).set({
3279
4693
  value: enabled ? "true" : "false",
3280
- updatedAt: sql6`datetime('now')`
4694
+ updatedAt: sql7`datetime('now')`
3281
4695
  }).where(
3282
- and4(
3283
- eq5(schema_exports.accessConfig.category, category),
3284
- eq5(schema_exports.accessConfig.key, "enabled")
4696
+ and5(
4697
+ eq6(schema_exports.accessConfig.category, category),
4698
+ eq6(schema_exports.accessConfig.key, "enabled")
3285
4699
  )
3286
4700
  );
3287
4701
  return deriveAccessState();
@@ -3310,8 +4724,8 @@ async function registerRoutes(app) {
3310
4724
  const { action } = request.body;
3311
4725
  const db = getDb();
3312
4726
  const newStatus = action === "ALLOW" ? "ALLOWED" : "BLOCKED";
3313
- await db.update(schema_exports.commandLogs).set({ status: newStatus, decisionBy: "USER" }).where(eq5(schema_exports.commandLogs.id, Number(id)));
3314
- const [updated] = await db.select().from(schema_exports.commandLogs).where(eq5(schema_exports.commandLogs.id, Number(id)));
4727
+ await db.update(schema_exports.commandLogs).set({ status: newStatus, decisionBy: "USER" }).where(eq6(schema_exports.commandLogs.id, Number(id)));
4728
+ const [updated] = await db.select().from(schema_exports.commandLogs).where(eq6(schema_exports.commandLogs.id, Number(id)));
3315
4729
  return updated;
3316
4730
  });
3317
4731
  app.get("/api/openclaw/config", async () => {
@@ -3413,6 +4827,59 @@ async function registerRoutes(app) {
3413
4827
  const patterns = monitor.getExecApprovalService().removeRestrictedPattern(pattern);
3414
4828
  return { patterns: patterns.map((p) => ({ pattern: p })) };
3415
4829
  });
4830
+ app.get("/api/security-posture", async () => {
4831
+ return computeSecurityPosture();
4832
+ });
4833
+ app.post("/api/skill-scanner/scan", async (request, reply) => {
4834
+ const parsed = skillScanRequestSchema.safeParse(request.body);
4835
+ if (!parsed.success) {
4836
+ return reply.status(400).send({ error: "Invalid request. Provide { content: string } (1 to 500K chars)." });
4837
+ }
4838
+ const result = scanSkillDefinition(parsed.data.content);
4839
+ return result;
4840
+ });
4841
+ app.post("/api/skill-scanner/clean", async (request, reply) => {
4842
+ const parsed = skillScanRequestSchema.safeParse(request.body);
4843
+ if (!parsed.success) {
4844
+ return reply.status(400).send({ error: "Invalid request. Provide { content: string } (1 to 500K chars)." });
4845
+ }
4846
+ const result = cleanSkillDefinition(parsed.data.content);
4847
+ return result;
4848
+ });
4849
+ app.get("/api/srt/status", async () => {
4850
+ return getSrtStatus();
4851
+ });
4852
+ app.put("/api/srt/toggle", async (request) => {
4853
+ const { enabled } = request.body;
4854
+ return toggleSrt(enabled);
4855
+ });
4856
+ app.get("/api/srt/settings", async () => {
4857
+ return readSrtSettings() ?? { error: "SRT settings file not found" };
4858
+ });
4859
+ app.put("/api/srt/settings", async (request) => {
4860
+ const updates = request.body;
4861
+ return updateSrtSettings(updates);
4862
+ });
4863
+ app.post("/api/srt/domains/:list", async (request, reply) => {
4864
+ const { list } = request.params;
4865
+ const { domain } = request.body;
4866
+ if (!domain?.trim()) {
4867
+ return reply.status(400).send({ error: "Domain is required" });
4868
+ }
4869
+ if (list === "allow") return addAllowedDomain(domain);
4870
+ if (list === "deny") return addDeniedDomain(domain);
4871
+ return reply.status(400).send({ error: "List must be 'allow' or 'deny'" });
4872
+ });
4873
+ app.delete("/api/srt/domains/:list", async (request, reply) => {
4874
+ const { list } = request.params;
4875
+ const { domain } = request.body;
4876
+ if (!domain?.trim()) {
4877
+ return reply.status(400).send({ error: "Domain is required" });
4878
+ }
4879
+ if (list === "allow") return removeAllowedDomain(domain);
4880
+ if (list === "deny") return removeDeniedDomain(domain);
4881
+ return reply.status(400).send({ error: "List must be 'allow' or 'deny'" });
4882
+ });
3416
4883
  }
3417
4884
 
3418
4885
  // src/server/index.ts
@@ -3423,7 +4890,7 @@ async function createAppServer(port) {
3423
4890
  await app.register(fastifyCors, { origin: "*" });
3424
4891
  await registerRoutes(app);
3425
4892
  const publicDir = getPublicDir();
3426
- if (fs7.existsSync(publicDir) && fs7.readdirSync(publicDir).filter((f) => f !== ".gitkeep").length > 0) {
4893
+ if (fs10.existsSync(publicDir) && fs10.readdirSync(publicDir).filter((f) => f !== ".gitkeep").length > 0) {
3427
4894
  await app.register(fastifyStatic, {
3428
4895
  root: publicDir,
3429
4896
  prefix: "/",
@@ -3685,7 +5152,7 @@ Permission denied for port ${port}.
3685
5152
  }
3686
5153
 
3687
5154
  // src/commands/reset.ts
3688
- import fs8 from "fs";
5155
+ import fs11 from "fs";
3689
5156
  import readline from "readline/promises";
3690
5157
  import pc3 from "picocolors";
3691
5158
  async function resetCommand(options) {
@@ -3704,12 +5171,12 @@ async function resetCommand(options) {
3704
5171
  }
3705
5172
  }
3706
5173
  console.log(pc3.bold("Resetting SafeClaw..."));
3707
- if (fs8.existsSync(DB_PATH)) {
3708
- fs8.unlinkSync(DB_PATH);
5174
+ if (fs11.existsSync(DB_PATH)) {
5175
+ fs11.unlinkSync(DB_PATH);
3709
5176
  const walPath = DB_PATH + "-wal";
3710
5177
  const shmPath = DB_PATH + "-shm";
3711
- if (fs8.existsSync(walPath)) fs8.unlinkSync(walPath);
3712
- if (fs8.existsSync(shmPath)) fs8.unlinkSync(shmPath);
5178
+ if (fs11.existsSync(walPath)) fs11.unlinkSync(walPath);
5179
+ if (fs11.existsSync(shmPath)) fs11.unlinkSync(shmPath);
3713
5180
  console.log(pc3.green(" Database deleted."));
3714
5181
  } else {
3715
5182
  console.log(pc3.dim(" No database found, skipping."));
@@ -3722,11 +5189,11 @@ async function resetCommand(options) {
3722
5189
  }
3723
5190
 
3724
5191
  // src/commands/status.ts
3725
- import fs9 from "fs";
5192
+ import fs12 from "fs";
3726
5193
  import Database3 from "better-sqlite3";
3727
5194
  import pc4 from "picocolors";
3728
5195
  async function statusCommand(options) {
3729
- const exists = fs9.existsSync(SAFECLAW_DIR);
5196
+ const exists = fs12.existsSync(SAFECLAW_DIR);
3730
5197
  if (!exists) {
3731
5198
  if (options.json) {
3732
5199
  console.log(JSON.stringify({ initialized: false }, null, 2));
@@ -3740,7 +5207,7 @@ async function statusCommand(options) {
3740
5207
  const config = readConfig();
3741
5208
  let logCount = 0;
3742
5209
  let activityCount = 0;
3743
- const dbExists = fs9.existsSync(DB_PATH);
5210
+ const dbExists = fs12.existsSync(DB_PATH);
3744
5211
  if (dbExists) {
3745
5212
  try {
3746
5213
  const sqlite = new Database3(DB_PATH, { readonly: true });
@@ -3754,14 +5221,14 @@ async function statusCommand(options) {
3754
5221
  activityCount = -1;
3755
5222
  }
3756
5223
  }
3757
- const openclawConfigExists = fs9.existsSync(OPENCLAW_CONFIG_PATH);
5224
+ const openclawConfigExists = fs12.existsSync(OPENCLAW_CONFIG_PATH);
3758
5225
  if (options.json) {
3759
5226
  const data = {
3760
5227
  version: VERSION,
3761
5228
  initialized: true,
3762
5229
  dataDir: SAFECLAW_DIR,
3763
5230
  database: dbExists ? "exists" : "not_found",
3764
- config: fs9.existsSync(CONFIG_PATH) ? "exists" : "not_found",
5231
+ config: fs12.existsSync(CONFIG_PATH) ? "exists" : "not_found",
3765
5232
  port: config.port,
3766
5233
  autoOpenBrowser: config.autoOpenBrowser,
3767
5234
  premium: config.premium,
@@ -3779,7 +5246,7 @@ async function statusCommand(options) {
3779
5246
  ` ${pc4.dim("Database:")} ${dbExists ? pc4.green("exists") : pc4.red("not found")}`
3780
5247
  );
3781
5248
  console.log(
3782
- ` ${pc4.dim("Config:")} ${fs9.existsSync(CONFIG_PATH) ? pc4.green("exists") : pc4.red("not found")}`
5249
+ ` ${pc4.dim("Config:")} ${fs12.existsSync(CONFIG_PATH) ? pc4.green("exists") : pc4.red("not found")}`
3783
5250
  );
3784
5251
  console.log(` ${pc4.dim("Port:")} ${config.port}`);
3785
5252
  console.log(` ${pc4.dim("Auto-open:")} ${config.autoOpenBrowser ? "Yes" : "No"}`);
@@ -3797,7 +5264,7 @@ async function statusCommand(options) {
3797
5264
  }
3798
5265
 
3799
5266
  // src/commands/doctor.ts
3800
- import fs10 from "fs";
5267
+ import fs13 from "fs";
3801
5268
  import net from "net";
3802
5269
  import Database4 from "better-sqlite3";
3803
5270
  import pc5 from "picocolors";
@@ -3819,7 +5286,7 @@ function checkNodeVersion() {
3819
5286
  function checkDataDir() {
3820
5287
  try {
3821
5288
  ensureDataDir();
3822
- fs10.accessSync(SAFECLAW_DIR, fs10.constants.W_OK);
5289
+ fs13.accessSync(SAFECLAW_DIR, fs13.constants.W_OK);
3823
5290
  return {
3824
5291
  name: "Data directory writable",
3825
5292
  status: "pass",
@@ -3834,7 +5301,7 @@ function checkDataDir() {
3834
5301
  }
3835
5302
  }
3836
5303
  function checkDatabase() {
3837
- if (!fs10.existsSync(DB_PATH)) {
5304
+ if (!fs13.existsSync(DB_PATH)) {
3838
5305
  return {
3839
5306
  name: "Database",
3840
5307
  status: "warn",
@@ -3877,7 +5344,7 @@ function checkDatabase() {
3877
5344
  }
3878
5345
  }
3879
5346
  function checkConfig() {
3880
- if (!fs10.existsSync(CONFIG_PATH)) {
5347
+ if (!fs13.existsSync(CONFIG_PATH)) {
3881
5348
  return {
3882
5349
  name: "Config file",
3883
5350
  status: "warn",
@@ -3919,14 +5386,14 @@ async function checkPort() {
3919
5386
  };
3920
5387
  }
3921
5388
  function checkOpenClawConfig() {
3922
- if (!fs10.existsSync(OPENCLAW_DIR)) {
5389
+ if (!fs13.existsSync(OPENCLAW_DIR)) {
3923
5390
  return {
3924
5391
  name: "OpenClaw directory",
3925
5392
  status: "warn",
3926
5393
  message: `${OPENCLAW_DIR} not found`
3927
5394
  };
3928
5395
  }
3929
- if (!fs10.existsSync(OPENCLAW_CONFIG_PATH)) {
5396
+ if (!fs13.existsSync(OPENCLAW_CONFIG_PATH)) {
3930
5397
  return {
3931
5398
  name: "OpenClaw config",
3932
5399
  status: "warn",
@@ -3958,7 +5425,7 @@ function isPortInUse(port) {
3958
5425
  });
3959
5426
  }
3960
5427
  async function checkOpenClawGateway() {
3961
- if (!fs10.existsSync(OPENCLAW_CONFIG_PATH)) {
5428
+ if (!fs13.existsSync(OPENCLAW_CONFIG_PATH)) {
3962
5429
  return {
3963
5430
  name: "OpenClaw gateway",
3964
5431
  status: "warn",
@@ -3966,7 +5433,7 @@ async function checkOpenClawGateway() {
3966
5433
  };
3967
5434
  }
3968
5435
  try {
3969
- const raw = JSON.parse(fs10.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
5436
+ const raw = JSON.parse(fs13.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
3970
5437
  const port = raw?.gateway?.port ?? 18789;
3971
5438
  const reachable = await isPortInUse(port);
3972
5439
  if (reachable) {
@@ -3991,10 +5458,10 @@ async function checkOpenClawGateway() {
3991
5458
  }
3992
5459
  function checkLogDir() {
3993
5460
  try {
3994
- if (!fs10.existsSync(LOGS_DIR)) {
3995
- fs10.mkdirSync(LOGS_DIR, { recursive: true });
5461
+ if (!fs13.existsSync(LOGS_DIR)) {
5462
+ fs13.mkdirSync(LOGS_DIR, { recursive: true });
3996
5463
  }
3997
- fs10.accessSync(LOGS_DIR, fs10.constants.W_OK);
5464
+ fs13.accessSync(LOGS_DIR, fs13.constants.W_OK);
3998
5465
  return {
3999
5466
  name: "Log directory writable",
4000
5467
  status: "pass",
@@ -4136,14 +5603,14 @@ async function configSetCommand(key, value) {
4136
5603
  }
4137
5604
 
4138
5605
  // src/commands/logs.ts
4139
- import fs11 from "fs";
5606
+ import fs14 from "fs";
4140
5607
  import pc7 from "picocolors";
4141
5608
  async function logsCommand(options) {
4142
5609
  if (options.clear) {
4143
5610
  await clearLogs();
4144
5611
  return;
4145
5612
  }
4146
- if (!fs11.existsSync(DEBUG_LOG_PATH)) {
5613
+ if (!fs14.existsSync(DEBUG_LOG_PATH)) {
4147
5614
  console.log(
4148
5615
  pc7.yellow("No log file found.") + " Run " + pc7.cyan("safeclaw start") + " to generate logs."
4149
5616
  );
@@ -4156,7 +5623,7 @@ async function logsCommand(options) {
4156
5623
  }
4157
5624
  }
4158
5625
  async function tailLogs(lineCount) {
4159
- const content = fs11.readFileSync(DEBUG_LOG_PATH, "utf-8");
5626
+ const content = fs14.readFileSync(DEBUG_LOG_PATH, "utf-8");
4160
5627
  const lines = content.split("\n").filter(Boolean);
4161
5628
  const tail = lines.slice(-lineCount);
4162
5629
  if (tail.length === 0) {
@@ -4174,23 +5641,23 @@ async function tailLogs(lineCount) {
4174
5641
  async function followLogs() {
4175
5642
  console.log(pc7.dim(`Following ${DEBUG_LOG_PATH} (Ctrl+C to stop)
4176
5643
  `));
4177
- if (fs11.existsSync(DEBUG_LOG_PATH)) {
4178
- const content = fs11.readFileSync(DEBUG_LOG_PATH, "utf-8");
5644
+ if (fs14.existsSync(DEBUG_LOG_PATH)) {
5645
+ const content = fs14.readFileSync(DEBUG_LOG_PATH, "utf-8");
4179
5646
  const lines = content.split("\n").filter(Boolean);
4180
5647
  const tail = lines.slice(-20);
4181
5648
  for (const line of tail) {
4182
5649
  process.stdout.write(line + "\n");
4183
5650
  }
4184
5651
  }
4185
- let position = fs11.existsSync(DEBUG_LOG_PATH) ? fs11.statSync(DEBUG_LOG_PATH).size : 0;
4186
- const watcher = fs11.watch(DEBUG_LOG_PATH, () => {
5652
+ let position = fs14.existsSync(DEBUG_LOG_PATH) ? fs14.statSync(DEBUG_LOG_PATH).size : 0;
5653
+ const watcher = fs14.watch(DEBUG_LOG_PATH, () => {
4187
5654
  try {
4188
- const stat = fs11.statSync(DEBUG_LOG_PATH);
5655
+ const stat = fs14.statSync(DEBUG_LOG_PATH);
4189
5656
  if (stat.size > position) {
4190
- const fd = fs11.openSync(DEBUG_LOG_PATH, "r");
5657
+ const fd = fs14.openSync(DEBUG_LOG_PATH, "r");
4191
5658
  const buffer = Buffer.alloc(stat.size - position);
4192
- fs11.readSync(fd, buffer, 0, buffer.length, position);
4193
- fs11.closeSync(fd);
5659
+ fs14.readSync(fd, buffer, 0, buffer.length, position);
5660
+ fs14.closeSync(fd);
4194
5661
  process.stdout.write(buffer.toString("utf-8"));
4195
5662
  position = stat.size;
4196
5663
  } else if (stat.size < position) {
@@ -4208,11 +5675,11 @@ async function followLogs() {
4208
5675
  });
4209
5676
  }
4210
5677
  async function clearLogs() {
4211
- if (!fs11.existsSync(DEBUG_LOG_PATH)) {
5678
+ if (!fs14.existsSync(DEBUG_LOG_PATH)) {
4212
5679
  console.log(pc7.dim("No log file to clear."));
4213
5680
  return;
4214
5681
  }
4215
- fs11.writeFileSync(DEBUG_LOG_PATH, "");
5682
+ fs14.writeFileSync(DEBUG_LOG_PATH, "");
4216
5683
  console.log(pc7.green("Log file cleared."));
4217
5684
  }
4218
5685