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,502 @@
1
+ import os from "node:os";
2
+ import { isIP } from "node:net";
3
+ import path from "node:path";
4
+
5
+ import type { ResourceScope, SensitivePathRule } from "../../types.ts";
6
+ import type {
7
+ ResourceContext,
8
+ ToolContext,
9
+ DestinationContext,
10
+ LabelContext,
11
+ VolumeContext
12
+ } from "../models/resource_context.ts";
13
+ import {
14
+ extractEmbeddedPathCandidates,
15
+ hasEmbeddedPathHint,
16
+ isPathLikeCandidate,
17
+ resolvePathCandidate,
18
+ } from "./path_candidate_inference.ts";
19
+ import { inferShellFilesystemSemantic } from "./shell_filesystem_inference.ts";
20
+ import { inferSensitivityLabels } from "./sensitivity_label_inference.ts";
21
+
22
+ const HOME_DIR = os.homedir();
23
+ const PATH_KEY_PATTERN = /(path|paths|file|files|dir|cwd|target|output|input|source|destination|dest|root)/i;
24
+ const COMMAND_KEY_PATTERN = /(command|cmd|script|query|sql)/i;
25
+ const URL_KEY_PATTERN = /(url|uri|endpoint|host|domain|upload|webhook|callback|proxy|origin|destination|dest)/i;
26
+ const SYSTEM_PATH_PREFIXES = ["/etc", "/usr", "/bin", "/sbin", "/var", "/private/etc", "/System", "/Library"];
27
+ const DEFAULT_MESSAGES_DB_PATH = path.join(HOME_DIR, "Library/Messages/chat.db");
28
+ const MESSAGE_DB_PATH_PATTERN = /(?:~\/Library\/Messages\/chat\.db|\/Users\/[^/\s"'`;]+\/Library\/Messages\/chat\.db)/i;
29
+ const PERSONAL_STORAGE_DOMAINS = [
30
+ "dropbox.com",
31
+ "drive.google.com",
32
+ "docs.google.com",
33
+ "onedrive.live.com",
34
+ "1drv.ms",
35
+ "notion.so",
36
+ "notion.site",
37
+ ];
38
+ const PASTE_SERVICE_DOMAINS = [
39
+ "pastebin.com",
40
+ "gist.github.com",
41
+ "gist.githubusercontent.com",
42
+ "hastebin.com",
43
+ "transfer.sh",
44
+ ];
45
+
46
+ export class ContextInferenceService {
47
+ inferResourceContext(args: unknown, workspaceDir?: string): ResourceContext {
48
+ const candidates = this.collectPathCandidates(args);
49
+ const resolved = Array.from(
50
+ new Set(
51
+ candidates
52
+ .map((candidate) => this.resolvePathCandidate(candidate, workspaceDir))
53
+ .filter((value): value is string => Boolean(value)),
54
+ ),
55
+ ).slice(0, 12);
56
+ return this.classifyResolvedResourcePaths(resolved, workspaceDir);
57
+ }
58
+
59
+ inferToolContext(
60
+ normalizedToolName: string | undefined,
61
+ args: unknown,
62
+ resourceScope: ResourceScope,
63
+ resourcePaths: string[],
64
+ workspaceDir?: string,
65
+ ): ToolContext {
66
+ let nextResourcePaths = [...resourcePaths];
67
+ let nextResourceScope = resourceScope;
68
+ let toolGroup = normalizedToolName ? this.inferToolGroup(normalizedToolName) : undefined;
69
+ let operation = normalizedToolName ? this.inferOperation(normalizedToolName) : undefined;
70
+ const tags: string[] = [];
71
+
72
+ if (normalizedToolName === "shell.exec") {
73
+ const commandText = this.extractShellCommandText(args);
74
+ if (this.isMessagesShellAccess(commandText, nextResourcePaths)) {
75
+ toolGroup = "sms";
76
+ operation = this.inferMessagesOperation(commandText);
77
+ if (!nextResourcePaths.some((candidate) => this.isMessagesDbPath(candidate))) {
78
+ nextResourcePaths = [...nextResourcePaths, DEFAULT_MESSAGES_DB_PATH];
79
+ }
80
+ const classified = this.classifyResolvedResourcePaths(nextResourcePaths, workspaceDir);
81
+ nextResourcePaths = classified.resourcePaths;
82
+ nextResourceScope = classified.resourceScope;
83
+ tags.push("messages_shell_access");
84
+ } else {
85
+ const shellSemantic = inferShellFilesystemSemantic(commandText, nextResourcePaths);
86
+ if (shellSemantic) {
87
+ toolGroup = "filesystem";
88
+ operation = shellSemantic.operation;
89
+ tags.push("shell_filesystem_access", `shell_filesystem_operation:${shellSemantic.operation}`);
90
+ }
91
+ }
92
+ }
93
+
94
+ return {
95
+ ...(toolGroup !== undefined ? { toolGroup } : {}),
96
+ ...(operation !== undefined ? { operation } : {}),
97
+ resourceScope: nextResourceScope,
98
+ resourcePaths: nextResourcePaths,
99
+ tags,
100
+ };
101
+ }
102
+
103
+ inferDestinationContext(args: unknown): DestinationContext {
104
+ const urls = this.collectUrlCandidates(args);
105
+ return this.classifyDestination(urls);
106
+ }
107
+
108
+ inferLabels(
109
+ toolGroup: string | undefined,
110
+ resourcePaths: string[],
111
+ toolArgsSummary: string | undefined,
112
+ sensitivePathRules?: SensitivePathRule[],
113
+ ): LabelContext {
114
+ const inferred = inferSensitivityLabels(toolGroup, resourcePaths, toolArgsSummary, sensitivePathRules);
115
+ return {
116
+ assetLabels: inferred.assetLabels,
117
+ dataLabels: inferred.dataLabels,
118
+ };
119
+ }
120
+
121
+ inferVolume(args: unknown, resourcePaths: string[]): VolumeContext {
122
+ const metrics: VolumeContext = {};
123
+ if (resourcePaths.length > 0) {
124
+ metrics.fileCount = resourcePaths.length;
125
+ }
126
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
127
+ return metrics;
128
+ }
129
+
130
+ const record = args as Record<string, unknown>;
131
+ for (const [key, value] of Object.entries(record)) {
132
+ const lower = key.toLowerCase();
133
+ if (Array.isArray(value)) {
134
+ if (/(files|paths|attachments|items|results|records|messages)/.test(lower)) {
135
+ if ((metrics.fileCount ?? 0) < value.length) {
136
+ metrics.fileCount = value.length;
137
+ }
138
+ if (/(results|records|messages)/.test(lower) && (metrics.recordCount ?? 0) < value.length) {
139
+ metrics.recordCount = value.length;
140
+ }
141
+ }
142
+ continue;
143
+ }
144
+ if (typeof value !== "number" || !Number.isFinite(value)) {
145
+ continue;
146
+ }
147
+ if (/(bytes|size|length)/.test(lower)) {
148
+ metrics.bytes = value;
149
+ }
150
+ if (/(count|limit|total|records)/.test(lower)) {
151
+ metrics.recordCount = value;
152
+ }
153
+ }
154
+
155
+ return metrics;
156
+ }
157
+
158
+ inferFileType(resourcePaths: string[]): string | undefined {
159
+ for (const candidate of resourcePaths) {
160
+ const basename = path.basename(candidate);
161
+ if (basename === "Dockerfile") {
162
+ return "dockerfile";
163
+ }
164
+ const extension = path.extname(basename).toLowerCase().replace(/^\./, "");
165
+ if (extension) {
166
+ return extension;
167
+ }
168
+ }
169
+ return undefined;
170
+ }
171
+
172
+ private collectPathCandidates(value: unknown, keyHint = "", depth = 0, output: string[] = []): string[] {
173
+ if (depth > 4 || output.length >= 24) {
174
+ return output;
175
+ }
176
+
177
+ if (typeof value === "string") {
178
+ const trimmed = value.trim();
179
+ if (trimmed && this.isPathLike(trimmed, keyHint)) {
180
+ output.push(trimmed);
181
+ } else if (trimmed && (COMMAND_KEY_PATTERN.test(keyHint) || hasEmbeddedPathHint(trimmed))) {
182
+ for (const candidate of extractEmbeddedPathCandidates(trimmed)) {
183
+ output.push(candidate);
184
+ if (output.length >= 24) {
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ return output;
190
+ }
191
+
192
+ if (Array.isArray(value)) {
193
+ for (const item of value) {
194
+ this.collectPathCandidates(item, keyHint, depth + 1, output);
195
+ if (output.length >= 24) {
196
+ break;
197
+ }
198
+ }
199
+ return output;
200
+ }
201
+
202
+ if (!value || typeof value !== "object") {
203
+ return output;
204
+ }
205
+
206
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
207
+ this.collectPathCandidates(item, key, depth + 1, output);
208
+ if (output.length >= 24) {
209
+ break;
210
+ }
211
+ }
212
+ return output;
213
+ }
214
+
215
+ private isPathLike(value: string, keyHint: string): boolean {
216
+ if (PATH_KEY_PATTERN.test(keyHint)) {
217
+ return true;
218
+ }
219
+ return isPathLikeCandidate(value);
220
+ }
221
+
222
+ private resolvePathCandidate(candidate: string, workspaceDir?: string): string | undefined {
223
+ return resolvePathCandidate(candidate, workspaceDir);
224
+ }
225
+
226
+ private classifyResolvedResourcePaths(
227
+ resolved: string[],
228
+ workspaceDir?: string,
229
+ ): ResourceContext {
230
+ if (resolved.length === 0) {
231
+ return { resourceScope: "none", resourcePaths: [] };
232
+ }
233
+
234
+ let hasInside = false;
235
+ let hasOutside = false;
236
+ let hasSystem = false;
237
+ const normalizedWorkspace = workspaceDir ? path.normalize(workspaceDir) : undefined;
238
+
239
+ for (const candidate of resolved) {
240
+ if (this.isSystemPath(candidate)) {
241
+ hasSystem = true;
242
+ }
243
+ if (normalizedWorkspace && this.isPathInside(normalizedWorkspace, candidate)) {
244
+ hasInside = true;
245
+ } else {
246
+ hasOutside = true;
247
+ }
248
+ }
249
+
250
+ if (hasSystem) {
251
+ return { resourceScope: "system", resourcePaths: resolved };
252
+ }
253
+ if (hasOutside) {
254
+ return { resourceScope: "workspace_outside", resourcePaths: resolved };
255
+ }
256
+ if (hasInside) {
257
+ return { resourceScope: "workspace_inside", resourcePaths: resolved };
258
+ }
259
+ return { resourceScope: "none", resourcePaths: resolved };
260
+ }
261
+
262
+ private isPathInside(rootDir: string, candidate: string): boolean {
263
+ const relative = path.relative(rootDir, candidate);
264
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
265
+ }
266
+
267
+ private isSystemPath(candidate: string): boolean {
268
+ return SYSTEM_PATH_PREFIXES.some((prefix) => candidate === prefix || candidate.startsWith(`${prefix}/`));
269
+ }
270
+
271
+ private collectUrlCandidates(value: unknown, keyHint = "", depth = 0, output: string[] = []): string[] {
272
+ if (depth > 4 || output.length >= 12) {
273
+ return output;
274
+ }
275
+
276
+ if (typeof value === "string") {
277
+ const trimmed = value.trim();
278
+ if (trimmed && this.isUrlLike(trimmed, keyHint)) {
279
+ output.push(trimmed);
280
+ }
281
+ return output;
282
+ }
283
+
284
+ if (Array.isArray(value)) {
285
+ for (const item of value) {
286
+ this.collectUrlCandidates(item, keyHint, depth + 1, output);
287
+ if (output.length >= 12) {
288
+ break;
289
+ }
290
+ }
291
+ return output;
292
+ }
293
+
294
+ if (!value || typeof value !== "object") {
295
+ return output;
296
+ }
297
+
298
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
299
+ this.collectUrlCandidates(item, key, depth + 1, output);
300
+ if (output.length >= 12) {
301
+ break;
302
+ }
303
+ }
304
+ return output;
305
+ }
306
+
307
+ private isUrlLike(value: string, keyHint: string): boolean {
308
+ return URL_KEY_PATTERN.test(keyHint) || value.startsWith("http://") || value.startsWith("https://");
309
+ }
310
+
311
+ private classifyDestination(urls: string[]): DestinationContext {
312
+ for (const candidate of urls) {
313
+ try {
314
+ const parsed = new URL(candidate);
315
+ const host = parsed.hostname.toLowerCase();
316
+ const ipVersion = isIP(host);
317
+ const isInternalHost =
318
+ host === "localhost" ||
319
+ host.endsWith(".internal") ||
320
+ host.endsWith(".corp") ||
321
+ host.endsWith(".local") ||
322
+ host.endsWith(".lan") ||
323
+ this.isPrivateIp(host);
324
+
325
+ const destinationType =
326
+ PERSONAL_STORAGE_DOMAINS.some((domain) => host === domain || host.endsWith(`.${domain}`))
327
+ ? "personal_storage"
328
+ : PASTE_SERVICE_DOMAINS.some((domain) => host === domain || host.endsWith(`.${domain}`))
329
+ ? "paste_service"
330
+ : isInternalHost
331
+ ? "internal"
332
+ : "public";
333
+
334
+ const destIpClass =
335
+ ipVersion === 0
336
+ ? destinationType === "internal"
337
+ ? "private"
338
+ : "unknown"
339
+ : this.isLoopbackIp(host)
340
+ ? "loopback"
341
+ : this.isPrivateIp(host)
342
+ ? "private"
343
+ : "public";
344
+
345
+ return {
346
+ destinationType,
347
+ destDomain: host,
348
+ destIpClass,
349
+ };
350
+ } catch {
351
+ continue;
352
+ }
353
+ }
354
+
355
+ return {};
356
+ }
357
+
358
+ private isPrivateIp(host: string): boolean {
359
+ if (isIP(host) !== 4) {
360
+ return false;
361
+ }
362
+ const octets = host.split(".").map((value) => Number(value));
363
+ return (
364
+ octets[0] === 10 ||
365
+ octets[0] === 127 ||
366
+ (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
367
+ (octets[0] === 192 && octets[1] === 168)
368
+ );
369
+ }
370
+
371
+ private isLoopbackIp(host: string): boolean {
372
+ if (isIP(host) === 4) {
373
+ return host.startsWith("127.");
374
+ }
375
+ return host === "::1";
376
+ }
377
+
378
+ private inferToolGroup(toolName: string): string | undefined {
379
+ const normalized = toolName.trim().toLowerCase();
380
+ if (normalized.startsWith("shell.")) {
381
+ return "execution";
382
+ }
383
+ if (normalized.startsWith("filesystem.")) {
384
+ return "filesystem";
385
+ }
386
+ if (normalized.startsWith("network.") || normalized.startsWith("http.")) {
387
+ return "network";
388
+ }
389
+ if (normalized.startsWith("email.") || normalized.startsWith("mail.")) {
390
+ return "email";
391
+ }
392
+ if (
393
+ normalized.startsWith("sms.") ||
394
+ normalized.startsWith("message.") ||
395
+ normalized.startsWith("messages.")
396
+ ) {
397
+ return "sms";
398
+ }
399
+ if (normalized.startsWith("album.") || normalized.startsWith("photo.") || normalized.startsWith("media.")) {
400
+ return "album";
401
+ }
402
+ if (normalized.startsWith("browser.")) {
403
+ return "browser";
404
+ }
405
+ if (
406
+ normalized.startsWith("archive.") ||
407
+ normalized.startsWith("compress.") ||
408
+ normalized.includes(".archive") ||
409
+ normalized.includes(".compress") ||
410
+ normalized.includes(".zip")
411
+ ) {
412
+ return "archive";
413
+ }
414
+ if (
415
+ normalized.startsWith("crm.") ||
416
+ normalized.startsWith("erp.") ||
417
+ normalized.startsWith("hr.") ||
418
+ normalized.startsWith("finance.") ||
419
+ normalized.startsWith("jira.") ||
420
+ normalized.startsWith("servicenow.") ||
421
+ normalized.startsWith("zendesk.")
422
+ ) {
423
+ return "business";
424
+ }
425
+ return undefined;
426
+ }
427
+
428
+ private inferOperation(toolName: string): string | undefined {
429
+ const normalized = toolName.trim().toLowerCase();
430
+ if (normalized.startsWith("network.") || normalized.startsWith("http.")) {
431
+ return "request";
432
+ }
433
+ if (/(exec|run|spawn)$/.test(normalized) || normalized.endsWith(".exec")) {
434
+ return "execute";
435
+ }
436
+ if (/(delete|remove|unlink|destroy)$/.test(normalized) || normalized.endsWith(".rm")) {
437
+ return "delete";
438
+ }
439
+ if (/(write|save|create|update|append|put)$/.test(normalized)) {
440
+ return "write";
441
+ }
442
+ if (/(list|ls|enumerate)$/.test(normalized)) {
443
+ return "list";
444
+ }
445
+ if (/(search|query|find)$/.test(normalized)) {
446
+ return "search";
447
+ }
448
+ if (/(read|get|open|cat|fetch|download)$/.test(normalized)) {
449
+ return "read";
450
+ }
451
+ if (/(upload|send|post|reply)$/.test(normalized)) {
452
+ return "upload";
453
+ }
454
+ if (/(export|dump)$/.test(normalized)) {
455
+ return "export";
456
+ }
457
+ if (/(archive|compress|zip|tar|bundle)$/.test(normalized)) {
458
+ return "archive";
459
+ }
460
+ if (/(deploy|apply|terraform|kubectl)$/.test(normalized)) {
461
+ return "modify";
462
+ }
463
+ return undefined;
464
+ }
465
+
466
+ private extractShellCommandText(args: unknown): string | undefined {
467
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
468
+ return undefined;
469
+ }
470
+ const record = args as Record<string, unknown>;
471
+ for (const key of ["command", "cmd", "script"]) {
472
+ const value = record[key];
473
+ if (typeof value === "string" && value.trim()) {
474
+ return value.trim();
475
+ }
476
+ }
477
+ return undefined;
478
+ }
479
+
480
+ private isMessagesDbPath(candidate: string): boolean {
481
+ return /\/Library\/Messages\/chat\.db$/i.test(candidate);
482
+ }
483
+
484
+ private isMessagesShellAccess(commandText: string | undefined, resourcePaths: string[]): boolean {
485
+ if (resourcePaths.some((candidate) => this.isMessagesDbPath(candidate))) {
486
+ return true;
487
+ }
488
+ const corpus = [commandText ?? "", ...resourcePaths].join(" ");
489
+ return /\bimsg\b/i.test(corpus) || (/\bsqlite3\b/i.test(corpus) && MESSAGE_DB_PATH_PATTERN.test(corpus));
490
+ }
491
+
492
+ private inferMessagesOperation(commandText: string | undefined): string {
493
+ const normalized = (commandText ?? "").toLowerCase();
494
+ if (/\b(export|dump)\b/.test(normalized)) {
495
+ return "export";
496
+ }
497
+ if (/\b(search|find|query)\b/.test(normalized)) {
498
+ return "search";
499
+ }
500
+ return "read";
501
+ }
502
+ }
@@ -0,0 +1,171 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ import type { Decision, FileRule } from "../../types.ts";
5
+
6
+ const VALID_DECISIONS = new Set<Decision>(["allow", "warn", "challenge", "block"]);
7
+
8
+ function trimmedString(value: unknown): string | undefined {
9
+ if (typeof value !== "string") {
10
+ return undefined;
11
+ }
12
+ const trimmed = value.trim();
13
+ return trimmed || undefined;
14
+ }
15
+
16
+ function normalizeDirectoryPath(value: string): string | undefined {
17
+ let normalized = value.trim();
18
+ if (!normalized) {
19
+ return undefined;
20
+ }
21
+ if (normalized === "~") {
22
+ normalized = os.homedir();
23
+ } else if (normalized.startsWith("~/")) {
24
+ normalized = path.join(os.homedir(), normalized.slice(2));
25
+ }
26
+ if (!path.isAbsolute(normalized)) {
27
+ return undefined;
28
+ }
29
+
30
+ const normalizedPath = path.normalize(normalized);
31
+ const root = path.parse(normalizedPath).root;
32
+ if (normalizedPath === root) {
33
+ return normalizedPath;
34
+ }
35
+ return normalizedPath.replace(/[\\/]+$/, "");
36
+ }
37
+
38
+ function normalizedPathForCompare(value: string): string {
39
+ const normalized = path.normalize(value);
40
+ if (process.platform === "win32" || process.platform === "darwin") {
41
+ return normalized.toLowerCase();
42
+ }
43
+ return normalized;
44
+ }
45
+
46
+ function isPathInsideDirectory(rootDir: string, candidate: string): boolean {
47
+ const relative = path.relative(rootDir, candidate);
48
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
49
+ }
50
+
51
+ function normalizeDecision(value: unknown): Decision | undefined {
52
+ if (typeof value !== "string") {
53
+ return undefined;
54
+ }
55
+ return VALID_DECISIONS.has(value as Decision) ? (value as Decision) : undefined;
56
+ }
57
+
58
+ function normalizeReasonCodes(value: unknown): string[] | undefined {
59
+ if (!Array.isArray(value)) {
60
+ return undefined;
61
+ }
62
+ const normalized = value
63
+ .map((entry) => trimmedString(entry))
64
+ .filter((entry): entry is string => Boolean(entry));
65
+ return normalized.length > 0 ? normalized : undefined;
66
+ }
67
+
68
+ function sortRules(rules: FileRule[]): FileRule[] {
69
+ return [...rules].sort((left, right) => {
70
+ const byDirectory = normalizedPathForCompare(left.directory).localeCompare(normalizedPathForCompare(right.directory));
71
+ if (byDirectory !== 0) {
72
+ return byDirectory;
73
+ }
74
+ return left.id.localeCompare(right.id);
75
+ });
76
+ }
77
+
78
+ export function defaultFileRuleReasonCode(decision: Decision): string {
79
+ if (decision === "allow") {
80
+ return "USER_FILE_RULE_ALLOW";
81
+ }
82
+ if (decision === "warn") {
83
+ return "USER_FILE_RULE_WARN";
84
+ }
85
+ if (decision === "challenge") {
86
+ return "USER_FILE_RULE_CHALLENGE";
87
+ }
88
+ return "USER_FILE_RULE_BLOCK";
89
+ }
90
+
91
+ export function normalizeFileRule(value: unknown): FileRule | undefined {
92
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
93
+ return undefined;
94
+ }
95
+ const record = value as Record<string, unknown>;
96
+ const id = trimmedString(record.id);
97
+ const directory = trimmedString(record.directory);
98
+ const decision = normalizeDecision(record.decision);
99
+ const reasonCodes = normalizeReasonCodes(record.reason_codes);
100
+ const updatedAt = trimmedString(record.updated_at);
101
+ const normalizedDirectory = directory ? normalizeDirectoryPath(directory) : undefined;
102
+ if (!id || !normalizedDirectory || !decision) {
103
+ return undefined;
104
+ }
105
+
106
+ return {
107
+ id,
108
+ directory: normalizedDirectory,
109
+ decision,
110
+ ...(reasonCodes ? { reason_codes: reasonCodes } : {}),
111
+ ...(updatedAt ? { updated_at: updatedAt } : {}),
112
+ };
113
+ }
114
+
115
+ export function normalizeFileRules(value: unknown): FileRule[] {
116
+ if (!Array.isArray(value)) {
117
+ return [];
118
+ }
119
+ const dedupedByDirectory = new Map<string, FileRule>();
120
+ value.forEach((entry) => {
121
+ const normalized = normalizeFileRule(entry);
122
+ if (!normalized) {
123
+ return;
124
+ }
125
+ dedupedByDirectory.set(normalizedPathForCompare(normalized.directory), normalized);
126
+ });
127
+ return sortRules(Array.from(dedupedByDirectory.values()));
128
+ }
129
+
130
+ export function matchFileRule(resourcePaths: string[], rules: FileRule[]): FileRule | undefined {
131
+ if (!rules.length || !resourcePaths.length) {
132
+ return undefined;
133
+ }
134
+
135
+ const normalizedPaths = resourcePaths
136
+ .map((entry) => normalizeDirectoryPath(entry))
137
+ .filter((entry): entry is string => Boolean(entry));
138
+ if (!normalizedPaths.length) {
139
+ return undefined;
140
+ }
141
+
142
+ const matches: FileRule[] = [];
143
+ rules.forEach((rule) => {
144
+ const normalizedDirectory = normalizeDirectoryPath(rule.directory);
145
+ if (!normalizedDirectory) {
146
+ return;
147
+ }
148
+ const matched = normalizedPaths.some((candidate) => isPathInsideDirectory(normalizedDirectory, candidate));
149
+ if (matched) {
150
+ matches.push({ ...rule, directory: normalizedDirectory });
151
+ }
152
+ });
153
+
154
+ if (!matches.length) {
155
+ return undefined;
156
+ }
157
+
158
+ matches.sort((left, right) => {
159
+ const leftDepth = left.directory.split(path.sep).length;
160
+ const rightDepth = right.directory.split(path.sep).length;
161
+ if (rightDepth !== leftDepth) {
162
+ return rightDepth - leftDepth;
163
+ }
164
+ const byDirectory = normalizedPathForCompare(left.directory).localeCompare(normalizedPathForCompare(right.directory));
165
+ if (byDirectory !== 0) {
166
+ return byDirectory;
167
+ }
168
+ return left.id.localeCompare(right.id);
169
+ });
170
+ return matches[0];
171
+ }