securityclaw 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/README.zh-CN.md +135 -0
  5. package/admin/public/app.js +148 -0
  6. package/admin/public/favicon.svg +21 -0
  7. package/admin/public/index.html +31 -0
  8. package/admin/public/styles.css +2715 -0
  9. package/admin/server.ts +1053 -0
  10. package/bin/install-lib.mjs +88 -0
  11. package/bin/securityclaw.mjs +66 -0
  12. package/config/policy.default.yaml +520 -0
  13. package/index.ts +2662 -0
  14. package/install.sh +22 -0
  15. package/openclaw.plugin.json +60 -0
  16. package/package.json +69 -0
  17. package/src/admin/build.ts +113 -0
  18. package/src/admin/console_notice.ts +195 -0
  19. package/src/admin/dashboard_url_state.ts +80 -0
  20. package/src/admin/openclaw_session_catalog.ts +137 -0
  21. package/src/admin/runtime_guard.ts +51 -0
  22. package/src/admin/skill_interception_store.ts +1606 -0
  23. package/src/application/commands/approval_commands.ts +189 -0
  24. package/src/approvals/chat_approval_store.ts +433 -0
  25. package/src/config/live_config.ts +144 -0
  26. package/src/config/loader.ts +168 -0
  27. package/src/config/runtime_override.ts +66 -0
  28. package/src/config/strategy_store.ts +121 -0
  29. package/src/config/validator.ts +222 -0
  30. package/src/domain/models/resource_context.ts +31 -0
  31. package/src/domain/ports/approval_repository.ts +40 -0
  32. package/src/domain/ports/notification_port.ts +29 -0
  33. package/src/domain/ports/openclaw_adapter.ts +22 -0
  34. package/src/domain/services/account_policy_engine.ts +163 -0
  35. package/src/domain/services/approval_service.ts +336 -0
  36. package/src/domain/services/approval_subject_resolver.ts +37 -0
  37. package/src/domain/services/context_inference_service.ts +502 -0
  38. package/src/domain/services/file_rule_registry.ts +171 -0
  39. package/src/domain/services/formatting_service.ts +101 -0
  40. package/src/domain/services/path_candidate_inference.ts +111 -0
  41. package/src/domain/services/sensitive_path_registry.ts +288 -0
  42. package/src/domain/services/sensitivity_label_inference.ts +161 -0
  43. package/src/domain/services/shell_filesystem_inference.ts +360 -0
  44. package/src/engine/approval_fsm.ts +104 -0
  45. package/src/engine/decision_engine.ts +39 -0
  46. package/src/engine/dlp_engine.ts +91 -0
  47. package/src/engine/rule_engine.ts +208 -0
  48. package/src/events/emitter.ts +86 -0
  49. package/src/events/schema.ts +27 -0
  50. package/src/hooks/context_guard.ts +36 -0
  51. package/src/hooks/output_guard.ts +66 -0
  52. package/src/hooks/persist_guard.ts +69 -0
  53. package/src/hooks/policy_guard.ts +222 -0
  54. package/src/hooks/result_guard.ts +88 -0
  55. package/src/i18n/locale.ts +36 -0
  56. package/src/index.ts +255 -0
  57. package/src/infrastructure/adapters/notification_adapter.ts +173 -0
  58. package/src/infrastructure/adapters/openclaw_adapter_impl.ts +59 -0
  59. package/src/infrastructure/config/plugin_config_parser.ts +105 -0
  60. package/src/monitoring/status_store.ts +612 -0
  61. package/src/types.ts +409 -0
  62. package/src/utils.ts +97 -0
@@ -0,0 +1,336 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+
3
+ import type { ApprovalRepository, StoredApprovalRecord, StoredApprovalNotification } from "../ports/approval_repository.ts";
4
+ import type { NotificationPort, NotificationTarget } from "../ports/notification_port.ts";
5
+ import type { OpenClawLogger } from "../ports/openclaw_adapter.ts";
6
+ import type { SecurityClawLocale } from "../../i18n/locale.ts";
7
+ import { localeForIntl, pickLocalized } from "../../i18n/locale.ts";
8
+
9
+ const APPROVAL_NOTIFICATION_MAX_ATTEMPTS = 3;
10
+ const APPROVAL_NOTIFICATION_RETRY_DELAYS_MS = [250, 750];
11
+ const APPROVAL_NOTIFICATION_RESEND_COOLDOWN_MS = 60_000;
12
+ const APPROVAL_NOTIFICATION_HISTORY_LIMIT = 12;
13
+ const APPROVAL_LONG_GRANT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
14
+ const APPROVAL_DISPLAY_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
15
+
16
+ export type ApprovalGrantMode = "temporary" | "longterm";
17
+
18
+ export interface ApprovalNotificationResult {
19
+ sent: boolean;
20
+ notifications: StoredApprovalNotification[];
21
+ }
22
+
23
+ export class ApprovalService {
24
+ constructor(
25
+ private repository: ApprovalRepository,
26
+ private notificationAdapters: Map<string, NotificationPort>,
27
+ private logger: OpenClawLogger,
28
+ private locale: SecurityClawLocale = "en",
29
+ ) {}
30
+
31
+ async sendNotifications(
32
+ targets: NotificationTarget[],
33
+ record: StoredApprovalRecord,
34
+ ): Promise<ApprovalNotificationResult> {
35
+ if (targets.length === 0) {
36
+ return { sent: false, notifications: [] };
37
+ }
38
+
39
+ const notifications: StoredApprovalNotification[] = [];
40
+ let sent = false;
41
+ const prompt = this.formatApprovalPrompt(record);
42
+
43
+ for (const target of targets) {
44
+ const adapter = this.notificationAdapters.get(target.channel);
45
+ if (!adapter) {
46
+ this.logger.warn?.(`securityclaw: no adapter for channel ${target.channel}`);
47
+ continue;
48
+ }
49
+
50
+ let delivered = false;
51
+ let lastError: unknown;
52
+
53
+ for (let attempt = 1; attempt <= APPROVAL_NOTIFICATION_MAX_ATTEMPTS; attempt += 1) {
54
+ try {
55
+ const options = target.channel === "telegram" ? {
56
+ buttons: [
57
+ [
58
+ {
59
+ text: this.formatApprovalButtonLabel(record, "temporary"),
60
+ callback_data: `/securityclaw-approve ${record.approval_id}`,
61
+ style: "success",
62
+ },
63
+ {
64
+ text: this.formatApprovalButtonLabel(record, "longterm"),
65
+ callback_data: `/securityclaw-approve ${record.approval_id} long`,
66
+ style: "primary",
67
+ },
68
+ ],
69
+ [
70
+ {
71
+ text: this.text("拒绝", "Reject"),
72
+ callback_data: `/securityclaw-reject ${record.approval_id}`,
73
+ style: "danger",
74
+ },
75
+ ],
76
+ ],
77
+ } : undefined;
78
+
79
+ const notification = await adapter.send(target, prompt, options);
80
+ notifications.push(notification);
81
+ sent = true;
82
+ delivered = true;
83
+
84
+ this.logger.info?.(
85
+ `securityclaw: sent approval prompt approval_id=${record.approval_id} channel=${target.channel} to=${target.to} attempt=${attempt}${notification.messageId ? ` message_id=${notification.messageId}` : ""}`,
86
+ );
87
+ break;
88
+ } catch (error) {
89
+ lastError = error;
90
+ if (attempt < APPROVAL_NOTIFICATION_MAX_ATTEMPTS) {
91
+ this.logger.warn?.(
92
+ `securityclaw: retrying approval prompt approval_id=${record.approval_id} channel=${target.channel} to=${target.to} attempt=${attempt} (${String(error)})`,
93
+ );
94
+ await sleep(APPROVAL_NOTIFICATION_RETRY_DELAYS_MS[attempt - 1] ?? APPROVAL_NOTIFICATION_RETRY_DELAYS_MS.at(-1) ?? 250);
95
+ }
96
+ }
97
+ }
98
+
99
+ if (!delivered) {
100
+ this.logger.warn?.(
101
+ `securityclaw: failed to send approval prompt approval_id=${record.approval_id} channel=${target.channel} to=${target.to} (${String(lastError)})`,
102
+ );
103
+ }
104
+ }
105
+
106
+ return { sent, notifications };
107
+ }
108
+
109
+ shouldResendPendingApproval(record: StoredApprovalRecord, nowMs = Date.now()): boolean {
110
+ if (record.notifications.length === 0) {
111
+ return true;
112
+ }
113
+ const latestSentAt = record.notifications
114
+ .map((notification) => this.parseTimestampMs(notification.sent_at))
115
+ .reduce<number | undefined>((latest, current) => {
116
+ if (current === undefined) {
117
+ return latest;
118
+ }
119
+ if (latest === undefined || current > latest) {
120
+ return current;
121
+ }
122
+ return latest;
123
+ }, undefined);
124
+ const baseline = latestSentAt ?? this.parseTimestampMs(record.requested_at);
125
+ if (baseline === undefined) {
126
+ return true;
127
+ }
128
+ return nowMs - baseline >= APPROVAL_NOTIFICATION_RESEND_COOLDOWN_MS;
129
+ }
130
+
131
+ mergeApprovalNotifications(
132
+ existing: StoredApprovalNotification[],
133
+ incoming: StoredApprovalNotification[],
134
+ ): StoredApprovalNotification[] {
135
+ if (incoming.length === 0) {
136
+ return existing;
137
+ }
138
+ return [...existing, ...incoming].slice(-APPROVAL_NOTIFICATION_HISTORY_LIMIT);
139
+ }
140
+
141
+ resolveApprovalGrantExpiry(record: StoredApprovalRecord, mode: ApprovalGrantMode): string {
142
+ if (mode === "longterm") {
143
+ return new Date(Date.now() + APPROVAL_LONG_GRANT_TTL_MS).toISOString();
144
+ }
145
+ return new Date(Date.now() + this.resolveTemporaryGrantDurationMs(record)).toISOString();
146
+ }
147
+
148
+ formatApprovalBlockReason(params: {
149
+ toolName: string;
150
+ scope: string;
151
+ traceId: string;
152
+ resourceScope: string;
153
+ reasonCodes: string[];
154
+ rules: string;
155
+ approvalId: string;
156
+ notificationSent: boolean;
157
+ }): string {
158
+ const reasons = params.reasonCodes.join(", ");
159
+ const notifyHint = params.notificationSent
160
+ ? this.text(
161
+ "已通知管理员,批准后可重试。",
162
+ "Sent to admin. Retry after approval.",
163
+ )
164
+ : this.text(
165
+ "通知失败,请将审批单交给管理员处理。",
166
+ "Admin notification failed. Share the request ID with an approver.",
167
+ );
168
+ const lines = [
169
+ this.text("SecurityClaw 需要审批", "SecurityClaw Approval Required"),
170
+ `${this.text("工具", "Tool")}: ${params.toolName}`,
171
+ `${this.text("范围", "Scope")}: ${params.scope}`,
172
+ `${this.text("资源", "Resource")}: ${this.formatResourceScopeDetail(params.resourceScope)}`,
173
+ `${this.text("原因", "Reason")}: ${reasons || this.text("策略要求复核", "Policy review required")}`,
174
+ ...(params.rules && params.rules !== "-"
175
+ ? [`${this.text("规则", "Policy")}: ${params.rules}`]
176
+ : []),
177
+ `${this.text("审批单", "Request ID")}: ${params.approvalId}`,
178
+ `${this.text("状态", "Status")}: ${notifyHint}`,
179
+ `${this.text("追踪", "Trace")}: ${params.traceId}`,
180
+ ];
181
+ return lines.join("\n");
182
+ }
183
+
184
+ formatPendingApprovals(records: StoredApprovalRecord[]): string {
185
+ if (records.length === 0) {
186
+ return this.text("当前没有待审批请求。", "No pending approval requests.");
187
+ }
188
+ return [
189
+ this.text(`待审批请求 ${records.length} 条:`, `Pending approval requests (${records.length}):`),
190
+ ...records.map((record) =>
191
+ `- ${record.approval_id} | ${record.actor_id} | ${record.scope} | ${record.tool_name} | ${this.formatTimestampForApproval(record.requested_at)}`,
192
+ ),
193
+ ].join("\n");
194
+ }
195
+
196
+ private formatApprovalPrompt(record: StoredApprovalRecord): string {
197
+ const paths = record.resource_paths.length > 0
198
+ ? this.trimText(record.resource_paths.slice(0, 3).join(" | "), 160)
199
+ : undefined;
200
+ const rules = record.rule_ids.length > 0 ? record.rule_ids.join(", ") : undefined;
201
+ const reasons = record.reason_codes.length > 0 ? record.reason_codes.join(", ") : this.text("策略要求复核", "Policy review required");
202
+ const summary = record.args_summary ? this.trimText(record.args_summary, 180) : undefined;
203
+
204
+ return [
205
+ this.text("SecurityClaw 审批请求", "SecurityClaw Approval"),
206
+ `${this.text("对象", "Subject")}: ${record.actor_id}`,
207
+ `${this.text("工具", "Tool")}: ${record.tool_name}`,
208
+ `${this.text("范围", "Scope")}: ${record.scope}`,
209
+ `${this.text("资源", "Resource")}: ${this.formatResourceScopeDetail(record.resource_scope)}`,
210
+ `${this.text("原因", "Reason")}: ${reasons}`,
211
+ `${this.text("请求截止", "Request expires")}: ${this.formatTimestampForApproval(record.expires_at)}`,
212
+ `${this.text("审批单", "Request ID")}: ${record.approval_id}`,
213
+ ...(paths ? [`${this.text("路径", "Paths")}: ${paths}`] : []),
214
+ ...(summary ? [`${this.text("参数", "Args")}: ${summary}`] : []),
215
+ ...(rules ? [`${this.text("规则", "Policy")}: ${rules}`] : []),
216
+ "",
217
+ this.text("操作", "Actions"),
218
+ `- ${this.text("批准", "Approve")} ${this.formatApprovalGrantDuration(record, "temporary")}: /securityclaw-approve ${record.approval_id}`,
219
+ `- ${this.text("批准", "Approve")} ${this.formatApprovalGrantDuration(record, "longterm")}: /securityclaw-approve ${record.approval_id} long`,
220
+ `- ${this.text("拒绝", "Reject")}: /securityclaw-reject ${record.approval_id}`,
221
+ ].join("\n");
222
+ }
223
+
224
+ private formatResourceScopeLabel(scope: string): string {
225
+ if (scope === "workspace_inside") {
226
+ return this.text("工作区内", "Inside workspace");
227
+ }
228
+ if (scope === "workspace_outside") {
229
+ return this.text("工作区外", "Outside workspace");
230
+ }
231
+ if (scope === "system") {
232
+ return this.text("系统目录", "System directory");
233
+ }
234
+ return this.text("无路径", "No path");
235
+ }
236
+
237
+ private formatApprovalGrantDuration(record: StoredApprovalRecord, mode: ApprovalGrantMode): string {
238
+ return mode === "longterm"
239
+ ? this.formatDurationMs(APPROVAL_LONG_GRANT_TTL_MS)
240
+ : this.formatDurationMs(this.resolveTemporaryGrantDurationMs(record));
241
+ }
242
+
243
+ private formatCompactApprovalGrantDuration(record: StoredApprovalRecord, mode: ApprovalGrantMode): string {
244
+ const durationMs = mode === "longterm"
245
+ ? APPROVAL_LONG_GRANT_TTL_MS
246
+ : this.resolveTemporaryGrantDurationMs(record);
247
+ const totalMinutes = Math.max(1, Math.round(durationMs / 60_000));
248
+ const totalHours = totalMinutes / 60;
249
+ const totalDays = totalHours / 24;
250
+ if (Number.isInteger(totalDays) && totalDays >= 1) {
251
+ return this.text(`${totalDays}天`, `${totalDays}d`);
252
+ }
253
+ if (Number.isInteger(totalHours) && totalHours >= 1) {
254
+ return this.text(`${totalHours}小时`, `${totalHours}h`);
255
+ }
256
+ return this.text(`${totalMinutes}分钟`, `${totalMinutes}m`);
257
+ }
258
+
259
+ private resolveTemporaryGrantDurationMs(record: StoredApprovalRecord): number {
260
+ const requestedAt = this.parseTimestampMs(record.requested_at) ?? Date.now();
261
+ const expiresAt = this.parseTimestampMs(record.expires_at) ?? (requestedAt + (15 * 60 * 1000));
262
+ return Math.max(60_000, expiresAt - requestedAt);
263
+ }
264
+
265
+ private formatDurationMs(durationMs: number): string {
266
+ const totalMinutes = Math.max(1, Math.round(durationMs / 60_000));
267
+ const totalHours = totalMinutes / 60;
268
+ const totalDays = totalHours / 24;
269
+ if (Number.isInteger(totalDays) && totalDays >= 1) {
270
+ return this.text(`${totalDays}天`, this.plural(totalDays, "day"));
271
+ }
272
+ if (Number.isInteger(totalHours) && totalHours >= 1) {
273
+ return this.text(`${totalHours}小时`, this.plural(totalHours, "hour"));
274
+ }
275
+ return this.text(`${totalMinutes}分钟`, this.plural(totalMinutes, "minute"));
276
+ }
277
+
278
+ private formatTimestampForApproval(value: string | undefined, timeZone = APPROVAL_DISPLAY_TIMEZONE): string {
279
+ const timestamp = this.parseTimestampMs(value);
280
+ if (timestamp === undefined) {
281
+ return value ?? this.text("未知", "Unknown");
282
+ }
283
+
284
+ try {
285
+ const parts = new Intl.DateTimeFormat(localeForIntl(this.locale), {
286
+ timeZone,
287
+ year: "numeric",
288
+ month: "2-digit",
289
+ day: "2-digit",
290
+ hour: "2-digit",
291
+ minute: "2-digit",
292
+ hour12: false,
293
+ }).formatToParts(new Date(timestamp));
294
+ const values = parts.reduce<Record<string, string>>((output, part) => {
295
+ if (part.type !== "literal") {
296
+ output[part.type] = part.value;
297
+ }
298
+ return output;
299
+ }, {});
300
+ return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute} (${timeZone})`;
301
+ } catch {
302
+ return `${new Date(timestamp).toISOString()} (${timeZone})`;
303
+ }
304
+ }
305
+
306
+ private formatApprovalButtonLabel(record: StoredApprovalRecord, mode: ApprovalGrantMode): string {
307
+ return `${this.text("批准", "Approve")} ${this.formatCompactApprovalGrantDuration(record, mode)}`;
308
+ }
309
+
310
+ private formatResourceScopeDetail(scope: string): string {
311
+ return `${this.formatResourceScopeLabel(scope)} (${scope})`;
312
+ }
313
+
314
+ private parseTimestampMs(value: string | undefined): number | undefined {
315
+ if (!value) {
316
+ return undefined;
317
+ }
318
+ const parsed = Date.parse(value);
319
+ return Number.isFinite(parsed) ? parsed : undefined;
320
+ }
321
+
322
+ private trimText(value: string, maxLength: number): string {
323
+ if (value.length <= maxLength) {
324
+ return value;
325
+ }
326
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
327
+ }
328
+
329
+ private text(zhText: string, enText: string): string {
330
+ return pickLocalized(this.locale, zhText, enText);
331
+ }
332
+
333
+ private plural(value: number, unit: "day" | "hour" | "minute"): string {
334
+ return `${value} ${unit}${value === 1 ? "" : "s"}`;
335
+ }
336
+ }
@@ -0,0 +1,37 @@
1
+ import path from "node:path";
2
+
3
+ export interface HookContext {
4
+ agentId?: string;
5
+ sessionKey?: string;
6
+ sessionId?: string;
7
+ runId?: string;
8
+ workspaceDir?: string;
9
+ channelId?: string;
10
+ }
11
+
12
+ export class ApprovalSubjectResolver {
13
+ static resolve(ctx: HookContext): string {
14
+ const sessionKey = ctx.sessionKey?.trim();
15
+ if (sessionKey) {
16
+ const directOrSlash = sessionKey.match(/^agent:[^:]+:([^:]+):(direct|slash):(.+)$/);
17
+ if (directOrSlash) {
18
+ return `${directOrSlash[1]}:${directOrSlash[3]}`;
19
+ }
20
+ const compactDirectOrSlash = sessionKey.match(/^([^:]+):(direct|slash):(.+)$/);
21
+ if (compactDirectOrSlash) {
22
+ return `${compactDirectOrSlash[1]}:${compactDirectOrSlash[3]}`;
23
+ }
24
+ return sessionKey;
25
+ }
26
+ if (ctx.channelId?.trim() && ctx.sessionId?.trim()) {
27
+ return `${ctx.channelId.trim()}:${ctx.sessionId.trim()}`;
28
+ }
29
+ if (ctx.sessionId?.trim()) {
30
+ return `session:${ctx.sessionId.trim()}`;
31
+ }
32
+ const actor = ctx.agentId?.trim() || "unknown-agent";
33
+ const channel = ctx.channelId?.trim() || "default-channel";
34
+ const workspace = ctx.workspaceDir ? path.normalize(ctx.workspaceDir) : "unknown-workspace";
35
+ return `fallback:${actor}:${channel}:${workspace}`;
36
+ }
37
+ }