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,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
+ }