@wyattjoh/demur 0.4.2 → 0.5.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 +79 -21
- package/extensions/demur/cost-tracker.ts +31 -6
- package/extensions/demur/index.ts +150 -36
- package/extensions/demur/paths.ts +46 -0
- package/extensions/demur/settings.ts +77 -19
- package/extensions/demur/training-store.ts +334 -0
- package/package.json +10 -2
- package/src/cli.ts +237 -0
- package/src/training-review-model.ts +176 -0
- package/src/training-review-tui.tsx +947 -0
package/README.md
CHANGED
|
@@ -48,6 +48,12 @@ 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, working directory, operating
|
|
52
|
+
mode, full verdict and judgments, and resulting host action locally. These
|
|
53
|
+
records can therefore contain secrets or sensitive names from shell arguments
|
|
54
|
+
and paths. Training capture is off by default and is unavailable while the Pi
|
|
55
|
+
integration is disabled.
|
|
56
|
+
|
|
51
57
|
## Requirements
|
|
52
58
|
|
|
53
59
|
- [Bun](https://bun.sh/) 1.4 or newer
|
|
@@ -92,7 +98,7 @@ Pin a specific release when reproducibility matters:
|
|
|
92
98
|
|
|
93
99
|
<!-- x-release-please-start-version -->
|
|
94
100
|
```sh
|
|
95
|
-
pi install npm:@wyattjoh/demur@0.
|
|
101
|
+
pi install npm:@wyattjoh/demur@0.5.0
|
|
96
102
|
```
|
|
97
103
|
<!-- x-release-please-end -->
|
|
98
104
|
|
|
@@ -109,23 +115,34 @@ command request and resulting verdict cross its local stdio pipes. `ask` opens
|
|
|
109
115
|
an interactive confirmation dialog; without an interactive UI, demur blocks the
|
|
110
116
|
command.
|
|
111
117
|
|
|
112
|
-
Use `/demur` to open the extension menu.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
Use `/demur` to open the extension menu. Its global operating mode is:
|
|
119
|
+
|
|
120
|
+
- `enforce` (default) applies `allow`, `ask`, and `deny` decisions normally.
|
|
121
|
+
- `passive` still judges every Bash call and prints the diagnostic, but never
|
|
122
|
+
prompts or blocks because of the verdict.
|
|
123
|
+
- `disabled` bypasses the worker and allows Bash calls without judgment.
|
|
124
|
+
|
|
125
|
+
Training capture can be enabled independently in `enforce` or `passive` mode.
|
|
126
|
+
It is automatically turned off when the integration is disabled. Pi's bottom
|
|
127
|
+
status bar always shows the current mode and whether training is active so every
|
|
128
|
+
bypass or local recording state remains visible.
|
|
129
|
+
|
|
130
|
+
The menu also controls what enforce mode does when demur cannot obtain a
|
|
131
|
+
trustworthy judgment because of a missing credential, timeout, API error,
|
|
132
|
+
malformed worker response, or unexpected guard failure:
|
|
116
133
|
|
|
117
134
|
- `block` (default) fails closed.
|
|
118
135
|
- `ask` requests interactive confirmation and blocks when no UI is available.
|
|
119
136
|
- `allow` fails open without confirmation.
|
|
120
137
|
|
|
121
|
-
|
|
122
|
-
bottom status bar always shows `demur: enabled` or `demur: disabled` so this
|
|
123
|
-
bypass remains visible.
|
|
124
|
-
|
|
125
|
-
Both settings are stored globally at `$XDG_CONFIG_HOME/demur/config.json`, or
|
|
138
|
+
Settings are stored globally at `$XDG_CONFIG_HOME/demur/config.json`, or
|
|
126
139
|
`~/.config/demur/config.json` when `XDG_CONFIG_HOME` is unset, and apply to
|
|
127
|
-
future Pi sessions.
|
|
128
|
-
|
|
140
|
+
future Pi sessions. Set `DEMUR_CONFIG_HOME` to use an isolated demur directory;
|
|
141
|
+
`config.json` is read and written directly beneath it. This demur-specific
|
|
142
|
+
override takes precedence over the XDG and home-directory locations. While
|
|
143
|
+
demur is enforcing, the failure policy never changes a completed `deny` policy
|
|
144
|
+
judgment; those commands remain blocked. Passive mode reports failures but does
|
|
145
|
+
not apply the failure policy because it never blocks.
|
|
129
146
|
|
|
130
147
|
After each run, Pi's interactive UI prints the decision, submitted input-token
|
|
131
148
|
count, the run's estimated input cost, the accumulated global estimate, and the
|
|
@@ -142,6 +159,16 @@ file before an atomic rename so the total cannot be partially written or lose a
|
|
|
142
159
|
concurrent increment. Cost-accounting failures do not change demur's guard
|
|
143
160
|
decision; the status reports `accumulated unavailable` instead.
|
|
144
161
|
|
|
162
|
+
Training evaluations are appended as private, versioned JSONL records at
|
|
163
|
+
`$XDG_STATE_HOME/demur/training.jsonl`, with the same home-directory fallback.
|
|
164
|
+
Set `DEMUR_STATE_HOME` to place `usage.json`, `training.jsonl`, and
|
|
165
|
+
`training-reviews.jsonl` directly beneath an isolated directory instead. This
|
|
166
|
+
demur-specific override takes precedence over the XDG and home-directory
|
|
167
|
+
locations. Training records are retained until the user removes them. Human
|
|
168
|
+
reviews are appended separately; accepted and corrected records are linked by a
|
|
169
|
+
stable record ID, leaving the original evidence unchanged. A training-write
|
|
170
|
+
failure is reported but never changes whether the command runs.
|
|
171
|
+
|
|
145
172
|
Pi packages execute with the user's full system permissions. Review this
|
|
146
173
|
repository before installing it.
|
|
147
174
|
|
|
@@ -182,16 +209,43 @@ Claude Code's `hookSpecificOutput.permissionDecision` response.
|
|
|
182
209
|
|
|
183
210
|
### CLI
|
|
184
211
|
|
|
185
|
-
|
|
186
|
-
integration:
|
|
212
|
+
Launch the central interface, manage the stored credential, or judge a single
|
|
213
|
+
command without a host integration:
|
|
187
214
|
|
|
188
215
|
```sh
|
|
216
|
+
demur
|
|
189
217
|
demur auth login
|
|
190
218
|
demur auth status
|
|
191
219
|
demur auth logout
|
|
220
|
+
demur training review
|
|
221
|
+
# Force the line-oriented interface for pipes or basic terminals:
|
|
222
|
+
demur training review --plain
|
|
192
223
|
demur judge "git reset --hard HEAD~3"
|
|
193
224
|
```
|
|
194
225
|
|
|
226
|
+
Bare `demur` opens the central OpenTUI interface when stdin and stdout are
|
|
227
|
+
interactive. `demur training review` remains an explicit alias for the same
|
|
228
|
+
interface. Its header shows the persisted global estimated cost, and each queue
|
|
229
|
+
row shows that evaluation's estimated input cost. It starts in an `all` view;
|
|
230
|
+
Tab and Shift-Tab rotate between `all`, `not reviewed`, `approved` (`allow`),
|
|
231
|
+
`ask`, and `deny` views. The queue is focused initially: arrow keys navigate it,
|
|
232
|
+
Up from its first result focuses a
|
|
233
|
+
fuzzy working-directory filter, and another Up focuses the tab strip. Left and
|
|
234
|
+
Right select adjacent focused tabs, while Down returns through the filter to the
|
|
235
|
+
queue. Right from the queue focuses the scrollable detail pane.
|
|
236
|
+
The detail pane supports arrows or `j`/`k`; Left returns to the queue. Page Up
|
|
237
|
+
and Page Down page within the focused pane, and queue navigation stops at its
|
|
238
|
+
first and last entries. Mouse clicks select tabs, records, the filter, or either
|
|
239
|
+
pane; the wheel scrolls the queue and detail pane. Enter selects the original
|
|
240
|
+
decision, `1`/`2`/`3` choose `allow`/`ask`/`deny`, `s`
|
|
241
|
+
leaves a record for a later pass, and `q` or Escape stops. Previously reviewed
|
|
242
|
+
records remain available, and changing an answer appends a review revision while
|
|
243
|
+
preserving its visible history. The TUI remains open when a view is empty and
|
|
244
|
+
polls training state for newly captured or externally reviewed evaluations.
|
|
245
|
+
Non-interactive invocations automatically use the line-oriented reviewer; pass
|
|
246
|
+
`--plain` to select it explicitly. Reviews remain separate from the original
|
|
247
|
+
evidence so they can later be curated into independently licensed eval fixtures.
|
|
248
|
+
|
|
195
249
|
From a development checkout, `bun run judge "<command>"` remains available.
|
|
196
250
|
|
|
197
251
|
## Configuration
|
|
@@ -200,6 +254,8 @@ From a development checkout, `bun run judge "<command>"` remains available.
|
|
|
200
254
|
| --- | --- | --- |
|
|
201
255
|
| `TYPESAFE_API_KEY` | stored credential | Optional TypeSafe API credential override. Missing keys fail closed. |
|
|
202
256
|
| `DEMUR_TIMEOUT_MS` | `4000` | Per-attempt model timeout in milliseconds. |
|
|
257
|
+
| `DEMUR_CONFIG_HOME` | XDG/home config | Demur-specific directory containing `config.json`. |
|
|
258
|
+
| `DEMUR_STATE_HOME` | XDG/home state | Demur-specific directory containing usage and training state. |
|
|
203
259
|
| `DEMUR_DISABLE` | unset | Emergency bypass. `1` or `true` allows every command. |
|
|
204
260
|
|
|
205
261
|
## Failure posture
|
|
@@ -209,12 +265,12 @@ timeout, API failure, malformed response, or unexpected guard error returns
|
|
|
209
265
|
`deny` with a reason that identifies the guard failure rather than presenting it
|
|
210
266
|
as a policy judgment. The Claude Code adapter and CLI preserve that verdict.
|
|
211
267
|
|
|
212
|
-
The Pi extension defaults to
|
|
213
|
-
its explicit `/demur` menu can globally
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
visible.
|
|
268
|
+
The Pi extension defaults to enforce mode with the same fail-closed behavior,
|
|
269
|
+
but its explicit `/demur` menu can globally select enforce, passive, or disabled
|
|
270
|
+
mode. The failure-policy override applies only in enforce mode when no
|
|
271
|
+
trustworthy judgment was produced; it cannot loosen a completed policy denial.
|
|
272
|
+
Passive mode always continues after reporting the underlying verdict, while the
|
|
273
|
+
bottom status bar keeps the active mode and training state visible.
|
|
218
274
|
|
|
219
275
|
`DEMUR_DISABLE=1` remains the cross-host emergency bypass. It disables judgment
|
|
220
276
|
and protection entirely and should remain unset during normal use.
|
|
@@ -248,7 +304,9 @@ and deterministic policy controls alongside demur.
|
|
|
248
304
|
- `src/key.ts` — environment precedence and operating-system credential storage
|
|
249
305
|
- `src/guard.internal.ts` — Effect-native orchestration and fail-closed recovery
|
|
250
306
|
- `src/guard.ts` — managed runtime and Promise boundary
|
|
251
|
-
- `
|
|
307
|
+
- `src/training-review-model.ts` — historical review status and cwd filtering
|
|
308
|
+
- `src/training-review-tui.tsx` — interactive OpenTUI training-review queue
|
|
309
|
+
- `extensions/demur/` — Pi `tool_call` integration and training-state storage
|
|
252
310
|
- `src/adapters/claude-code.ts` — Claude Code `PreToolUse` integration
|
|
253
311
|
- `eval/` — safe synthetic contrast cases and the live evaluation runner
|
|
254
312
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "node:fs/promises";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
|
+
import { getDemurStateDirectory } from "./paths.ts";
|
|
12
13
|
|
|
13
14
|
const LOCK_RETRY_MS = 10;
|
|
14
15
|
const LOCK_TIMEOUT_MS = 5_000;
|
|
@@ -31,9 +32,9 @@ export type CostTotals = {
|
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
* Resolve the global demur usage file
|
|
35
|
+
* Resolve the global demur usage file using demur-specific and XDG conventions.
|
|
35
36
|
*
|
|
36
|
-
* @param environment - Process environment used to resolve
|
|
37
|
+
* @param environment - Process environment used to resolve demur and XDG overrides
|
|
37
38
|
* @param homeDirectory - Home directory used when the XDG override is absent
|
|
38
39
|
* @returns Absolute path to demur's usage state file
|
|
39
40
|
*/
|
|
@@ -41,8 +42,10 @@ export function getCostStatePath(
|
|
|
41
42
|
environment: NodeJS.ProcessEnv = process.env,
|
|
42
43
|
homeDirectory: string = homedir(),
|
|
43
44
|
): string {
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
return join(
|
|
46
|
+
getDemurStateDirectory(environment, homeDirectory),
|
|
47
|
+
"usage.json",
|
|
48
|
+
);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/**
|
|
@@ -57,6 +60,17 @@ export function estimateInputCostUsd(inputTokens: number): number {
|
|
|
57
60
|
return (inputTokens * JEV_INPUT_COST_USD_PER_MILLION) / 1_000_000;
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Format an estimated US-dollar cost without hiding sub-cent evaluations.
|
|
65
|
+
*
|
|
66
|
+
* @param value - Estimated cost in US dollars
|
|
67
|
+
* @returns Dollar-prefixed cost with up to nine fractional digits
|
|
68
|
+
*/
|
|
69
|
+
export function formatUsd(value: number): string {
|
|
70
|
+
const decimal = value.toFixed(9).replace(/0+$/, "").replace(/\.$/, "");
|
|
71
|
+
return `$${decimal}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
/**
|
|
61
75
|
* Atomically add one judgment's usage to the global accumulated estimate.
|
|
62
76
|
*
|
|
@@ -80,7 +94,7 @@ export async function recordInputCost(
|
|
|
80
94
|
const release = await acquireLock(`${statePath}.lock`);
|
|
81
95
|
|
|
82
96
|
try {
|
|
83
|
-
const current = await
|
|
97
|
+
const current = await loadCostTotals(statePath);
|
|
84
98
|
const next: CostTotals = {
|
|
85
99
|
version: 1,
|
|
86
100
|
totalInputTokens: current.totalInputTokens + inputTokens,
|
|
@@ -114,7 +128,18 @@ async function acquireLock(lockPath: string): Promise<() => Promise<void>> {
|
|
|
114
128
|
}
|
|
115
129
|
}
|
|
116
130
|
|
|
117
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Load the persisted global usage and estimated-cost totals.
|
|
133
|
+
*
|
|
134
|
+
* A missing state file represents zero recorded usage. Invalid state fails
|
|
135
|
+
* rather than returning a misleading total.
|
|
136
|
+
*
|
|
137
|
+
* @param statePath - Usage file to read
|
|
138
|
+
* @returns Persisted totals, or zero totals when the file does not exist
|
|
139
|
+
*/
|
|
140
|
+
export async function loadCostTotals(
|
|
141
|
+
statePath: string = getCostStatePath(),
|
|
142
|
+
): Promise<CostTotals> {
|
|
118
143
|
let content: string;
|
|
119
144
|
try {
|
|
120
145
|
content = await readFile(statePath, "utf8");
|
|
@@ -10,17 +10,21 @@ import {
|
|
|
10
10
|
import type { Verdict } from "../../src/types.ts";
|
|
11
11
|
import {
|
|
12
12
|
estimateInputCostUsd,
|
|
13
|
+
formatUsd,
|
|
13
14
|
recordInputCost,
|
|
14
15
|
} from "./cost-tracker.ts";
|
|
15
16
|
import {
|
|
16
17
|
DEFAULT_DEMUR_SETTINGS,
|
|
18
|
+
DEMUR_MODES,
|
|
17
19
|
FAILURE_POLICIES,
|
|
18
20
|
loadDemurSettings,
|
|
21
|
+
parseDemurMode,
|
|
19
22
|
parseFailurePolicy,
|
|
20
23
|
saveDemurSettings,
|
|
21
24
|
type DemurSettings,
|
|
22
25
|
type FailurePolicy,
|
|
23
26
|
} from "./settings.ts";
|
|
27
|
+
import { recordTrainingEvaluation } from "./training-store.ts";
|
|
24
28
|
|
|
25
29
|
const WORKER_PATH = fileURLToPath(
|
|
26
30
|
new URL("../../src/adapters/pi-worker.ts", import.meta.url),
|
|
@@ -44,7 +48,7 @@ export async function handleToolCall(
|
|
|
44
48
|
settings: DemurSettings = DEFAULT_DEMUR_SETTINGS,
|
|
45
49
|
): Promise<ToolCallEventResult | undefined> {
|
|
46
50
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
47
|
-
if (
|
|
51
|
+
if (settings.mode === "disabled") return undefined;
|
|
48
52
|
|
|
49
53
|
const command = event.input.command ?? "";
|
|
50
54
|
if (command.trim() === "") return undefined;
|
|
@@ -70,28 +74,45 @@ export async function handleToolCall(
|
|
|
70
74
|
};
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
verdict = {
|
|
78
|
+
decision: "deny",
|
|
79
|
+
reason: `demur: guard worker crashed — ${errorDetail(error)}`,
|
|
80
|
+
judgments: undefined,
|
|
81
|
+
failure: "unexpected",
|
|
82
|
+
latencyMs: evaluationMs,
|
|
83
|
+
usage: undefined,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const evaluationMs = performance.now() - evaluationStartedAt;
|
|
88
|
+
const inputTokens = verdict.usage?.inputTokens;
|
|
89
|
+
const accumulatedCostUsd = await recordAccumulatedCost(inputTokens);
|
|
90
|
+
const result = settings.mode === "passive"
|
|
91
|
+
? resolvePassiveVerdict(
|
|
92
|
+
verdict,
|
|
93
|
+
ctx,
|
|
94
|
+
accumulatedCostUsd,
|
|
95
|
+
evaluationMs,
|
|
96
|
+
)
|
|
97
|
+
: await resolveVerdict(
|
|
98
|
+
verdict,
|
|
75
99
|
command,
|
|
76
100
|
ctx,
|
|
77
101
|
settings.failurePolicy,
|
|
78
|
-
|
|
79
|
-
undefined,
|
|
102
|
+
accumulatedCostUsd,
|
|
80
103
|
evaluationMs,
|
|
81
104
|
);
|
|
82
|
-
}
|
|
83
105
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
106
|
+
if (settings.training) {
|
|
107
|
+
await recordTrainingResult(
|
|
108
|
+
command,
|
|
109
|
+
ctx,
|
|
110
|
+
settings.mode,
|
|
111
|
+
verdict,
|
|
112
|
+
result,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
/**
|
|
@@ -185,6 +206,40 @@ export async function resolveVerdict(
|
|
|
185
206
|
return { block: true, reason: `${verdict.reason} Declined by the user.` };
|
|
186
207
|
}
|
|
187
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Report a completed verdict without allowing it to affect execution.
|
|
211
|
+
*
|
|
212
|
+
* Passive mode never prompts and never returns a block result. Failures remain
|
|
213
|
+
* visible as failures rather than being mapped through the enforcement-only
|
|
214
|
+
* failure policy.
|
|
215
|
+
*
|
|
216
|
+
* @param verdict - Completed demur guard result
|
|
217
|
+
* @param ctx - Pi extension context used for notifications
|
|
218
|
+
* @param accumulatedCostUsd - Persisted global estimate after this run
|
|
219
|
+
* @param evaluationMs - Wall-clock time spent obtaining the guard verdict
|
|
220
|
+
* @returns Nothing so Pi continues with the command
|
|
221
|
+
*/
|
|
222
|
+
export function resolvePassiveVerdict(
|
|
223
|
+
verdict: Verdict,
|
|
224
|
+
ctx: ExtensionContext,
|
|
225
|
+
accumulatedCostUsd: number | undefined,
|
|
226
|
+
evaluationMs: number,
|
|
227
|
+
): undefined {
|
|
228
|
+
const source = verdict.failure === undefined
|
|
229
|
+
? verdict.decision.toUpperCase()
|
|
230
|
+
: "FAILURE";
|
|
231
|
+
const result = `PASSIVE: ${source} · NOT ENFORCED`;
|
|
232
|
+
notifyRun(
|
|
233
|
+
ctx,
|
|
234
|
+
result,
|
|
235
|
+
verdict.usage?.inputTokens,
|
|
236
|
+
accumulatedCostUsd,
|
|
237
|
+
evaluationMs,
|
|
238
|
+
source === "ALLOW" ? "info" : "warning",
|
|
239
|
+
);
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
188
243
|
/**
|
|
189
244
|
* Format the compact status Pi prints after each demur run.
|
|
190
245
|
*
|
|
@@ -326,14 +381,40 @@ export default function demur(pi: ExtensionAPI): void {
|
|
|
326
381
|
}
|
|
327
382
|
|
|
328
383
|
await refreshSettings(ctx);
|
|
329
|
-
const
|
|
384
|
+
const modeLabel = `Change mode (current: ${settings.mode})`;
|
|
385
|
+
const trainingLabel = settings.training
|
|
386
|
+
? "Disable training capture"
|
|
387
|
+
: "Enable training capture";
|
|
330
388
|
const policyLabel = `Change failure policy (current: ${settings.failurePolicy})`;
|
|
331
|
-
const
|
|
389
|
+
const actions = settings.mode === "disabled"
|
|
390
|
+
? [modeLabel, policyLabel]
|
|
391
|
+
: [modeLabel, trainingLabel, policyLabel];
|
|
392
|
+
const action = await ctx.ui.select("demur", actions);
|
|
332
393
|
if (action === undefined) return;
|
|
333
394
|
|
|
334
|
-
if (action ===
|
|
395
|
+
if (action === modeLabel) {
|
|
396
|
+
const selection = await ctx.ui.select(
|
|
397
|
+
`demur mode (current: ${settings.mode})`,
|
|
398
|
+
[...DEMUR_MODES],
|
|
399
|
+
);
|
|
400
|
+
if (selection === undefined) return;
|
|
401
|
+
|
|
402
|
+
const mode = parseDemurMode(selection);
|
|
403
|
+
if (mode === undefined) return;
|
|
404
|
+
await persistSettings(
|
|
405
|
+
{
|
|
406
|
+
...settings,
|
|
407
|
+
mode,
|
|
408
|
+
training: mode === "disabled" ? false : settings.training,
|
|
409
|
+
},
|
|
410
|
+
ctx,
|
|
411
|
+
);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (action === trainingLabel) {
|
|
335
416
|
await persistSettings(
|
|
336
|
-
{ ...settings,
|
|
417
|
+
{ ...settings, training: !settings.training },
|
|
337
418
|
ctx,
|
|
338
419
|
);
|
|
339
420
|
return;
|
|
@@ -366,7 +447,7 @@ export default function demur(pi: ExtensionAPI): void {
|
|
|
366
447
|
} catch (error: unknown) {
|
|
367
448
|
settings = { ...DEFAULT_DEMUR_SETTINGS };
|
|
368
449
|
ctx.ui.notify(
|
|
369
|
-
`Could not load demur settings; using
|
|
450
|
+
`Could not load demur settings; using enforce/block with training off: ${errorDetail(error)}`,
|
|
370
451
|
"warning",
|
|
371
452
|
);
|
|
372
453
|
}
|
|
@@ -395,14 +476,42 @@ function updateStatus(
|
|
|
395
476
|
ctx: ExtensionContext,
|
|
396
477
|
settings: DemurSettings,
|
|
397
478
|
): void {
|
|
398
|
-
const
|
|
399
|
-
const color = settings.
|
|
400
|
-
|
|
479
|
+
const training = settings.training ? " + training" : "";
|
|
480
|
+
const color = settings.mode === "enforce" && !settings.training
|
|
481
|
+
? "success"
|
|
482
|
+
: "warning";
|
|
483
|
+
ctx.ui.setStatus(
|
|
484
|
+
"demur",
|
|
485
|
+
ctx.ui.theme.fg(color, `demur: ${settings.mode}${training}`),
|
|
486
|
+
);
|
|
401
487
|
}
|
|
402
488
|
|
|
403
489
|
function settingsNotification(settings: DemurSettings): string {
|
|
404
|
-
const
|
|
405
|
-
return `demur ${
|
|
490
|
+
const training = settings.training ? "on" : "off";
|
|
491
|
+
return `demur mode: ${settings.mode}; training: ${training}; failure policy: ${settings.failurePolicy}.`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function recordTrainingResult(
|
|
495
|
+
command: string,
|
|
496
|
+
ctx: ExtensionContext,
|
|
497
|
+
mode: "enforce" | "passive",
|
|
498
|
+
verdict: Verdict,
|
|
499
|
+
result: ToolCallEventResult | undefined,
|
|
500
|
+
): Promise<void> {
|
|
501
|
+
try {
|
|
502
|
+
await recordTrainingEvaluation({
|
|
503
|
+
command,
|
|
504
|
+
cwd: ctx.cwd,
|
|
505
|
+
mode,
|
|
506
|
+
verdict,
|
|
507
|
+
hostAction: result?.block === true ? "block" : "allow",
|
|
508
|
+
});
|
|
509
|
+
} catch (error: unknown) {
|
|
510
|
+
ctx.ui.notify(
|
|
511
|
+
`demur: could not record training evaluation — ${errorDetail(error)}`,
|
|
512
|
+
"warning",
|
|
513
|
+
);
|
|
514
|
+
}
|
|
406
515
|
}
|
|
407
516
|
|
|
408
517
|
async function handleGuardFailure(
|
|
@@ -521,26 +630,31 @@ function formatDecimal(value: number, fractionDigits: number): string {
|
|
|
521
630
|
.replace(/\.0+$/, "");
|
|
522
631
|
}
|
|
523
632
|
|
|
524
|
-
function formatUsd(value: number): string {
|
|
525
|
-
const decimal = value.toFixed(9).replace(/0+$/, "").replace(/\.$/, "");
|
|
526
|
-
return `$${decimal}`;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
633
|
function parseVerdict(output: string): Verdict {
|
|
530
634
|
const value: unknown = JSON.parse(output);
|
|
531
635
|
if (value === null || typeof value !== "object") {
|
|
532
636
|
throw new Error("verdict must be an object");
|
|
533
637
|
}
|
|
534
638
|
|
|
535
|
-
const { decision, reason
|
|
639
|
+
const { decision, reason, judgments, failure, latencyMs, usage } =
|
|
640
|
+
value as Record<string, unknown>;
|
|
536
641
|
if (
|
|
537
642
|
(decision !== "allow" && decision !== "ask" && decision !== "deny") ||
|
|
538
|
-
typeof reason !== "string"
|
|
643
|
+
typeof reason !== "string" ||
|
|
644
|
+
typeof latencyMs !== "number" ||
|
|
645
|
+
!Number.isFinite(latencyMs)
|
|
539
646
|
) {
|
|
540
|
-
throw new Error("verdict must contain a valid decision and
|
|
647
|
+
throw new Error("verdict must contain a valid decision, reason, and latency");
|
|
541
648
|
}
|
|
542
649
|
|
|
543
|
-
return
|
|
650
|
+
return {
|
|
651
|
+
decision,
|
|
652
|
+
reason,
|
|
653
|
+
judgments: judgments as Verdict["judgments"],
|
|
654
|
+
failure: failure as Verdict["failure"],
|
|
655
|
+
latencyMs,
|
|
656
|
+
usage: usage as Verdict["usage"],
|
|
657
|
+
};
|
|
544
658
|
}
|
|
545
659
|
|
|
546
660
|
function errorDetail(error: unknown): string {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve demur's configuration directory.
|
|
6
|
+
*
|
|
7
|
+
* `DEMUR_CONFIG_HOME` takes precedence over the XDG config directory and the
|
|
8
|
+
* standard home-directory fallback. Unlike `XDG_CONFIG_HOME`, the demur-specific
|
|
9
|
+
* override names demur's directory directly.
|
|
10
|
+
*
|
|
11
|
+
* @param environment - Process environment used to resolve directory overrides
|
|
12
|
+
* @param homeDirectory - Home directory used when overrides are absent
|
|
13
|
+
* @returns Absolute or caller-provided path to demur's configuration directory
|
|
14
|
+
*/
|
|
15
|
+
export function getDemurConfigDirectory(
|
|
16
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
17
|
+
homeDirectory: string = homedir(),
|
|
18
|
+
): string {
|
|
19
|
+
if (environment.DEMUR_CONFIG_HOME) return environment.DEMUR_CONFIG_HOME;
|
|
20
|
+
|
|
21
|
+
const configHome = environment.XDG_CONFIG_HOME ||
|
|
22
|
+
join(homeDirectory, ".config");
|
|
23
|
+
return join(configHome, "demur");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve demur's persistent state directory.
|
|
28
|
+
*
|
|
29
|
+
* `DEMUR_STATE_HOME` takes precedence over the XDG state directory and the
|
|
30
|
+
* standard home-directory fallback. Unlike `XDG_STATE_HOME`, the demur-specific
|
|
31
|
+
* override names demur's directory directly.
|
|
32
|
+
*
|
|
33
|
+
* @param environment - Process environment used to resolve directory overrides
|
|
34
|
+
* @param homeDirectory - Home directory used when overrides are absent
|
|
35
|
+
* @returns Absolute or caller-provided path to demur's persistent state directory
|
|
36
|
+
*/
|
|
37
|
+
export function getDemurStateDirectory(
|
|
38
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
39
|
+
homeDirectory: string = homedir(),
|
|
40
|
+
): string {
|
|
41
|
+
if (environment.DEMUR_STATE_HOME) return environment.DEMUR_STATE_HOME;
|
|
42
|
+
|
|
43
|
+
const stateHome = environment.XDG_STATE_HOME ||
|
|
44
|
+
join(homeDirectory, ".local", "state");
|
|
45
|
+
return join(stateHome, "demur");
|
|
46
|
+
}
|