@wyattjoh/demur 0.6.0 → 0.7.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/README.md +53 -16
- package/extensions/demur/index.ts +99 -15
- package/extensions/demur/training-store.ts +213 -35
- package/package.json +2 -1
- package/src/adapters/pi-worker.ts +21 -7
- package/src/cli.ts +154 -5
- package/src/guard.internal.ts +83 -33
- package/src/guard.ts +42 -2
- package/src/judge.ts +53 -7
- package/src/policy.ts +10 -1
- package/src/questions.ts +9 -1
- package/src/state.ts +14 -16
- package/src/training-evaluation.ts +408 -0
- package/src/training-review-model.ts +17 -0
- package/src/training-review-tui.tsx +110 -30
- package/src/types.ts +56 -0
package/README.md
CHANGED
|
@@ -48,11 +48,13 @@ or sensitive names. Review TypeSafe's service terms and data-handling policy
|
|
|
48
48
|
before enabling demur in a sensitive repository. Do not run secrets directly in
|
|
49
49
|
shell arguments when the guard is active.
|
|
50
50
|
|
|
51
|
-
Pi training capture stores the complete command,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
Pi training capture stores the complete command, exact TypeSafe state, local
|
|
52
|
+
static-analysis result, model/question/policy versions, exact policy thresholds,
|
|
53
|
+
operating mode, full verdict and judgments, and resulting host action locally.
|
|
54
|
+
These records can therefore contain secrets or sensitive names from shell
|
|
55
|
+
arguments, paths, Git
|
|
56
|
+
context, and deterministic command analysis. Training capture is off by default
|
|
57
|
+
and is unavailable while the Pi integration is disabled.
|
|
56
58
|
|
|
57
59
|
## Requirements
|
|
58
60
|
|
|
@@ -98,7 +100,7 @@ Pin a specific release when reproducibility matters:
|
|
|
98
100
|
|
|
99
101
|
<!-- x-release-please-start-version -->
|
|
100
102
|
```sh
|
|
101
|
-
pi install npm:@wyattjoh/demur@0.
|
|
103
|
+
pi install npm:@wyattjoh/demur@0.7.0
|
|
102
104
|
```
|
|
103
105
|
<!-- x-release-please-end -->
|
|
104
106
|
|
|
@@ -111,7 +113,9 @@ pi
|
|
|
111
113
|
The extension intercepts `bash` tool calls. Because Pi runs extensions under
|
|
112
114
|
Node.js while demur uses `Bun.secrets`, the extension launches a package-local
|
|
113
115
|
Bun worker for each judgment. The API key remains inside that worker; only the
|
|
114
|
-
command request and resulting verdict cross its local stdio pipes.
|
|
116
|
+
command request and resulting verdict cross its local stdio pipes. When training
|
|
117
|
+
capture is enabled, the exact model state and local policy evidence return with
|
|
118
|
+
the verdict for private local persistence. `ask` opens
|
|
115
119
|
an interactive confirmation dialog; without an interactive UI, demur blocks the
|
|
116
120
|
command.
|
|
117
121
|
|
|
@@ -166,8 +170,11 @@ Set `DEMUR_STATE_HOME` to place `usage.json`, `training.jsonl`, and
|
|
|
166
170
|
demur-specific override takes precedence over the XDG and home-directory
|
|
167
171
|
locations. Training records are retained until the user removes them. Human
|
|
168
172
|
reviews are appended separately; accepted and corrected records are linked by a
|
|
169
|
-
stable record ID, leaving the original evidence unchanged.
|
|
170
|
-
|
|
173
|
+
stable record ID, leaving the original evidence unchanged. Corrected reviews
|
|
174
|
+
also carry a machine-readable reason so recurring question, context, policy, and
|
|
175
|
+
service failures can be measured without mining free-form notes. Version-one
|
|
176
|
+
records and reviews remain readable. A training-write failure is reported but
|
|
177
|
+
never changes whether the command runs.
|
|
171
178
|
|
|
172
179
|
Pi packages execute with the user's full system permissions. Review this
|
|
173
180
|
repository before installing it.
|
|
@@ -220,7 +227,8 @@ demur auth logout
|
|
|
220
227
|
demur training review
|
|
221
228
|
demur training list --status=unreviewed --cwd=/workspace
|
|
222
229
|
demur training list --status=deny --json
|
|
223
|
-
demur training
|
|
230
|
+
demur training evaluate --json
|
|
231
|
+
demur training review <record-id> --decision=deny --reason=recoverability --note="would destroy work" --json
|
|
224
232
|
demur judge "git reset --hard HEAD~3"
|
|
225
233
|
```
|
|
226
234
|
|
|
@@ -249,18 +257,22 @@ and Page Down page within the focused pane, and queue navigation stops at its
|
|
|
249
257
|
first and last entries. Mouse clicks select tabs, records, the filter, or either
|
|
250
258
|
pane; the wheel scrolls the queue and detail pane. Enter selects the original
|
|
251
259
|
decision, `1`/`2`/`3` choose `allow`/`ask`/`deny`, `s`
|
|
252
|
-
leaves a record for a later pass, and `q` or Escape stops.
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
leaves a record for a later pass, and `q` or Escape stops. A correction first
|
|
261
|
+
asks for one structured reason, then accepts an optional note. Previously
|
|
262
|
+
reviewed records remain available, and changing an answer appends a review
|
|
263
|
+
revision while preserving its visible history. The TUI remains open when a view is empty and
|
|
255
264
|
polls training state for newly captured or externally reviewed evaluations.
|
|
256
265
|
|
|
257
266
|
Flag-based training commands provide the same review operations without the
|
|
258
267
|
TUI. `demur training list` accepts `--status` and fuzzy `--cwd` filters. Status
|
|
259
268
|
is derived from the latest human review, so `allow`, `ask`, and `deny` select
|
|
260
269
|
reviewed records while `unreviewed` selects records without a review. Record a
|
|
261
|
-
new append-only review revision by passing a record ID
|
|
262
|
-
`--decision=<allow|ask|deny
|
|
263
|
-
|
|
270
|
+
new append-only review revision by passing a record ID and a required
|
|
271
|
+
`--decision=<allow|ask|deny>`. Corrections also require `--reason` with one of
|
|
272
|
+
`inert-or-read-only`, `sensitive-data`, `security-boundary`, `recoverability`,
|
|
273
|
+
`shared-infrastructure`, `blast-radius`, `static-uncertainty`, `missing-context`,
|
|
274
|
+
or `service-failure`; `--note` remains optional. Add `--json` to list, evaluate,
|
|
275
|
+
or review to emit one versioned JSON document; JSON failures are written to
|
|
264
276
|
stdout with a nonzero exit code. Interactive `demur training review` still opens
|
|
265
277
|
the TUI, but without a terminal it requires an explicit list or record-ID review
|
|
266
278
|
operation. Reviews remain separate from the original evidence so they can later
|
|
@@ -325,11 +337,36 @@ and deterministic policy controls alongside demur.
|
|
|
325
337
|
- `src/guard.internal.ts` — Effect-native orchestration and fail-closed recovery
|
|
326
338
|
- `src/guard.ts` — managed runtime and Promise boundary
|
|
327
339
|
- `src/training-review-model.ts` — historical review status and cwd filtering
|
|
340
|
+
- `src/training-evaluation.ts` — offline correction metrics and threshold comparisons
|
|
328
341
|
- `src/training-review-tui.tsx` — interactive OpenTUI training-review queue
|
|
329
342
|
- `extensions/demur/` — Pi `tool_call` integration and training-state storage
|
|
330
343
|
- `src/adapters/claude-code.ts` — Claude Code `PreToolUse` integration
|
|
331
344
|
- `eval/` — safe synthetic contrast cases and the live evaluation runner
|
|
332
345
|
|
|
346
|
+
## Training feedback evaluation
|
|
347
|
+
|
|
348
|
+
`demur training evaluate` joins each record with its latest review and replays
|
|
349
|
+
stored raw judgments through the shared `decide()` policy. By default it makes
|
|
350
|
+
no TypeSafe requests. The report includes a decision matrix, an asymmetric weighted loss
|
|
351
|
+
that penalizes unsafe false allows most heavily, correction counts grouped by
|
|
352
|
+
structured reason, replay fidelity, and up to five single-threshold candidates
|
|
353
|
+
that improve the observed records.
|
|
354
|
+
|
|
355
|
+
Candidates are exploratory and are never applied automatically. The command
|
|
356
|
+
scores them on the same private records used to discover them, so validate a
|
|
357
|
+
candidate on an independent holdout and the synthetic corpus before changing
|
|
358
|
+
`THRESHOLDS`. Newly captured version-two records include the exact model state
|
|
359
|
+
and static-gate analysis required for complete replay; legacy records support
|
|
360
|
+
policy-only replay.
|
|
361
|
+
|
|
362
|
+
Pass `--replay` explicitly to send reviewed version-two records' previously
|
|
363
|
+
captured model state to TypeSafe again using the current question set. This is a
|
|
364
|
+
cost-bearing operation and re-discloses the stored command and context described
|
|
365
|
+
above. It is capped at 20 requests by default; use `--limit=<1-100>` to choose a
|
|
366
|
+
different bound. Commands remain data and are never executed. The replay report shows
|
|
367
|
+
improvements, regressions, failures, token usage, and per-record raw judgments;
|
|
368
|
+
use it when changing one question at a time.
|
|
369
|
+
|
|
333
370
|
## Synthetic evaluation
|
|
334
371
|
|
|
335
372
|
The synthetic evaluation measures whether the two policy-qualification questions
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
type ToolCallEvent,
|
|
8
8
|
type ToolCallEventResult,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
GuardEvaluation,
|
|
12
|
+
GuardEvidence,
|
|
13
|
+
Verdict,
|
|
14
|
+
} from "../../src/types.ts";
|
|
11
15
|
import {
|
|
12
16
|
estimateInputCostUsd,
|
|
13
17
|
formatUsd,
|
|
@@ -54,9 +58,15 @@ export async function handleToolCall(
|
|
|
54
58
|
if (command.trim() === "") return undefined;
|
|
55
59
|
|
|
56
60
|
const evaluationStartedAt = performance.now();
|
|
57
|
-
let
|
|
61
|
+
let evaluation: GuardEvaluation;
|
|
58
62
|
try {
|
|
59
|
-
|
|
63
|
+
evaluation = await runGuardWorkerRequest(
|
|
64
|
+
command,
|
|
65
|
+
ctx.cwd,
|
|
66
|
+
ctx.signal,
|
|
67
|
+
process.env,
|
|
68
|
+
settings.training,
|
|
69
|
+
);
|
|
60
70
|
} catch (error: unknown) {
|
|
61
71
|
const evaluationMs = performance.now() - evaluationStartedAt;
|
|
62
72
|
if (ctx.signal?.aborted) {
|
|
@@ -74,16 +84,20 @@ export async function handleToolCall(
|
|
|
74
84
|
};
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
evaluation = {
|
|
88
|
+
verdict: {
|
|
89
|
+
decision: "deny",
|
|
90
|
+
reason: `demur: guard worker crashed — ${errorDetail(error)}`,
|
|
91
|
+
judgments: undefined,
|
|
92
|
+
failure: "unexpected",
|
|
93
|
+
latencyMs: evaluationMs,
|
|
94
|
+
usage: undefined,
|
|
95
|
+
},
|
|
96
|
+
evidence: undefined,
|
|
84
97
|
};
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
const verdict = evaluation.verdict;
|
|
87
101
|
const evaluationMs = performance.now() - evaluationStartedAt;
|
|
88
102
|
const inputTokens = verdict.usage?.inputTokens;
|
|
89
103
|
const accumulatedCostUsd = await recordAccumulatedCost(inputTokens);
|
|
@@ -109,6 +123,7 @@ export async function handleToolCall(
|
|
|
109
123
|
ctx,
|
|
110
124
|
settings.mode,
|
|
111
125
|
verdict,
|
|
126
|
+
evaluation.evidence,
|
|
112
127
|
result,
|
|
113
128
|
);
|
|
114
129
|
}
|
|
@@ -302,6 +317,18 @@ export function runGuardWorker(
|
|
|
302
317
|
signal: AbortSignal | undefined,
|
|
303
318
|
environment: NodeJS.ProcessEnv = process.env,
|
|
304
319
|
): Promise<Verdict> {
|
|
320
|
+
return runGuardWorkerRequest(command, cwd, signal, environment, false).then(
|
|
321
|
+
(evaluation) => evaluation.verdict,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function runGuardWorkerRequest(
|
|
326
|
+
command: string,
|
|
327
|
+
cwd: string,
|
|
328
|
+
signal: AbortSignal | undefined,
|
|
329
|
+
environment: NodeJS.ProcessEnv,
|
|
330
|
+
includeEvidence: boolean,
|
|
331
|
+
): Promise<GuardEvaluation> {
|
|
305
332
|
return new Promise((resolve, reject) => {
|
|
306
333
|
if (signal?.aborted) {
|
|
307
334
|
reject(new Error("guard request cancelled"));
|
|
@@ -333,7 +360,6 @@ export function runGuardWorker(
|
|
|
333
360
|
signal?.addEventListener("abort", abort, { once: true });
|
|
334
361
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
335
362
|
stdout = appendBounded(stdout, chunk);
|
|
336
|
-
if (stdout.length >= MAX_WORKER_OUTPUT_BYTES) child.kill();
|
|
337
363
|
});
|
|
338
364
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
339
365
|
stderr = appendBounded(stderr, chunk);
|
|
@@ -349,7 +375,7 @@ export function runGuardWorker(
|
|
|
349
375
|
}
|
|
350
376
|
|
|
351
377
|
try {
|
|
352
|
-
resolve(
|
|
378
|
+
resolve(parseGuardWorkerResponse(stdout, includeEvidence));
|
|
353
379
|
} catch (error: unknown) {
|
|
354
380
|
reject(new Error(`invalid guard worker response: ${errorDetail(error)}`));
|
|
355
381
|
}
|
|
@@ -357,7 +383,7 @@ export function runGuardWorker(
|
|
|
357
383
|
});
|
|
358
384
|
|
|
359
385
|
child.stdin.on("error", (error) => finish(() => reject(error)));
|
|
360
|
-
child.stdin.end(JSON.stringify({ command, cwd }));
|
|
386
|
+
child.stdin.end(JSON.stringify({ command, cwd, includeEvidence }));
|
|
361
387
|
});
|
|
362
388
|
}
|
|
363
389
|
|
|
@@ -496,14 +522,22 @@ async function recordTrainingResult(
|
|
|
496
522
|
ctx: ExtensionContext,
|
|
497
523
|
mode: "enforce" | "passive",
|
|
498
524
|
verdict: Verdict,
|
|
525
|
+
evidence: GuardEvidence | undefined,
|
|
499
526
|
result: ToolCallEventResult | undefined,
|
|
500
527
|
): Promise<void> {
|
|
501
528
|
try {
|
|
529
|
+
if (verdict.judgments !== undefined && evidence === undefined) {
|
|
530
|
+
ctx.ui.notify(
|
|
531
|
+
"demur: recording training evaluation without replay evidence",
|
|
532
|
+
"warning",
|
|
533
|
+
);
|
|
534
|
+
}
|
|
502
535
|
await recordTrainingEvaluation({
|
|
503
536
|
command,
|
|
504
537
|
cwd: ctx.cwd,
|
|
505
538
|
mode,
|
|
506
539
|
verdict,
|
|
540
|
+
evidence,
|
|
507
541
|
hostAction: result?.block === true ? "block" : "allow",
|
|
508
542
|
});
|
|
509
543
|
} catch (error: unknown) {
|
|
@@ -630,8 +664,36 @@ function formatDecimal(value: number, fractionDigits: number): string {
|
|
|
630
664
|
.replace(/\.0+$/, "");
|
|
631
665
|
}
|
|
632
666
|
|
|
633
|
-
function
|
|
634
|
-
|
|
667
|
+
function parseGuardWorkerResponse(
|
|
668
|
+
output: string,
|
|
669
|
+
includeEvidence: boolean,
|
|
670
|
+
): GuardEvaluation {
|
|
671
|
+
if (!includeEvidence) {
|
|
672
|
+
return {
|
|
673
|
+
verdict: parseVerdictValue(JSON.parse(output)),
|
|
674
|
+
evidence: undefined,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const separator = output.indexOf("\n");
|
|
679
|
+
if (separator < 0) {
|
|
680
|
+
throw new Error("training guard response did not contain a verdict line");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const verdict = parseVerdictValue(JSON.parse(output.slice(0, separator)));
|
|
684
|
+
const encodedEvidence = output.slice(separator + 1);
|
|
685
|
+
try {
|
|
686
|
+
return {
|
|
687
|
+
verdict,
|
|
688
|
+
evidence: parseGuardEvidence(JSON.parse(encodedEvidence)),
|
|
689
|
+
};
|
|
690
|
+
} catch {
|
|
691
|
+
// Training evidence is best-effort and must never change the guard verdict.
|
|
692
|
+
return { verdict, evidence: undefined };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function parseVerdictValue(value: unknown): Verdict {
|
|
635
697
|
if (value === null || typeof value !== "object") {
|
|
636
698
|
throw new Error("verdict must be an object");
|
|
637
699
|
}
|
|
@@ -657,6 +719,28 @@ function parseVerdict(output: string): Verdict {
|
|
|
657
719
|
};
|
|
658
720
|
}
|
|
659
721
|
|
|
722
|
+
function parseGuardEvidence(value: unknown): GuardEvidence | undefined {
|
|
723
|
+
if (value === undefined || value === null) return undefined;
|
|
724
|
+
if (typeof value !== "object") {
|
|
725
|
+
throw new Error("guard evidence must be an object");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const evidence = value as Record<string, unknown>;
|
|
729
|
+
if (
|
|
730
|
+
evidence.modelState === null ||
|
|
731
|
+
typeof evidence.modelState !== "object" ||
|
|
732
|
+
typeof evidence.model !== "string" ||
|
|
733
|
+
!Number.isInteger(evidence.questionSetVersion) ||
|
|
734
|
+
!Number.isInteger(evidence.policyVersion) ||
|
|
735
|
+
evidence.policyThresholds === null ||
|
|
736
|
+
typeof evidence.policyThresholds !== "object"
|
|
737
|
+
) {
|
|
738
|
+
throw new Error("guard evidence is incomplete");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return evidence as GuardEvidence;
|
|
742
|
+
}
|
|
743
|
+
|
|
660
744
|
function errorDetail(error: unknown): string {
|
|
661
745
|
return error instanceof Error ? error.message : String(error);
|
|
662
746
|
}
|
|
@@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
Decision,
|
|
7
|
+
GuardEvidence,
|
|
8
|
+
Verdict,
|
|
9
|
+
} from "../../src/types.ts";
|
|
6
10
|
import { getDemurStateDirectory } from "./paths.ts";
|
|
7
11
|
|
|
8
12
|
const LOCK_RETRY_MS = 10;
|
|
@@ -19,9 +23,9 @@ export type TrainingMode = "enforce" | "passive";
|
|
|
19
23
|
export type TrainingHostAction = "allow" | "block";
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
*
|
|
26
|
+
* Legacy training record captured before replayable invocation evidence existed.
|
|
23
27
|
*/
|
|
24
|
-
export type
|
|
28
|
+
export type TrainingRecordV1 = {
|
|
25
29
|
version: 1;
|
|
26
30
|
id: string;
|
|
27
31
|
recordedAt: string;
|
|
@@ -33,9 +37,60 @@ export type TrainingRecord = {
|
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
|
-
*
|
|
40
|
+
* Training record with exact model state and policy provenance.
|
|
37
41
|
*/
|
|
38
|
-
export type
|
|
42
|
+
export type TrainingRecordV2 = {
|
|
43
|
+
version: 2;
|
|
44
|
+
id: string;
|
|
45
|
+
recordedAt: string;
|
|
46
|
+
command: string;
|
|
47
|
+
cwd: string;
|
|
48
|
+
mode: TrainingMode;
|
|
49
|
+
verdict: Verdict;
|
|
50
|
+
evidence: GuardEvidence | undefined;
|
|
51
|
+
hostAction: TrainingHostAction;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Complete evidence captured for one training-mode command evaluation.
|
|
56
|
+
*/
|
|
57
|
+
export type TrainingRecord = TrainingRecordV1 | TrainingRecordV2;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Machine-readable explanation for why a human corrected a verdict.
|
|
61
|
+
*/
|
|
62
|
+
export type TrainingCorrectionReason =
|
|
63
|
+
| "inert-or-read-only"
|
|
64
|
+
| "sensitive-data"
|
|
65
|
+
| "security-boundary"
|
|
66
|
+
| "recoverability"
|
|
67
|
+
| "shared-infrastructure"
|
|
68
|
+
| "blast-radius"
|
|
69
|
+
| "static-uncertainty"
|
|
70
|
+
| "missing-context"
|
|
71
|
+
| "service-failure";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Stable correction-reason values accepted by storage and CLI boundaries.
|
|
75
|
+
*/
|
|
76
|
+
export const TRAINING_CORRECTION_REASONS: ReadonlyArray<
|
|
77
|
+
TrainingCorrectionReason
|
|
78
|
+
> = [
|
|
79
|
+
"inert-or-read-only",
|
|
80
|
+
"sensitive-data",
|
|
81
|
+
"security-boundary",
|
|
82
|
+
"recoverability",
|
|
83
|
+
"shared-infrastructure",
|
|
84
|
+
"blast-radius",
|
|
85
|
+
"static-uncertainty",
|
|
86
|
+
"missing-context",
|
|
87
|
+
"service-failure",
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Legacy human review recorded before structured correction reasons existed.
|
|
92
|
+
*/
|
|
93
|
+
export type TrainingReviewV1 = {
|
|
39
94
|
version: 1;
|
|
40
95
|
recordId: string;
|
|
41
96
|
reviewedAt: string;
|
|
@@ -44,6 +99,24 @@ export type TrainingReview = {
|
|
|
44
99
|
note: string | undefined;
|
|
45
100
|
};
|
|
46
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Human review with a structured reason for corrected model decisions.
|
|
104
|
+
*/
|
|
105
|
+
export type TrainingReviewV2 = {
|
|
106
|
+
version: 2;
|
|
107
|
+
recordId: string;
|
|
108
|
+
reviewedAt: string;
|
|
109
|
+
originalDecision: Decision;
|
|
110
|
+
expectedDecision: Decision;
|
|
111
|
+
correctionReason: TrainingCorrectionReason | undefined;
|
|
112
|
+
note: string | undefined;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Human review of one captured training evaluation.
|
|
117
|
+
*/
|
|
118
|
+
export type TrainingReview = TrainingReviewV1 | TrainingReviewV2;
|
|
119
|
+
|
|
47
120
|
/**
|
|
48
121
|
* Input required to append a human review.
|
|
49
122
|
*/
|
|
@@ -51,9 +124,18 @@ export type TrainingReviewInput = {
|
|
|
51
124
|
recordId: string;
|
|
52
125
|
originalDecision: Decision;
|
|
53
126
|
expectedDecision: Decision;
|
|
127
|
+
correctionReason: TrainingCorrectionReason | undefined;
|
|
54
128
|
note: string | undefined;
|
|
55
129
|
};
|
|
56
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Input required to append a replayable training evaluation.
|
|
133
|
+
*/
|
|
134
|
+
export type TrainingRecordInput = Omit<
|
|
135
|
+
TrainingRecordV2,
|
|
136
|
+
"version" | "id" | "recordedAt"
|
|
137
|
+
>;
|
|
138
|
+
|
|
57
139
|
/**
|
|
58
140
|
* Resolve the global training-record file according to demur and XDG overrides.
|
|
59
141
|
*
|
|
@@ -96,11 +178,11 @@ export function getTrainingReviewPath(
|
|
|
96
178
|
* @returns The persisted record with generated identity and timestamp
|
|
97
179
|
*/
|
|
98
180
|
export async function recordTrainingEvaluation(
|
|
99
|
-
input:
|
|
181
|
+
input: TrainingRecordInput,
|
|
100
182
|
logPath: string = getTrainingLogPath(),
|
|
101
|
-
): Promise<
|
|
102
|
-
const record:
|
|
103
|
-
version:
|
|
183
|
+
): Promise<TrainingRecordV2> {
|
|
184
|
+
const record: TrainingRecordV2 = {
|
|
185
|
+
version: 2,
|
|
104
186
|
id: randomUUID(),
|
|
105
187
|
recordedAt: new Date().toISOString(),
|
|
106
188
|
...input,
|
|
@@ -131,9 +213,22 @@ export async function loadTrainingRecords(
|
|
|
131
213
|
export async function recordTrainingReview(
|
|
132
214
|
input: TrainingReviewInput,
|
|
133
215
|
reviewPath: string = getTrainingReviewPath(),
|
|
134
|
-
): Promise<
|
|
135
|
-
|
|
136
|
-
|
|
216
|
+
): Promise<TrainingReviewV2> {
|
|
217
|
+
if (
|
|
218
|
+
input.originalDecision !== input.expectedDecision &&
|
|
219
|
+
input.correctionReason === undefined
|
|
220
|
+
) {
|
|
221
|
+
throw new Error("corrected training reviews require a correction reason");
|
|
222
|
+
}
|
|
223
|
+
if (
|
|
224
|
+
input.originalDecision === input.expectedDecision &&
|
|
225
|
+
input.correctionReason !== undefined
|
|
226
|
+
) {
|
|
227
|
+
throw new Error("accepted training reviews cannot have a correction reason");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const review: TrainingReviewV2 = {
|
|
231
|
+
version: 2,
|
|
137
232
|
reviewedAt: new Date().toISOString(),
|
|
138
233
|
...input,
|
|
139
234
|
};
|
|
@@ -213,41 +308,37 @@ function parseTrainingRecord(
|
|
|
213
308
|
|
|
214
309
|
const record = value as Record<string, unknown>;
|
|
215
310
|
if (
|
|
216
|
-
record.version !== 1 ||
|
|
311
|
+
(record.version !== 1 && record.version !== 2) ||
|
|
217
312
|
typeof record.id !== "string" ||
|
|
218
313
|
typeof record.recordedAt !== "string" ||
|
|
219
314
|
typeof record.command !== "string" ||
|
|
220
315
|
typeof record.cwd !== "string" ||
|
|
221
316
|
(record.mode !== "enforce" && record.mode !== "passive") ||
|
|
222
317
|
(record.hostAction !== "allow" && record.hostAction !== "block") ||
|
|
223
|
-
!isVerdict(record.verdict)
|
|
318
|
+
!isVerdict(record.verdict) ||
|
|
319
|
+
(record.version === 2 && !isGuardEvidence(record.evidence))
|
|
224
320
|
) {
|
|
225
321
|
throw invalidRecord(path, line);
|
|
226
322
|
}
|
|
227
323
|
|
|
228
|
-
const verdict = record.verdict
|
|
229
|
-
|
|
230
|
-
version: 1,
|
|
324
|
+
const verdict = normalizeVerdict(record.verdict);
|
|
325
|
+
const common = {
|
|
231
326
|
id: record.id,
|
|
232
327
|
recordedAt: record.recordedAt,
|
|
233
328
|
command: record.command,
|
|
234
329
|
cwd: record.cwd,
|
|
235
330
|
mode: record.mode,
|
|
236
|
-
verdict
|
|
237
|
-
decision: verdict.decision as Decision,
|
|
238
|
-
reason: verdict.reason as string,
|
|
239
|
-
judgments: verdict.judgments === null
|
|
240
|
-
? undefined
|
|
241
|
-
: verdict.judgments as Verdict["judgments"],
|
|
242
|
-
failure: verdict.failure === null
|
|
243
|
-
? undefined
|
|
244
|
-
: verdict.failure as Verdict["failure"],
|
|
245
|
-
latencyMs: verdict.latencyMs as number,
|
|
246
|
-
usage: verdict.usage === null
|
|
247
|
-
? undefined
|
|
248
|
-
: verdict.usage as Verdict["usage"],
|
|
249
|
-
},
|
|
331
|
+
verdict,
|
|
250
332
|
hostAction: record.hostAction,
|
|
333
|
+
} as const;
|
|
334
|
+
|
|
335
|
+
if (record.version === 1) return { version: 1, ...common };
|
|
336
|
+
return {
|
|
337
|
+
version: 2,
|
|
338
|
+
...common,
|
|
339
|
+
evidence: record.evidence === null
|
|
340
|
+
? undefined
|
|
341
|
+
: normalizeGuardEvidence(record.evidence),
|
|
251
342
|
};
|
|
252
343
|
}
|
|
253
344
|
|
|
@@ -262,25 +353,55 @@ function parseTrainingReview(
|
|
|
262
353
|
|
|
263
354
|
const review = value as Record<string, unknown>;
|
|
264
355
|
if (
|
|
265
|
-
review.version !== 1 ||
|
|
356
|
+
(review.version !== 1 && review.version !== 2) ||
|
|
266
357
|
typeof review.recordId !== "string" ||
|
|
267
358
|
typeof review.reviewedAt !== "string" ||
|
|
268
359
|
!isDecision(review.originalDecision) ||
|
|
269
360
|
!isDecision(review.expectedDecision) ||
|
|
270
361
|
(review.note !== undefined &&
|
|
271
362
|
review.note !== null &&
|
|
272
|
-
typeof review.note !== "string")
|
|
363
|
+
typeof review.note !== "string") ||
|
|
364
|
+
(review.version === 2 &&
|
|
365
|
+
(!isReviewCorrectionReasonValid(review) ||
|
|
366
|
+
(review.correctionReason !== undefined &&
|
|
367
|
+
review.correctionReason !== null &&
|
|
368
|
+
!isTrainingCorrectionReason(review.correctionReason))))
|
|
273
369
|
) {
|
|
274
370
|
throw invalidRecord(path, line);
|
|
275
371
|
}
|
|
276
372
|
|
|
277
|
-
|
|
278
|
-
version: 1,
|
|
373
|
+
const common = {
|
|
279
374
|
recordId: review.recordId,
|
|
280
375
|
reviewedAt: review.reviewedAt,
|
|
281
376
|
originalDecision: review.originalDecision,
|
|
282
377
|
expectedDecision: review.expectedDecision,
|
|
283
378
|
note: review.note === null ? undefined : review.note,
|
|
379
|
+
} as const;
|
|
380
|
+
if (review.version === 1) return { version: 1, ...common };
|
|
381
|
+
return {
|
|
382
|
+
version: 2,
|
|
383
|
+
...common,
|
|
384
|
+
correctionReason: review.correctionReason === null
|
|
385
|
+
? undefined
|
|
386
|
+
: review.correctionReason as TrainingCorrectionReason | undefined,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function normalizeVerdict(value: unknown): Verdict {
|
|
391
|
+
const verdict = value as Record<string, unknown>;
|
|
392
|
+
return {
|
|
393
|
+
decision: verdict.decision as Decision,
|
|
394
|
+
reason: verdict.reason as string,
|
|
395
|
+
judgments: verdict.judgments === null
|
|
396
|
+
? undefined
|
|
397
|
+
: verdict.judgments as Verdict["judgments"],
|
|
398
|
+
failure: verdict.failure === null
|
|
399
|
+
? undefined
|
|
400
|
+
: verdict.failure as Verdict["failure"],
|
|
401
|
+
latencyMs: verdict.latencyMs as number,
|
|
402
|
+
usage: verdict.usage === null
|
|
403
|
+
? undefined
|
|
404
|
+
: verdict.usage as Verdict["usage"],
|
|
284
405
|
};
|
|
285
406
|
}
|
|
286
407
|
|
|
@@ -296,6 +417,63 @@ function isDecision(value: unknown): value is Decision {
|
|
|
296
417
|
return value === "allow" || value === "ask" || value === "deny";
|
|
297
418
|
}
|
|
298
419
|
|
|
420
|
+
function isReviewCorrectionReasonValid(
|
|
421
|
+
review: Record<string, unknown>,
|
|
422
|
+
): boolean {
|
|
423
|
+
const corrected = review.originalDecision !== review.expectedDecision;
|
|
424
|
+
const hasReason = review.correctionReason !== undefined &&
|
|
425
|
+
review.correctionReason !== null;
|
|
426
|
+
return corrected === hasReason;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Check whether a value is a supported structured correction reason.
|
|
431
|
+
*
|
|
432
|
+
* @param value - Candidate correction-reason value
|
|
433
|
+
* @returns Whether the value belongs to the stable correction taxonomy
|
|
434
|
+
*/
|
|
435
|
+
export function isTrainingCorrectionReason(
|
|
436
|
+
value: unknown,
|
|
437
|
+
): value is TrainingCorrectionReason {
|
|
438
|
+
return typeof value === "string" &&
|
|
439
|
+
TRAINING_CORRECTION_REASONS.includes(value as TrainingCorrectionReason);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isGuardEvidence(value: unknown): boolean {
|
|
443
|
+
if (value === null || value === undefined) return true;
|
|
444
|
+
if (typeof value !== "object") return false;
|
|
445
|
+
const evidence = value as Record<string, unknown>;
|
|
446
|
+
return evidence.modelState !== null &&
|
|
447
|
+
typeof evidence.modelState === "object" &&
|
|
448
|
+
(evidence.analysis === null || evidence.analysis === undefined ||
|
|
449
|
+
typeof evidence.analysis === "object") &&
|
|
450
|
+
typeof evidence.model === "string" &&
|
|
451
|
+
Number.isInteger(evidence.questionSetVersion) &&
|
|
452
|
+
Number.isInteger(evidence.policyVersion) &&
|
|
453
|
+
isNumberRecord(evidence.policyThresholds);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function normalizeGuardEvidence(value: unknown): GuardEvidence {
|
|
457
|
+
const evidence = value as Record<string, unknown>;
|
|
458
|
+
return {
|
|
459
|
+
modelState: evidence.modelState as GuardEvidence["modelState"],
|
|
460
|
+
analysis: evidence.analysis === null
|
|
461
|
+
? undefined
|
|
462
|
+
: evidence.analysis as GuardEvidence["analysis"],
|
|
463
|
+
model: evidence.model as string,
|
|
464
|
+
questionSetVersion: evidence.questionSetVersion as number,
|
|
465
|
+
policyVersion: evidence.policyVersion as number,
|
|
466
|
+
policyThresholds: evidence.policyThresholds as GuardEvidence["policyThresholds"],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isNumberRecord(value: unknown): boolean {
|
|
471
|
+
if (value === null || typeof value !== "object") return false;
|
|
472
|
+
return Object.values(value).every((entry) =>
|
|
473
|
+
typeof entry === "number" && Number.isFinite(entry)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
299
477
|
function invalidRecord(path: string, line: number): Error {
|
|
300
478
|
return new Error(`invalid training record in ${path} at line ${line}`);
|
|
301
479
|
}
|