@wyattjoh/demur 0.3.2 → 0.4.1

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.1
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.1",
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",
package/src/questions.ts CHANGED
@@ -21,62 +21,60 @@ import { Decision } from "effect/unstable/ai";
21
21
  export const QUESTIONS = {
22
22
  executesDestruction: Decision.probability({
23
23
  instructions:
24
- "Executing this command will actually carry out an operation that changes existing state, rather than only reading, searching, printing, or querying.",
24
+ "Will this command actually change existing state, rather than only read, search, print, list, query, or check?",
25
25
  criteria: {
26
- true: "Running it performs a real operation on something that already exists: removing, overwriting, moving, resetting, or reconfiguring files, permissions, ownership, processes, containers, or remote resources. Judge what the command does when it runs, including anything it reaches through a shell, an interpreter, a wrapper such as `env` or `sudo`, command substitution, an alias, or a glob that expands to an executable.",
26
+ true: "It removes, overwrites, moves, resets, or reconfigures existing files, permissions, ownership, processes, containers, or remote resources, including through a shell, interpreter, wrapper, substitution, alias, or executable glob.",
27
27
  false:
28
- "It only reads, searches, prints, lists, queries, or checks. Any destructive-looking text is inert: a search pattern, an argument to a read-only command, a string being printed or sent as data to a network service, quoted content inside a heredoc body that is payload rather than script, a comment, or documentation describing a command rather than running it.",
28
+ "It only reads or reports. Destructive-looking text is inert when used as search text, a read-only argument, printed or network payload, heredoc data, a comment, or documentation.",
29
29
  },
30
30
  }),
31
31
 
32
32
  exposesSensitiveData: Decision.probability({
33
33
  instructions:
34
- "Executing this command will reveal or transmit the value of a secret, credential, authentication material, or personal data, including by printing it into the coding agent's command output.",
34
+ "Will this command disclose an actual secret, credential, authentication material, or personal-data value to the agent or another recipient?",
35
35
  criteria: {
36
- true: "It reads sensitive values from environment variables, configuration files, credential stores, secret managers, databases, or remote systems and then prints, logs, copies, uploads, or otherwise discloses them. Output returned to the coding agent counts as disclosure, even when the command is read-only.",
36
+ true: "It reads a value from an environment variable, file, credential or secret store, database, or remote system and prints, logs, copies, or transmits it. Read-only output still counts.",
37
37
  false:
38
- "It only names sensitive files, variables, or records; lists metadata; returns redacted values; rotates or revokes credentials without revealing them; or contains labels such as `secret` or `API_KEY` as inert search text, documentation, comments, or non-sensitive payload data rather than actual values.",
38
+ "It reveals only names, metadata, schema, attributes, or redacted values; rotates or revokes without revealing; or quotes or prints secret-looking names or commands as documentation, comments, or other inert payload.",
39
39
  },
40
40
  }),
41
41
 
42
42
  weakensSecurityBoundary: Decision.probability({
43
43
  instructions:
44
- "Executing this command will actually disable, weaken, evade, or misrepresent a security boundary, or grant elevated access, rather than merely displaying or describing such an operation.",
44
+ "Will this command weaken or bypass a security control, conceal a bypass, grant elevated access, or make a sensitive resource more permissive?",
45
45
  criteria: {
46
- true: "Running it defeats authentication, authorization, policy enforcement, auditing, MFA, SSO, a firewall, or another safeguard; grants administrator, root, sudo, or otherwise elevated access; hides that a control was bypassed; or makes a sensitive resource more permissive.",
46
+ true: "It disables or evades authentication, authorization, policy, auditing, MFA, SSO, or firewall controls; grants admin, root, or sudo access; hides a bypass; or broadens sensitive access.",
47
47
  false:
48
- "It only inspects security state; enables or tightens a safeguard; revokes or narrows access; performs an ordinary scoped credential, role, permission, or membership change without elevating privileges; or changes non-security configuration. Dangerous-looking command text is inert when it is quoted, printed, searched, documented, commented, or sent as data rather than executed.",
48
+ "It only inspects controls; enables or strengthens them; revokes or narrows access; makes an ordinary non-elevating role, permission, or membership change; changes non-security configuration; or merely quotes, prints, searches, documents, comments on, or transmits dangerous text.",
49
49
  },
50
50
  }),
51
51
 
52
52
  unrecoverable: Decision.probability({
53
53
  instructions:
54
- "Whatever this command destroys could not be recovered afterwards from the state described.",
54
+ "Could state destroyed by this command not be recovered from the described state?",
55
55
  criteria: {
56
- true: "The affected data has no second copy. Uncommitted edits, untracked files, unpushed commits with no upstream, or remote resources with no backup.",
56
+ true: "It affects uncommitted edits, untracked files, unpushed commits without an upstream, or unbacked remote resources.",
57
57
  false:
58
- "Everything it touches can be restored: it is committed and pushed to git, reinstallable, regenerable by re-running a build, or a cache that rebuilds on demand.",
58
+ "Affected state is committed and pushed, reinstallable, regenerable, temporary, self-created, a rebuildable cache, or a replaceable local container.",
59
59
  },
60
60
  }),
61
61
 
62
62
  targetsSharedInfrastructure: Decision.probability({
63
63
  instructions:
64
- "This command acts on a shared, remote, or production system rather than on this machine's local checkout.",
64
+ "Does this command act on a shared, remote, or production system rather than only this machine?",
65
65
  criteria: {
66
- true: "It operates on a production or shared database, a cloud account, a Kubernetes cluster, a remote host, a container registry, or a branch on a git remote.",
67
- false:
68
- "It operates only on local files, local containers, or the local git repository.",
66
+ true: "It affects a production or shared database, cloud account, Kubernetes cluster, remote host, container registry, or remote git branch.",
67
+ false: "It affects only local files, containers, or the local git repository.",
69
68
  },
70
69
  }),
71
70
 
72
71
  blastRadius: Decision.rate({
73
- instructions:
74
- "How much would be lost if this command ran and the outcome turned out to be unwanted.",
72
+ instructions: "Rate the unwanted loss if this command ran.",
75
73
  criteria: [
76
- "Nothing of value. The command is read-only, or it touches only caches, build output, temporary files, or files it created itself.",
77
- "Recoverable work inside this project: committed files, dependencies that can be reinstalled, or generated code that can be regenerated.",
78
- "Irreplaceable local work: uncommitted edits, untracked files, unpushed commits, or unrelated files elsewhere on this machine.",
79
- "Shared or production state: a branch other people depend on, a production database, cloud infrastructure, or a live service.",
74
+ "No valuable loss: read-only, cache, build output, temporary, or self-created files.",
75
+ "Recoverable project work: committed files, reinstallable dependencies, or regenerable output.",
76
+ "Irreplaceable local work: uncommitted edits, untracked files, unpushed commits, or unrelated local files.",
77
+ "Shared or production state: depended-on remote branches, production databases, cloud infrastructure, or live services.",
80
78
  ],
81
79
  }),
82
80
  } as const;