@wyattjoh/demur 0.3.1 → 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
@@ -90,9 +90,11 @@ pi install npm:@wyattjoh/demur
90
90
 
91
91
  Pin a specific release when reproducibility matters:
92
92
 
93
+ <!-- x-release-please-start-version -->
93
94
  ```sh
94
- pi install npm:@wyattjoh/demur@0.3.1 # x-release-please-version
95
+ pi install npm:@wyattjoh/demur@0.4.0
95
96
  ```
97
+ <!-- x-release-please-end -->
96
98
 
97
99
  Launch Pi normally after configuring the credential:
98
100
 
@@ -100,8 +102,45 @@ Launch Pi normally after configuring the credential:
100
102
  pi
101
103
  ```
102
104
 
103
- The extension intercepts `bash` tool calls. `ask` opens an interactive
104
- confirmation dialog; without an interactive UI, demur blocks the command.
105
+ The extension intercepts `bash` tool calls. Because Pi runs extensions under
106
+ Node.js while demur uses `Bun.secrets`, the extension launches a package-local
107
+ Bun worker for each judgment. The API key remains inside that worker; only the
108
+ command request and resulting verdict cross its local stdio pipes. `ask` opens
109
+ an interactive confirmation dialog; without an interactive UI, demur blocks the
110
+ command.
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
+
130
+ After each run, Pi's interactive UI prints the decision, submitted input-token
131
+ count, the run's estimated input cost, the accumulated global estimate, and the
132
+ wall-clock evaluation time in human-readable units. The
133
+ estimate uses TypeSafe's published Jev price of
134
+ [$0.042 per million input tokens](https://typesafe.ai/blog/introducing-system-one-models-and-jev);
135
+ it is informational rather than an authoritative billing amount. Failure and
136
+ bypass paths that do not call Jev report that cost is unavailable.
137
+
138
+ The accumulated estimate is stored at `$XDG_STATE_HOME/demur/usage.json`, or
139
+ `~/.local/state/demur/usage.json` when `XDG_STATE_HOME` is unset. A lock
140
+ serializes concurrent Pi instances, and each update is written to a temporary
141
+ file before an atomic rename so the total cannot be partially written or lose a
142
+ concurrent increment. Cost-accounting failures do not change demur's guard
143
+ decision; the status reports `accumulated unavailable` instead.
105
144
 
106
145
  Pi packages execute with the user's full system permissions. Review this
107
146
  repository before installing it.
@@ -165,13 +204,20 @@ From a development checkout, `bun run judge "<command>"` remains available.
165
204
 
166
205
  ## Failure posture
167
206
 
168
- demur fails closed. A missing key, credential-store failure, timeout, API
169
- failure, malformed response, or unexpected guard error returns `deny` with a
170
- reason that identifies the guard failure rather than presenting it as a policy
171
- 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.
172
211
 
173
- `DEMUR_DISABLE=1` is an explicit emergency bypass. It disables all protection
174
- and should remain unset during normal use.
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.
218
+
219
+ `DEMUR_DISABLE=1` remains the cross-host emergency bypass. It disables judgment
220
+ and protection entirely and should remain unset during normal use.
175
221
 
176
222
  ## Known limitations
177
223
 
@@ -182,7 +228,8 @@ and should remain unset during normal use.
182
228
  - Attacker-controlled command text can influence the model.
183
229
  - Shell expansion, obfuscation, aliases, wrappers, and runtime environment can
184
230
  make a command behave differently from its text.
185
- - 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.
186
233
  - Every decision adds remote-call latency and may incur provider cost.
187
234
  - The integrations guard agent-issued Bash tool calls only. They do not guard
188
235
  user shells, other process-launching tools, or commands run outside the host.
@@ -0,0 +1,185 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ mkdir,
4
+ readFile,
5
+ rename,
6
+ rmdir,
7
+ unlink,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+
13
+ const LOCK_RETRY_MS = 10;
14
+ const LOCK_TIMEOUT_MS = 5_000;
15
+
16
+ /**
17
+ * TypeSafe's published Jev input price in US dollars per million tokens.
18
+ *
19
+ * Source: https://typesafe.ai/blog/introducing-system-one-models-and-jev
20
+ */
21
+ export const JEV_INPUT_COST_USD_PER_MILLION = 0.042;
22
+
23
+ /**
24
+ * Persisted global usage and estimated-cost totals.
25
+ */
26
+ export type CostTotals = {
27
+ version: 1;
28
+ totalInputTokens: number;
29
+ estimatedCostUsd: number;
30
+ updatedAt: string;
31
+ };
32
+
33
+ /**
34
+ * Resolve the global demur usage file according to the XDG state convention.
35
+ *
36
+ * @param environment - Process environment used to resolve `XDG_STATE_HOME`
37
+ * @param homeDirectory - Home directory used when the XDG override is absent
38
+ * @returns Absolute path to demur's usage state file
39
+ */
40
+ export function getCostStatePath(
41
+ environment: NodeJS.ProcessEnv = process.env,
42
+ homeDirectory: string = homedir(),
43
+ ): string {
44
+ const stateDirectory = environment.XDG_STATE_HOME || join(homeDirectory, ".local", "state");
45
+ return join(stateDirectory, "demur", "usage.json");
46
+ }
47
+
48
+ /**
49
+ * Estimate the Jev input cost from TypeSafe's published per-token price.
50
+ *
51
+ * This is a display estimate rather than an authoritative billing amount.
52
+ *
53
+ * @param inputTokens - Number of input tokens submitted to Jev
54
+ * @returns Estimated cost in US dollars
55
+ */
56
+ export function estimateInputCostUsd(inputTokens: number): number {
57
+ return (inputTokens * JEV_INPUT_COST_USD_PER_MILLION) / 1_000_000;
58
+ }
59
+
60
+ /**
61
+ * Atomically add one judgment's usage to the global accumulated estimate.
62
+ *
63
+ * A lock directory serializes read-modify-write operations across Pi processes.
64
+ * The updated JSON is written to a same-directory temporary file and atomically
65
+ * renamed over the previous state so readers never observe partial content.
66
+ *
67
+ * @param inputTokens - Number of input tokens submitted for this judgment
68
+ * @param statePath - Usage file to update
69
+ * @returns Updated accumulated totals
70
+ */
71
+ export async function recordInputCost(
72
+ inputTokens: number,
73
+ statePath: string = getCostStatePath(),
74
+ ): Promise<CostTotals> {
75
+ if (!Number.isSafeInteger(inputTokens) || inputTokens < 0) {
76
+ throw new Error("input token count must be a non-negative safe integer");
77
+ }
78
+
79
+ await mkdir(dirname(statePath), { recursive: true, mode: 0o700 });
80
+ const release = await acquireLock(`${statePath}.lock`);
81
+
82
+ try {
83
+ const current = await readTotals(statePath);
84
+ const next: CostTotals = {
85
+ version: 1,
86
+ totalInputTokens: current.totalInputTokens + inputTokens,
87
+ estimatedCostUsd:
88
+ current.estimatedCostUsd + estimateInputCostUsd(inputTokens),
89
+ updatedAt: new Date().toISOString(),
90
+ };
91
+ await replaceJsonAtomically(statePath, next);
92
+ return next;
93
+ } finally {
94
+ await release();
95
+ }
96
+ }
97
+
98
+ async function acquireLock(lockPath: string): Promise<() => Promise<void>> {
99
+ const startedAt = Date.now();
100
+
101
+ while (true) {
102
+ try {
103
+ await mkdir(lockPath, { mode: 0o700 });
104
+ return async () => {
105
+ await rmdir(lockPath);
106
+ };
107
+ } catch (error: unknown) {
108
+ if (!isErrorCode(error, "EEXIST")) throw error;
109
+ if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
110
+ throw new Error(`timed out waiting for cost state lock: ${lockPath}`);
111
+ }
112
+ await delay(LOCK_RETRY_MS);
113
+ }
114
+ }
115
+ }
116
+
117
+ async function readTotals(statePath: string): Promise<CostTotals> {
118
+ let content: string;
119
+ try {
120
+ content = await readFile(statePath, "utf8");
121
+ } catch (error: unknown) {
122
+ if (isErrorCode(error, "ENOENT")) {
123
+ return {
124
+ version: 1,
125
+ totalInputTokens: 0,
126
+ estimatedCostUsd: 0,
127
+ updatedAt: new Date(0).toISOString(),
128
+ };
129
+ }
130
+ throw error;
131
+ }
132
+
133
+ const value: unknown = JSON.parse(content);
134
+ if (value === null || typeof value !== "object") {
135
+ throw new Error(`invalid cost state in ${statePath}: expected an object`);
136
+ }
137
+
138
+ const { version, totalInputTokens, estimatedCostUsd, updatedAt } =
139
+ value as Record<string, unknown>;
140
+ if (
141
+ version !== 1 ||
142
+ !Number.isSafeInteger(totalInputTokens) ||
143
+ (totalInputTokens as number) < 0 ||
144
+ typeof estimatedCostUsd !== "number" ||
145
+ !Number.isFinite(estimatedCostUsd) ||
146
+ estimatedCostUsd < 0 ||
147
+ typeof updatedAt !== "string"
148
+ ) {
149
+ throw new Error(`invalid cost state in ${statePath}: unsupported values`);
150
+ }
151
+
152
+ return value as CostTotals;
153
+ }
154
+
155
+ async function replaceJsonAtomically(
156
+ statePath: string,
157
+ totals: CostTotals,
158
+ ): Promise<void> {
159
+ const temporaryPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
160
+
161
+ try {
162
+ await writeFile(temporaryPath, `${JSON.stringify(totals, null, 2)}\n`, {
163
+ encoding: "utf8",
164
+ flag: "wx",
165
+ mode: 0o600,
166
+ });
167
+ await rename(temporaryPath, statePath);
168
+ } finally {
169
+ try {
170
+ await unlink(temporaryPath);
171
+ } catch (error: unknown) {
172
+ if (!isErrorCode(error, "ENOENT")) throw error;
173
+ }
174
+ }
175
+ }
176
+
177
+ function isErrorCode(error: unknown, code: string): boolean {
178
+ return error instanceof Error &&
179
+ "code" in error &&
180
+ (error as NodeJS.ErrnoException).code === code;
181
+ }
182
+
183
+ function delay(milliseconds: number): Promise<void> {
184
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
185
+ }
@@ -1,3 +1,5 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
1
3
  import {
2
4
  isToolCallEventType,
3
5
  type ExtensionAPI,
@@ -5,7 +7,25 @@ import {
5
7
  type ToolCallEvent,
6
8
  type ToolCallEventResult,
7
9
  } from "@earendil-works/pi-coding-agent";
8
- import { guard } from "../../src/guard.ts";
10
+ import type { Verdict } from "../../src/types.ts";
11
+ import {
12
+ estimateInputCostUsd,
13
+ recordInputCost,
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";
24
+
25
+ const WORKER_PATH = fileURLToPath(
26
+ new URL("../../src/adapters/pi-worker.ts", import.meta.url),
27
+ );
28
+ const MAX_WORKER_OUTPUT_BYTES = 64 * 1024;
9
29
 
10
30
  /**
11
31
  * Handle one `tool_call` event, guarding shell commands only.
@@ -15,22 +35,121 @@ import { guard } from "../../src/guard.ts";
15
35
  *
16
36
  * @param event - The tool call Pi is about to execute
17
37
  * @param ctx - Extension context, used for the working directory and prompts
38
+ * @param settings - Current global Pi extension settings
18
39
  * @returns A block result when the command is denied, otherwise nothing
19
40
  */
20
41
  export async function handleToolCall(
21
42
  event: ToolCallEvent,
22
43
  ctx: ExtensionContext,
44
+ settings: DemurSettings = DEFAULT_DEMUR_SETTINGS,
23
45
  ): Promise<ToolCallEventResult | undefined> {
24
46
  if (!isToolCallEventType("bash", event)) return undefined;
47
+ if (!settings.enabled) return undefined;
25
48
 
26
49
  const command = event.input.command ?? "";
27
50
  if (command.trim() === "") return undefined;
28
51
 
29
- const verdict = await guard(command, ctx.cwd, "pi", ctx.signal);
52
+ const evaluationStartedAt = performance.now();
53
+ let verdict: Verdict;
54
+ try {
55
+ verdict = await runGuardWorker(command, ctx.cwd, ctx.signal);
56
+ } catch (error: unknown) {
57
+ const evaluationMs = performance.now() - evaluationStartedAt;
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
+ );
82
+ }
83
+
84
+ const evaluationMs = performance.now() - evaluationStartedAt;
85
+ const inputTokens = verdict.usage?.inputTokens;
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
+ }
30
131
 
31
- if (verdict.decision === "allow") return undefined;
132
+ if (verdict.decision === "allow") {
133
+ notifyRun(
134
+ ctx,
135
+ "ALLOW",
136
+ inputTokens,
137
+ accumulatedCostUsd,
138
+ evaluationMs,
139
+ "info",
140
+ );
141
+ return undefined;
142
+ }
32
143
 
33
144
  if (verdict.decision === "deny") {
145
+ notifyRun(
146
+ ctx,
147
+ "DENY",
148
+ inputTokens,
149
+ accumulatedCostUsd,
150
+ evaluationMs,
151
+ "warning",
152
+ );
34
153
  return { block: true, reason: verdict.reason };
35
154
  }
36
155
 
@@ -38,6 +157,14 @@ export async function handleToolCall(
38
157
  // better than the agent guessing. Without a UI there is nobody to ask, so the
39
158
  // fail-closed posture applies and the command is blocked.
40
159
  if (!ctx.hasUI) {
160
+ notifyRun(
161
+ ctx,
162
+ "ASK → BLOCK",
163
+ inputTokens,
164
+ accumulatedCostUsd,
165
+ evaluationMs,
166
+ "warning",
167
+ );
41
168
  return {
42
169
  block: true,
43
170
  reason: `${verdict.reason} No interactive UI available to confirm, so blocking.`,
@@ -45,11 +172,140 @@ export async function handleToolCall(
45
172
  }
46
173
 
47
174
  const approved = await ctx.ui.confirm("demur", `${verdict.reason}\n\n${command}\n\nRun it anyway?`);
175
+ notifyRun(
176
+ ctx,
177
+ approved ? "ASK → ALLOW" : "ASK → BLOCK",
178
+ inputTokens,
179
+ accumulatedCostUsd,
180
+ evaluationMs,
181
+ approved ? "info" : "warning",
182
+ );
48
183
  if (approved) return undefined;
49
184
 
50
185
  return { block: true, reason: `${verdict.reason} Declined by the user.` };
51
186
  }
52
187
 
188
+ /**
189
+ * Format the compact status Pi prints after each demur run.
190
+ *
191
+ * @param result - Guard decision and any final user-confirmation outcome
192
+ * @param inputTokens - Submitted Jev input tokens, when a judgment completed
193
+ * @param accumulatedCostUsd - Persisted global estimate after this run
194
+ * @param evaluationMs - Wall-clock time spent obtaining the guard verdict
195
+ * @returns One-line status with costs and evaluation duration
196
+ */
197
+ export function formatRunNotification(
198
+ result: string,
199
+ inputTokens: number | undefined,
200
+ accumulatedCostUsd: number | undefined,
201
+ evaluationMs: number,
202
+ ): string {
203
+ const duration = `evaluated in ${formatEvaluationDuration(evaluationMs)}`;
204
+ if (inputTokens === undefined) {
205
+ return `demur: ${result} · cost unavailable · ${duration}`;
206
+ }
207
+
208
+ const accumulated =
209
+ accumulatedCostUsd === undefined
210
+ ? "accumulated unavailable"
211
+ : `accumulated ${formatUsd(accumulatedCostUsd)}`;
212
+ return `demur: ${result} · ${inputTokens.toLocaleString("en-US")} input tokens · estimated cost ${formatUsd(estimateInputCostUsd(inputTokens))} · ${accumulated} · ${duration}`;
213
+ }
214
+
215
+ /**
216
+ * Format an evaluation duration using compact human-readable units.
217
+ *
218
+ * @param milliseconds - Non-negative wall-clock duration in milliseconds
219
+ * @returns Duration rendered in milliseconds, seconds, or minutes
220
+ */
221
+ export function formatEvaluationDuration(milliseconds: number): string {
222
+ const bounded = Math.max(0, milliseconds);
223
+ if (bounded < 1) return "<1 ms";
224
+ if (bounded < 1_000) return `${Math.round(bounded)} ms`;
225
+ if (bounded < 60_000) return `${formatDecimal(bounded / 1_000, bounded < 10_000 ? 2 : 1)} s`;
226
+
227
+ const minutes = Math.floor(bounded / 60_000);
228
+ const seconds = (bounded % 60_000) / 1_000;
229
+ return `${minutes}m ${formatDecimal(seconds, seconds < 10 ? 1 : 0)}s`;
230
+ }
231
+
232
+ /**
233
+ * Run the Bun-native guard behind Pi's Node-compatible extension boundary.
234
+ *
235
+ * The API key remains inside the worker process: only the command request and
236
+ * resulting verdict cross the local stdio pipes.
237
+ *
238
+ * @param command - The shell command Pi is about to execute
239
+ * @param cwd - Absolute working directory for the command
240
+ * @param signal - Optional cancellation signal from Pi
241
+ * @param environment - Environment inherited by the Bun worker
242
+ * @returns The guard verdict produced by the worker
243
+ */
244
+ export function runGuardWorker(
245
+ command: string,
246
+ cwd: string,
247
+ signal: AbortSignal | undefined,
248
+ environment: NodeJS.ProcessEnv = process.env,
249
+ ): Promise<Verdict> {
250
+ return new Promise((resolve, reject) => {
251
+ if (signal?.aborted) {
252
+ reject(new Error("guard request cancelled"));
253
+ return;
254
+ }
255
+
256
+ const child = spawn("bun", [WORKER_PATH], {
257
+ cwd,
258
+ env: environment,
259
+ stdio: ["pipe", "pipe", "pipe"],
260
+ });
261
+ let stdout = "";
262
+ let stderr = "";
263
+ let settled = false;
264
+
265
+ const finish = (outcome: () => void) => {
266
+ if (settled) return;
267
+ settled = true;
268
+ signal?.removeEventListener("abort", abort);
269
+ outcome();
270
+ };
271
+ const abort = () => {
272
+ child.kill();
273
+ finish(() => reject(new Error("guard request cancelled")));
274
+ };
275
+ const appendBounded = (current: string, chunk: Buffer): string =>
276
+ `${current}${chunk.toString("utf8")}`.slice(0, MAX_WORKER_OUTPUT_BYTES);
277
+
278
+ signal?.addEventListener("abort", abort, { once: true });
279
+ child.stdout.on("data", (chunk: Buffer) => {
280
+ stdout = appendBounded(stdout, chunk);
281
+ if (stdout.length >= MAX_WORKER_OUTPUT_BYTES) child.kill();
282
+ });
283
+ child.stderr.on("data", (chunk: Buffer) => {
284
+ stderr = appendBounded(stderr, chunk);
285
+ if (stderr.length >= MAX_WORKER_OUTPUT_BYTES) child.kill();
286
+ });
287
+ child.on("error", (error) => finish(() => reject(error)));
288
+ child.on("close", (code, exitSignal) => {
289
+ finish(() => {
290
+ if (code !== 0) {
291
+ const detail = stderr.trim() || `exit ${code ?? exitSignal ?? "unknown"}`;
292
+ reject(new Error(detail));
293
+ return;
294
+ }
295
+
296
+ try {
297
+ resolve(parseVerdict(stdout));
298
+ } catch (error: unknown) {
299
+ reject(new Error(`invalid guard worker response: ${errorDetail(error)}`));
300
+ }
301
+ });
302
+ });
303
+
304
+ child.stdin.on("error", (error) => finish(() => reject(error)));
305
+ child.stdin.end(JSON.stringify({ command, cwd }));
306
+ });
307
+ }
308
+
53
309
  /**
54
310
  * Pi extension entry point.
55
311
  *
@@ -59,5 +315,228 @@ export async function handleToolCall(
59
315
  * @param pi - The extension API provided by Pi
60
316
  */
61
317
  export default function demur(pi: ExtensionAPI): void {
62
- 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
+ );
478
+ }
479
+
480
+ function notifyRun(
481
+ ctx: ExtensionContext,
482
+ result: string,
483
+ inputTokens: number | undefined,
484
+ accumulatedCostUsd: number | undefined,
485
+ evaluationMs: number,
486
+ level: "info" | "warning",
487
+ ): void {
488
+ ctx.ui.notify(
489
+ formatRunNotification(
490
+ result,
491
+ inputTokens,
492
+ accumulatedCostUsd,
493
+ evaluationMs,
494
+ ),
495
+ level,
496
+ );
497
+ }
498
+
499
+ async function recordAccumulatedCost(
500
+ inputTokens: number | undefined,
501
+ ): Promise<number | undefined> {
502
+ if (inputTokens === undefined) return undefined;
503
+
504
+ try {
505
+ return (await recordInputCost(inputTokens)).estimatedCostUsd;
506
+ } catch {
507
+ return undefined;
508
+ }
509
+ }
510
+
511
+ function formatDecimal(value: number, fractionDigits: number): string {
512
+ return value
513
+ .toFixed(fractionDigits)
514
+ .replace(/(\.\d*?[1-9])0+$/, "$1")
515
+ .replace(/\.0+$/, "");
516
+ }
517
+
518
+ function formatUsd(value: number): string {
519
+ const decimal = value.toFixed(9).replace(/0+$/, "").replace(/\.$/, "");
520
+ return `$${decimal}`;
521
+ }
522
+
523
+ function parseVerdict(output: string): Verdict {
524
+ const value: unknown = JSON.parse(output);
525
+ if (value === null || typeof value !== "object") {
526
+ throw new Error("verdict must be an object");
527
+ }
528
+
529
+ const { decision, reason } = value as Record<string, unknown>;
530
+ if (
531
+ (decision !== "allow" && decision !== "ask" && decision !== "deny") ||
532
+ typeof reason !== "string"
533
+ ) {
534
+ throw new Error("verdict must contain a valid decision and reason");
535
+ }
536
+
537
+ return value as Verdict;
538
+ }
539
+
540
+ function errorDetail(error: unknown): string {
541
+ return error instanceof Error ? error.message : String(error);
63
542
  }
@@ -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.1",
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",
@@ -22,8 +22,11 @@
22
22
  "security"
23
23
  ],
24
24
  "files": [
25
+ "extensions/demur/cost-tracker.ts",
26
+ "extensions/demur/settings.ts",
25
27
  "extensions/demur/index.ts",
26
28
  "src/adapters/claude-code.ts",
29
+ "src/adapters/pi-worker.ts",
27
30
  "src/analyze.ts",
28
31
  "src/cli.ts",
29
32
  "src/guard.internal.ts",
@@ -45,7 +48,7 @@
45
48
  "scripts": {
46
49
  "check": "tsc --noEmit",
47
50
  "test": "vitest run",
48
- "build:pi": "bun build extensions/demur/index.ts --target=bun --outfile=dist/demur-guard.ts --format=esm --external @earendil-works/pi-coding-agent",
51
+ "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",
49
52
  "build:claude": "bun build src/adapters/claude-code.ts --target=bun --outfile=dist/demur-hook.js --format=esm",
50
53
  "build": "bun run build:pi && bun run build:claude",
51
54
  "ci": "bun run check && bun run test && bun run build",
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bun
2
+ import { guard } from "../guard.ts";
3
+
4
+ type GuardRequest = {
5
+ command: string;
6
+ cwd: string;
7
+ };
8
+
9
+ const request = parseRequest(await Bun.stdin.text());
10
+ const verdict = await guard(request.command, request.cwd, "pi");
11
+ process.stdout.write(JSON.stringify(verdict));
12
+
13
+ function parseRequest(input: string): GuardRequest {
14
+ const value: unknown = JSON.parse(input);
15
+ if (value === null || typeof value !== "object") {
16
+ throw new Error("guard request must be an object");
17
+ }
18
+
19
+ const { command, cwd } = value as Record<string, unknown>;
20
+ if (typeof command !== "string" || typeof cwd !== "string") {
21
+ throw new Error("guard request must contain string command and cwd fields");
22
+ }
23
+
24
+ return { command, cwd };
25
+ }