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
package/index.ts ADDED
@@ -0,0 +1,2662 @@
1
+ import os from "node:os";
2
+ import { isIP } from "node:net";
3
+ import path from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import type {
8
+ OpenClawPluginApi
9
+ } from "openclaw/plugin-sdk";
10
+ import * as OpenClawCompat from "openclaw/plugin-sdk/compat";
11
+
12
+ import { LiveConfigResolver, type LiveConfigSnapshot } from "./src/config/live_config.ts";
13
+ import {
14
+ ChatApprovalStore,
15
+ type ApprovalChannel,
16
+ type ChatApprovalApprover,
17
+ type ChatApprovalTarget,
18
+ type StoredApprovalNotification,
19
+ type StoredApprovalRecord,
20
+ createApprovalRequestKey,
21
+ } from "./src/approvals/chat_approval_store.ts";
22
+ import { DecisionEngine } from "./src/engine/decision_engine.ts";
23
+ import { DlpEngine } from "./src/engine/dlp_engine.ts";
24
+ import { RuleEngine } from "./src/engine/rule_engine.ts";
25
+ import { EventEmitter, HttpEventSink } from "./src/events/emitter.ts";
26
+ import {
27
+ PluginConfigParser,
28
+ type ResolvedPluginRuntime,
29
+ type SecurityClawPluginConfig,
30
+ } from "./src/infrastructure/config/plugin_config_parser.ts";
31
+ import { RuntimeStatusStore } from "./src/monitoring/status_store.ts";
32
+ import { startAdminServer } from "./admin/server.ts";
33
+ import { ensureAdminAssetsBuilt } from "./src/admin/build.ts";
34
+ import { announceAdminConsole, shouldAnnounceAdminConsoleForArgv } from "./src/admin/console_notice.ts";
35
+ import { shouldAutoStartAdminServer } from "./src/admin/runtime_guard.ts";
36
+ import { AccountPolicyEngine } from "./src/domain/services/account_policy_engine.ts";
37
+ import { ApprovalSubjectResolver } from "./src/domain/services/approval_subject_resolver.ts";
38
+ import {
39
+ extractEmbeddedPathCandidates,
40
+ hasEmbeddedPathHint,
41
+ isPathLikeCandidate,
42
+ resolvePathCandidate,
43
+ } from "./src/domain/services/path_candidate_inference.ts";
44
+ import { defaultFileRuleReasonCode, matchFileRule } from "./src/domain/services/file_rule_registry.ts";
45
+ import { hydrateSensitivePathConfig } from "./src/domain/services/sensitive_path_registry.ts";
46
+ import { inferShellFilesystemSemantic } from "./src/domain/services/shell_filesystem_inference.ts";
47
+ import { inferSensitivityLabels } from "./src/domain/services/sensitivity_label_inference.ts";
48
+ import type { SecurityClawLocale } from "./src/i18n/locale.ts";
49
+ import { localeForIntl, pickLocalized, resolveSecurityClawLocale } from "./src/i18n/locale.ts";
50
+ import type {
51
+ AccountPolicyRecord,
52
+ Decision,
53
+ DecisionContext,
54
+ DecisionSource,
55
+ DlpFinding,
56
+ ResourceScope,
57
+ RuleMatch,
58
+ SecurityClawConfig,
59
+ SecurityDecisionEvent
60
+ } from "./src/types.ts";
61
+
62
+ type RuntimeDependencies = {
63
+ config: SecurityClawConfig;
64
+ ruleEngine: RuleEngine;
65
+ decisionEngine: DecisionEngine;
66
+ accountPolicyEngine: AccountPolicyEngine;
67
+ dlpEngine: DlpEngine;
68
+ emitter: EventEmitter;
69
+ overrideLoaded: boolean;
70
+ };
71
+
72
+ type SecurityClawHookContext = {
73
+ agentId?: string;
74
+ sessionKey?: string;
75
+ sessionId?: string;
76
+ runId?: string;
77
+ workspaceDir?: string;
78
+ channelId?: string;
79
+ };
80
+
81
+ type SecurityClawApprovalCommandContext = {
82
+ channel?: string;
83
+ senderId?: string;
84
+ from?: string;
85
+ to?: string;
86
+ accountId?: string;
87
+ args?: string;
88
+ isAuthorizedSender: boolean;
89
+ };
90
+
91
+ type ApprovalNotificationResult = {
92
+ sent: boolean;
93
+ notifications: StoredApprovalNotification[];
94
+ };
95
+
96
+ type ApprovalGrantMode = "temporary" | "longterm";
97
+
98
+ type ResolvedApprovalBridge = {
99
+ enabled: boolean;
100
+ targets: ChatApprovalTarget[];
101
+ approvers: ChatApprovalApprover[];
102
+ };
103
+
104
+ const PLUGIN_ROOT = path.dirname(fileURLToPath(import.meta.url));
105
+ const HOME_DIR = os.homedir();
106
+ const APPROVAL_APPROVE_COMMAND = "securityclaw-approve";
107
+ const APPROVAL_REJECT_COMMAND = "securityclaw-reject";
108
+ const APPROVAL_PENDING_COMMAND = "securityclaw-pending";
109
+ const APPROVAL_LONG_GRANT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
110
+ const APPROVAL_DISPLAY_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
111
+ const APPROVAL_NOTIFICATION_MAX_ATTEMPTS = 3;
112
+ const APPROVAL_NOTIFICATION_RETRY_DELAYS_MS = [250, 750];
113
+ const APPROVAL_NOTIFICATION_RESEND_COOLDOWN_MS = 60_000;
114
+ const APPROVAL_NOTIFICATION_HISTORY_LIMIT = 12;
115
+ const SECURITYCLAW_PROTECTED_STORAGE_RULE_ID = "internal:securityclaw-protected-storage";
116
+ const SECURITYCLAW_PROTECTED_STORAGE_REASON = "SECURITYCLAW_STATE_STORAGE_PROTECTED";
117
+ const PATH_KEY_PATTERN = /(path|paths|file|files|dir|cwd|target|output|input|source|destination|dest|root)/i;
118
+ const COMMAND_KEY_PATTERN = /(command|cmd|script|query|sql)/i;
119
+ const URL_KEY_PATTERN = /(url|uri|endpoint|host|domain|upload|webhook|callback|proxy|origin|destination|dest)/i;
120
+ const SYSTEM_PATH_PREFIXES = ["/etc", "/usr", "/bin", "/sbin", "/var", "/private/etc", "/System", "/Library"];
121
+ const DEFAULT_MESSAGES_DB_PATH = path.join(HOME_DIR, "Library/Messages/chat.db");
122
+ const MESSAGE_DB_PATH_PATTERN = /(?:~\/Library\/Messages\/chat\.db|\/Users\/[^/\s"'`;]+\/Library\/Messages\/chat\.db)/i;
123
+ const PERSONAL_STORAGE_DOMAINS = [
124
+ "dropbox.com",
125
+ "drive.google.com",
126
+ "docs.google.com",
127
+ "onedrive.live.com",
128
+ "1drv.ms",
129
+ "notion.so",
130
+ "notion.site",
131
+ ];
132
+ const PASTE_SERVICE_DOMAINS = [
133
+ "pastebin.com",
134
+ "gist.github.com",
135
+ "gist.githubusercontent.com",
136
+ "hastebin.com",
137
+ "transfer.sh",
138
+ ];
139
+ const CHANNEL_METHOD_SUFFIX_OVERRIDES: Record<string, string> = {
140
+ imessage: "IMessage",
141
+ whatsapp: "WhatsApp",
142
+ lark: "Feishu",
143
+ };
144
+ const FEISHU_DEFAULT_API_BASE = "https://open.feishu.cn";
145
+ const LARK_DEFAULT_API_BASE = "https://open.larksuite.com";
146
+ const FEISHU_HTTP_TIMEOUT_MS = 10_000;
147
+ const CHANNEL_LOOKUP_ALIASES: Record<string, string[]> = {
148
+ feishu: ["lark"],
149
+ lark: ["feishu"],
150
+ };
151
+ const getChannelPluginCompat = (OpenClawCompat as Record<string, unknown>).getChannelPlugin as
152
+ | ((id: string) => unknown)
153
+ | undefined;
154
+
155
+ function resolveRuntimeLocale(): SecurityClawLocale {
156
+ const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale;
157
+ return resolveSecurityClawLocale(systemLocale, "en");
158
+ }
159
+
160
+ let runtimeLocale: SecurityClawLocale = resolveRuntimeLocale();
161
+
162
+ function text(zhText: string, enText: string): string {
163
+ return pickLocalized(runtimeLocale, zhText, enText);
164
+ }
165
+
166
+ function resolvePluginStateDir(api: OpenClawPluginApi): string {
167
+ try {
168
+ return api.runtime.state.resolveStateDir();
169
+ } catch {
170
+ return path.join(HOME_DIR, ".openclaw");
171
+ }
172
+ }
173
+
174
+ function resolveAdminConsoleUrl(pluginConfig: SecurityClawPluginConfig): string {
175
+ const port = pluginConfig.adminPort ?? Number(process.env.SECURITYCLAW_ADMIN_PORT ?? 4780);
176
+ return `http://127.0.0.1:${port}`;
177
+ }
178
+
179
+ function plural(value: number, unit: "day" | "hour" | "minute"): string {
180
+ return `${value} ${unit}${value === 1 ? "" : "s"}`;
181
+ }
182
+
183
+ function resolveScope(ctx: { workspaceDir?: string | undefined; channelId?: string | undefined }): string {
184
+ if (ctx.workspaceDir) {
185
+ return path.basename(ctx.workspaceDir);
186
+ }
187
+ return ctx.channelId ?? "default";
188
+ }
189
+
190
+ function isPathLike(value: string, keyHint: string): boolean {
191
+ if (PATH_KEY_PATTERN.test(keyHint)) {
192
+ return true;
193
+ }
194
+ return isPathLikeCandidate(value);
195
+ }
196
+
197
+ function collectPathCandidates(value: unknown, keyHint = "", depth = 0, output: string[] = []): string[] {
198
+ if (depth > 4 || output.length >= 24) {
199
+ return output;
200
+ }
201
+
202
+ if (typeof value === "string") {
203
+ const trimmed = value.trim();
204
+ if (trimmed && isPathLike(trimmed, keyHint)) {
205
+ output.push(trimmed);
206
+ } else if (trimmed && (COMMAND_KEY_PATTERN.test(keyHint) || hasEmbeddedPathHint(trimmed))) {
207
+ for (const candidate of extractEmbeddedPathCandidates(trimmed)) {
208
+ output.push(candidate);
209
+ if (output.length >= 24) {
210
+ break;
211
+ }
212
+ }
213
+ }
214
+ return output;
215
+ }
216
+
217
+ if (Array.isArray(value)) {
218
+ for (const item of value) {
219
+ collectPathCandidates(item, keyHint, depth + 1, output);
220
+ if (output.length >= 24) {
221
+ break;
222
+ }
223
+ }
224
+ return output;
225
+ }
226
+
227
+ if (!value || typeof value !== "object") {
228
+ return output;
229
+ }
230
+
231
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
232
+ collectPathCandidates(item, key, depth + 1, output);
233
+ if (output.length >= 24) {
234
+ break;
235
+ }
236
+ }
237
+ return output;
238
+ }
239
+
240
+ function isPathInside(rootDir: string, candidate: string): boolean {
241
+ const relative = path.relative(rootDir, candidate);
242
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
243
+ }
244
+
245
+ function isSystemPath(candidate: string): boolean {
246
+ return SYSTEM_PATH_PREFIXES.some((prefix) => candidate === prefix || candidate.startsWith(`${prefix}/`));
247
+ }
248
+
249
+ function classifyResolvedResourcePaths(
250
+ resolved: string[],
251
+ workspaceDir?: string,
252
+ ): { resourceScope: ResourceScope; resourcePaths: string[] } {
253
+ if (resolved.length === 0) {
254
+ return { resourceScope: "none", resourcePaths: [] };
255
+ }
256
+
257
+ let hasInside = false;
258
+ let hasOutside = false;
259
+ let hasSystem = false;
260
+ const normalizedWorkspace = workspaceDir ? path.normalize(workspaceDir) : undefined;
261
+
262
+ for (const candidate of resolved) {
263
+ if (isSystemPath(candidate)) {
264
+ hasSystem = true;
265
+ }
266
+ if (normalizedWorkspace && isPathInside(normalizedWorkspace, candidate)) {
267
+ hasInside = true;
268
+ } else {
269
+ hasOutside = true;
270
+ }
271
+ }
272
+
273
+ if (hasSystem) {
274
+ return { resourceScope: "system", resourcePaths: resolved };
275
+ }
276
+ if (hasOutside) {
277
+ return { resourceScope: "workspace_outside", resourcePaths: resolved };
278
+ }
279
+ if (hasInside) {
280
+ return { resourceScope: "workspace_inside", resourcePaths: resolved };
281
+ }
282
+ return { resourceScope: "none", resourcePaths: resolved };
283
+ }
284
+
285
+ function extractResourceContext(args: unknown, workspaceDir?: string): { resourceScope: ResourceScope; resourcePaths: string[] } {
286
+ const candidates = collectPathCandidates(args);
287
+ const resolved = Array.from(
288
+ new Set(
289
+ candidates
290
+ .map((candidate) => resolvePathCandidate(candidate, workspaceDir))
291
+ .filter((value): value is string => Boolean(value)),
292
+ ),
293
+ ).slice(0, 12);
294
+ return classifyResolvedResourcePaths(resolved, workspaceDir);
295
+ }
296
+
297
+ function isUrlLike(value: string, keyHint: string): boolean {
298
+ return URL_KEY_PATTERN.test(keyHint) || value.startsWith("http://") || value.startsWith("https://");
299
+ }
300
+
301
+ function collectUrlCandidates(value: unknown, keyHint = "", depth = 0, output: string[] = []): string[] {
302
+ if (depth > 4 || output.length >= 12) {
303
+ return output;
304
+ }
305
+
306
+ if (typeof value === "string") {
307
+ const trimmed = value.trim();
308
+ if (trimmed && isUrlLike(trimmed, keyHint)) {
309
+ output.push(trimmed);
310
+ }
311
+ return output;
312
+ }
313
+
314
+ if (Array.isArray(value)) {
315
+ for (const item of value) {
316
+ collectUrlCandidates(item, keyHint, depth + 1, output);
317
+ if (output.length >= 12) {
318
+ break;
319
+ }
320
+ }
321
+ return output;
322
+ }
323
+
324
+ if (!value || typeof value !== "object") {
325
+ return output;
326
+ }
327
+
328
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
329
+ collectUrlCandidates(item, key, depth + 1, output);
330
+ if (output.length >= 12) {
331
+ break;
332
+ }
333
+ }
334
+ return output;
335
+ }
336
+
337
+ function isPrivateIp(host: string): boolean {
338
+ if (isIP(host) !== 4) {
339
+ return false;
340
+ }
341
+ const octets = host.split(".").map((value) => Number(value));
342
+ return (
343
+ octets[0] === 10 ||
344
+ octets[0] === 127 ||
345
+ (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
346
+ (octets[0] === 192 && octets[1] === 168)
347
+ );
348
+ }
349
+
350
+ function isLoopbackIp(host: string): boolean {
351
+ if (isIP(host) === 4) {
352
+ return host.startsWith("127.");
353
+ }
354
+ return host === "::1";
355
+ }
356
+
357
+ function classifyDestination(urls: string[]): Pick<
358
+ DecisionContext,
359
+ "destination_type" | "dest_domain" | "dest_ip_class"
360
+ > {
361
+ for (const candidate of urls) {
362
+ try {
363
+ const parsed = new URL(candidate);
364
+ const host = parsed.hostname.toLowerCase();
365
+ const ipVersion = isIP(host);
366
+ const isInternalHost =
367
+ host === "localhost" ||
368
+ host.endsWith(".internal") ||
369
+ host.endsWith(".corp") ||
370
+ host.endsWith(".local") ||
371
+ host.endsWith(".lan") ||
372
+ isPrivateIp(host);
373
+
374
+ const destinationType =
375
+ PERSONAL_STORAGE_DOMAINS.some((domain) => host === domain || host.endsWith(`.${domain}`))
376
+ ? "personal_storage"
377
+ : PASTE_SERVICE_DOMAINS.some((domain) => host === domain || host.endsWith(`.${domain}`))
378
+ ? "paste_service"
379
+ : isInternalHost
380
+ ? "internal"
381
+ : "public";
382
+
383
+ const destIpClass =
384
+ ipVersion === 0
385
+ ? destinationType === "internal"
386
+ ? "private"
387
+ : "unknown"
388
+ : isLoopbackIp(host)
389
+ ? "loopback"
390
+ : isPrivateIp(host)
391
+ ? "private"
392
+ : "public";
393
+
394
+ return {
395
+ destination_type: destinationType,
396
+ dest_domain: host,
397
+ dest_ip_class: destIpClass,
398
+ };
399
+ } catch {
400
+ continue;
401
+ }
402
+ }
403
+
404
+ return {};
405
+ }
406
+
407
+ function inferToolGroup(toolName: string): string | undefined {
408
+ const normalized = toolName.trim().toLowerCase();
409
+ if (normalized.startsWith("shell.")) {
410
+ return "execution";
411
+ }
412
+ if (normalized.startsWith("filesystem.")) {
413
+ return "filesystem";
414
+ }
415
+ if (normalized.startsWith("network.") || normalized.startsWith("http.")) {
416
+ return "network";
417
+ }
418
+ if (normalized.startsWith("email.") || normalized.startsWith("mail.")) {
419
+ return "email";
420
+ }
421
+ if (
422
+ normalized.startsWith("sms.") ||
423
+ normalized.startsWith("message.") ||
424
+ normalized.startsWith("messages.")
425
+ ) {
426
+ return "sms";
427
+ }
428
+ if (normalized.startsWith("album.") || normalized.startsWith("photo.") || normalized.startsWith("media.")) {
429
+ return "album";
430
+ }
431
+ if (normalized.startsWith("browser.")) {
432
+ return "browser";
433
+ }
434
+ if (
435
+ normalized.startsWith("archive.") ||
436
+ normalized.startsWith("compress.") ||
437
+ normalized.includes(".archive") ||
438
+ normalized.includes(".compress") ||
439
+ normalized.includes(".zip")
440
+ ) {
441
+ return "archive";
442
+ }
443
+ if (
444
+ normalized.startsWith("crm.") ||
445
+ normalized.startsWith("erp.") ||
446
+ normalized.startsWith("hr.") ||
447
+ normalized.startsWith("finance.") ||
448
+ normalized.startsWith("jira.") ||
449
+ normalized.startsWith("servicenow.") ||
450
+ normalized.startsWith("zendesk.")
451
+ ) {
452
+ return "business";
453
+ }
454
+ return undefined;
455
+ }
456
+
457
+ function inferOperation(toolName: string): string | undefined {
458
+ const normalized = toolName.trim().toLowerCase();
459
+ if (normalized.startsWith("network.") || normalized.startsWith("http.")) {
460
+ return "request";
461
+ }
462
+ if (/(exec|run|spawn)$/.test(normalized) || normalized.endsWith(".exec")) {
463
+ return "execute";
464
+ }
465
+ if (/(delete|remove|unlink|destroy)$/.test(normalized) || normalized.endsWith(".rm")) {
466
+ return "delete";
467
+ }
468
+ if (/(write|save|create|update|append|put)$/.test(normalized)) {
469
+ return "write";
470
+ }
471
+ if (/(list|ls|enumerate)$/.test(normalized)) {
472
+ return "list";
473
+ }
474
+ if (/(search|query|find)$/.test(normalized)) {
475
+ return "search";
476
+ }
477
+ if (/(read|get|open|cat|fetch|download)$/.test(normalized)) {
478
+ return "read";
479
+ }
480
+ if (/(upload|send|post|reply)$/.test(normalized)) {
481
+ return "upload";
482
+ }
483
+ if (/(export|dump)$/.test(normalized)) {
484
+ return "export";
485
+ }
486
+ if (/(archive|compress|zip|tar|bundle)$/.test(normalized)) {
487
+ return "archive";
488
+ }
489
+ if (/(deploy|apply|terraform|kubectl)$/.test(normalized)) {
490
+ return "modify";
491
+ }
492
+ return undefined;
493
+ }
494
+
495
+ function inferFileType(resourcePaths: string[]): string | undefined {
496
+ for (const candidate of resourcePaths) {
497
+ const basename = path.basename(candidate);
498
+ if (basename === "Dockerfile") {
499
+ return "dockerfile";
500
+ }
501
+ const extension = path.extname(basename).toLowerCase().replace(/^\./, "");
502
+ if (extension) {
503
+ return extension;
504
+ }
505
+ }
506
+ return undefined;
507
+ }
508
+
509
+ function extractShellCommandText(args: unknown): string | undefined {
510
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
511
+ return undefined;
512
+ }
513
+ const record = args as Record<string, unknown>;
514
+ for (const key of ["command", "cmd", "script"]) {
515
+ const value = record[key];
516
+ if (typeof value === "string" && value.trim()) {
517
+ return value.trim();
518
+ }
519
+ }
520
+ return undefined;
521
+ }
522
+
523
+ function isMessagesDbPath(candidate: string): boolean {
524
+ return /\/Library\/Messages\/chat\.db$/i.test(candidate);
525
+ }
526
+
527
+ function isMessagesShellAccess(commandText: string | undefined, resourcePaths: string[]): boolean {
528
+ if (resourcePaths.some((candidate) => isMessagesDbPath(candidate))) {
529
+ return true;
530
+ }
531
+ const corpus = [commandText ?? "", ...resourcePaths].join(" ");
532
+ return /\bimsg\b/i.test(corpus) || (/\bsqlite3\b/i.test(corpus) && MESSAGE_DB_PATH_PATTERN.test(corpus));
533
+ }
534
+
535
+ function inferMessagesOperation(commandText: string | undefined): string {
536
+ const normalized = (commandText ?? "").toLowerCase();
537
+ if (/\b(export|dump)\b/.test(normalized)) {
538
+ return "export";
539
+ }
540
+ if (/\b(search|find|query)\b/.test(normalized)) {
541
+ return "search";
542
+ }
543
+ return "read";
544
+ }
545
+
546
+ function deriveToolContext(
547
+ normalizedToolName: string | undefined,
548
+ args: unknown,
549
+ resourceScope: ResourceScope,
550
+ resourcePaths: string[],
551
+ workspaceDir?: string,
552
+ ): {
553
+ inferredToolName?: string;
554
+ toolGroup?: string;
555
+ operation?: string;
556
+ resourceScope: ResourceScope;
557
+ resourcePaths: string[];
558
+ tags: string[];
559
+ } {
560
+ let nextResourcePaths = [...resourcePaths];
561
+ let nextResourceScope = resourceScope;
562
+ let toolGroup = normalizedToolName ? inferToolGroup(normalizedToolName) : undefined;
563
+ let operation = normalizedToolName ? inferOperation(normalizedToolName) : undefined;
564
+ let inferredToolName: string | undefined;
565
+ const tags: string[] = [];
566
+
567
+ if (normalizedToolName === "shell.exec") {
568
+ const commandText = extractShellCommandText(args);
569
+ if (isMessagesShellAccess(commandText, nextResourcePaths)) {
570
+ toolGroup = "sms";
571
+ operation = inferMessagesOperation(commandText);
572
+ if (!nextResourcePaths.some((candidate) => isMessagesDbPath(candidate))) {
573
+ nextResourcePaths = [...nextResourcePaths, DEFAULT_MESSAGES_DB_PATH];
574
+ }
575
+ const classified = classifyResolvedResourcePaths(nextResourcePaths, workspaceDir);
576
+ nextResourcePaths = classified.resourcePaths;
577
+ nextResourceScope = classified.resourceScope;
578
+ tags.push("messages_shell_access");
579
+ } else {
580
+ const shellSemantic = inferShellFilesystemSemantic(commandText, nextResourcePaths);
581
+ if (shellSemantic) {
582
+ inferredToolName = shellSemantic.toolName;
583
+ toolGroup = "filesystem";
584
+ operation = shellSemantic.operation;
585
+ tags.push("shell_filesystem_access", `shell_filesystem_operation:${shellSemantic.operation}`);
586
+ }
587
+ }
588
+ }
589
+
590
+ return {
591
+ ...(inferredToolName !== undefined ? { inferredToolName } : {}),
592
+ ...(toolGroup !== undefined ? { toolGroup } : {}),
593
+ ...(operation !== undefined ? { operation } : {}),
594
+ resourceScope: nextResourceScope,
595
+ resourcePaths: nextResourcePaths,
596
+ tags,
597
+ };
598
+ }
599
+
600
+ function inferLabels(
601
+ config: SecurityClawConfig,
602
+ toolGroup: string | undefined,
603
+ resourcePaths: string[],
604
+ toolArgsSummary: string | undefined,
605
+ ): Pick<DecisionContext, "asset_labels" | "data_labels"> {
606
+ const inferred = inferSensitivityLabels(
607
+ toolGroup,
608
+ resourcePaths,
609
+ toolArgsSummary,
610
+ config.sensitivity.path_rules,
611
+ );
612
+ return {
613
+ asset_labels: inferred.assetLabels,
614
+ data_labels: inferred.dataLabels,
615
+ };
616
+ }
617
+
618
+ function inferVolume(args: unknown, resourcePaths: string[]): DecisionContext["volume"] {
619
+ const metrics: DecisionContext["volume"] = {};
620
+ if (resourcePaths.length > 0) {
621
+ metrics.file_count = resourcePaths.length;
622
+ }
623
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
624
+ return metrics;
625
+ }
626
+
627
+ const record = args as Record<string, unknown>;
628
+ for (const [key, value] of Object.entries(record)) {
629
+ const lower = key.toLowerCase();
630
+ if (Array.isArray(value)) {
631
+ if (/(files|paths|attachments|items|results|records|messages)/.test(lower)) {
632
+ if ((metrics.file_count ?? 0) < value.length) {
633
+ metrics.file_count = value.length;
634
+ }
635
+ if (/(results|records|messages)/.test(lower) && (metrics.record_count ?? 0) < value.length) {
636
+ metrics.record_count = value.length;
637
+ }
638
+ }
639
+ continue;
640
+ }
641
+ if (typeof value !== "number" || !Number.isFinite(value)) {
642
+ continue;
643
+ }
644
+ if (/(bytes|size|length)/.test(lower)) {
645
+ metrics.bytes = value;
646
+ }
647
+ if (/(count|limit|total|records)/.test(lower)) {
648
+ metrics.record_count = value;
649
+ }
650
+ }
651
+
652
+ return metrics;
653
+ }
654
+
655
+ function trimText(value: string, maxLength: number): string {
656
+ if (value.length <= maxLength) {
657
+ return value;
658
+ }
659
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
660
+ }
661
+
662
+ function normalizeApprovalChannel(value: string | undefined): ApprovalChannel | undefined {
663
+ const normalized = value?.trim().toLowerCase();
664
+ return normalized ? (normalized as ApprovalChannel) : undefined;
665
+ }
666
+
667
+ function normalizeThreadId(threadId: string | number | undefined): number | undefined {
668
+ if (typeof threadId === "number" && Number.isInteger(threadId)) {
669
+ return threadId;
670
+ }
671
+ if (typeof threadId === "string" && /^\d+$/.test(threadId.trim())) {
672
+ return Number(threadId.trim());
673
+ }
674
+ return undefined;
675
+ }
676
+
677
+ function resolveApprovalSubject(ctx: SecurityClawHookContext): string {
678
+ return ApprovalSubjectResolver.resolve(ctx);
679
+ }
680
+
681
+ function splitApprovalSubject(value: string | undefined): { channel?: ApprovalChannel; identifier?: string } {
682
+ const subject = value?.trim();
683
+ if (!subject) {
684
+ return {};
685
+ }
686
+ const separator = subject.indexOf(":");
687
+ if (separator <= 0) {
688
+ return {};
689
+ }
690
+ const channel = normalizeApprovalChannel(subject.slice(0, separator));
691
+ if (!channel) {
692
+ return {};
693
+ }
694
+ const identifier = subject.slice(separator + 1).trim();
695
+ if (!identifier) {
696
+ return {};
697
+ }
698
+ return { channel, identifier };
699
+ }
700
+
701
+ function normalizeApprovalIdentity(value: string | undefined, channel: ApprovalChannel): string | undefined {
702
+ const candidate = value?.trim();
703
+ if (!candidate) {
704
+ return undefined;
705
+ }
706
+ const channelPrefix = `${channel}:`;
707
+ if (candidate.toLowerCase().startsWith(channelPrefix)) {
708
+ const unscoped = candidate.slice(channelPrefix.length).trim();
709
+ return unscoped || undefined;
710
+ }
711
+ return candidate;
712
+ }
713
+
714
+ function collectAdminApprovalIdentities(policy: AccountPolicyRecord, channel: ApprovalChannel): string[] {
715
+ const candidates = new Set<string>();
716
+ const subject = splitApprovalSubject(policy.subject);
717
+ const subjectIdentity = normalizeApprovalIdentity(subject.identifier, channel);
718
+ if (subjectIdentity) {
719
+ candidates.add(subjectIdentity);
720
+ } else {
721
+ const sessionIdentity = normalizeApprovalIdentity(policy.session_id, channel);
722
+ if (sessionIdentity) {
723
+ candidates.add(sessionIdentity);
724
+ }
725
+ }
726
+ return Array.from(candidates);
727
+ }
728
+
729
+ function deriveApprovalBridgeFromAdminPolicies(
730
+ accountPolicyEngine: AccountPolicyEngine,
731
+ ): Pick<ResolvedApprovalBridge, "targets" | "approvers"> {
732
+ const targets: ChatApprovalTarget[] = [];
733
+ const approvers: ChatApprovalApprover[] = [];
734
+ for (const policy of accountPolicyEngine.listPolicies()) {
735
+ if (!policy.is_admin) {
736
+ continue;
737
+ }
738
+ const subject = splitApprovalSubject(policy.subject);
739
+ const channel = normalizeApprovalChannel(policy.channel) ?? subject.channel;
740
+ if (!channel) {
741
+ continue;
742
+ }
743
+ const identities = collectAdminApprovalIdentities(policy, channel);
744
+ for (const identity of identities) {
745
+ targets.push({
746
+ channel,
747
+ to: identity,
748
+ });
749
+ approvers.push({
750
+ channel,
751
+ from: identity,
752
+ });
753
+ }
754
+ }
755
+ return { targets, approvers };
756
+ }
757
+
758
+ function dedupeApprovalTargets(targets: ChatApprovalTarget[]): ChatApprovalTarget[] {
759
+ const deduped: ChatApprovalTarget[] = [];
760
+ const seen = new Set<string>();
761
+ for (const target of targets) {
762
+ const key = [
763
+ target.channel,
764
+ target.to,
765
+ target.account_id ?? "",
766
+ target.thread_id !== undefined ? String(target.thread_id) : "",
767
+ ].join("|");
768
+ if (seen.has(key)) {
769
+ continue;
770
+ }
771
+ seen.add(key);
772
+ deduped.push(target);
773
+ }
774
+ return deduped;
775
+ }
776
+
777
+ function dedupeApprovalApprovers(approvers: ChatApprovalApprover[]): ChatApprovalApprover[] {
778
+ const deduped: ChatApprovalApprover[] = [];
779
+ const seen = new Set<string>();
780
+ for (const approver of approvers) {
781
+ const key = [approver.channel, approver.from, approver.account_id ?? ""].join("|");
782
+ if (seen.has(key)) {
783
+ continue;
784
+ }
785
+ seen.add(key);
786
+ deduped.push(approver);
787
+ }
788
+ return deduped;
789
+ }
790
+
791
+ function mergeApprovalBridgeConfig(
792
+ derived: Pick<ResolvedApprovalBridge, "targets" | "approvers">,
793
+ ): ResolvedApprovalBridge {
794
+ const targets = dedupeApprovalTargets(derived.targets);
795
+ const approvers = dedupeApprovalApprovers(derived.approvers);
796
+ return {
797
+ enabled: approvers.length > 0,
798
+ targets,
799
+ approvers,
800
+ };
801
+ }
802
+
803
+ function matchesApprover(approvers: ChatApprovalApprover[], ctx: SecurityClawApprovalCommandContext): boolean {
804
+ const channel = normalizeApprovalChannel(ctx.channel);
805
+ if (!channel) {
806
+ return false;
807
+ }
808
+ const senderIds = new Set<string>();
809
+ const collectSenderId = (value: string | undefined) => {
810
+ const trimmed = value?.trim();
811
+ if (!trimmed) {
812
+ return;
813
+ }
814
+ senderIds.add(trimmed);
815
+ const lower = trimmed.toLowerCase();
816
+ const channelPrefix = `${channel}:`;
817
+ if (lower.startsWith(channelPrefix)) {
818
+ const unscoped = trimmed.slice(channelPrefix.length).trim();
819
+ if (unscoped) {
820
+ senderIds.add(unscoped);
821
+ }
822
+ return;
823
+ }
824
+ senderIds.add(`${channel}:${trimmed}`);
825
+ };
826
+
827
+ collectSenderId(ctx.from);
828
+ collectSenderId(ctx.senderId);
829
+ if (senderIds.size === 0) {
830
+ return false;
831
+ }
832
+
833
+ return approvers.some((approver) => {
834
+ if (approver.channel !== channel || !senderIds.has(approver.from)) {
835
+ return false;
836
+ }
837
+ if (approver.account_id && approver.account_id !== ctx.accountId) {
838
+ return false;
839
+ }
840
+ return true;
841
+ });
842
+ }
843
+
844
+ function formatResourceScopeLabel(scope: ResourceScope): string {
845
+ if (scope === "workspace_inside") {
846
+ return text("工作区内", "Inside workspace");
847
+ }
848
+ if (scope === "workspace_outside") {
849
+ return text("工作区外", "Outside workspace");
850
+ }
851
+ if (scope === "system") {
852
+ return text("系统目录", "System directory");
853
+ }
854
+ return text("无路径", "No path");
855
+ }
856
+
857
+ function formatResourceScopeDetail(scope: ResourceScope): string {
858
+ return `${formatResourceScopeLabel(scope)} (${scope})`;
859
+ }
860
+
861
+ function formatApprovalPrompt(record: StoredApprovalRecord): string {
862
+ const paths = record.resource_paths.length > 0
863
+ ? trimText(record.resource_paths.slice(0, 3).join(" | "), 160)
864
+ : undefined;
865
+ const rules = record.rule_ids.length > 0 ? record.rule_ids.join(", ") : undefined;
866
+ const reasons = record.reason_codes.length > 0 ? record.reason_codes.join(", ") : text("策略要求复核", "Policy review required");
867
+ const summary = record.args_summary ? trimText(record.args_summary, 180) : undefined;
868
+
869
+ return [
870
+ text("SecurityClaw 审批请求", "SecurityClaw Approval"),
871
+ `${text("对象", "Subject")}: ${record.actor_id}`,
872
+ `${text("工具", "Tool")}: ${record.tool_name}`,
873
+ `${text("范围", "Scope")}: ${record.scope}`,
874
+ `${text("资源", "Resource")}: ${formatResourceScopeDetail(record.resource_scope)}`,
875
+ `${text("原因", "Reason")}: ${reasons}`,
876
+ `${text("请求截止", "Request expires")}: ${formatTimestampForApproval(record.expires_at)}`,
877
+ `${text("审批单", "Request ID")}: ${record.approval_id}`,
878
+ ...(paths ? [`${text("路径", "Paths")}: ${paths}`] : []),
879
+ ...(summary ? [`${text("参数", "Args")}: ${summary}`] : []),
880
+ ...(rules ? [`${text("规则", "Policy")}: ${rules}`] : []),
881
+ "",
882
+ text("操作", "Actions"),
883
+ `- ${text("批准", "Approve")} ${formatApprovalGrantDuration(record, "temporary")}: /${APPROVAL_APPROVE_COMMAND} ${record.approval_id}`,
884
+ `- ${text("批准", "Approve")} ${formatApprovalGrantDuration(record, "longterm")}: /${APPROVAL_APPROVE_COMMAND} ${record.approval_id} long`,
885
+ `- ${text("拒绝", "Reject")}: /${APPROVAL_REJECT_COMMAND} ${record.approval_id}`,
886
+ ].join("\n");
887
+ }
888
+
889
+ function formatPendingApprovals(records: StoredApprovalRecord[]): string {
890
+ if (records.length === 0) {
891
+ return text("当前没有待审批请求。", "No pending approval requests.");
892
+ }
893
+ return [
894
+ text(`待审批请求 ${records.length} 条:`, `Pending approval requests (${records.length}):`),
895
+ ...records.map((record) =>
896
+ `- ${record.approval_id} | ${record.actor_id} | ${record.scope} | ${record.tool_name} | ${formatTimestampForApproval(record.requested_at)}`,
897
+ ),
898
+ ].join("\n");
899
+ }
900
+
901
+ function parseTimestampMs(value: string | undefined): number | undefined {
902
+ if (!value) {
903
+ return undefined;
904
+ }
905
+ const parsed = Date.parse(value);
906
+ return Number.isFinite(parsed) ? parsed : undefined;
907
+ }
908
+
909
+ function formatDurationMs(durationMs: number): string {
910
+ const totalMinutes = Math.max(1, Math.round(durationMs / 60_000));
911
+ const totalHours = totalMinutes / 60;
912
+ const totalDays = totalHours / 24;
913
+ if (Number.isInteger(totalDays) && totalDays >= 1) {
914
+ return text(`${totalDays}天`, plural(totalDays, "day"));
915
+ }
916
+ if (Number.isInteger(totalHours) && totalHours >= 1) {
917
+ return text(`${totalHours}小时`, plural(totalHours, "hour"));
918
+ }
919
+ return text(`${totalMinutes}分钟`, plural(totalMinutes, "minute"));
920
+ }
921
+
922
+ function formatTimestampForApproval(value: string | undefined, timeZone = APPROVAL_DISPLAY_TIMEZONE): string {
923
+ const timestamp = parseTimestampMs(value);
924
+ if (timestamp === undefined) {
925
+ return value ?? text("未知", "Unknown");
926
+ }
927
+
928
+ try {
929
+ const parts = new Intl.DateTimeFormat(localeForIntl(runtimeLocale), {
930
+ timeZone,
931
+ year: "numeric",
932
+ month: "2-digit",
933
+ day: "2-digit",
934
+ hour: "2-digit",
935
+ minute: "2-digit",
936
+ hour12: false,
937
+ }).formatToParts(new Date(timestamp));
938
+ const values = parts.reduce<Record<string, string>>((output, part) => {
939
+ if (part.type !== "literal") {
940
+ output[part.type] = part.value;
941
+ }
942
+ return output;
943
+ }, {});
944
+ return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute} (${timeZone})`;
945
+ } catch {
946
+ return `${new Date(timestamp).toISOString()} (${timeZone})`;
947
+ }
948
+ }
949
+
950
+ function resolveTemporaryGrantDurationMs(record: StoredApprovalRecord): number {
951
+ const requestedAt = parseTimestampMs(record.requested_at) ?? Date.now();
952
+ const expiresAt = parseTimestampMs(record.expires_at) ?? (requestedAt + (15 * 60 * 1000));
953
+ return Math.max(60_000, expiresAt - requestedAt);
954
+ }
955
+
956
+ function formatApprovalGrantDuration(record: StoredApprovalRecord, mode: ApprovalGrantMode): string {
957
+ return mode === "longterm"
958
+ ? formatDurationMs(APPROVAL_LONG_GRANT_TTL_MS)
959
+ : formatDurationMs(resolveTemporaryGrantDurationMs(record));
960
+ }
961
+
962
+ function formatCompactApprovalGrantDuration(record: StoredApprovalRecord, mode: ApprovalGrantMode): string {
963
+ const durationMs = mode === "longterm"
964
+ ? APPROVAL_LONG_GRANT_TTL_MS
965
+ : resolveTemporaryGrantDurationMs(record);
966
+ const totalMinutes = Math.max(1, Math.round(durationMs / 60_000));
967
+ const totalHours = totalMinutes / 60;
968
+ const totalDays = totalHours / 24;
969
+ if (Number.isInteger(totalDays) && totalDays >= 1) {
970
+ return text(`${totalDays}天`, `${totalDays}d`);
971
+ }
972
+ if (Number.isInteger(totalHours) && totalHours >= 1) {
973
+ return text(`${totalHours}小时`, `${totalHours}h`);
974
+ }
975
+ return text(`${totalMinutes}分钟`, `${totalMinutes}m`);
976
+ }
977
+
978
+ function formatApprovalButtonLabel(record: StoredApprovalRecord, mode: ApprovalGrantMode): string {
979
+ return `${text("批准", "Approve")} ${formatCompactApprovalGrantDuration(record, mode)}`;
980
+ }
981
+
982
+ function shouldResendPendingApproval(record: StoredApprovalRecord, nowMs = Date.now()): boolean {
983
+ if (record.notifications.length === 0) {
984
+ return true;
985
+ }
986
+ const latestSentAt = record.notifications
987
+ .map((notification) => parseTimestampMs(notification.sent_at))
988
+ .reduce<number | undefined>((latest, current) => {
989
+ if (current === undefined) {
990
+ return latest;
991
+ }
992
+ if (latest === undefined || current > latest) {
993
+ return current;
994
+ }
995
+ return latest;
996
+ }, undefined);
997
+ const baseline = latestSentAt ?? parseTimestampMs(record.requested_at);
998
+ if (baseline === undefined) {
999
+ return true;
1000
+ }
1001
+ return nowMs - baseline >= APPROVAL_NOTIFICATION_RESEND_COOLDOWN_MS;
1002
+ }
1003
+
1004
+ function mergeApprovalNotifications(
1005
+ existing: StoredApprovalNotification[],
1006
+ incoming: StoredApprovalNotification[],
1007
+ ): StoredApprovalNotification[] {
1008
+ if (incoming.length === 0) {
1009
+ return existing;
1010
+ }
1011
+ return [...existing, ...incoming].slice(-APPROVAL_NOTIFICATION_HISTORY_LIMIT);
1012
+ }
1013
+
1014
+ function nowIsoString(): string {
1015
+ return new Date(Date.now()).toISOString();
1016
+ }
1017
+
1018
+ function parseApprovalGrantMode(args: string | undefined): ApprovalGrantMode {
1019
+ const value = args?.trim();
1020
+ const mode = value ? value.split(/\s+/)[1]?.toLowerCase() : undefined;
1021
+ if (mode === "long" || mode === "longterm" || mode === "permanent" || mode === "长期") {
1022
+ return "longterm";
1023
+ }
1024
+ return "temporary";
1025
+ }
1026
+
1027
+ function formatGrantModeLabel(mode: ApprovalGrantMode): string {
1028
+ return text(mode === "longterm" ? "长期授权" : "临时授权", mode === "longterm" ? "Long-lived grant" : "Temporary grant");
1029
+ }
1030
+
1031
+ function resolveApprovalGrantExpiry(record: StoredApprovalRecord, mode: ApprovalGrantMode): string {
1032
+ if (mode === "longterm") {
1033
+ return new Date(Date.now() + APPROVAL_LONG_GRANT_TTL_MS).toISOString();
1034
+ }
1035
+ return new Date(Date.now() + resolveTemporaryGrantDurationMs(record)).toISOString();
1036
+ }
1037
+
1038
+ type ChannelSendMessageFn = (
1039
+ to: string,
1040
+ text: string,
1041
+ opts?: Record<string, unknown>,
1042
+ ) => Promise<{ messageId?: string }>;
1043
+
1044
+ function resolveChannelLookupCandidates(channel: string): string[] {
1045
+ const normalized = channel.trim().toLowerCase();
1046
+ if (!normalized) {
1047
+ return [];
1048
+ }
1049
+ const candidates = new Set<string>([normalized]);
1050
+ for (const alias of CHANNEL_LOOKUP_ALIASES[normalized] ?? []) {
1051
+ candidates.add(alias);
1052
+ }
1053
+ return Array.from(candidates);
1054
+ }
1055
+
1056
+ function resolveChannelMethodSuffix(channel: string): string {
1057
+ const normalized = channel.trim().toLowerCase();
1058
+ const override = CHANNEL_METHOD_SUFFIX_OVERRIDES[normalized];
1059
+ if (override) {
1060
+ return override;
1061
+ }
1062
+ return normalized
1063
+ .split(/[-_]/)
1064
+ .filter(Boolean)
1065
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1066
+ .join("");
1067
+ }
1068
+
1069
+ function buildChannelMethodCandidates(channel: string): string[] {
1070
+ const suffix = resolveChannelMethodSuffix(channel);
1071
+ return [
1072
+ `sendMessage${suffix}`,
1073
+ `pushMessage${suffix}`,
1074
+ `postMessage${suffix}`,
1075
+ `send${suffix}`,
1076
+ `push${suffix}`,
1077
+ "sendMessage",
1078
+ "pushMessage",
1079
+ ];
1080
+ }
1081
+
1082
+ function resolveDynamicChannelSender(
1083
+ api: OpenClawPluginApi,
1084
+ channel: string,
1085
+ ): ChannelSendMessageFn | undefined {
1086
+ const runtimeChannels = api.runtime.channel as unknown as Record<string, unknown>;
1087
+ for (const channelCandidate of resolveChannelLookupCandidates(channel)) {
1088
+ const channelClient = runtimeChannels[channelCandidate];
1089
+ if (!channelClient || typeof channelClient !== "object") {
1090
+ continue;
1091
+ }
1092
+ const methodNames = Array.from(new Set<string>([
1093
+ ...buildChannelMethodCandidates(channel),
1094
+ ...buildChannelMethodCandidates(channelCandidate),
1095
+ ]));
1096
+ for (const methodName of methodNames) {
1097
+ const candidate = (channelClient as Record<string, unknown>)[methodName];
1098
+ if (typeof candidate === "function") {
1099
+ return (to: string, text: string, opts?: Record<string, unknown>) =>
1100
+ (candidate as (to: string, text: string, opts?: Record<string, unknown>) => Promise<{ messageId?: string }>)
1101
+ .call(channelClient, to, text, opts);
1102
+ }
1103
+ }
1104
+ }
1105
+ return undefined;
1106
+ }
1107
+
1108
+ type ChannelPluginSendTextFn = (ctx: {
1109
+ cfg: unknown;
1110
+ to: string;
1111
+ text: string;
1112
+ accountId?: string | null;
1113
+ threadId?: string | number | null;
1114
+ }) => Promise<Record<string, unknown>>;
1115
+
1116
+ function resolveChannelPluginSendText(channel: string): ChannelPluginSendTextFn | undefined {
1117
+ if (typeof getChannelPluginCompat !== "function") {
1118
+ return undefined;
1119
+ }
1120
+ for (const channelCandidate of resolveChannelLookupCandidates(channel)) {
1121
+ const plugin = getChannelPluginCompat(channelCandidate) as {
1122
+ outbound?: {
1123
+ sendText?: ChannelPluginSendTextFn;
1124
+ };
1125
+ } | undefined;
1126
+ const sendText = plugin?.outbound?.sendText;
1127
+ if (typeof sendText === "function") {
1128
+ return sendText;
1129
+ }
1130
+ }
1131
+ return undefined;
1132
+ }
1133
+
1134
+ type FeishuReceiveIdType = "chat_id" | "open_id" | "user_id";
1135
+
1136
+ type FeishuRuntimeConfig = {
1137
+ appId: string;
1138
+ appSecret: string;
1139
+ apiBase: string;
1140
+ };
1141
+
1142
+ function feishuAsRecord(value: unknown): Record<string, unknown> | undefined {
1143
+ return value && typeof value === "object" && !Array.isArray(value)
1144
+ ? (value as Record<string, unknown>)
1145
+ : undefined;
1146
+ }
1147
+
1148
+ function feishuTrimmedString(value: unknown): string | undefined {
1149
+ if (typeof value !== "string") {
1150
+ return undefined;
1151
+ }
1152
+ const trimmed = value.trim();
1153
+ return trimmed || undefined;
1154
+ }
1155
+
1156
+ function resolveFeishuSecretValue(value: unknown): string | undefined {
1157
+ const direct = feishuTrimmedString(value);
1158
+ if (direct) {
1159
+ return direct;
1160
+ }
1161
+ const record = feishuAsRecord(value);
1162
+ if (!record) {
1163
+ return undefined;
1164
+ }
1165
+ const source = feishuTrimmedString(record.source)?.toLowerCase();
1166
+ const id = feishuTrimmedString(record.id);
1167
+ if (source === "env" && id) {
1168
+ const envValue = feishuTrimmedString(process.env[id]);
1169
+ if (envValue) {
1170
+ return envValue;
1171
+ }
1172
+ }
1173
+ for (const key of ["value", "secret", "token", "text"]) {
1174
+ const candidate = feishuTrimmedString(record[key]);
1175
+ if (candidate) {
1176
+ return candidate;
1177
+ }
1178
+ }
1179
+ return undefined;
1180
+ }
1181
+
1182
+ function resolveFeishuApiBase(domain: unknown): string {
1183
+ const domainValue = feishuTrimmedString(domain)?.replace(/\/+$/, "");
1184
+ if (!domainValue || domainValue.toLowerCase() === "feishu") {
1185
+ return FEISHU_DEFAULT_API_BASE;
1186
+ }
1187
+ if (domainValue.toLowerCase() === "lark") {
1188
+ return LARK_DEFAULT_API_BASE;
1189
+ }
1190
+ if (/^https?:\/\//i.test(domainValue)) {
1191
+ return domainValue;
1192
+ }
1193
+ return `https://${domainValue}`;
1194
+ }
1195
+
1196
+ function resolveFeishuRuntimeConfig(
1197
+ api: OpenClawPluginApi,
1198
+ target: ChatApprovalTarget,
1199
+ ): FeishuRuntimeConfig | undefined {
1200
+ const configRoot = feishuAsRecord(api.config);
1201
+ const channels = feishuAsRecord(configRoot?.channels);
1202
+ const feishu = feishuAsRecord(channels?.feishu) ?? feishuAsRecord(channels?.lark);
1203
+ if (!feishu) {
1204
+ return undefined;
1205
+ }
1206
+ const accounts = feishuAsRecord(feishu.accounts);
1207
+ const pickAccount = (accountId: string | undefined): Record<string, unknown> | undefined => {
1208
+ if (!accounts || !accountId) {
1209
+ return undefined;
1210
+ }
1211
+ return feishuAsRecord(accounts[accountId]);
1212
+ };
1213
+ const explicitAccount = pickAccount(feishuTrimmedString(target.account_id));
1214
+ const defaultAccount = pickAccount(feishuTrimmedString(feishu.defaultAccount));
1215
+ const firstAccount = accounts
1216
+ ? feishuAsRecord(accounts[Object.keys(accounts).sort((left, right) => left.localeCompare(right))[0]])
1217
+ : undefined;
1218
+ const merged = {
1219
+ ...feishu,
1220
+ ...(explicitAccount ?? defaultAccount ?? firstAccount ?? {}),
1221
+ };
1222
+
1223
+ const appId = resolveFeishuSecretValue(merged.appId);
1224
+ const appSecret = resolveFeishuSecretValue(merged.appSecret);
1225
+ if (!appId || !appSecret) {
1226
+ return undefined;
1227
+ }
1228
+ return {
1229
+ appId,
1230
+ appSecret,
1231
+ apiBase: resolveFeishuApiBase(merged.domain),
1232
+ };
1233
+ }
1234
+
1235
+ function resolveFeishuReceiveTarget(rawTarget: string): { receiveId: string; receiveIdType: FeishuReceiveIdType } | undefined {
1236
+ const scoped = rawTarget.trim().replace(/^(feishu|lark):/i, "").trim();
1237
+ if (!scoped) {
1238
+ return undefined;
1239
+ }
1240
+ const lowered = scoped.toLowerCase();
1241
+ const stripPrefix = (prefix: string): string => scoped.slice(prefix.length).trim();
1242
+ if (lowered.startsWith("chat:")) {
1243
+ const receiveId = stripPrefix("chat:");
1244
+ return receiveId ? { receiveId, receiveIdType: "chat_id" } : undefined;
1245
+ }
1246
+ if (lowered.startsWith("group:")) {
1247
+ const receiveId = stripPrefix("group:");
1248
+ return receiveId ? { receiveId, receiveIdType: "chat_id" } : undefined;
1249
+ }
1250
+ if (lowered.startsWith("channel:")) {
1251
+ const receiveId = stripPrefix("channel:");
1252
+ return receiveId ? { receiveId, receiveIdType: "chat_id" } : undefined;
1253
+ }
1254
+ if (lowered.startsWith("open_id:")) {
1255
+ const receiveId = stripPrefix("open_id:");
1256
+ return receiveId ? { receiveId, receiveIdType: "open_id" } : undefined;
1257
+ }
1258
+ if (lowered.startsWith("user:")) {
1259
+ const receiveId = stripPrefix("user:");
1260
+ if (!receiveId) {
1261
+ return undefined;
1262
+ }
1263
+ return {
1264
+ receiveId,
1265
+ receiveIdType: receiveId.startsWith("ou_") ? "open_id" : "user_id",
1266
+ };
1267
+ }
1268
+ if (lowered.startsWith("dm:")) {
1269
+ const receiveId = stripPrefix("dm:");
1270
+ if (!receiveId) {
1271
+ return undefined;
1272
+ }
1273
+ return {
1274
+ receiveId,
1275
+ receiveIdType: receiveId.startsWith("ou_") ? "open_id" : "user_id",
1276
+ };
1277
+ }
1278
+ if (scoped.startsWith("oc_")) {
1279
+ return {
1280
+ receiveId: scoped,
1281
+ receiveIdType: "chat_id",
1282
+ };
1283
+ }
1284
+ if (scoped.startsWith("ou_")) {
1285
+ return {
1286
+ receiveId: scoped,
1287
+ receiveIdType: "open_id",
1288
+ };
1289
+ }
1290
+ return {
1291
+ receiveId: scoped,
1292
+ receiveIdType: "user_id",
1293
+ };
1294
+ }
1295
+
1296
+ type FeishuApiResponse = {
1297
+ code?: number;
1298
+ msg?: string;
1299
+ message?: string;
1300
+ tenant_access_token?: string;
1301
+ data?: Record<string, unknown>;
1302
+ };
1303
+
1304
+ async function parseFeishuJsonResponse(response: Response): Promise<FeishuApiResponse> {
1305
+ const payload = await response.json() as unknown;
1306
+ const record = feishuAsRecord(payload);
1307
+ if (!record) {
1308
+ throw new Error("feishu api returned non-object response");
1309
+ }
1310
+ return record as FeishuApiResponse;
1311
+ }
1312
+
1313
+ function buildFeishuApiError(prefix: string, payload: FeishuApiResponse): Error {
1314
+ const code = payload.code ?? "unknown";
1315
+ const message = payload.msg ?? payload.message ?? "unknown";
1316
+ return new Error(`${prefix}: code=${code} msg=${message}`);
1317
+ }
1318
+
1319
+ async function sendFeishuApprovalNotificationDirect(
1320
+ api: OpenClawPluginApi,
1321
+ target: ChatApprovalTarget,
1322
+ message: string,
1323
+ ): Promise<{ messageId?: string }> {
1324
+ const feishuConfig = resolveFeishuRuntimeConfig(api, target);
1325
+ if (!feishuConfig) {
1326
+ throw new Error("feishu credentials not configured for approval notification");
1327
+ }
1328
+ const receiveTarget = resolveFeishuReceiveTarget(target.to);
1329
+ if (!receiveTarget) {
1330
+ throw new Error(`invalid feishu approval target: ${target.to}`);
1331
+ }
1332
+
1333
+ const authResponse = await fetch(`${feishuConfig.apiBase}/open-apis/auth/v3/tenant_access_token/internal`, {
1334
+ method: "POST",
1335
+ headers: {
1336
+ "content-type": "application/json",
1337
+ },
1338
+ body: JSON.stringify({
1339
+ app_id: feishuConfig.appId,
1340
+ app_secret: feishuConfig.appSecret,
1341
+ }),
1342
+ signal: AbortSignal.timeout(FEISHU_HTTP_TIMEOUT_MS),
1343
+ });
1344
+ const authPayload = await parseFeishuJsonResponse(authResponse);
1345
+ if (!authResponse.ok) {
1346
+ throw buildFeishuApiError(`feishu auth http_${authResponse.status}`, authPayload);
1347
+ }
1348
+ if (authPayload.code !== 0) {
1349
+ throw buildFeishuApiError("feishu auth failed", authPayload);
1350
+ }
1351
+ const token = feishuTrimmedString(authPayload.tenant_access_token);
1352
+ if (!token) {
1353
+ throw new Error("feishu auth failed: missing tenant_access_token");
1354
+ }
1355
+
1356
+ const sendResponse = await fetch(
1357
+ `${feishuConfig.apiBase}/open-apis/im/v1/messages?receive_id_type=${receiveTarget.receiveIdType}`,
1358
+ {
1359
+ method: "POST",
1360
+ headers: {
1361
+ "content-type": "application/json",
1362
+ authorization: `Bearer ${token}`,
1363
+ },
1364
+ body: JSON.stringify({
1365
+ receive_id: receiveTarget.receiveId,
1366
+ msg_type: "text",
1367
+ content: JSON.stringify({ text: message }),
1368
+ }),
1369
+ signal: AbortSignal.timeout(FEISHU_HTTP_TIMEOUT_MS),
1370
+ },
1371
+ );
1372
+ const sendPayload = await parseFeishuJsonResponse(sendResponse);
1373
+ if (!sendResponse.ok) {
1374
+ throw buildFeishuApiError(`feishu send http_${sendResponse.status}`, sendPayload);
1375
+ }
1376
+ if (sendPayload.code !== 0) {
1377
+ throw buildFeishuApiError("feishu send failed", sendPayload);
1378
+ }
1379
+ const messageId = feishuTrimmedString(sendPayload.data?.message_id);
1380
+ return messageId ? { messageId } : {};
1381
+ }
1382
+
1383
+ async function sendApprovalNotification(
1384
+ api: OpenClawPluginApi,
1385
+ target: ChatApprovalTarget,
1386
+ message: string,
1387
+ record: StoredApprovalRecord,
1388
+ ): Promise<StoredApprovalNotification> {
1389
+ const notification: StoredApprovalNotification = {
1390
+ channel: target.channel,
1391
+ to: target.to,
1392
+ ...(target.account_id ? { account_id: target.account_id } : {}),
1393
+ ...(target.thread_id !== undefined ? { thread_id: target.thread_id } : {}),
1394
+ };
1395
+
1396
+ if (target.channel === "telegram") {
1397
+ const sendTelegram = api.runtime.channel.telegram.sendMessageTelegram as (
1398
+ to: string,
1399
+ text: string,
1400
+ opts?: Record<string, unknown>,
1401
+ ) => Promise<{ messageId?: string }>;
1402
+
1403
+ const result = await sendTelegram(target.to, message, {
1404
+ cfg: api.config,
1405
+ ...(target.account_id ? { accountId: target.account_id } : {}),
1406
+ ...(normalizeThreadId(target.thread_id) !== undefined ? { messageThreadId: normalizeThreadId(target.thread_id) } : {}),
1407
+ buttons: [
1408
+ [
1409
+ {
1410
+ text: formatApprovalButtonLabel(record, "temporary"),
1411
+ callback_data: `/${APPROVAL_APPROVE_COMMAND} ${record.approval_id}`,
1412
+ style: "success",
1413
+ },
1414
+ {
1415
+ text: formatApprovalButtonLabel(record, "longterm"),
1416
+ callback_data: `/${APPROVAL_APPROVE_COMMAND} ${record.approval_id} long`,
1417
+ style: "primary",
1418
+ },
1419
+ ],
1420
+ [
1421
+ {
1422
+ text: text("拒绝", "Reject"),
1423
+ callback_data: `/${APPROVAL_REJECT_COMMAND} ${record.approval_id}`,
1424
+ style: "danger",
1425
+ },
1426
+ ],
1427
+ ],
1428
+ });
1429
+ if (result?.messageId) {
1430
+ notification.message_id = result.messageId;
1431
+ }
1432
+ notification.sent_at = nowIsoString();
1433
+ return notification;
1434
+ }
1435
+
1436
+ if (target.channel === "discord") {
1437
+ const sendDiscord = api.runtime.channel.discord.sendMessageDiscord as (
1438
+ to: string,
1439
+ text: string,
1440
+ opts?: Record<string, unknown>,
1441
+ ) => Promise<{ messageId?: string }>;
1442
+ const result = await sendDiscord(target.to, message, {
1443
+ cfg: api.config,
1444
+ ...(target.account_id ? { accountId: target.account_id } : {}),
1445
+ });
1446
+ if (result?.messageId) {
1447
+ notification.message_id = result.messageId;
1448
+ }
1449
+ notification.sent_at = nowIsoString();
1450
+ return notification;
1451
+ }
1452
+
1453
+ if (target.channel === "slack") {
1454
+ const sendSlack = api.runtime.channel.slack.sendMessageSlack as (
1455
+ to: string,
1456
+ text: string,
1457
+ opts?: Record<string, unknown>,
1458
+ ) => Promise<{ messageId?: string }>;
1459
+ const result = await sendSlack(target.to, message, {
1460
+ cfg: api.config,
1461
+ ...(target.account_id ? { accountId: target.account_id } : {}),
1462
+ });
1463
+ if (result?.messageId) {
1464
+ notification.message_id = result.messageId;
1465
+ }
1466
+ notification.sent_at = nowIsoString();
1467
+ return notification;
1468
+ }
1469
+
1470
+ if (target.channel === "signal") {
1471
+ const sendSignal = api.runtime.channel.signal.sendMessageSignal as (
1472
+ to: string,
1473
+ text: string,
1474
+ opts?: Record<string, unknown>,
1475
+ ) => Promise<{ messageId?: string }>;
1476
+ const result = await sendSignal(target.to, message, {
1477
+ cfg: api.config,
1478
+ ...(target.account_id ? { accountId: target.account_id } : {}),
1479
+ });
1480
+ if (result?.messageId) {
1481
+ notification.message_id = result.messageId;
1482
+ }
1483
+ notification.sent_at = nowIsoString();
1484
+ return notification;
1485
+ }
1486
+
1487
+ if (target.channel === "imessage") {
1488
+ const sendIMessage = api.runtime.channel.imessage.sendMessageIMessage as (
1489
+ to: string,
1490
+ text: string,
1491
+ opts?: Record<string, unknown>,
1492
+ ) => Promise<{ messageId?: string }>;
1493
+ const result = await sendIMessage(target.to, message, {
1494
+ cfg: api.config,
1495
+ ...(target.account_id ? { accountId: target.account_id } : {}),
1496
+ });
1497
+ if (result?.messageId) {
1498
+ notification.message_id = result.messageId;
1499
+ }
1500
+ notification.sent_at = nowIsoString();
1501
+ return notification;
1502
+ }
1503
+
1504
+ if (target.channel === "whatsapp") {
1505
+ const sendWhatsApp = api.runtime.channel.whatsapp.sendMessageWhatsApp as unknown as (
1506
+ to: string,
1507
+ text: string,
1508
+ opts?: Record<string, unknown>,
1509
+ ) => Promise<{ messageId?: string }>;
1510
+ const result = await sendWhatsApp(target.to, message, {
1511
+ cfg: api.config,
1512
+ });
1513
+ if (result?.messageId) {
1514
+ notification.message_id = result.messageId;
1515
+ }
1516
+ notification.sent_at = nowIsoString();
1517
+ return notification;
1518
+ }
1519
+
1520
+ if (target.channel === "line") {
1521
+ const pushLine = api.runtime.channel.line.pushMessageLine as (
1522
+ to: string,
1523
+ text: string,
1524
+ opts?: Record<string, unknown>,
1525
+ ) => Promise<{ messageId?: string }>;
1526
+ const result = await pushLine(target.to, message, {
1527
+ cfg: api.config,
1528
+ ...(target.account_id ? { accountId: target.account_id } : {}),
1529
+ });
1530
+ if (result?.messageId) {
1531
+ notification.message_id = result.messageId;
1532
+ }
1533
+ notification.sent_at = nowIsoString();
1534
+ return notification;
1535
+ }
1536
+
1537
+ const sendDynamic = resolveDynamicChannelSender(api, target.channel);
1538
+ if (sendDynamic) {
1539
+ const threadId = normalizeThreadId(target.thread_id);
1540
+ const result = await sendDynamic(target.to, message, {
1541
+ cfg: api.config,
1542
+ ...(target.account_id ? { accountId: target.account_id } : {}),
1543
+ ...(threadId !== undefined ? { messageThreadId: threadId } : {}),
1544
+ });
1545
+ if (result?.messageId) {
1546
+ notification.message_id = result.messageId;
1547
+ }
1548
+ notification.sent_at = nowIsoString();
1549
+ return notification;
1550
+ }
1551
+
1552
+ const sendPluginText = resolveChannelPluginSendText(target.channel);
1553
+ if (sendPluginText) {
1554
+ const result = await sendPluginText({
1555
+ cfg: api.config,
1556
+ to: target.to,
1557
+ text: message,
1558
+ ...(target.account_id ? { accountId: target.account_id } : {}),
1559
+ ...(target.thread_id !== undefined ? { threadId: target.thread_id } : {}),
1560
+ });
1561
+ const messageId = typeof result.messageId === "string" ? result.messageId : undefined;
1562
+ if (messageId) {
1563
+ notification.message_id = messageId;
1564
+ }
1565
+ notification.sent_at = nowIsoString();
1566
+ return notification;
1567
+ }
1568
+
1569
+ if (target.channel === "feishu" || target.channel === "lark") {
1570
+ const result = await sendFeishuApprovalNotificationDirect(api, target, message);
1571
+ if (result.messageId) {
1572
+ notification.message_id = result.messageId;
1573
+ }
1574
+ notification.sent_at = nowIsoString();
1575
+ return notification;
1576
+ }
1577
+
1578
+ const runtimeChannels = Object.keys((api.runtime.channel as unknown as Record<string, unknown>) ?? {});
1579
+ throw new Error(
1580
+ `unsupported approval notification channel: ${target.channel} (runtime channels: ${
1581
+ runtimeChannels.length > 0 ? runtimeChannels.join(", ") : "none"
1582
+ })`,
1583
+ );
1584
+ }
1585
+
1586
+ async function notifyApprovalTargets(
1587
+ api: OpenClawPluginApi,
1588
+ targets: ChatApprovalTarget[],
1589
+ record: StoredApprovalRecord,
1590
+ ): Promise<ApprovalNotificationResult> {
1591
+ if (targets.length === 0) {
1592
+ return {
1593
+ sent: false,
1594
+ notifications: [],
1595
+ };
1596
+ }
1597
+
1598
+ const notifications: StoredApprovalNotification[] = [];
1599
+ let sent = false;
1600
+ const prompt = formatApprovalPrompt(record);
1601
+ for (const target of targets) {
1602
+ let delivered = false;
1603
+ let lastError: unknown;
1604
+ for (let attempt = 1; attempt <= APPROVAL_NOTIFICATION_MAX_ATTEMPTS; attempt += 1) {
1605
+ try {
1606
+ const notification = await sendApprovalNotification(api, target, prompt, record);
1607
+ notifications.push(notification);
1608
+ sent = true;
1609
+ delivered = true;
1610
+ api.logger.info?.(
1611
+ `securityclaw: sent approval prompt approval_id=${record.approval_id} channel=${target.channel} to=${target.to} attempt=${attempt}${notification.message_id ? ` message_id=${notification.message_id}` : ""}`,
1612
+ );
1613
+ break;
1614
+ } catch (error) {
1615
+ lastError = error;
1616
+ if (attempt < APPROVAL_NOTIFICATION_MAX_ATTEMPTS) {
1617
+ api.logger.warn?.(
1618
+ `securityclaw: retrying approval prompt approval_id=${record.approval_id} channel=${target.channel} to=${target.to} attempt=${attempt} (${String(error)})`,
1619
+ );
1620
+ await sleep(APPROVAL_NOTIFICATION_RETRY_DELAYS_MS[attempt - 1] ?? APPROVAL_NOTIFICATION_RETRY_DELAYS_MS.at(-1) ?? 250);
1621
+ }
1622
+ }
1623
+ }
1624
+ if (!delivered) {
1625
+ api.logger.warn?.(
1626
+ `securityclaw: failed to send approval prompt approval_id=${record.approval_id} channel=${target.channel} to=${target.to} (${String(lastError)})`,
1627
+ );
1628
+ }
1629
+ }
1630
+
1631
+ return { sent, notifications };
1632
+ }
1633
+
1634
+ function formatApprovalBlockReason(params: {
1635
+ toolName: string;
1636
+ scope: string;
1637
+ traceId: string;
1638
+ resourceScope: ResourceScope;
1639
+ reasonCodes: string[];
1640
+ rules: string;
1641
+ approvalId: string;
1642
+ notificationSent: boolean;
1643
+ }): string {
1644
+ const reasons = params.reasonCodes.join(", ");
1645
+ const notifyHint = params.notificationSent
1646
+ ? text(
1647
+ "已通知管理员,批准后可重试。",
1648
+ "Sent to admin. Retry after approval.",
1649
+ )
1650
+ : text(
1651
+ "通知失败,请将审批单交给管理员处理。",
1652
+ "Admin notification failed. Share the request ID with an approver.",
1653
+ );
1654
+ const lines = [
1655
+ text("SecurityClaw 需要审批", "SecurityClaw Approval Required"),
1656
+ `${text("工具", "Tool")}: ${params.toolName}`,
1657
+ `${text("范围", "Scope")}: ${params.scope}`,
1658
+ `${text("资源", "Resource")}: ${formatResourceScopeDetail(params.resourceScope)}`,
1659
+ `${text("原因", "Reason")}: ${reasons || text("策略要求复核", "Policy review required")}`,
1660
+ ...(params.rules && params.rules !== "-" ? [`${text("规则", "Policy")}: ${params.rules}`] : []),
1661
+ `${text("审批单", "Request ID")}: ${params.approvalId}`,
1662
+ `${text("状态", "Status")}: ${notifyHint}`,
1663
+ `${text("追踪", "Trace")}: ${params.traceId}`,
1664
+ ];
1665
+ return lines.join("\n");
1666
+ }
1667
+
1668
+ function hasExplicitReadOnlyAccess(
1669
+ rawToolName: string | undefined,
1670
+ decisionContext: DecisionContext,
1671
+ ): boolean {
1672
+ if (rawToolName === "shell.exec") {
1673
+ return false;
1674
+ }
1675
+ if (decisionContext.tool_group !== "filesystem") {
1676
+ return false;
1677
+ }
1678
+ return decisionContext.operation === "read" ||
1679
+ decisionContext.operation === "list" ||
1680
+ decisionContext.operation === "search";
1681
+ }
1682
+
1683
+ function matchesProtectedStoragePath(candidate: string, resolved: ResolvedPluginRuntime): boolean {
1684
+ if (resolved.protectedDataDir) {
1685
+ const normalizedProtectedDataDir = path.normalize(resolved.protectedDataDir);
1686
+ if (candidate === normalizedProtectedDataDir || isPathInside(normalizedProtectedDataDir, candidate)) {
1687
+ return true;
1688
+ }
1689
+ }
1690
+ return resolved.protectedDbPaths.some((protectedPath) => candidate === path.normalize(protectedPath));
1691
+ }
1692
+
1693
+ function evaluateProtectedStorageAccess(
1694
+ rawToolName: string | undefined,
1695
+ decisionContext: DecisionContext,
1696
+ resolved: ResolvedPluginRuntime,
1697
+ ): {
1698
+ decision: Decision;
1699
+ decisionSource: DecisionSource;
1700
+ reasonCodes: string[];
1701
+ rules: string;
1702
+ } | undefined {
1703
+ if (decisionContext.resource_paths.length === 0) {
1704
+ return undefined;
1705
+ }
1706
+
1707
+ const matchedPath = decisionContext.resource_paths.some((candidate) =>
1708
+ matchesProtectedStoragePath(path.normalize(candidate), resolved)
1709
+ );
1710
+ if (!matchedPath || hasExplicitReadOnlyAccess(rawToolName, decisionContext)) {
1711
+ return undefined;
1712
+ }
1713
+
1714
+ return {
1715
+ decision: "block",
1716
+ decisionSource: "default",
1717
+ reasonCodes: [SECURITYCLAW_PROTECTED_STORAGE_REASON],
1718
+ rules: SECURITYCLAW_PROTECTED_STORAGE_RULE_ID,
1719
+ };
1720
+ }
1721
+
1722
+ function parseApprovalId(args: string | undefined): string | undefined {
1723
+ const value = args?.trim();
1724
+ return value ? value.split(/\s+/)[0] : undefined;
1725
+ }
1726
+
1727
+ function resolvePluginRuntime(api: OpenClawPluginApi): ResolvedPluginRuntime {
1728
+ const pluginConfig = (api.pluginConfig ?? {}) as SecurityClawPluginConfig;
1729
+ return PluginConfigParser.resolve(PLUGIN_ROOT, pluginConfig, resolvePluginStateDir(api));
1730
+ }
1731
+
1732
+ function createEventEmitter(config: SecurityClawConfig): EventEmitter {
1733
+ const sink = config.event_sink.webhook_url
1734
+ ? new HttpEventSink(config.event_sink.webhook_url, config.event_sink.timeout_ms)
1735
+ : undefined;
1736
+ return new EventEmitter(sink, config.event_sink.max_buffer, config.event_sink.retry_limit);
1737
+ }
1738
+
1739
+ function applyPluginConfigOverrides(config: SecurityClawConfig, pluginConfig: SecurityClawPluginConfig): SecurityClawConfig {
1740
+ const webhookUrl = pluginConfig.webhookUrl ?? config.event_sink.webhook_url;
1741
+ return {
1742
+ ...config,
1743
+ policy_version: pluginConfig.policyVersion ?? config.policy_version,
1744
+ environment: pluginConfig.environment ?? config.environment,
1745
+ defaults: {
1746
+ ...config.defaults,
1747
+ approval_ttl_seconds: pluginConfig.approvalTtlSeconds ?? config.defaults.approval_ttl_seconds,
1748
+ persist_mode: pluginConfig.persistMode ?? config.defaults.persist_mode
1749
+ },
1750
+ event_sink: {
1751
+ ...config.event_sink,
1752
+ ...(webhookUrl !== undefined ? { webhook_url: webhookUrl } : {})
1753
+ },
1754
+ sensitivity: hydrateSensitivePathConfig(config.sensitivity)
1755
+ };
1756
+ }
1757
+
1758
+ function buildRuntime(snapshot: LiveConfigSnapshot): RuntimeDependencies {
1759
+ return {
1760
+ config: snapshot.config,
1761
+ ruleEngine: new RuleEngine(snapshot.config.policies),
1762
+ decisionEngine: new DecisionEngine(snapshot.config),
1763
+ accountPolicyEngine: new AccountPolicyEngine(snapshot.override?.account_policies),
1764
+ dlpEngine: new DlpEngine(snapshot.config.dlp),
1765
+ emitter: createEventEmitter(snapshot.config),
1766
+ overrideLoaded: snapshot.overrideLoaded
1767
+ };
1768
+ }
1769
+
1770
+ function toStatusConfig(config: SecurityClawConfig, overrideLoaded: boolean, resolved: ResolvedPluginRuntime) {
1771
+ return {
1772
+ environment: config.environment,
1773
+ policy_version: config.policy_version,
1774
+ policy_count: config.policies.length,
1775
+ config_path: resolved.configPath,
1776
+ strategy_db_path: resolved.dbPath,
1777
+ strategy_loaded: overrideLoaded,
1778
+ legacy_override_path: resolved.legacyOverridePath
1779
+ };
1780
+ }
1781
+
1782
+ function buildDecisionContext(
1783
+ config: SecurityClawConfig,
1784
+ ctx: SecurityClawHookContext,
1785
+ toolName?: string,
1786
+ tags: string[] = [],
1787
+ resourceScope: ResourceScope = "none",
1788
+ resourcePaths: string[] = [],
1789
+ args?: unknown,
1790
+ toolArgsSummary?: string,
1791
+ ): DecisionContext {
1792
+ const workspace = "workspaceDir" in ctx ? ctx.workspaceDir : undefined;
1793
+ const runtimeScope = resolveScope({ workspaceDir: workspace, channelId: "channelId" in ctx ? ctx.channelId : undefined });
1794
+ const scope = config.environment || runtimeScope;
1795
+ const normalizedToolName = toolName ? normalizeToolName(toolName) : undefined;
1796
+ const derivedToolContext = deriveToolContext(normalizedToolName, args, resourceScope, resourcePaths, workspace);
1797
+ const effectiveToolName = derivedToolContext.inferredToolName ?? normalizedToolName;
1798
+ const mergedTags = [...new Set([...tags, ...derivedToolContext.tags, `resource_scope:${derivedToolContext.resourceScope}`])];
1799
+ const toolGroup = derivedToolContext.toolGroup;
1800
+ const operation = derivedToolContext.operation;
1801
+ const urlCandidates = args !== undefined ? collectUrlCandidates(args) : [];
1802
+ const destination = classifyDestination(urlCandidates);
1803
+ const fileType = inferFileType(derivedToolContext.resourcePaths);
1804
+ const summary = toolArgsSummary ?? (args !== undefined ? summarizeForLog(args, 240) : undefined);
1805
+ const labels = inferLabels(config, toolGroup, derivedToolContext.resourcePaths, summary);
1806
+ const volume = inferVolume(args, derivedToolContext.resourcePaths);
1807
+
1808
+ return {
1809
+ actor_id: ctx.agentId ?? "unknown-agent",
1810
+ scope,
1811
+ ...(effectiveToolName !== undefined ? { tool_name: effectiveToolName } : {}),
1812
+ ...(toolGroup !== undefined ? { tool_group: toolGroup } : {}),
1813
+ ...(operation !== undefined ? { operation } : {}),
1814
+ tags: mergedTags,
1815
+ resource_scope: derivedToolContext.resourceScope,
1816
+ resource_paths: derivedToolContext.resourcePaths,
1817
+ ...(fileType !== undefined ? { file_type: fileType } : {}),
1818
+ asset_labels: labels.asset_labels,
1819
+ data_labels: labels.data_labels,
1820
+ trust_level: mergedTags.includes("untrusted") ? "untrusted" : "unknown",
1821
+ ...(destination.destination_type !== undefined ? { destination_type: destination.destination_type } : {}),
1822
+ ...(destination.dest_domain !== undefined ? { dest_domain: destination.dest_domain } : {}),
1823
+ ...(destination.dest_ip_class !== undefined ? { dest_ip_class: destination.dest_ip_class } : {}),
1824
+ ...(summary !== undefined ? { tool_args_summary: summary } : {}),
1825
+ volume,
1826
+ security_context: {
1827
+ trace_id: ctx.runId ?? ctx.sessionId ?? ctx.sessionKey ?? `trace-${Date.now()}`,
1828
+ actor_id: ctx.agentId ?? "unknown-agent",
1829
+ workspace: workspace ?? "unknown-workspace",
1830
+ policy_version: config.policy_version,
1831
+ untrusted: false,
1832
+ tags: mergedTags,
1833
+ created_at: new Date().toISOString()
1834
+ }
1835
+ };
1836
+ }
1837
+
1838
+ function findingsToText(findings: DlpFinding[]): string {
1839
+ return findings.map((finding) => `${finding.pattern_name}@${finding.path}`).join(", ");
1840
+ }
1841
+
1842
+ function emitEvent(
1843
+ emitter: EventEmitter,
1844
+ event: SecurityDecisionEvent,
1845
+ logger: OpenClawPluginApi["logger"],
1846
+ ): void {
1847
+ void emitter.emitSecurityEvent(event).catch((error) => {
1848
+ logger.warn?.(`securityclaw: failed to emit event (${String(error)})`);
1849
+ });
1850
+ }
1851
+
1852
+ function createEvent(
1853
+ traceId: string,
1854
+ hook:
1855
+ | "before_prompt_build"
1856
+ | "before_tool_call"
1857
+ | "after_tool_call"
1858
+ | "tool_result_persist"
1859
+ | "message_sending",
1860
+ decision: "allow" | "warn" | "challenge" | "block",
1861
+ reasonCodes: string[],
1862
+ decisionSource?: DecisionSource,
1863
+ resourceScope?: ResourceScope,
1864
+ ): SecurityDecisionEvent {
1865
+ return {
1866
+ schema_version: "1.0",
1867
+ event_type: "SecurityDecisionEvent",
1868
+ trace_id: traceId,
1869
+ hook,
1870
+ decision,
1871
+ reason_codes: reasonCodes,
1872
+ latency_ms: 0,
1873
+ ts: new Date().toISOString(),
1874
+ ...(decisionSource !== undefined ? { decision_source: decisionSource } : {}),
1875
+ ...(resourceScope !== undefined ? { resource_scope: resourceScope } : {})
1876
+ };
1877
+ }
1878
+
1879
+ function sanitizeUnknown<T>(dlpEngine: DlpEngine, value: T): { value: T; findings: DlpFinding[] } {
1880
+ const findings = dlpEngine.scan(value);
1881
+ if (findings.length === 0) {
1882
+ return { value, findings };
1883
+ }
1884
+ return {
1885
+ value: dlpEngine.sanitize(value, findings, "sanitize"),
1886
+ findings
1887
+ };
1888
+ }
1889
+
1890
+ function summarizeForLog(value: unknown, maxLength: number): string {
1891
+ try {
1892
+ const text = JSON.stringify(value);
1893
+ if (text === undefined) {
1894
+ return String(value);
1895
+ }
1896
+ if (text.length <= maxLength) {
1897
+ return text;
1898
+ }
1899
+ return `${text.slice(0, maxLength)}...(truncated)`;
1900
+ } catch {
1901
+ return "[unserializable]";
1902
+ }
1903
+ }
1904
+
1905
+ function matchedRuleIds(matches: RuleMatch[]): string {
1906
+ if (matches.length === 0) {
1907
+ return "-";
1908
+ }
1909
+ return matches.map((match) => match.rule.rule_id).join(",");
1910
+ }
1911
+
1912
+ function normalizeToolName(rawToolName: string): string {
1913
+ const tool = rawToolName.trim().toLowerCase();
1914
+ if (tool === "exec" || tool === "shell" || tool === "shell_exec") {
1915
+ return "shell.exec";
1916
+ }
1917
+ if (tool === "fs.list" || tool === "file.list") {
1918
+ return "filesystem.list";
1919
+ }
1920
+ return rawToolName;
1921
+ }
1922
+
1923
+ function formatToolBlockReason(
1924
+ toolName: string,
1925
+ scope: string,
1926
+ traceId: string,
1927
+ decision: "challenge" | "block",
1928
+ decisionSource: DecisionSource,
1929
+ resourceScope: ResourceScope,
1930
+ reasonCodes: string[],
1931
+ rules: string,
1932
+ ): string {
1933
+ const reasons = reasonCodes.join(", ");
1934
+ const lines = [
1935
+ text(
1936
+ decision === "challenge" ? "SecurityClaw 需要审批" : "SecurityClaw 已阻止此操作",
1937
+ decision === "challenge" ? "SecurityClaw Approval Required" : "SecurityClaw Blocked",
1938
+ ),
1939
+ `${text("工具", "Tool")}: ${toolName}`,
1940
+ `${text("范围", "Scope")}: ${scope}`,
1941
+ `${text("资源", "Resource")}: ${formatResourceScopeDetail(resourceScope)}`,
1942
+ `${text("来源", "Source")}: ${decisionSource}`,
1943
+ `${text("原因", "Reason")}: ${reasons || text("策略要求复核", "Policy review required")}`,
1944
+ ...(rules && rules !== "-" ? [`${text("规则", "Policy")}: ${rules}`] : []),
1945
+ `${text("处理", "Action")}: ${text(
1946
+ decision === "challenge" ? "联系管理员审批后重试" : "联系安全管理员调整策略",
1947
+ decision === "challenge" ? "Contact an admin to approve and retry" : "Contact a security admin to adjust policy",
1948
+ )}`,
1949
+ `${text("追踪", "Trace")}: ${traceId}`,
1950
+ ];
1951
+ return lines.join("\n");
1952
+ }
1953
+
1954
+ const plugin = {
1955
+ id: "securityclaw",
1956
+ name: "SecurityClaw Security",
1957
+ description: "Runtime policy enforcement, transcript sanitization, and audit events for OpenClaw.",
1958
+ register(api: OpenClawPluginApi) {
1959
+ const resolved = resolvePluginRuntime(api);
1960
+ const pluginConfig = (api.pluginConfig ?? {}) as SecurityClawPluginConfig;
1961
+ const adminConsoleUrl = resolveAdminConsoleUrl(pluginConfig);
1962
+ const stateDir = resolvePluginStateDir(api);
1963
+ runtimeLocale = resolveRuntimeLocale();
1964
+ const adminAutoStart = pluginConfig.adminAutoStart ?? true;
1965
+ const decisionLogMaxLength = pluginConfig.decisionLogMaxLength ?? 240;
1966
+ const statusPath = resolved.statusPath;
1967
+ const dbPath = resolved.dbPath;
1968
+ const statusStore = new RuntimeStatusStore({ snapshotPath: statusPath, dbPath });
1969
+ const liveConfig = new LiveConfigResolver({
1970
+ configPath: resolved.configPath,
1971
+ dbPath,
1972
+ legacyOverridePath: resolved.legacyOverridePath,
1973
+ logger: {
1974
+ info: (message: string) => api.logger.info?.(message),
1975
+ warn: (message: string) => api.logger.warn?.(message)
1976
+ },
1977
+ transform: (config: SecurityClawConfig) => applyPluginConfigOverrides(config, pluginConfig),
1978
+ onReload: (snapshot: LiveConfigSnapshot) => {
1979
+ statusStore.updateConfig(toStatusConfig(snapshot.config, snapshot.overrideLoaded, resolved));
1980
+ api.logger.info?.(
1981
+ `securityclaw: policy refresh env=${snapshot.config.environment} policy_version=${snapshot.config.policy_version} rules=${snapshot.config.policies.length}`,
1982
+ );
1983
+ }
1984
+ });
1985
+ let runtime = buildRuntime(liveConfig.getSnapshot());
1986
+ function getRuntime(): RuntimeDependencies {
1987
+ const snapshot = liveConfig.getSnapshot();
1988
+ if (snapshot.config !== runtime.config || snapshot.overrideLoaded !== runtime.overrideLoaded) {
1989
+ runtime = buildRuntime(snapshot);
1990
+ }
1991
+ return runtime;
1992
+ }
1993
+
1994
+ statusStore.markBoot(toStatusConfig(runtime.config, runtime.overrideLoaded, resolved));
1995
+ const adminBuildPromise = ensureAdminAssetsBuilt({
1996
+ logger: {
1997
+ info: (message: string) => api.logger.info?.(`securityclaw: ${message}`)
1998
+ }
1999
+ }).catch((error) => {
2000
+ api.logger.warn?.(`securityclaw: failed to refresh admin bundle (${String(error)})`);
2001
+ });
2002
+ if (adminAutoStart) {
2003
+ const autoStartDecision = shouldAutoStartAdminServer(process.env);
2004
+ if (autoStartDecision.enabled) {
2005
+ const adminServerOptions = {
2006
+ configPath: resolved.configPath,
2007
+ legacyOverridePath: resolved.legacyOverridePath,
2008
+ statusPath,
2009
+ dbPath,
2010
+ unrefOnStart: true,
2011
+ logger: {
2012
+ info: (message: string) => api.logger.info?.(`securityclaw: ${message}`),
2013
+ warn: (message: string) => api.logger.warn?.(`securityclaw: ${message}`)
2014
+ },
2015
+ ...(pluginConfig.adminPort !== undefined ? { port: pluginConfig.adminPort } : {})
2016
+ };
2017
+ void adminBuildPromise
2018
+ .then(() => startAdminServer(adminServerOptions))
2019
+ .then((result) => {
2020
+ announceAdminConsole({
2021
+ locale: runtimeLocale,
2022
+ logger: {
2023
+ info: (message: string) => api.logger.info?.(`securityclaw: ${message}`),
2024
+ warn: (message: string) => api.logger.warn?.(`securityclaw: ${message}`),
2025
+ },
2026
+ stateDir,
2027
+ state: result.state,
2028
+ url: `http://127.0.0.1:${result.runtime.port}`,
2029
+ });
2030
+ })
2031
+ .catch((error) => {
2032
+ api.logger.warn?.(`securityclaw: failed to auto-start admin dashboard (${String(error)})`);
2033
+ });
2034
+ } else {
2035
+ if (shouldAnnounceAdminConsoleForArgv(process.argv)) {
2036
+ announceAdminConsole({
2037
+ locale: runtimeLocale,
2038
+ logger: {
2039
+ info: (message: string) => api.logger.info?.(`securityclaw: ${message}`),
2040
+ warn: (message: string) => api.logger.warn?.(`securityclaw: ${message}`),
2041
+ },
2042
+ stateDir,
2043
+ state: "service-command",
2044
+ url: adminConsoleUrl,
2045
+ });
2046
+ api.logger.info?.("securityclaw: admin dashboard is hosted by the background OpenClaw gateway service");
2047
+ } else {
2048
+ api.logger.info?.(
2049
+ `securityclaw: admin auto-start skipped in ${autoStartDecision.reason}; use npm run admin for standalone dashboard`,
2050
+ );
2051
+ }
2052
+ }
2053
+ } else {
2054
+ api.logger.info?.("securityclaw: admin auto-start disabled by config");
2055
+ }
2056
+
2057
+ api.logger.info?.(
2058
+ `securityclaw: boot env=${runtime.config.environment} policy_version=${runtime.config.policy_version} dlp_mode=${runtime.config.dlp.on_dlp_hit} rules=${runtime.config.policies.length}`,
2059
+ );
2060
+ if (!runtime.config.event_sink.webhook_url) {
2061
+ api.logger.info?.("securityclaw: event sink disabled (webhook_url is empty), using logger-only observability");
2062
+ }
2063
+
2064
+ function resolveApprovalBridge(current: RuntimeDependencies = getRuntime()): ResolvedApprovalBridge {
2065
+ return mergeApprovalBridgeConfig(deriveApprovalBridgeFromAdminPolicies(current.accountPolicyEngine));
2066
+ }
2067
+ const approvalStore = new ChatApprovalStore(dbPath);
2068
+ const initialApprovalBridge = resolveApprovalBridge(runtime);
2069
+ if (initialApprovalBridge.enabled) {
2070
+ api.logger.info?.(
2071
+ `securityclaw: approval bridge enabled targets=${initialApprovalBridge.targets.length} approvers=${initialApprovalBridge.approvers.length}`,
2072
+ );
2073
+ if (initialApprovalBridge.approvers.length === 0) {
2074
+ api.logger.warn?.("securityclaw: approval bridge is enabled but no approvers are configured");
2075
+ }
2076
+ api.logger.info?.("securityclaw: approval bridge source=account_policies_admin");
2077
+ }
2078
+
2079
+ api.registerCommand({
2080
+ name: APPROVAL_APPROVE_COMMAND,
2081
+ description: "Approve a pending SecurityClaw request in the current admin chat.",
2082
+ acceptsArgs: true,
2083
+ requireAuth: false,
2084
+ handler: async (ctx) => {
2085
+ const approvalBridge = resolveApprovalBridge();
2086
+ const commandContext: SecurityClawApprovalCommandContext = {
2087
+ channel: ctx.channel,
2088
+ ...(ctx.senderId !== undefined ? { senderId: ctx.senderId } : {}),
2089
+ ...(ctx.from !== undefined ? { from: ctx.from } : {}),
2090
+ ...(ctx.to !== undefined ? { to: ctx.to } : {}),
2091
+ ...(ctx.accountId !== undefined ? { accountId: ctx.accountId } : {}),
2092
+ ...(ctx.args !== undefined ? { args: ctx.args } : {}),
2093
+ isAuthorizedSender: ctx.isAuthorizedSender,
2094
+ };
2095
+ if (!approvalBridge.enabled) {
2096
+ return { text: text("SecurityClaw 审批桥接未启用。", "SecurityClaw approval bridge is not enabled.") };
2097
+ }
2098
+ if (!commandContext.isAuthorizedSender || !matchesApprover(approvalBridge.approvers, commandContext)) {
2099
+ return { text: text("你无权审批 SecurityClaw 请求。", "You are not allowed to approve SecurityClaw requests.") };
2100
+ }
2101
+ const approvalId = parseApprovalId(commandContext.args);
2102
+ if (!approvalId) {
2103
+ return {
2104
+ text: text(
2105
+ `用法: /${APPROVAL_APPROVE_COMMAND} <approval_id> [long]`,
2106
+ `Usage: /${APPROVAL_APPROVE_COMMAND} <approval_id> [long]`,
2107
+ ),
2108
+ };
2109
+ }
2110
+ const existing = approvalStore.getById(approvalId);
2111
+ if (!existing) {
2112
+ return { text: text(`审批请求不存在: ${approvalId}`, `Approval request not found: ${approvalId}`) };
2113
+ }
2114
+ if (existing.status !== "pending") {
2115
+ return {
2116
+ text: text(
2117
+ `审批请求当前状态为 ${existing.status},无法重复批准。`,
2118
+ `Approval request is ${existing.status}; it cannot be approved again.`,
2119
+ ),
2120
+ };
2121
+ }
2122
+ const grantMode = parseApprovalGrantMode(commandContext.args);
2123
+ const grantExpiresAt = resolveApprovalGrantExpiry(existing, grantMode);
2124
+ approvalStore.resolve(
2125
+ approvalId,
2126
+ `${commandContext.channel ?? "unknown"}:${commandContext.from ?? "unknown"}`,
2127
+ "approved",
2128
+ { expires_at: grantExpiresAt },
2129
+ );
2130
+ return {
2131
+ text: text(
2132
+ `已为 ${existing.actor_id} 添加${formatGrantModeLabel(grantMode)},范围=${existing.scope},有效期至 ${formatTimestampForApproval(grantExpiresAt)}。`,
2133
+ `${formatGrantModeLabel(grantMode)} granted for ${existing.actor_id}, scope=${existing.scope}, expires at ${formatTimestampForApproval(grantExpiresAt)}.`,
2134
+ ),
2135
+ };
2136
+ },
2137
+ });
2138
+
2139
+ api.registerCommand({
2140
+ name: APPROVAL_REJECT_COMMAND,
2141
+ description: "Reject a pending SecurityClaw request in the current admin chat.",
2142
+ acceptsArgs: true,
2143
+ requireAuth: false,
2144
+ handler: async (ctx) => {
2145
+ const approvalBridge = resolveApprovalBridge();
2146
+ const commandContext: SecurityClawApprovalCommandContext = {
2147
+ channel: ctx.channel,
2148
+ ...(ctx.senderId !== undefined ? { senderId: ctx.senderId } : {}),
2149
+ ...(ctx.from !== undefined ? { from: ctx.from } : {}),
2150
+ ...(ctx.to !== undefined ? { to: ctx.to } : {}),
2151
+ ...(ctx.accountId !== undefined ? { accountId: ctx.accountId } : {}),
2152
+ ...(ctx.args !== undefined ? { args: ctx.args } : {}),
2153
+ isAuthorizedSender: ctx.isAuthorizedSender,
2154
+ };
2155
+ if (!approvalBridge.enabled) {
2156
+ return { text: text("SecurityClaw 审批桥接未启用。", "SecurityClaw approval bridge is not enabled.") };
2157
+ }
2158
+ if (!commandContext.isAuthorizedSender || !matchesApprover(approvalBridge.approvers, commandContext)) {
2159
+ return { text: text("你无权审批 SecurityClaw 请求。", "You are not allowed to approve SecurityClaw requests.") };
2160
+ }
2161
+ const approvalId = parseApprovalId(commandContext.args);
2162
+ if (!approvalId) {
2163
+ return { text: text(`用法: /${APPROVAL_REJECT_COMMAND} <approval_id>`, `Usage: /${APPROVAL_REJECT_COMMAND} <approval_id>`) };
2164
+ }
2165
+ const existing = approvalStore.getById(approvalId);
2166
+ if (!existing) {
2167
+ return { text: text(`审批请求不存在: ${approvalId}`, `Approval request not found: ${approvalId}`) };
2168
+ }
2169
+ if (existing.status !== "pending") {
2170
+ return {
2171
+ text: text(
2172
+ `审批请求当前状态为 ${existing.status},无法重复拒绝。`,
2173
+ `Approval request is ${existing.status}; it cannot be rejected again.`,
2174
+ ),
2175
+ };
2176
+ }
2177
+ approvalStore.resolve(
2178
+ approvalId,
2179
+ `${commandContext.channel ?? "unknown"}:${commandContext.from ?? "unknown"}`,
2180
+ "rejected",
2181
+ );
2182
+ return {
2183
+ text: text(
2184
+ `已拒绝 ${approvalId},不会为 ${existing.actor_id} 增加授权。`,
2185
+ `Rejected ${approvalId}. No grant was added for ${existing.actor_id}.`,
2186
+ ),
2187
+ };
2188
+ },
2189
+ });
2190
+
2191
+ api.registerCommand({
2192
+ name: APPROVAL_PENDING_COMMAND,
2193
+ description: "List recent pending SecurityClaw approval requests.",
2194
+ acceptsArgs: false,
2195
+ requireAuth: false,
2196
+ handler: async (ctx) => {
2197
+ const approvalBridge = resolveApprovalBridge();
2198
+ const commandContext: SecurityClawApprovalCommandContext = {
2199
+ channel: ctx.channel,
2200
+ ...(ctx.senderId !== undefined ? { senderId: ctx.senderId } : {}),
2201
+ ...(ctx.from !== undefined ? { from: ctx.from } : {}),
2202
+ ...(ctx.to !== undefined ? { to: ctx.to } : {}),
2203
+ ...(ctx.accountId !== undefined ? { accountId: ctx.accountId } : {}),
2204
+ ...(ctx.args !== undefined ? { args: ctx.args } : {}),
2205
+ isAuthorizedSender: ctx.isAuthorizedSender,
2206
+ };
2207
+ if (!approvalBridge.enabled) {
2208
+ return { text: text("SecurityClaw 审批桥接未启用。", "SecurityClaw approval bridge is not enabled.") };
2209
+ }
2210
+ if (!commandContext.isAuthorizedSender || !matchesApprover(approvalBridge.approvers, commandContext)) {
2211
+ return {
2212
+ text: text(
2213
+ "你无权查看 SecurityClaw 待审批请求。",
2214
+ "You are not allowed to view pending SecurityClaw approvals.",
2215
+ ),
2216
+ };
2217
+ }
2218
+ return { text: formatPendingApprovals(approvalStore.listPending(10)) };
2219
+ },
2220
+ });
2221
+
2222
+ api.on(
2223
+ "before_prompt_build",
2224
+ async (_event, ctx) => {
2225
+ const hookContext = ctx as SecurityClawHookContext;
2226
+ const current = getRuntime();
2227
+ const traceId = hookContext.runId ?? hookContext.sessionId ?? hookContext.sessionKey ?? `trace-${Date.now()}`;
2228
+ const scope = resolveScope({ workspaceDir: hookContext.workspaceDir, channelId: hookContext.channelId });
2229
+ const prependSystemContext = [
2230
+ "[SecurityClaw Security Context]",
2231
+ `trace_id=${traceId}`,
2232
+ `agent_id=${hookContext.agentId ?? "unknown-agent"}`,
2233
+ `scope=${scope}`,
2234
+ `policy_version=${current.config.policy_version}`
2235
+ ].join("\n");
2236
+ emitEvent(
2237
+ current.emitter,
2238
+ createEvent(traceId, "before_prompt_build", "allow", ["SECURITY_CONTEXT_INJECTED"]),
2239
+ api.logger,
2240
+ );
2241
+ statusStore.recordDecision({
2242
+ ts: new Date().toISOString(),
2243
+ hook: "before_prompt_build",
2244
+ trace_id: traceId,
2245
+ actor: hookContext.agentId ?? "unknown-agent",
2246
+ scope,
2247
+ decision: "allow",
2248
+ reasons: ["SECURITY_CONTEXT_INJECTED"]
2249
+ });
2250
+ return { prependSystemContext };
2251
+ },
2252
+ { priority: 100 },
2253
+ );
2254
+
2255
+ api.on(
2256
+ "before_tool_call",
2257
+ async (event, ctx) => {
2258
+ const hookContext = ctx as SecurityClawHookContext;
2259
+ const current = getRuntime();
2260
+ const approvalBridge = resolveApprovalBridge(current);
2261
+ const normalizedToolName = normalizeToolName(event.toolName);
2262
+ const rawArguments = event.params;
2263
+ const resource = extractResourceContext(rawArguments, hookContext.workspaceDir);
2264
+ const argsSummary = summarizeForLog(rawArguments, decisionLogMaxLength);
2265
+ const decisionContext = buildDecisionContext(
2266
+ current.config,
2267
+ hookContext,
2268
+ normalizedToolName,
2269
+ [],
2270
+ resource.resourceScope,
2271
+ resource.resourcePaths,
2272
+ rawArguments,
2273
+ argsSummary,
2274
+ );
2275
+ const traceId = decisionContext.security_context.trace_id;
2276
+ const effectiveToolName = decisionContext.tool_name ?? normalizedToolName ?? "unknown-tool";
2277
+ const approvalSubject = resolveApprovalSubject(hookContext);
2278
+ const accountPolicy = current.accountPolicyEngine.getPolicy(approvalSubject);
2279
+ const protectedStorageAccess = evaluateProtectedStorageAccess(normalizedToolName, decisionContext, resolved);
2280
+ let rules = protectedStorageAccess?.rules ?? "-";
2281
+ let effectiveDecision = protectedStorageAccess?.decision ?? "allow";
2282
+ let effectiveDecisionSource = protectedStorageAccess?.decisionSource ?? "default";
2283
+ let effectiveReasonCodes = [...(protectedStorageAccess?.reasonCodes ?? ["ALLOW"])];
2284
+ let accountOverride: ReturnType<AccountPolicyEngine["evaluate"]> | undefined;
2285
+ let approvalBlockReason: string | undefined;
2286
+
2287
+ if (!protectedStorageAccess) {
2288
+ const matchedFileRule = matchFileRule(decisionContext.resource_paths, current.config.file_rules);
2289
+ const matches = matchedFileRule ? [] : current.ruleEngine.match(decisionContext);
2290
+ const outcome = matchedFileRule
2291
+ ? {
2292
+ decision: matchedFileRule.decision,
2293
+ decision_source: "file_rule" as const,
2294
+ reason_codes: matchedFileRule.reason_codes?.length
2295
+ ? [...matchedFileRule.reason_codes]
2296
+ : [defaultFileRuleReasonCode(matchedFileRule.decision)],
2297
+ matched_rules: [],
2298
+ ...(matchedFileRule.decision === "challenge"
2299
+ ? { challenge_ttl_seconds: current.config.defaults.approval_ttl_seconds }
2300
+ : {}),
2301
+ }
2302
+ : current.decisionEngine.evaluate(decisionContext, matches);
2303
+ const ruleIds = matchedFileRule
2304
+ ? [`file_rule:${matchedFileRule.id}`]
2305
+ : matches.map((match) => match.rule.rule_id);
2306
+ rules = matchedFileRule ? `file_rule:${matchedFileRule.id}` : matchedRuleIds(matches);
2307
+ accountOverride = matchedFileRule ? undefined : current.accountPolicyEngine.evaluate(approvalSubject);
2308
+ const approvalRequestKey = createApprovalRequestKey({
2309
+ policy_version: current.config.policy_version,
2310
+ scope: decisionContext.scope,
2311
+ tool_name: effectiveToolName,
2312
+ resource_scope: decisionContext.resource_scope,
2313
+ resource_paths: [],
2314
+ params: {
2315
+ operation: decisionContext.operation ?? null,
2316
+ destination_type: decisionContext.destination_type ?? null,
2317
+ dest_domain: decisionContext.dest_domain ?? null,
2318
+ rule_ids: ruleIds,
2319
+ },
2320
+ });
2321
+ effectiveDecision = accountOverride?.decision ?? outcome.decision;
2322
+ effectiveDecisionSource = accountOverride?.decision_source ?? outcome.decision_source;
2323
+ effectiveReasonCodes = [...(accountOverride?.reason_codes ?? outcome.reason_codes)];
2324
+
2325
+ if (effectiveDecision === "challenge" && approvalBridge.enabled) {
2326
+ const approvalScope = decisionContext.scope;
2327
+ const approved = approvalStore.findApproved(approvalSubject, approvalRequestKey);
2328
+ if (approved) {
2329
+ effectiveDecision = "allow";
2330
+ effectiveDecisionSource = "approval";
2331
+ effectiveReasonCodes = ["APPROVAL_GRANTED"];
2332
+ } else {
2333
+ let pending = approvalStore.findPending(approvalSubject, approvalRequestKey);
2334
+ let notificationResult: ApprovalNotificationResult = {
2335
+ sent: Boolean(pending?.notifications.length),
2336
+ notifications: pending?.notifications ?? [],
2337
+ };
2338
+
2339
+ if (!pending) {
2340
+ pending = approvalStore.create({
2341
+ request_key: approvalRequestKey,
2342
+ session_scope: approvalSubject,
2343
+ expires_at: new Date(
2344
+ Date.now() +
2345
+ ((outcome.challenge_ttl_seconds ?? current.config.defaults.approval_ttl_seconds) * 1000),
2346
+ ).toISOString(),
2347
+ policy_version: current.config.policy_version,
2348
+ actor_id: approvalSubject,
2349
+ scope: approvalScope,
2350
+ tool_name: effectiveToolName,
2351
+ resource_scope: decisionContext.resource_scope,
2352
+ resource_paths: decisionContext.resource_paths,
2353
+ reason_codes: outcome.reason_codes,
2354
+ rule_ids: ruleIds,
2355
+ args_summary: argsSummary,
2356
+ });
2357
+ }
2358
+
2359
+ if (approvalBridge.targets.length > 0 && shouldResendPendingApproval(pending)) {
2360
+ notificationResult = await notifyApprovalTargets(api, approvalBridge.targets, pending);
2361
+ if (notificationResult.notifications.length > 0) {
2362
+ pending =
2363
+ approvalStore.updateNotifications(
2364
+ pending.approval_id,
2365
+ mergeApprovalNotifications(pending.notifications, notificationResult.notifications),
2366
+ ) ?? pending;
2367
+ }
2368
+ }
2369
+
2370
+ approvalBlockReason = formatApprovalBlockReason({
2371
+ toolName: event.toolName,
2372
+ scope: decisionContext.scope,
2373
+ traceId,
2374
+ resourceScope: decisionContext.resource_scope,
2375
+ reasonCodes: outcome.reason_codes,
2376
+ rules,
2377
+ approvalId: pending.approval_id,
2378
+ notificationSent: notificationResult.sent || pending.notifications.length > 0,
2379
+ });
2380
+ }
2381
+ }
2382
+ }
2383
+
2384
+ const decisionLog = [
2385
+ "securityclaw: before_tool_call",
2386
+ `trace_id=${traceId}`,
2387
+ `actor=${approvalSubject}`,
2388
+ `scope=${decisionContext.scope}`,
2389
+ `resource_scope=${decisionContext.resource_scope}`,
2390
+ `paths=${decisionContext.resource_paths.length > 0 ? trimText(decisionContext.resource_paths.slice(0, 3).join("|"), 200) : "-"}`,
2391
+ `asset_labels=${decisionContext.asset_labels.length > 0 ? decisionContext.asset_labels.join(",") : "-"}`,
2392
+ `data_labels=${decisionContext.data_labels.length > 0 ? decisionContext.data_labels.join(",") : "-"}`,
2393
+ `tool=${effectiveToolName}`,
2394
+ `raw_tool=${event.toolName}`,
2395
+ `decision=${effectiveDecision}`,
2396
+ `source=${effectiveDecisionSource}`,
2397
+ `account_mode=${accountPolicy?.mode ?? "apply_rules"}`,
2398
+ `is_admin=${accountPolicy?.is_admin === true}`,
2399
+ `rules=${rules}`,
2400
+ `reasons=${effectiveReasonCodes.join(",")}`,
2401
+ `args=${argsSummary}`
2402
+ ].join(" ");
2403
+
2404
+ if (effectiveDecision === "allow") {
2405
+ api.logger.info?.(decisionLog);
2406
+ } else {
2407
+ api.logger.warn?.(decisionLog);
2408
+ }
2409
+
2410
+ emitEvent(
2411
+ current.emitter,
2412
+ createEvent(
2413
+ traceId,
2414
+ "before_tool_call",
2415
+ effectiveDecision,
2416
+ effectiveReasonCodes,
2417
+ effectiveDecisionSource,
2418
+ decisionContext.resource_scope,
2419
+ ),
2420
+ api.logger,
2421
+ );
2422
+ statusStore.recordDecision({
2423
+ ts: new Date().toISOString(),
2424
+ hook: "before_tool_call",
2425
+ trace_id: traceId,
2426
+ actor: approvalSubject,
2427
+ scope: decisionContext.scope,
2428
+ tool: effectiveToolName,
2429
+ decision: effectiveDecision,
2430
+ decision_source: effectiveDecisionSource,
2431
+ resource_scope: decisionContext.resource_scope,
2432
+ reasons: effectiveReasonCodes,
2433
+ rules
2434
+ });
2435
+
2436
+ if (effectiveDecision === "block") {
2437
+ return {
2438
+ block: true,
2439
+ blockReason: formatToolBlockReason(
2440
+ event.toolName,
2441
+ decisionContext.scope,
2442
+ traceId,
2443
+ effectiveDecision,
2444
+ effectiveDecisionSource,
2445
+ decisionContext.resource_scope,
2446
+ effectiveReasonCodes,
2447
+ rules,
2448
+ )
2449
+ };
2450
+ }
2451
+
2452
+ if (effectiveDecision === "challenge") {
2453
+ return {
2454
+ block: true,
2455
+ blockReason:
2456
+ approvalBlockReason ??
2457
+ formatToolBlockReason(
2458
+ event.toolName,
2459
+ decisionContext.scope,
2460
+ traceId,
2461
+ effectiveDecision,
2462
+ effectiveDecisionSource,
2463
+ decisionContext.resource_scope,
2464
+ effectiveReasonCodes,
2465
+ rules,
2466
+ )
2467
+ };
2468
+ }
2469
+
2470
+ return undefined;
2471
+ },
2472
+ { priority: 100 },
2473
+ );
2474
+
2475
+ api.on("after_tool_call", async (event, ctx) => {
2476
+ const current = getRuntime();
2477
+ const decisionContext = buildDecisionContext(current.config, ctx, event.toolName);
2478
+ const traceId = decisionContext.security_context.trace_id;
2479
+ const findings = current.dlpEngine.scan(event.result);
2480
+ const decision =
2481
+ findings.length === 0 ? "allow" : current.config.dlp.on_dlp_hit === "block" ? "block" : "warn";
2482
+ if (findings.length > 0) {
2483
+ api.logger.warn?.(
2484
+ `securityclaw: after_tool_call findings tool=${event.toolName} findings=${findingsToText(findings)}`,
2485
+ );
2486
+ }
2487
+ emitEvent(
2488
+ current.emitter,
2489
+ createEvent(
2490
+ traceId,
2491
+ "after_tool_call",
2492
+ decision,
2493
+ findings.length > 0 ? ["DLP_HIT"] : ["RESULT_OK"],
2494
+ ),
2495
+ api.logger,
2496
+ );
2497
+ statusStore.recordDecision({
2498
+ ts: new Date().toISOString(),
2499
+ hook: "after_tool_call",
2500
+ trace_id: traceId,
2501
+ actor: decisionContext.actor_id,
2502
+ scope: decisionContext.scope,
2503
+ tool: event.toolName,
2504
+ decision,
2505
+ reasons: findings.length > 0 ? ["DLP_HIT"] : ["RESULT_OK"]
2506
+ });
2507
+ });
2508
+
2509
+ api.on(
2510
+ "tool_result_persist",
2511
+ (event) => {
2512
+ const current = getRuntime();
2513
+ const traceId = event.toolCallId ?? event.toolName ?? `trace-${Date.now()}`;
2514
+ const sanitized = sanitizeUnknown(current.dlpEngine, event.message);
2515
+ if (sanitized.findings.length === 0) {
2516
+ emitEvent(
2517
+ current.emitter,
2518
+ createEvent(traceId, "tool_result_persist", "allow", ["PERSIST_OK"]),
2519
+ api.logger,
2520
+ );
2521
+ if (event.toolName !== undefined) {
2522
+ statusStore.recordDecision({
2523
+ ts: new Date().toISOString(),
2524
+ hook: "tool_result_persist",
2525
+ trace_id: traceId,
2526
+ tool: event.toolName,
2527
+ decision: "allow",
2528
+ reasons: ["PERSIST_OK"]
2529
+ });
2530
+ } else {
2531
+ statusStore.recordDecision({
2532
+ ts: new Date().toISOString(),
2533
+ hook: "tool_result_persist",
2534
+ trace_id: traceId,
2535
+ decision: "allow",
2536
+ reasons: ["PERSIST_OK"]
2537
+ });
2538
+ }
2539
+ return undefined;
2540
+ }
2541
+ emitEvent(
2542
+ current.emitter,
2543
+ createEvent(
2544
+ traceId,
2545
+ "tool_result_persist",
2546
+ current.config.defaults.persist_mode === "strict" ? "block" : "warn",
2547
+ ["PERSIST_SANITIZED"],
2548
+ ),
2549
+ api.logger,
2550
+ );
2551
+ if (event.toolName !== undefined) {
2552
+ statusStore.recordDecision({
2553
+ ts: new Date().toISOString(),
2554
+ hook: "tool_result_persist",
2555
+ trace_id: traceId,
2556
+ tool: event.toolName,
2557
+ decision: current.config.defaults.persist_mode === "strict" ? "block" : "warn",
2558
+ reasons: ["PERSIST_SANITIZED"]
2559
+ });
2560
+ } else {
2561
+ statusStore.recordDecision({
2562
+ ts: new Date().toISOString(),
2563
+ hook: "tool_result_persist",
2564
+ trace_id: traceId,
2565
+ decision: current.config.defaults.persist_mode === "strict" ? "block" : "warn",
2566
+ reasons: ["PERSIST_SANITIZED"]
2567
+ });
2568
+ }
2569
+ api.logger.warn?.(
2570
+ `securityclaw: tool_result_persist trace_id=${traceId} tool=${event.toolName} decision=${current.config.defaults.persist_mode === "strict" ? "block" : "warn"} findings=${findingsToText(sanitized.findings)}`,
2571
+ );
2572
+ return { message: sanitized.value };
2573
+ },
2574
+ { priority: 100 },
2575
+ );
2576
+
2577
+ api.on(
2578
+ "before_message_write",
2579
+ (event) => {
2580
+ const current = getRuntime();
2581
+ if (current.config.defaults.persist_mode !== "strict") {
2582
+ return undefined;
2583
+ }
2584
+ const findings = current.dlpEngine.scan(event.message);
2585
+ if (findings.length === 0) {
2586
+ return undefined;
2587
+ }
2588
+ statusStore.recordDecision({
2589
+ ts: new Date().toISOString(),
2590
+ hook: "before_message_write",
2591
+ trace_id: `before-write-${Date.now()}`,
2592
+ decision: "block",
2593
+ reasons: ["PERSIST_BLOCKED_DLP"]
2594
+ });
2595
+ api.logger.warn?.(
2596
+ `securityclaw: before_message_write blocked findings=${findingsToText(findings)}`,
2597
+ );
2598
+ return { block: true };
2599
+ },
2600
+ { priority: 100 },
2601
+ );
2602
+
2603
+ api.on(
2604
+ "message_sending",
2605
+ async (event, ctx) => {
2606
+ const current = getRuntime();
2607
+ const traceId = ctx.conversationId ?? ctx.accountId ?? `trace-${Date.now()}`;
2608
+ const sanitized = sanitizeUnknown(current.dlpEngine, event.content);
2609
+ if (sanitized.findings.length === 0) {
2610
+ emitEvent(
2611
+ current.emitter,
2612
+ createEvent(traceId, "message_sending", "allow", ["MESSAGE_OK"]),
2613
+ api.logger,
2614
+ );
2615
+ statusStore.recordDecision({
2616
+ ts: new Date().toISOString(),
2617
+ hook: "message_sending",
2618
+ trace_id: traceId,
2619
+ decision: "allow",
2620
+ reasons: ["MESSAGE_OK"]
2621
+ });
2622
+ return undefined;
2623
+ }
2624
+ const decision = current.config.dlp.on_dlp_hit === "block" ? "block" : "warn";
2625
+ emitEvent(
2626
+ current.emitter,
2627
+ createEvent(traceId, "message_sending", decision, ["MESSAGE_SANITIZED"]),
2628
+ api.logger,
2629
+ );
2630
+ statusStore.recordDecision({
2631
+ ts: new Date().toISOString(),
2632
+ hook: "message_sending",
2633
+ trace_id: traceId,
2634
+ decision,
2635
+ reasons: ["MESSAGE_SANITIZED"]
2636
+ });
2637
+ api.logger.warn?.(
2638
+ `securityclaw: message_sending trace_id=${traceId} decision=${decision} findings=${findingsToText(sanitized.findings)}`,
2639
+ );
2640
+ if (current.config.dlp.on_dlp_hit === "block") {
2641
+ return { cancel: true };
2642
+ }
2643
+ return { content: sanitized.value as string };
2644
+ },
2645
+ { priority: 100 },
2646
+ );
2647
+
2648
+ api.on(
2649
+ "gateway_stop",
2650
+ async () => {
2651
+ approvalStore.close();
2652
+ statusStore.close();
2653
+ liveConfig.close();
2654
+ },
2655
+ { priority: 100 },
2656
+ );
2657
+
2658
+ api.logger.info?.(`securityclaw: loaded policy_version=${runtime.config.policy_version}`);
2659
+ }
2660
+ };
2661
+
2662
+ export default plugin;