@wyattjoh/demur 0.3.2 → 0.4.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 CHANGED
@@ -92,7 +92,7 @@ Pin a specific release when reproducibility matters:
92
92
 
93
93
  <!-- x-release-please-start-version -->
94
94
  ```sh
95
- pi install npm:@wyattjoh/demur@0.3.2
95
+ pi install npm:@wyattjoh/demur@0.4.0
96
96
  ```
97
97
  <!-- x-release-please-end -->
98
98
 
@@ -109,6 +109,24 @@ command request and resulting verdict cross its local stdio pipes. `ask` opens
109
109
  an interactive confirmation dialog; without an interactive UI, demur blocks the
110
110
  command.
111
111
 
112
+ Use `/demur` to open the extension menu. It can enable or disable demur and
113
+ change what Pi does when demur cannot obtain a trustworthy judgment because of
114
+ a missing credential, timeout, API error, malformed worker response, or
115
+ unexpected guard failure:
116
+
117
+ - `block` (default) fails closed.
118
+ - `ask` requests interactive confirmation and blocks when no UI is available.
119
+ - `allow` fails open without confirmation.
120
+
121
+ Disabling demur bypasses the worker and allows Bash calls without judgment. Pi's
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
126
+ `~/.config/demur/config.json` when `XDG_CONFIG_HOME` is unset, and apply to
127
+ future Pi sessions. While demur is enabled, the failure policy never changes a
128
+ completed `deny` policy judgment; those commands remain blocked.
129
+
112
130
  After each run, Pi's interactive UI prints the decision, submitted input-token
113
131
  count, the run's estimated input cost, the accumulated global estimate, and the
114
132
  wall-clock evaluation time in human-readable units. The
@@ -186,13 +204,20 @@ From a development checkout, `bun run judge "<command>"` remains available.
186
204
 
187
205
  ## Failure posture
188
206
 
189
- demur fails closed. A missing key, credential-store failure, timeout, API
190
- failure, malformed response, or unexpected guard error returns `deny` with a
191
- reason that identifies the guard failure rather than presenting it as a policy
192
- judgment.
207
+ demur's core guard fails closed. A missing key, credential-store failure,
208
+ timeout, API failure, malformed response, or unexpected guard error returns
209
+ `deny` with a reason that identifies the guard failure rather than presenting it
210
+ as a policy judgment. The Claude Code adapter and CLI preserve that verdict.
211
+
212
+ The Pi extension defaults to enabled with the same fail-closed behavior, but
213
+ its explicit `/demur` menu can globally change how Pi handles guard failures or
214
+ disable the extension entirely. The failure-policy override applies only when
215
+ no trustworthy judgment was produced; it cannot loosen a completed policy
216
+ denial while demur is enabled. The bottom status bar makes the enabled state
217
+ visible.
193
218
 
194
- `DEMUR_DISABLE=1` is an explicit emergency bypass. It disables all protection
195
- and should remain unset during normal use.
219
+ `DEMUR_DISABLE=1` remains the cross-host emergency bypass. It disables judgment
220
+ and protection entirely and should remain unset during normal use.
196
221
 
197
222
  ## Known limitations
198
223
 
@@ -203,7 +228,8 @@ and should remain unset during normal use.
203
228
  - Attacker-controlled command text can influence the model.
204
229
  - Shell expansion, obfuscation, aliases, wrappers, and runtime environment can
205
230
  make a command behave differently from its text.
206
- - Network outages block commands unless the emergency bypass is enabled.
231
+ - Network outages block commands by default; Pi can override that failure
232
+ handling from the `/demur` menu.
207
233
  - Every decision adds remote-call latency and may incur provider cost.
208
234
  - The integrations guard agent-issued Bash tool calls only. They do not guard
209
235
  user shells, other process-launching tools, or commands run outside the host.
@@ -12,6 +12,15 @@ import {
12
12
  estimateInputCostUsd,
13
13
  recordInputCost,
14
14
  } from "./cost-tracker.ts";
15
+ import {
16
+ DEFAULT_DEMUR_SETTINGS,
17
+ FAILURE_POLICIES,
18
+ loadDemurSettings,
19
+ parseFailurePolicy,
20
+ saveDemurSettings,
21
+ type DemurSettings,
22
+ type FailurePolicy,
23
+ } from "./settings.ts";
15
24
 
16
25
  const WORKER_PATH = fileURLToPath(
17
26
  new URL("../../src/adapters/pi-worker.ts", import.meta.url),
@@ -26,13 +35,16 @@ const MAX_WORKER_OUTPUT_BYTES = 64 * 1024;
26
35
  *
27
36
  * @param event - The tool call Pi is about to execute
28
37
  * @param ctx - Extension context, used for the working directory and prompts
38
+ * @param settings - Current global Pi extension settings
29
39
  * @returns A block result when the command is denied, otherwise nothing
30
40
  */
31
41
  export async function handleToolCall(
32
42
  event: ToolCallEvent,
33
43
  ctx: ExtensionContext,
44
+ settings: DemurSettings = DEFAULT_DEMUR_SETTINGS,
34
45
  ): Promise<ToolCallEventResult | undefined> {
35
46
  if (!isToolCallEventType("bash", event)) return undefined;
47
+ if (!settings.enabled) return undefined;
36
48
 
37
49
  const command = event.input.command ?? "";
38
50
  if (command.trim() === "") return undefined;
@@ -43,16 +55,79 @@ export async function handleToolCall(
43
55
  verdict = await runGuardWorker(command, ctx.cwd, ctx.signal);
44
56
  } catch (error: unknown) {
45
57
  const evaluationMs = performance.now() - evaluationStartedAt;
46
- notifyRun(ctx, "ERROR", undefined, undefined, evaluationMs, "warning");
47
- return {
48
- block: true,
49
- reason: `demur: guard worker crashed — ${errorDetail(error)} Blocking because demur fails closed. Set DEMUR_DISABLE=1 to bypass.`,
50
- };
58
+ if (ctx.signal?.aborted) {
59
+ notifyRun(
60
+ ctx,
61
+ "CANCELLED BLOCK",
62
+ undefined,
63
+ undefined,
64
+ evaluationMs,
65
+ "warning",
66
+ );
67
+ return {
68
+ block: true,
69
+ reason: "demur: guard request cancelled, so blocking the command.",
70
+ };
71
+ }
72
+
73
+ return handleGuardFailure(
74
+ `demur: guard worker crashed — ${errorDetail(error)}`,
75
+ command,
76
+ ctx,
77
+ settings.failurePolicy,
78
+ undefined,
79
+ undefined,
80
+ evaluationMs,
81
+ );
51
82
  }
52
83
 
53
84
  const evaluationMs = performance.now() - evaluationStartedAt;
54
85
  const inputTokens = verdict.usage?.inputTokens;
55
86
  const accumulatedCostUsd = await recordAccumulatedCost(inputTokens);
87
+ return resolveVerdict(
88
+ verdict,
89
+ command,
90
+ ctx,
91
+ settings.failurePolicy,
92
+ accumulatedCostUsd,
93
+ evaluationMs,
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Apply the Pi extension's host policy to one completed guard verdict.
99
+ *
100
+ * A configured failure policy is consulted only when `verdict.failure` is set.
101
+ * Ordinary model and deterministic-policy denials always remain blocked.
102
+ *
103
+ * @param verdict - Completed demur guard result
104
+ * @param command - Shell command awaiting execution
105
+ * @param ctx - Pi extension context used for prompts and notifications
106
+ * @param failurePolicy - Host action to take when the guard failed
107
+ * @param accumulatedCostUsd - Persisted global estimate after this run
108
+ * @param evaluationMs - Wall-clock time spent obtaining the guard verdict
109
+ * @returns A block result when Pi must stop the command, otherwise nothing
110
+ */
111
+ export async function resolveVerdict(
112
+ verdict: Verdict,
113
+ command: string,
114
+ ctx: ExtensionContext,
115
+ failurePolicy: FailurePolicy,
116
+ accumulatedCostUsd: number | undefined,
117
+ evaluationMs: number,
118
+ ): Promise<ToolCallEventResult | undefined> {
119
+ const inputTokens = verdict.usage?.inputTokens;
120
+ if (verdict.failure !== undefined) {
121
+ return handleGuardFailure(
122
+ stripFailClosedSuffix(verdict.reason),
123
+ command,
124
+ ctx,
125
+ failurePolicy,
126
+ inputTokens,
127
+ accumulatedCostUsd,
128
+ evaluationMs,
129
+ );
130
+ }
56
131
 
57
132
  if (verdict.decision === "allow") {
58
133
  notifyRun(
@@ -240,7 +315,166 @@ export function runGuardWorker(
240
315
  * @param pi - The extension API provided by Pi
241
316
  */
242
317
  export default function demur(pi: ExtensionAPI): void {
243
- pi.on("tool_call", handleToolCall);
318
+ let settings = { ...DEFAULT_DEMUR_SETTINGS };
319
+
320
+ pi.registerCommand("demur", {
321
+ description: "Configure the demur guard",
322
+ handler: async (_args, ctx) => {
323
+ if (!ctx.hasUI) {
324
+ ctx.ui.notify("The /demur menu requires an interactive UI.", "warning");
325
+ return;
326
+ }
327
+
328
+ const toggleLabel = settings.enabled ? "Disable demur" : "Enable demur";
329
+ const policyLabel = `Change failure policy (current: ${settings.failurePolicy})`;
330
+ const action = await ctx.ui.select("demur", [toggleLabel, policyLabel]);
331
+ if (action === undefined) return;
332
+
333
+ if (action === toggleLabel) {
334
+ await persistSettings(
335
+ { ...settings, enabled: !settings.enabled },
336
+ ctx,
337
+ );
338
+ return;
339
+ }
340
+
341
+ const selection = await ctx.ui.select(
342
+ `demur failure policy (current: ${settings.failurePolicy})`,
343
+ [...FAILURE_POLICIES],
344
+ );
345
+ if (selection === undefined) return;
346
+
347
+ const failurePolicy = parseFailurePolicy(selection);
348
+ if (failurePolicy === undefined) return;
349
+ await persistSettings({ ...settings, failurePolicy }, ctx);
350
+ },
351
+ });
352
+
353
+ pi.on("session_start", async (_event, ctx) => {
354
+ try {
355
+ settings = await loadDemurSettings();
356
+ } catch (error: unknown) {
357
+ settings = { ...DEFAULT_DEMUR_SETTINGS };
358
+ ctx.ui.notify(
359
+ `Could not load demur settings; using enabled/block: ${errorDetail(error)}`,
360
+ "warning",
361
+ );
362
+ }
363
+ updateStatus(ctx, settings);
364
+ });
365
+
366
+ pi.on("tool_call", (event, ctx) =>
367
+ handleToolCall(event, ctx, settings),
368
+ );
369
+
370
+ async function persistSettings(
371
+ nextSettings: DemurSettings,
372
+ ctx: ExtensionContext,
373
+ ): Promise<void> {
374
+ try {
375
+ await saveDemurSettings(nextSettings);
376
+ settings = nextSettings;
377
+ updateStatus(ctx, settings);
378
+ ctx.ui.notify(settingsNotification(settings), "info");
379
+ } catch (error: unknown) {
380
+ ctx.ui.notify(
381
+ `Could not save demur settings: ${errorDetail(error)}`,
382
+ "error",
383
+ );
384
+ }
385
+ }
386
+ }
387
+
388
+ function updateStatus(
389
+ ctx: ExtensionContext,
390
+ settings: DemurSettings,
391
+ ): void {
392
+ const status = settings.enabled ? "enabled" : "disabled";
393
+ const color = settings.enabled ? "success" : "warning";
394
+ ctx.ui.setStatus("demur", ctx.ui.theme.fg(color, `demur: ${status}`));
395
+ }
396
+
397
+ function settingsNotification(settings: DemurSettings): string {
398
+ const status = settings.enabled ? "enabled" : "disabled";
399
+ return `demur ${status} globally; failure policy: ${settings.failurePolicy}.`;
400
+ }
401
+
402
+ async function handleGuardFailure(
403
+ reason: string,
404
+ command: string,
405
+ ctx: ExtensionContext,
406
+ failurePolicy: FailurePolicy,
407
+ inputTokens: number | undefined,
408
+ accumulatedCostUsd: number | undefined,
409
+ evaluationMs: number,
410
+ ): Promise<ToolCallEventResult | undefined> {
411
+ if (failurePolicy === "allow") {
412
+ notifyRun(
413
+ ctx,
414
+ "FAILURE → ALLOW",
415
+ inputTokens,
416
+ accumulatedCostUsd,
417
+ evaluationMs,
418
+ "warning",
419
+ );
420
+ return undefined;
421
+ }
422
+
423
+ if (failurePolicy === "block") {
424
+ notifyRun(
425
+ ctx,
426
+ "FAILURE → BLOCK",
427
+ inputTokens,
428
+ accumulatedCostUsd,
429
+ evaluationMs,
430
+ "warning",
431
+ );
432
+ return {
433
+ block: true,
434
+ reason: `${reason} Blocking because the Pi failure policy is block.`,
435
+ };
436
+ }
437
+
438
+ if (!ctx.hasUI) {
439
+ notifyRun(
440
+ ctx,
441
+ "FAILURE → BLOCK",
442
+ inputTokens,
443
+ accumulatedCostUsd,
444
+ evaluationMs,
445
+ "warning",
446
+ );
447
+ return {
448
+ block: true,
449
+ reason: `${reason} The Pi failure policy is ask, but no interactive UI is available, so blocking.`,
450
+ };
451
+ }
452
+
453
+ const approved = await ctx.ui.confirm(
454
+ "demur guard failure",
455
+ `${reason}\n\n${command}\n\nThe guard could not validate this command. Run it anyway?`,
456
+ );
457
+ notifyRun(
458
+ ctx,
459
+ approved ? "FAILURE → ALLOW" : "FAILURE → BLOCK",
460
+ inputTokens,
461
+ accumulatedCostUsd,
462
+ evaluationMs,
463
+ "warning",
464
+ );
465
+ if (approved) return undefined;
466
+
467
+ return {
468
+ block: true,
469
+ reason: `${reason} Execution declined after the guard failure.`,
470
+ };
471
+ }
472
+
473
+ function stripFailClosedSuffix(reason: string): string {
474
+ return reason.replace(
475
+ / Blocking because demur fails closed\. Set DEMUR_DISABLE=1 to bypass\.$/,
476
+ "",
477
+ );
244
478
  }
245
479
 
246
480
  function notifyRun(
@@ -0,0 +1,155 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ /**
7
+ * How the Pi extension handles guard failures that produce no trustworthy
8
+ * policy judgment.
9
+ */
10
+ export type FailurePolicy = "block" | "ask" | "allow";
11
+
12
+ /**
13
+ * Failure policies accepted by the Pi extension command.
14
+ */
15
+ export const FAILURE_POLICIES: readonly FailurePolicy[] = [
16
+ "block",
17
+ "ask",
18
+ "allow",
19
+ ];
20
+
21
+ /**
22
+ * Globally persisted settings for the Pi extension.
23
+ */
24
+ export type DemurSettings = {
25
+ /**
26
+ * Whether Bash calls are routed through demur.
27
+ */
28
+ enabled: boolean;
29
+ /**
30
+ * Host action to take when demur cannot obtain a trustworthy judgment.
31
+ */
32
+ failurePolicy: FailurePolicy;
33
+ };
34
+
35
+ /**
36
+ * Safe settings used when no persisted Pi configuration exists.
37
+ */
38
+ export const DEFAULT_DEMUR_SETTINGS: DemurSettings = {
39
+ enabled: true,
40
+ failurePolicy: "block",
41
+ };
42
+
43
+ type DemurConfig = DemurSettings & {
44
+ version: 1;
45
+ };
46
+
47
+ /**
48
+ * Resolve the global demur configuration file according to the XDG config
49
+ * convention.
50
+ *
51
+ * @param environment - Process environment used to resolve `XDG_CONFIG_HOME`
52
+ * @param homeDirectory - Home directory used when the XDG override is absent
53
+ * @returns Absolute path to demur's global configuration file
54
+ */
55
+ export function getDemurConfigPath(
56
+ environment: NodeJS.ProcessEnv = process.env,
57
+ homeDirectory: string = homedir(),
58
+ ): string {
59
+ const configDirectory =
60
+ environment.XDG_CONFIG_HOME || join(homeDirectory, ".config");
61
+ return join(configDirectory, "demur", "config.json");
62
+ }
63
+
64
+ /**
65
+ * Load the globally persisted Pi settings.
66
+ *
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.
69
+ * Invalid or unreadable files are rejected so the extension can warn the user
70
+ * while still falling back safely.
71
+ *
72
+ * @param configPath - Configuration file to read
73
+ * @returns Persisted settings, or safe defaults when no file exists
74
+ */
75
+ export async function loadDemurSettings(
76
+ configPath: string = getDemurConfigPath(),
77
+ ): Promise<DemurSettings> {
78
+ let content: string;
79
+ try {
80
+ content = await readFile(configPath, "utf8");
81
+ } catch (error: unknown) {
82
+ if (isErrorCode(error, "ENOENT")) return { ...DEFAULT_DEMUR_SETTINGS };
83
+ throw error;
84
+ }
85
+
86
+ const value: unknown = JSON.parse(content);
87
+ if (value === null || typeof value !== "object") {
88
+ throw new Error(`invalid demur config in ${configPath}: expected an object`);
89
+ }
90
+
91
+ const { version, enabled, failurePolicy } = value as Record<string, unknown>;
92
+ if (
93
+ version !== 1 ||
94
+ (enabled !== undefined && typeof enabled !== "boolean") ||
95
+ !isFailurePolicy(failurePolicy)
96
+ ) {
97
+ throw new Error(`invalid demur config in ${configPath}: unsupported values`);
98
+ }
99
+
100
+ return {
101
+ enabled: enabled ?? true,
102
+ failurePolicy,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Atomically persist the global Pi settings.
108
+ *
109
+ * @param settings - Settings to persist
110
+ * @param configPath - Configuration file to replace
111
+ */
112
+ export async function saveDemurSettings(
113
+ settings: DemurSettings,
114
+ configPath: string = getDemurConfigPath(),
115
+ ): Promise<void> {
116
+ const config: DemurConfig = { version: 1, ...settings };
117
+ const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
118
+
119
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
120
+ try {
121
+ await writeFile(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, {
122
+ encoding: "utf8",
123
+ flag: "wx",
124
+ mode: 0o600,
125
+ });
126
+ await rename(temporaryPath, configPath);
127
+ } finally {
128
+ try {
129
+ await unlink(temporaryPath);
130
+ } catch (error: unknown) {
131
+ if (!isErrorCode(error, "ENOENT")) throw error;
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Parse a command argument as a Pi failure policy.
138
+ *
139
+ * @param value - Raw slash-command argument
140
+ * @returns Normalized policy, or `undefined` when the argument is invalid
141
+ */
142
+ export function parseFailurePolicy(value: string): FailurePolicy | undefined {
143
+ const normalized = value.trim().toLowerCase();
144
+ return isFailurePolicy(normalized) ? normalized : undefined;
145
+ }
146
+
147
+ function isFailurePolicy(value: unknown): value is FailurePolicy {
148
+ return FAILURE_POLICIES.some((policy) => policy === value);
149
+ }
150
+
151
+ function isErrorCode(error: unknown, code: string): boolean {
152
+ return error instanceof Error &&
153
+ "code" in error &&
154
+ (error as NodeJS.ErrnoException).code === code;
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyattjoh/demur",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "A proof-of-concept destructive-command guard for coding agents.",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  ],
24
24
  "files": [
25
25
  "extensions/demur/cost-tracker.ts",
26
+ "extensions/demur/settings.ts",
26
27
  "extensions/demur/index.ts",
27
28
  "src/adapters/claude-code.ts",
28
29
  "src/adapters/pi-worker.ts",