@wyattjoh/demur 0.5.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 +82 -25
- package/extensions/demur/index.ts +99 -15
- package/extensions/demur/training-store.ts +213 -35
- package/package.json +4 -2
- package/src/adapters/pi-worker.ts +21 -7
- package/src/cli.ts +554 -135
- 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/settings-model.ts +60 -0
- package/src/state.ts +14 -16
- package/src/training-evaluation.ts +408 -0
- package/src/training-review-model.ts +18 -2
- package/src/training-review-tui.tsx +408 -44
- package/src/types.ts +56 -0
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyattjoh/demur",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A proof-of-concept destructive-command guard for coding agents.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"src/policy.ts",
|
|
39
39
|
"src/questions.ts",
|
|
40
40
|
"src/state.ts",
|
|
41
|
+
"src/settings-model.ts",
|
|
42
|
+
"src/training-evaluation.ts",
|
|
41
43
|
"src/training-review-model.ts",
|
|
42
44
|
"src/training-review-tui.tsx",
|
|
43
45
|
"src/types.ts"
|
|
@@ -51,7 +53,7 @@
|
|
|
51
53
|
},
|
|
52
54
|
"scripts": {
|
|
53
55
|
"check": "tsc --noEmit",
|
|
54
|
-
"test": "vitest run",
|
|
56
|
+
"test": "vitest run && bun test ./src/training-review-tui.bun.tsx",
|
|
55
57
|
"build:pi": "bun build extensions/demur/index.ts --target=node --outfile=dist/demur-guard.js --format=esm --external @earendil-works/pi-coding-agent && bun build src/adapters/pi-worker.ts --target=bun --outfile=dist/demur-pi-worker.js --format=esm",
|
|
56
58
|
"build:claude": "bun build src/adapters/claude-code.ts --target=bun --outfile=dist/demur-hook.js --format=esm",
|
|
57
59
|
"build": "bun run build:pi && bun run build:claude",
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { guard } from "../guard.ts";
|
|
2
|
+
import { guard, guardWithEvidence } from "../guard.ts";
|
|
3
3
|
|
|
4
4
|
type GuardRequest = {
|
|
5
5
|
command: string;
|
|
6
6
|
cwd: string;
|
|
7
|
+
includeEvidence: boolean;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
const request = parseRequest(await Bun.stdin.text());
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
if (request.includeEvidence) {
|
|
12
|
+
const evaluation = await guardWithEvidence(request.command, request.cwd, "pi");
|
|
13
|
+
process.stdout.write(`${JSON.stringify(evaluation.verdict)}\n`);
|
|
14
|
+
process.stdout.write(JSON.stringify(evaluation.evidence ?? null));
|
|
15
|
+
} else {
|
|
16
|
+
process.stdout.write(
|
|
17
|
+
JSON.stringify(await guard(request.command, request.cwd, "pi")),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
12
20
|
|
|
13
21
|
function parseRequest(input: string): GuardRequest {
|
|
14
22
|
const value: unknown = JSON.parse(input);
|
|
@@ -16,10 +24,16 @@ function parseRequest(input: string): GuardRequest {
|
|
|
16
24
|
throw new Error("guard request must be an object");
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
const { command, cwd } = value as Record<string, unknown>;
|
|
20
|
-
if (
|
|
21
|
-
|
|
27
|
+
const { command, cwd, includeEvidence } = value as Record<string, unknown>;
|
|
28
|
+
if (
|
|
29
|
+
typeof command !== "string" ||
|
|
30
|
+
typeof cwd !== "string" ||
|
|
31
|
+
(includeEvidence !== undefined && typeof includeEvidence !== "boolean")
|
|
32
|
+
) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"guard request must contain string command and cwd fields and an optional boolean includeEvidence field",
|
|
35
|
+
);
|
|
22
36
|
}
|
|
23
37
|
|
|
24
|
-
return { command, cwd };
|
|
38
|
+
return { command, cwd, includeEvidence: includeEvidence === true };
|
|
25
39
|
}
|