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,612 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
|
|
5
|
+
type Decision = "allow" | "warn" | "challenge" | "block";
|
|
6
|
+
type DecisionSource = "rule" | "default" | "approval" | "account" | "file_rule";
|
|
7
|
+
|
|
8
|
+
type HookCounter = {
|
|
9
|
+
total: number;
|
|
10
|
+
allow: number;
|
|
11
|
+
warn: number;
|
|
12
|
+
challenge: number;
|
|
13
|
+
block: number;
|
|
14
|
+
last_ts?: string;
|
|
15
|
+
last_tool?: string;
|
|
16
|
+
last_scope?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type StatusRecord = {
|
|
20
|
+
ts: string;
|
|
21
|
+
hook: string;
|
|
22
|
+
trace_id: string;
|
|
23
|
+
actor?: string;
|
|
24
|
+
scope?: string;
|
|
25
|
+
tool?: string;
|
|
26
|
+
decision: Decision;
|
|
27
|
+
decision_source?: DecisionSource;
|
|
28
|
+
resource_scope?: string;
|
|
29
|
+
reasons: string[];
|
|
30
|
+
rules?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type RuntimeStatus = {
|
|
34
|
+
updated_at: string;
|
|
35
|
+
started_at: string;
|
|
36
|
+
config: {
|
|
37
|
+
environment: string;
|
|
38
|
+
policy_version: string;
|
|
39
|
+
policy_count: number;
|
|
40
|
+
config_path: string;
|
|
41
|
+
strategy_db_path: string;
|
|
42
|
+
strategy_loaded: boolean;
|
|
43
|
+
legacy_override_path?: string;
|
|
44
|
+
};
|
|
45
|
+
hooks: Record<string, HookCounter>;
|
|
46
|
+
recent_decisions: StatusRecord[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type RuntimeStatusStoreOptions = {
|
|
50
|
+
snapshotPath: string;
|
|
51
|
+
dbPath?: string;
|
|
52
|
+
maxRecent?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type HookRow = {
|
|
56
|
+
hook: string;
|
|
57
|
+
total: number;
|
|
58
|
+
allow: number;
|
|
59
|
+
warn: number;
|
|
60
|
+
challenge: number;
|
|
61
|
+
block: number;
|
|
62
|
+
last_ts: string | null;
|
|
63
|
+
last_tool: string | null;
|
|
64
|
+
last_scope: string | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type DecisionRow = {
|
|
68
|
+
ts: string;
|
|
69
|
+
hook: string;
|
|
70
|
+
trace_id: string;
|
|
71
|
+
actor: string | null;
|
|
72
|
+
scope: string | null;
|
|
73
|
+
tool: string | null;
|
|
74
|
+
decision: Decision;
|
|
75
|
+
decision_source: DecisionSource | null;
|
|
76
|
+
resource_scope: string | null;
|
|
77
|
+
reasons_json: string;
|
|
78
|
+
rules: string | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const DEFAULT_HOOKS = [
|
|
82
|
+
"before_prompt_build",
|
|
83
|
+
"before_tool_call",
|
|
84
|
+
"after_tool_call",
|
|
85
|
+
"tool_result_persist",
|
|
86
|
+
"before_message_write",
|
|
87
|
+
"message_sending"
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function createHookCounter(): HookCounter {
|
|
91
|
+
return {
|
|
92
|
+
total: 0,
|
|
93
|
+
allow: 0,
|
|
94
|
+
warn: 0,
|
|
95
|
+
challenge: 0,
|
|
96
|
+
block: 0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function createEmptyStatus(): RuntimeStatus {
|
|
101
|
+
const hooks: Record<string, HookCounter> = {};
|
|
102
|
+
for (const hook of DEFAULT_HOOKS) {
|
|
103
|
+
hooks[hook] = createHookCounter();
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
updated_at: new Date().toISOString(),
|
|
107
|
+
started_at: new Date().toISOString(),
|
|
108
|
+
config: {
|
|
109
|
+
environment: "unknown",
|
|
110
|
+
policy_version: "unknown",
|
|
111
|
+
policy_count: 0,
|
|
112
|
+
config_path: "",
|
|
113
|
+
strategy_db_path: "",
|
|
114
|
+
strategy_loaded: false
|
|
115
|
+
},
|
|
116
|
+
hooks,
|
|
117
|
+
recent_decisions: []
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseReasons(raw: string): string[] {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
124
|
+
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function optionalString(value: string | null): string | undefined {
|
|
131
|
+
return value ?? undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveDbPathFromSnapshotPath(snapshotPath: string): string {
|
|
135
|
+
const snapshotDir = path.dirname(path.resolve(snapshotPath));
|
|
136
|
+
if (path.basename(snapshotDir) === "runtime") {
|
|
137
|
+
return path.resolve(snapshotDir, "..", "data", "securityclaw.db");
|
|
138
|
+
}
|
|
139
|
+
return path.resolve(snapshotDir, "securityclaw.db");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const STATUS_SCHEMA_SQL = `
|
|
143
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
144
|
+
key TEXT PRIMARY KEY,
|
|
145
|
+
value TEXT NOT NULL
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
CREATE TABLE IF NOT EXISTS hook_counters (
|
|
149
|
+
hook TEXT PRIMARY KEY,
|
|
150
|
+
total INTEGER NOT NULL DEFAULT 0,
|
|
151
|
+
allow INTEGER NOT NULL DEFAULT 0,
|
|
152
|
+
warn INTEGER NOT NULL DEFAULT 0,
|
|
153
|
+
challenge INTEGER NOT NULL DEFAULT 0,
|
|
154
|
+
block INTEGER NOT NULL DEFAULT 0,
|
|
155
|
+
last_ts TEXT,
|
|
156
|
+
last_tool TEXT,
|
|
157
|
+
last_scope TEXT
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
161
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
162
|
+
ts TEXT NOT NULL,
|
|
163
|
+
hook TEXT NOT NULL,
|
|
164
|
+
trace_id TEXT NOT NULL,
|
|
165
|
+
actor TEXT,
|
|
166
|
+
scope TEXT,
|
|
167
|
+
tool TEXT,
|
|
168
|
+
decision TEXT NOT NULL,
|
|
169
|
+
decision_source TEXT,
|
|
170
|
+
resource_scope TEXT,
|
|
171
|
+
reasons_json TEXT NOT NULL,
|
|
172
|
+
rules TEXT
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_recent ON decisions(id DESC);
|
|
176
|
+
`;
|
|
177
|
+
|
|
178
|
+
export class RuntimeStatusStore {
|
|
179
|
+
#snapshotPath: string;
|
|
180
|
+
#dbPath: string;
|
|
181
|
+
#maxRecent: number;
|
|
182
|
+
#db: DatabaseSync;
|
|
183
|
+
|
|
184
|
+
constructor(statusPath: string, maxRecent?: number);
|
|
185
|
+
constructor(options: RuntimeStatusStoreOptions);
|
|
186
|
+
constructor(statusPathOrOptions: string | RuntimeStatusStoreOptions, maxRecent = 80) {
|
|
187
|
+
const options =
|
|
188
|
+
typeof statusPathOrOptions === "string"
|
|
189
|
+
? ({
|
|
190
|
+
snapshotPath: statusPathOrOptions,
|
|
191
|
+
dbPath: resolveDbPathFromSnapshotPath(statusPathOrOptions),
|
|
192
|
+
maxRecent
|
|
193
|
+
} satisfies RuntimeStatusStoreOptions)
|
|
194
|
+
: {
|
|
195
|
+
snapshotPath: statusPathOrOptions.snapshotPath,
|
|
196
|
+
dbPath: statusPathOrOptions.dbPath ?? resolveDbPathFromSnapshotPath(statusPathOrOptions.snapshotPath),
|
|
197
|
+
maxRecent: statusPathOrOptions.maxRecent ?? 80
|
|
198
|
+
};
|
|
199
|
+
this.#snapshotPath = options.snapshotPath;
|
|
200
|
+
this.#dbPath = options.dbPath ?? resolveDbPathFromSnapshotPath(options.snapshotPath);
|
|
201
|
+
this.#maxRecent = options.maxRecent ?? 80;
|
|
202
|
+
mkdirSync(path.dirname(this.#snapshotPath), { recursive: true });
|
|
203
|
+
mkdirSync(path.dirname(this.#dbPath), { recursive: true });
|
|
204
|
+
this.#db = new DatabaseSync(this.#dbPath);
|
|
205
|
+
this.#db.exec("PRAGMA journal_mode=WAL;");
|
|
206
|
+
this.#db.exec("PRAGMA synchronous=NORMAL;");
|
|
207
|
+
this.#db.exec(STATUS_SCHEMA_SQL);
|
|
208
|
+
this.#ensureDefaultHooks();
|
|
209
|
+
this.#bootstrapFromLegacySnapshot();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
markBoot(config: RuntimeStatus["config"]): void {
|
|
213
|
+
try {
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
this.#db.exec("BEGIN IMMEDIATE;");
|
|
216
|
+
try {
|
|
217
|
+
this.#writeConfigMeta(config, now, true);
|
|
218
|
+
this.#db.exec("COMMIT;");
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this.#db.exec("ROLLBACK;");
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
this.#flushSnapshot();
|
|
224
|
+
} catch {
|
|
225
|
+
// Swallow status persistence errors to avoid impacting guard execution.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
updateConfig(config: RuntimeStatus["config"]): void {
|
|
230
|
+
try {
|
|
231
|
+
const now = new Date().toISOString();
|
|
232
|
+
this.#db.exec("BEGIN IMMEDIATE;");
|
|
233
|
+
try {
|
|
234
|
+
this.#writeConfigMeta(config, now, false);
|
|
235
|
+
this.#db.exec("COMMIT;");
|
|
236
|
+
} catch (error) {
|
|
237
|
+
this.#db.exec("ROLLBACK;");
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
this.#flushSnapshot();
|
|
241
|
+
} catch {
|
|
242
|
+
// Swallow status persistence errors to avoid impacting guard execution.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
recordDecision(record: StatusRecord): void {
|
|
247
|
+
try {
|
|
248
|
+
this.#db.exec("BEGIN IMMEDIATE;");
|
|
249
|
+
try {
|
|
250
|
+
const allow = record.decision === "allow" ? 1 : 0;
|
|
251
|
+
const warn = record.decision === "warn" ? 1 : 0;
|
|
252
|
+
const challenge = record.decision === "challenge" ? 1 : 0;
|
|
253
|
+
const block = record.decision === "block" ? 1 : 0;
|
|
254
|
+
|
|
255
|
+
this.#db
|
|
256
|
+
.prepare(
|
|
257
|
+
`
|
|
258
|
+
INSERT INTO hook_counters (
|
|
259
|
+
hook, total, allow, warn, challenge, block, last_ts, last_tool, last_scope
|
|
260
|
+
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?)
|
|
261
|
+
ON CONFLICT(hook) DO UPDATE SET
|
|
262
|
+
total = hook_counters.total + 1,
|
|
263
|
+
allow = hook_counters.allow + excluded.allow,
|
|
264
|
+
warn = hook_counters.warn + excluded.warn,
|
|
265
|
+
challenge = hook_counters.challenge + excluded.challenge,
|
|
266
|
+
block = hook_counters.block + excluded.block,
|
|
267
|
+
last_ts = excluded.last_ts,
|
|
268
|
+
last_tool = excluded.last_tool,
|
|
269
|
+
last_scope = excluded.last_scope
|
|
270
|
+
`,
|
|
271
|
+
)
|
|
272
|
+
.run(record.hook, allow, warn, challenge, block, record.ts, record.tool ?? null, record.scope ?? null);
|
|
273
|
+
|
|
274
|
+
this.#db
|
|
275
|
+
.prepare(
|
|
276
|
+
`
|
|
277
|
+
INSERT INTO decisions (
|
|
278
|
+
ts, hook, trace_id, actor, scope, tool, decision, decision_source, resource_scope, reasons_json, rules
|
|
279
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
280
|
+
`,
|
|
281
|
+
)
|
|
282
|
+
.run(
|
|
283
|
+
record.ts,
|
|
284
|
+
record.hook,
|
|
285
|
+
record.trace_id,
|
|
286
|
+
record.actor ?? null,
|
|
287
|
+
record.scope ?? null,
|
|
288
|
+
record.tool ?? null,
|
|
289
|
+
record.decision,
|
|
290
|
+
record.decision_source ?? null,
|
|
291
|
+
record.resource_scope ?? null,
|
|
292
|
+
JSON.stringify(record.reasons),
|
|
293
|
+
record.rules ?? null,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
this.#setMeta("updated_at", new Date().toISOString());
|
|
297
|
+
this.#db.exec("COMMIT;");
|
|
298
|
+
} catch (error) {
|
|
299
|
+
this.#db.exec("ROLLBACK;");
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
this.#flushSnapshot();
|
|
303
|
+
} catch {
|
|
304
|
+
// Swallow status persistence errors to avoid impacting guard execution.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
close(): void {
|
|
309
|
+
this.#db.close();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#ensureDefaultHooks(): void {
|
|
313
|
+
const insert = this.#db.prepare("INSERT OR IGNORE INTO hook_counters (hook) VALUES (?)");
|
|
314
|
+
for (const hook of DEFAULT_HOOKS) {
|
|
315
|
+
insert.run(hook);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#setMeta(key: string, value: string): void {
|
|
320
|
+
this.#db
|
|
321
|
+
.prepare(
|
|
322
|
+
`
|
|
323
|
+
INSERT INTO meta (key, value)
|
|
324
|
+
VALUES (?, ?)
|
|
325
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
326
|
+
`,
|
|
327
|
+
)
|
|
328
|
+
.run(key, value);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#writeConfigMeta(config: RuntimeStatus["config"], now: string, includeStartedAt: boolean): void {
|
|
332
|
+
if (includeStartedAt) {
|
|
333
|
+
this.#setMeta("started_at", now);
|
|
334
|
+
}
|
|
335
|
+
this.#setMeta("updated_at", now);
|
|
336
|
+
this.#setMeta("config", JSON.stringify(config));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#getMeta(key: string): string | undefined {
|
|
340
|
+
const row = this.#db.prepare("SELECT value FROM meta WHERE key = ?").get(key) as
|
|
341
|
+
| { value: string }
|
|
342
|
+
| undefined;
|
|
343
|
+
return row?.value;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#hasPersistedData(): boolean {
|
|
347
|
+
const decisionCount = this.#db.prepare("SELECT COUNT(1) AS count FROM decisions").get() as { count: number };
|
|
348
|
+
if (Number(decisionCount.count ?? 0) > 0) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
const nonZeroHooks = this.#db
|
|
352
|
+
.prepare("SELECT COUNT(1) AS count FROM hook_counters WHERE total > 0")
|
|
353
|
+
.get() as { count: number };
|
|
354
|
+
if (Number(nonZeroHooks.count ?? 0) > 0) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
return Boolean(this.#getMeta("started_at") || this.#getMeta("config"));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#bootstrapFromLegacySnapshot(): void {
|
|
361
|
+
if (this.#hasPersistedData() || !existsSync(this.#snapshotPath)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
let legacy: RuntimeStatus | undefined;
|
|
365
|
+
try {
|
|
366
|
+
const raw = JSON.parse(readFileSync(this.#snapshotPath, "utf8")) as unknown;
|
|
367
|
+
if (raw && typeof raw === "object") {
|
|
368
|
+
legacy = raw as RuntimeStatus;
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (!legacy) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const startedAt =
|
|
379
|
+
typeof legacy.started_at === "string" && legacy.started_at.length > 0
|
|
380
|
+
? legacy.started_at
|
|
381
|
+
: new Date().toISOString();
|
|
382
|
+
const updatedAt =
|
|
383
|
+
typeof legacy.updated_at === "string" && legacy.updated_at.length > 0
|
|
384
|
+
? legacy.updated_at
|
|
385
|
+
: startedAt;
|
|
386
|
+
|
|
387
|
+
this.#db.exec("BEGIN IMMEDIATE;");
|
|
388
|
+
try {
|
|
389
|
+
this.#setMeta("started_at", startedAt);
|
|
390
|
+
this.#setMeta("updated_at", updatedAt);
|
|
391
|
+
|
|
392
|
+
if (legacy.config && typeof legacy.config === "object") {
|
|
393
|
+
const legacyConfig = legacy.config as RuntimeStatus["config"] & {
|
|
394
|
+
override_path?: string;
|
|
395
|
+
override_loaded?: boolean;
|
|
396
|
+
};
|
|
397
|
+
this.#setMeta(
|
|
398
|
+
"config",
|
|
399
|
+
JSON.stringify({
|
|
400
|
+
environment: legacyConfig.environment ?? "unknown",
|
|
401
|
+
policy_version: legacyConfig.policy_version ?? "unknown",
|
|
402
|
+
policy_count: legacyConfig.policy_count ?? 0,
|
|
403
|
+
config_path: legacyConfig.config_path ?? "",
|
|
404
|
+
strategy_db_path: legacyConfig.strategy_db_path ?? legacyConfig.override_path ?? "",
|
|
405
|
+
strategy_loaded: legacyConfig.strategy_loaded ?? legacyConfig.override_loaded ?? false,
|
|
406
|
+
legacy_override_path: legacyConfig.legacy_override_path
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const upsertHook = this.#db.prepare(
|
|
412
|
+
`
|
|
413
|
+
INSERT INTO hook_counters (
|
|
414
|
+
hook, total, allow, warn, challenge, block, last_ts, last_tool, last_scope
|
|
415
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
416
|
+
ON CONFLICT(hook) DO UPDATE SET
|
|
417
|
+
total = excluded.total,
|
|
418
|
+
allow = excluded.allow,
|
|
419
|
+
warn = excluded.warn,
|
|
420
|
+
challenge = excluded.challenge,
|
|
421
|
+
block = excluded.block,
|
|
422
|
+
last_ts = excluded.last_ts,
|
|
423
|
+
last_tool = excluded.last_tool,
|
|
424
|
+
last_scope = excluded.last_scope
|
|
425
|
+
`,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
for (const hook of Object.keys(legacy.hooks ?? {})) {
|
|
429
|
+
const counter = legacy.hooks[hook];
|
|
430
|
+
upsertHook.run(
|
|
431
|
+
hook,
|
|
432
|
+
Number(counter?.total ?? 0),
|
|
433
|
+
Number(counter?.allow ?? 0),
|
|
434
|
+
Number(counter?.warn ?? 0),
|
|
435
|
+
Number(counter?.challenge ?? 0),
|
|
436
|
+
Number(counter?.block ?? 0),
|
|
437
|
+
counter?.last_ts ?? null,
|
|
438
|
+
counter?.last_tool ?? null,
|
|
439
|
+
counter?.last_scope ?? null,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const insertDecision = this.#db.prepare(
|
|
444
|
+
`
|
|
445
|
+
INSERT INTO decisions (
|
|
446
|
+
ts, hook, trace_id, actor, scope, tool, decision, decision_source, resource_scope, reasons_json, rules
|
|
447
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
448
|
+
`,
|
|
449
|
+
);
|
|
450
|
+
const legacyDecisions = Array.isArray(legacy.recent_decisions) ? [...legacy.recent_decisions].reverse() : [];
|
|
451
|
+
for (const item of legacyDecisions) {
|
|
452
|
+
if (!item || typeof item !== "object") {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
insertDecision.run(
|
|
456
|
+
item.ts ?? new Date().toISOString(),
|
|
457
|
+
item.hook ?? "unknown",
|
|
458
|
+
item.trace_id ?? `legacy-${Date.now()}`,
|
|
459
|
+
item.actor ?? null,
|
|
460
|
+
item.scope ?? null,
|
|
461
|
+
item.tool ?? null,
|
|
462
|
+
item.decision ?? "allow",
|
|
463
|
+
item.decision_source ?? null,
|
|
464
|
+
item.resource_scope ?? null,
|
|
465
|
+
JSON.stringify(Array.isArray(item.reasons) ? item.reasons : []),
|
|
466
|
+
item.rules ?? null,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
this.#db.exec("COMMIT;");
|
|
471
|
+
} catch (error) {
|
|
472
|
+
this.#db.exec("ROLLBACK;");
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
this.#flushSnapshot();
|
|
477
|
+
} catch {
|
|
478
|
+
// Ignore bootstrap failures and continue with empty database.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#readStatus(): RuntimeStatus {
|
|
483
|
+
const fallback = createEmptyStatus();
|
|
484
|
+
const startedAt = this.#getMeta("started_at") ?? fallback.started_at;
|
|
485
|
+
const updatedAt = this.#getMeta("updated_at") ?? startedAt;
|
|
486
|
+
|
|
487
|
+
const configRaw = this.#getMeta("config");
|
|
488
|
+
let config = fallback.config;
|
|
489
|
+
if (configRaw) {
|
|
490
|
+
try {
|
|
491
|
+
const parsed = JSON.parse(configRaw) as RuntimeStatus["config"] & {
|
|
492
|
+
override_path?: string;
|
|
493
|
+
override_loaded?: boolean;
|
|
494
|
+
};
|
|
495
|
+
if (parsed && typeof parsed === "object") {
|
|
496
|
+
const nextConfig: RuntimeStatus["config"] = {
|
|
497
|
+
environment: parsed.environment ?? fallback.config.environment,
|
|
498
|
+
policy_version: parsed.policy_version ?? fallback.config.policy_version,
|
|
499
|
+
policy_count: parsed.policy_count ?? fallback.config.policy_count,
|
|
500
|
+
config_path: parsed.config_path ?? fallback.config.config_path,
|
|
501
|
+
strategy_db_path:
|
|
502
|
+
parsed.strategy_db_path ?? parsed.override_path ?? fallback.config.strategy_db_path,
|
|
503
|
+
strategy_loaded:
|
|
504
|
+
parsed.strategy_loaded ?? parsed.override_loaded ?? fallback.config.strategy_loaded
|
|
505
|
+
};
|
|
506
|
+
if (parsed.legacy_override_path !== undefined) {
|
|
507
|
+
nextConfig.legacy_override_path = parsed.legacy_override_path;
|
|
508
|
+
}
|
|
509
|
+
config = nextConfig;
|
|
510
|
+
}
|
|
511
|
+
} catch {
|
|
512
|
+
// Ignore malformed config payload and keep fallback values.
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const hooks: Record<string, HookCounter> = {};
|
|
517
|
+
for (const hook of DEFAULT_HOOKS) {
|
|
518
|
+
hooks[hook] = createHookCounter();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const rows = this.#db
|
|
522
|
+
.prepare(
|
|
523
|
+
`
|
|
524
|
+
SELECT hook, total, allow, warn, challenge, block, last_ts, last_tool, last_scope
|
|
525
|
+
FROM hook_counters
|
|
526
|
+
`,
|
|
527
|
+
)
|
|
528
|
+
.all() as HookRow[];
|
|
529
|
+
|
|
530
|
+
for (const row of rows) {
|
|
531
|
+
const counter: HookCounter = {
|
|
532
|
+
total: Number(row.total ?? 0),
|
|
533
|
+
allow: Number(row.allow ?? 0),
|
|
534
|
+
warn: Number(row.warn ?? 0),
|
|
535
|
+
challenge: Number(row.challenge ?? 0),
|
|
536
|
+
block: Number(row.block ?? 0)
|
|
537
|
+
};
|
|
538
|
+
const lastTs = optionalString(row.last_ts);
|
|
539
|
+
const lastTool = optionalString(row.last_tool);
|
|
540
|
+
const lastScope = optionalString(row.last_scope);
|
|
541
|
+
if (lastTs !== undefined) {
|
|
542
|
+
counter.last_ts = lastTs;
|
|
543
|
+
}
|
|
544
|
+
if (lastTool !== undefined) {
|
|
545
|
+
counter.last_tool = lastTool;
|
|
546
|
+
}
|
|
547
|
+
if (lastScope !== undefined) {
|
|
548
|
+
counter.last_scope = lastScope;
|
|
549
|
+
}
|
|
550
|
+
hooks[row.hook] = counter;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const decisions = this.#db
|
|
554
|
+
.prepare(
|
|
555
|
+
`
|
|
556
|
+
SELECT ts, hook, trace_id, actor, scope, tool, decision, decision_source, resource_scope, reasons_json, rules
|
|
557
|
+
FROM decisions
|
|
558
|
+
ORDER BY id DESC
|
|
559
|
+
LIMIT ?
|
|
560
|
+
`,
|
|
561
|
+
)
|
|
562
|
+
.all(this.#maxRecent) as DecisionRow[];
|
|
563
|
+
|
|
564
|
+
const recent = decisions.map((row) => {
|
|
565
|
+
const result: StatusRecord = {
|
|
566
|
+
ts: row.ts,
|
|
567
|
+
hook: row.hook,
|
|
568
|
+
trace_id: row.trace_id,
|
|
569
|
+
decision: row.decision,
|
|
570
|
+
reasons: parseReasons(row.reasons_json)
|
|
571
|
+
};
|
|
572
|
+
const actor = optionalString(row.actor);
|
|
573
|
+
const scope = optionalString(row.scope);
|
|
574
|
+
const tool = optionalString(row.tool);
|
|
575
|
+
const decisionSource = optionalString(row.decision_source);
|
|
576
|
+
const resourceScope = optionalString(row.resource_scope);
|
|
577
|
+
const rules = optionalString(row.rules);
|
|
578
|
+
if (actor) {
|
|
579
|
+
result.actor = actor;
|
|
580
|
+
}
|
|
581
|
+
if (scope) {
|
|
582
|
+
result.scope = scope;
|
|
583
|
+
}
|
|
584
|
+
if (tool) {
|
|
585
|
+
result.tool = tool;
|
|
586
|
+
}
|
|
587
|
+
if (decisionSource) {
|
|
588
|
+
result.decision_source = decisionSource as DecisionSource;
|
|
589
|
+
}
|
|
590
|
+
if (resourceScope) {
|
|
591
|
+
result.resource_scope = resourceScope;
|
|
592
|
+
}
|
|
593
|
+
if (rules) {
|
|
594
|
+
result.rules = rules;
|
|
595
|
+
}
|
|
596
|
+
return result;
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
updated_at: updatedAt,
|
|
601
|
+
started_at: startedAt,
|
|
602
|
+
config,
|
|
603
|
+
hooks,
|
|
604
|
+
recent_decisions: recent
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#flushSnapshot(): void {
|
|
609
|
+
const snapshot = this.#readStatus();
|
|
610
|
+
writeFileSync(this.#snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
|
|
611
|
+
}
|
|
612
|
+
}
|