@goodandready/dsh-approval-gate 0.1.21 → 0.1.23

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
+ }
package/lib/lineage.js ADDED
@@ -0,0 +1,182 @@
1
+ // lib/lineage.js
2
+ // Subagent hierarchy and parentSession lineage authority (#98).
3
+ // Tracks session ancestry trees and propagates authority/policies down the lineage chain.
4
+
5
+ export class LineageManager {
6
+ constructor() {
7
+ /** @type {Map<string, string>} childSessionId -> parentSessionId */
8
+ this.parentMap = new Map();
9
+ /** @type {Map<string, string>} childSessionId -> explicit rootSessionId */
10
+ this.rootMap = new Map();
11
+ /** @type {Map<string, Set<string>>} parentSessionId -> Set of childSessionIds */
12
+ this.childrenMap = new Map();
13
+ }
14
+
15
+ /**
16
+ * Registers parent-child session relationship.
17
+ * Prevents circular parent-child references.
18
+ *
19
+ * @param {string} childSessionId
20
+ * @param {string} parentSessionId
21
+ * @param {string} [rootSessionId]
22
+ * @returns {boolean} True if successfully registered, false if invalid or circular
23
+ */
24
+ registerLineage(childSessionId, parentSessionId, rootSessionId = null) {
25
+ if (!childSessionId || !parentSessionId) return false;
26
+ if (childSessionId === parentSessionId) return false;
27
+
28
+ // Check circular references: walking upwards from parent must not reach child
29
+ let curr = parentSessionId;
30
+ const visited = new Set([childSessionId]);
31
+ while (curr) {
32
+ if (visited.has(curr)) return false;
33
+ visited.add(curr);
34
+ curr = this.parentMap.get(curr);
35
+ }
36
+
37
+ this.parentMap.set(childSessionId, parentSessionId);
38
+ if (rootSessionId && rootSessionId !== childSessionId) {
39
+ this.rootMap.set(childSessionId, rootSessionId);
40
+ }
41
+
42
+ if (!this.childrenMap.has(parentSessionId)) {
43
+ this.childrenMap.set(parentSessionId, new Set());
44
+ }
45
+ this.childrenMap.get(parentSessionId).add(childSessionId);
46
+
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Retrieves the direct parent session ID for a session.
52
+ * @param {string} sessionId
53
+ * @returns {string|null}
54
+ */
55
+ getParent(sessionId) {
56
+ if (!sessionId) return null;
57
+ return this.parentMap.get(sessionId) || null;
58
+ }
59
+
60
+ /**
61
+ * Retrieves the ultimate root session ID for a session.
62
+ * @param {string} sessionId
63
+ * @returns {string}
64
+ */
65
+ getRoot(sessionId) {
66
+ if (!sessionId) return "";
67
+ if (this.rootMap.has(sessionId)) {
68
+ return this.rootMap.get(sessionId);
69
+ }
70
+
71
+ let curr = sessionId;
72
+ const visited = new Set();
73
+ while (curr && !visited.has(curr)) {
74
+ visited.add(curr);
75
+ const parent = this.parentMap.get(curr);
76
+ if (!parent) break;
77
+ curr = parent;
78
+ }
79
+ return curr;
80
+ }
81
+
82
+ /**
83
+ * Returns ancestry array from current session up to root: [sessionId, parentId, ..., rootId].
84
+ * @param {string} sessionId
85
+ * @returns {string[]}
86
+ */
87
+ getLineage(sessionId) {
88
+ if (!sessionId) return [];
89
+ const chain = [];
90
+ let curr = sessionId;
91
+ const visited = new Set();
92
+
93
+ while (curr && !visited.has(curr)) {
94
+ visited.add(curr);
95
+ chain.push(curr);
96
+ curr = this.parentMap.get(curr);
97
+ }
98
+
99
+ return chain;
100
+ }
101
+
102
+ /**
103
+ * Checks whether ancestorSessionId is an ancestor of childSessionId.
104
+ * @param {string} childSessionId
105
+ * @param {string} ancestorSessionId
106
+ * @returns {boolean}
107
+ */
108
+ hasLineageAuthority(childSessionId, ancestorSessionId) {
109
+ if (!childSessionId || !ancestorSessionId) return false;
110
+ if (childSessionId === ancestorSessionId) return true;
111
+
112
+ const lineage = this.getLineage(childSessionId);
113
+ return lineage.includes(ancestorSessionId);
114
+ }
115
+
116
+ /**
117
+ * Resolves effective policy walking up the lineage hierarchy.
118
+ * Evaluates getPolicyFn(sessionId). If undefined or null, falls back to parent, then root.
119
+ *
120
+ * @param {string} sessionId
121
+ * @param {(id: string) => any} getPolicyFn
122
+ * @returns {any}
123
+ */
124
+ resolveEffectivePolicy(sessionId, getPolicyFn) {
125
+ if (!sessionId || typeof getPolicyFn !== "function") return null;
126
+
127
+ const lineage = this.getLineage(sessionId);
128
+ for (const sid of lineage) {
129
+ try {
130
+ const policy = getPolicyFn(sid);
131
+ if (policy !== undefined && policy !== null) {
132
+ return {
133
+ policy,
134
+ sourceSessionId: sid,
135
+ inherited: sid !== sessionId,
136
+ };
137
+ }
138
+ } catch {
139
+ // Continue upward traversal on error
140
+ }
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Removes tracking for a session and unlinks from its parent.
148
+ * @param {string} sessionId
149
+ * @returns {boolean}
150
+ */
151
+ clearSession(sessionId) {
152
+ if (!sessionId) return false;
153
+ const parent = this.parentMap.get(sessionId);
154
+ if (parent && this.childrenMap.has(parent)) {
155
+ this.childrenMap.get(parent).delete(sessionId);
156
+ }
157
+ this.parentMap.delete(sessionId);
158
+ this.rootMap.delete(sessionId);
159
+ this.childrenMap.delete(sessionId);
160
+ return true;
161
+ }
162
+
163
+ /**
164
+ * Clears all lineage mappings.
165
+ */
166
+ clearAll() {
167
+ this.parentMap.clear();
168
+ this.rootMap.clear();
169
+ this.childrenMap.clear();
170
+ }
171
+
172
+ /**
173
+ * Returns current statistics for debugging and auditing.
174
+ * @returns {{ trackedSessions: number, rootSessions: number }}
175
+ */
176
+ getStats() {
177
+ return {
178
+ trackedSessions: this.parentMap.size,
179
+ parentCount: this.childrenMap.size,
180
+ };
181
+ }
182
+ }
@@ -56,6 +56,9 @@ const SYSTEM_LEVEL_TOOLS = new Set([
56
56
  */
57
57
  export function classifyMeasureKind(execution, context = {}) {
58
58
  if (!execution) return MEASURE_KINDS.LOCAL_SINGLE;
59
+ if (typeof execution === "string") {
60
+ execution = { name: "bash", arguments: { command: execution } };
61
+ }
59
62
 
60
63
  const toolName = execution.name || execution.tool || "";
61
64
  const args = execution.arguments || {};