@synoi/gateway-lite 0.1.0
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/LICENSE +195 -0
- package/README.md +185 -0
- package/bin/synoi-gateway-lite.js +22 -0
- package/dist/cdro-mirror.js +280 -0
- package/dist/daemon.js +194 -0
- package/dist/gap/cited-oracle-inputs.js +283 -0
- package/dist/gap/lite-dashboard.html +502 -0
- package/dist/gap/lite-keystore.js +351 -0
- package/dist/gap/lite-mode.js +31 -0
- package/dist/gap/lite-signing-key.js +362 -0
- package/dist/gap/local-ingest-router.js +1642 -0
- package/dist/gap/operator-enrollment.js +466 -0
- package/dist/gap/store.js +1063 -0
- package/dist/gap/types.js +15 -0
- package/dist/key-provider.js +275 -0
- package/dist/keys.js +250 -0
- package/dist/native-mldsa.js +82 -0
- package/dist/receipt-store.js +801 -0
- package/dist/shadow-mode.js +85 -0
- package/dist/state-drift/quantize.js +107 -0
- package/dist/tenant-store.js +366 -0
- package/dist/verify-router.js +1755 -0
- package/npm-shrinkwrap.json +1400 -0
- package/package.json +46 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* receipt-store.ts
|
|
4
|
+
*
|
|
5
|
+
* Persists Decision Receipts and a lightweight action log to SQLite so the
|
|
6
|
+
* dashboard can show them. Separate from the core EdgeStore / Evidence Journal
|
|
7
|
+
* (which live in synoi-memory) — this is the gateway's own observable surface.
|
|
8
|
+
*
|
|
9
|
+
* Schema:
|
|
10
|
+
* receipts — one row per Sentinel decision
|
|
11
|
+
* action_log — one row per gateway request (shadow + enforce)
|
|
12
|
+
* policy_suggestions — auto-classification suggestions per tenant+action_type
|
|
13
|
+
*/
|
|
14
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
15
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.receiptStore = exports.ReceiptStore = void 0;
|
|
19
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const os_1 = __importDefault(require("os"));
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
// ─── ReceiptStore ─────────────────────────────────────────────────────────────
|
|
24
|
+
class ReceiptStore {
|
|
25
|
+
db;
|
|
26
|
+
constructor(dataDir) {
|
|
27
|
+
fs_1.default.mkdirSync(dataDir, { recursive: true });
|
|
28
|
+
this.db = new better_sqlite3_1.default(path_1.default.join(dataDir, 'receipts.db'));
|
|
29
|
+
this.db.pragma('journal_mode = WAL');
|
|
30
|
+
this.migrate();
|
|
31
|
+
}
|
|
32
|
+
migrate() {
|
|
33
|
+
this.db.exec(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS receipts (
|
|
35
|
+
receipt_id TEXT PRIMARY KEY,
|
|
36
|
+
intent_id TEXT NOT NULL,
|
|
37
|
+
tenant_id TEXT NOT NULL,
|
|
38
|
+
decision TEXT NOT NULL,
|
|
39
|
+
action_class TEXT NOT NULL,
|
|
40
|
+
risk_level TEXT NOT NULL,
|
|
41
|
+
action_type TEXT NOT NULL,
|
|
42
|
+
action_desc TEXT NOT NULL,
|
|
43
|
+
oid_hex TEXT NOT NULL,
|
|
44
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
45
|
+
pts_tier INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
mode TEXT NOT NULL DEFAULT 'observe',
|
|
48
|
+
signature TEXT NOT NULL DEFAULT '',
|
|
49
|
+
signer_key_id TEXT NOT NULL DEFAULT '',
|
|
50
|
+
policy_refs TEXT NOT NULL DEFAULT '[]',
|
|
51
|
+
recorded_at INTEGER NOT NULL
|
|
52
|
+
);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_receipts_tenant
|
|
54
|
+
ON receipts(tenant_id, recorded_at DESC);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_receipts_decision
|
|
56
|
+
ON receipts(tenant_id, decision, recorded_at DESC);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS action_log (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
tenant_id TEXT NOT NULL,
|
|
61
|
+
action_type TEXT NOT NULL,
|
|
62
|
+
risk_level TEXT NOT NULL,
|
|
63
|
+
decision TEXT NOT NULL,
|
|
64
|
+
receipt_id TEXT NOT NULL,
|
|
65
|
+
oid_hex TEXT NOT NULL,
|
|
66
|
+
savings_usd REAL NOT NULL DEFAULT 0,
|
|
67
|
+
recorded_at INTEGER NOT NULL
|
|
68
|
+
);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_action_log_tenant
|
|
70
|
+
ON action_log(tenant_id, recorded_at DESC);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS policy_suggestions (
|
|
73
|
+
tenant_id TEXT NOT NULL,
|
|
74
|
+
action_type TEXT NOT NULL,
|
|
75
|
+
suggested_class TEXT NOT NULL,
|
|
76
|
+
confidence REAL NOT NULL DEFAULT 0,
|
|
77
|
+
sample_count INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
example_desc TEXT NOT NULL DEFAULT '',
|
|
79
|
+
last_seen INTEGER NOT NULL,
|
|
80
|
+
PRIMARY KEY (tenant_id, action_type)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS human_approvals (
|
|
84
|
+
approval_id TEXT PRIMARY KEY,
|
|
85
|
+
receipt_id TEXT NOT NULL,
|
|
86
|
+
tenant_id TEXT NOT NULL,
|
|
87
|
+
approved_by TEXT NOT NULL,
|
|
88
|
+
note TEXT NOT NULL DEFAULT '',
|
|
89
|
+
expires_at INTEGER,
|
|
90
|
+
approved_at INTEGER NOT NULL
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_approvals_receipt
|
|
93
|
+
ON human_approvals(receipt_id);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_approvals_tenant
|
|
95
|
+
ON human_approvals(tenant_id, approved_at DESC);
|
|
96
|
+
`);
|
|
97
|
+
// Idempotent migrations for columns added after initial schema
|
|
98
|
+
try {
|
|
99
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN parent_receipt_id TEXT`);
|
|
100
|
+
}
|
|
101
|
+
catch { /* already exists */ }
|
|
102
|
+
// Sprint 1 (2026-05-19) — informational SRAID OID + manifest hash
|
|
103
|
+
try {
|
|
104
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN sraid_oid_hex TEXT`);
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
try {
|
|
108
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN gateway_manifest_sha256 TEXT`);
|
|
109
|
+
}
|
|
110
|
+
catch { }
|
|
111
|
+
try {
|
|
112
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN degraded INTEGER`);
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
115
|
+
try {
|
|
116
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN degraded_reason TEXT`);
|
|
117
|
+
}
|
|
118
|
+
catch { }
|
|
119
|
+
// Sprint 3 (2026-05-19) — anchor metadata. Receipts get a leaf_hash
|
|
120
|
+
// computed at signing time; the anchor worker batches unanchored
|
|
121
|
+
// receipts every 10 min, builds a Merkle root, and writes back
|
|
122
|
+
// batch_id + merkle_proof_json + anchor_* columns.
|
|
123
|
+
try {
|
|
124
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN leaf_hash TEXT`);
|
|
125
|
+
}
|
|
126
|
+
catch { }
|
|
127
|
+
try {
|
|
128
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN batch_id TEXT`);
|
|
129
|
+
}
|
|
130
|
+
catch { }
|
|
131
|
+
try {
|
|
132
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN merkle_proof_json TEXT`);
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
try {
|
|
136
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN anchor_status TEXT`);
|
|
137
|
+
}
|
|
138
|
+
catch { } // 'pending' | 'submitted' | 'confirmed' | 'failed'
|
|
139
|
+
try {
|
|
140
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN anchored_at INTEGER`);
|
|
141
|
+
}
|
|
142
|
+
catch { }
|
|
143
|
+
try {
|
|
144
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_receipts_unanchored ON receipts(anchor_status, recorded_at) WHERE anchor_status IS NULL OR anchor_status = 'pending'`);
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
// Compliance pack tagging (2026-05-19) — receipts carry a JSON array
|
|
148
|
+
// of compliance-subcategory tags (e.g. ['NIST.MEASURE-2.6',
|
|
149
|
+
// 'NIST.MANAGE-4.1', 'EU.Article12']). Auditors filter receipts by
|
|
150
|
+
// tag to assemble per-framework evidence bundles. Derivation rules
|
|
151
|
+
// live in src/compliance-tagger.ts.
|
|
152
|
+
try {
|
|
153
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN compliance_tags TEXT`);
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
// Sprint 11 (2026-05-20) — post-quantum hybrid signature. Receipts
|
|
157
|
+
// produced from this version forward carry BOTH the Ed25519
|
|
158
|
+
// signature (signature column above) AND an ML-DSA-65 (FIPS 204)
|
|
159
|
+
// signature in this column, over the same canonical bytes. NULL on
|
|
160
|
+
// legacy receipts; the /verify endpoint falls back to Ed25519-only
|
|
161
|
+
// verification when this is NULL.
|
|
162
|
+
try {
|
|
163
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN ml_dsa_signature TEXT`);
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
// T14 (#274) — Authority context columns. All optional; NULL when the
|
|
167
|
+
// gateway's direct-inference path ran without an GAP grant, GAP invocation,
|
|
168
|
+
// or matched risk-policy rule. When present, these values are included in
|
|
169
|
+
// the SIGNED canonical payload so tampering any authority field is
|
|
170
|
+
// cryptographically detectable.
|
|
171
|
+
try {
|
|
172
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN grant_oid TEXT`);
|
|
173
|
+
}
|
|
174
|
+
catch { }
|
|
175
|
+
try {
|
|
176
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN intent_oid TEXT`);
|
|
177
|
+
}
|
|
178
|
+
catch { }
|
|
179
|
+
try {
|
|
180
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN rule_id TEXT`);
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
try {
|
|
184
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN subject_oid TEXT`);
|
|
185
|
+
}
|
|
186
|
+
catch { }
|
|
187
|
+
// #101 (Step 1) -- four governance-sensing fields. All signed.
|
|
188
|
+
// session_id: GAP actor_session_id; null on direct-inference path.
|
|
189
|
+
// policy_versions: JSON array of {id,version} tuples for active packs.
|
|
190
|
+
// action_payload_hash: SHA-256 hex of action payload; null when absent.
|
|
191
|
+
// sequence: per-tenant monotonic counter from tenant_sequences table.
|
|
192
|
+
try {
|
|
193
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN session_id TEXT`);
|
|
194
|
+
}
|
|
195
|
+
catch { }
|
|
196
|
+
try {
|
|
197
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN policy_versions TEXT`);
|
|
198
|
+
}
|
|
199
|
+
catch { }
|
|
200
|
+
try {
|
|
201
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN action_payload_hash TEXT`);
|
|
202
|
+
}
|
|
203
|
+
catch { }
|
|
204
|
+
try {
|
|
205
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN sequence INTEGER`);
|
|
206
|
+
}
|
|
207
|
+
catch { }
|
|
208
|
+
// #124 (Step 4) -- authorized-axis correlation key fields. All signed.
|
|
209
|
+
// target_id: v0 = pack_id for gateway policy packs; null on direct-inference.
|
|
210
|
+
// target_class: category of authorized target; null on direct-inference.
|
|
211
|
+
// state_sha256: SHA-256 of canonicalPolicyState at authorization time; null in v0.
|
|
212
|
+
try {
|
|
213
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN target_id TEXT`);
|
|
214
|
+
}
|
|
215
|
+
catch { }
|
|
216
|
+
try {
|
|
217
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN target_class TEXT`);
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
try {
|
|
221
|
+
this.db.exec(`ALTER TABLE receipts ADD COLUMN state_sha256 TEXT`);
|
|
222
|
+
}
|
|
223
|
+
catch { }
|
|
224
|
+
// #124 -- index supporting getLatestAllowingReceiptForTarget.
|
|
225
|
+
// Idempotent; runs after the ADD COLUMN above.
|
|
226
|
+
try {
|
|
227
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_receipts_target ON receipts(tenant_id, target_id, decision, recorded_at DESC)`);
|
|
228
|
+
}
|
|
229
|
+
catch { }
|
|
230
|
+
// Sequence counter table: one row per tenant. The counter is incremented
|
|
231
|
+
// atomically inside the same SQLite write transaction as the receipt row.
|
|
232
|
+
// Using SQLite's ROWID-backed INTEGER PRIMARY KEY guarantees monotonicity
|
|
233
|
+
// within the single-writer WAL journal without external locking.
|
|
234
|
+
this.db.exec(`
|
|
235
|
+
CREATE TABLE IF NOT EXISTS tenant_sequences (
|
|
236
|
+
tenant_id TEXT PRIMARY KEY,
|
|
237
|
+
seq INTEGER NOT NULL DEFAULT 0
|
|
238
|
+
)
|
|
239
|
+
`);
|
|
240
|
+
// Anchor batch table — one row per Merkle batch.
|
|
241
|
+
this.db.exec(`
|
|
242
|
+
CREATE TABLE IF NOT EXISTS anchor_batches (
|
|
243
|
+
batch_id TEXT PRIMARY KEY, -- random 16-byte hex
|
|
244
|
+
merkle_root TEXT NOT NULL, -- 32-byte hex
|
|
245
|
+
leaf_count INTEGER NOT NULL,
|
|
246
|
+
created_at INTEGER NOT NULL,
|
|
247
|
+
ots_proof TEXT, -- OpenTimestamps OTS bytes, base64 (NULL until submitted)
|
|
248
|
+
ots_submitted_at INTEGER,
|
|
249
|
+
ots_upgraded_at INTEGER, -- when Bitcoin attestation became available
|
|
250
|
+
rekor_log_id TEXT, -- sigstore Rekor log entry id (future)
|
|
251
|
+
rekor_log_index INTEGER,
|
|
252
|
+
rekor_at INTEGER,
|
|
253
|
+
notes TEXT
|
|
254
|
+
);
|
|
255
|
+
CREATE INDEX IF NOT EXISTS idx_anchor_batches_created ON anchor_batches(created_at DESC);
|
|
256
|
+
`);
|
|
257
|
+
// Sprint 5 — SynOI counter-signature on each batch.
|
|
258
|
+
// Gateway submits batch_root to control plane BEFORE OpenTimestamps;
|
|
259
|
+
// SynOI cross-validates the install's integrity posture and signs a
|
|
260
|
+
// canonical bundle. Both the SynOI signature AND the OTS proof end
|
|
261
|
+
// up on the same anchor_batches row.
|
|
262
|
+
try {
|
|
263
|
+
this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_signature TEXT`);
|
|
264
|
+
}
|
|
265
|
+
catch { }
|
|
266
|
+
try {
|
|
267
|
+
this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_signer_key_id TEXT`);
|
|
268
|
+
}
|
|
269
|
+
catch { }
|
|
270
|
+
try {
|
|
271
|
+
this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_status TEXT`);
|
|
272
|
+
}
|
|
273
|
+
catch { } // 'trusted' | 'flagged'
|
|
274
|
+
try {
|
|
275
|
+
this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_reason TEXT`);
|
|
276
|
+
}
|
|
277
|
+
catch { }
|
|
278
|
+
try {
|
|
279
|
+
this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_signed_at INTEGER`);
|
|
280
|
+
}
|
|
281
|
+
catch { }
|
|
282
|
+
try {
|
|
283
|
+
this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_canonical_bundle TEXT`);
|
|
284
|
+
}
|
|
285
|
+
catch { }
|
|
286
|
+
// N16 Phase 1: human_approvals is append-only.
|
|
287
|
+
//
|
|
288
|
+
// An approval record must never be modified or deleted after it is written —
|
|
289
|
+
// the Class C gate in isApproved() trusts its presence as immutable evidence.
|
|
290
|
+
// The triggers below enforce this at the SQLite layer so the invariant holds
|
|
291
|
+
// regardless of which code path touches the table.
|
|
292
|
+
this.db.exec(`
|
|
293
|
+
CREATE TRIGGER IF NOT EXISTS trg_approvals_no_update
|
|
294
|
+
BEFORE UPDATE ON human_approvals
|
|
295
|
+
BEGIN
|
|
296
|
+
SELECT RAISE(ABORT,'human_approvals are append-only');
|
|
297
|
+
END;
|
|
298
|
+
|
|
299
|
+
CREATE TRIGGER IF NOT EXISTS trg_approvals_no_delete
|
|
300
|
+
BEFORE DELETE ON human_approvals
|
|
301
|
+
BEGIN
|
|
302
|
+
SELECT RAISE(ABORT,'human_approvals are append-only');
|
|
303
|
+
END;
|
|
304
|
+
`);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Atomically increment and return the next sequence number for a tenant.
|
|
308
|
+
* Uses an INSERT OR REPLACE so the first call for a new tenant starts at 1.
|
|
309
|
+
* Called inside putReceipt; callers never need to call this directly.
|
|
310
|
+
*/
|
|
311
|
+
nextSequence(tenant_id) {
|
|
312
|
+
this.db.prepare(`
|
|
313
|
+
INSERT INTO tenant_sequences (tenant_id, seq)
|
|
314
|
+
VALUES (@tenant_id, 1)
|
|
315
|
+
ON CONFLICT(tenant_id) DO UPDATE SET seq = seq + 1
|
|
316
|
+
`).run({ tenant_id });
|
|
317
|
+
const row = this.db.prepare(`SELECT seq FROM tenant_sequences WHERE tenant_id = ?`).get(tenant_id);
|
|
318
|
+
return row.seq;
|
|
319
|
+
}
|
|
320
|
+
putReceipt(r) {
|
|
321
|
+
// Sequence is NOT auto-assigned here. The caller is responsible for
|
|
322
|
+
// pre-fetching the sequence via nextSequence() and including it in both
|
|
323
|
+
// the signed canonical payload AND the StoredReceipt before calling
|
|
324
|
+
// putReceipt. This ensures the stored sequence always matches what was
|
|
325
|
+
// signed; silent auto-assignment here would mutate the canonical bytes
|
|
326
|
+
// after signing and break verification for legacy receipts or any path
|
|
327
|
+
// that was signed without a sequence. Only signAndStoreAnthropic (and
|
|
328
|
+
// equivalent signers for new paths) should call nextSequence.
|
|
329
|
+
const sequence = r.sequence ?? null;
|
|
330
|
+
this.db.prepare(`
|
|
331
|
+
INSERT OR REPLACE INTO receipts
|
|
332
|
+
(receipt_id, intent_id, tenant_id, decision, action_class, risk_level,
|
|
333
|
+
action_type, action_desc, oid_hex, provider, pts_tier, latency_ms,
|
|
334
|
+
mode, signature, signer_key_id, policy_refs, recorded_at, parent_receipt_id,
|
|
335
|
+
sraid_oid_hex, gateway_manifest_sha256, degraded, degraded_reason,
|
|
336
|
+
compliance_tags, ml_dsa_signature,
|
|
337
|
+
grant_oid, intent_oid, rule_id, subject_oid,
|
|
338
|
+
session_id, policy_versions, action_payload_hash, sequence,
|
|
339
|
+
target_id, target_class, state_sha256)
|
|
340
|
+
VALUES
|
|
341
|
+
(@receipt_id, @intent_id, @tenant_id, @decision, @action_class, @risk_level,
|
|
342
|
+
@action_type, @action_desc, @oid_hex, @provider, @pts_tier, @latency_ms,
|
|
343
|
+
@mode, @signature, @signer_key_id, @policy_refs, @recorded_at, @parent_receipt_id,
|
|
344
|
+
@sraid_oid_hex, @gateway_manifest_sha256, @degraded, @degraded_reason,
|
|
345
|
+
@compliance_tags, @ml_dsa_signature,
|
|
346
|
+
@grant_oid, @intent_oid, @rule_id, @subject_oid,
|
|
347
|
+
@session_id, @policy_versions, @action_payload_hash, @sequence,
|
|
348
|
+
@target_id, @target_class, @state_sha256)
|
|
349
|
+
`).run({
|
|
350
|
+
...r,
|
|
351
|
+
parent_receipt_id: r.parent_receipt_id ?? null,
|
|
352
|
+
sraid_oid_hex: r.sraid_oid_hex ?? null,
|
|
353
|
+
gateway_manifest_sha256: r.gateway_manifest_sha256 ?? null,
|
|
354
|
+
degraded: r.degraded ? 1 : 0,
|
|
355
|
+
degraded_reason: r.degraded_reason ?? null,
|
|
356
|
+
compliance_tags: r.compliance_tags ?? null,
|
|
357
|
+
ml_dsa_signature: r.ml_dsa_signature ?? null,
|
|
358
|
+
grant_oid: r.grant_oid ?? null,
|
|
359
|
+
intent_oid: r.intent_oid ?? null,
|
|
360
|
+
rule_id: r.rule_id ?? null,
|
|
361
|
+
subject_oid: r.subject_oid ?? null,
|
|
362
|
+
session_id: r.session_id ?? null,
|
|
363
|
+
policy_versions: r.policy_versions ?? null,
|
|
364
|
+
action_payload_hash: r.action_payload_hash ?? null,
|
|
365
|
+
sequence,
|
|
366
|
+
// #124 -- correlation key fields
|
|
367
|
+
target_id: r.target_id ?? null,
|
|
368
|
+
target_class: r.target_class ?? null,
|
|
369
|
+
state_sha256: r.state_sha256 ?? null,
|
|
370
|
+
});
|
|
371
|
+
this.db.prepare(`
|
|
372
|
+
INSERT INTO action_log
|
|
373
|
+
(tenant_id, action_type, risk_level, decision, receipt_id, oid_hex, recorded_at)
|
|
374
|
+
VALUES
|
|
375
|
+
(@tenant_id, @action_type, @risk_level, @decision, @receipt_id, @oid_hex, @recorded_at)
|
|
376
|
+
`).run({
|
|
377
|
+
tenant_id: r.tenant_id,
|
|
378
|
+
action_type: r.action_type,
|
|
379
|
+
risk_level: r.risk_level,
|
|
380
|
+
decision: r.decision,
|
|
381
|
+
receipt_id: r.receipt_id,
|
|
382
|
+
oid_hex: r.oid_hex,
|
|
383
|
+
recorded_at: r.recorded_at,
|
|
384
|
+
});
|
|
385
|
+
this._updateSuggestion(r);
|
|
386
|
+
// SRAID Phase 4 — mirror the signed receipt as a CDRO into an
|
|
387
|
+
// EdgeStore-backed store. Best-effort; if the mirror fails the SQLite
|
|
388
|
+
// write is already done. Lazy require avoids a circular import at boot.
|
|
389
|
+
if (r.signature) {
|
|
390
|
+
try {
|
|
391
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
392
|
+
const { mirrorReceipt } = require('./cdro-mirror');
|
|
393
|
+
mirrorReceipt({
|
|
394
|
+
receipt_id: r.receipt_id,
|
|
395
|
+
tenant_id: r.tenant_id,
|
|
396
|
+
decision: r.decision,
|
|
397
|
+
action_class: r.action_class,
|
|
398
|
+
risk_level: r.risk_level,
|
|
399
|
+
oid_hex: r.oid_hex,
|
|
400
|
+
signature: r.signature,
|
|
401
|
+
signer_key_id: r.signer_key_id,
|
|
402
|
+
recorded_at: r.recorded_at,
|
|
403
|
+
gateway_manifest_sha256: r.gateway_manifest_sha256 ?? null,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch { /* mirror is best-effort */ }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
listReceipts(tenant_id, limit = 50, offset = 0) {
|
|
410
|
+
return this.db.prepare(`
|
|
411
|
+
SELECT * FROM receipts
|
|
412
|
+
WHERE tenant_id = ?
|
|
413
|
+
ORDER BY recorded_at DESC
|
|
414
|
+
LIMIT ? OFFSET ?
|
|
415
|
+
`).all(tenant_id, limit, offset);
|
|
416
|
+
}
|
|
417
|
+
getReceipt(receipt_id) {
|
|
418
|
+
return this.db.prepare(`SELECT * FROM receipts WHERE receipt_id = ? LIMIT 1`).get(receipt_id);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* All receipts for a specific (tenant_id, session_id) pair within a time
|
|
422
|
+
* window. Used by session-observation.ts (#102) to compute windowed
|
|
423
|
+
* aggregates for the governance PIP layer.
|
|
424
|
+
*
|
|
425
|
+
* Tenant isolation is enforced at the SQL level: both tenant_id AND
|
|
426
|
+
* session_id must match. The query is bounded by the (tenant_id,
|
|
427
|
+
* recorded_at DESC) index so it never scans the full table; session_id
|
|
428
|
+
* filtering happens inside that already-indexed set.
|
|
429
|
+
*
|
|
430
|
+
* Limit of 10,000 prevents runaway memory on pathological sessions.
|
|
431
|
+
* Circuit-breaker (#103) should trip long before this limit is reached.
|
|
432
|
+
*/
|
|
433
|
+
listReceiptsForSession(tenant_id, session_id, since_ms, limit = 10_000) {
|
|
434
|
+
return this.db.prepare(`
|
|
435
|
+
SELECT * FROM receipts
|
|
436
|
+
WHERE tenant_id = ?
|
|
437
|
+
AND session_id = ?
|
|
438
|
+
AND recorded_at >= ?
|
|
439
|
+
ORDER BY recorded_at ASC
|
|
440
|
+
LIMIT ?
|
|
441
|
+
`).all(tenant_id, session_id, since_ms, limit);
|
|
442
|
+
}
|
|
443
|
+
// ── Compliance export (2026-05-19) ─────────────────────────────────────
|
|
444
|
+
/**
|
|
445
|
+
* Receipts in a tenant + time window. Used by the compliance-export
|
|
446
|
+
* pipeline. `tag_filter`, when present, filters by a single
|
|
447
|
+
* NIST/EU/ISO subcategory tag.
|
|
448
|
+
*/
|
|
449
|
+
receiptsForExport(args) {
|
|
450
|
+
if (args.tag_filter) {
|
|
451
|
+
// SQLite has no portable JSON contains operator. Tag strings are
|
|
452
|
+
// wrapped in quotes inside the JSON array; LIKE on the
|
|
453
|
+
// double-quoted-tag-substring matches without false positives.
|
|
454
|
+
const like = `%${JSON.stringify(args.tag_filter)}%`;
|
|
455
|
+
return this.db.prepare(`
|
|
456
|
+
SELECT * FROM receipts
|
|
457
|
+
WHERE tenant_id = ?
|
|
458
|
+
AND recorded_at BETWEEN ? AND ?
|
|
459
|
+
AND compliance_tags LIKE ?
|
|
460
|
+
ORDER BY recorded_at ASC
|
|
461
|
+
LIMIT ?
|
|
462
|
+
`).all(args.tenant_id, args.from_ms, args.to_ms, like, args.limit ?? 10_000);
|
|
463
|
+
}
|
|
464
|
+
return this.db.prepare(`
|
|
465
|
+
SELECT * FROM receipts
|
|
466
|
+
WHERE tenant_id = ?
|
|
467
|
+
AND recorded_at BETWEEN ? AND ?
|
|
468
|
+
ORDER BY recorded_at ASC
|
|
469
|
+
LIMIT ?
|
|
470
|
+
`).all(args.tenant_id, args.from_ms, args.to_ms, args.limit ?? 10_000);
|
|
471
|
+
}
|
|
472
|
+
/** Distinct compliance tags seen in receipts for a tenant + window. */
|
|
473
|
+
distinctTagsForTenant(tenant_id, from_ms, to_ms) {
|
|
474
|
+
const rows = this.db.prepare(`
|
|
475
|
+
SELECT DISTINCT compliance_tags
|
|
476
|
+
FROM receipts
|
|
477
|
+
WHERE tenant_id = ?
|
|
478
|
+
AND recorded_at BETWEEN ? AND ?
|
|
479
|
+
AND compliance_tags IS NOT NULL
|
|
480
|
+
`).all(tenant_id, from_ms, to_ms);
|
|
481
|
+
const set = new Set();
|
|
482
|
+
for (const r of rows) {
|
|
483
|
+
try {
|
|
484
|
+
const arr = JSON.parse(r.compliance_tags);
|
|
485
|
+
for (const t of arr)
|
|
486
|
+
set.add(t);
|
|
487
|
+
}
|
|
488
|
+
catch { /* skip malformed */ }
|
|
489
|
+
}
|
|
490
|
+
return [...set].sort();
|
|
491
|
+
}
|
|
492
|
+
// ── Sprint 3 — Anchor batch helpers ────────────────────────────────────
|
|
493
|
+
/** Receipts not yet assigned to an anchor batch. Ordered by recorded_at ASC
|
|
494
|
+
* so each batch covers a contiguous time window. */
|
|
495
|
+
getUnanchoredReceipts(limit) {
|
|
496
|
+
return this.db.prepare(`
|
|
497
|
+
SELECT * FROM receipts
|
|
498
|
+
WHERE (anchor_status IS NULL OR anchor_status = '')
|
|
499
|
+
ORDER BY recorded_at ASC
|
|
500
|
+
LIMIT ?
|
|
501
|
+
`).all(limit);
|
|
502
|
+
}
|
|
503
|
+
/** Insert an anchor batch row + mark each receipt as anchored with its
|
|
504
|
+
* Merkle proof. Atomic via SQLite transaction. */
|
|
505
|
+
recordAnchorBatch(args) {
|
|
506
|
+
const cs = args.synoi_countersignature ?? null;
|
|
507
|
+
const tx = this.db.transaction(() => {
|
|
508
|
+
this.db.prepare(`
|
|
509
|
+
INSERT INTO anchor_batches
|
|
510
|
+
(batch_id, merkle_root, leaf_count, created_at, ots_proof, ots_submitted_at,
|
|
511
|
+
rekor_log_id, rekor_log_index, rekor_at,
|
|
512
|
+
synoi_signature, synoi_signer_key_id, synoi_status, synoi_reason,
|
|
513
|
+
synoi_signed_at, synoi_canonical_bundle)
|
|
514
|
+
VALUES
|
|
515
|
+
(@batch_id, @merkle_root, @leaf_count, @created_at, @ots_proof, @ots_submitted_at,
|
|
516
|
+
@rekor_log_id, @rekor_log_index, @rekor_at,
|
|
517
|
+
@synoi_signature, @synoi_signer_key_id, @synoi_status, @synoi_reason,
|
|
518
|
+
@synoi_signed_at, @synoi_canonical_bundle)
|
|
519
|
+
`).run({
|
|
520
|
+
batch_id: args.batch_id,
|
|
521
|
+
merkle_root: args.merkle_root,
|
|
522
|
+
leaf_count: args.leaf_count,
|
|
523
|
+
created_at: args.created_at,
|
|
524
|
+
ots_proof: args.ots_proof ?? null,
|
|
525
|
+
ots_submitted_at: args.ots_submitted_at ?? null,
|
|
526
|
+
rekor_log_id: args.rekor_log_id ?? null,
|
|
527
|
+
rekor_log_index: args.rekor_log_index ?? null,
|
|
528
|
+
rekor_at: args.rekor_at ?? null,
|
|
529
|
+
synoi_signature: cs?.signature ?? null,
|
|
530
|
+
synoi_signer_key_id: cs?.signer_key_id ?? null,
|
|
531
|
+
synoi_status: cs?.status ?? null,
|
|
532
|
+
synoi_reason: cs?.reason ?? null,
|
|
533
|
+
synoi_signed_at: cs?.signed_at ?? null,
|
|
534
|
+
synoi_canonical_bundle: cs?.canonical_bundle ?? null,
|
|
535
|
+
});
|
|
536
|
+
const update = this.db.prepare(`
|
|
537
|
+
UPDATE receipts SET
|
|
538
|
+
leaf_hash = @leaf_hash,
|
|
539
|
+
batch_id = @batch_id,
|
|
540
|
+
merkle_proof_json = @proof_json,
|
|
541
|
+
anchor_status = @status,
|
|
542
|
+
anchored_at = @anchored_at
|
|
543
|
+
WHERE receipt_id = @receipt_id
|
|
544
|
+
`);
|
|
545
|
+
const status = args.ots_proof ? 'submitted' : 'pending';
|
|
546
|
+
for (const r of args.receipts) {
|
|
547
|
+
update.run({
|
|
548
|
+
batch_id: args.batch_id,
|
|
549
|
+
leaf_hash: r.leaf_hash,
|
|
550
|
+
proof_json: r.proof_json,
|
|
551
|
+
status,
|
|
552
|
+
anchored_at: args.created_at,
|
|
553
|
+
receipt_id: r.receipt_id,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
tx();
|
|
558
|
+
}
|
|
559
|
+
getAnchorBatch(batch_id) {
|
|
560
|
+
return this.db.prepare(`SELECT * FROM anchor_batches WHERE batch_id = ? LIMIT 1`).get(batch_id);
|
|
561
|
+
}
|
|
562
|
+
/** For tests + diagnostics — list recent batches. */
|
|
563
|
+
listAnchorBatches(limit = 20) {
|
|
564
|
+
return this.db.prepare(`SELECT * FROM anchor_batches ORDER BY created_at DESC LIMIT ?`).all(limit);
|
|
565
|
+
}
|
|
566
|
+
/** Count of shadow_block decisions in the last 7 days */
|
|
567
|
+
/** Total receipts written since `since_ms`, across all tenants. Used by
|
|
568
|
+
* /status as a cheap liveness probe (indexed on recorded_at). */
|
|
569
|
+
countSince(since_ms) {
|
|
570
|
+
const row = this.db.prepare(`
|
|
571
|
+
SELECT COUNT(*) AS n FROM receipts WHERE recorded_at >= ?
|
|
572
|
+
`).get(since_ms);
|
|
573
|
+
return row.n;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Count receipts for a tenant matching any of a list of action_types
|
|
577
|
+
* within a sliding window. Powers mass-read rate-limit risk rules — the
|
|
578
|
+
* defense against the NYC H+H-class mass-exfiltration pattern. Cheap:
|
|
579
|
+
* (tenant_id, recorded_at) is the natural index, action_type filter is
|
|
580
|
+
* a small IN clause.
|
|
581
|
+
*
|
|
582
|
+
* Use case:
|
|
583
|
+
* countRecentActionsByType(
|
|
584
|
+
* 'nychhc-prod',
|
|
585
|
+
* ['patient.read', 'patient.search'],
|
|
586
|
+
* Date.now() - 5 * 60_000
|
|
587
|
+
* )
|
|
588
|
+
* → 142 reads in the last 5 minutes
|
|
589
|
+
*
|
|
590
|
+
* Trigger HITL if > N. See src/rate-limit.ts.
|
|
591
|
+
*/
|
|
592
|
+
countRecentActionsByType(tenant_id, action_types, since_ms) {
|
|
593
|
+
if (action_types.length === 0)
|
|
594
|
+
return 0;
|
|
595
|
+
// SQLite parameter expansion for IN — build placeholder list.
|
|
596
|
+
const placeholders = action_types.map(() => '?').join(',');
|
|
597
|
+
const row = this.db.prepare(`
|
|
598
|
+
SELECT COUNT(*) AS n FROM receipts
|
|
599
|
+
WHERE tenant_id = ?
|
|
600
|
+
AND recorded_at >= ?
|
|
601
|
+
AND action_type IN (${placeholders})
|
|
602
|
+
AND decision IN ('allow', 'shadow_block')
|
|
603
|
+
`).get(tenant_id, since_ms, ...action_types);
|
|
604
|
+
return row.n;
|
|
605
|
+
}
|
|
606
|
+
shadowBlockCount(tenant_id) {
|
|
607
|
+
const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
608
|
+
const row = this.db.prepare(`
|
|
609
|
+
SELECT COUNT(*) as n FROM receipts
|
|
610
|
+
WHERE tenant_id = ? AND decision = 'shadow_block' AND recorded_at >= ?
|
|
611
|
+
`).get(tenant_id, since);
|
|
612
|
+
return row.n;
|
|
613
|
+
}
|
|
614
|
+
getSuggestions(tenant_id) {
|
|
615
|
+
return this.db.prepare(`
|
|
616
|
+
SELECT * FROM policy_suggestions WHERE tenant_id = ?
|
|
617
|
+
ORDER BY sample_count DESC
|
|
618
|
+
`).all(tenant_id);
|
|
619
|
+
}
|
|
620
|
+
/** Summary stats for the dashboard */
|
|
621
|
+
summary(tenant_id, since_ms) {
|
|
622
|
+
const row = this.db.prepare(`
|
|
623
|
+
SELECT
|
|
624
|
+
COUNT(*) as total,
|
|
625
|
+
SUM(CASE WHEN decision = 'allow' THEN 1 ELSE 0 END) as allows,
|
|
626
|
+
SUM(CASE WHEN decision = 'shadow_block' THEN 1 ELSE 0 END) as shadow_blocks,
|
|
627
|
+
SUM(CASE WHEN decision = 'deny' THEN 1 ELSE 0 END) as hard_blocks
|
|
628
|
+
FROM receipts
|
|
629
|
+
WHERE tenant_id = ? AND recorded_at >= ?
|
|
630
|
+
`).get(tenant_id, since_ms);
|
|
631
|
+
return {
|
|
632
|
+
total: row.total ?? 0,
|
|
633
|
+
allows: row.allows ?? 0,
|
|
634
|
+
shadow_blocks: row.shadow_blocks ?? 0,
|
|
635
|
+
hard_blocks: row.hard_blocks ?? 0,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
// ── Human approvals (Class C gate) ────────────────────────────────────────
|
|
639
|
+
/**
|
|
640
|
+
* Record that a human has approved a specific receipt_id for execution.
|
|
641
|
+
* In enforce mode the handler checks this before allowing Class C (high-risk) requests.
|
|
642
|
+
* expires_at: optional unix ms — approval expires (useful for time-boxed permits).
|
|
643
|
+
*/
|
|
644
|
+
approveReceipt(receipt_id, approved_by, note = '', expires_at) {
|
|
645
|
+
const receipt = this.getReceipt(receipt_id);
|
|
646
|
+
if (!receipt)
|
|
647
|
+
throw new Error(`Receipt not found: ${receipt_id}`);
|
|
648
|
+
const approval_id = `apv-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
649
|
+
this.db.prepare(`
|
|
650
|
+
INSERT INTO human_approvals
|
|
651
|
+
(approval_id, receipt_id, tenant_id, approved_by, note, expires_at, approved_at)
|
|
652
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
653
|
+
`).run(approval_id, receipt_id, receipt.tenant_id, approved_by, note, expires_at ?? null, Date.now());
|
|
654
|
+
return approval_id;
|
|
655
|
+
}
|
|
656
|
+
/** Returns true if the given receipt_id has a valid (non-expired) human approval. */
|
|
657
|
+
isApproved(receipt_id) {
|
|
658
|
+
const row = this.db.prepare(`
|
|
659
|
+
SELECT approval_id, expires_at FROM human_approvals
|
|
660
|
+
WHERE receipt_id = ?
|
|
661
|
+
LIMIT 1
|
|
662
|
+
`).get(receipt_id);
|
|
663
|
+
if (!row)
|
|
664
|
+
return false;
|
|
665
|
+
if (row.expires_at !== null && Date.now() > row.expires_at)
|
|
666
|
+
return false;
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
listApprovals(tenant_id, limit = 50) {
|
|
670
|
+
return this.db.prepare(`
|
|
671
|
+
SELECT ha.*, r.action_type, r.risk_level, r.action_desc
|
|
672
|
+
FROM human_approvals ha
|
|
673
|
+
JOIN receipts r ON r.receipt_id = ha.receipt_id
|
|
674
|
+
WHERE ha.tenant_id = ?
|
|
675
|
+
ORDER BY ha.approved_at DESC
|
|
676
|
+
LIMIT ?
|
|
677
|
+
`).all(tenant_id, limit);
|
|
678
|
+
}
|
|
679
|
+
// ── KB stale detection helper ─────────────────────────────────────────────
|
|
680
|
+
/**
|
|
681
|
+
* Returns the recorded_at timestamp of the most recent 'allow' receipt for
|
|
682
|
+
* a given tenant + OID, or null if no such receipt exists.
|
|
683
|
+
* Used by the Notion stale detection path to determine when a cached response
|
|
684
|
+
* was first stored, so it can be compared against KB supersession timestamps.
|
|
685
|
+
*/
|
|
686
|
+
getLatestAllowedAt(tenant_id, oid_hex) {
|
|
687
|
+
const row = this.db.prepare(`
|
|
688
|
+
SELECT MAX(recorded_at) AS latest FROM receipts
|
|
689
|
+
WHERE tenant_id = ? AND oid_hex = ? AND decision = 'allow'
|
|
690
|
+
`).get(tenant_id, oid_hex);
|
|
691
|
+
return row.latest;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Returns the receipt_id and recorded_at of the most recent 'allow' receipt
|
|
695
|
+
* for a given tenant + OID, or null if none exists.
|
|
696
|
+
*
|
|
697
|
+
* Used by the compare engine (#116) so AUTHORIZED items can carry the exact
|
|
698
|
+
* receipt id, enabling #121 revoke-and-reclassify without a full table scan.
|
|
699
|
+
* Kept separate from getLatestAllowedAt (timestamp-only) so existing callers
|
|
700
|
+
* are unaffected.
|
|
701
|
+
*/
|
|
702
|
+
getLatestAllowingReceipt(tenant_id, oid_hex) {
|
|
703
|
+
const row = this.db.prepare(`
|
|
704
|
+
SELECT receipt_id, recorded_at FROM receipts
|
|
705
|
+
WHERE tenant_id = ? AND oid_hex = ? AND decision = 'allow'
|
|
706
|
+
ORDER BY recorded_at DESC LIMIT 1
|
|
707
|
+
`).get(tenant_id, oid_hex);
|
|
708
|
+
return row ?? null;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Returns the receipt_id and recorded_at of the most recent 'allow' receipt
|
|
712
|
+
* for a given (tenant_id, target_id) pair, or null if none exists.
|
|
713
|
+
*
|
|
714
|
+
* #124 — the authorized-path receipt producer (signAndStorePolicyChange)
|
|
715
|
+
* sets target_id on every policy-change receipt. The compare engine joins on
|
|
716
|
+
* (tenant_id, target_id) via this getter so a gateway-authorized policy change
|
|
717
|
+
* classifies AUTHORIZED without requiring oid_hex identity between the
|
|
718
|
+
* producer and the sensor.
|
|
719
|
+
*
|
|
720
|
+
* Tenant isolation is enforced at the SQL level: both tenant_id AND target_id
|
|
721
|
+
* must match. The (tenant_id, target_id, decision, recorded_at DESC) index
|
|
722
|
+
* keeps this O(log n).
|
|
723
|
+
*
|
|
724
|
+
* Window check stays in compare-engine (as with getLatestAllowingReceipt)
|
|
725
|
+
* so this getter returns the newest match and the engine applies the lookback
|
|
726
|
+
* / grace bounds.
|
|
727
|
+
*/
|
|
728
|
+
getLatestAllowingReceiptForTarget(tenant_id, target_id) {
|
|
729
|
+
const row = this.db.prepare(`
|
|
730
|
+
SELECT receipt_id, recorded_at FROM receipts
|
|
731
|
+
WHERE tenant_id = ? AND target_id = ? AND decision = 'allow'
|
|
732
|
+
ORDER BY recorded_at DESC LIMIT 1
|
|
733
|
+
`).get(tenant_id, target_id);
|
|
734
|
+
return row ?? null;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* #119 FIX 4 — window-bounded in-window allow-receipt existence check.
|
|
738
|
+
*
|
|
739
|
+
* Returns the latest allow-receipt for (tenant_id, target_id) WHOSE
|
|
740
|
+
* recorded_at falls within [window_start, window_end] inclusive, or null.
|
|
741
|
+
*
|
|
742
|
+
* The window bounds are pushed into the SQL so a newer out-of-window
|
|
743
|
+
* allow-receipt cannot shadow a legitimate in-window one. The plain
|
|
744
|
+
* getLatestAllowingReceiptForTarget returns the single newest row regardless
|
|
745
|
+
* of window; the compare engine then window-checked that one row, so a newer
|
|
746
|
+
* out-of-window receipt masked an in-window receipt and the change was
|
|
747
|
+
* misclassified ORPHANED -> wrongly blocked in enforce mode.
|
|
748
|
+
*
|
|
749
|
+
* Tenant isolation: both tenant_id AND target_id must match.
|
|
750
|
+
* Uses the (tenant_id, target_id, decision, recorded_at DESC) index (line 368).
|
|
751
|
+
*/
|
|
752
|
+
getInWindowAllowingReceiptForTarget(tenant_id, target_id, window_start, window_end) {
|
|
753
|
+
const row = this.db.prepare(`
|
|
754
|
+
SELECT receipt_id, recorded_at FROM receipts
|
|
755
|
+
WHERE tenant_id = ? AND target_id = ? AND decision = 'allow'
|
|
756
|
+
AND recorded_at >= ? AND recorded_at <= ?
|
|
757
|
+
ORDER BY recorded_at DESC LIMIT 1
|
|
758
|
+
`).get(tenant_id, target_id, window_start, window_end);
|
|
759
|
+
return row ?? null;
|
|
760
|
+
}
|
|
761
|
+
// ── Auto-classification ────────────────────────────────────────────────────
|
|
762
|
+
_updateSuggestion(r) {
|
|
763
|
+
const suggested_class = riskToClass(r.risk_level);
|
|
764
|
+
const existing = this.db.prepare(`
|
|
765
|
+
SELECT * FROM policy_suggestions WHERE tenant_id = ? AND action_type = ?
|
|
766
|
+
`).get(r.tenant_id, r.action_type);
|
|
767
|
+
if (!existing) {
|
|
768
|
+
this.db.prepare(`
|
|
769
|
+
INSERT INTO policy_suggestions
|
|
770
|
+
(tenant_id, action_type, suggested_class, confidence, sample_count, example_desc, last_seen)
|
|
771
|
+
VALUES (?, ?, ?, ?, 1, ?, ?)
|
|
772
|
+
`).run(r.tenant_id, r.action_type, suggested_class, 0.6, r.action_desc, r.recorded_at);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// Bayesian-lite: confidence grows with consistent sample_count
|
|
776
|
+
const newCount = existing.sample_count + 1;
|
|
777
|
+
const sameClass = existing.suggested_class === suggested_class;
|
|
778
|
+
const newConfidence = sameClass
|
|
779
|
+
? Math.min(existing.confidence + 0.05, 0.98)
|
|
780
|
+
: Math.max(existing.confidence - 0.10, 0.40);
|
|
781
|
+
const newClass = newConfidence < 0.50 ? suggested_class : existing.suggested_class;
|
|
782
|
+
this.db.prepare(`
|
|
783
|
+
UPDATE policy_suggestions SET
|
|
784
|
+
suggested_class = ?, confidence = ?, sample_count = ?, last_seen = ?
|
|
785
|
+
WHERE tenant_id = ? AND action_type = ?
|
|
786
|
+
`).run(newClass, newConfidence, newCount, r.recorded_at, r.tenant_id, r.action_type);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
exports.ReceiptStore = ReceiptStore;
|
|
790
|
+
function riskToClass(risk) {
|
|
791
|
+
if (risk === 'high')
|
|
792
|
+
return 'C';
|
|
793
|
+
if (risk === 'medium')
|
|
794
|
+
return 'B';
|
|
795
|
+
return 'A';
|
|
796
|
+
}
|
|
797
|
+
// ─── Singleton ────────────────────────────────────────────────────────────────
|
|
798
|
+
const _dataDir = process.env.SYNOI_DATA_DIR
|
|
799
|
+
? path_1.default.join(process.env.SYNOI_DATA_DIR, '_gateway')
|
|
800
|
+
: path_1.default.join(os_1.default.homedir(), '.synoi', 'gateway', '_gateway');
|
|
801
|
+
exports.receiptStore = new ReceiptStore(_dataDir);
|