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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/README.zh-CN.md +135 -0
- package/admin/public/app.js +148 -0
- package/admin/public/favicon.svg +21 -0
- package/admin/public/index.html +31 -0
- package/admin/public/styles.css +2715 -0
- package/admin/server.ts +1053 -0
- package/bin/install-lib.mjs +88 -0
- package/bin/securityclaw.mjs +66 -0
- package/config/policy.default.yaml +520 -0
- package/index.ts +2662 -0
- package/install.sh +22 -0
- package/openclaw.plugin.json +60 -0
- package/package.json +69 -0
- package/src/admin/build.ts +113 -0
- package/src/admin/console_notice.ts +195 -0
- package/src/admin/dashboard_url_state.ts +80 -0
- package/src/admin/openclaw_session_catalog.ts +137 -0
- package/src/admin/runtime_guard.ts +51 -0
- package/src/admin/skill_interception_store.ts +1606 -0
- package/src/application/commands/approval_commands.ts +189 -0
- package/src/approvals/chat_approval_store.ts +433 -0
- package/src/config/live_config.ts +144 -0
- package/src/config/loader.ts +168 -0
- package/src/config/runtime_override.ts +66 -0
- package/src/config/strategy_store.ts +121 -0
- package/src/config/validator.ts +222 -0
- package/src/domain/models/resource_context.ts +31 -0
- package/src/domain/ports/approval_repository.ts +40 -0
- package/src/domain/ports/notification_port.ts +29 -0
- package/src/domain/ports/openclaw_adapter.ts +22 -0
- package/src/domain/services/account_policy_engine.ts +163 -0
- package/src/domain/services/approval_service.ts +336 -0
- package/src/domain/services/approval_subject_resolver.ts +37 -0
- package/src/domain/services/context_inference_service.ts +502 -0
- package/src/domain/services/file_rule_registry.ts +171 -0
- package/src/domain/services/formatting_service.ts +101 -0
- package/src/domain/services/path_candidate_inference.ts +111 -0
- package/src/domain/services/sensitive_path_registry.ts +288 -0
- package/src/domain/services/sensitivity_label_inference.ts +161 -0
- package/src/domain/services/shell_filesystem_inference.ts +360 -0
- package/src/engine/approval_fsm.ts +104 -0
- package/src/engine/decision_engine.ts +39 -0
- package/src/engine/dlp_engine.ts +91 -0
- package/src/engine/rule_engine.ts +208 -0
- package/src/events/emitter.ts +86 -0
- package/src/events/schema.ts +27 -0
- package/src/hooks/context_guard.ts +36 -0
- package/src/hooks/output_guard.ts +66 -0
- package/src/hooks/persist_guard.ts +69 -0
- package/src/hooks/policy_guard.ts +222 -0
- package/src/hooks/result_guard.ts +88 -0
- package/src/i18n/locale.ts +36 -0
- package/src/index.ts +255 -0
- package/src/infrastructure/adapters/notification_adapter.ts +173 -0
- package/src/infrastructure/adapters/openclaw_adapter_impl.ts +59 -0
- package/src/infrastructure/config/plugin_config_parser.ts +105 -0
- package/src/monitoring/status_store.ts +612 -0
- package/src/types.ts +409 -0
- package/src/utils.ts +97 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import type { SecurityClawConfig } from "../types.ts";
|
|
4
|
+
import { ConfigManager } from "./loader.ts";
|
|
5
|
+
import { applyRuntimeOverride, type RuntimeOverride } from "./runtime_override.ts";
|
|
6
|
+
import { StrategyStore } from "./strategy_store.ts";
|
|
7
|
+
|
|
8
|
+
type LiveConfigLogger = {
|
|
9
|
+
info?: (message: string) => void;
|
|
10
|
+
warn?: (message: string) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type LiveConfigOptions = {
|
|
14
|
+
configPath: string;
|
|
15
|
+
dbPath: string;
|
|
16
|
+
legacyOverridePath?: string;
|
|
17
|
+
logger?: LiveConfigLogger;
|
|
18
|
+
transform?: (config: SecurityClawConfig) => SecurityClawConfig;
|
|
19
|
+
onReload?: (snapshot: LiveConfigSnapshot) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type LiveConfigSnapshot = {
|
|
23
|
+
config: SecurityClawConfig;
|
|
24
|
+
override?: RuntimeOverride;
|
|
25
|
+
overrideLoaded: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function safeMtimeMs(filePath: string): number | undefined {
|
|
29
|
+
try {
|
|
30
|
+
if (!existsSync(filePath)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return statSync(filePath).mtimeMs;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function overrideSignature(override: RuntimeOverride | undefined): string {
|
|
40
|
+
return override ? JSON.stringify(override) : "none";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class LiveConfigResolver {
|
|
44
|
+
#configPath: string;
|
|
45
|
+
#configManager: ConfigManager;
|
|
46
|
+
#strategyStore: StrategyStore;
|
|
47
|
+
#logger: LiveConfigLogger | undefined;
|
|
48
|
+
#transform: ((config: SecurityClawConfig) => SecurityClawConfig) | undefined;
|
|
49
|
+
#onReload: ((snapshot: LiveConfigSnapshot) => void) | undefined;
|
|
50
|
+
#configMtimeMs: number | undefined;
|
|
51
|
+
#overrideSig = "uninitialized";
|
|
52
|
+
#snapshot: LiveConfigSnapshot;
|
|
53
|
+
|
|
54
|
+
constructor(options: LiveConfigOptions) {
|
|
55
|
+
this.#configPath = options.configPath;
|
|
56
|
+
this.#logger = options.logger;
|
|
57
|
+
this.#transform = options.transform;
|
|
58
|
+
this.#onReload = options.onReload;
|
|
59
|
+
this.#configManager = ConfigManager.fromFile(options.configPath);
|
|
60
|
+
const initialConfig = this.#configManager.getConfig();
|
|
61
|
+
this.#snapshot = {
|
|
62
|
+
config: this.#transform ? this.#transform(initialConfig) : initialConfig,
|
|
63
|
+
overrideLoaded: false
|
|
64
|
+
};
|
|
65
|
+
this.#strategyStore = new StrategyStore(options.dbPath, {
|
|
66
|
+
...(options.legacyOverridePath !== undefined ? { legacyOverridePath: options.legacyOverridePath } : {}),
|
|
67
|
+
...(options.logger !== undefined ? { logger: options.logger } : {})
|
|
68
|
+
});
|
|
69
|
+
this.#configMtimeMs = safeMtimeMs(this.#configPath);
|
|
70
|
+
this.#snapshot = this.#buildSnapshot(true);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getSnapshot(): LiveConfigSnapshot {
|
|
74
|
+
const nextMtimeMs = safeMtimeMs(this.#configPath);
|
|
75
|
+
const baseChanged = nextMtimeMs !== this.#configMtimeMs;
|
|
76
|
+
if (baseChanged) {
|
|
77
|
+
this.#configManager.reload();
|
|
78
|
+
this.#configMtimeMs = nextMtimeMs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let override: RuntimeOverride | undefined;
|
|
82
|
+
try {
|
|
83
|
+
override = this.#strategyStore.readOverride();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
this.#logger?.warn?.(`securityclaw: failed to read runtime strategy override (${String(error)})`);
|
|
86
|
+
return this.#snapshot;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const nextOverrideSig = overrideSignature(override);
|
|
90
|
+
if (!baseChanged && nextOverrideSig === this.#overrideSig) {
|
|
91
|
+
return this.#snapshot;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return this.#buildSnapshot(false, override, nextOverrideSig);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
close(): void {
|
|
98
|
+
this.#strategyStore.close();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#buildSnapshot(
|
|
102
|
+
isInitialLoad: boolean,
|
|
103
|
+
override?: RuntimeOverride,
|
|
104
|
+
signature?: string,
|
|
105
|
+
): LiveConfigSnapshot {
|
|
106
|
+
const base = this.#configManager.getConfig();
|
|
107
|
+
let effectiveOverride = override;
|
|
108
|
+
|
|
109
|
+
if (effectiveOverride === undefined) {
|
|
110
|
+
try {
|
|
111
|
+
effectiveOverride = this.#strategyStore.readOverride();
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this.#logger?.warn?.(`securityclaw: failed to read runtime strategy override (${String(error)})`);
|
|
114
|
+
return this.#snapshot;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const effectiveSignature = signature ?? overrideSignature(effectiveOverride);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const effective = effectiveOverride ? applyRuntimeOverride(base, effectiveOverride) : base;
|
|
121
|
+
const config = this.#transform ? this.#transform(effective) : effective;
|
|
122
|
+
this.#overrideSig = effectiveSignature;
|
|
123
|
+
const snapshot: LiveConfigSnapshot = {
|
|
124
|
+
config,
|
|
125
|
+
overrideLoaded: Boolean(effectiveOverride)
|
|
126
|
+
};
|
|
127
|
+
if (effectiveOverride !== undefined) {
|
|
128
|
+
snapshot.override = effectiveOverride;
|
|
129
|
+
}
|
|
130
|
+
this.#snapshot = snapshot;
|
|
131
|
+
const action = isInitialLoad ? "loaded" : "reloaded";
|
|
132
|
+
this.#logger?.info?.(
|
|
133
|
+
`securityclaw: ${action} policy_version=${config.policy_version} rules=${config.policies.length} strategy_loaded=${Boolean(effectiveOverride)}`,
|
|
134
|
+
);
|
|
135
|
+
if (!isInitialLoad) {
|
|
136
|
+
this.#onReload?.(this.#snapshot);
|
|
137
|
+
}
|
|
138
|
+
return this.#snapshot;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
this.#logger?.warn?.(`securityclaw: failed to apply runtime strategy (${String(error)})`);
|
|
141
|
+
return this.#snapshot;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { SecurityClawConfig } from "../types.ts";
|
|
5
|
+
import { hydrateSensitivePathConfig } from "../domain/services/sensitive_path_registry.ts";
|
|
6
|
+
import { deepClone, deepFreeze, parseScalar, stripInlineComment } from "../utils.ts";
|
|
7
|
+
import { validateConfig } from "./validator.ts";
|
|
8
|
+
|
|
9
|
+
type Frame = {
|
|
10
|
+
indent: number;
|
|
11
|
+
container: Record<string, unknown> | unknown[];
|
|
12
|
+
parent?: Record<string, unknown> | unknown[];
|
|
13
|
+
key?: string | number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function isArrayFrame(frame: Frame): frame is Frame & { container: unknown[] } {
|
|
17
|
+
return Array.isArray(frame.container);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function attachChild(frame: Frame, child: Record<string, unknown> | unknown[]): void {
|
|
21
|
+
if (frame.parent === undefined || frame.key === undefined) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(frame.parent)) {
|
|
25
|
+
frame.parent[Number(frame.key)] = child;
|
|
26
|
+
} else {
|
|
27
|
+
frame.parent[String(frame.key)] = child;
|
|
28
|
+
}
|
|
29
|
+
frame.container = child;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseYaml(source: string): Record<string, unknown> {
|
|
33
|
+
const root: Record<string, unknown> = {};
|
|
34
|
+
const frames: Frame[] = [{ indent: -1, container: root }];
|
|
35
|
+
|
|
36
|
+
const lines = source.split(/\r?\n/);
|
|
37
|
+
for (const originalLine of lines) {
|
|
38
|
+
const withoutComment = stripInlineComment(originalLine);
|
|
39
|
+
if (!withoutComment.trim()) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const indent = withoutComment.match(/^ */)?.[0].length ?? 0;
|
|
43
|
+
const text = withoutComment.trim();
|
|
44
|
+
while (frames.length > 1 && indent <= frames[frames.length - 1].indent) {
|
|
45
|
+
frames.pop();
|
|
46
|
+
}
|
|
47
|
+
const current = frames[frames.length - 1];
|
|
48
|
+
|
|
49
|
+
if (text.startsWith("- ")) {
|
|
50
|
+
if (!isArrayFrame(current)) {
|
|
51
|
+
const replacement: unknown[] = [];
|
|
52
|
+
attachChild(current, replacement);
|
|
53
|
+
}
|
|
54
|
+
const arrayFrame = frames[frames.length - 1] as Frame & { container: unknown[] };
|
|
55
|
+
const itemText = text.slice(2).trim();
|
|
56
|
+
if (itemText === "") {
|
|
57
|
+
const child: Record<string, unknown> = {};
|
|
58
|
+
arrayFrame.container.push(child);
|
|
59
|
+
frames.push({
|
|
60
|
+
indent,
|
|
61
|
+
container: child,
|
|
62
|
+
parent: arrayFrame.container,
|
|
63
|
+
key: arrayFrame.container.length - 1
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (itemText.includes(":")) {
|
|
68
|
+
const colonIndex = itemText.indexOf(":");
|
|
69
|
+
const key = itemText.slice(0, colonIndex).trim();
|
|
70
|
+
const valueText = itemText.slice(colonIndex + 1).trim();
|
|
71
|
+
const child: Record<string, unknown> = {};
|
|
72
|
+
child[key] = valueText === "" ? {} : parseScalar(valueText);
|
|
73
|
+
arrayFrame.container.push(child);
|
|
74
|
+
frames.push({
|
|
75
|
+
indent,
|
|
76
|
+
container: child,
|
|
77
|
+
parent: arrayFrame.container,
|
|
78
|
+
key: arrayFrame.container.length - 1
|
|
79
|
+
});
|
|
80
|
+
if (valueText === "") {
|
|
81
|
+
frames.push({
|
|
82
|
+
indent: indent + 1,
|
|
83
|
+
container: child[key] as Record<string, unknown>,
|
|
84
|
+
parent: child,
|
|
85
|
+
key
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
arrayFrame.container.push(parseScalar(itemText));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const colonIndex = text.indexOf(":");
|
|
95
|
+
if (colonIndex === -1) {
|
|
96
|
+
throw new Error(`Invalid YAML line: ${text}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const key = text.slice(0, colonIndex).trim();
|
|
100
|
+
const valueText = text.slice(colonIndex + 1).trim();
|
|
101
|
+
if (Array.isArray(current.container)) {
|
|
102
|
+
throw new Error(`Unexpected mapping under array without item context: ${text}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (valueText === "") {
|
|
106
|
+
current.container[key] = {};
|
|
107
|
+
frames.push({
|
|
108
|
+
indent,
|
|
109
|
+
container: current.container[key] as Record<string, unknown>,
|
|
110
|
+
parent: current.container,
|
|
111
|
+
key
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
current.container[key] = parseScalar(valueText);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return root;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class ConfigManager {
|
|
122
|
+
#config: SecurityClawConfig;
|
|
123
|
+
#lastKnownGood: SecurityClawConfig;
|
|
124
|
+
#path: string | undefined;
|
|
125
|
+
|
|
126
|
+
constructor(config: SecurityClawConfig, path?: string) {
|
|
127
|
+
const frozen = deepFreeze(deepClone(config));
|
|
128
|
+
this.#config = frozen;
|
|
129
|
+
this.#lastKnownGood = frozen;
|
|
130
|
+
this.#path = path;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static fromFile(path: string): ConfigManager {
|
|
134
|
+
const resolved = resolve(path);
|
|
135
|
+
const source = readFileSync(resolved, "utf8");
|
|
136
|
+
const raw = parseYaml(source);
|
|
137
|
+
const config = hydrateConfig(validateConfig(raw));
|
|
138
|
+
return new ConfigManager(config, resolved);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getConfig(): SecurityClawConfig {
|
|
142
|
+
return this.#config;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
getLastKnownGood(): SecurityClawConfig {
|
|
146
|
+
return this.#lastKnownGood;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
reload(nextSource?: string): SecurityClawConfig {
|
|
150
|
+
try {
|
|
151
|
+
const raw = nextSource ? parseYaml(nextSource) : parseYaml(readFileSync(this.#path!, "utf8"));
|
|
152
|
+
const validated = deepFreeze(deepClone(hydrateConfig(validateConfig(raw))));
|
|
153
|
+
this.#config = validated;
|
|
154
|
+
this.#lastKnownGood = validated;
|
|
155
|
+
return this.#config;
|
|
156
|
+
} catch {
|
|
157
|
+
this.#config = this.#lastKnownGood;
|
|
158
|
+
return this.#config;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function hydrateConfig(config: SecurityClawConfig): SecurityClawConfig {
|
|
164
|
+
return {
|
|
165
|
+
...config,
|
|
166
|
+
sensitivity: hydrateSensitivePathConfig(config.sensitivity)
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applySensitivePathStrategyOverride,
|
|
5
|
+
hydrateSensitivePathConfig,
|
|
6
|
+
normalizeSensitivePathStrategyOverride,
|
|
7
|
+
} from "../domain/services/sensitive_path_registry.ts";
|
|
8
|
+
import { normalizeFileRules } from "../domain/services/file_rule_registry.ts";
|
|
9
|
+
import type {
|
|
10
|
+
AccountPolicyRecord,
|
|
11
|
+
DlpConfig,
|
|
12
|
+
FileRule,
|
|
13
|
+
PolicyRule,
|
|
14
|
+
SecurityClawConfig,
|
|
15
|
+
SensitivePathStrategyOverride,
|
|
16
|
+
} from "../types.ts";
|
|
17
|
+
import { validateConfig } from "./validator.ts";
|
|
18
|
+
|
|
19
|
+
export type RuntimeOverride = {
|
|
20
|
+
updated_at?: string | undefined;
|
|
21
|
+
environment?: string | undefined;
|
|
22
|
+
policy_version?: string | undefined;
|
|
23
|
+
defaults?: Partial<SecurityClawConfig["defaults"]> | undefined;
|
|
24
|
+
policies?: PolicyRule[] | undefined;
|
|
25
|
+
account_policies?: AccountPolicyRecord[] | undefined;
|
|
26
|
+
sensitivity?: SensitivePathStrategyOverride | undefined;
|
|
27
|
+
file_rules?: FileRule[] | undefined;
|
|
28
|
+
dlp?: (Partial<Omit<DlpConfig, "patterns">> & { patterns?: DlpConfig["patterns"]; }) | undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readRuntimeOverride(overridePath: string): RuntimeOverride | undefined {
|
|
36
|
+
if (!existsSync(overridePath)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const raw = JSON.parse(readFileSync(overridePath, "utf8")) as unknown;
|
|
40
|
+
if (!isObject(raw)) {
|
|
41
|
+
throw new Error("runtime override must be an object");
|
|
42
|
+
}
|
|
43
|
+
return raw as RuntimeOverride;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function applyRuntimeOverride(base: SecurityClawConfig, override: RuntimeOverride): SecurityClawConfig {
|
|
47
|
+
const baseSensitivity = hydrateSensitivePathConfig(base.sensitivity);
|
|
48
|
+
const merged: SecurityClawConfig = {
|
|
49
|
+
...base,
|
|
50
|
+
environment: override.environment ?? base.environment,
|
|
51
|
+
policy_version: override.policy_version ?? base.policy_version,
|
|
52
|
+
defaults: {
|
|
53
|
+
...base.defaults,
|
|
54
|
+
...(override.defaults ?? {})
|
|
55
|
+
},
|
|
56
|
+
dlp: {
|
|
57
|
+
...base.dlp,
|
|
58
|
+
...(override.dlp ?? {}),
|
|
59
|
+
patterns: override.dlp?.patterns ?? base.dlp.patterns
|
|
60
|
+
},
|
|
61
|
+
policies: override.policies ?? base.policies,
|
|
62
|
+
sensitivity: applySensitivePathStrategyOverride(baseSensitivity, normalizeSensitivePathStrategyOverride(override.sensitivity)),
|
|
63
|
+
file_rules: override.file_rules !== undefined ? normalizeFileRules(override.file_rules) : base.file_rules,
|
|
64
|
+
};
|
|
65
|
+
return validateConfig(merged as unknown as Record<string, unknown>);
|
|
66
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
|
|
5
|
+
import type { SecurityClawConfig } from "../types.ts";
|
|
6
|
+
import { applyRuntimeOverride, readRuntimeOverride, type RuntimeOverride } from "./runtime_override.ts";
|
|
7
|
+
|
|
8
|
+
type StrategyStoreOptions = {
|
|
9
|
+
legacyOverridePath?: string;
|
|
10
|
+
logger?: {
|
|
11
|
+
warn?: (message: string) => void;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type OverrideRow = {
|
|
16
|
+
payload_json: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const STRATEGY_SCHEMA_SQL = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS strategy_override (
|
|
21
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
22
|
+
payload_json TEXT NOT NULL,
|
|
23
|
+
updated_at TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class StrategyStore {
|
|
32
|
+
#dbPath: string;
|
|
33
|
+
#db: DatabaseSync;
|
|
34
|
+
#legacyOverridePath: string | undefined;
|
|
35
|
+
#logger: StrategyStoreOptions["logger"] | undefined;
|
|
36
|
+
|
|
37
|
+
constructor(dbPath: string, options: StrategyStoreOptions = {}) {
|
|
38
|
+
this.#dbPath = dbPath;
|
|
39
|
+
this.#legacyOverridePath = options.legacyOverridePath;
|
|
40
|
+
this.#logger = options.logger;
|
|
41
|
+
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
42
|
+
this.#db = new DatabaseSync(dbPath);
|
|
43
|
+
this.#db.exec("PRAGMA journal_mode=WAL;");
|
|
44
|
+
this.#db.exec("PRAGMA synchronous=NORMAL;");
|
|
45
|
+
this.#db.exec(STRATEGY_SCHEMA_SQL);
|
|
46
|
+
this.#bootstrapFromLegacyFile();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get dbPath(): string {
|
|
50
|
+
return this.#dbPath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
readOverride(): RuntimeOverride | undefined {
|
|
54
|
+
const row = this.#db
|
|
55
|
+
.prepare("SELECT payload_json FROM strategy_override WHERE id = 1")
|
|
56
|
+
.get() as OverrideRow | undefined;
|
|
57
|
+
if (!row) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const raw = JSON.parse(row.payload_json) as unknown;
|
|
61
|
+
if (!isObject(raw)) {
|
|
62
|
+
throw new Error("runtime override in database must be an object");
|
|
63
|
+
}
|
|
64
|
+
return raw as RuntimeOverride;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
writeOverride(override: RuntimeOverride): void {
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
const payload: RuntimeOverride = {
|
|
70
|
+
...override,
|
|
71
|
+
updated_at: override.updated_at ?? now
|
|
72
|
+
};
|
|
73
|
+
this.#db
|
|
74
|
+
.prepare(
|
|
75
|
+
`
|
|
76
|
+
INSERT INTO strategy_override (id, payload_json, updated_at)
|
|
77
|
+
VALUES (1, ?, ?)
|
|
78
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
79
|
+
payload_json = excluded.payload_json,
|
|
80
|
+
updated_at = excluded.updated_at
|
|
81
|
+
`,
|
|
82
|
+
)
|
|
83
|
+
.run(JSON.stringify(payload), payload.updated_at ?? now);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
readEffective(base: SecurityClawConfig): {
|
|
87
|
+
effective: SecurityClawConfig;
|
|
88
|
+
override?: RuntimeOverride;
|
|
89
|
+
} {
|
|
90
|
+
const override = this.readOverride();
|
|
91
|
+
if (!override) {
|
|
92
|
+
return { effective: base };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
effective: applyRuntimeOverride(base, override),
|
|
96
|
+
override
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
close(): void {
|
|
101
|
+
this.#db.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#bootstrapFromLegacyFile(): void {
|
|
105
|
+
if (!this.#legacyOverridePath || this.readOverride()) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const legacy = readRuntimeOverride(this.#legacyOverridePath);
|
|
110
|
+
if (!legacy) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.writeOverride(legacy);
|
|
114
|
+
this.#logger?.warn?.(
|
|
115
|
+
`migrated strategy override from legacy file (${this.#legacyOverridePath}) into sqlite (${this.#dbPath})`,
|
|
116
|
+
);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
this.#logger?.warn?.(`failed to migrate legacy override file (${String(error)})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|