@goodandready/dsh-approval-gate 0.1.22 → 0.1.24

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/lib/ledger.js CHANGED
@@ -1,164 +1,264 @@
1
- // lib/ledger.js
2
- // Quality and Anomaly Ledger for dsh-approval-gate (#88).
3
- // Tracks session health, consecutive denials, and execution anomalies.
4
-
5
- export const MAX_LEDGER_EVENTS_PER_SESSION = 50;
6
-
7
- /**
8
- * @typedef {Object} LedgerEvent
9
- * @property {number} timestamp
10
- * @property {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
11
- * @property {string} reason
12
- * @property {string} [detail]
13
- * @property {string} [tool]
14
- */
15
-
16
- /**
17
- * @typedef {Object} SessionLedger
18
- * @property {string} sessionId
19
- * @property {number} totalDecisions
20
- * @property {number} allowedCount
21
- * @property {number} deniedCount
22
- * @property {number} askCount
23
- * @property {number} anomalyCount
24
- * @property {number} consecutiveDenies
25
- * @property {boolean} breakerTripped
26
- * @property {boolean} retryExhausted
27
- * @property {LedgerEvent[]} recentEvents
28
- */
29
-
30
- export class QualityLedger {
31
- constructor() {
32
- /** @type {Map<string, SessionLedger>} */
33
- this.sessions = new Map();
34
- }
35
-
36
- /**
37
- * @private
38
- */
39
- _getOrCreate(sessionId) {
40
- const key = sessionId || "default";
41
- let ledger = this.sessions.get(key);
42
- if (!ledger) {
43
- ledger = {
44
- sessionId: key,
45
- totalDecisions: 0,
46
- allowedCount: 0,
47
- deniedCount: 0,
48
- askCount: 0,
49
- anomalyCount: 0,
50
- consecutiveDenies: 0,
51
- breakerTripped: false,
52
- retryExhausted: false,
53
- recentEvents: [],
54
- };
55
- this.sessions.set(key, ledger);
56
- }
57
- return ledger;
58
- }
59
-
60
- /**
61
- * Record a decision or system event in the ledger.
62
- *
63
- * @param {string} sessionId
64
- * @param {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
65
- * @param {{ reason?: string, detail?: string, tool?: string }} [details]
66
- */
67
- record(sessionId, type, details = {}) {
68
- const ledger = this._getOrCreate(sessionId);
69
- const now = Date.now();
70
-
71
- const event = {
72
- timestamp: now,
73
- type,
74
- reason: details.reason || "",
75
- detail: details.detail ? String(details.detail).slice(0, 200) : undefined,
76
- tool: details.tool || "",
77
- };
78
-
79
- ledger.recentEvents.push(event);
80
- if (ledger.recentEvents.length > MAX_LEDGER_EVENTS_PER_SESSION) {
81
- ledger.recentEvents.shift();
82
- }
83
-
84
- if (type === "allow") {
85
- ledger.totalDecisions += 1;
86
- ledger.allowedCount += 1;
87
- ledger.consecutiveDenies = 0;
88
- } else if (type === "deny") {
89
- ledger.totalDecisions += 1;
90
- ledger.deniedCount += 1;
91
- ledger.consecutiveDenies += 1;
92
- if (ledger.consecutiveDenies > 1) {
93
- ledger.anomalyCount += 1;
94
- }
95
- } else if (type === "ask") {
96
- ledger.totalDecisions += 1;
97
- ledger.askCount += 1;
98
- } else if (type === "exhaustion") {
99
- ledger.retryExhausted = true;
100
- ledger.anomalyCount += 1;
101
- } else if (type === "breaker") {
102
- ledger.breakerTripped = true;
103
- ledger.anomalyCount += 1;
104
- } else if (type === "error") {
105
- ledger.anomalyCount += 1;
106
- }
107
- }
108
-
109
- /**
110
- * Returns a quality summary for the session.
111
- *
112
- * @param {string} sessionId
113
- * @returns {{
114
- * sessionId: string,
115
- * totalDecisions: number,
116
- * allowedCount: number,
117
- * deniedCount: number,
118
- * askCount: number,
119
- * anomalyCount: number,
120
- * consecutiveDenies: number,
121
- * qualityScore: number,
122
- * degradationWarning: boolean,
123
- * recentEvents: LedgerEvent[]
124
- * }}
125
- */
126
- getSummary(sessionId) {
127
- const ledger = this._getOrCreate(sessionId);
128
- const total = ledger.totalDecisions;
129
-
130
- // Quality score: 1.0 down towards 0.0 based on denial & anomaly ratio
131
- let score = 1.0;
132
- if (total > 0) {
133
- const penalty = (ledger.deniedCount * 0.2 + ledger.anomalyCount * 0.3) / total;
134
- score = Math.max(0, Math.min(1.0, 1.0 - penalty));
135
- }
136
-
137
- const degradationWarning = ledger.consecutiveDenies >= 2 ||
138
- ledger.retryExhausted ||
139
- ledger.breakerTripped ||
140
- (total >= 3 && ledger.deniedCount / total >= 0.5);
141
-
142
- return {
143
- sessionId: ledger.sessionId,
144
- totalDecisions: ledger.totalDecisions,
145
- allowedCount: ledger.allowedCount,
146
- deniedCount: ledger.deniedCount,
147
- askCount: ledger.askCount,
148
- anomalyCount: ledger.anomalyCount,
149
- consecutiveDenies: ledger.consecutiveDenies,
150
- qualityScore: Number(score.toFixed(2)),
151
- degradationWarning,
152
- recentEvents: ledger.recentEvents.map((e) => ({ ...e })),
153
- };
154
- }
155
-
156
- reset(sessionId) {
157
- const key = sessionId || "default";
158
- this.sessions.delete(key);
159
- }
160
-
161
- clear() {
162
- this.sessions.clear();
163
- }
164
- }
1
+ // lib/ledger.js
2
+ // Quality and Anomaly Ledger for dsh-approval-gate (#88, #73).
3
+ // Tracks session health, consecutive denials, and execution anomalies with schema versioning.
4
+
5
+ export const MAX_LEDGER_EVENTS_PER_SESSION = 50;
6
+
7
+ export const CURRENT_SCHEMA_VERSION = 3;
8
+ export const SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2, 3]);
9
+
10
+ /**
11
+ * Migrates a verdict record across schema versions (v1 -> v2 -> v3) (#73).
12
+ * - v1: { id, verdict, ruleId, timestamp, riskBucket }
13
+ * - v2: + { reviewDecision, bandVerdict, mitigation, auditSummary }
14
+ * - v3: + { track, dossier, driftScore, driftWarning, schema_version: 3 }
15
+ *
16
+ * @param {Record<string, any>} record
17
+ * @param {number} [targetVersion=CURRENT_SCHEMA_VERSION]
18
+ * @returns {Record<string, any>}
19
+ */
20
+ export function migrateVerdictRecord(record, targetVersion = CURRENT_SCHEMA_VERSION) {
21
+ if (!record || typeof record !== "object") return record;
22
+
23
+ let currentVersion = record.schema_version;
24
+ if (!currentVersion) {
25
+ // Infer version from fields
26
+ if (record.reviewDecision !== undefined || record.bandVerdict !== undefined) {
27
+ currentVersion = 2;
28
+ } else {
29
+ currentVersion = 1;
30
+ }
31
+ }
32
+
33
+ let migrated = { ...record };
34
+
35
+ // v1 -> v2 migration
36
+ if (currentVersion < 2 && targetVersion >= 2) {
37
+ migrated.reviewDecision = migrated.reviewDecision || migrated.verdict || "allow";
38
+ migrated.bandVerdict = migrated.bandVerdict || migrated.verdict || "allow";
39
+ migrated.mitigation = migrated.mitigation || null;
40
+ migrated.auditSummary = migrated.auditSummary || null;
41
+ migrated.schema_version = 2;
42
+ currentVersion = 2;
43
+ }
44
+
45
+ // v2 -> v3 migration
46
+ if (currentVersion < 3 && targetVersion >= 3) {
47
+ migrated.track = migrated.track || "external";
48
+ migrated.dossier = migrated.dossier || null;
49
+ migrated.driftScore = typeof migrated.driftScore === "number" ? migrated.driftScore : 0;
50
+ migrated.driftWarning = Boolean(migrated.driftWarning);
51
+ migrated.schema_version = 3;
52
+ currentVersion = 3;
53
+ }
54
+
55
+ migrated.schema_version = currentVersion;
56
+ return migrated;
57
+ }
58
+
59
+ /**
60
+ * @typedef {Object} LedgerEvent
61
+ * @property {number} timestamp
62
+ * @property {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
63
+ * @property {string} reason
64
+ * @property {string} [detail]
65
+ * @property {string} [tool]
66
+ * @property {number} [schema_version]
67
+ */
68
+
69
+ /**
70
+ * @typedef {Object} SessionLedger
71
+ * @property {string} sessionId
72
+ * @property {number} totalDecisions
73
+ * @property {number} allowedCount
74
+ * @property {number} deniedCount
75
+ * @property {number} askCount
76
+ * @property {number} anomalyCount
77
+ * @property {number} consecutiveDenies
78
+ * @property {boolean} breakerTripped
79
+ * @property {boolean} retryExhausted
80
+ * @property {LedgerEvent[]} recentEvents
81
+ */
82
+
83
+ export class QualityLedger {
84
+ constructor() {
85
+ /** @type {Map<string, SessionLedger>} */
86
+ this.sessions = new Map();
87
+ }
88
+
89
+ /**
90
+ * @private
91
+ */
92
+ _getOrCreate(sessionId) {
93
+ const key = sessionId || "default";
94
+ let ledger = this.sessions.get(key);
95
+ if (!ledger) {
96
+ ledger = {
97
+ sessionId: key,
98
+ totalDecisions: 0,
99
+ allowedCount: 0,
100
+ deniedCount: 0,
101
+ askCount: 0,
102
+ anomalyCount: 0,
103
+ consecutiveDenies: 0,
104
+ breakerTripped: false,
105
+ retryExhausted: false,
106
+ recentEvents: [],
107
+ };
108
+ this.sessions.set(key, ledger);
109
+ }
110
+ return ledger;
111
+ }
112
+
113
+ /**
114
+ * Record a decision or system event in the ledger.
115
+ *
116
+ * @param {string} sessionId
117
+ * @param {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
118
+ * @param {{ reason?: string, detail?: string, tool?: string, track?: string, dossier?: any }} [details]
119
+ */
120
+ record(sessionId, type, details = {}) {
121
+ const ledger = this._getOrCreate(sessionId);
122
+ const now = Date.now();
123
+
124
+ const rawEvent = {
125
+ timestamp: now,
126
+ type,
127
+ reason: details.reason || "",
128
+ detail: details.detail ? String(details.detail).slice(0, 200) : undefined,
129
+ tool: details.tool || "",
130
+ track: details.track,
131
+ dossier: details.dossier,
132
+ schema_version: CURRENT_SCHEMA_VERSION,
133
+ };
134
+
135
+ const event = migrateVerdictRecord(rawEvent, CURRENT_SCHEMA_VERSION);
136
+
137
+ ledger.recentEvents.push(event);
138
+ if (ledger.recentEvents.length > MAX_LEDGER_EVENTS_PER_SESSION) {
139
+ ledger.recentEvents.shift();
140
+ }
141
+
142
+ if (type === "allow") {
143
+ ledger.totalDecisions += 1;
144
+ ledger.allowedCount += 1;
145
+ ledger.consecutiveDenies = 0;
146
+ } else if (type === "deny") {
147
+ ledger.totalDecisions += 1;
148
+ ledger.deniedCount += 1;
149
+ ledger.consecutiveDenies += 1;
150
+ if (ledger.consecutiveDenies > 1) {
151
+ ledger.anomalyCount += 1;
152
+ }
153
+ } else if (type === "ask") {
154
+ ledger.totalDecisions += 1;
155
+ ledger.askCount += 1;
156
+ } else if (type === "exhaustion") {
157
+ ledger.retryExhausted = true;
158
+ ledger.anomalyCount += 1;
159
+ } else if (type === "breaker") {
160
+ ledger.breakerTripped = true;
161
+ ledger.anomalyCount += 1;
162
+ } else if (type === "error") {
163
+ ledger.anomalyCount += 1;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Returns a quality summary for the session.
169
+ *
170
+ * @param {string} sessionId
171
+ * @returns {{
172
+ * sessionId: string,
173
+ * totalDecisions: number,
174
+ * allowedCount: number,
175
+ * deniedCount: number,
176
+ * askCount: number,
177
+ * anomalyCount: number,
178
+ * consecutiveDenies: number,
179
+ * qualityScore: number,
180
+ * degradationWarning: boolean,
181
+ * recentEvents: LedgerEvent[]
182
+ * }}
183
+ */
184
+ getSummary(sessionId) {
185
+ const ledger = this._getOrCreate(sessionId);
186
+ const total = ledger.totalDecisions;
187
+
188
+ // Quality score: 1.0 down towards 0.0 based on denial & anomaly ratio
189
+ let score = 1.0;
190
+ if (total > 0) {
191
+ const penalty = (ledger.deniedCount * 0.2 + ledger.anomalyCount * 0.3) / total;
192
+ score = Math.max(0, Math.min(1.0, 1.0 - penalty));
193
+ }
194
+
195
+ const degradationWarning = ledger.consecutiveDenies >= 2 ||
196
+ ledger.retryExhausted ||
197
+ ledger.breakerTripped ||
198
+ (total >= 3 && ledger.deniedCount / total >= 0.5);
199
+
200
+ return {
201
+ sessionId: ledger.sessionId,
202
+ totalDecisions: ledger.totalDecisions,
203
+ allowedCount: ledger.allowedCount,
204
+ deniedCount: ledger.deniedCount,
205
+ askCount: ledger.askCount,
206
+ anomalyCount: ledger.anomalyCount,
207
+ consecutiveDenies: ledger.consecutiveDenies,
208
+ qualityScore: Number(score.toFixed(2)),
209
+ degradationWarning,
210
+ recentEvents: ledger.recentEvents.map((e) => ({ ...e })),
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Serializes session ledger state with current schema version (#73).
216
+ * @param {string} sessionId
217
+ * @returns {Record<string, any>}
218
+ */
219
+ serializeSession(sessionId) {
220
+ const ledger = this._getOrCreate(sessionId);
221
+ return {
222
+ schema_version: CURRENT_SCHEMA_VERSION,
223
+ sessionId: ledger.sessionId,
224
+ totalDecisions: ledger.totalDecisions,
225
+ allowedCount: ledger.allowedCount,
226
+ deniedCount: ledger.deniedCount,
227
+ askCount: ledger.askCount,
228
+ anomalyCount: ledger.anomalyCount,
229
+ consecutiveDenies: ledger.consecutiveDenies,
230
+ breakerTripped: ledger.breakerTripped,
231
+ retryExhausted: ledger.retryExhausted,
232
+ recentEvents: ledger.recentEvents.map((e) => migrateVerdictRecord(e, CURRENT_SCHEMA_VERSION)),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Restores session ledger state from serialized data with automatic migration (#73).
238
+ * @param {Record<string, any>} data
239
+ */
240
+ deserializeSession(data) {
241
+ if (!data || !data.sessionId) return;
242
+ const ledger = this._getOrCreate(data.sessionId);
243
+ ledger.totalDecisions = data.totalDecisions || 0;
244
+ ledger.allowedCount = data.allowedCount || 0;
245
+ ledger.deniedCount = data.deniedCount || 0;
246
+ ledger.askCount = data.askCount || 0;
247
+ ledger.anomalyCount = data.anomalyCount || 0;
248
+ ledger.consecutiveDenies = data.consecutiveDenies || 0;
249
+ ledger.breakerTripped = Boolean(data.breakerTripped);
250
+ ledger.retryExhausted = Boolean(data.retryExhausted);
251
+ ledger.recentEvents = Array.isArray(data.recentEvents)
252
+ ? data.recentEvents.map((e) => migrateVerdictRecord(e, CURRENT_SCHEMA_VERSION))
253
+ : [];
254
+ }
255
+
256
+ reset(sessionId) {
257
+ const key = sessionId || "default";
258
+ this.sessions.delete(key);
259
+ }
260
+
261
+ clear() {
262
+ this.sessions.clear();
263
+ }
264
+ }
@@ -0,0 +1,134 @@
1
+ // lib/reviewer.js
2
+ // Independent Second-Model AI Reviewer with Separation of Duties (#62).
3
+ // Prevents an agent from approving its own commands by routing safety reviews to an isolated observer model.
4
+
5
+ export const SAFETY_REVIEWER_SYSTEM_PROMPT = `You are an independent security reviewer observing an autonomous AI agent.
6
+ Your sole responsibility is to protect user data, credentials, and system integrity.
7
+ The agent cannot approve its own risky actions. You must review the tool call and evidence dossier.
8
+ Deny or ask for confirmation if:
9
+ 1. Operations mutate files outside declared project boundaries.
10
+ 2. Operations attempt credential leakage, reverse shell, port scanning, or privilege escalation.
11
+ 3. Operations cause destructive mass deletion or service termination.
12
+ Always respond in JSON: {"approved": boolean, "verdict": "allow"|"deny"|"ask", "reason": string}`;
13
+
14
+ export const DEFAULT_REVIEWER_AGENT_ID = "security-reviewer-v1";
15
+
16
+ /**
17
+ * Asserts separation of duties: primary agent cannot act as its own reviewer (#62).
18
+ *
19
+ * @param {string} agentId - The agent requesting or generating the tool execution
20
+ * @param {string} reviewerId - The independent reviewer agent id
21
+ * @returns {{ valid: boolean, error?: string }}
22
+ */
23
+ export function assertSeparationOfDuties(agentId, reviewerId) {
24
+ const agent = String(agentId || "").trim();
25
+ const reviewer = String(reviewerId || "").trim();
26
+
27
+ if (agent && reviewer && agent.toLowerCase() === reviewer.toLowerCase()) {
28
+ return {
29
+ valid: false,
30
+ error: "self-approval-forbidden: primary agent cannot act as its own security reviewer (#62)",
31
+ };
32
+ }
33
+
34
+ return { valid: true };
35
+ }
36
+
37
+ /**
38
+ * Independent Security Reviewer for second-model AI validation (#62).
39
+ */
40
+ export class IndependentReviewer {
41
+ /**
42
+ * @param {{ reviewerId?: string, reviewerClient?: any, fallbackVerdict?: string }} [options]
43
+ */
44
+ constructor(options = {}) {
45
+ this.reviewerId = options.reviewerId || DEFAULT_REVIEWER_AGENT_ID;
46
+ this.reviewerClient = options.reviewerClient || null;
47
+ this.fallbackVerdict = options.fallbackVerdict || "ask";
48
+ }
49
+
50
+ /**
51
+ * Reviews an execution request with separation of duties enforcement (#62).
52
+ *
53
+ * @param {{ name: string, arguments?: any }} execution
54
+ * @param {Record<string, any>} [context]
55
+ * @returns {Promise<{
56
+ * approved: boolean,
57
+ * verdict: "allow" | "deny" | "ask",
58
+ * reason: string,
59
+ * reviewerId: string,
60
+ * separationOfDuties: boolean,
61
+ * timestamp: number
62
+ * }>}
63
+ */
64
+ async review(execution, context = {}) {
65
+ const agentId = context?.agentId || context?.agent?.id || execution?.agentId || "agent-main";
66
+ const reviewerId = context?.reviewerId || this.reviewerId;
67
+
68
+ // Enforce Separation of Duties (#62)
69
+ const sep = assertSeparationOfDuties(agentId, reviewerId);
70
+ if (!sep.valid) {
71
+ return {
72
+ approved: false,
73
+ verdict: "deny",
74
+ reason: sep.error,
75
+ reviewerId,
76
+ separationOfDuties: false,
77
+ timestamp: Date.now(),
78
+ };
79
+ }
80
+
81
+ // If no external model client is configured, return safe fallback
82
+ if (!this.reviewerClient) {
83
+ return {
84
+ approved: false,
85
+ verdict: this.fallbackVerdict,
86
+ reason: "no-independent-reviewer-model-configured",
87
+ reviewerId,
88
+ separationOfDuties: true,
89
+ timestamp: Date.now(),
90
+ };
91
+ }
92
+
93
+ try {
94
+ const payload = {
95
+ tool: execution?.name,
96
+ arguments: execution?.arguments,
97
+ dossier: context?.dossier,
98
+ track: context?.track,
99
+ riskBucket: context?.riskBucket,
100
+ };
101
+
102
+ let response = null;
103
+ if (typeof this.reviewerClient.classify === "function") {
104
+ response = await this.reviewerClient.classify(payload, {
105
+ systemPrompt: SAFETY_REVIEWER_SYSTEM_PROMPT,
106
+ });
107
+ } else if (typeof this.reviewerClient === "function") {
108
+ response = await this.reviewerClient(payload);
109
+ }
110
+
111
+ const verdict = response?.verdict === "allow" ? "allow" : (response?.verdict === "deny" ? "deny" : "ask");
112
+ const approved = verdict === "allow";
113
+
114
+ return {
115
+ approved,
116
+ verdict,
117
+ reason: response?.reason || "independent-reviewer-verdict",
118
+ reviewerId,
119
+ separationOfDuties: true,
120
+ timestamp: Date.now(),
121
+ };
122
+ } catch (err) {
123
+ // Reviewer errors fail closed
124
+ return {
125
+ approved: false,
126
+ verdict: "ask",
127
+ reason: `independent-reviewer-error: ${err?.message || err}`,
128
+ reviewerId,
129
+ separationOfDuties: true,
130
+ timestamp: Date.now(),
131
+ };
132
+ }
133
+ }
134
+ }