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,360 @@
1
+ export type FilesystemOperation = "list" | "read" | "search" | "write" | "delete" | "archive";
2
+
3
+ export type ShellFilesystemSemantic = {
4
+ operation: FilesystemOperation;
5
+ toolName: `filesystem.${FilesystemOperation}`;
6
+ };
7
+
8
+ const SEGMENT_SPLITTER = /&&|\|\||;|\||\n/;
9
+ const TOKEN_PATTERN = /"[^"]*"|'[^']*'|`[^`]*`|[^\s]+/g;
10
+ const ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
11
+ const WRITE_REDIRECTION_PATTERN = /(^|[^<])(?:>>?|1>>?|2>>?)\s*(?![&|])/;
12
+
13
+ const WRAPPER_COMMANDS = new Set([
14
+ "sudo",
15
+ "doas",
16
+ "command",
17
+ "builtin",
18
+ "env",
19
+ "nohup",
20
+ "time",
21
+ "stdbuf",
22
+ "nice",
23
+ "ionice",
24
+ ]);
25
+
26
+ const LIST_COMMANDS = new Set([
27
+ "ls",
28
+ "dir",
29
+ "tree",
30
+ "fd",
31
+ "fdfind",
32
+ "du",
33
+ ]);
34
+
35
+ const READ_COMMANDS = new Set([
36
+ "cat",
37
+ "head",
38
+ "tail",
39
+ "less",
40
+ "more",
41
+ "bat",
42
+ "stat",
43
+ "file",
44
+ "readlink",
45
+ "realpath",
46
+ "wc",
47
+ "strings",
48
+ ]);
49
+
50
+ const SEARCH_COMMANDS = new Set([
51
+ "grep",
52
+ "egrep",
53
+ "fgrep",
54
+ "rg",
55
+ "ripgrep",
56
+ "ack",
57
+ "ag",
58
+ "awk",
59
+ "jq",
60
+ ]);
61
+
62
+ const WRITE_COMMANDS = new Set([
63
+ "touch",
64
+ "cp",
65
+ "mv",
66
+ "mkdir",
67
+ "mktemp",
68
+ "install",
69
+ "ln",
70
+ "chmod",
71
+ "chown",
72
+ "chgrp",
73
+ "truncate",
74
+ "tee",
75
+ "dd",
76
+ "rsync",
77
+ "scp",
78
+ ]);
79
+
80
+ const DELETE_COMMANDS = new Set([
81
+ "rm",
82
+ "unlink",
83
+ "rmdir",
84
+ "shred",
85
+ ]);
86
+
87
+ const ARCHIVE_COMMANDS = new Set([
88
+ "zip",
89
+ "tar",
90
+ "gzip",
91
+ "gunzip",
92
+ "bzip2",
93
+ "bunzip2",
94
+ "xz",
95
+ "unxz",
96
+ "7z",
97
+ "7za",
98
+ "zstd",
99
+ "unzstd",
100
+ ]);
101
+
102
+ const FILE_METADATA_COMMANDS = new Set([
103
+ "test",
104
+ "[",
105
+ "basename",
106
+ "dirname",
107
+ ]);
108
+
109
+ const OPERATION_PRIORITY: Record<FilesystemOperation, number> = {
110
+ list: 1,
111
+ read: 2,
112
+ search: 3,
113
+ archive: 4,
114
+ write: 5,
115
+ delete: 6,
116
+ };
117
+
118
+ function toTokens(segment: string): string[] {
119
+ return segment.match(TOKEN_PATTERN) ?? [];
120
+ }
121
+
122
+ function normalizeToken(token: string): string {
123
+ const unquoted = token.replace(/^["'`]+|["'`]+$/g, "");
124
+ const basename = unquoted.includes("/") ? (unquoted.split("/").at(-1) ?? unquoted) : unquoted;
125
+ return basename.toLowerCase();
126
+ }
127
+
128
+ function resolvePrimaryCommand(tokens: string[]): { command?: string; remaining: string[] } {
129
+ let index = 0;
130
+ while (index < tokens.length) {
131
+ const raw = tokens[index];
132
+ const normalized = normalizeToken(raw);
133
+ if (!normalized || normalized === "(" || normalized === ")") {
134
+ index += 1;
135
+ continue;
136
+ }
137
+ if (ENV_ASSIGNMENT_PATTERN.test(normalized)) {
138
+ index += 1;
139
+ continue;
140
+ }
141
+ if (WRAPPER_COMMANDS.has(normalized)) {
142
+ index += 1;
143
+ while (index < tokens.length && (tokens[index].startsWith("-") || ENV_ASSIGNMENT_PATTERN.test(tokens[index]))) {
144
+ index += 1;
145
+ }
146
+ continue;
147
+ }
148
+ return {
149
+ command: normalized,
150
+ remaining: tokens.slice(index + 1),
151
+ };
152
+ }
153
+ return { remaining: [] };
154
+ }
155
+
156
+ function classifyGitOperation(remaining: string[]): FilesystemOperation | undefined {
157
+ let subcommand: string | undefined;
158
+ let skipNext = false;
159
+
160
+ for (const token of remaining) {
161
+ if (skipNext) {
162
+ skipNext = false;
163
+ continue;
164
+ }
165
+ const normalized = normalizeToken(token);
166
+ if (!normalized) {
167
+ continue;
168
+ }
169
+ if (normalized === "-c" || normalized === "--git-dir" || normalized === "--work-tree") {
170
+ skipNext = true;
171
+ continue;
172
+ }
173
+ if (normalized === "-c" || normalized.startsWith("-c")) {
174
+ continue;
175
+ }
176
+ if (normalized === "-C" || normalized === "--super-prefix" || normalized === "--exec-path") {
177
+ skipNext = true;
178
+ continue;
179
+ }
180
+ if (normalized.startsWith("-")) {
181
+ continue;
182
+ }
183
+ subcommand = normalized;
184
+ break;
185
+ }
186
+
187
+ if (!subcommand) {
188
+ return undefined;
189
+ }
190
+ if (subcommand === "grep") {
191
+ return "search";
192
+ }
193
+ if (["status", "log", "show", "diff", "cat-file", "blame"].includes(subcommand)) {
194
+ return "read";
195
+ }
196
+ if (["ls-files"].includes(subcommand)) {
197
+ return "list";
198
+ }
199
+ if (["archive"].includes(subcommand)) {
200
+ return "archive";
201
+ }
202
+ if (["rm", "clean"].includes(subcommand)) {
203
+ return "delete";
204
+ }
205
+ if (
206
+ [
207
+ "add",
208
+ "mv",
209
+ "commit",
210
+ "checkout",
211
+ "restore",
212
+ "reset",
213
+ "revert",
214
+ "cherry-pick",
215
+ "merge",
216
+ "rebase",
217
+ "am",
218
+ "apply",
219
+ "stash",
220
+ "pull",
221
+ "clone",
222
+ ].includes(subcommand)
223
+ ) {
224
+ return "write";
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ function classifyFindOperation(segment: string): FilesystemOperation {
230
+ const normalized = segment.toLowerCase();
231
+ if (/\s-delete(\s|$)/.test(normalized) || /-exec\s+[^\n;]*\brm\b/.test(normalized)) {
232
+ return "delete";
233
+ }
234
+ if (/-exec\s+[^\n;]*\b(cp|mv|chmod|chown|touch|mkdir)\b/.test(normalized)) {
235
+ return "write";
236
+ }
237
+ if (/\s-(name|iname|path|ipath|regex|iregex)\b/.test(normalized)) {
238
+ return "search";
239
+ }
240
+ return "list";
241
+ }
242
+
243
+ function classifyTarOperation(segment: string): FilesystemOperation {
244
+ const normalized = segment.toLowerCase();
245
+ if (/(^|\s)--extract(\s|$)|(^|\s)-[^\s]*x/.test(normalized)) {
246
+ return "write";
247
+ }
248
+ if (/(^|\s)--list(\s|$)|(^|\s)-[^\s]*t/.test(normalized)) {
249
+ return "list";
250
+ }
251
+ return "archive";
252
+ }
253
+
254
+ function classifyArchiveCommand(command: string, segment: string): FilesystemOperation {
255
+ if (command === "tar") {
256
+ return classifyTarOperation(segment);
257
+ }
258
+ if (["gunzip", "bunzip2", "unxz", "unzstd"].includes(command)) {
259
+ return "write";
260
+ }
261
+ return "archive";
262
+ }
263
+
264
+ function classifySegment(segment: string): FilesystemOperation | undefined {
265
+ const tokens = toTokens(segment);
266
+ if (tokens.length === 0) {
267
+ return undefined;
268
+ }
269
+
270
+ const { command, remaining } = resolvePrimaryCommand(tokens);
271
+ if (!command) {
272
+ return undefined;
273
+ }
274
+
275
+ const normalizedSegment = segment.toLowerCase();
276
+
277
+ if (command === "git") {
278
+ return classifyGitOperation(remaining);
279
+ }
280
+ if (command === "find") {
281
+ return classifyFindOperation(segment);
282
+ }
283
+ if (ARCHIVE_COMMANDS.has(command)) {
284
+ return classifyArchiveCommand(command, segment);
285
+ }
286
+ if (DELETE_COMMANDS.has(command)) {
287
+ return "delete";
288
+ }
289
+ if (WRITE_COMMANDS.has(command)) {
290
+ return "write";
291
+ }
292
+ if (SEARCH_COMMANDS.has(command)) {
293
+ return "search";
294
+ }
295
+ if (READ_COMMANDS.has(command)) {
296
+ return "read";
297
+ }
298
+ if (LIST_COMMANDS.has(command)) {
299
+ return "list";
300
+ }
301
+ if (command === "sed" || command === "perl") {
302
+ if (/\s-i(?:\s|$)/.test(normalizedSegment)) {
303
+ return "write";
304
+ }
305
+ return "search";
306
+ }
307
+ if (command === "unzip") {
308
+ return "write";
309
+ }
310
+ if ((command === "echo" || command === "printf") && WRITE_REDIRECTION_PATTERN.test(segment)) {
311
+ return "write";
312
+ }
313
+ if (FILE_METADATA_COMMANDS.has(command)) {
314
+ return "read";
315
+ }
316
+ if (WRITE_REDIRECTION_PATTERN.test(segment)) {
317
+ return "write";
318
+ }
319
+ return undefined;
320
+ }
321
+
322
+ function pickHigherPriority(
323
+ current: FilesystemOperation | undefined,
324
+ candidate: FilesystemOperation | undefined,
325
+ ): FilesystemOperation | undefined {
326
+ if (!candidate) {
327
+ return current;
328
+ }
329
+ if (!current) {
330
+ return candidate;
331
+ }
332
+ return OPERATION_PRIORITY[candidate] > OPERATION_PRIORITY[current] ? candidate : current;
333
+ }
334
+
335
+ export function inferShellFilesystemSemantic(
336
+ commandText: string | undefined,
337
+ resourcePaths: string[],
338
+ ): ShellFilesystemSemantic | undefined {
339
+ const segments = (commandText ?? "")
340
+ .split(SEGMENT_SPLITTER)
341
+ .map((item) => item.trim())
342
+ .filter(Boolean);
343
+
344
+ let operation: FilesystemOperation | undefined;
345
+ for (const segment of segments) {
346
+ operation = pickHigherPriority(operation, classifySegment(segment));
347
+ }
348
+
349
+ if (!operation && resourcePaths.length > 0) {
350
+ operation = "read";
351
+ }
352
+ if (!operation) {
353
+ return undefined;
354
+ }
355
+
356
+ return {
357
+ operation,
358
+ toolName: `filesystem.${operation}`,
359
+ };
360
+ }
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type {
4
+ ApprovalRecord,
5
+ ApprovalRequestOptions,
6
+ ApprovalResolutionMetadata,
7
+ ApprovalService,
8
+ DecisionContext,
9
+ } from "../types.ts";
10
+ import { nowIso } from "../utils.ts";
11
+
12
+ export class ApprovalFsm implements ApprovalService {
13
+ #records = new Map<string, ApprovalRecord>();
14
+ #now: () => number;
15
+
16
+ constructor(now: () => number) {
17
+ this.#now = now;
18
+ }
19
+
20
+ requestApproval(
21
+ context: DecisionContext,
22
+ options: ApprovalRequestOptions = {},
23
+ ): ApprovalRecord {
24
+ const requestedAt = this.#now();
25
+ const requestContext: ApprovalRecord["request_context"] = {
26
+ trace_id: context.security_context.trace_id,
27
+ actor_id: context.actor_id,
28
+ scope: context.scope,
29
+ resource_scope: context.resource_scope,
30
+ resource_paths: [...context.resource_paths],
31
+ reason_codes: [...(options.reason_codes ?? [])],
32
+ rule_ids: [...(options.rule_ids ?? [])],
33
+ };
34
+ if (context.tool_name !== undefined) {
35
+ requestContext.tool_name = context.tool_name;
36
+ }
37
+ if (context.tool_group !== undefined) {
38
+ requestContext.tool_group = context.tool_group;
39
+ }
40
+ const ttlSeconds = options.ttl_seconds ?? 900;
41
+ const record: ApprovalRecord = {
42
+ approval_id: randomUUID(),
43
+ status: "pending",
44
+ requested_at: new Date(requestedAt).toISOString(),
45
+ expires_at: new Date(requestedAt + ttlSeconds * 1000).toISOString(),
46
+ request_context: requestContext,
47
+ ...(options.approval_requirements ? { approval_requirements: options.approval_requirements } : {}),
48
+ };
49
+ this.#records.set(record.approval_id, record);
50
+ return record;
51
+ }
52
+
53
+ resolveApproval(
54
+ approvalId: string,
55
+ approver: string,
56
+ decision: "approved" | "rejected",
57
+ metadata?: ApprovalResolutionMetadata,
58
+ ): ApprovalRecord | undefined {
59
+ const record = this.getApprovalStatus(approvalId);
60
+ if (!record || record.status !== "pending") {
61
+ return record;
62
+ }
63
+ const updated: ApprovalRecord = {
64
+ ...record,
65
+ status: decision,
66
+ decision,
67
+ approver,
68
+ ...(metadata?.approver_role ? { approver_role: metadata.approver_role } : {}),
69
+ ...(metadata?.ticket_id ? { ticket_id: metadata.ticket_id } : {}),
70
+ decided_at: nowIso(this.#now)
71
+ };
72
+ this.#records.set(approvalId, updated);
73
+ return updated;
74
+ }
75
+
76
+ markApprovalUsed(approvalId: string): ApprovalRecord | undefined {
77
+ const record = this.getApprovalStatus(approvalId);
78
+ if (!record || record.status !== "approved") {
79
+ return record;
80
+ }
81
+ const updated: ApprovalRecord = {
82
+ ...record,
83
+ used_at: nowIso(this.#now),
84
+ };
85
+ this.#records.set(approvalId, updated);
86
+ return updated;
87
+ }
88
+
89
+ getApprovalStatus(approvalId: string): ApprovalRecord | undefined {
90
+ const record = this.#records.get(approvalId);
91
+ if (!record) {
92
+ return undefined;
93
+ }
94
+ if (record.status === "pending" && this.#now() > new Date(record.expires_at).getTime()) {
95
+ const expired: ApprovalRecord = {
96
+ ...record,
97
+ status: "expired"
98
+ };
99
+ this.#records.set(approvalId, expired);
100
+ return expired;
101
+ }
102
+ return record;
103
+ }
104
+ }
@@ -0,0 +1,39 @@
1
+ import type { DecisionContext, DecisionOutcome, RuleMatch, SecurityClawConfig } from "../types.ts";
2
+
3
+ export class DecisionEngine {
4
+ readonly config: SecurityClawConfig;
5
+
6
+ constructor(config: SecurityClawConfig) {
7
+ this.config = config;
8
+ }
9
+
10
+ evaluate(_context: DecisionContext, matches: RuleMatch[]): DecisionOutcome {
11
+ const decisiveRule = matches.find((match) => Boolean(match.rule.decision));
12
+
13
+ if (decisiveRule?.rule.decision) {
14
+ const outcome: DecisionOutcome = {
15
+ decision: decisiveRule.rule.decision,
16
+ decision_source: "rule",
17
+ reason_codes: decisiveRule.rule.reason_codes,
18
+ matched_rules: matches.map((match) => match.rule)
19
+ };
20
+ if (decisiveRule.rule.decision === "challenge") {
21
+ outcome.challenge_ttl_seconds =
22
+ decisiveRule.rule.approval_requirements?.ttl_seconds ??
23
+ decisiveRule.rule.challenge?.ttl_seconds ??
24
+ this.config.defaults.approval_ttl_seconds;
25
+ if (decisiveRule.rule.approval_requirements) {
26
+ outcome.approval_requirements = decisiveRule.rule.approval_requirements;
27
+ }
28
+ }
29
+ return outcome;
30
+ }
31
+
32
+ return {
33
+ decision: "allow",
34
+ decision_source: "default",
35
+ reason_codes: ["NO_MATCH_DEFAULT_ALLOW"],
36
+ matched_rules: matches.map((match) => match.rule)
37
+ };
38
+ }
39
+ }
@@ -0,0 +1,91 @@
1
+ import type { DlpConfig, DlpFinding } from "../types.ts";
2
+ import { deepClone } from "../utils.ts";
3
+
4
+ function visit(value: unknown, path: string, onString: (text: string, path: string) => void): void {
5
+ if (typeof value === "string") {
6
+ onString(value, path);
7
+ return;
8
+ }
9
+ if (Array.isArray(value)) {
10
+ value.forEach((entry, index) => visit(entry, `${path}[${index}]`, onString));
11
+ return;
12
+ }
13
+ if (value && typeof value === "object") {
14
+ for (const [key, child] of Object.entries(value)) {
15
+ visit(child, `${path}.${key}`, onString);
16
+ }
17
+ }
18
+ }
19
+
20
+ export class DlpEngine {
21
+ readonly config: DlpConfig;
22
+
23
+ constructor(config: DlpConfig) {
24
+ this.config = config;
25
+ }
26
+
27
+ scan(content: unknown): DlpFinding[] {
28
+ const findings: DlpFinding[] = [];
29
+ visit(content, "root", (text, path) => {
30
+ for (const pattern of this.config.patterns) {
31
+ const flags = pattern.flags?.includes("g") ? pattern.flags : `${pattern.flags ?? ""}g`;
32
+ const regex = new RegExp(pattern.regex, flags);
33
+ const matches = text.matchAll(regex);
34
+ for (const match of matches) {
35
+ findings.push({
36
+ pattern_name: pattern.name,
37
+ type: pattern.type,
38
+ action: pattern.action,
39
+ path,
40
+ match: match[0]
41
+ });
42
+ }
43
+ }
44
+ });
45
+ return findings;
46
+ }
47
+
48
+ sanitize<T>(content: T, findings: DlpFinding[], mode = this.config.on_dlp_hit): T {
49
+ if (mode === "warn") {
50
+ return deepClone(content);
51
+ }
52
+ const cloned = deepClone(content) as unknown;
53
+ return this.#sanitizeNode(cloned, "root", findings) as T;
54
+ }
55
+
56
+ #sanitizeNode(node: unknown, path: string, findings: DlpFinding[]): unknown {
57
+ const nodeFindings = findings.filter((finding) => finding.path === path);
58
+ if (typeof node === "string") {
59
+ if (nodeFindings.length === 0) {
60
+ return node;
61
+ }
62
+ let next = node;
63
+ for (const finding of nodeFindings) {
64
+ if (finding.action === "remove") {
65
+ return undefined;
66
+ }
67
+ next = next.split(finding.match).join("[REDACTED]");
68
+ }
69
+ return next;
70
+ }
71
+
72
+ if (Array.isArray(node)) {
73
+ return node
74
+ .map((entry, index) => this.#sanitizeNode(entry, `${path}[${index}]`, findings))
75
+ .filter((entry) => entry !== undefined);
76
+ }
77
+
78
+ if (node && typeof node === "object") {
79
+ const copy: Record<string, unknown> = {};
80
+ for (const [key, value] of Object.entries(node)) {
81
+ const next = this.#sanitizeNode(value, `${path}.${key}`, findings);
82
+ if (next !== undefined) {
83
+ copy[key] = next;
84
+ }
85
+ }
86
+ return copy;
87
+ }
88
+
89
+ return node;
90
+ }
91
+ }