@wyattjoh/demur 0.4.2 → 0.6.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.
@@ -2,6 +2,21 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import { getDemurConfigDirectory } from "./paths.ts";
6
+
7
+ /**
8
+ * How the Pi extension applies demur to Bash calls.
9
+ */
10
+ export type DemurMode = "enforce" | "passive" | "disabled";
11
+
12
+ /**
13
+ * Modes accepted by the Pi extension command.
14
+ */
15
+ export const DEMUR_MODES: readonly DemurMode[] = [
16
+ "enforce",
17
+ "passive",
18
+ "disabled",
19
+ ];
5
20
 
6
21
  /**
7
22
  * How the Pi extension handles guard failures that produce no trustworthy
@@ -23,9 +38,13 @@ export const FAILURE_POLICIES: readonly FailurePolicy[] = [
23
38
  */
24
39
  export type DemurSettings = {
25
40
  /**
26
- * Whether Bash calls are routed through demur.
41
+ * Whether demur enforces, observes, or bypasses Bash calls.
42
+ */
43
+ mode: DemurMode;
44
+ /**
45
+ * Whether full command evaluations are appended to the training log.
27
46
  */
28
- enabled: boolean;
47
+ training: boolean;
29
48
  /**
30
49
  * Host action to take when demur cannot obtain a trustworthy judgment.
31
50
  */
@@ -36,19 +55,20 @@ export type DemurSettings = {
36
55
  * Safe settings used when no persisted Pi configuration exists.
37
56
  */
38
57
  export const DEFAULT_DEMUR_SETTINGS: DemurSettings = {
39
- enabled: true,
58
+ mode: "enforce",
59
+ training: false,
40
60
  failurePolicy: "block",
41
61
  };
42
62
 
43
63
  type DemurConfig = DemurSettings & {
44
- version: 1;
64
+ version: 2;
45
65
  };
46
66
 
47
67
  /**
48
- * Resolve the global demur configuration file according to the XDG config
49
- * convention.
68
+ * Resolve the global demur configuration file using demur-specific and XDG
69
+ * directory conventions.
50
70
  *
51
- * @param environment - Process environment used to resolve `XDG_CONFIG_HOME`
71
+ * @param environment - Process environment used to resolve demur and XDG overrides
52
72
  * @param homeDirectory - Home directory used when the XDG override is absent
53
73
  * @returns Absolute path to demur's global configuration file
54
74
  */
@@ -56,16 +76,17 @@ export function getDemurConfigPath(
56
76
  environment: NodeJS.ProcessEnv = process.env,
57
77
  homeDirectory: string = homedir(),
58
78
  ): string {
59
- const configDirectory =
60
- environment.XDG_CONFIG_HOME || join(homeDirectory, ".config");
61
- return join(configDirectory, "demur", "config.json");
79
+ return join(
80
+ getDemurConfigDirectory(environment, homeDirectory),
81
+ "config.json",
82
+ );
62
83
  }
63
84
 
64
85
  /**
65
86
  * Load the globally persisted Pi settings.
66
87
  *
67
- * A missing file uses the enabled, fail-closed defaults. Version 1 files from
68
- * before the enabled toggle omit that field and are treated as enabled.
88
+ * A missing file uses the enforcing, fail-closed defaults. Version 1 files are
89
+ * migrated from their former `enabled` boolean and start with training off.
69
90
  * Invalid or unreadable files are rejected so the extension can warn the user
70
91
  * while still falling back safely.
71
92
  *
@@ -88,18 +109,36 @@ export async function loadDemurSettings(
88
109
  throw new Error(`invalid demur config in ${configPath}: expected an object`);
89
110
  }
90
111
 
91
- const { version, enabled, failurePolicy } = value as Record<string, unknown>;
112
+ const config = value as Record<string, unknown>;
113
+ if (config.version === 1) {
114
+ if (
115
+ (config.enabled !== undefined && typeof config.enabled !== "boolean") ||
116
+ !isFailurePolicy(config.failurePolicy)
117
+ ) {
118
+ throw new Error(`invalid demur config in ${configPath}: unsupported values`);
119
+ }
120
+
121
+ return {
122
+ mode: config.enabled === false ? "disabled" : "enforce",
123
+ training: false,
124
+ failurePolicy: config.failurePolicy,
125
+ };
126
+ }
127
+
92
128
  if (
93
- version !== 1 ||
94
- (enabled !== undefined && typeof enabled !== "boolean") ||
95
- !isFailurePolicy(failurePolicy)
129
+ config.version !== 2 ||
130
+ !isDemurMode(config.mode) ||
131
+ typeof config.training !== "boolean" ||
132
+ (config.mode === "disabled" && config.training) ||
133
+ !isFailurePolicy(config.failurePolicy)
96
134
  ) {
97
135
  throw new Error(`invalid demur config in ${configPath}: unsupported values`);
98
136
  }
99
137
 
100
138
  return {
101
- enabled: enabled ?? true,
102
- failurePolicy,
139
+ mode: config.mode,
140
+ training: config.training,
141
+ failurePolicy: config.failurePolicy,
103
142
  };
104
143
  }
105
144
 
@@ -113,7 +152,11 @@ export async function saveDemurSettings(
113
152
  settings: DemurSettings,
114
153
  configPath: string = getDemurConfigPath(),
115
154
  ): Promise<void> {
116
- const config: DemurConfig = { version: 1, ...settings };
155
+ if (settings.mode === "disabled" && settings.training) {
156
+ throw new Error("training cannot be enabled while demur is disabled");
157
+ }
158
+
159
+ const config: DemurConfig = { version: 2, ...settings };
117
160
  const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
118
161
 
119
162
  await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
@@ -133,6 +176,17 @@ export async function saveDemurSettings(
133
176
  }
134
177
  }
135
178
 
179
+ /**
180
+ * Parse a command argument as a Pi operating mode.
181
+ *
182
+ * @param value - Raw slash-command argument
183
+ * @returns Normalized mode, or `undefined` when the argument is invalid
184
+ */
185
+ export function parseDemurMode(value: string): DemurMode | undefined {
186
+ const normalized = value.trim().toLowerCase();
187
+ return isDemurMode(normalized) ? normalized : undefined;
188
+ }
189
+
136
190
  /**
137
191
  * Parse a command argument as a Pi failure policy.
138
192
  *
@@ -144,6 +198,10 @@ export function parseFailurePolicy(value: string): FailurePolicy | undefined {
144
198
  return isFailurePolicy(normalized) ? normalized : undefined;
145
199
  }
146
200
 
201
+ function isDemurMode(value: unknown): value is DemurMode {
202
+ return DEMUR_MODES.some((mode) => mode === value);
203
+ }
204
+
147
205
  function isFailurePolicy(value: unknown): value is FailurePolicy {
148
206
  return FAILURE_POLICIES.some((policy) => policy === value);
149
207
  }
@@ -0,0 +1,334 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import type { Decision, Verdict } from "../../src/types.ts";
6
+ import { getDemurStateDirectory } from "./paths.ts";
7
+
8
+ const LOCK_RETRY_MS = 10;
9
+ const LOCK_TIMEOUT_MS = 5_000;
10
+
11
+ /**
12
+ * Guard modes that can emit training records.
13
+ */
14
+ export type TrainingMode = "enforce" | "passive";
15
+
16
+ /**
17
+ * Action the Pi host took after demur evaluated a command.
18
+ */
19
+ export type TrainingHostAction = "allow" | "block";
20
+
21
+ /**
22
+ * Complete evidence captured for one training-mode command evaluation.
23
+ */
24
+ export type TrainingRecord = {
25
+ version: 1;
26
+ id: string;
27
+ recordedAt: string;
28
+ command: string;
29
+ cwd: string;
30
+ mode: TrainingMode;
31
+ verdict: Verdict;
32
+ hostAction: TrainingHostAction;
33
+ };
34
+
35
+ /**
36
+ * Human review of one captured training evaluation.
37
+ */
38
+ export type TrainingReview = {
39
+ version: 1;
40
+ recordId: string;
41
+ reviewedAt: string;
42
+ originalDecision: Decision;
43
+ expectedDecision: Decision;
44
+ note: string | undefined;
45
+ };
46
+
47
+ /**
48
+ * Input required to append a human review.
49
+ */
50
+ export type TrainingReviewInput = {
51
+ recordId: string;
52
+ originalDecision: Decision;
53
+ expectedDecision: Decision;
54
+ note: string | undefined;
55
+ };
56
+
57
+ /**
58
+ * Resolve the global training-record file according to demur and XDG overrides.
59
+ *
60
+ * @param environment - Process environment used to resolve demur and XDG overrides
61
+ * @param homeDirectory - Home directory used when the XDG override is absent
62
+ * @returns Absolute path to the append-only training JSONL file
63
+ */
64
+ export function getTrainingLogPath(
65
+ environment: NodeJS.ProcessEnv = process.env,
66
+ homeDirectory: string = homedir(),
67
+ ): string {
68
+ return join(
69
+ getDemurStateDirectory(environment, homeDirectory),
70
+ "training.jsonl",
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Resolve the global training-review file according to demur and XDG overrides.
76
+ *
77
+ * @param environment - Process environment used to resolve demur and XDG overrides
78
+ * @param homeDirectory - Home directory used when the XDG override is absent
79
+ * @returns Absolute path to the append-only review JSONL file
80
+ */
81
+ export function getTrainingReviewPath(
82
+ environment: NodeJS.ProcessEnv = process.env,
83
+ homeDirectory: string = homedir(),
84
+ ): string {
85
+ return join(
86
+ getDemurStateDirectory(environment, homeDirectory),
87
+ "training-reviews.jsonl",
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Append one complete command evaluation to the global training log.
93
+ *
94
+ * @param input - Evaluation evidence captured by the Pi extension
95
+ * @param logPath - Training file to append
96
+ * @returns The persisted record with generated identity and timestamp
97
+ */
98
+ export async function recordTrainingEvaluation(
99
+ input: Omit<TrainingRecord, "version" | "id" | "recordedAt">,
100
+ logPath: string = getTrainingLogPath(),
101
+ ): Promise<TrainingRecord> {
102
+ const record: TrainingRecord = {
103
+ version: 1,
104
+ id: randomUUID(),
105
+ recordedAt: new Date().toISOString(),
106
+ ...input,
107
+ };
108
+ await appendJsonLine(logPath, record);
109
+ return record;
110
+ }
111
+
112
+ /**
113
+ * Load and validate all captured training evaluations.
114
+ *
115
+ * @param logPath - Training file to read
116
+ * @returns Valid training records in capture order
117
+ */
118
+ export async function loadTrainingRecords(
119
+ logPath: string = getTrainingLogPath(),
120
+ ): Promise<ReadonlyArray<TrainingRecord>> {
121
+ return readJsonLines(logPath, parseTrainingRecord);
122
+ }
123
+
124
+ /**
125
+ * Append a human decision review to the global review log.
126
+ *
127
+ * @param input - Reviewed record identity, expected decision, and optional note
128
+ * @param reviewPath - Review file to append
129
+ * @returns The persisted review with its generated timestamp
130
+ */
131
+ export async function recordTrainingReview(
132
+ input: TrainingReviewInput,
133
+ reviewPath: string = getTrainingReviewPath(),
134
+ ): Promise<TrainingReview> {
135
+ const review: TrainingReview = {
136
+ version: 1,
137
+ reviewedAt: new Date().toISOString(),
138
+ ...input,
139
+ };
140
+ await appendJsonLine(reviewPath, review);
141
+ return review;
142
+ }
143
+
144
+ /**
145
+ * Load and validate all human training reviews.
146
+ *
147
+ * @param reviewPath - Review file to read
148
+ * @returns Valid reviews in capture order
149
+ */
150
+ export async function loadTrainingReviews(
151
+ reviewPath: string = getTrainingReviewPath(),
152
+ ): Promise<ReadonlyArray<TrainingReview>> {
153
+ return readJsonLines(reviewPath, parseTrainingReview);
154
+ }
155
+
156
+ async function appendJsonLine(path: string, value: unknown): Promise<void> {
157
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
158
+ const release = await acquireLock(`${path}.lock`);
159
+
160
+ try {
161
+ const line = JSON.stringify(
162
+ value,
163
+ (_key, nestedValue: unknown) =>
164
+ nestedValue === undefined ? null : nestedValue,
165
+ );
166
+ await appendFile(path, `${line}\n`, {
167
+ encoding: "utf8",
168
+ flag: "a",
169
+ mode: 0o600,
170
+ });
171
+ } finally {
172
+ await release();
173
+ }
174
+ }
175
+
176
+ async function readJsonLines<T>(
177
+ path: string,
178
+ parse: (value: unknown, path: string, line: number) => T,
179
+ ): Promise<ReadonlyArray<T>> {
180
+ let content: string;
181
+ try {
182
+ content = await readFile(path, "utf8");
183
+ } catch (error: unknown) {
184
+ if (isErrorCode(error, "ENOENT")) return [];
185
+ throw error;
186
+ }
187
+
188
+ return content
189
+ .split("\n")
190
+ .map((line) => line.trim())
191
+ .filter((line) => line !== "")
192
+ .map((line, index) => {
193
+ let value: unknown;
194
+ try {
195
+ value = JSON.parse(line);
196
+ } catch (error: unknown) {
197
+ throw new Error(
198
+ `invalid JSONL in ${path} at line ${index + 1}: ${errorDetail(error)}`,
199
+ );
200
+ }
201
+ return parse(value, path, index + 1);
202
+ });
203
+ }
204
+
205
+ function parseTrainingRecord(
206
+ value: unknown,
207
+ path: string,
208
+ line: number,
209
+ ): TrainingRecord {
210
+ if (value === null || typeof value !== "object") {
211
+ throw invalidRecord(path, line);
212
+ }
213
+
214
+ const record = value as Record<string, unknown>;
215
+ if (
216
+ record.version !== 1 ||
217
+ typeof record.id !== "string" ||
218
+ typeof record.recordedAt !== "string" ||
219
+ typeof record.command !== "string" ||
220
+ typeof record.cwd !== "string" ||
221
+ (record.mode !== "enforce" && record.mode !== "passive") ||
222
+ (record.hostAction !== "allow" && record.hostAction !== "block") ||
223
+ !isVerdict(record.verdict)
224
+ ) {
225
+ throw invalidRecord(path, line);
226
+ }
227
+
228
+ const verdict = record.verdict as Record<string, unknown>;
229
+ return {
230
+ version: 1,
231
+ id: record.id,
232
+ recordedAt: record.recordedAt,
233
+ command: record.command,
234
+ cwd: record.cwd,
235
+ 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
+ },
250
+ hostAction: record.hostAction,
251
+ };
252
+ }
253
+
254
+ function parseTrainingReview(
255
+ value: unknown,
256
+ path: string,
257
+ line: number,
258
+ ): TrainingReview {
259
+ if (value === null || typeof value !== "object") {
260
+ throw invalidRecord(path, line);
261
+ }
262
+
263
+ const review = value as Record<string, unknown>;
264
+ if (
265
+ review.version !== 1 ||
266
+ typeof review.recordId !== "string" ||
267
+ typeof review.reviewedAt !== "string" ||
268
+ !isDecision(review.originalDecision) ||
269
+ !isDecision(review.expectedDecision) ||
270
+ (review.note !== undefined &&
271
+ review.note !== null &&
272
+ typeof review.note !== "string")
273
+ ) {
274
+ throw invalidRecord(path, line);
275
+ }
276
+
277
+ return {
278
+ version: 1,
279
+ recordId: review.recordId,
280
+ reviewedAt: review.reviewedAt,
281
+ originalDecision: review.originalDecision,
282
+ expectedDecision: review.expectedDecision,
283
+ note: review.note === null ? undefined : review.note,
284
+ };
285
+ }
286
+
287
+ function isVerdict(value: unknown): value is Verdict {
288
+ if (value === null || typeof value !== "object") return false;
289
+ const verdict = value as Record<string, unknown>;
290
+ return isDecision(verdict.decision) &&
291
+ typeof verdict.reason === "string" &&
292
+ typeof verdict.latencyMs === "number";
293
+ }
294
+
295
+ function isDecision(value: unknown): value is Decision {
296
+ return value === "allow" || value === "ask" || value === "deny";
297
+ }
298
+
299
+ function invalidRecord(path: string, line: number): Error {
300
+ return new Error(`invalid training record in ${path} at line ${line}`);
301
+ }
302
+
303
+ async function acquireLock(lockPath: string): Promise<() => Promise<void>> {
304
+ const startedAt = Date.now();
305
+
306
+ while (true) {
307
+ try {
308
+ await mkdir(lockPath, { mode: 0o700 });
309
+ return async () => {
310
+ await rmdir(lockPath);
311
+ };
312
+ } catch (error: unknown) {
313
+ if (!isErrorCode(error, "EEXIST")) throw error;
314
+ if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
315
+ throw new Error(`timed out waiting for training state lock: ${lockPath}`);
316
+ }
317
+ await delay(LOCK_RETRY_MS);
318
+ }
319
+ }
320
+ }
321
+
322
+ function isErrorCode(error: unknown, code: string): boolean {
323
+ return error instanceof Error &&
324
+ "code" in error &&
325
+ (error as NodeJS.ErrnoException).code === code;
326
+ }
327
+
328
+ function errorDetail(error: unknown): string {
329
+ return error instanceof Error ? error.message : String(error);
330
+ }
331
+
332
+ function delay(milliseconds: number): Promise<void> {
333
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
334
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyattjoh/demur",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "A proof-of-concept destructive-command guard for coding agents.",
6
6
  "license": "MIT",
@@ -23,7 +23,9 @@
23
23
  ],
24
24
  "files": [
25
25
  "extensions/demur/cost-tracker.ts",
26
+ "extensions/demur/paths.ts",
26
27
  "extensions/demur/settings.ts",
28
+ "extensions/demur/training-store.ts",
27
29
  "extensions/demur/index.ts",
28
30
  "src/adapters/claude-code.ts",
29
31
  "src/adapters/pi-worker.ts",
@@ -36,6 +38,9 @@
36
38
  "src/policy.ts",
37
39
  "src/questions.ts",
38
40
  "src/state.ts",
41
+ "src/settings-model.ts",
42
+ "src/training-review-model.ts",
43
+ "src/training-review-tui.tsx",
39
44
  "src/types.ts"
40
45
  ],
41
46
  "publishConfig": {
@@ -47,7 +52,7 @@
47
52
  },
48
53
  "scripts": {
49
54
  "check": "tsc --noEmit",
50
- "test": "vitest run",
55
+ "test": "vitest run && bun test ./src/training-review-tui.bun.tsx",
51
56
  "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",
52
57
  "build:claude": "bun build src/adapters/claude-code.ts --target=bun --outfile=dist/demur-hook.js --format=esm",
53
58
  "build": "bun run build:pi && bun run build:claude",
@@ -63,12 +68,16 @@
63
68
  },
64
69
  "dependencies": {
65
70
  "@effect/ai-typesafe": "4.0.0-rc.116",
66
- "effect": "4.0.0-rc.116"
71
+ "@opentui/core": "^0.5.11",
72
+ "@opentui/react": "^0.5.11",
73
+ "effect": "4.0.0-rc.116",
74
+ "react": "^19.3.0"
67
75
  },
68
76
  "devDependencies": {
69
77
  "@earendil-works/pi-coding-agent": "0.85.1",
70
78
  "@effect/vitest": "4.0.0-rc.116",
71
79
  "@types/bun": "1.4.2",
80
+ "@types/react": "^19.3.0",
72
81
  "typescript": "7.0.2",
73
82
  "vitest": "5.0.1"
74
83
  },