@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/LICENSE +21 -0
- package/README.md +179 -0
- package/index.mjs +674 -0
- package/package.json +82 -0
- package/packaging/README.md +78 -0
- package/packaging/nixos/agents.nix.snippet +46 -0
- package/packaging/nixos/default.nix.snippet +6 -0
- package/packaging/nixos/dsh-thinking-auditor.nix +54 -0
- package/src/async.mjs +90 -0
- package/src/auditor.mjs +252 -0
- package/src/claims.mjs +520 -0
- package/src/config.mjs +243 -0
- package/src/file-sink.mjs +76 -0
- package/src/ledger.mjs +398 -0
- package/src/policy.mjs +189 -0
- package/src/stakes.mjs +104 -0
- package/src/state.mjs +131 -0
- package/src/store.mjs +185 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@codebam/dsh-thinking-auditor` configuration.
|
|
3
|
+
*
|
|
4
|
+
* Pure module: no dsh imports, so it is testable with plain `node --test`.
|
|
5
|
+
* The mounted Cordis `Config` schema is declarative (a configuration catalog);
|
|
6
|
+
* this module owns the one normalization path every consumer goes through,
|
|
7
|
+
* including values that arrive from the user settings layer (`thinking-audit`).
|
|
8
|
+
*
|
|
9
|
+
* Invalid configuration fails loud at `apply()` with a path-qualified error,
|
|
10
|
+
* never by silently drifting a threshold or a gate.
|
|
11
|
+
*
|
|
12
|
+
* @module @codebam/dsh-thinking-auditor/config
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Audit threat levels, ordered from least to most severe. */
|
|
16
|
+
export const AUDIT_LEVELS = Object.freeze(['none', 'watch', 'verify', 'block'])
|
|
17
|
+
|
|
18
|
+
/** Claim/evidence statuses a finding may carry. */
|
|
19
|
+
export const CLAIM_STATUSES = Object.freeze(['supported', 'unsupported', 'contradicted', 'speculative'])
|
|
20
|
+
|
|
21
|
+
/** Finding severities, ordered from least to most severe. */
|
|
22
|
+
export const SEVERITIES = Object.freeze(['low', 'medium', 'high'])
|
|
23
|
+
|
|
24
|
+
/** Maximum actuator tier a deployment may enable. */
|
|
25
|
+
export const MAX_TIERS = Object.freeze(['observe', 'verify', 'block', 'cancel'])
|
|
26
|
+
|
|
27
|
+
/** How a block-tier gate may present itself. */
|
|
28
|
+
export const TIER2_MODES = Object.freeze(['ask', 'deny'])
|
|
29
|
+
|
|
30
|
+
/** What T1 treats as sufficient deterministic evidence to force verification. */
|
|
31
|
+
export const VERIFY_ON = Object.freeze(['speculative', 'unsupported', 'contradicted', 'never'])
|
|
32
|
+
|
|
33
|
+
/** What T2 treats as sufficient deterministic evidence to gate an irreversible action. */
|
|
34
|
+
export const BLOCK_ON = Object.freeze(['never', 'contradicted', 'confirmed'])
|
|
35
|
+
|
|
36
|
+
/** What T3 treats as sufficient evidence to cancel a turn. */
|
|
37
|
+
export const CANCEL_ON = Object.freeze(['never', 'confirmed'])
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One complete, resolved configuration. Every field is required after
|
|
41
|
+
* resolution; user/entry configuration is a partial overlay.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
44
|
+
/** Master switch. `false` makes `apply()` a no-op. */
|
|
45
|
+
enabled: true,
|
|
46
|
+
/** Audit at a completed tool boundary when enough unaudited reasoning is pending. */
|
|
47
|
+
auditOnToolResult: true,
|
|
48
|
+
/** Audit the pending reasoning window before a turn is allowed to close. */
|
|
49
|
+
auditOnTurnStop: true,
|
|
50
|
+
/** Audit a short final reasoning slice when one model attempt ends. */
|
|
51
|
+
auditOnAttemptEnd: true,
|
|
52
|
+
/** Reasoning characters that force a background audit window. */
|
|
53
|
+
windowChars: 1800,
|
|
54
|
+
/** Minimum pending reasoning characters for an opportunistic audit trigger. */
|
|
55
|
+
minWindowChars: 500,
|
|
56
|
+
/** Hard cap on the trace excerpt sent to one audit; the oldest part is dropped. */
|
|
57
|
+
maxTraceChars: 12000,
|
|
58
|
+
/** Minimum spacing between background audits for one session, in milliseconds. */
|
|
59
|
+
cooldownMs: 1500,
|
|
60
|
+
/** Deadline for a background audit model call, in milliseconds. */
|
|
61
|
+
requestTimeoutMs: 20000,
|
|
62
|
+
/** Maximum time `agent/turn-stopping` will wait for an audit verdict, in milliseconds. */
|
|
63
|
+
turnStopTimeoutMs: 5000,
|
|
64
|
+
/** Maximum background audits one turn may start. */
|
|
65
|
+
maxAuditsPerTurn: 8,
|
|
66
|
+
|
|
67
|
+
auditor: Object.freeze({
|
|
68
|
+
/** Same-model route override; `null` follows the session's logged request header. */
|
|
69
|
+
provider: null,
|
|
70
|
+
/** Same-model id override; `null` follows the session's logged request header. */
|
|
71
|
+
model: null,
|
|
72
|
+
/** Maximum output tokens for one audit response. */
|
|
73
|
+
maxTokens: 1600,
|
|
74
|
+
/** Greedy decoding by default; the auditor should not be creative. */
|
|
75
|
+
temperature: 0,
|
|
76
|
+
/** Follow the session's reasoning-effort selection when the route advertises one. */
|
|
77
|
+
followReasoningEffort: true,
|
|
78
|
+
/** Hard cap on the decoded auditor response text before JSON parsing. */
|
|
79
|
+
maxResponseChars: 50000,
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
evidence: Object.freeze({
|
|
83
|
+
/** Maximum tool calls retained per session. */
|
|
84
|
+
maxToolCalls: 120,
|
|
85
|
+
/** Per-result text cap in the evidence ledger. */
|
|
86
|
+
maxResultChars: 6000,
|
|
87
|
+
/** Overall evidence digest cap sent to the auditor. */
|
|
88
|
+
maxDigestChars: 16000,
|
|
89
|
+
/** Per-user-message text cap in the evidence digest. */
|
|
90
|
+
maxUserChars: 4000,
|
|
91
|
+
}),
|
|
92
|
+
|
|
93
|
+
gates: Object.freeze({
|
|
94
|
+
/** Highest actuator tier this deployment permits: observe | verify | block | cancel. */
|
|
95
|
+
maxTier: 'verify',
|
|
96
|
+
/** Findings below this severity do not drive a gate (they remain audited). */
|
|
97
|
+
minimumSeverity: 'medium',
|
|
98
|
+
/** Lowest deterministic status that makes T1 force an extra verification step. */
|
|
99
|
+
verifyOn: 'unsupported',
|
|
100
|
+
/** What T2 treats as sufficient to gate an irreversible action. */
|
|
101
|
+
blockOn: 'confirmed',
|
|
102
|
+
/** What T3 treats as sufficient to cancel a turn. */
|
|
103
|
+
cancelOn: 'never',
|
|
104
|
+
/** Windows at `verify`+ for T1; block needs this many, cancel needs one more. */
|
|
105
|
+
consecutive: 2,
|
|
106
|
+
/** T2 presentation: `ask` routes through the approval seam, `deny` blocks outright. */
|
|
107
|
+
t2Mode: 'ask',
|
|
108
|
+
/** If an audit is in flight at an irreversible boundary and no verdict exists, ask. */
|
|
109
|
+
askOnTimeoutForIrreversible: true,
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
store: Object.freeze({
|
|
113
|
+
/** Persist verdict/action records to a private JSONL audit log beside the dsh home. */
|
|
114
|
+
persist: true,
|
|
115
|
+
/** Explicit audit directory; `null` derives from `dshHomePath` / `$DSH_HOME` / `~/.dsh`. */
|
|
116
|
+
dir: null,
|
|
117
|
+
/** Verdict records retained per session. */
|
|
118
|
+
maxRecordsPerSession: 200,
|
|
119
|
+
/** Sessions retained in the in-memory audit store. */
|
|
120
|
+
maxSessions: 200,
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const SEVERITY_RANK = Object.freeze({ low: 1, medium: 2, high: 3 })
|
|
125
|
+
|
|
126
|
+
/** True for a plain JSON-style object (not an array or null). */
|
|
127
|
+
function isPlainObject(value) {
|
|
128
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Deep-freeze a plain configuration tree in place. */
|
|
132
|
+
export function deepFreeze(value) {
|
|
133
|
+
if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value
|
|
134
|
+
for (const nested of Object.values(value)) deepFreeze(nested)
|
|
135
|
+
return Object.freeze(value)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Recursively overlay `override` on `base`, treating arrays as leaf values. */
|
|
139
|
+
function merge(base, override) {
|
|
140
|
+
if (!isPlainObject(base) || !isPlainObject(override)) {
|
|
141
|
+
return override === undefined ? base : override
|
|
142
|
+
}
|
|
143
|
+
const out = { ...base }
|
|
144
|
+
for (const [key, value] of Object.entries(override)) {
|
|
145
|
+
if (value === undefined) continue
|
|
146
|
+
out[key] = isPlainObject(base[key]) && isPlainObject(value) ? merge(base[key], value) : value
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Throw a path-qualified configuration error. */
|
|
152
|
+
function fail(path, message) {
|
|
153
|
+
throw new TypeError(`thinking-auditor: invalid config at ${path}: ${message}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Validate one integer field. */
|
|
157
|
+
function integer(config, path, { min, max } = {}) {
|
|
158
|
+
const value = path.split('.').reduce((node, key) => node?.[key], config)
|
|
159
|
+
if (!Number.isInteger(value)) fail(path, `expected an integer, got ${JSON.stringify(value)}`)
|
|
160
|
+
if (min !== undefined && value < min) fail(path, `expected >= ${min}, got ${value}`)
|
|
161
|
+
if (max !== undefined && value > max) fail(path, `expected <= ${max}, got ${value}`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Validate one numeric field. */
|
|
165
|
+
function number(config, path, { min, max } = {}) {
|
|
166
|
+
const value = path.split('.').reduce((node, key) => node?.[key], config)
|
|
167
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) fail(path, `expected a finite number, got ${JSON.stringify(value)}`)
|
|
168
|
+
if (min !== undefined && value < min) fail(path, `expected >= ${min}, got ${value}`)
|
|
169
|
+
if (max !== undefined && value > max) fail(path, `expected <= ${max}, got ${value}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Validate one enum-like field. */
|
|
173
|
+
function oneOf(config, path, allowed) {
|
|
174
|
+
const value = path.split('.').reduce((node, key) => node?.[key], config)
|
|
175
|
+
if (!allowed.includes(value)) fail(path, `expected one of ${allowed.join(', ')}, got ${JSON.stringify(value)}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Stop invalid scalar types before they reach a comparator. */
|
|
179
|
+
function validateConfig(config) {
|
|
180
|
+
for (const section of ['auditor', 'evidence', 'gates', 'store']) {
|
|
181
|
+
if (!isPlainObject(config[section])) fail(section, 'expected an object')
|
|
182
|
+
}
|
|
183
|
+
if (typeof config.enabled !== 'boolean') fail('enabled', 'expected a boolean')
|
|
184
|
+
for (const key of ['auditOnToolResult', 'auditOnTurnStop', 'auditOnAttemptEnd']) {
|
|
185
|
+
if (typeof config[key] !== 'boolean') fail(key, 'expected a boolean')
|
|
186
|
+
}
|
|
187
|
+
integer(config, 'windowChars', { min: 200 })
|
|
188
|
+
integer(config, 'minWindowChars', { min: 50 })
|
|
189
|
+
integer(config, 'maxTraceChars', { min: 200 })
|
|
190
|
+
if (config.minWindowChars > config.windowChars) fail('minWindowChars', 'must be <= windowChars')
|
|
191
|
+
if (config.maxTraceChars < config.windowChars) fail('maxTraceChars', 'must be >= windowChars')
|
|
192
|
+
integer(config, 'cooldownMs', { min: 0 })
|
|
193
|
+
integer(config, 'requestTimeoutMs', { min: 1000 })
|
|
194
|
+
integer(config, 'turnStopTimeoutMs', { min: 250 })
|
|
195
|
+
if (config.turnStopTimeoutMs > config.requestTimeoutMs) fail('turnStopTimeoutMs', 'must be <= requestTimeoutMs')
|
|
196
|
+
integer(config, 'maxAuditsPerTurn', { min: 1 })
|
|
197
|
+
|
|
198
|
+
if (config.auditor.provider !== null && typeof config.auditor.provider !== 'string') fail('auditor.provider', 'expected a string or null')
|
|
199
|
+
if (config.auditor.model !== null && typeof config.auditor.model !== 'string') fail('auditor.model', 'expected a string or null')
|
|
200
|
+
integer(config, 'auditor.maxTokens', { min: 64 })
|
|
201
|
+
number(config, 'auditor.temperature', { min: 0, max: 2 })
|
|
202
|
+
if (typeof config.auditor.followReasoningEffort !== 'boolean') fail('auditor.followReasoningEffort', 'expected a boolean')
|
|
203
|
+
integer(config, 'auditor.maxResponseChars', { min: 1000 })
|
|
204
|
+
|
|
205
|
+
integer(config, 'evidence.maxToolCalls', { min: 1 })
|
|
206
|
+
integer(config, 'evidence.maxResultChars', { min: 256 })
|
|
207
|
+
integer(config, 'evidence.maxDigestChars', { min: 512 })
|
|
208
|
+
integer(config, 'evidence.maxUserChars', { min: 128 })
|
|
209
|
+
|
|
210
|
+
oneOf(config, 'gates.maxTier', MAX_TIERS)
|
|
211
|
+
oneOf(config, 'gates.minimumSeverity', SEVERITIES)
|
|
212
|
+
oneOf(config, 'gates.verifyOn', VERIFY_ON)
|
|
213
|
+
oneOf(config, 'gates.blockOn', BLOCK_ON)
|
|
214
|
+
oneOf(config, 'gates.cancelOn', CANCEL_ON)
|
|
215
|
+
integer(config, 'gates.consecutive', { min: 1, max: 12 })
|
|
216
|
+
oneOf(config, 'gates.t2Mode', TIER2_MODES)
|
|
217
|
+
if (typeof config.gates.askOnTimeoutForIrreversible !== 'boolean') fail('gates.askOnTimeoutForIrreversible', 'expected a boolean')
|
|
218
|
+
|
|
219
|
+
if (typeof config.store.persist !== 'boolean') fail('store.persist', 'expected a boolean')
|
|
220
|
+
if (config.store.dir !== null && (typeof config.store.dir !== 'string' || config.store.dir.trim().length === 0)) fail('store.dir', 'expected a non-empty string or null')
|
|
221
|
+
integer(config, 'store.maxRecordsPerSession', { min: 1 })
|
|
222
|
+
integer(config, 'store.maxSessions', { min: 1 })
|
|
223
|
+
return config
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve entry configuration plus an optional user-settings overlay onto the
|
|
228
|
+
* complete default. The returned value is detached and deep-frozen.
|
|
229
|
+
* @param {object|undefined} raw - partial configuration from the loader or settings section.
|
|
230
|
+
* @returns {object} the resolved, frozen configuration.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveConfig(raw) {
|
|
233
|
+
if (raw === undefined || raw === null) return deepFreeze(merge(DEFAULT_CONFIG, {}))
|
|
234
|
+
if (!isPlainObject(raw)) fail('<root>', `expected an object, got ${JSON.stringify(raw)}`)
|
|
235
|
+
return deepFreeze(validateConfig(merge(DEFAULT_CONFIG, raw)))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Severity comparator used by policy and prompt assembly. */
|
|
239
|
+
export function severityAtLeast(actual, minimum) {
|
|
240
|
+
return (SEVERITY_RANK[actual] ?? 0) >= (SEVERITY_RANK[minimum] ?? 0)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export { SEVERITY_RANK }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional durable sink: private JSONL audit records beside the harness home.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately NOT a session-log event. The session format treats an
|
|
5
|
+
* unknown event type as required unless it carries an `ignorable` envelope
|
|
6
|
+
* marker, and `Session.append()` cannot set that marker; writing audit records
|
|
7
|
+
* into the monitored session would risk making the session unreadable on
|
|
8
|
+
* resume. A separate 0600 JSONL file keeps verdicts durable without touching
|
|
9
|
+
* session compatibility.
|
|
10
|
+
*
|
|
11
|
+
* @module @codebam/dsh-thinking-auditor/file-sink
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendFile, mkdir, chmod } from 'node:fs/promises'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
/** Resolve the default audit directory from the harness home seam. */
|
|
19
|
+
export function resolveAuditDir(ctx, configured) {
|
|
20
|
+
if (typeof configured === 'string' && configured.trim().length > 0) return resolve(configured.trim())
|
|
21
|
+
const dshHomePath = ctx?.get?.('dshHomePath')
|
|
22
|
+
if (typeof dshHomePath === 'function') {
|
|
23
|
+
try {
|
|
24
|
+
return dshHomePath('thinking-auditor')
|
|
25
|
+
} catch {
|
|
26
|
+
// Fall through to environment/home resolution.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim().length > 0) {
|
|
30
|
+
return join(process.env.DSH_HOME.trim(), 'thinking-auditor')
|
|
31
|
+
}
|
|
32
|
+
return join(homedir(), '.dsh', 'thinking-auditor')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a serialized append sink for one JSONL file.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} options - `ctx`, configured `dir`, `file`, and an optional logger.
|
|
39
|
+
* @returns {{ path: string, write: (record: object) => Promise<void>, disabled: boolean, error?: string }}
|
|
40
|
+
*/
|
|
41
|
+
export function createJsonlSink({ ctx, dir, file = 'audit.jsonl', log } = {}) {
|
|
42
|
+
const auditDir = resolveAuditDir(ctx, dir)
|
|
43
|
+
const path = join(auditDir, file)
|
|
44
|
+
let tail = Promise.resolve()
|
|
45
|
+
let disabled = false
|
|
46
|
+
let error
|
|
47
|
+
let secured = false
|
|
48
|
+
|
|
49
|
+
const ensureDir = mkdir(auditDir, { recursive: true, mode: 0o700 }).then(() => chmod(auditDir, 0o700)).catch((cause) => {
|
|
50
|
+
disabled = true
|
|
51
|
+
error = cause?.message ?? String(cause)
|
|
52
|
+
log?.warn?.(`thinking-auditor: audit store disabled: ${error}`)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const write = (record) => {
|
|
56
|
+
if (disabled) return Promise.resolve()
|
|
57
|
+
tail = tail
|
|
58
|
+
.then(async () => {
|
|
59
|
+
await ensureDir
|
|
60
|
+
if (disabled) return
|
|
61
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, { mode: 0o600 })
|
|
62
|
+
if (!secured) {
|
|
63
|
+
secured = true
|
|
64
|
+
await chmod(path, 0o600).catch(() => {})
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.catch((cause) => {
|
|
68
|
+
disabled = true
|
|
69
|
+
error = cause?.message ?? String(cause)
|
|
70
|
+
log?.warn?.(`thinking-auditor: audit store disabled after write failure: ${error}`)
|
|
71
|
+
})
|
|
72
|
+
return tail
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { path, write, get disabled() { return disabled }, get error() { return error } }
|
|
76
|
+
}
|