@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/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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"src/questions.ts",
|
|
40
40
|
"src/state.ts",
|
|
41
41
|
"src/settings-model.ts",
|
|
42
|
+
"src/training-evaluation.ts",
|
|
42
43
|
"src/training-review-model.ts",
|
|
43
44
|
"src/training-review-tui.tsx",
|
|
44
45
|
"src/types.ts"
|
|
@@ -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
|
}
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type DemurSettings,
|
|
8
8
|
} from "../extensions/demur/settings.ts";
|
|
9
9
|
import {
|
|
10
|
+
isTrainingCorrectionReason,
|
|
10
11
|
loadTrainingRecords,
|
|
11
12
|
loadTrainingReviews,
|
|
12
13
|
recordTrainingReview,
|
|
@@ -15,12 +16,17 @@ import {
|
|
|
15
16
|
type TrainingReviewInput,
|
|
16
17
|
} from "../extensions/demur/training-store.ts";
|
|
17
18
|
import { guard } from "./guard.ts";
|
|
19
|
+
import { judgeRenderedState, type JudgeResult } from "./judge.ts";
|
|
18
20
|
import {
|
|
19
21
|
deleteApiKey,
|
|
20
22
|
resolveApiKey,
|
|
21
23
|
storeApiKey,
|
|
22
24
|
type ResolvedApiKey,
|
|
23
25
|
} from "./key.ts";
|
|
26
|
+
import {
|
|
27
|
+
analyzeTrainingFeedback,
|
|
28
|
+
replayTrainingQuestions,
|
|
29
|
+
} from "./training-evaluation.ts";
|
|
24
30
|
import {
|
|
25
31
|
buildTrainingReviewEntries,
|
|
26
32
|
createTrainingReviewInput,
|
|
@@ -32,7 +38,7 @@ import {
|
|
|
32
38
|
type TrainingReviewSnapshot,
|
|
33
39
|
} from "./training-review-model.ts";
|
|
34
40
|
import type { TrainingReviewTuiResult } from "./training-review-tui.tsx";
|
|
35
|
-
import type { Verdict } from "./types.ts";
|
|
41
|
+
import type { RenderedCommandState, Verdict } from "./types.ts";
|
|
36
42
|
|
|
37
43
|
const USAGE = `Usage:
|
|
38
44
|
demur
|
|
@@ -40,8 +46,9 @@ const USAGE = `Usage:
|
|
|
40
46
|
demur auth status
|
|
41
47
|
demur auth logout
|
|
42
48
|
demur training list [--status=<all|unreviewed|allow|ask|deny>] [--cwd=<query>] [--json]
|
|
49
|
+
demur training evaluate [--replay] [--limit=<1-100>] [--json]
|
|
43
50
|
demur training review
|
|
44
|
-
demur training review <record-id> --decision=<allow|ask|deny> [--note=<text>] [--json]
|
|
51
|
+
demur training review <record-id> --decision=<allow|ask|deny> [--reason=<correction-reason>] [--note=<text>] [--json]
|
|
45
52
|
demur judge "<command>" [--cwd=<path>]`;
|
|
46
53
|
|
|
47
54
|
/**
|
|
@@ -49,6 +56,7 @@ const USAGE = `Usage:
|
|
|
49
56
|
*/
|
|
50
57
|
export type CliDependencies = {
|
|
51
58
|
judge(command: string, cwd: string): Promise<Verdict>;
|
|
59
|
+
judgeTrainingState(state: RenderedCommandState): Promise<JudgeResult>;
|
|
52
60
|
resolveApiKey(): Promise<ResolvedApiKey | undefined>;
|
|
53
61
|
storeApiKey(value: string): Promise<void>;
|
|
54
62
|
deleteApiKey(): Promise<boolean>;
|
|
@@ -74,6 +82,7 @@ export type CliDependencies = {
|
|
|
74
82
|
|
|
75
83
|
const defaultDependencies: CliDependencies = {
|
|
76
84
|
judge: (command, cwd) => guard(command, cwd, "cli"),
|
|
85
|
+
judgeTrainingState: judgeRenderedState,
|
|
77
86
|
resolveApiKey,
|
|
78
87
|
storeApiKey,
|
|
79
88
|
deleteApiKey,
|
|
@@ -246,6 +255,10 @@ async function runTraining(
|
|
|
246
255
|
return runTrainingList(args.slice(1), dependencies);
|
|
247
256
|
}
|
|
248
257
|
|
|
258
|
+
if (command === "evaluate") {
|
|
259
|
+
return runTrainingEvaluate(args.slice(1), dependencies);
|
|
260
|
+
}
|
|
261
|
+
|
|
249
262
|
if (command === "review") {
|
|
250
263
|
if (args.length === 1) {
|
|
251
264
|
return runInteractiveTrainingReview(dependencies);
|
|
@@ -256,7 +269,7 @@ async function runTraining(
|
|
|
256
269
|
return writeTrainingUsageError(
|
|
257
270
|
trainingOperation(["training", ...args]),
|
|
258
271
|
args.includes("--json"),
|
|
259
|
-
"expected `training list` or `training review`",
|
|
272
|
+
"expected `training list`, `training evaluate`, or `training review`",
|
|
260
273
|
dependencies,
|
|
261
274
|
);
|
|
262
275
|
}
|
|
@@ -370,6 +383,120 @@ async function runTrainingList(
|
|
|
370
383
|
return 0;
|
|
371
384
|
}
|
|
372
385
|
|
|
386
|
+
async function runTrainingEvaluate(
|
|
387
|
+
args: ReadonlyArray<string>,
|
|
388
|
+
dependencies: CliDependencies,
|
|
389
|
+
): Promise<number> {
|
|
390
|
+
const operation = "training.evaluate";
|
|
391
|
+
const parsedResult = parseArguments(
|
|
392
|
+
args,
|
|
393
|
+
new Set(["json", "replay"]),
|
|
394
|
+
new Set(["limit"]),
|
|
395
|
+
);
|
|
396
|
+
const json = args.includes("--json");
|
|
397
|
+
if (parsedResult.error !== undefined) {
|
|
398
|
+
return writeTrainingUsageError(
|
|
399
|
+
operation,
|
|
400
|
+
json,
|
|
401
|
+
parsedResult.error,
|
|
402
|
+
dependencies,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const parsed = parsedResult.parsed;
|
|
407
|
+
if (parsed.positionals.length !== 0) {
|
|
408
|
+
return writeTrainingUsageError(
|
|
409
|
+
operation,
|
|
410
|
+
hasOption(parsed, "json"),
|
|
411
|
+
"`training evaluate` does not accept positional arguments",
|
|
412
|
+
dependencies,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const limitValue = getStringOption(parsed, "limit");
|
|
417
|
+
if (limitValue !== undefined && !hasOption(parsed, "replay")) {
|
|
418
|
+
return writeTrainingUsageError(
|
|
419
|
+
operation,
|
|
420
|
+
hasOption(parsed, "json"),
|
|
421
|
+
"`--limit` requires `--replay`",
|
|
422
|
+
dependencies,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const replayLimit = limitValue === undefined ? 20 : Number(limitValue);
|
|
426
|
+
if (
|
|
427
|
+
!Number.isInteger(replayLimit) ||
|
|
428
|
+
replayLimit < 1 ||
|
|
429
|
+
replayLimit > 100
|
|
430
|
+
) {
|
|
431
|
+
return writeTrainingUsageError(
|
|
432
|
+
operation,
|
|
433
|
+
hasOption(parsed, "json"),
|
|
434
|
+
"`--limit` must be an integer from 1 to 100",
|
|
435
|
+
dependencies,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const entries = await loadTrainingEntries(dependencies);
|
|
440
|
+
const report = analyzeTrainingFeedback(entries);
|
|
441
|
+
const replay = hasOption(parsed, "replay")
|
|
442
|
+
? await replayTrainingQuestions(
|
|
443
|
+
entries,
|
|
444
|
+
dependencies.judgeTrainingState,
|
|
445
|
+
replayLimit,
|
|
446
|
+
)
|
|
447
|
+
: undefined;
|
|
448
|
+
if (hasOption(parsed, "json")) {
|
|
449
|
+
writeTrainingJsonSuccess(
|
|
450
|
+
operation,
|
|
451
|
+
replay === undefined ? report : { offline: report, replay },
|
|
452
|
+
dependencies,
|
|
453
|
+
);
|
|
454
|
+
return 0;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
dependencies.stdout(
|
|
458
|
+
`Reviewed ${report.reviewed}; evaluable ${report.evaluable}; unavailable ${report.unavailable}.`,
|
|
459
|
+
);
|
|
460
|
+
dependencies.stdout(
|
|
461
|
+
`Replay fidelity: ${report.completeReplayRecords} complete, ${report.policyOnlyRecords} policy-only legacy.`,
|
|
462
|
+
);
|
|
463
|
+
dependencies.stdout(
|
|
464
|
+
`Current policy: ${report.current.matches}/${report.current.evaluated} match; weighted loss ${report.current.weightedLoss}.`,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const reasons = Object.entries(report.correctionsByReason);
|
|
468
|
+
if (reasons.length > 0) {
|
|
469
|
+
dependencies.stdout(
|
|
470
|
+
`Correction reasons: ${reasons.map(([reason, count]) => `${reason}=${count}`).join(", ")}.`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (report.candidates.length === 0) {
|
|
475
|
+
dependencies.stdout("No single-threshold candidate improved observed weighted loss.");
|
|
476
|
+
} else {
|
|
477
|
+
dependencies.stdout("Exploratory single-threshold candidates:");
|
|
478
|
+
for (const candidate of report.candidates) {
|
|
479
|
+
dependencies.stdout(
|
|
480
|
+
` ${candidate.field}=${candidate.value}: ${candidate.metrics.matches}/${candidate.metrics.evaluated} match; weighted loss ${candidate.metrics.weightedLoss}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (report.warning !== undefined) dependencies.stdout(`Warning: ${report.warning}`);
|
|
486
|
+
dependencies.stdout(
|
|
487
|
+
"Candidates are not applied automatically; validate them on an independent holdout and the synthetic corpus.",
|
|
488
|
+
);
|
|
489
|
+
if (replay !== undefined) {
|
|
490
|
+
dependencies.stdout(
|
|
491
|
+
`Current questions replay: ${replay.metrics.matches}/${replay.metrics.evaluated} match; ${replay.improved} improved; ${replay.regressed} regressed; ${replay.unavailable} unavailable; ${replay.skipped} skipped by limit.`,
|
|
492
|
+
);
|
|
493
|
+
dependencies.stdout(
|
|
494
|
+
`Replay usage: ${replay.inputTokens} input / ${replay.outputTokens} output tokens. Stored commands remained data and were never executed.`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
373
500
|
async function runTrainingReview(
|
|
374
501
|
args: ReadonlyArray<string>,
|
|
375
502
|
dependencies: CliDependencies,
|
|
@@ -378,7 +505,7 @@ async function runTrainingReview(
|
|
|
378
505
|
const parsedResult = parseArguments(
|
|
379
506
|
args,
|
|
380
507
|
new Set(["json"]),
|
|
381
|
-
new Set(["decision", "note"]),
|
|
508
|
+
new Set(["decision", "reason", "note"]),
|
|
382
509
|
);
|
|
383
510
|
const json = args.includes("--json");
|
|
384
511
|
if (parsedResult.error !== undefined) {
|
|
@@ -423,15 +550,37 @@ async function runTrainingReview(
|
|
|
423
550
|
);
|
|
424
551
|
}
|
|
425
552
|
|
|
553
|
+
const corrected = decisionValue !== entry.record.verdict.decision;
|
|
554
|
+
const reasonValue = getStringOption(parsed, "reason");
|
|
555
|
+
if (corrected && !isTrainingCorrectionReason(reasonValue)) {
|
|
556
|
+
return writeTrainingUsageError(
|
|
557
|
+
operation,
|
|
558
|
+
hasOption(parsed, "json"),
|
|
559
|
+
"corrected reviews require `--reason` with a supported correction reason",
|
|
560
|
+
dependencies,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (!corrected && reasonValue !== undefined) {
|
|
564
|
+
return writeTrainingUsageError(
|
|
565
|
+
operation,
|
|
566
|
+
hasOption(parsed, "json"),
|
|
567
|
+
"`--reason` is only valid when correcting the model decision",
|
|
568
|
+
dependencies,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const correctionReason = corrected && isTrainingCorrectionReason(reasonValue)
|
|
573
|
+
? reasonValue
|
|
574
|
+
: undefined;
|
|
426
575
|
const previousReview = getLatestTrainingReview(entry) ?? null;
|
|
427
576
|
const review = await dependencies.recordTrainingReview(
|
|
428
577
|
createTrainingReviewInput(
|
|
429
578
|
entry.record,
|
|
430
579
|
decisionValue,
|
|
580
|
+
correctionReason,
|
|
431
581
|
getStringOption(parsed, "note"),
|
|
432
582
|
),
|
|
433
583
|
);
|
|
434
|
-
const corrected = decisionValue !== entry.record.verdict.decision;
|
|
435
584
|
|
|
436
585
|
if (hasOption(parsed, "json")) {
|
|
437
586
|
writeTrainingJsonSuccess(
|
package/src/guard.internal.ts
CHANGED
|
@@ -6,14 +6,91 @@ import {
|
|
|
6
6
|
Predicate,
|
|
7
7
|
Result,
|
|
8
8
|
} from "effect";
|
|
9
|
-
import { judgeEffect, Judgment } from "./judge.ts";
|
|
9
|
+
import { judgeEffect, Judgment, TYPESAFE_MODEL } from "./judge.ts";
|
|
10
10
|
import { Environment } from "./key.ts";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
import {
|
|
12
|
+
applyStaticGate,
|
|
13
|
+
decide,
|
|
14
|
+
POLICY_VERSION,
|
|
15
|
+
THRESHOLDS,
|
|
16
|
+
} from "./policy.ts";
|
|
17
|
+
import { QUESTION_SET_VERSION } from "./questions.ts";
|
|
18
|
+
import { gatherStateEffect, GitCommand, renderState } from "./state.ts";
|
|
19
|
+
import type {
|
|
20
|
+
CommandState,
|
|
21
|
+
GuardEvaluation,
|
|
22
|
+
Host,
|
|
23
|
+
Verdict,
|
|
24
|
+
} from "./types.ts";
|
|
14
25
|
|
|
15
26
|
const PREFIX = "demur:";
|
|
16
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Effect-native guard implementation with replayable evidence.
|
|
30
|
+
*
|
|
31
|
+
* @param command - The shell command the agent wants to run
|
|
32
|
+
* @param cwd - Absolute working directory for the command
|
|
33
|
+
* @param agent - Which coding agent is asking
|
|
34
|
+
* @returns A fail-closed verdict and the exact evidence behind it
|
|
35
|
+
*/
|
|
36
|
+
export const guardEvaluationEffect = Effect.fn("guardEvaluationEffect")(
|
|
37
|
+
function* (
|
|
38
|
+
command: string,
|
|
39
|
+
cwd: string,
|
|
40
|
+
agent: Host,
|
|
41
|
+
): Effect.fn.Return<
|
|
42
|
+
GuardEvaluation,
|
|
43
|
+
never,
|
|
44
|
+
Environment | GitCommand | Judgment
|
|
45
|
+
> {
|
|
46
|
+
const started = yield* Clock.monotonicTimeNanos;
|
|
47
|
+
const core = Effect.gen(function* () {
|
|
48
|
+
const environment = yield* Environment;
|
|
49
|
+
const disabled = isDisabled(yield* environment.get("DEMUR_DISABLE"));
|
|
50
|
+
|
|
51
|
+
if (disabled) {
|
|
52
|
+
const verdict = yield* completeVerdict(started, {
|
|
53
|
+
...emptyEvidence,
|
|
54
|
+
decision: "allow",
|
|
55
|
+
reason: `${PREFIX} disabled via DEMUR_DISABLE.`,
|
|
56
|
+
});
|
|
57
|
+
return { verdict, evidence: undefined } satisfies GuardEvaluation;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (command.trim() === "") {
|
|
61
|
+
const verdict = yield* completeVerdict(started, {
|
|
62
|
+
...emptyEvidence,
|
|
63
|
+
decision: "allow",
|
|
64
|
+
reason: `${PREFIX} empty command.`,
|
|
65
|
+
});
|
|
66
|
+
return { verdict, evidence: undefined } satisfies GuardEvaluation;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const state = yield* gatherStateEffect(command, cwd, agent);
|
|
70
|
+
const verdict = yield* judgeStateCore(state, started, 0);
|
|
71
|
+
return {
|
|
72
|
+
verdict,
|
|
73
|
+
evidence: {
|
|
74
|
+
modelState: renderState(state),
|
|
75
|
+
analysis: state.analysis,
|
|
76
|
+
model: TYPESAFE_MODEL,
|
|
77
|
+
questionSetVersion: QUESTION_SET_VERSION,
|
|
78
|
+
policyVersion: POLICY_VERSION,
|
|
79
|
+
policyThresholds: { ...THRESHOLDS },
|
|
80
|
+
},
|
|
81
|
+
} satisfies GuardEvaluation;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const exit = yield* Effect.exit(core);
|
|
85
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
verdict: yield* unexpectedVerdict(started, exit.cause, 0),
|
|
89
|
+
evidence: undefined,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
17
94
|
/**
|
|
18
95
|
* Effect-native guard implementation used by the Promise boundary.
|
|
19
96
|
*
|
|
@@ -27,35 +104,8 @@ export const guardEffect = Effect.fn("guardEffect")(function* (
|
|
|
27
104
|
cwd: string,
|
|
28
105
|
agent: Host,
|
|
29
106
|
): Effect.fn.Return<Verdict, never, Environment | GitCommand | Judgment> {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const environment = yield* Environment;
|
|
33
|
-
const disabled = isDisabled(yield* environment.get("DEMUR_DISABLE"));
|
|
34
|
-
|
|
35
|
-
if (disabled) {
|
|
36
|
-
return yield* completeVerdict(started, {
|
|
37
|
-
...emptyEvidence,
|
|
38
|
-
decision: "allow",
|
|
39
|
-
reason: `${PREFIX} disabled via DEMUR_DISABLE.`,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (command.trim() === "") {
|
|
44
|
-
return yield* completeVerdict(started, {
|
|
45
|
-
...emptyEvidence,
|
|
46
|
-
decision: "allow",
|
|
47
|
-
reason: `${PREFIX} empty command.`,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const state = yield* gatherStateEffect(command, cwd, agent);
|
|
52
|
-
return yield* judgeStateCore(state, started, 0);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const exit = yield* Effect.exit(core);
|
|
56
|
-
if (Exit.isSuccess(exit)) return exit.value;
|
|
57
|
-
|
|
58
|
-
return yield* unexpectedVerdict(started, exit.cause, 0);
|
|
107
|
+
const evaluation = yield* guardEvaluationEffect(command, cwd, agent);
|
|
108
|
+
return evaluation.verdict;
|
|
59
109
|
});
|
|
60
110
|
|
|
61
111
|
/**
|
package/src/guard.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Layer, ManagedRuntime, Predicate } from "effect";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
guardEffect,
|
|
4
|
+
guardEvaluationEffect,
|
|
5
|
+
judgeStateEffect,
|
|
6
|
+
} from "./guard.internal.ts";
|
|
3
7
|
import { Judgment } from "./judge.ts";
|
|
4
8
|
import { Environment } from "./key.ts";
|
|
5
9
|
import { GitCommand } from "./state.ts";
|
|
6
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
CommandState,
|
|
12
|
+
GuardEvaluation,
|
|
13
|
+
Host,
|
|
14
|
+
Verdict,
|
|
15
|
+
} from "./types.ts";
|
|
7
16
|
|
|
8
17
|
const PREFIX = "demur:";
|
|
9
18
|
|
|
@@ -41,6 +50,37 @@ export function guard(
|
|
|
41
50
|
.catch((error: unknown) => unexpectedVerdict(error, started));
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Judge one command while retaining the exact state and policy versions.
|
|
55
|
+
*
|
|
56
|
+
* This boundary is used only when a host has enabled local training capture;
|
|
57
|
+
* ordinary guard callers continue to receive the smaller {@link Verdict}.
|
|
58
|
+
*
|
|
59
|
+
* @param command - The shell command the agent wants to run
|
|
60
|
+
* @param cwd - Absolute working directory for the command
|
|
61
|
+
* @param agent - Which coding agent is asking
|
|
62
|
+
* @param signal - Optional cancellation signal from the host
|
|
63
|
+
* @returns The verdict and replayable evidence, when state collection occurred
|
|
64
|
+
*/
|
|
65
|
+
export function guardWithEvidence(
|
|
66
|
+
command: string,
|
|
67
|
+
cwd: string,
|
|
68
|
+
agent: Host,
|
|
69
|
+
signal: AbortSignal | undefined = undefined,
|
|
70
|
+
): Promise<GuardEvaluation> {
|
|
71
|
+
const started = performance.now();
|
|
72
|
+
|
|
73
|
+
return runtime
|
|
74
|
+
.runPromise(
|
|
75
|
+
guardEvaluationEffect(command, cwd, agent),
|
|
76
|
+
signal === undefined ? undefined : { signal },
|
|
77
|
+
)
|
|
78
|
+
.catch((error: unknown) => ({
|
|
79
|
+
verdict: unexpectedVerdict(error, started),
|
|
80
|
+
evidence: undefined,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
44
84
|
/**
|
|
45
85
|
* Judge a command from state that has already been collected.
|
|
46
86
|
*
|
package/src/judge.ts
CHANGED
|
@@ -19,7 +19,12 @@ import {
|
|
|
19
19
|
} from "./key.ts";
|
|
20
20
|
import { COMMAND_JUDGMENTS } from "./questions.ts";
|
|
21
21
|
import { renderState } from "./state.ts";
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
CommandState,
|
|
24
|
+
FailureKind,
|
|
25
|
+
Judgments,
|
|
26
|
+
RenderedCommandState,
|
|
27
|
+
} from "./types.ts";
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Default per-attempt timeout for the judgment call.
|
|
@@ -29,7 +34,10 @@ import type { CommandState, FailureKind, Judgments } from "./types.ts";
|
|
|
29
34
|
*/
|
|
30
35
|
const DEFAULT_TIMEOUT_MS = 4000;
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
/**
|
|
38
|
+
* TypeSafe model used for command judgments.
|
|
39
|
+
*/
|
|
40
|
+
export const TYPESAFE_MODEL = "jev-latest";
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
43
|
* Build the TypeSafe-backed Effect decision model for one API key.
|
|
@@ -90,7 +98,9 @@ export class JudgmentError extends Schema.TaggedError<JudgmentError>()(
|
|
|
90
98
|
export class Judgment extends Context.Service<
|
|
91
99
|
Judgment,
|
|
92
100
|
{
|
|
93
|
-
judge(
|
|
101
|
+
judge(
|
|
102
|
+
state: RenderedCommandState,
|
|
103
|
+
): Effect.Effect<JudgeSuccess, JudgmentError>;
|
|
94
104
|
}
|
|
95
105
|
>()("demur/judge/Judgment") {
|
|
96
106
|
static readonly layerNoDeps = Layer.effect(
|
|
@@ -134,11 +144,11 @@ export class Judgment extends Context.Service<
|
|
|
134
144
|
);
|
|
135
145
|
|
|
136
146
|
const judge = Effect.fn("Judgment.judge")(function* (
|
|
137
|
-
state:
|
|
147
|
+
state: RenderedCommandState,
|
|
138
148
|
): Effect.fn.Return<JudgeSuccess, JudgmentError> {
|
|
139
149
|
const activeDecisionLayer = yield* getDecisionLayer();
|
|
140
150
|
const result = yield* DecisionModel.decide(COMMAND_JUDGMENTS, {
|
|
141
|
-
input:
|
|
151
|
+
input: state,
|
|
142
152
|
}).pipe(
|
|
143
153
|
Effect.provide(activeDecisionLayer),
|
|
144
154
|
Effect.timeout(timeoutMs),
|
|
@@ -188,9 +198,24 @@ export const judgeEffect = Effect.fn("judgeEffect")(function* (
|
|
|
188
198
|
state: CommandState,
|
|
189
199
|
): Effect.fn.Return<JudgeSuccess, JudgmentError, Judgment> {
|
|
190
200
|
const judgment = yield* Judgment;
|
|
191
|
-
return yield* judgment.judge(state);
|
|
201
|
+
return yield* judgment.judge(renderState(state));
|
|
192
202
|
});
|
|
193
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Ask the configured judgment service about already-rendered TypeSafe state.
|
|
206
|
+
*
|
|
207
|
+
* @param state - Exact JSON state captured from an earlier invocation
|
|
208
|
+
* @returns The current question set's judgments in the Effect error channel
|
|
209
|
+
*/
|
|
210
|
+
export const judgeRenderedStateEffect = Effect.fn("judgeRenderedStateEffect")(
|
|
211
|
+
function* (
|
|
212
|
+
state: RenderedCommandState,
|
|
213
|
+
): Effect.fn.Return<JudgeSuccess, JudgmentError, Judgment> {
|
|
214
|
+
const judgment = yield* Judgment;
|
|
215
|
+
return yield* judgment.judge(state);
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
194
219
|
const runtime = ManagedRuntime.make(Judgment.layer);
|
|
195
220
|
|
|
196
221
|
/**
|
|
@@ -206,9 +231,30 @@ const runtime = ManagedRuntime.make(Judgment.layer);
|
|
|
206
231
|
export function judge(
|
|
207
232
|
state: CommandState,
|
|
208
233
|
signal: AbortSignal | undefined = undefined,
|
|
234
|
+
): Promise<JudgeResult> {
|
|
235
|
+
return runJudgment(judgeEffect(state), signal);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Re-run the current question set against exact previously captured state.
|
|
240
|
+
*
|
|
241
|
+
* @param state - Exact model-visible state from a version-two training record
|
|
242
|
+
* @param signal - Optional cancellation signal
|
|
243
|
+
* @returns Current raw judgments or a typed failure
|
|
244
|
+
*/
|
|
245
|
+
export function judgeRenderedState(
|
|
246
|
+
state: RenderedCommandState,
|
|
247
|
+
signal: AbortSignal | undefined = undefined,
|
|
248
|
+
): Promise<JudgeResult> {
|
|
249
|
+
return runJudgment(judgeRenderedStateEffect(state), signal);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function runJudgment(
|
|
253
|
+
effect: Effect.Effect<JudgeSuccess, JudgmentError, Judgment>,
|
|
254
|
+
signal: AbortSignal | undefined,
|
|
209
255
|
): Promise<JudgeResult> {
|
|
210
256
|
const program = Effect.gen(function* () {
|
|
211
|
-
const result = yield* Effect.result(
|
|
257
|
+
const result = yield* Effect.result(effect);
|
|
212
258
|
if (Result.isFailure(result)) {
|
|
213
259
|
return {
|
|
214
260
|
ok: false,
|
package/src/policy.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { CommandAnalysis } from "./analyze.ts";
|
|
2
2
|
import type { Decision, Judgments } from "./types.ts";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Version of the policy ordering and threshold semantics.
|
|
6
|
+
*
|
|
7
|
+
* Increment this whenever composition behavior changes. Threshold values are
|
|
8
|
+
* captured separately by offline evaluation, so candidate sweeps do not need a
|
|
9
|
+
* version bump until a candidate is promoted.
|
|
10
|
+
*/
|
|
11
|
+
export const POLICY_VERSION = 2;
|
|
12
|
+
|
|
4
13
|
/**
|
|
5
14
|
* Decision thresholds applied to raw judgments.
|
|
6
15
|
*
|
|
@@ -81,7 +90,7 @@ export const THRESHOLDS: Thresholds = {
|
|
|
81
90
|
denyUnrecoverable: 0.45,
|
|
82
91
|
denySharedInfrastructure: 0.5,
|
|
83
92
|
denyBlastRadius: 2.0,
|
|
84
|
-
minBlastRadiusConfidence: 0.
|
|
93
|
+
minBlastRadiusConfidence: 0.95,
|
|
85
94
|
uncertaintyBand: 0.04,
|
|
86
95
|
uncertaintyBandScore: 0.12,
|
|
87
96
|
staticGateMinExecution: 0.8,
|
package/src/questions.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
import { Decision } from "effect/unstable/ai";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Version of the complete TypeSafe question set.
|
|
6
|
+
*
|
|
7
|
+
* Increment this whenever question instructions, criteria, or primitives change
|
|
8
|
+
* so captured training evidence remains attributable to its exact semantics.
|
|
9
|
+
*/
|
|
10
|
+
export const QUESTION_SET_VERSION = 2;
|
|
11
|
+
|
|
4
12
|
/**
|
|
5
13
|
* The judgment set demur asks about every command.
|
|
6
14
|
*
|
|
@@ -35,7 +43,7 @@ export const QUESTIONS = {
|
|
|
35
43
|
criteria: {
|
|
36
44
|
true: "It reads a value from an environment variable, file, credential or secret store, database, or remote system and prints, logs, copies, or transmits it. Read-only output still counts.",
|
|
37
45
|
false:
|
|
38
|
-
"It reveals only names, metadata, schema, attributes, or redacted values; rotates or revokes without revealing; or quotes or prints secret-looking names or commands as documentation, comments, or other inert payload.",
|
|
46
|
+
"It reveals only names, metadata, schema, attributes, or redacted values; looks up or describes a credential item without requesting its secret value; rotates or revokes without revealing; or quotes or prints secret-looking names or commands as documentation, comments, or other inert payload. For example, `security find-generic-password -s NAME` without `-w` prints item attributes rather than the password value.",
|
|
39
47
|
},
|
|
40
48
|
}),
|
|
41
49
|
|