@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 +57 -10
- package/extensions/demur/cost-tracker.ts +185 -0
- package/extensions/demur/index.ts +483 -4
- package/extensions/demur/settings.ts +155 -0
- package/package.json +5 -2
- package/src/adapters/pi-worker.ts +25 -0
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.
|
|
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.
|
|
104
|
-
|
|
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,
|
|
169
|
-
failure, malformed response, or unexpected guard error returns
|
|
170
|
-
reason that identifies the guard failure rather than presenting it
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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")
|
|
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
|
-
|
|
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
|
+
"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=
|
|
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
|
+
}
|