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,189 @@
1
+ import type { ApprovalRepository } from "../../domain/ports/approval_repository.ts";
2
+ import type { ApprovalGrantMode, ApprovalService } from "../../domain/services/approval_service.ts";
3
+ import type { SecurityClawLocale } from "../../i18n/locale.ts";
4
+ import { pickLocalized } from "../../i18n/locale.ts";
5
+
6
+ export interface ApprovalCommandContext {
7
+ channel?: string;
8
+ senderId?: string;
9
+ from?: string;
10
+ to?: string;
11
+ accountId?: string;
12
+ args?: string;
13
+ isAuthorizedSender: boolean;
14
+ }
15
+
16
+ export interface ApprovalCommandConfig {
17
+ enabled: boolean;
18
+ approvers: Array<{
19
+ channel: string;
20
+ from: string;
21
+ accountId?: string;
22
+ }>;
23
+ locale?: SecurityClawLocale;
24
+ }
25
+
26
+ export class ApprovalCommands {
27
+ private locale: SecurityClawLocale;
28
+
29
+ constructor(
30
+ private repository: ApprovalRepository,
31
+ private approvalService: ApprovalService,
32
+ private config: ApprovalCommandConfig,
33
+ ) {
34
+ this.locale = config.locale ?? "en";
35
+ }
36
+
37
+ async handleApprove(ctx: ApprovalCommandContext): Promise<{ text: string }> {
38
+ if (!this.config.enabled) {
39
+ return { text: this.text("SecurityClaw 审批桥接未启用。", "SecurityClaw approval bridge is not enabled.") };
40
+ }
41
+ if (!ctx.isAuthorizedSender || !this.matchesApprover(ctx)) {
42
+ return { text: this.text("你无权审批 SecurityClaw 请求。", "You are not allowed to approve SecurityClaw requests.") };
43
+ }
44
+ const approvalId = this.parseApprovalId(ctx.args);
45
+ if (!approvalId) {
46
+ return { text: this.text("用法: /securityclaw-approve <approval_id> [long]", "Usage: /securityclaw-approve <approval_id> [long]") };
47
+ }
48
+ const existing = this.repository.getById(approvalId);
49
+ if (!existing) {
50
+ return { text: this.text(`审批请求不存在: ${approvalId}`, `Approval request not found: ${approvalId}`) };
51
+ }
52
+ if (existing.status !== "pending") {
53
+ return {
54
+ text: this.text(
55
+ `审批请求当前状态为 ${existing.status},无法重复批准。`,
56
+ `Approval request is ${existing.status}; it cannot be approved again.`,
57
+ ),
58
+ };
59
+ }
60
+ const grantMode = this.parseApprovalGrantMode(ctx.args);
61
+ const grantExpiresAt = this.approvalService.resolveApprovalGrantExpiry(existing, grantMode);
62
+ this.repository.resolve(
63
+ approvalId,
64
+ `${ctx.channel ?? "unknown"}:${ctx.from ?? "unknown"}`,
65
+ "approved",
66
+ { expires_at: grantExpiresAt },
67
+ );
68
+ return {
69
+ text: this.text(
70
+ `已为 ${existing.actor_id} 添加${this.formatGrantModeLabel(grantMode)},范围=${existing.scope},有效期至 ${this.approvalService["formatTimestampForApproval"](grantExpiresAt)}。`,
71
+ `${this.formatGrantModeLabel(grantMode)} granted for ${existing.actor_id}, scope=${existing.scope}, expires at ${this.approvalService["formatTimestampForApproval"](grantExpiresAt)}.`,
72
+ ),
73
+ };
74
+ }
75
+
76
+ async handleReject(ctx: ApprovalCommandContext): Promise<{ text: string }> {
77
+ if (!this.config.enabled) {
78
+ return { text: this.text("SecurityClaw 审批桥接未启用。", "SecurityClaw approval bridge is not enabled.") };
79
+ }
80
+ if (!ctx.isAuthorizedSender || !this.matchesApprover(ctx)) {
81
+ return { text: this.text("你无权审批 SecurityClaw 请求。", "You are not allowed to approve SecurityClaw requests.") };
82
+ }
83
+ const approvalId = this.parseApprovalId(ctx.args);
84
+ if (!approvalId) {
85
+ return { text: this.text("用法: /securityclaw-reject <approval_id>", "Usage: /securityclaw-reject <approval_id>") };
86
+ }
87
+ const existing = this.repository.getById(approvalId);
88
+ if (!existing) {
89
+ return { text: this.text(`审批请求不存在: ${approvalId}`, `Approval request not found: ${approvalId}`) };
90
+ }
91
+ if (existing.status !== "pending") {
92
+ return {
93
+ text: this.text(
94
+ `审批请求当前状态为 ${existing.status},无法重复拒绝。`,
95
+ `Approval request is ${existing.status}; it cannot be rejected again.`,
96
+ ),
97
+ };
98
+ }
99
+ this.repository.resolve(
100
+ approvalId,
101
+ `${ctx.channel ?? "unknown"}:${ctx.from ?? "unknown"}`,
102
+ "rejected",
103
+ );
104
+ return {
105
+ text: this.text(
106
+ `已拒绝 ${approvalId},不会为 ${existing.actor_id} 增加授权。`,
107
+ `Rejected ${approvalId}. No grant was added for ${existing.actor_id}.`,
108
+ ),
109
+ };
110
+ }
111
+
112
+ async handlePending(ctx: ApprovalCommandContext): Promise<{ text: string }> {
113
+ if (!this.config.enabled) {
114
+ return { text: this.text("SecurityClaw 审批桥接未启用。", "SecurityClaw approval bridge is not enabled.") };
115
+ }
116
+ if (!ctx.isAuthorizedSender || !this.matchesApprover(ctx)) {
117
+ return { text: this.text("你无权查看 SecurityClaw 待审批请求。", "You are not allowed to view pending SecurityClaw approvals.") };
118
+ }
119
+ return { text: this.approvalService.formatPendingApprovals(this.repository.listPending(10)) };
120
+ }
121
+
122
+ private matchesApprover(ctx: ApprovalCommandContext): boolean {
123
+ const channel = this.normalizeApprovalChannel(ctx.channel);
124
+ if (!channel) {
125
+ return false;
126
+ }
127
+ const senderIds = new Set<string>();
128
+ const collectSenderId = (value: string | undefined) => {
129
+ const trimmed = value?.trim();
130
+ if (!trimmed) {
131
+ return;
132
+ }
133
+ senderIds.add(trimmed);
134
+ const lower = trimmed.toLowerCase();
135
+ const channelPrefix = `${channel}:`;
136
+ if (lower.startsWith(channelPrefix)) {
137
+ const unscoped = trimmed.slice(channelPrefix.length).trim();
138
+ if (unscoped) {
139
+ senderIds.add(unscoped);
140
+ }
141
+ return;
142
+ }
143
+ senderIds.add(`${channel}:${trimmed}`);
144
+ };
145
+
146
+ collectSenderId(ctx.from);
147
+ collectSenderId(ctx.senderId);
148
+ if (senderIds.size === 0) {
149
+ return false;
150
+ }
151
+
152
+ return this.config.approvers.some((approver) => {
153
+ if (approver.channel !== channel || !senderIds.has(approver.from)) {
154
+ return false;
155
+ }
156
+ if (approver.accountId && approver.accountId !== ctx.accountId) {
157
+ return false;
158
+ }
159
+ return true;
160
+ });
161
+ }
162
+
163
+ private normalizeApprovalChannel(value: string | undefined): string | undefined {
164
+ const normalized = value?.trim().toLowerCase();
165
+ return normalized || undefined;
166
+ }
167
+
168
+ private parseApprovalId(args: string | undefined): string | undefined {
169
+ const value = args?.trim();
170
+ return value ? value.split(/\s+/)[0] : undefined;
171
+ }
172
+
173
+ private parseApprovalGrantMode(args: string | undefined): ApprovalGrantMode {
174
+ const value = args?.trim();
175
+ const mode = value ? value.split(/\s+/)[1]?.toLowerCase() : undefined;
176
+ if (mode === "long" || mode === "longterm" || mode === "permanent" || mode === "长期") {
177
+ return "longterm";
178
+ }
179
+ return "temporary";
180
+ }
181
+
182
+ private formatGrantModeLabel(mode: ApprovalGrantMode): string {
183
+ return this.text(mode === "longterm" ? "长期授权" : "临时授权", mode === "longterm" ? "Long-lived grant" : "Temporary grant");
184
+ }
185
+
186
+ private text(zhText: string, enText: string): string {
187
+ return pickLocalized(this.locale, zhText, enText);
188
+ }
189
+ }
@@ -0,0 +1,433 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+
6
+ import type { ApprovalStatus, ResourceScope } from "../types.ts";
7
+
8
+ export type ApprovalChannel = string;
9
+
10
+ export type ChatApprovalTarget = {
11
+ channel: ApprovalChannel;
12
+ to: string;
13
+ account_id?: string;
14
+ thread_id?: string | number;
15
+ };
16
+
17
+ export type ChatApprovalApprover = {
18
+ channel: ApprovalChannel;
19
+ from: string;
20
+ account_id?: string;
21
+ };
22
+
23
+ export type ChatApprovalConfig = {
24
+ enabled?: boolean;
25
+ targets?: ChatApprovalTarget[];
26
+ approvers?: ChatApprovalApprover[];
27
+ };
28
+
29
+ export type StoredApprovalNotification = {
30
+ channel: ApprovalChannel;
31
+ to: string;
32
+ account_id?: string;
33
+ thread_id?: string | number;
34
+ message_id?: string;
35
+ sent_at?: string;
36
+ };
37
+
38
+ export type StoredApprovalRecord = {
39
+ approval_id: string;
40
+ request_key: string;
41
+ session_scope: string;
42
+ status: ApprovalStatus;
43
+ requested_at: string;
44
+ expires_at: string;
45
+ policy_version: string;
46
+ actor_id: string;
47
+ scope: string;
48
+ tool_name: string;
49
+ resource_scope: ResourceScope;
50
+ resource_paths: string[];
51
+ reason_codes: string[];
52
+ rule_ids: string[];
53
+ args_summary: string;
54
+ approver?: string;
55
+ decided_at?: string;
56
+ notifications: StoredApprovalNotification[];
57
+ };
58
+
59
+ export type CreateApprovalRecordInput = Omit<
60
+ StoredApprovalRecord,
61
+ "approval_id" | "status" | "requested_at" | "notifications"
62
+ > & {
63
+ requested_at?: string;
64
+ notifications?: StoredApprovalNotification[];
65
+ };
66
+
67
+ type ChatApprovalStoreOptions = {
68
+ now?: () => number;
69
+ };
70
+
71
+ type ApprovalRow = {
72
+ approval_id: string;
73
+ request_key: string;
74
+ session_scope: string;
75
+ status: ApprovalStatus;
76
+ requested_at: string;
77
+ expires_at: string;
78
+ policy_version: string;
79
+ actor_id: string;
80
+ scope: string;
81
+ tool_name: string;
82
+ resource_scope: string;
83
+ resource_paths_json: string;
84
+ reason_codes_json: string;
85
+ rule_ids_json: string;
86
+ args_summary: string;
87
+ approver: string | null;
88
+ decided_at: string | null;
89
+ notifications_json: string | null;
90
+ };
91
+
92
+ const CHAT_APPROVAL_SCHEMA_SQL = `
93
+ CREATE TABLE IF NOT EXISTS chat_approval_requests (
94
+ approval_id TEXT PRIMARY KEY,
95
+ request_key TEXT NOT NULL,
96
+ session_scope TEXT NOT NULL,
97
+ status TEXT NOT NULL,
98
+ requested_at TEXT NOT NULL,
99
+ expires_at TEXT NOT NULL,
100
+ policy_version TEXT NOT NULL,
101
+ actor_id TEXT NOT NULL,
102
+ scope TEXT NOT NULL,
103
+ tool_name TEXT NOT NULL,
104
+ resource_scope TEXT NOT NULL,
105
+ resource_paths_json TEXT NOT NULL,
106
+ reason_codes_json TEXT NOT NULL,
107
+ rule_ids_json TEXT NOT NULL,
108
+ args_summary TEXT NOT NULL,
109
+ approver TEXT,
110
+ decided_at TEXT,
111
+ notifications_json TEXT
112
+ );
113
+
114
+ CREATE INDEX IF NOT EXISTS idx_chat_approval_session_request
115
+ ON chat_approval_requests (session_scope, request_key, status, expires_at);
116
+
117
+ CREATE INDEX IF NOT EXISTS idx_chat_approval_status
118
+ ON chat_approval_requests (status, requested_at DESC);
119
+ `;
120
+
121
+ function optionalString(value: unknown): string | undefined {
122
+ return typeof value === "string" && value.trim() ? value : undefined;
123
+ }
124
+
125
+ function parseJsonArray<T>(raw: string | null): T[] {
126
+ if (!raw) {
127
+ return [];
128
+ }
129
+ const parsed = JSON.parse(raw) as unknown;
130
+ return Array.isArray(parsed) ? (parsed as T[]) : [];
131
+ }
132
+
133
+ function rowToRecord(row: ApprovalRow): StoredApprovalRecord {
134
+ const approver = optionalString(row.approver);
135
+ const decidedAt = optionalString(row.decided_at);
136
+ return {
137
+ approval_id: row.approval_id,
138
+ request_key: row.request_key,
139
+ session_scope: row.session_scope,
140
+ status: row.status,
141
+ requested_at: row.requested_at,
142
+ expires_at: row.expires_at,
143
+ policy_version: row.policy_version,
144
+ actor_id: row.actor_id,
145
+ scope: row.scope,
146
+ tool_name: row.tool_name,
147
+ resource_scope: row.resource_scope as ResourceScope,
148
+ resource_paths: parseJsonArray<string>(row.resource_paths_json),
149
+ reason_codes: parseJsonArray<string>(row.reason_codes_json),
150
+ rule_ids: parseJsonArray<string>(row.rule_ids_json),
151
+ args_summary: row.args_summary,
152
+ ...(approver ? { approver } : {}),
153
+ ...(decidedAt ? { decided_at: decidedAt } : {}),
154
+ notifications: parseJsonArray<StoredApprovalNotification>(row.notifications_json),
155
+ };
156
+ }
157
+
158
+ function normalizeForHash(value: unknown, depth = 0): unknown {
159
+ if (depth > 8) {
160
+ return "[MAX_DEPTH]";
161
+ }
162
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
163
+ return value;
164
+ }
165
+ if (Array.isArray(value)) {
166
+ return value.map((item) => normalizeForHash(item, depth + 1));
167
+ }
168
+ if (value && typeof value === "object") {
169
+ const record = value as Record<string, unknown>;
170
+ return Object.keys(record)
171
+ .sort()
172
+ .reduce<Record<string, unknown>>((output, key) => {
173
+ const normalized = normalizeForHash(record[key], depth + 1);
174
+ if (normalized !== undefined) {
175
+ output[key] = normalized;
176
+ }
177
+ return output;
178
+ }, {});
179
+ }
180
+ return String(value);
181
+ }
182
+
183
+ export function createApprovalRequestKey(input: {
184
+ policy_version: string;
185
+ scope: string;
186
+ tool_name: string;
187
+ resource_scope: ResourceScope;
188
+ resource_paths: string[];
189
+ params: unknown;
190
+ }): string {
191
+ const payload = JSON.stringify(
192
+ normalizeForHash({
193
+ policy_version: input.policy_version,
194
+ scope: input.scope,
195
+ tool_name: input.tool_name,
196
+ resource_scope: input.resource_scope,
197
+ resource_paths: [...input.resource_paths].sort(),
198
+ params: input.params,
199
+ }),
200
+ );
201
+
202
+ return createHash("sha256").update(payload).digest("hex");
203
+ }
204
+
205
+ export class ChatApprovalStore {
206
+ #db: DatabaseSync;
207
+ #now: () => number;
208
+
209
+ constructor(dbPath: string, options: ChatApprovalStoreOptions = {}) {
210
+ mkdirSync(path.dirname(dbPath), { recursive: true });
211
+ this.#db = new DatabaseSync(dbPath);
212
+ this.#db.exec("PRAGMA journal_mode=WAL;");
213
+ this.#db.exec("PRAGMA synchronous=NORMAL;");
214
+ this.#db.exec(CHAT_APPROVAL_SCHEMA_SQL);
215
+ this.#now = options.now ?? Date.now;
216
+ }
217
+
218
+ create(input: CreateApprovalRecordInput): StoredApprovalRecord {
219
+ const requestedAt = input.requested_at ?? new Date(this.#now()).toISOString();
220
+ const record: StoredApprovalRecord = {
221
+ approval_id: randomUUID(),
222
+ request_key: input.request_key,
223
+ session_scope: input.session_scope,
224
+ status: "pending",
225
+ requested_at: requestedAt,
226
+ expires_at: input.expires_at,
227
+ policy_version: input.policy_version,
228
+ actor_id: input.actor_id,
229
+ scope: input.scope,
230
+ tool_name: input.tool_name,
231
+ resource_scope: input.resource_scope,
232
+ resource_paths: [...input.resource_paths],
233
+ reason_codes: [...input.reason_codes],
234
+ rule_ids: [...input.rule_ids],
235
+ args_summary: input.args_summary,
236
+ notifications: [...(input.notifications ?? [])],
237
+ };
238
+
239
+ this.#write(record);
240
+ return record;
241
+ }
242
+
243
+ getById(approvalId: string): StoredApprovalRecord | undefined {
244
+ const row = this.#db
245
+ .prepare(
246
+ `
247
+ SELECT *
248
+ FROM chat_approval_requests
249
+ WHERE approval_id = ?
250
+ `,
251
+ )
252
+ .get(approvalId) as ApprovalRow | undefined;
253
+
254
+ return row ? this.#expireIfNeeded(rowToRecord(row)) : undefined;
255
+ }
256
+
257
+ findPending(sessionScope: string, requestKey: string): StoredApprovalRecord | undefined {
258
+ const row = this.#db
259
+ .prepare(
260
+ `
261
+ SELECT *
262
+ FROM chat_approval_requests
263
+ WHERE session_scope = ?
264
+ AND request_key = ?
265
+ AND status = 'pending'
266
+ ORDER BY requested_at DESC
267
+ LIMIT 1
268
+ `,
269
+ )
270
+ .get(sessionScope, requestKey) as ApprovalRow | undefined;
271
+
272
+ const record = row ? this.#expireIfNeeded(rowToRecord(row)) : undefined;
273
+ return record?.status === "pending" ? record : undefined;
274
+ }
275
+
276
+ findApproved(sessionScope: string, requestKey: string): StoredApprovalRecord | undefined {
277
+ const row = this.#db
278
+ .prepare(
279
+ `
280
+ SELECT *
281
+ FROM chat_approval_requests
282
+ WHERE session_scope = ?
283
+ AND request_key = ?
284
+ AND status = 'approved'
285
+ ORDER BY decided_at DESC, requested_at DESC
286
+ LIMIT 1
287
+ `,
288
+ )
289
+ .get(sessionScope, requestKey) as ApprovalRow | undefined;
290
+
291
+ const record = row ? this.#expireIfNeeded(rowToRecord(row)) : undefined;
292
+ return record?.status === "approved" ? record : undefined;
293
+ }
294
+
295
+ resolve(
296
+ approvalId: string,
297
+ approver: string,
298
+ decision: "approved" | "rejected",
299
+ options: { expires_at?: string } = {},
300
+ ): StoredApprovalRecord | undefined {
301
+ const existing = this.getById(approvalId);
302
+ if (!existing || existing.status !== "pending") {
303
+ return existing;
304
+ }
305
+
306
+ const updated: StoredApprovalRecord = {
307
+ ...existing,
308
+ status: decision,
309
+ ...(options.expires_at ? { expires_at: options.expires_at } : {}),
310
+ approver,
311
+ decided_at: new Date(this.#now()).toISOString(),
312
+ };
313
+
314
+ this.#write(updated);
315
+ return updated;
316
+ }
317
+
318
+ listPending(limit = 10): StoredApprovalRecord[] {
319
+ const rows = this.#db
320
+ .prepare(
321
+ `
322
+ SELECT *
323
+ FROM chat_approval_requests
324
+ WHERE status = 'pending'
325
+ ORDER BY requested_at DESC
326
+ LIMIT ?
327
+ `,
328
+ )
329
+ .all(limit) as ApprovalRow[];
330
+
331
+ return rows
332
+ .map((row) => this.#expireIfNeeded(rowToRecord(row)))
333
+ .filter((record): record is StoredApprovalRecord => record?.status === "pending");
334
+ }
335
+
336
+ updateNotifications(approvalId: string, notifications: StoredApprovalNotification[]): StoredApprovalRecord | undefined {
337
+ const existing = this.getById(approvalId);
338
+ if (!existing) {
339
+ return undefined;
340
+ }
341
+ const updated: StoredApprovalRecord = {
342
+ ...existing,
343
+ notifications: [...notifications],
344
+ };
345
+ this.#write(updated);
346
+ return updated;
347
+ }
348
+
349
+ close(): void {
350
+ this.#db.close();
351
+ }
352
+
353
+ #expireIfNeeded(record: StoredApprovalRecord): StoredApprovalRecord {
354
+ if (record.status === "pending" || record.status === "approved") {
355
+ if (this.#now() > new Date(record.expires_at).getTime()) {
356
+ const expired: StoredApprovalRecord = {
357
+ ...record,
358
+ status: "expired",
359
+ };
360
+ this.#write(expired);
361
+ return expired;
362
+ }
363
+ }
364
+ return record;
365
+ }
366
+
367
+ #write(record: StoredApprovalRecord): void {
368
+ this.#db
369
+ .prepare(
370
+ `
371
+ INSERT INTO chat_approval_requests (
372
+ approval_id,
373
+ request_key,
374
+ session_scope,
375
+ status,
376
+ requested_at,
377
+ expires_at,
378
+ policy_version,
379
+ actor_id,
380
+ scope,
381
+ tool_name,
382
+ resource_scope,
383
+ resource_paths_json,
384
+ reason_codes_json,
385
+ rule_ids_json,
386
+ args_summary,
387
+ approver,
388
+ decided_at,
389
+ notifications_json
390
+ )
391
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
392
+ ON CONFLICT(approval_id) DO UPDATE SET
393
+ request_key = excluded.request_key,
394
+ session_scope = excluded.session_scope,
395
+ status = excluded.status,
396
+ requested_at = excluded.requested_at,
397
+ expires_at = excluded.expires_at,
398
+ policy_version = excluded.policy_version,
399
+ actor_id = excluded.actor_id,
400
+ scope = excluded.scope,
401
+ tool_name = excluded.tool_name,
402
+ resource_scope = excluded.resource_scope,
403
+ resource_paths_json = excluded.resource_paths_json,
404
+ reason_codes_json = excluded.reason_codes_json,
405
+ rule_ids_json = excluded.rule_ids_json,
406
+ args_summary = excluded.args_summary,
407
+ approver = excluded.approver,
408
+ decided_at = excluded.decided_at,
409
+ notifications_json = excluded.notifications_json
410
+ `,
411
+ )
412
+ .run(
413
+ record.approval_id,
414
+ record.request_key,
415
+ record.session_scope,
416
+ record.status,
417
+ record.requested_at,
418
+ record.expires_at,
419
+ record.policy_version,
420
+ record.actor_id,
421
+ record.scope,
422
+ record.tool_name,
423
+ record.resource_scope,
424
+ JSON.stringify(record.resource_paths),
425
+ JSON.stringify(record.reason_codes),
426
+ JSON.stringify(record.rule_ids),
427
+ record.args_summary,
428
+ record.approver ?? null,
429
+ record.decided_at ?? null,
430
+ JSON.stringify(record.notifications),
431
+ );
432
+ }
433
+ }