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,208 @@
1
+ import type { DecisionContext, PolicyRule, RuleMatch } from "../types.ts";
2
+
3
+ function intersects(targets: string[] | undefined, value: string | undefined): boolean {
4
+ return !targets || targets.length === 0 || (value !== undefined && targets.includes(value));
5
+ }
6
+
7
+ function includesAny(targets: string[] | undefined, values: string[]): boolean {
8
+ return !targets || targets.length === 0 || targets.some((target) => values.includes(target));
9
+ }
10
+
11
+ function matchesPathPrefixes(prefixes: string[] | undefined, paths: string[]): boolean {
12
+ if (!prefixes || prefixes.length === 0) {
13
+ return true;
14
+ }
15
+ if (paths.length === 0) {
16
+ return false;
17
+ }
18
+ return prefixes.some((prefix) => paths.some((candidate) => candidate.startsWith(prefix)));
19
+ }
20
+
21
+ function escapeRegExp(value: string): string {
22
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+ }
24
+
25
+ function globToRegExp(pattern: string): RegExp {
26
+ let source = "";
27
+ for (let index = 0; index < pattern.length; index += 1) {
28
+ const char = pattern[index];
29
+ const next = pattern[index + 1];
30
+ if (char === "*" && next === "*") {
31
+ source += ".*";
32
+ index += 1;
33
+ continue;
34
+ }
35
+ if (char === "*") {
36
+ source += "[^/]*";
37
+ continue;
38
+ }
39
+ if (char === "?") {
40
+ source += ".";
41
+ continue;
42
+ }
43
+ source += escapeRegExp(char);
44
+ }
45
+ return new RegExp(`^${source}$`);
46
+ }
47
+
48
+ function matchesPathGlobs(globs: string[] | undefined, paths: string[]): boolean {
49
+ if (!globs || globs.length === 0) {
50
+ return true;
51
+ }
52
+ if (paths.length === 0) {
53
+ return false;
54
+ }
55
+ const regexes = globs.map((pattern) => globToRegExp(pattern));
56
+ return regexes.some((regex) => paths.some((candidate) => regex.test(candidate)));
57
+ }
58
+
59
+ function matchesRegexList(patterns: string[] | undefined, value: string | undefined): boolean {
60
+ if (!patterns || patterns.length === 0) {
61
+ return true;
62
+ }
63
+ if (!value) {
64
+ return false;
65
+ }
66
+ return patterns.some((pattern) => {
67
+ try {
68
+ return new RegExp(pattern, "i").test(value);
69
+ } catch {
70
+ return false;
71
+ }
72
+ });
73
+ }
74
+
75
+ function matchesPathRegexes(patterns: string[] | undefined, paths: string[]): boolean {
76
+ if (!patterns || patterns.length === 0) {
77
+ return true;
78
+ }
79
+ if (paths.length === 0) {
80
+ return false;
81
+ }
82
+ return patterns.some((pattern) => {
83
+ try {
84
+ const regex = new RegExp(pattern, "i");
85
+ return paths.some((candidate) => regex.test(candidate));
86
+ } catch {
87
+ return false;
88
+ }
89
+ });
90
+ }
91
+
92
+ function matchesDomains(patterns: string[] | undefined, value: string | undefined): boolean {
93
+ if (!patterns || patterns.length === 0) {
94
+ return true;
95
+ }
96
+ if (!value) {
97
+ return false;
98
+ }
99
+ const normalized = value.toLowerCase();
100
+ return patterns.some((pattern) => {
101
+ const candidate = pattern.toLowerCase();
102
+ if (candidate.startsWith("*.")) {
103
+ const suffix = candidate.slice(1);
104
+ return normalized.endsWith(suffix);
105
+ }
106
+ return normalized === candidate;
107
+ });
108
+ }
109
+
110
+ function matchesSubstrings(patterns: string[] | undefined, value: string | undefined): boolean {
111
+ if (!patterns || patterns.length === 0) {
112
+ return true;
113
+ }
114
+ if (!value) {
115
+ return false;
116
+ }
117
+ const normalized = value.toLowerCase();
118
+ return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
119
+ }
120
+
121
+ function meetsMinimum(minimum: number | undefined, actual: number | undefined): boolean {
122
+ return minimum === undefined || (actual !== undefined && actual >= minimum);
123
+ }
124
+
125
+ function precedence(rule: PolicyRule): number {
126
+ let score = 1;
127
+ const weights: Array<[unknown, number]> = [
128
+ [rule.match.identity, 4],
129
+ [rule.match.scope, 3],
130
+ [rule.match.tool, 3],
131
+ [rule.match.tool_group, 2],
132
+ [rule.match.operation, 2],
133
+ [rule.match.tags, 1],
134
+ [rule.match.resource_scope, 1],
135
+ [rule.match.path_prefix, 2],
136
+ [rule.match.path_glob, 2],
137
+ [rule.match.path_regex, 2],
138
+ [rule.match.file_type, 1],
139
+ [rule.match.asset_labels, 2],
140
+ [rule.match.data_labels, 2],
141
+ [rule.match.trust_level, 2],
142
+ [rule.match.destination_type, 2],
143
+ [rule.match.dest_domain, 2],
144
+ [rule.match.dest_ip_class, 1],
145
+ [rule.match.tool_args_summary, 1],
146
+ [rule.match.tool_args_regex, 1],
147
+ [rule.match.min_file_count, 1],
148
+ [rule.match.min_bytes, 1],
149
+ [rule.match.min_record_count, 1],
150
+ ];
151
+
152
+ for (const [value, weight] of weights) {
153
+ if (Array.isArray(value) ? value.length > 0 : value !== undefined) {
154
+ score += weight;
155
+ }
156
+ }
157
+ return score;
158
+ }
159
+
160
+ export class RuleEngine {
161
+ readonly rules: PolicyRule[];
162
+
163
+ constructor(rules: PolicyRule[]) {
164
+ this.rules = [...rules];
165
+ }
166
+
167
+ match(context: DecisionContext): RuleMatch[] {
168
+ const matches = this.rules
169
+ .filter((rule) => {
170
+ if (!rule.enabled) {
171
+ return false;
172
+ }
173
+ return (
174
+ intersects(rule.match.identity, context.actor_id) &&
175
+ intersects(rule.match.scope, context.scope) &&
176
+ intersects(rule.match.tool, context.tool_name) &&
177
+ intersects(rule.match.tool_group, context.tool_group) &&
178
+ intersects(rule.match.operation, context.operation) &&
179
+ includesAny(rule.match.tags, context.tags) &&
180
+ intersects(rule.match.resource_scope, context.resource_scope) &&
181
+ matchesPathPrefixes(rule.match.path_prefix, context.resource_paths) &&
182
+ matchesPathGlobs(rule.match.path_glob, context.resource_paths) &&
183
+ matchesPathRegexes(rule.match.path_regex, context.resource_paths) &&
184
+ intersects(rule.match.file_type, context.file_type) &&
185
+ includesAny(rule.match.asset_labels, context.asset_labels) &&
186
+ includesAny(rule.match.data_labels, context.data_labels) &&
187
+ intersects(rule.match.trust_level, context.trust_level) &&
188
+ intersects(rule.match.destination_type, context.destination_type) &&
189
+ matchesDomains(rule.match.dest_domain, context.dest_domain) &&
190
+ intersects(rule.match.dest_ip_class, context.dest_ip_class) &&
191
+ matchesSubstrings(rule.match.tool_args_summary, context.tool_args_summary) &&
192
+ matchesRegexList(rule.match.tool_args_regex, context.tool_args_summary) &&
193
+ meetsMinimum(rule.match.min_file_count, context.volume.file_count) &&
194
+ meetsMinimum(rule.match.min_bytes, context.volume.bytes) &&
195
+ meetsMinimum(rule.match.min_record_count, context.volume.record_count)
196
+ );
197
+ })
198
+ .map((rule) => ({ rule, precedence: precedence(rule) }));
199
+
200
+ matches.sort((left, right) => {
201
+ if (right.precedence !== left.precedence) {
202
+ return right.precedence - left.precedence;
203
+ }
204
+ return right.rule.priority - left.rule.priority;
205
+ });
206
+ return matches;
207
+ }
208
+ }
@@ -0,0 +1,86 @@
1
+ import type { EventSink, SecurityDecisionEvent } from "../types.ts";
2
+
3
+ export class HttpEventSink implements EventSink {
4
+ readonly url: string;
5
+ readonly timeoutMs: number;
6
+
7
+ constructor(url: string, timeoutMs: number) {
8
+ this.url = url;
9
+ this.timeoutMs = timeoutMs;
10
+ }
11
+
12
+ async send(event: SecurityDecisionEvent): Promise<void> {
13
+ const response = await fetch(this.url, {
14
+ method: "POST",
15
+ headers: { "content-type": "application/json" },
16
+ body: JSON.stringify(event),
17
+ signal: AbortSignal.timeout(this.timeoutMs)
18
+ });
19
+ if (!response.ok) {
20
+ throw new Error(`Webhook sink failed with status ${response.status}.`);
21
+ }
22
+ }
23
+ }
24
+
25
+ type QueueItem = {
26
+ event: SecurityDecisionEvent;
27
+ attempts: number;
28
+ };
29
+
30
+ export class EventEmitter {
31
+ #sink: EventSink | undefined;
32
+ #maxBuffer: number;
33
+ #retryLimit: number;
34
+ #queue: QueueItem[] = [];
35
+ #dropped = 0;
36
+
37
+ constructor(sink: EventSink | undefined, maxBuffer: number, retryLimit: number) {
38
+ this.#sink = sink;
39
+ this.#maxBuffer = maxBuffer;
40
+ this.#retryLimit = retryLimit;
41
+ }
42
+
43
+ async emitSecurityEvent(event: SecurityDecisionEvent): Promise<void> {
44
+ if (!this.#sink) {
45
+ return;
46
+ }
47
+ try {
48
+ await this.#sink.send(event);
49
+ await this.flush();
50
+ } catch {
51
+ this.enqueue(event, 1);
52
+ }
53
+ }
54
+
55
+ async flush(): Promise<void> {
56
+ if (!this.#sink) {
57
+ this.#queue = [];
58
+ return;
59
+ }
60
+ const pending = [...this.#queue];
61
+ this.#queue = [];
62
+ for (const item of pending) {
63
+ try {
64
+ await this.#sink.send(item.event);
65
+ } catch {
66
+ this.enqueue(item.event, item.attempts + 1);
67
+ }
68
+ }
69
+ }
70
+
71
+ getStats(): { queued: number; dropped: number } {
72
+ return { queued: this.#queue.length, dropped: this.#dropped };
73
+ }
74
+
75
+ enqueue(event: SecurityDecisionEvent, attempts: number): void {
76
+ if (attempts > this.#retryLimit) {
77
+ this.#dropped += 1;
78
+ return;
79
+ }
80
+ if (this.#queue.length >= this.#maxBuffer) {
81
+ this.#queue.shift();
82
+ this.#dropped += 1;
83
+ }
84
+ this.#queue.push({ event, attempts });
85
+ }
86
+ }
@@ -0,0 +1,27 @@
1
+ export const securityDecisionEventSchema = {
2
+ $schema: "https://json-schema.org/draft/2020-12/schema",
3
+ title: "SecurityDecisionEvent",
4
+ type: "object",
5
+ required: [
6
+ "schema_version",
7
+ "event_type",
8
+ "trace_id",
9
+ "hook",
10
+ "decision",
11
+ "reason_codes",
12
+ "latency_ms",
13
+ "ts"
14
+ ],
15
+ properties: {
16
+ schema_version: { type: "string" },
17
+ event_type: { const: "SecurityDecisionEvent" },
18
+ trace_id: { type: "string" },
19
+ hook: { type: "string" },
20
+ decision: { type: "string" },
21
+ decision_source: { type: "string" },
22
+ resource_scope: { type: "string" },
23
+ reason_codes: { type: "array", items: { type: "string" } },
24
+ latency_ms: { type: "number" },
25
+ ts: { type: "string", format: "date-time" }
26
+ }
27
+ } as const;
@@ -0,0 +1,36 @@
1
+ import type { BeforePromptBuildInput, GuardComputation, SecurityContext } from "../types.ts";
2
+
3
+ export function runContextGuard(
4
+ input: BeforePromptBuildInput,
5
+ policyVersion: string,
6
+ traceId: string,
7
+ nowIso: string,
8
+ ): GuardComputation<BeforePromptBuildInput> {
9
+ const untrusted = input.source === "external";
10
+ const tags = [...(input.tags ?? [])];
11
+ if (untrusted && !tags.includes("untrusted")) {
12
+ tags.push("untrusted");
13
+ }
14
+ const securityContext: SecurityContext = {
15
+ trace_id: traceId,
16
+ actor_id: input.actor_id,
17
+ workspace: input.workspace,
18
+ policy_version: policyVersion,
19
+ untrusted,
20
+ tags,
21
+ created_at: nowIso
22
+ };
23
+ return {
24
+ mutated_payload: {
25
+ ...input,
26
+ tags,
27
+ trace_id: traceId,
28
+ prompt: input.prompt,
29
+ security_context: securityContext
30
+ } as BeforePromptBuildInput,
31
+ decision: "allow",
32
+ reason_codes: [untrusted ? "CONTENT_MARKED_UNTRUSTED" : "CONTEXT_INJECTED"],
33
+ sanitization_actions: [],
34
+ security_context: securityContext
35
+ };
36
+ }
@@ -0,0 +1,66 @@
1
+ import type { GuardComputation, MessageSendingInput, SanitizationAction, SecurityContext } from "../types.ts";
2
+ import type { DlpEngine } from "../engine/dlp_engine.ts";
3
+
4
+ function buildSecurityContext(input: MessageSendingInput, traceId: string, policyVersion: string, nowIso: string): SecurityContext {
5
+ return {
6
+ trace_id: input.security_context?.trace_id ?? traceId,
7
+ actor_id: input.actor_id,
8
+ workspace: input.workspace,
9
+ policy_version: input.security_context?.policy_version ?? policyVersion,
10
+ untrusted: input.security_context?.untrusted ?? false,
11
+ tags: [...(input.security_context?.tags ?? [])],
12
+ created_at: input.security_context?.created_at ?? nowIso
13
+ };
14
+ }
15
+
16
+ function redactRestrictedTerms(message: unknown, restrictedTerms: string[]): { output: unknown; actions: SanitizationAction[] } {
17
+ if (typeof message !== "string" || restrictedTerms.length === 0) {
18
+ return { output: message, actions: [] };
19
+ }
20
+ let output = message;
21
+ const actions: SanitizationAction[] = [];
22
+ for (const term of restrictedTerms) {
23
+ if (output.includes(term)) {
24
+ output = output.split(term).join("[REDACTED]");
25
+ actions.push({
26
+ path: "root",
27
+ action: "mask",
28
+ detail: `restricted_term:${term}`
29
+ });
30
+ }
31
+ }
32
+ return { output, actions };
33
+ }
34
+
35
+ export function runOutputGuard(
36
+ input: MessageSendingInput,
37
+ traceId: string,
38
+ policyVersion: string,
39
+ nowIso: string,
40
+ dlpEngine: DlpEngine,
41
+ ): GuardComputation<MessageSendingInput> {
42
+ const securityContext = buildSecurityContext(input, traceId, policyVersion, nowIso);
43
+ const restricted = redactRestrictedTerms(input.message, input.restricted_terms ?? []);
44
+ const findings = dlpEngine.scan(restricted.output);
45
+ const sanitized = findings.length > 0 ? dlpEngine.sanitize(restricted.output, findings, "sanitize") : restricted.output;
46
+ const dlpActions: SanitizationAction[] = findings.map((finding) => ({
47
+ path: finding.path,
48
+ action: finding.action,
49
+ detail: `${finding.pattern_name}:${finding.type}`
50
+ }));
51
+
52
+ return {
53
+ mutated_payload: {
54
+ ...input,
55
+ message: sanitized,
56
+ security_context: securityContext
57
+ } as MessageSendingInput,
58
+ decision: findings.length > 0 || restricted.actions.length > 0 ? "warn" : "allow",
59
+ reason_codes:
60
+ findings.length > 0 || restricted.actions.length > 0
61
+ ? ["MESSAGE_SANITIZED"]
62
+ : ["MESSAGE_OK"],
63
+ sanitization_actions: [...restricted.actions, ...dlpActions],
64
+ security_context: securityContext
65
+ };
66
+ }
@@ -0,0 +1,69 @@
1
+ import type { GuardComputation, SanitizationAction, SecurityContext, ToolResultPersistInput } from "../types.ts";
2
+ import type { DlpEngine } from "../engine/dlp_engine.ts";
3
+
4
+ function buildSecurityContext(
5
+ input: ToolResultPersistInput,
6
+ traceId: string,
7
+ policyVersion: string,
8
+ nowIso: string,
9
+ ): SecurityContext {
10
+ return {
11
+ trace_id: input.security_context?.trace_id ?? traceId,
12
+ actor_id: input.actor_id,
13
+ workspace: input.workspace,
14
+ policy_version: input.security_context?.policy_version ?? policyVersion,
15
+ untrusted: input.security_context?.untrusted ?? false,
16
+ tags: [...(input.security_context?.tags ?? [])],
17
+ created_at: input.security_context?.created_at ?? nowIso
18
+ };
19
+ }
20
+
21
+ export function runPersistGuard(
22
+ input: ToolResultPersistInput,
23
+ traceId: string,
24
+ policyVersion: string,
25
+ nowIso: string,
26
+ dlpEngine: DlpEngine,
27
+ defaultMode: "strict" | "compat",
28
+ ): GuardComputation<ToolResultPersistInput> {
29
+ const securityContext = buildSecurityContext(input, traceId, policyVersion, nowIso);
30
+ const findings = dlpEngine.scan(input.result);
31
+ const mode = input.mode ?? defaultMode;
32
+ const sanitizationActions: SanitizationAction[] = findings.map((finding) => ({
33
+ path: finding.path,
34
+ action: finding.action,
35
+ detail: `${finding.pattern_name}:${finding.type}`
36
+ }));
37
+
38
+ if (findings.length === 0) {
39
+ return {
40
+ mutated_payload: { ...input, security_context: securityContext } as ToolResultPersistInput,
41
+ decision: "allow",
42
+ reason_codes: ["PERSIST_OK"],
43
+ sanitization_actions: [],
44
+ security_context: securityContext
45
+ };
46
+ }
47
+
48
+ if (mode === "strict") {
49
+ return {
50
+ mutated_payload: { ...input, security_context: securityContext } as ToolResultPersistInput,
51
+ decision: "block",
52
+ reason_codes: ["PERSIST_BLOCKED_DLP"],
53
+ sanitization_actions: sanitizationActions,
54
+ security_context: securityContext
55
+ };
56
+ }
57
+
58
+ return {
59
+ mutated_payload: {
60
+ ...input,
61
+ result: dlpEngine.sanitize(input.result, findings, "sanitize"),
62
+ security_context: securityContext
63
+ } as ToolResultPersistInput,
64
+ decision: "warn",
65
+ reason_codes: ["PERSIST_REDACTED"],
66
+ sanitization_actions: sanitizationActions,
67
+ security_context: securityContext
68
+ };
69
+ }