@codebam/dsh-thinking-auditor 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/src/ledger.mjs ADDED
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Evidence ledger: a bounded, per-session index of what the monitored model
3
+ * actually saw.
4
+ *
5
+ * The session log is the harness's model-visible source of truth. This module
6
+ * subscribes to committed `session/event`s incrementally (never rewrites or
7
+ * blocks them) and keeps the small slice an auditor needs: tool calls with
8
+ * their raw arguments, paired results/outcomes, and user messages. On resume,
9
+ * sessions seeded before the plugin mounted are backfilled from
10
+ * `session.deriveMessages()`, which is the current supported full-history read.
11
+ *
12
+ * Pure module: no dsh imports, so claim verification is testable without the
13
+ * harness closure.
14
+ *
15
+ * @module @codebam/dsh-thinking-auditor/ledger
16
+ */
17
+
18
+ /** Recursively flatten model content blocks to their text. */
19
+ export function flattenContentText(content) {
20
+ if (content === undefined || content === null) return ''
21
+ if (typeof content === 'string') return content
22
+ if (!Array.isArray(content)) return ''
23
+ const parts = []
24
+ for (const block of content) {
25
+ if (typeof block === 'string') {
26
+ parts.push(block)
27
+ continue
28
+ }
29
+ if (block === null || typeof block !== 'object') continue
30
+ switch (block.type) {
31
+ case 'text':
32
+ if (typeof block.text === 'string') parts.push(block.text)
33
+ break
34
+ case 'reasoning':
35
+ // Reasoning is never evidence for a factual claim.
36
+ break
37
+ case 'tool-result':
38
+ parts.push(flattenContentText(block.content))
39
+ break
40
+ default:
41
+ if (typeof block.text === 'string') parts.push(block.text)
42
+ break
43
+ }
44
+ }
45
+ return parts.filter((part) => part.length > 0).join('\n')
46
+ }
47
+
48
+ /** Normalize text for case/whitespace-insensitive evidence search. */
49
+ export function normalizeEvidenceText(text) {
50
+ return String(text ?? '').toLowerCase().replace(/\s+/g, ' ').trim()
51
+ }
52
+
53
+ /** Extract a non-zero exit code from rendered command output, when present. */
54
+ export function parseExitCode(text) {
55
+ const match = /\[exit code:\s*(-?\d+|null)\]/i.exec(String(text ?? ''))
56
+ if (match === null || match[1] === 'null') return undefined
57
+ const parsed = Number(match[1])
58
+ return Number.isSafeInteger(parsed) ? parsed : undefined
59
+ }
60
+
61
+ /** True when a rendered result carries a failure marker independent of `isError`. */
62
+ export function hasFailureMarker(text) {
63
+ const value = String(text ?? '')
64
+ if (/\[(?:timed out after|killed by signal|sandbox: file access denied)[^\]]*\]/i.test(value)) return true
65
+ if (/\b(?:ERROR|FAIL(?:ED)?|Traceback|command not found|no such file or directory)\b/.test(value)) return true
66
+ const exitCode = parseExitCode(value)
67
+ return exitCode !== undefined && exitCode !== 0
68
+ }
69
+
70
+ /** Cap one string at `max` characters with a deterministic suffix. */
71
+ function capText(value, max) {
72
+ const text = String(value ?? '')
73
+ if (text.length <= max) return text
74
+ const suffix = `…[truncated ${text.length - max} chars]`
75
+ if (suffix.length >= max) return text.slice(0, max)
76
+ return `${text.slice(0, max - suffix.length)}${suffix}`
77
+ }
78
+
79
+ /** Plain deep copy of a ledger record safe to expose in a view. */
80
+ function detach(record) {
81
+ return { ...record }
82
+ }
83
+
84
+ /** One session's bounded ledger. */
85
+ class SessionLedger {
86
+ constructor({ maxToolCalls, maxResultChars, maxUserMessages, maxUserChars }) {
87
+ this.maxToolCalls = maxToolCalls
88
+ this.maxResultChars = maxResultChars
89
+ this.maxUserMessages = maxUserMessages
90
+ this.maxUserChars = maxUserChars
91
+ /** @type {Map<string, object>} */
92
+ this.toolCalls = new Map()
93
+ /** @type {string[]} insertion order for eviction. */
94
+ this.order = []
95
+ /** @type {object[]} */
96
+ this.users = []
97
+ this.currentTurn = 0
98
+ this.currentStep = 0
99
+ this.lastSeq = -1
100
+ }
101
+
102
+ /** Evict the oldest tool call when the bounded window is full. */
103
+ #trimToolCalls() {
104
+ while (this.order.length > this.maxToolCalls) {
105
+ const oldest = this.order.shift()
106
+ this.toolCalls.delete(oldest)
107
+ }
108
+ }
109
+
110
+ /** Record (or extend) one tool call. */
111
+ setToolCall(input) {
112
+ const callId = String(input.callId)
113
+ let record = this.toolCalls.get(callId)
114
+ if (record === undefined) {
115
+ record = {
116
+ callId,
117
+ turn: input.turn ?? this.currentTurn,
118
+ step: input.step ?? this.currentStep,
119
+ seq: input.seq ?? this.lastSeq,
120
+ name: String(input.name ?? 'unknown'),
121
+ argsRaw: capText(input.arguments ?? '', 4000),
122
+ resultText: '',
123
+ failed: false,
124
+ isError: false,
125
+ error: undefined,
126
+ }
127
+ this.toolCalls.set(callId, record)
128
+ this.order.push(callId)
129
+ this.#trimToolCalls()
130
+ } else {
131
+ if (input.name !== undefined) record.name = String(input.name)
132
+ if (input.arguments !== undefined) record.argsRaw = capText(input.arguments, 4000)
133
+ if (input.turn !== undefined) record.turn = input.turn
134
+ if (input.step !== undefined) record.step = input.step
135
+ if (input.seq !== undefined) record.seq = input.seq
136
+ }
137
+ return record
138
+ }
139
+
140
+ /** Record one tool result against its call id. */
141
+ setToolResult(input) {
142
+ const callId = String(input.callId)
143
+ const record = this.setToolCall({
144
+ callId,
145
+ name: input.name ?? this.toolCalls.get(callId)?.name ?? 'unknown',
146
+ turn: input.turn,
147
+ step: input.step,
148
+ seq: input.seq,
149
+ })
150
+ record.resultText = capText(input.text ?? '', this.maxResultChars)
151
+ record.isError = input.isError === true
152
+ record.failed = input.isError === true || hasFailureMarker(record.resultText)
153
+ record.error = input.error
154
+ return record
155
+ }
156
+
157
+ /** Record one user-role message on the model-visible surface. */
158
+ addUser(input) {
159
+ this.users.push({
160
+ seq: input.seq ?? this.lastSeq,
161
+ turn: input.turn ?? this.currentTurn,
162
+ step: input.step ?? this.currentStep,
163
+ sourceKind: input.sourceKind ?? 'unknown',
164
+ text: capText(input.text ?? '', this.maxUserChars),
165
+ })
166
+ while (this.users.length > this.maxUserMessages) this.users.shift()
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Bounded evidence index over every session the process observes.
172
+ *
173
+ * The class is deliberately passive: it records events and answers questions;
174
+ * it never mutates a session and never appends to a log.
175
+ */
176
+ export class EvidenceLedger {
177
+ constructor(options = {}) {
178
+ this.config = {
179
+ maxToolCalls: options.maxToolCalls ?? 120,
180
+ maxResultChars: options.maxResultChars ?? 6000,
181
+ maxUserMessages: options.maxUserMessages ?? 12,
182
+ maxUserChars: options.maxUserChars ?? 4000,
183
+ }
184
+ /** @type {Map<string, SessionLedger>} */
185
+ this.sessions = new Map()
186
+ }
187
+
188
+ /** Get or lazily create one session ledger. */
189
+ #session(sessionId, create = true) {
190
+ const key = String(sessionId)
191
+ let ledger = this.sessions.get(key)
192
+ if (ledger === undefined && create) {
193
+ ledger = new SessionLedger(this.config)
194
+ this.sessions.set(key, ledger)
195
+ }
196
+ return ledger
197
+ }
198
+
199
+ /**
200
+ * Record one committed session event.
201
+ * @param {string} sessionId - owning session.
202
+ * @param {{type: string, seq: number, data: object}} event - committed event envelope.
203
+ */
204
+ onSessionEvent(sessionId, event) {
205
+ const ledger = this.#session(sessionId)
206
+ ledger.lastSeq = event.seq
207
+ const data = event.data ?? {}
208
+ switch (event.type) {
209
+ case 'turn/start':
210
+ ledger.currentTurn = data.turn ?? ledger.currentTurn + 1
211
+ ledger.currentStep = 0
212
+ break
213
+ case 'step/start':
214
+ ledger.currentTurn = data.turn ?? ledger.currentTurn
215
+ ledger.currentStep = data.step ?? ledger.currentStep
216
+ break
217
+ case 'tool/call':
218
+ ledger.setToolCall({
219
+ callId: data.callId,
220
+ name: data.name,
221
+ arguments: data.arguments,
222
+ turn: data.turn,
223
+ step: data.step,
224
+ seq: event.seq,
225
+ })
226
+ break
227
+ case 'tool/result':
228
+ ledger.setToolResult({
229
+ callId: data.message?.content?.[0]?.toolCallId,
230
+ text: flattenContentText(data.message?.content),
231
+ isError: data.message?.content?.[0]?.isError === true || data.error !== undefined,
232
+ error: data.error,
233
+ turn: data.turn,
234
+ step: data.step,
235
+ seq: event.seq,
236
+ })
237
+ break
238
+ case 'user/message':
239
+ ledger.addUser({
240
+ text: flattenContentText(data.content),
241
+ sourceKind: data.source?.kind ?? 'unknown',
242
+ seq: event.seq,
243
+ })
244
+ break
245
+ default:
246
+ break
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Backfill a session that was seeded from persistence before this plugin
252
+ * mounted. Uses `session.deriveMessages()` (current supported full read)
253
+ * rather than the deprecated synchronous event-range reads.
254
+ * @param {string} sessionId - owning session.
255
+ * @param {Array<object>} messages - derived, model-visible history.
256
+ */
257
+ seedFromMessages(sessionId, messages) {
258
+ const ledger = this.#session(sessionId)
259
+ for (const message of messages ?? []) {
260
+ if (message?.role === 'assistant') {
261
+ for (const block of message.content ?? []) {
262
+ if (block?.type === 'tool-call') {
263
+ ledger.setToolCall({
264
+ callId: block.id,
265
+ name: block.name,
266
+ arguments: block.arguments,
267
+ })
268
+ }
269
+ }
270
+ continue
271
+ }
272
+ if (message?.role !== 'user') continue
273
+ if (message.source?.kind === 'tool') {
274
+ const block = message.content?.[0]
275
+ ledger.setToolResult({
276
+ callId: block?.toolCallId ?? message.source.callId,
277
+ text: flattenContentText(block?.content ?? message.content),
278
+ isError: block?.isError === true,
279
+ })
280
+ continue
281
+ }
282
+ ledger.addUser({
283
+ text: flattenContentText(message.content),
284
+ sourceKind: message.source?.kind ?? 'unknown',
285
+ })
286
+ }
287
+ }
288
+
289
+ /** Drop one session's ledger. */
290
+ dropSession(sessionId) {
291
+ this.sessions.delete(String(sessionId))
292
+ }
293
+
294
+ /**
295
+ * Build a detached, read-only evidence view for one session.
296
+ *
297
+ * The view is what deterministic claim verification and prompt assembly
298
+ * consume: plain data plus `has`/`hasUser` search helpers. It never exposes
299
+ * the live ledger object.
300
+ *
301
+ * @param {string} sessionId - session to view.
302
+ * @returns {object} evidence view.
303
+ */
304
+ view(sessionId) {
305
+ const ledger = this.#session(sessionId, false)
306
+ if (ledger === undefined) {
307
+ return {
308
+ calls: [],
309
+ users: [],
310
+ currentTurn: 0,
311
+ currentStep: 0,
312
+ has: () => false,
313
+ hasUser: () => false,
314
+ callById: () => undefined,
315
+ }
316
+ }
317
+ const calls = ledger.order
318
+ .map((callId) => ledger.toolCalls.get(callId))
319
+ .filter((record) => record !== undefined)
320
+ .map((record) => ({
321
+ ...detach(record),
322
+ searchText: normalizeEvidenceText(`${record.name} ${record.argsRaw} ${record.resultText}`),
323
+ }))
324
+ const users = ledger.users.map((record) => ({
325
+ ...detach(record),
326
+ searchText: normalizeEvidenceText(record.text),
327
+ }))
328
+ const has = (needle) => {
329
+ const query = normalizeEvidenceText(needle)
330
+ if (query.length < 2) return false
331
+ return calls.some((call) => call.searchText.includes(query))
332
+ }
333
+ const hasUser = (needle) => {
334
+ const query = normalizeEvidenceText(needle)
335
+ if (query.length < 2) return false
336
+ return users.some((user) => user.searchText.includes(query))
337
+ }
338
+ return {
339
+ calls,
340
+ users,
341
+ currentTurn: ledger.currentTurn,
342
+ currentStep: ledger.currentStep,
343
+ has,
344
+ hasUser,
345
+ callById: (callId) => calls.find((call) => call.callId === String(callId)),
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Build the bounded evidence digest sent to the auditor. Recent calls and
351
+ * calls that share a focus term with the current claims are preferred; the
352
+ * result is chronological and hard-capped.
353
+ * @param {string} sessionId - session to summarize.
354
+ * @param {object} [options] - `focus` terms and `maxChars`.
355
+ * @returns {string} digest text.
356
+ */
357
+ digest(sessionId, options = {}) {
358
+ const view = this.view(sessionId)
359
+ const maxChars = options.maxChars ?? 16000
360
+ const focus = (options.focus ?? [])
361
+ .map((term) => normalizeEvidenceText(term))
362
+ .filter((term) => term.length >= 3)
363
+ const selected = new Map()
364
+ for (let index = view.calls.length - 1; index >= 0 && selected.size < 40; index -= 1) {
365
+ selected.set(view.calls[index].callId, view.calls[index])
366
+ }
367
+ if (focus.length > 0) {
368
+ for (const call of view.calls) {
369
+ if (selected.has(call.callId)) continue
370
+ if (focus.some((term) => call.searchText.includes(term))) selected.set(call.callId, call)
371
+ }
372
+ }
373
+ const ordered = [...selected.values()].sort((left, right) => left.seq - right.seq)
374
+ const lines = []
375
+ for (const call of ordered) {
376
+ lines.push(`[call ${call.callId}] ${call.name} args=${JSON.stringify(call.argsRaw)}`)
377
+ if (call.resultText.length > 0 || call.isError) {
378
+ const outcome = call.failed ? 'FAILED' : 'ok'
379
+ lines.push(`[result ${call.callId}] ${outcome}${call.error?.code ? ` code=${call.error.code}` : ''} output=${JSON.stringify(call.resultText)}`)
380
+ }
381
+ }
382
+ for (const user of view.users.slice(-4)) {
383
+ lines.push(`[user seq=${user.seq} source=${user.sourceKind}] ${JSON.stringify(user.text)}`)
384
+ }
385
+ if (lines.length === 0) return '(evidence ledger empty)'
386
+ let digest = ''
387
+ let truncated = false
388
+ for (const line of lines) {
389
+ if (digest.length + line.length + 1 > maxChars) {
390
+ truncated = true
391
+ break
392
+ }
393
+ digest += `${line}\n`
394
+ }
395
+ if (truncated) digest += '[evidence digest truncated]\n'
396
+ return digest.trimEnd()
397
+ }
398
+ }
package/src/policy.mjs ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Deterministic gate policy.
3
+ *
4
+ * The LLM auditor supplies one more opinion; this module owns escalation. It
5
+ * converts mechanical findings plus the auditor's structured response into one
6
+ * frozen `record.level`, then decides actuator actions from that level,
7
+ * trailing-window hysteresis, configured maximum tier, and the reversibility of
8
+ * the next action.
9
+ *
10
+ * Tiers:
11
+ * - T0 observe: always on; verdicts are stored, never acted on.
12
+ * - T1 verify: at `agent/turn-stopping`, steer one neutral re-verification step.
13
+ * - T2 block: at `tools/pre-execute`, ask/deny an irreversible next action.
14
+ * - T3 cancel: only when explicitly configured; cancel the active turn.
15
+ *
16
+ * Pure module: no dsh imports.
17
+ *
18
+ * @module @codebam/dsh-thinking-auditor/policy
19
+ */
20
+
21
+ /** Threat-level order used by escalation math. */
22
+ export const LEVEL_RANK = Object.freeze({ none: 0, watch: 1, verify: 2, block: 3 })
23
+
24
+ /** Status order used by deterministic merge. */
25
+ export const STATUS_RANK = Object.freeze({ supported: 0, speculative: 1, unsupported: 2, contradicted: 3 })
26
+
27
+ /** Highest threat level. */
28
+ export function maxLevel(left, right) {
29
+ return (LEVEL_RANK[left] ?? 0) >= (LEVEL_RANK[right] ?? 0) ? left : right
30
+ }
31
+
32
+ /** Number of trailing history entries at or above one level. */
33
+ export function trailingCount(history, level) {
34
+ let count = 0
35
+ for (let index = (history?.length ?? 0) - 1; index >= 0; index -= 1) {
36
+ if ((LEVEL_RANK[history[index]?.level] ?? 0) < (LEVEL_RANK[level] ?? 0)) break
37
+ count += 1
38
+ }
39
+ return count
40
+ }
41
+
42
+ /** Normalize an auditor response into a level, treating bad data as no signal. */
43
+ export function auditorLevel(llm) {
44
+ return LEVEL_RANK[llm?.level] === undefined ? 'none' : llm.level
45
+ }
46
+
47
+ /** True when the auditor itself reports a high-confidence contradiction. */
48
+ export function auditorConfirmedFabrication(llm) {
49
+ const claims = Array.isArray(llm?.claims) ? llm.claims : []
50
+ const contradiction = claims.some((claim) => claim?.status === 'contradicted' && (claim.severity === 'high' || claim.severity === 'medium'))
51
+ const fabrication = typeof llm?.signals?.fabrication === 'number' ? llm.signals.fabrication : 0
52
+ return contradiction && fabrication >= 0.5
53
+ }
54
+
55
+ /**
56
+ * Merge the mechanical summary and the auditor response into one verdict.
57
+ *
58
+ * Mechanical findings are authoritative: the auditor may add claims and raise
59
+ * concern, but it cannot clear a deterministic contradiction or a missing
60
+ * tool-call antecedent.
61
+ *
62
+ * @param {object} mechanical - output of `summarizeFindings`.
63
+ * @param {object} llm - normalized auditor response.
64
+ * @returns {{ level: string, confirmedFabrication: boolean, auditorConfirmed: boolean, reasons: string[] }}
65
+ */
66
+ export function mergeVerdict(mechanical, llm) {
67
+ const mechanicalLevel = mechanical?.level ?? 'none'
68
+ const llmLevel = auditorLevel(llm)
69
+ const confirmed = mechanical?.confirmedFabrication === true || auditorConfirmedFabrication(llm)
70
+ const reasons = [...(mechanical?.reasons ?? [])]
71
+ if (llmLevel !== 'none') reasons.push(`auditor reported ${llmLevel}${llm?.summary ? `: ${llm.summary}` : ''}`)
72
+ return {
73
+ level: maxLevel(mechanicalLevel, llmLevel),
74
+ confirmedFabrication: confirmed,
75
+ auditorConfirmed: auditorConfirmedFabrication(llm),
76
+ reasons,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Decide the turn-boundary action (T1/T3).
82
+ *
83
+ * Verify needs `max(1, consecutive - 1)` trailing windows at verify+; block
84
+ * and cancel need `consecutive`; cancel additionally requires a confirmed
85
+ * fabrication and an explicit `maxTier: cancel` + `cancelOn: confirmed`.
86
+ *
87
+ * @param {object} input - record, history, resolved config, and per-turn flags.
88
+ * @returns {{ action: 'observe'|'verify'|'cancel', reason: string, tier: number }}
89
+ */
90
+ export function decideTurnAction({ record, history = [], config, alreadyVerified = false, alreadyCancelled = false }) {
91
+ const gates = config?.gates ?? {}
92
+ if (!record) return { action: 'observe', reason: 'no verdict', tier: 0 }
93
+ const consecutive = Math.max(1, gates.consecutive ?? 1)
94
+ const verifyRequired = Math.max(1, consecutive - 1)
95
+ const blockRequired = Math.max(1, consecutive)
96
+ const cancelRequired = Math.max(blockRequired, 2)
97
+
98
+ if (
99
+ gates.maxTier === 'cancel'
100
+ && gates.cancelOn === 'confirmed'
101
+ && record.confirmedFabrication === true
102
+ && trailingCount(history, 'block') >= cancelRequired
103
+ && !alreadyCancelled
104
+ ) {
105
+ return {
106
+ action: 'cancel',
107
+ tier: 3,
108
+ reason: 'confirmed fabrication survived verification and the turn is about to close',
109
+ }
110
+ }
111
+
112
+ const verifyEnabled = gates.maxTier === 'verify' || gates.maxTier === 'block' || gates.maxTier === 'cancel'
113
+ if (
114
+ verifyEnabled
115
+ && (LEVEL_RANK[record.level] ?? 0) >= LEVEL_RANK.verify
116
+ && trailingCount(history, 'verify') >= verifyRequired
117
+ && !alreadyVerified
118
+ ) {
119
+ return {
120
+ action: 'verify',
121
+ tier: 1,
122
+ reason: record.confirmedFabrication
123
+ ? 'confirmed fabrication is about to close the turn'
124
+ : 'unsupported claims need a verification step before the turn closes',
125
+ }
126
+ }
127
+ return { action: 'observe', reason: `verdict ${record.level} below the active gate`, tier: 0 }
128
+ }
129
+
130
+ /**
131
+ * Decide the pre-execute action (T2) for the next tool call.
132
+ *
133
+ * T2 requires `maxTier: block|cancel`, an irreversible next action, a block
134
+ * verdict, and hysteresis: a confirmed fabrication needs one fewer window than
135
+ * an unconfirmed block. An in-flight audit at an irreversible boundary can be
136
+ * gated fail-closed when `askOnTimeoutForIrreversible` is on and no verdict has
137
+ * arrived.
138
+ *
139
+ * @param {object} input - record, history, config, stakes, and audit-pending flag.
140
+ * @returns {{ action: 'allow'|'ask'|'deny', reason: string, tier: number }}
141
+ */
142
+ export function decideToolAction({ record, history = [], config, stakes, auditPending = false }) {
143
+ const gates = config?.gates ?? {}
144
+ if (gates.blockOn === 'never') {
145
+ return { action: 'allow', reason: 'T2 disabled by blockOn', tier: 0 }
146
+ }
147
+ if (gates.maxTier !== 'block' && gates.maxTier !== 'cancel') {
148
+ return { action: 'allow', reason: 'T2 disabled by maxTier', tier: 0 }
149
+ }
150
+ if (!stakes || stakes.kind !== 'irreversible') {
151
+ return { action: 'allow', reason: `stakes are ${stakes?.kind ?? 'unknown'}; T2 targets irreversible actions only`, tier: 0 }
152
+ }
153
+ if (!record) {
154
+ if (auditPending && gates.askOnTimeoutForIrreversible === true) {
155
+ return { action: 'ask', tier: 2, reason: 'no verdict yet for an irreversible action while an audit is in flight' }
156
+ }
157
+ return { action: 'allow', reason: 'no verdict yet', tier: 0 }
158
+ }
159
+ if ((LEVEL_RANK[record.level] ?? 0) < LEVEL_RANK.block) {
160
+ // A fresh window is still being audited; an irreversible action should not
161
+ // race the verdict when the deployment opted into fail-closed handles.
162
+ if (auditPending && gates.askOnTimeoutForIrreversible === true) {
163
+ return { action: 'ask', tier: 2, reason: `verdict ${record.level} but a fresh audit is still in flight for an irreversible action` }
164
+ }
165
+ return { action: 'allow', reason: `verdict ${record.level} below block`, tier: 0 }
166
+ }
167
+ const consecutive = Math.max(1, gates.consecutive ?? 1)
168
+ const required = record.confirmedFabrication === true ? Math.max(1, consecutive - 1) : consecutive
169
+ if (trailingCount(history, 'block') < required) {
170
+ return { action: 'allow', reason: `only ${trailingCount(history, 'block')}/${required} block windows`, tier: 0 }
171
+ }
172
+ if (gates.t2Mode === 'deny') {
173
+ return { action: 'deny', tier: 2, reason: 'blocked by a confirmed untrusted-claims gate' }
174
+ }
175
+ return { action: 'ask', tier: 2, reason: 'irreversible action needs approval while claims are unresolved' }
176
+ }
177
+
178
+ /**
179
+ * Append one verdict record to a bounded trailing history used for hysteresis.
180
+ * @param {object[]} history - mutable trailing history.
181
+ * @param {object} record - the verdict just committed.
182
+ * @param {number} [cap] - maximum trailing entries.
183
+ * @returns {object[]} the same array.
184
+ */
185
+ export function pushHistory(history, record, cap = 12) {
186
+ history.push({ level: record.level, at: record.at, confirmedFabrication: record.confirmedFabrication, id: record.id })
187
+ while (history.length > cap) history.shift()
188
+ return history
189
+ }
package/src/stakes.mjs ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Reversibility classification for the next tool action.
3
+ *
4
+ * T2's whole point is that the right response depends on the next action, not
5
+ * on certainty about intent: an irreversible action gets gated, a reversible
6
+ * one continues. This module classifies only the action; it never decides.
7
+ *
8
+ * Pure module: no dsh imports.
9
+ *
10
+ * @module @codebam/dsh-thinking-auditor/stakes
11
+ */
12
+
13
+ const IRREVERSIBLE_COMMAND_PATTERNS = Object.freeze([
14
+ { re: /\brm\s+-(?:[a-zA-Z]*[rR][a-zA-Z]*[fF]|[a-zA-Z]*[fF][a-zA-Z]*[rR])/, why: 'recursive/forced removal' },
15
+ { re: /\bgit\s+push\b/, why: 'push to a remote' },
16
+ { re: /\b(?:npm|pnpm|yarn|bun)\s+(?:publish|unpublish|deprecate)\b/, why: 'package publication' },
17
+ { re: /\bgh\s+(?:api|pr|release|repo|workflow|gist)\b/, why: 'GitHub mutation' },
18
+ { re: /\bcurl\b[\s\S]*?(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--request\s+(?:POST|PUT|PATCH|DELETE)|--data(?:-raw|-binary)?\b|-d\s|--upload-file\b|-T\s)/i, why: 'network write' },
19
+ { re: /\bwget\b[\s\S]*?(?:--post-data|--post-file|--method=POST)/i, why: 'network write' },
20
+ { re: /\b(?:scp|sftp|rsync)\b/, why: 'remote file transfer' },
21
+ { re: /\bnixos-rebuild\s+(?:switch|boot|test)\b/, why: 'system generation activation' },
22
+ { re: /\bhome-manager\s+switch\b/, why: 'user generation activation' },
23
+ { re: /\bnix\s+profile\s+install\b|\bnix-env\s+-i\b/, why: 'imperative profile write' },
24
+ { re: /\bsystemctl\s+(?:start|stop|restart|enable|disable|daemon-reload|mask)\b/, why: 'service mutation' },
25
+ { re: /\b(?:kill|pkill|killall)\b/, why: 'process termination' },
26
+ { re: /\bmkfs(?:\.\w+)?\b|\bdd\s+(?:if|of)=|\btruncate\b/, why: 'destructive disk operation' },
27
+ { re: /\bsudo\b|\bdoas\b/, why: 'privilege escalation' },
28
+ { re: /\bchmod\s+-R\b|\bchown\s+-R\b/, why: 'recursive ownership/permission change' },
29
+ { re: /(?:^|[^|])(?:>>?|(?:^|\s)tee\s+(?:-a\s+)?)\s*[^\s|>]/, why: 'shell output redirection/write' },
30
+ { re: /\bgit\s+(?:reset\s+--hard|clean\s+-[a-zA-Z]*f|checkout\s+--\s+\.)\b/, why: 'destructive git operation' },
31
+ { re: /\b(?:shutdown|reboot|poweroff)\b/, why: 'machine power state' },
32
+ { re: /\b(?:crontab\s+-r|at\s+now)\b/, why: 'scheduled job mutation' },
33
+ { re: /\bpython[0-9.]*\s+-(?:c|-e)\b[\s\S]*?(?:requests\.(?:post|put|delete|patch)|urlopen|socket\.)/i, why: 'scripted network write' },
34
+ { re: /\bnode\s+-(?:e|p)\b[\s\S]*?(?:fetch\(|https?\.request|axios\.(?:post|put|delete))/i, why: 'scripted network write' },
35
+ ])
36
+
37
+ const REVERSIBLE_MUTATION_TOOL_RE = /\b(?:write|write_file|edit|edit_file|str_replace|replace|apply_patch|create|mkdir|touch|save|move|copy|rename|bash|nu|pwsh|shell|exec|run|terminal)\b/i
38
+
39
+ const READ_ONLY_COMMAND_RE = /^\s*(?:ls|pwd|cat|head|tail|wc|file|stat|find|rg|grep|sed\s+-n|awk|view|open|show|read|git\s+(?:status|log|diff|show|branch|remote\s+-v)|nix\s+(?:eval|build|flake\s+show|path-info|why-depends)|npm\s+(?:ls|view)|node\s+--version|which|type|env|printenv|date|echo)\b/
40
+
41
+ /** Recursively collect short string arguments from parsed tool arguments. */
42
+ export function collectArgumentStrings(value, depth = 0, out = []) {
43
+ if (depth > 4 || out.length > 40) return out
44
+ if (typeof value === 'string') {
45
+ if (value.length > 0 && value.length <= 8000) out.push(value)
46
+ return out
47
+ }
48
+ if (Array.isArray(value)) {
49
+ for (const item of value) collectArgumentStrings(item, depth + 1, out)
50
+ return out
51
+ }
52
+ if (value !== null && typeof value === 'object') {
53
+ for (const [key, nested] of Object.entries(value)) {
54
+ if (key === 'description' || key === 'justification') continue
55
+ collectArgumentStrings(nested, depth + 1, out)
56
+ }
57
+ }
58
+ return out
59
+ }
60
+
61
+ /** True when a string is plausibly a command or shell snippet worth classifying. */
62
+ function looksLikeCommand(value) {
63
+ const text = String(value ?? '')
64
+ if (text.trim().length === 0) return false
65
+ if (/[\s|;&$(){}[\]<>]/.test(text)) return true
66
+ if (READ_ONLY_COMMAND_RE.test(text)) return true
67
+ return /^(?:git|npm|pnpm|yarn|nix|docker|podman|curl|wget|ssh|scp|rsync|rm|mv|cp|sed|awk|python|node|bash|sh|nu|pwsh|powershell)\b/.test(text)
68
+ }
69
+
70
+ /**
71
+ * Classify one pending tool call.
72
+ * @param {string} toolName - registry tool name.
73
+ * @param {unknown} args - parsed arguments (the loop's JSON value).
74
+ * @returns {{ kind: 'read-only'|'mutating'|'irreversible'|'unknown', reason: string, command?: string }}
75
+ */
76
+ export function classifyAction(toolName, args) {
77
+ const name = String(toolName ?? '').toLowerCase()
78
+ const strings = collectArgumentStrings(args)
79
+
80
+ for (const value of strings) {
81
+ for (const pattern of IRREVERSIBLE_COMMAND_PATTERNS) {
82
+ if (pattern.re.test(value)) {
83
+ return { kind: 'irreversible', reason: pattern.why, command: value.slice(0, 800) }
84
+ }
85
+ }
86
+ }
87
+
88
+ const commandCandidates = strings.filter(looksLikeCommand)
89
+ const firstCandidate = commandCandidates[0] ?? strings[0] ?? ''
90
+ const allReadOnly = commandCandidates.length > 0 && commandCandidates.every((value) => READ_ONLY_COMMAND_RE.test(value))
91
+
92
+ // A write/edit tool is mutating even when the payload has no command string;
93
+ // its explicit view/read mode is the one read-only exception.
94
+ if (REVERSIBLE_MUTATION_TOOL_RE.test(name)) {
95
+ if (allReadOnly) return { kind: 'read-only', reason: 'read-only invocation of a mutating tool', command: firstCandidate.slice(0, 800) }
96
+ return { kind: 'mutating', reason: 'file/system mutation that can be reviewed or reverted', command: firstCandidate.slice(0, 800) }
97
+ }
98
+
99
+ const executor = /^(?:bash|nu|pwsh|powershell|shell|exec|run|process|subprocess|terminal|command|run_code)$/.test(name)
100
+ if (commandCandidates.length === 0) return { kind: 'unknown', reason: 'no command-like arguments to classify' }
101
+ if (allReadOnly) return { kind: 'read-only', reason: 'read-only command string', command: firstCandidate.slice(0, 800) }
102
+ if (executor) return { kind: 'mutating', reason: 'executor command with no irreversible pattern and no read-only proof', command: firstCandidate.slice(0, 800) }
103
+ return { kind: 'unknown', reason: 'no irreversible pattern matched', command: firstCandidate.slice(0, 800) }
104
+ }