securityclaw 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/README.zh-CN.md +135 -0
  5. package/admin/public/app.js +148 -0
  6. package/admin/public/favicon.svg +21 -0
  7. package/admin/public/index.html +31 -0
  8. package/admin/public/styles.css +2715 -0
  9. package/admin/server.ts +1053 -0
  10. package/bin/install-lib.mjs +88 -0
  11. package/bin/securityclaw.mjs +66 -0
  12. package/config/policy.default.yaml +520 -0
  13. package/index.ts +2662 -0
  14. package/install.sh +22 -0
  15. package/openclaw.plugin.json +60 -0
  16. package/package.json +69 -0
  17. package/src/admin/build.ts +113 -0
  18. package/src/admin/console_notice.ts +195 -0
  19. package/src/admin/dashboard_url_state.ts +80 -0
  20. package/src/admin/openclaw_session_catalog.ts +137 -0
  21. package/src/admin/runtime_guard.ts +51 -0
  22. package/src/admin/skill_interception_store.ts +1606 -0
  23. package/src/application/commands/approval_commands.ts +189 -0
  24. package/src/approvals/chat_approval_store.ts +433 -0
  25. package/src/config/live_config.ts +144 -0
  26. package/src/config/loader.ts +168 -0
  27. package/src/config/runtime_override.ts +66 -0
  28. package/src/config/strategy_store.ts +121 -0
  29. package/src/config/validator.ts +222 -0
  30. package/src/domain/models/resource_context.ts +31 -0
  31. package/src/domain/ports/approval_repository.ts +40 -0
  32. package/src/domain/ports/notification_port.ts +29 -0
  33. package/src/domain/ports/openclaw_adapter.ts +22 -0
  34. package/src/domain/services/account_policy_engine.ts +163 -0
  35. package/src/domain/services/approval_service.ts +336 -0
  36. package/src/domain/services/approval_subject_resolver.ts +37 -0
  37. package/src/domain/services/context_inference_service.ts +502 -0
  38. package/src/domain/services/file_rule_registry.ts +171 -0
  39. package/src/domain/services/formatting_service.ts +101 -0
  40. package/src/domain/services/path_candidate_inference.ts +111 -0
  41. package/src/domain/services/sensitive_path_registry.ts +288 -0
  42. package/src/domain/services/sensitivity_label_inference.ts +161 -0
  43. package/src/domain/services/shell_filesystem_inference.ts +360 -0
  44. package/src/engine/approval_fsm.ts +104 -0
  45. package/src/engine/decision_engine.ts +39 -0
  46. package/src/engine/dlp_engine.ts +91 -0
  47. package/src/engine/rule_engine.ts +208 -0
  48. package/src/events/emitter.ts +86 -0
  49. package/src/events/schema.ts +27 -0
  50. package/src/hooks/context_guard.ts +36 -0
  51. package/src/hooks/output_guard.ts +66 -0
  52. package/src/hooks/persist_guard.ts +69 -0
  53. package/src/hooks/policy_guard.ts +222 -0
  54. package/src/hooks/result_guard.ts +88 -0
  55. package/src/i18n/locale.ts +36 -0
  56. package/src/index.ts +255 -0
  57. package/src/infrastructure/adapters/notification_adapter.ts +173 -0
  58. package/src/infrastructure/adapters/openclaw_adapter_impl.ts +59 -0
  59. package/src/infrastructure/config/plugin_config_parser.ts +105 -0
  60. package/src/monitoring/status_store.ts +612 -0
  61. package/src/types.ts +409 -0
  62. package/src/utils.ts +97 -0
@@ -0,0 +1,1053 @@
1
+ import http from "node:http";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import {
10
+ matchesAdminDecisionFilter,
11
+ normalizeAdminDecisionFilterId,
12
+ } from "../src/admin/dashboard_url_state.ts";
13
+ import { SkillInterceptionStore } from "../src/admin/skill_interception_store.ts";
14
+ import { listOpenClawChatSessions } from "../src/admin/openclaw_session_catalog.ts";
15
+ import { ConfigManager } from "../src/config/loader.ts";
16
+ import { applyRuntimeOverride, type RuntimeOverride } from "../src/config/runtime_override.ts";
17
+ import { StrategyStore } from "../src/config/strategy_store.ts";
18
+ import { AccountPolicyEngine } from "../src/domain/services/account_policy_engine.ts";
19
+ import {
20
+ PluginConfigParser,
21
+ resolveDefaultOpenClawStateDir,
22
+ type SecurityClawPluginConfig,
23
+ } from "../src/infrastructure/config/plugin_config_parser.ts";
24
+ import { normalizeFileRules } from "../src/domain/services/file_rule_registry.ts";
25
+ import {
26
+ hydrateSensitivePathConfig,
27
+ listRemovedBuiltinSensitivePathRules,
28
+ normalizeSensitivePathStrategyOverride,
29
+ } from "../src/domain/services/sensitive_path_registry.ts";
30
+ import type { SecurityClawLocale } from "../src/i18n/locale.ts";
31
+ import { pickLocalized, resolveSecurityClawLocale } from "../src/i18n/locale.ts";
32
+
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
+ const PUBLIC_DIR = path.resolve(ROOT, "admin/public");
35
+ const DEFAULT_PORT = Number(process.env.SECURITYCLAW_ADMIN_PORT ?? 4780);
36
+ const DEFAULT_OPENCLAW_HOME = resolveDefaultOpenClawStateDir();
37
+
38
+ type AdminLogger = {
39
+ info?: (message: string) => void;
40
+ warn?: (message: string) => void;
41
+ error?: (message: string) => void;
42
+ };
43
+
44
+ type AdminServerOptions = {
45
+ port?: number;
46
+ configPath?: string;
47
+ legacyOverridePath?: string;
48
+ statusPath?: string;
49
+ dbPath?: string;
50
+ openClawHome?: string;
51
+ logger?: AdminLogger;
52
+ reclaimPortOnStart?: boolean;
53
+ unrefOnStart?: boolean;
54
+ };
55
+
56
+ type AdminRuntime = {
57
+ port: number;
58
+ configPath: string;
59
+ legacyOverridePath: string;
60
+ statusPath: string;
61
+ dbPath: string;
62
+ openClawHome: string;
63
+ };
64
+
65
+ type AdminServerStartResult = {
66
+ state: "started" | "already-running";
67
+ runtime: AdminRuntime;
68
+ };
69
+
70
+ type GlobalWithSecurityClawAdmin = typeof globalThis & {
71
+ __securityclawAdminStartPromise?: Promise<AdminServerStartResult>;
72
+ };
73
+
74
+ type JsonRecord = Record<string, unknown>;
75
+ type DecisionValue = "allow" | "warn" | "challenge" | "block";
76
+
77
+ type DecisionHistoryRecord = {
78
+ ts: string;
79
+ hook: string;
80
+ trace_id: string;
81
+ actor?: string;
82
+ scope?: string;
83
+ tool?: string;
84
+ decision: DecisionValue;
85
+ decision_source?: string;
86
+ resource_scope?: string;
87
+ reasons: string[];
88
+ rules?: string;
89
+ };
90
+
91
+ type DecisionHistoryCounts = {
92
+ all: number;
93
+ allow: number;
94
+ warn: number;
95
+ challenge: number;
96
+ block: number;
97
+ };
98
+
99
+ type DecisionHistoryPage = {
100
+ items: DecisionHistoryRecord[];
101
+ total: number;
102
+ page: number;
103
+ page_size: number;
104
+ counts: DecisionHistoryCounts;
105
+ };
106
+
107
+ type DecisionHistoryRow = {
108
+ ts: string;
109
+ hook: string;
110
+ trace_id: string;
111
+ actor: string | null;
112
+ scope: string | null;
113
+ tool: string | null;
114
+ decision: DecisionValue;
115
+ decision_source: string | null;
116
+ resource_scope: string | null;
117
+ reasons_json: string;
118
+ rules: string | null;
119
+ };
120
+
121
+ const DEFAULT_DECISION_PAGE_SIZE = 12;
122
+ const MAX_DECISION_PAGE_SIZE = 100;
123
+ const EMPTY_DECISION_COUNTS: DecisionHistoryCounts = {
124
+ all: 0,
125
+ allow: 0,
126
+ warn: 0,
127
+ challenge: 0,
128
+ block: 0,
129
+ };
130
+
131
+ const ADMIN_DEFAULT_LOCALE = resolveSecurityClawLocale(process.env.SECURITYCLAW_LOCALE, "en");
132
+
133
+ function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
134
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
135
+ res.end(JSON.stringify(body));
136
+ }
137
+
138
+ function sendText(res: http.ServerResponse, status: number, body: string, contentType = "text/plain; charset=utf-8"): void {
139
+ res.writeHead(status, { "content-type": contentType });
140
+ res.end(body);
141
+ }
142
+
143
+ function localize(locale: SecurityClawLocale, zhText: string, enText: string): string {
144
+ return pickLocalized(locale, zhText, enText);
145
+ }
146
+
147
+ function readHeaderLocale(value: string | string[] | undefined): string | undefined {
148
+ if (Array.isArray(value)) {
149
+ return value[0];
150
+ }
151
+ return value;
152
+ }
153
+
154
+ function resolveRequestLocale(req: http.IncomingMessage, url: URL): SecurityClawLocale {
155
+ const headerLocale = readHeaderLocale(req.headers["x-securityclaw-locale"]);
156
+ const queryLocale = url.searchParams.get("locale") ?? url.searchParams.get("lang") ?? undefined;
157
+ return resolveSecurityClawLocale(headerLocale ?? queryLocale, ADMIN_DEFAULT_LOCALE);
158
+ }
159
+
160
+ async function readBody(req: http.IncomingMessage): Promise<JsonRecord> {
161
+ const chunks: Buffer[] = [];
162
+ for await (const chunk of req) {
163
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
164
+ }
165
+ if (chunks.length === 0) {
166
+ return {};
167
+ }
168
+ const raw = Buffer.concat(chunks).toString("utf8");
169
+ const parsed = JSON.parse(raw) as unknown;
170
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
171
+ throw new Error("request body must be a JSON object");
172
+ }
173
+ return parsed as JsonRecord;
174
+ }
175
+
176
+ function safeReadStatus(statusPath: string): JsonRecord {
177
+ if (!existsSync(statusPath)) {
178
+ return {
179
+ message: "status file not found yet",
180
+ status_path: statusPath
181
+ };
182
+ }
183
+ try {
184
+ return JSON.parse(readFileSync(statusPath, "utf8")) as JsonRecord;
185
+ } catch {
186
+ return {
187
+ message: "status file exists but cannot be parsed",
188
+ status_path: statusPath
189
+ };
190
+ }
191
+ }
192
+
193
+ function parsePositiveInteger(value: string | null | undefined, fallback: number): number {
194
+ const parsed = Number.parseInt(String(value || ""), 10);
195
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
196
+ }
197
+
198
+ function clampDecisionPageSize(value: string | null | undefined): number {
199
+ return Math.min(MAX_DECISION_PAGE_SIZE, parsePositiveInteger(value, DEFAULT_DECISION_PAGE_SIZE));
200
+ }
201
+
202
+ function parseReasons(raw: string | null): string[] {
203
+ if (!raw) {
204
+ return [];
205
+ }
206
+ try {
207
+ const parsed = JSON.parse(raw) as unknown;
208
+ return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
209
+ } catch {
210
+ return [];
211
+ }
212
+ }
213
+
214
+ function countDecisions(records: Array<{ decision?: string }>): DecisionHistoryCounts {
215
+ const counts: DecisionHistoryCounts = { ...EMPTY_DECISION_COUNTS };
216
+ counts.all = records.length;
217
+ records.forEach((record) => {
218
+ if (record.decision === "allow") {
219
+ counts.allow += 1;
220
+ return;
221
+ }
222
+ if (record.decision === "warn") {
223
+ counts.warn += 1;
224
+ return;
225
+ }
226
+ if (record.decision === "challenge") {
227
+ counts.challenge += 1;
228
+ return;
229
+ }
230
+ if (record.decision === "block") {
231
+ counts.block += 1;
232
+ }
233
+ });
234
+ return counts;
235
+ }
236
+
237
+ function readDecisionsFromStatusFallback(
238
+ statusPath: string,
239
+ filter: ReturnType<typeof normalizeAdminDecisionFilterId>,
240
+ page: number,
241
+ pageSize: number,
242
+ ): DecisionHistoryPage {
243
+ const status = safeReadStatus(statusPath);
244
+ const source = Array.isArray(status.recent_decisions) ? status.recent_decisions : [];
245
+ const records = source
246
+ .filter((item): item is JsonRecord => Boolean(item) && typeof item === "object" && !Array.isArray(item))
247
+ .map((item) => ({
248
+ ts: String(item.ts ?? ""),
249
+ hook: String(item.hook ?? ""),
250
+ trace_id: String(item.trace_id ?? ""),
251
+ decision: String(item.decision ?? "allow") as DecisionValue,
252
+ reasons: Array.isArray(item.reasons) ? item.reasons.map((value) => String(value)) : [],
253
+ ...(typeof item.actor === "string" ? { actor: item.actor } : {}),
254
+ ...(typeof item.scope === "string" ? { scope: item.scope } : {}),
255
+ ...(typeof item.tool === "string" ? { tool: item.tool } : {}),
256
+ ...(typeof item.decision_source === "string" ? { decision_source: item.decision_source } : {}),
257
+ ...(typeof item.resource_scope === "string" ? { resource_scope: item.resource_scope } : {}),
258
+ ...(typeof item.rules === "string" ? { rules: item.rules } : {}),
259
+ }));
260
+ const filtered = records.filter((record) => matchesAdminDecisionFilter(record.decision, filter));
261
+ const total = filtered.length;
262
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
263
+ const resolvedPage = Math.min(page, totalPages);
264
+ const startIndex = (resolvedPage - 1) * pageSize;
265
+
266
+ return {
267
+ items: filtered.slice(startIndex, startIndex + pageSize),
268
+ total,
269
+ page: resolvedPage,
270
+ page_size: pageSize,
271
+ counts: countDecisions(records),
272
+ };
273
+ }
274
+
275
+ function readDecisionsPage(runtime: AdminRuntime, url: URL): DecisionHistoryPage {
276
+ const filter = normalizeAdminDecisionFilterId(url.searchParams.get("decision"));
277
+ const requestedPage = parsePositiveInteger(url.searchParams.get("page"), 1);
278
+ const pageSize = clampDecisionPageSize(url.searchParams.get("page_size"));
279
+
280
+ if (!existsSync(runtime.dbPath)) {
281
+ return readDecisionsFromStatusFallback(runtime.statusPath, filter, requestedPage, pageSize);
282
+ }
283
+
284
+ let db: DatabaseSync | undefined;
285
+ try {
286
+ db = new DatabaseSync(runtime.dbPath);
287
+
288
+ const countRows = db.prepare("SELECT decision, COUNT(1) AS count FROM decisions GROUP BY decision").all() as Array<{
289
+ decision: string;
290
+ count: number;
291
+ }>;
292
+ const counts: DecisionHistoryCounts = { ...EMPTY_DECISION_COUNTS };
293
+ countRows.forEach((row) => {
294
+ if (row.decision === "allow") {
295
+ counts.allow = Number(row.count ?? 0);
296
+ } else if (row.decision === "warn") {
297
+ counts.warn = Number(row.count ?? 0);
298
+ } else if (row.decision === "challenge") {
299
+ counts.challenge = Number(row.count ?? 0);
300
+ } else if (row.decision === "block") {
301
+ counts.block = Number(row.count ?? 0);
302
+ }
303
+ });
304
+ counts.all = counts.allow + counts.warn + counts.challenge + counts.block;
305
+
306
+ const totalRow =
307
+ filter === "all"
308
+ ? (db.prepare("SELECT COUNT(1) AS count FROM decisions").get() as { count: number })
309
+ : (db.prepare("SELECT COUNT(1) AS count FROM decisions WHERE decision = ?").get(filter) as {
310
+ count: number;
311
+ });
312
+ const total = Number(totalRow.count ?? 0);
313
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
314
+ const page = Math.min(requestedPage, totalPages);
315
+ const offset = (page - 1) * pageSize;
316
+ const rows =
317
+ filter === "all"
318
+ ? (db
319
+ .prepare(
320
+ `SELECT ts, hook, trace_id, actor, scope, tool, decision, decision_source, resource_scope, reasons_json, rules
321
+ FROM decisions
322
+ ORDER BY id DESC
323
+ LIMIT ? OFFSET ?`,
324
+ )
325
+ .all(pageSize, offset) as DecisionHistoryRow[])
326
+ : (db
327
+ .prepare(
328
+ `SELECT ts, hook, trace_id, actor, scope, tool, decision, decision_source, resource_scope, reasons_json, rules
329
+ FROM decisions
330
+ WHERE decision = ?
331
+ ORDER BY id DESC
332
+ LIMIT ? OFFSET ?`,
333
+ )
334
+ .all(filter, pageSize, offset) as DecisionHistoryRow[]);
335
+
336
+ return {
337
+ items: rows.map((row) => ({
338
+ ts: row.ts,
339
+ hook: row.hook,
340
+ trace_id: row.trace_id,
341
+ decision: row.decision,
342
+ reasons: parseReasons(row.reasons_json),
343
+ ...(row.actor ? { actor: row.actor } : {}),
344
+ ...(row.scope ? { scope: row.scope } : {}),
345
+ ...(row.tool ? { tool: row.tool } : {}),
346
+ ...(row.decision_source ? { decision_source: row.decision_source } : {}),
347
+ ...(row.resource_scope ? { resource_scope: row.resource_scope } : {}),
348
+ ...(row.rules ? { rules: row.rules } : {}),
349
+ })),
350
+ total,
351
+ page,
352
+ page_size: pageSize,
353
+ counts,
354
+ };
355
+ } catch {
356
+ return readDecisionsFromStatusFallback(runtime.statusPath, filter, requestedPage, pageSize);
357
+ } finally {
358
+ db?.close();
359
+ }
360
+ }
361
+
362
+ function summarizeTotals(status: JsonRecord): JsonRecord {
363
+ const hooks = (status.hooks ?? {}) as Record<string, Record<string, number>>;
364
+ let total = 0;
365
+ let block = 0;
366
+ let challenge = 0;
367
+ let warn = 0;
368
+ let allow = 0;
369
+ for (const value of Object.values(hooks)) {
370
+ total += Number(value.total ?? 0);
371
+ block += Number(value.block ?? 0);
372
+ challenge += Number(value.challenge ?? 0);
373
+ warn += Number(value.warn ?? 0);
374
+ allow += Number(value.allow ?? 0);
375
+ }
376
+ return { total, allow, warn, challenge, block };
377
+ }
378
+
379
+ function readAccountPolicies(strategyStore: StrategyStore) {
380
+ return AccountPolicyEngine.sanitize(strategyStore.readOverride()?.account_policies);
381
+ }
382
+
383
+ function readSensitivePathStrategy(
384
+ baseConfig: ReturnType<ConfigManager["getConfig"]>,
385
+ override: RuntimeOverride | undefined,
386
+ ) {
387
+ const baseSensitivity = hydrateSensitivePathConfig(baseConfig.sensitivity);
388
+ const sensitivityOverride = normalizeSensitivePathStrategyOverride(override?.sensitivity);
389
+ return {
390
+ path_rules: baseConfig.sensitivity.path_rules,
391
+ effective_path_rules: baseSensitivity.path_rules,
392
+ custom_path_rules: sensitivityOverride?.custom_path_rules ?? [],
393
+ disabled_builtin_ids: sensitivityOverride?.disabled_builtin_ids ?? [],
394
+ removed_builtin_path_rules: listRemovedBuiltinSensitivePathRules(baseSensitivity, sensitivityOverride),
395
+ };
396
+ }
397
+
398
+ function isExistingDirectory(value: string): boolean {
399
+ try {
400
+ return statSync(value).isDirectory();
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+
406
+ function listFileRuleDirectoryOptions(existingDirectories: string[] = []): string[] {
407
+ return Array.from(new Set(existingDirectories.map((entry) => path.normalize(entry))))
408
+ .filter((entry) => path.isAbsolute(entry))
409
+ .filter((entry) => isExistingDirectory(entry))
410
+ .sort((left, right) => left.localeCompare(right));
411
+ }
412
+
413
+ function normalizeBrowsePath(candidate: string | null | undefined, fallback: string): string {
414
+ if (!candidate || typeof candidate !== "string") {
415
+ return fallback;
416
+ }
417
+ const trimmed = candidate.trim();
418
+ if (!trimmed) {
419
+ return fallback;
420
+ }
421
+ const expanded = trimmed === "~"
422
+ ? os.homedir()
423
+ : trimmed.startsWith("~/")
424
+ ? path.join(os.homedir(), trimmed.slice(2))
425
+ : trimmed;
426
+ if (!path.isAbsolute(expanded)) {
427
+ return fallback;
428
+ }
429
+ const normalized = path.normalize(expanded);
430
+ return isExistingDirectory(normalized) ? normalized : fallback;
431
+ }
432
+
433
+ function listDirectoryChildren(absolutePath: string): Array<{ name: string; path: string }> {
434
+ const entries = readdirSync(absolutePath, { withFileTypes: true });
435
+ const directories: Array<{ name: string; path: string }> = [];
436
+ entries.forEach((entry) => {
437
+ const childPath = path.join(absolutePath, entry.name);
438
+ if (entry.isDirectory() || (entry.isSymbolicLink() && isExistingDirectory(childPath))) {
439
+ directories.push({
440
+ name: entry.name,
441
+ path: path.normalize(childPath),
442
+ });
443
+ }
444
+ });
445
+ return directories
446
+ .sort((left, right) => left.name.localeCompare(right.name))
447
+ .slice(0, 300);
448
+ }
449
+
450
+ function listDirectoryBrowseRoots(existingDirectories: string[] = []): string[] {
451
+ const homeDir = path.normalize(os.homedir());
452
+ const homeRoot = path.parse(homeDir).root || "/";
453
+ const extras = Array.from(
454
+ new Set(existingDirectories.map((entry) => path.normalize(entry))),
455
+ )
456
+ .filter((entry) => path.isAbsolute(entry))
457
+ .filter((entry) => isExistingDirectory(entry))
458
+ .filter((entry) => entry !== homeDir && entry !== homeRoot)
459
+ .sort((left, right) => left.localeCompare(right));
460
+ const orderedRoots = [homeDir, ...(homeRoot !== homeDir ? [homeRoot] : []), ...extras];
461
+ return Array.from(new Set(orderedRoots));
462
+ }
463
+
464
+ function handleApi(
465
+ req: http.IncomingMessage,
466
+ res: http.ServerResponse,
467
+ url: URL,
468
+ runtime: AdminRuntime,
469
+ strategyStore: StrategyStore,
470
+ skillStore: SkillInterceptionStore,
471
+ ): void {
472
+ const locale = resolveRequestLocale(req, url);
473
+
474
+ if (req.method === "GET" && url.pathname === "/api/status") {
475
+ try {
476
+ const status = safeReadStatus(runtime.statusPath);
477
+ const { effective, override } = readEffectivePolicy(runtime, strategyStore);
478
+ sendJson(res, 200, {
479
+ paths: {
480
+ config_path: runtime.configPath,
481
+ legacy_override_path: runtime.legacyOverridePath,
482
+ status_path: runtime.statusPath,
483
+ db_path: runtime.dbPath
484
+ },
485
+ status,
486
+ totals: summarizeTotals(status),
487
+ effective: {
488
+ environment: effective.environment,
489
+ policy_version: effective.policy_version,
490
+ policy_count: effective.policies.length,
491
+ file_rule_count: effective.file_rules.length,
492
+ event_sink_enabled: Boolean(effective.event_sink.webhook_url),
493
+ strategy_loaded: Boolean(override)
494
+ }
495
+ });
496
+ } catch (error) {
497
+ sendJson(res, 500, { error: String(error) });
498
+ }
499
+ return;
500
+ }
501
+
502
+ if (req.method === "GET" && url.pathname === "/api/decisions") {
503
+ try {
504
+ sendJson(res, 200, readDecisionsPage(runtime, url));
505
+ } catch (error) {
506
+ sendJson(res, 500, { error: String(error) });
507
+ }
508
+ return;
509
+ }
510
+
511
+ if (req.method === "GET" && url.pathname === "/api/skills/status") {
512
+ try {
513
+ sendJson(res, 200, skillStore.getStatus());
514
+ } catch (error) {
515
+ sendJson(res, 500, { error: String(error) });
516
+ }
517
+ return;
518
+ }
519
+
520
+ if (req.method === "GET" && url.pathname === "/api/skills") {
521
+ try {
522
+ sendJson(
523
+ res,
524
+ 200,
525
+ skillStore.listSkills({
526
+ risk: url.searchParams.get("risk"),
527
+ state: url.searchParams.get("state"),
528
+ source: url.searchParams.get("source"),
529
+ drift: url.searchParams.get("drift"),
530
+ intercepted: url.searchParams.get("intercepted"),
531
+ }),
532
+ );
533
+ } catch (error) {
534
+ sendJson(res, 500, { error: String(error) });
535
+ }
536
+ return;
537
+ }
538
+
539
+ if (req.method === "PUT" && url.pathname === "/api/skills/policy") {
540
+ void (async () => {
541
+ try {
542
+ const body = await readBody(req);
543
+ const policy = skillStore.writePolicyConfig(body);
544
+ sendJson(res, 200, {
545
+ ok: true,
546
+ restart_required: false,
547
+ message: localize(
548
+ locale,
549
+ "Skill 拦截策略已保存到本地 SQLite,并会在下一次扫描与后台刷新时自动生效。",
550
+ "Skill interception policy has been saved to local SQLite and will apply on the next scan and dashboard refresh.",
551
+ ),
552
+ policy,
553
+ });
554
+ } catch (error) {
555
+ sendJson(res, 400, { ok: false, error: String(error) });
556
+ }
557
+ })();
558
+ return;
559
+ }
560
+
561
+ const skillRouteMatch = url.pathname.match(/^\/api\/skills\/([^/]+?)(?:\/(rescan|quarantine|trust-override))?$/);
562
+ if (skillRouteMatch) {
563
+ const skillId = decodeURIComponent(skillRouteMatch[1]);
564
+ const action = skillRouteMatch[2];
565
+
566
+ if (req.method === "GET" && !action) {
567
+ try {
568
+ const detail = skillStore.getSkill(skillId);
569
+ if (!detail) {
570
+ sendJson(res, 404, { error: "skill not found" });
571
+ return;
572
+ }
573
+ sendJson(res, 200, detail);
574
+ } catch (error) {
575
+ sendJson(res, 500, { error: String(error) });
576
+ }
577
+ return;
578
+ }
579
+
580
+ if (req.method === "POST" && action === "rescan") {
581
+ try {
582
+ const detail = skillStore.rescanSkill(skillId, "admin-ui");
583
+ if (!detail) {
584
+ sendJson(res, 404, { error: "skill not found" });
585
+ return;
586
+ }
587
+ sendJson(res, 200, {
588
+ ok: true,
589
+ message: localize(
590
+ locale,
591
+ "Skill 已完成重扫,风险结论和最新信号已刷新。",
592
+ "The skill has been rescanned and the latest risk signals have been refreshed.",
593
+ ),
594
+ detail,
595
+ });
596
+ } catch (error) {
597
+ sendJson(res, 400, { ok: false, error: String(error) });
598
+ }
599
+ return;
600
+ }
601
+
602
+ if (req.method === "POST" && action === "quarantine") {
603
+ void (async () => {
604
+ try {
605
+ const body = await readBody(req);
606
+ const detail = skillStore.setQuarantine(skillId, {
607
+ quarantined: Boolean(body.quarantined),
608
+ updatedBy: typeof body.updated_by === "string" ? body.updated_by : "admin-ui",
609
+ });
610
+ if (!detail) {
611
+ sendJson(res, 404, { error: "skill not found" });
612
+ return;
613
+ }
614
+ sendJson(res, 200, {
615
+ ok: true,
616
+ message: body.quarantined
617
+ ? localize(
618
+ locale,
619
+ "Skill 已隔离,高危调用会按更严格策略阻断。",
620
+ "The skill is quarantined and high-risk calls will be blocked more aggressively.",
621
+ )
622
+ : localize(
623
+ locale,
624
+ "Skill 已解除隔离,后续将按风险策略继续评估。",
625
+ "The skill quarantine has been removed and future actions will follow risk policy again.",
626
+ ),
627
+ detail,
628
+ });
629
+ } catch (error) {
630
+ sendJson(res, 400, { ok: false, error: String(error) });
631
+ }
632
+ })();
633
+ return;
634
+ }
635
+
636
+ if (req.method === "POST" && action === "trust-override") {
637
+ void (async () => {
638
+ try {
639
+ const body = await readBody(req);
640
+ const detail = skillStore.setTrustOverride(skillId, {
641
+ enabled: Boolean(body.enabled),
642
+ updatedBy: typeof body.updated_by === "string" ? body.updated_by : "admin-ui",
643
+ ...(typeof body.hours === "number" ? { hours: body.hours } : {}),
644
+ });
645
+ if (!detail) {
646
+ sendJson(res, 404, { error: "skill not found" });
647
+ return;
648
+ }
649
+ sendJson(res, 200, {
650
+ ok: true,
651
+ message: body.enabled
652
+ ? localize(
653
+ locale,
654
+ "Skill 已设置临时受信覆盖,仍会保留审计记录与过期时间。",
655
+ "A temporary trust override has been applied. Audit records and expiry time are preserved.",
656
+ )
657
+ : localize(
658
+ locale,
659
+ "Skill 的受信覆盖已撤销,风险矩阵恢复正常生效。",
660
+ "The trust override has been removed and the normal risk matrix is active again.",
661
+ ),
662
+ detail,
663
+ });
664
+ } catch (error) {
665
+ sendJson(res, 400, { ok: false, error: String(error) });
666
+ }
667
+ })();
668
+ return;
669
+ }
670
+ }
671
+
672
+ if (req.method === "GET" && url.pathname === "/api/file-rule/directories") {
673
+ try {
674
+ const { effective } = readEffectivePolicy(runtime, strategyStore);
675
+ const existingRuleDirectories = listFileRuleDirectoryOptions(effective.file_rules.map((rule) => rule.directory));
676
+ const roots = listDirectoryBrowseRoots(existingRuleDirectories);
677
+ const fallbackPath = roots[0] ?? path.normalize(os.homedir());
678
+ const currentPath = normalizeBrowsePath(url.searchParams.get("path"), fallbackPath);
679
+ const parentPath = path.dirname(currentPath);
680
+ sendJson(res, 200, {
681
+ current_path: currentPath,
682
+ ...(parentPath && parentPath !== currentPath ? { parent_path: parentPath } : {}),
683
+ roots,
684
+ directories: listDirectoryChildren(currentPath),
685
+ });
686
+ } catch (error) {
687
+ sendJson(res, 500, { error: String(error) });
688
+ }
689
+ return;
690
+ }
691
+
692
+ if (req.method === "GET" && url.pathname === "/api/strategy") {
693
+ try {
694
+ const { base, effective, override } = readEffectivePolicy(runtime, strategyStore);
695
+ sendJson(res, 200, {
696
+ paths: {
697
+ config_path: runtime.configPath,
698
+ db_path: runtime.dbPath
699
+ },
700
+ override: override ?? {},
701
+ strategy: {
702
+ environment: effective.environment,
703
+ policy_version: effective.policy_version,
704
+ policies: effective.policies,
705
+ file_rules: effective.file_rules,
706
+ file_rule_directories: listFileRuleDirectoryOptions(effective.file_rules.map((rule) => rule.directory)),
707
+ sensitivity: {
708
+ ...readSensitivePathStrategy(base, override),
709
+ effective_path_rules: effective.sensitivity.path_rules,
710
+ }
711
+ }
712
+ });
713
+ } catch (error) {
714
+ sendJson(res, 500, { error: String(error) });
715
+ }
716
+ return;
717
+ }
718
+
719
+ if (req.method === "GET" && url.pathname === "/api/accounts") {
720
+ try {
721
+ const override = strategyStore.readOverride() ?? {};
722
+ sendJson(res, 200, {
723
+ paths: {
724
+ db_path: runtime.dbPath,
725
+ openclaw_home: runtime.openClawHome
726
+ },
727
+ account_policies: AccountPolicyEngine.sanitize(override.account_policies),
728
+ sessions: listOpenClawChatSessions(runtime.openClawHome)
729
+ });
730
+ } catch (error) {
731
+ sendJson(res, 500, { error: String(error) });
732
+ }
733
+ return;
734
+ }
735
+
736
+ if (req.method === "PUT" && url.pathname === "/api/strategy") {
737
+ void (async () => {
738
+ try {
739
+ const body = await readBody(req);
740
+ const current = strategyStore.readOverride() ?? {};
741
+ const hasSensitivity = Object.prototype.hasOwnProperty.call(body, "sensitivity");
742
+ const nextSensitivity = hasSensitivity
743
+ ? normalizeSensitivePathStrategyOverride(body.sensitivity)
744
+ : current.sensitivity;
745
+ const hasFileRules = Object.prototype.hasOwnProperty.call(body, "file_rules");
746
+ const nextFileRules = hasFileRules
747
+ ? normalizeFileRules(body.file_rules)
748
+ : normalizeFileRules(current.file_rules);
749
+
750
+ const nextOverride: RuntimeOverride = {
751
+ ...current,
752
+ updated_at: new Date().toISOString(),
753
+ environment:
754
+ typeof body.environment === "string" ? body.environment : current.environment,
755
+ policy_version:
756
+ typeof body.policy_version === "string" ? body.policy_version : current.policy_version,
757
+ policies:
758
+ Array.isArray(body.policies)
759
+ ? (body.policies as RuntimeOverride["policies"])
760
+ : current.policies,
761
+ ...(hasFileRules ? { file_rules: nextFileRules } : {}),
762
+ ...(hasSensitivity ? { sensitivity: nextSensitivity } : {})
763
+ };
764
+
765
+ const base = ConfigManager.fromFile(runtime.configPath).getConfig();
766
+ const validated = applyRuntimeOverride(base, nextOverride);
767
+ strategyStore.writeOverride(nextOverride);
768
+
769
+ sendJson(res, 200, {
770
+ ok: true,
771
+ restart_required: false,
772
+ message: localize(
773
+ locale,
774
+ "策略已保存到本地 SQLite,并会在下一次安全决策时自动生效。",
775
+ "Strategy has been saved to local SQLite and will apply on the next security decision.",
776
+ ),
777
+ effective: {
778
+ environment: validated.environment,
779
+ policy_version: validated.policy_version,
780
+ policy_count: validated.policies.length,
781
+ file_rule_count: validated.file_rules.length,
782
+ sensitive_path_rule_count: validated.sensitivity.path_rules.length
783
+ }
784
+ });
785
+ } catch (error) {
786
+ sendJson(res, 400, { ok: false, error: String(error) });
787
+ }
788
+ })();
789
+ return;
790
+ }
791
+
792
+ if (req.method === "PUT" && url.pathname === "/api/accounts") {
793
+ void (async () => {
794
+ try {
795
+ const body = await readBody(req);
796
+ const current = strategyStore.readOverride() ?? {};
797
+ const nextOverride: RuntimeOverride = {
798
+ ...current,
799
+ updated_at: new Date().toISOString(),
800
+ account_policies: AccountPolicyEngine.sanitize(body.account_policies)
801
+ };
802
+
803
+ const base = ConfigManager.fromFile(runtime.configPath).getConfig();
804
+ applyRuntimeOverride(base, nextOverride);
805
+ strategyStore.writeOverride(nextOverride);
806
+
807
+ sendJson(res, 200, {
808
+ ok: true,
809
+ restart_required: false,
810
+ message: localize(
811
+ locale,
812
+ "账号策略已保存到本地 SQLite,并会在下一次安全决策时自动生效。",
813
+ "Account policies have been saved to local SQLite and will apply on the next security decision.",
814
+ ),
815
+ account_policy_count: readAccountPolicies(strategyStore).length
816
+ });
817
+ } catch (error) {
818
+ sendJson(res, 400, { ok: false, error: String(error) });
819
+ }
820
+ })();
821
+ return;
822
+ }
823
+
824
+ sendJson(res, 404, { error: "not found" });
825
+ }
826
+
827
+ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {
828
+ const relative = url.pathname === "/" ? "/index.html" : url.pathname;
829
+ const absolute = path.resolve(PUBLIC_DIR, `.${relative}`);
830
+ if (!absolute.startsWith(PUBLIC_DIR) || !existsSync(absolute)) {
831
+ sendText(res, 404, "Not found");
832
+ return;
833
+ }
834
+ const ext = path.extname(absolute);
835
+ const contentType =
836
+ ext === ".html"
837
+ ? "text/html; charset=utf-8"
838
+ : ext === ".js"
839
+ ? "text/javascript; charset=utf-8"
840
+ : ext === ".css"
841
+ ? "text/css; charset=utf-8"
842
+ : ext === ".svg"
843
+ ? "image/svg+xml"
844
+ : "application/octet-stream";
845
+ sendText(res, 200, readFileSync(absolute, "utf8"), contentType);
846
+ }
847
+
848
+ function readEffectivePolicy(runtime: AdminRuntime, strategyStore: StrategyStore): {
849
+ base: ReturnType<ConfigManager["getConfig"]>;
850
+ effective: ReturnType<ConfigManager["getConfig"]>;
851
+ override?: RuntimeOverride;
852
+ } {
853
+ const base = ConfigManager.fromFile(runtime.configPath).getConfig();
854
+ const override = strategyStore.readOverride();
855
+ const effective = override ? applyRuntimeOverride(base, override) : base;
856
+ return override !== undefined ? { base, effective, override } : { base, effective };
857
+ }
858
+
859
+ function resolveAdminPluginConfig(options: AdminServerOptions): SecurityClawPluginConfig {
860
+ return {
861
+ ...(process.env.SECURITYCLAW_CONFIG_PATH ? { configPath: process.env.SECURITYCLAW_CONFIG_PATH } : {}),
862
+ ...(process.env.SECURITYCLAW_LEGACY_OVERRIDE_PATH ? { overridePath: process.env.SECURITYCLAW_LEGACY_OVERRIDE_PATH } : {}),
863
+ ...(process.env.SECURITYCLAW_STATUS_PATH ? { statusPath: process.env.SECURITYCLAW_STATUS_PATH } : {}),
864
+ ...(process.env.SECURITYCLAW_DB_PATH ? { dbPath: process.env.SECURITYCLAW_DB_PATH } : {}),
865
+ ...(options.configPath !== undefined ? { configPath: options.configPath } : {}),
866
+ ...(options.legacyOverridePath !== undefined ? { overridePath: options.legacyOverridePath } : {}),
867
+ ...(options.statusPath !== undefined ? { statusPath: options.statusPath } : {}),
868
+ ...(options.dbPath !== undefined ? { dbPath: options.dbPath } : {}),
869
+ };
870
+ }
871
+
872
+ function resolveRuntime(options: AdminServerOptions): AdminRuntime {
873
+ const openClawHome = options.openClawHome ?? DEFAULT_OPENCLAW_HOME;
874
+ const resolved = PluginConfigParser.resolve(ROOT, resolveAdminPluginConfig(options), openClawHome);
875
+ return {
876
+ port: options.port ?? DEFAULT_PORT,
877
+ configPath: resolved.configPath,
878
+ legacyOverridePath: resolved.legacyOverridePath,
879
+ statusPath: resolved.statusPath,
880
+ dbPath: resolved.dbPath,
881
+ openClawHome
882
+ };
883
+ }
884
+
885
+ function parsePids(output: string): number[] {
886
+ return output
887
+ .split(/\s+/)
888
+ .map((value) => Number(value.trim()))
889
+ .filter((value) => Number.isInteger(value) && value > 0);
890
+ }
891
+
892
+ function listListeningPidsByPort(port: number): number[] {
893
+ const result = spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf8" });
894
+ if (result.error || result.status !== 0) {
895
+ return [];
896
+ }
897
+ return parsePids(result.stdout);
898
+ }
899
+
900
+ function readProcessCommand(pid: number): string {
901
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
902
+ if (result.error || result.status !== 0) {
903
+ return "";
904
+ }
905
+ return result.stdout.trim();
906
+ }
907
+
908
+ function looksLikeOpenClawProcess(command: string): boolean {
909
+ return /(openclaw|securityclaw|admin\/server|gateway)/i.test(command);
910
+ }
911
+
912
+ function reclaimAdminPort(port: number, logger: AdminLogger): void {
913
+ const pids = listListeningPidsByPort(port);
914
+ for (const pid of pids) {
915
+ if (pid === process.pid) {
916
+ continue;
917
+ }
918
+
919
+ const command = readProcessCommand(pid);
920
+ if (!looksLikeOpenClawProcess(command)) {
921
+ logger.warn?.(
922
+ `SecurityClaw admin: port ${port} is in use by pid=${pid}, but command is not OpenClaw/SecurityClaw; skip terminate.`,
923
+ );
924
+ continue;
925
+ }
926
+
927
+ try {
928
+ process.kill(pid, "SIGKILL");
929
+ logger.warn?.(`SecurityClaw admin: killed stale admin process pid=${pid} on port ${port}.`);
930
+ } catch (error) {
931
+ logger.warn?.(`SecurityClaw admin: failed to kill pid=${pid} on port ${port} (${String(error)}).`);
932
+ }
933
+ }
934
+ }
935
+
936
+ export function startAdminServer(options: AdminServerOptions = {}): Promise<AdminServerStartResult> {
937
+ const state = globalThis as GlobalWithSecurityClawAdmin;
938
+ if (state.__securityclawAdminStartPromise) {
939
+ return state.__securityclawAdminStartPromise;
940
+ }
941
+
942
+ const runtime = resolveRuntime(options);
943
+ const logger: AdminLogger = options.logger ?? {
944
+ info: (message: string) => console.log(message),
945
+ warn: (message: string) => console.warn(message),
946
+ error: (message: string) => console.error(message)
947
+ };
948
+ const strategyStore = new StrategyStore(runtime.dbPath, {
949
+ legacyOverridePath: runtime.legacyOverridePath,
950
+ logger: {
951
+ warn: (message: string) => logger.warn?.(`SecurityClaw strategy store: ${message}`)
952
+ }
953
+ });
954
+ const skillStore = new SkillInterceptionStore(runtime.dbPath, {
955
+ openClawHome: runtime.openClawHome,
956
+ });
957
+ let strategyStoreClosed = false;
958
+ let skillStoreClosed = false;
959
+ function closeStrategyStore(): void {
960
+ if (strategyStoreClosed) {
961
+ return;
962
+ }
963
+ strategyStoreClosed = true;
964
+ try {
965
+ strategyStore.close();
966
+ } catch {
967
+ // Ignore close errors during shutdown paths.
968
+ }
969
+ }
970
+ function closeSkillStore(): void {
971
+ if (skillStoreClosed) {
972
+ return;
973
+ }
974
+ skillStoreClosed = true;
975
+ try {
976
+ skillStore.close();
977
+ } catch {
978
+ // Ignore close errors during shutdown paths.
979
+ }
980
+ }
981
+ const reclaimPortOnStart = options.reclaimPortOnStart ?? true;
982
+ const unrefOnStart = options.unrefOnStart ?? false;
983
+
984
+ if (reclaimPortOnStart) {
985
+ reclaimAdminPort(runtime.port, logger);
986
+ }
987
+
988
+ const startPromise = new Promise<AdminServerStartResult>((resolve, reject) => {
989
+ const server = http.createServer((req, res) => {
990
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
991
+ if (url.pathname.startsWith("/api/")) {
992
+ handleApi(req, res, url, runtime, strategyStore, skillStore);
993
+ return;
994
+ }
995
+ serveStatic(req, res, url);
996
+ });
997
+
998
+ let resolved = false;
999
+ server.once("error", (error: Error & { code?: string }) => {
1000
+ if (error.code === "EADDRINUSE") {
1001
+ resolved = true;
1002
+ closeStrategyStore();
1003
+ closeSkillStore();
1004
+ logger.warn?.(
1005
+ `SecurityClaw admin already running on http://127.0.0.1:${runtime.port} (port in use); reusing existing server.`,
1006
+ );
1007
+ resolve({ state: "already-running", runtime });
1008
+ return;
1009
+ }
1010
+ closeStrategyStore();
1011
+ closeSkillStore();
1012
+ logger.error?.(`SecurityClaw admin failed to start: ${String(error)}`);
1013
+ reject(error);
1014
+ });
1015
+
1016
+ server.listen(runtime.port, "127.0.0.1", () => {
1017
+ resolved = true;
1018
+ if (unrefOnStart) {
1019
+ server.unref();
1020
+ }
1021
+ logger.info?.(`SecurityClaw admin listening on http://127.0.0.1:${runtime.port}`);
1022
+ logger.info?.(`Using config: ${runtime.configPath}`);
1023
+ logger.info?.(`Using strategy db: ${runtime.dbPath}`);
1024
+ logger.info?.(`Using legacy override import path: ${runtime.legacyOverridePath}`);
1025
+ logger.info?.(`Using status: ${runtime.statusPath}`);
1026
+ resolve({ state: "started", runtime });
1027
+ });
1028
+
1029
+ server.on("close", () => {
1030
+ const current = globalThis as GlobalWithSecurityClawAdmin;
1031
+ if (current.__securityclawAdminStartPromise && resolved) {
1032
+ delete current.__securityclawAdminStartPromise;
1033
+ }
1034
+ closeStrategyStore();
1035
+ closeSkillStore();
1036
+ });
1037
+ });
1038
+
1039
+ state.__securityclawAdminStartPromise = startPromise.catch((error) => {
1040
+ delete state.__securityclawAdminStartPromise;
1041
+ throw error;
1042
+ });
1043
+ return state.__securityclawAdminStartPromise;
1044
+ }
1045
+
1046
+ const thisFile = fileURLToPath(import.meta.url);
1047
+ const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : undefined;
1048
+ if (entryFile && entryFile === thisFile) {
1049
+ void startAdminServer().catch((error) => {
1050
+ console.error(`SecurityClaw admin startup failed: ${String(error)}`);
1051
+ process.exitCode = 1;
1052
+ });
1053
+ }