@pify/yolo 0.2.0 → 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
@@ -1,26 +1,35 @@
1
1
  # @pify/yolo
2
2
 
3
- One toggle to auto-approve everything in [pi](https://github.com/earendil-works/pi) — with an undo trail so YOLO never means unrecoverable.
3
+ A safety gradient for [pi](https://github.com/earendil-works/pi), from auto-approve-everything to ask-about-anything — with an undo trail so YOLO never means unrecoverable.
4
4
 
5
5
  Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install yolo`](https://github.com/pifydev/cli) or `pi install npm:@pify/yolo`.
6
6
 
7
- ## Two modes, one toggle
7
+ ## Four modes, one command (v0.4)
8
8
 
9
- **🛡 guard** (default) every bash call runs through a three-tier gate:
9
+ `/yolo <mode>` moves along a gradient. Two things hold in **every** mode, which is what makes the gradient safe to move along: catastrophic commands block, and secret material asks.
10
+
11
+ | Mode | Catastrophic | Built-in destructive | Your `.pi/yolo.json` ask-rules | Everything else |
12
+ |---|---|---|---|---|
13
+ | `⚡ yolo` | blocked | runs | runs | runs |
14
+ | `⚙ auto` | blocked | runs | asks | runs |
15
+ | `🛡 approve` *(default)* | blocked | asks | asks | runs |
16
+ | `🔒 strict` | blocked | asks | asks | asks unless plainly read-only |
17
+
18
+ Bare `/yolo` still flips between `yolo` and `approve` — the two ends people actually toggle between. Sessions saved before v0.4 carried `guard`; that is what `approve` is now called, and they reopen there.
19
+
20
+ **Behaviour change in v0.4**: `yolo` used to stand the *whole* gate down, catastrophic patterns included, which contradicted the rules' own claim that the floor is never overridable. The floor now holds in yolo mode too. If you were relying on `rm -rf /` auto-approving, you were relying on a bug.
10
21
 
11
22
  | Tier | Examples | Behavior |
12
23
  |---|---|---|
13
- | **BLOCK** | `rm -rf /`, `rm -rf ~`, `rm -rf .git`, `mkfs`, `dd of=/dev/…`, fork bomb, `> /dev/sda` | Refused outright. Never overridable — not even by user rules. |
24
+ | **BLOCK** | `rm -rf /`, `rm -rf ~`, `rm -rf .git`, `mkfs`, `dd of=/dev/…`, fork bomb, `> /dev/sda` | Refused outright, in every mode. Never overridable — not by user rules, not by a mode. |
14
25
  | **ASK** | `rm -rf <path>`, `git push --force`, `git reset --hard`, `git clean -f`, `curl \| sh`, `find -delete`, `chmod 777`, history rewrites | Confirmation dialog with the command shown. Denials can carry your reason back to the agent. |
15
- | ALLOW | everything else | Runs untouched. |
16
-
17
- **⚡ yolo** (`/yolo`) — the gate stands down and everything auto-approves. The trail keeps recording.
26
+ | ALLOW | everything else | Runs untouched (unless you are in `strict`). |
18
27
 
19
28
  Fail-closed everywhere: rule-evaluation errors block; ASK without a UI (headless/CI) denies.
20
29
 
21
30
  ## Secret files (v0.2)
22
31
 
23
- Credentials are the one thing yolo mode does **not** wave through — auto-approving speed is worth it, auto-approving your AWS keys into a prompt is not. Any `read`/`edit`/`write` on secret material, and any bash command that names it, asks first in both modes:
32
+ Credentials are the one thing no mode waves through — auto-approving speed is worth it, auto-approving your AWS keys into a prompt is not. Any `read`/`edit`/`write` on secret material, and any bash command that names it, asks first in every mode:
24
33
 
25
34
  `.env` (and `.env.*`, but not `.env.example`/`.sample`/`.template`) · `~/.ssh/*` and `id_rsa`/`id_ed25519`-style keys (`.pub` halves are fine) · `.aws/credentials` · `.pi/agent/auth.json`, `.claude/.credentials.json` · `.npmrc`, `.pypirc`, `.netrc`, `.git-credentials` · `~/.config/gh/hosts.yml` · `*.pem`, `*.key`, `*.p12`, `*.pfx` · `secrets.json`/`credentials.yaml`
26
35
 
@@ -39,12 +48,37 @@ That covers what `/yolo undo` can't: damage done by a command rather than by an
39
48
 
40
49
  ## The undo trail
41
50
 
42
- Always on, in both modes:
51
+ Always on, in every mode:
43
52
 
44
53
  - Every `edit`/`write` saves the file's **pre-image** first (per-project trail under the agent dir — survives restarts).
45
54
  - Risky bash commands are logged with cwd, timestamp, and git HEAD.
46
55
  - `/yolo trail` shows history; `/yolo undo [n]` restores the newest n file changes (with a confirmation listing exactly what will be touched). Files that didn't exist before are deleted; bash effects are logged but not undoable.
47
56
 
57
+ ## AI classifier (v0.3, opt-in)
58
+
59
+ `/yolo classifier on` adds a third tier behind the regexes. Regexes only know the destructive shapes someone thought to write down — `find . -name '*.ts' -exec sed -i … {} +` is not one of them. When no rule matches, a model reads the command and can raise it to a confirmation.
60
+
61
+ Two rules keep it honest:
62
+
63
+ - **Escalation only.** It can turn `allow` into `ask`. It can never turn an `ask` or a `block` into an `allow`, so a classifier that gets talked into approving something cannot open the gate.
64
+ - **A broken classifier changes nothing.** Timeout (20s), unreadable answer, no model available → the deterministic verdict stands. Safety comes from the rules; this is a second pair of eyes, not the gate.
65
+
66
+ Obviously-safe commands (`git status`, `ls`, `cat`, `bun test`, …) skip the call entirely, so the cost lands only on unfamiliar ones.
67
+
68
+ Measured over OpenRouter on six commands (three genuinely destructive, three read-only):
69
+
70
+ | Model | Correct | Unreadable → no opinion |
71
+ |---|---|---|
72
+ | GPT-5.6 luna | 6/6 | 0 |
73
+ | GPT-5.5 | 6/6 | 0 |
74
+ | Claude Opus 4.8 | 6/6 | 0 |
75
+ | GPT-5.6 terra / sol | 5/6 | 1 |
76
+ | Claude Opus 5 | 4/6 | 1 |
77
+ | Gemini 3.1 Pro | 2/6 | 4 |
78
+ | Qwen3 235B | 2/6 | 4 |
79
+
80
+ Every miss fell back to *allow* — no run ever downgraded a command the rules had already flagged. Weaker models simply give you less extra protection.
81
+
48
82
  ## Custom rules
49
83
 
50
84
  `.pi/yolo.json` — wildcard patterns, last-match-wins, may retune ASK/ALLOW but never the BLOCK floor:
@@ -61,10 +95,12 @@ Always on, in both modes:
61
95
  ## Commands
62
96
 
63
97
  ```
64
- /yolo # toggle guard yolo
65
- /yolo status # mode, rule count, trail size
98
+ /yolo # flip between yolo and approve
99
+ /yolo strict # or: yolo | auto | approve | strict (v0.4)
100
+ /yolo status # mode, the other modes, rule count, trail size
66
101
  /yolo trail # recent trail entries
67
102
  /yolo undo 3 # restore the newest 3 file pre-images
103
+ /yolo classifier on # let a model flag unfamiliar commands (v0.3)
68
104
  ```
69
105
 
70
106
  ## License
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * @pify/yolo — one toggle to auto-approve everything, with an undo trail.
3
3
  *
4
- * Two modes. guard (default): bash runs through a three-tier safety gate
5
- * catastrophic patterns BLOCK outright (never overridable), destructive
6
- * ones ASK with the command shown (denial reasons flow back to the agent),
7
- * everything else runs. yolo: the gate stands down and everything
8
- * auto-approves but the trail keeps recording. In BOTH modes every
4
+ * A four-mode gradient (v0.4): yolo · auto · approve (default) · strict.
5
+ * Bash runs through a three-tier rule set — catastrophic patterns BLOCK,
6
+ * destructive ones ASK with the command shown (denial reasons flow back to
7
+ * the agent), everything else runs and the mode decides how much of that
8
+ * to relax or tighten. Two invariants hold in every mode: the catastrophic
9
+ * floor blocks, and secret material asks. In EVERY mode
9
10
  * edit/write saves a pre-image first and risky bash commands are logged,
10
11
  * so /yolo undo can walk file changes back even after a restart.
11
12
  *
@@ -13,7 +14,7 @@
13
14
  * headless ASK becomes deny. User rules in .pi/yolo.json (wildcard,
14
15
  * last-match-wins) can retune ASK/ALLOW but never the catastrophic floor.
15
16
  *
16
- * v0.2 adds two things yolo mode deliberately does not stand down for:
17
+ * v0.2 adds two things no mode stands down for:
17
18
  * secret material (.env, ssh keys, cloud/registry credentials) asks before
18
19
  * any read/edit/write or naming command, and every risky bash command gets a
19
20
  * `git stash create` checkpoint recorded on the trail so command damage —
@@ -24,7 +25,11 @@
24
25
  * /yolo session toggle (valdo766hi).
25
26
  */
26
27
  import {
28
+ DefaultResourceLoader,
29
+ SessionManager,
30
+ createAgentSession,
27
31
  getAgentDir,
32
+ type AgentSession,
28
33
  type ExtensionAPI,
29
34
  type ExtensionContext,
30
35
  } from "@earendil-works/pi-coding-agent";
@@ -32,22 +37,46 @@ import { execFileSync } from "node:child_process";
32
37
  import { readFileSync } from "node:fs";
33
38
  import { join } from "node:path";
34
39
 
40
+ import {
41
+ CLASSIFY_SYSTEM_PROMPT,
42
+ applyClassification,
43
+ buildClassifyPrompt,
44
+ needsClassification,
45
+ parseClassification,
46
+ type Classification,
47
+ } from "../src/classify.ts";
35
48
  import { evaluateCommand, evaluatePath, parseUserRules } from "../src/rules.ts";
36
49
  import { formatTrail, readManifest, recordBash, recordPreImage, trailDir, undo } from "../src/trail.ts";
50
+ import {
51
+ MODES,
52
+ MODE_BADGES,
53
+ MODE_LABELS,
54
+ askTitle,
55
+ normalizeMode,
56
+ resolveAction,
57
+ } from "../src/modes.ts";
37
58
  import { isRecord, type Mode, type UserRule } from "../src/types.ts";
38
59
 
60
+ /** Today's guard, under its new name. */
61
+ const DEFAULT_MODE: Mode = "approve";
62
+
39
63
  const MODE_ENTRY = "yolo-mode";
64
+ const CLASSIFIER_ENTRY = "yolo-classifier";
65
+ /** In front of every bash call: a slow answer costs seconds, not minutes. */
66
+ const CLASSIFY_TIMEOUT_MS = 20_000;
40
67
 
41
68
  type UiContext = ExtensionContext;
42
69
 
43
70
  export default function yolo(pi: ExtensionAPI) {
44
- let mode: Mode = "guard";
71
+ let mode: Mode = DEFAULT_MODE;
72
+ /** Opt-in: layer 3 costs a model call on unfamiliar commands. */
73
+ let classifierEnabled = false;
45
74
  let userRules: UserRule[] = [];
46
75
  let dir = "";
47
76
 
48
77
  function updateFooter(ctx: UiContext): void {
49
78
  if (!ctx.hasUI) return;
50
- ctx.ui.setStatus("yolo", mode === "yolo" ? "⚡ YOLO" : undefined);
79
+ ctx.ui.setStatus("yolo", MODE_BADGES[mode]);
51
80
  }
52
81
 
53
82
  function setMode(ctx: UiContext, next: Mode): void {
@@ -56,9 +85,10 @@ export default function yolo(pi: ExtensionAPI) {
56
85
  updateFooter(ctx);
57
86
  if (ctx.hasUI) {
58
87
  ctx.ui.notify(
59
- next === "yolo"
60
- ? "⚡ YOLO on — everything auto-approves. The undo trail keeps recording; /yolo to turn the guard back on."
61
- : "🛡 Guard on catastrophic commands block, destructive ones ask.",
88
+ [
89
+ MODE_LABELS[next],
90
+ "Catastrophic commands block and secrets ask in every mode. The undo trail keeps recording.",
91
+ ].join("\n"),
62
92
  next === "yolo" ? "warning" : "info",
63
93
  );
64
94
  }
@@ -134,6 +164,56 @@ export default function yolo(pi: ExtensionAPI) {
134
164
  };
135
165
  }
136
166
 
167
+ /**
168
+ * Ask a model whether an unmatched command is risky. Short timeout: this
169
+ * sits in front of every bash call, so a slow answer must cost the session
170
+ * seconds, not minutes — and a timeout is simply "no opinion".
171
+ */
172
+ async function classifyCommand(ctx: UiContext, command: string): Promise<Classification> {
173
+ let session: AgentSession | null = null;
174
+ try {
175
+ const created = await createAgentSession({
176
+ sessionManager: SessionManager.inMemory(ctx.cwd),
177
+ model: ctx.model as never,
178
+ tools: [],
179
+ resourceLoader: new DefaultResourceLoader({
180
+ cwd: ctx.cwd,
181
+ agentDir: getAgentDir(),
182
+ noExtensions: true,
183
+ noPromptTemplates: true,
184
+ noThemes: true,
185
+ // Replace the coding-agent prompt rather than append to it: with
186
+ // the default prompt in place, models answer a classification
187
+ // request with a markdown explanation instead of the JSON line.
188
+ systemPrompt: CLASSIFY_SYSTEM_PROMPT.join(" "),
189
+ } as never),
190
+ });
191
+ session = created.session;
192
+ await session.prompt(buildClassifyPrompt(command, ctx.cwd), {
193
+ signal: AbortSignal.timeout(CLASSIFY_TIMEOUT_MS),
194
+ } as never);
195
+ const messages = session.messages as Array<{ role?: string; content?: Array<{ type?: string; text?: string }> }>;
196
+ const last = [...messages].reverse().find((m) => m.role === "assistant");
197
+ const text = (last?.content ?? [])
198
+ .filter((part) => part.type === "text" && typeof part.text === "string")
199
+ .map((part) => part.text)
200
+ .join("");
201
+ return parseClassification(text);
202
+ } catch (err) {
203
+ return {
204
+ risk: "safe",
205
+ reason: `classifier unavailable (${err instanceof Error ? err.message : String(err)})`,
206
+ fallback: true,
207
+ };
208
+ } finally {
209
+ try {
210
+ session?.dispose();
211
+ } catch {
212
+ // best-effort
213
+ }
214
+ }
215
+ }
216
+
137
217
  function loadUserRules(cwd: string): void {
138
218
  try {
139
219
  userRules = parseUserRules(JSON.parse(readFileSync(join(cwd, ".pi", "yolo.json"), "utf8")));
@@ -167,8 +247,25 @@ export default function yolo(pi: ExtensionAPI) {
167
247
  return { block: true, reason: "yolo guard: bash call without a command (fail-closed)." };
168
248
  }
169
249
 
170
- const verdict = evaluateCommand(command, userRules);
171
- const touchesSecret = verdict.rule.startsWith("secret:");
250
+ let verdict = evaluateCommand(command, userRules);
251
+
252
+ // Layer 3: a model looks at what the regexes had no opinion about. It can
253
+ // only escalate allow → ask, so a talked-into-it classifier cannot open
254
+ // the gate, and a broken one leaves the deterministic verdict standing.
255
+ if (classifierEnabled && verdict.action === "allow" && needsClassification(command)) {
256
+ const classification = await classifyCommand(ctx, command);
257
+ const escalated = applyClassification(verdict.action, classification);
258
+ if (escalated.rule) verdict = { action: "ask", rule: escalated.rule };
259
+ }
260
+
261
+ // The mode decides what the verdict means: it may relax the destructive
262
+ // tier (auto/yolo) or tighten the allow tier (strict), but never touches
263
+ // the catastrophic floor or the secret gate.
264
+ const action = resolveAction({
265
+ mode,
266
+ verdict,
267
+ obviouslySafe: !needsClassification(command),
268
+ });
172
269
 
173
270
  // Log risky commands, with a checkpoint of the tree as it was.
174
271
  if (dir && verdict.action !== "allow") {
@@ -176,12 +273,9 @@ export default function yolo(pi: ExtensionAPI) {
176
273
  recordBash(dir, command, ctx.cwd, gitHead(ctx.cwd), now, gitCheckpoint(ctx.cwd, now));
177
274
  }
178
275
 
179
- // The gate stands down in yolo mode — except for secrets.
180
- if (mode === "yolo" && !touchesSecret) return undefined;
181
-
182
- if (verdict.action === "allow") return undefined;
276
+ if (action === "allow") return undefined;
183
277
 
184
- if (verdict.action === "block") {
278
+ if (action === "block") {
185
279
  return {
186
280
  block: true,
187
281
  reason: `yolo guard blocked this command (${verdict.rule}) — catastrophic patterns are never auto-approved. Do not retry it; choose a safer approach.`,
@@ -196,7 +290,7 @@ export default function yolo(pi: ExtensionAPI) {
196
290
  };
197
291
  }
198
292
  const approved = await ctx.ui.confirm(
199
- touchesSecret ? "Command touches secret material" : "Destructive command",
293
+ askTitle(mode, verdict),
200
294
  `${command}\n\nRule: ${verdict.rule}. Run it?`,
201
295
  );
202
296
  if (approved) return undefined;
@@ -216,22 +310,32 @@ export default function yolo(pi: ExtensionAPI) {
216
310
  pi.on("session_start", async (_event, ctx) => {
217
311
  dir = trailDir(getAgentDir(), ctx.cwd);
218
312
  loadUserRules(ctx.cwd);
219
- mode = "guard";
313
+ mode = DEFAULT_MODE;
314
+ classifierEnabled = false;
220
315
  for (const entry of ctx.sessionManager.getBranch()) {
221
316
  const e = entry as { type?: string; customType?: string; data?: unknown };
222
317
  if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
223
- if (e.data.mode === "yolo" || e.data.mode === "guard") mode = e.data.mode;
318
+ const restored = normalizeMode(e.data.mode);
319
+ if (restored) mode = restored;
320
+ }
321
+ if (e.type === "custom" && e.customType === CLASSIFIER_ENTRY && isRecord(e.data)) {
322
+ if (typeof e.data.enabled === "boolean") classifierEnabled = e.data.enabled;
224
323
  }
225
324
  }
226
325
  updateFooter(ctx);
227
326
  });
228
327
 
229
328
  pi.on("session_tree", async (_event, ctx) => {
230
- mode = "guard";
329
+ mode = DEFAULT_MODE;
330
+ classifierEnabled = false;
231
331
  for (const entry of ctx.sessionManager.getBranch()) {
232
332
  const e = entry as { type?: string; customType?: string; data?: unknown };
233
333
  if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
234
- if (e.data.mode === "yolo" || e.data.mode === "guard") mode = e.data.mode;
334
+ const restored = normalizeMode(e.data.mode);
335
+ if (restored) mode = restored;
336
+ }
337
+ if (e.type === "custom" && e.customType === CLASSIFIER_ENTRY && isRecord(e.data)) {
338
+ if (typeof e.data.enabled === "boolean") classifierEnabled = e.data.enabled;
235
339
  }
236
340
  }
237
341
  updateFooter(ctx);
@@ -244,32 +348,69 @@ export default function yolo(pi: ExtensionAPI) {
244
348
  // ── Command ──────────────────────────────────────────────────────────
245
349
 
246
350
  pi.registerCommand("yolo", {
247
- description: "Toggle auto-approve: /yolo [on|off|status|trail|undo [n]]",
351
+ description: "Safety gradient: /yolo [yolo|auto|approve|strict|status|trail|undo [n]|classifier on|off]",
248
352
  handler: async (args, ctx) => {
249
353
  const [route, countRaw] = (args ?? "").trim().toLowerCase().split(/\s+/);
250
354
  switch (route || "toggle") {
251
355
  case "toggle":
252
- setMode(ctx, mode === "yolo" ? "guard" : "yolo");
356
+ // The bare command still flips between the two ends people use.
357
+ setMode(ctx, mode === "yolo" ? DEFAULT_MODE : "yolo");
253
358
  return;
254
359
  case "on":
255
360
  setMode(ctx, "yolo");
256
361
  return;
257
362
  case "off":
258
- setMode(ctx, "guard");
363
+ case "guard":
364
+ setMode(ctx, DEFAULT_MODE);
365
+ return;
366
+ case "yolo":
367
+ case "auto":
368
+ case "approve":
369
+ case "strict":
370
+ setMode(ctx, route as Mode);
259
371
  return;
260
372
  case "status": {
261
373
  if (!ctx.hasUI) return;
262
374
  const entries = readManifest(dir);
263
375
  ctx.ui.notify(
264
376
  [
265
- `Mode: ${mode === "yolo" ? "⚡ YOLO (gate off)" : "🛡 guard"}`,
377
+ `Mode: ${MODE_LABELS[mode]}`,
378
+ `Other modes: ${MODES.filter((m) => m !== mode).join(" · ")} (/yolo <mode>)`,
266
379
  `User rules: ${userRules.length} (.pi/yolo.json)`,
380
+ `AI classifier: ${classifierEnabled ? "on" : "off"} (/yolo classifier on)`,
267
381
  `Trail: ${entries.length} entries — /yolo trail to view, /yolo undo [n] to restore`,
268
382
  ].join("\n"),
269
383
  "info",
270
384
  );
271
385
  return;
272
386
  }
387
+ case "classifier": {
388
+ const value = (countRaw ?? "").toLowerCase();
389
+ if (value !== "on" && value !== "off") {
390
+ if (ctx.hasUI) {
391
+ ctx.ui.notify(
392
+ [
393
+ `AI classifier: ${classifierEnabled ? "on" : "off"}.`,
394
+ "When on, commands no rule matched are read by a model, which can escalate them to a confirmation — never to an approval.",
395
+ "Usage: /yolo classifier <on|off>",
396
+ ].join("\n"),
397
+ "info",
398
+ );
399
+ }
400
+ return;
401
+ }
402
+ classifierEnabled = value === "on";
403
+ pi.appendEntry(CLASSIFIER_ENTRY, { enabled: classifierEnabled });
404
+ if (ctx.hasUI) {
405
+ ctx.ui.notify(
406
+ classifierEnabled
407
+ ? "AI classifier ON — unmatched commands get a second opinion before they run."
408
+ : "AI classifier OFF.",
409
+ "info",
410
+ );
411
+ }
412
+ return;
413
+ }
273
414
  case "trail": {
274
415
  if (!ctx.hasUI) return;
275
416
  ctx.ui.notify(formatTrail(readManifest(dir), 20), "info");
@@ -305,7 +446,7 @@ export default function yolo(pi: ExtensionAPI) {
305
446
  return;
306
447
  }
307
448
  default:
308
- if (ctx.hasUI) ctx.ui.notify("Usage: /yolo [on|off|status|trail|undo [n]]", "warning");
449
+ if (ctx.hasUI) ctx.ui.notify("Usage: /yolo [on|off|status|trail|undo [n]|classifier on|off]", "warning");
309
450
  }
310
451
  },
311
452
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/yolo",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "One toggle to auto-approve everything, with an undo trail: three-tier bash guard, file pre-images, fail-closed everywhere",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Layer 3 of the guard (zhushanwen's model): a model reads the commands the
3
+ * regex tiers had no opinion about. Regexes only know the destructive shapes
4
+ * someone thought to write down; `find . -name '*.ts' -exec sed -i ... {} +`
5
+ * is not one of them.
6
+ *
7
+ * Two rules keep this honest. The classifier can only ESCALATE — it may turn
8
+ * an allow into an ask, never an ask or a block into an allow, so a model
9
+ * that is talked into approving something cannot open the gate. And when it
10
+ * is slow, broken, or unreadable, the deterministic verdict stands: safety
11
+ * here comes from the rules, and the model is an extra pair of eyes.
12
+ */
13
+
14
+ export type Risk = "safe" | "risky";
15
+
16
+ export interface Classification {
17
+ risk: Risk;
18
+ reason: string;
19
+ /** True when the model was not consulted or could not be read. */
20
+ fallback: boolean;
21
+ }
22
+
23
+ export const CLASSIFY_SYSTEM_PROMPT = [
24
+ "You classify shell commands for a coding agent's safety guard.",
25
+ "A command is RISKY if running it could destroy work or state that is hard to get back:",
26
+ "deleting or overwriting files, rewriting git history, force-pushing, resetting or cleaning a",
27
+ "working tree, mass in-place edits, dropping databases, killing processes, changing permissions",
28
+ "or ownership broadly, downloading and executing code, or writing outside the project.",
29
+ "A command is SAFE if it only reads, inspects, queries, builds, or tests.",
30
+ "Judge what the command actually does, not what it is named. When you are unsure, answer risky.",
31
+ 'Answer with ONE line of JSON and nothing else: {"risk":"safe","reason":"…"} or',
32
+ '{"risk":"risky","reason":"…"}. Keep the reason under 140 characters.',
33
+ "Do not explain. Do not use markdown. Your entire reply must start with { and end with }.",
34
+ ];
35
+
36
+ /** Commands so common that asking a model about them is pure latency. */
37
+ const OBVIOUSLY_SAFE =
38
+ /^(git (status|log|diff|show|branch|remote|fetch)|ls|pwd|cat|head|tail|wc|grep|rg|find|which|echo|node -v|npm (ls|view|test)|bun (test|--version)|python -V|cd|whoami|date|env)\b/i;
39
+
40
+ /** Flags that turn a read-only-looking command into an executor. */
41
+ const EXECUTOR_FLAGS = /\s-(exec|execdir|delete|ok|okdir)\b/i;
42
+
43
+ /** Should the classifier be consulted for this command at all? */
44
+ export function needsClassification(command: string): boolean {
45
+ const trimmed = command.trim();
46
+ if (!trimmed) return false;
47
+ // A pipeline or chain hides its real work; always look at those.
48
+ if (/[|;&]|&&|\$\(|`/.test(trimmed)) return true;
49
+ // `find` is on the safe list, but `find … -exec` is a way to run anything.
50
+ if (EXECUTOR_FLAGS.test(trimmed)) return true;
51
+ return !OBVIOUSLY_SAFE.test(trimmed);
52
+ }
53
+
54
+ export function buildClassifyPrompt(command: string, cwd: string): string {
55
+ return [
56
+ `Working directory: ${cwd}`,
57
+ "Command:",
58
+ "```sh",
59
+ command.trim(),
60
+ "```",
61
+ "Classify it.",
62
+ ].join("\n");
63
+ }
64
+
65
+ const MAX_REASON = 140;
66
+
67
+ const RISKY_WORDS = new Set(["risky", "unsafe", "dangerous", "destructive", "irreversible"]);
68
+ const SAFE_WORDS = new Set(["safe", "harmless", "benign"]);
69
+
70
+ /** First meaningful sentence of a prose answer, trimmed to reason length. */
71
+ function summarize(text: string): string {
72
+ const flat = text
73
+ .replace(/```[\s\S]*?```/g, " ")
74
+ .replace(/[*_`#]/g, "")
75
+ .replace(/\s+/g, " ")
76
+ .trim();
77
+ return flat.slice(0, MAX_REASON);
78
+ }
79
+
80
+ /**
81
+ * Read the classifier's answer. Anything unreadable falls back to safe with
82
+ * `fallback: true` — the caller then keeps the deterministic verdict rather
83
+ * than inventing an escalation from noise.
84
+ */
85
+ export function parseClassification(text: string): Classification {
86
+ const trimmed = (text ?? "").trim();
87
+ if (!trimmed) return { risk: "safe", reason: "the classifier returned nothing", fallback: true };
88
+
89
+ const objects = [...trimmed.matchAll(/\{[^{}]*\}/g)].map((m) => m[0]).reverse();
90
+ for (const raw of objects) {
91
+ let parsed: Record<string, unknown>;
92
+ try {
93
+ parsed = JSON.parse(raw) as Record<string, unknown>;
94
+ } catch {
95
+ continue;
96
+ }
97
+ const reason = typeof parsed.reason === "string" ? parsed.reason.trim().slice(0, MAX_REASON) : "";
98
+ for (const key of ["risk", "verdict", "classification", "result"]) {
99
+ const value = String(parsed[key] ?? "").toLowerCase();
100
+ if (value === "risky" || value === "unsafe" || value === "dangerous" || value === "destructive") {
101
+ return { risk: "risky", reason: reason || "the classifier flagged it", fallback: false };
102
+ }
103
+ if (value === "safe" || value === "harmless") {
104
+ return { risk: "safe", reason: reason || "the classifier saw no risk", fallback: false };
105
+ }
106
+ }
107
+ if (typeof parsed.risky === "boolean") {
108
+ return {
109
+ risk: parsed.risky ? "risky" : "safe",
110
+ reason: reason || (parsed.risky ? "the classifier flagged it" : "the classifier saw no risk"),
111
+ fallback: false,
112
+ };
113
+ }
114
+ }
115
+
116
+ // Models routinely ignore the format and write an explanation instead. Read
117
+ // the verdict out of the prose rather than throwing the answer away: a
118
+ // labelled verdict first, then a standalone RISKY/SAFE token (the last one
119
+ // wins — the conclusion comes at the end), then a single unambiguous signal.
120
+ const labelled = [...trimmed.matchAll(/\b(?:classification|verdict|risk|answer)\b\s*[:=]?\s*\**\s*(\w+)/gi)];
121
+ for (const match of labelled.reverse()) {
122
+ const word = match[1]!.toLowerCase();
123
+ if (RISKY_WORDS.has(word)) return { risk: "risky", reason: summarize(trimmed), fallback: false };
124
+ if (SAFE_WORDS.has(word)) return { risk: "safe", reason: summarize(trimmed), fallback: false };
125
+ }
126
+
127
+ const tokens = [...trimmed.matchAll(/\b(RISKY|SAFE|UNSAFE|DANGEROUS|DESTRUCTIVE)\b/g)];
128
+ const lastToken = tokens.length > 0 ? tokens[tokens.length - 1]![1]! : null;
129
+ if (lastToken) {
130
+ return {
131
+ risk: lastToken === "SAFE" ? "safe" : "risky",
132
+ reason: summarize(trimmed),
133
+ fallback: false,
134
+ };
135
+ }
136
+
137
+ const risky = /\b(risky|unsafe|dangerous|destructive|irreversible)\b/i.test(trimmed);
138
+ const safe = /\b(safe|harmless|read-only|benign)\b/i.test(trimmed);
139
+ if (risky && !safe) return { risk: "risky", reason: summarize(trimmed), fallback: false };
140
+ if (safe && !risky) return { risk: "safe", reason: summarize(trimmed), fallback: false };
141
+ return { risk: "safe", reason: `unreadable classifier answer: ${trimmed.slice(0, 80)}`, fallback: true };
142
+ }
143
+
144
+ export type GuardAction = "allow" | "ask" | "block";
145
+
146
+ /**
147
+ * Fold a classification into the deterministic verdict. Escalation only:
148
+ * allow can become ask, nothing can become allow.
149
+ */
150
+ export function applyClassification(
151
+ action: GuardAction,
152
+ classification: Classification,
153
+ ): { action: GuardAction; rule: string | null } {
154
+ if (action !== "allow") return { action, rule: null };
155
+ if (classification.fallback || classification.risk === "safe") return { action, rule: null };
156
+ return { action: "ask", rule: `classifier:${classification.reason}` };
157
+ }
package/src/modes.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The four-mode gradient (zhushanwen). One toggle was too blunt: "guard"
3
+ * asked about every `rm -rf build`, and "yolo" stood the whole gate down,
4
+ * including the catastrophic floor the rules call non-negotiable. These are
5
+ * the four positions people actually want between those extremes.
6
+ *
7
+ * Two invariants hold in every mode, which is what makes the gradient safe
8
+ * to move along: the catastrophic tier always blocks, and secret material
9
+ * always asks.
10
+ */
11
+ import type { GuardAction } from "./classify.ts";
12
+ import type { Mode, RuleHit } from "./types.ts";
13
+
14
+ export const MODES: readonly Mode[] = ["yolo", "auto", "approve", "strict"];
15
+
16
+ export const MODE_LABELS: Record<Mode, string> = {
17
+ yolo: "⚡ YOLO — only catastrophic commands and secrets stop you",
18
+ auto: "⚙ auto — built-in destructive commands run; your own ask-rules still ask",
19
+ approve: "🛡 approve — catastrophic blocks, destructive asks (default)",
20
+ strict: "🔒 strict — anything not plainly read-only asks first",
21
+ };
22
+
23
+ export const MODE_BADGES: Record<Mode, string | undefined> = {
24
+ yolo: "⚡ YOLO",
25
+ auto: "⚙ auto",
26
+ // The default needs no badge; a permanent one just becomes furniture.
27
+ approve: undefined,
28
+ strict: "🔒 strict",
29
+ };
30
+
31
+ export function isMode(value: unknown): value is Mode {
32
+ return typeof value === "string" && (MODES as readonly string[]).includes(value);
33
+ }
34
+
35
+ /** Pre-v0.4 sessions stored "guard"; it is what "approve" is now called. */
36
+ export function normalizeMode(value: unknown): Mode | null {
37
+ if (value === "guard") return "approve";
38
+ return isMode(value) ? value : null;
39
+ }
40
+
41
+ export interface ResolveInput {
42
+ mode: Mode;
43
+ verdict: RuleHit;
44
+ /** The command is on the read-only list (see classify.ts). */
45
+ obviouslySafe: boolean;
46
+ }
47
+
48
+ /**
49
+ * The effective action for a command, given the deterministic verdict and the
50
+ * current mode. Modes may relax the destructive tier and tighten the allow
51
+ * tier; they can never touch the two invariants.
52
+ */
53
+ export function resolveAction({ mode, verdict, obviouslySafe }: ResolveInput): GuardAction {
54
+ // Invariant 1: catastrophic patterns block everywhere, yolo included. The
55
+ // rules call this floor non-negotiable, so a mode must not be a way under it.
56
+ if (verdict.action === "block") return "block";
57
+ // Invariant 2: credentials are never auto-approved (cc-safety-net pillar 2).
58
+ if (verdict.rule.startsWith("secret:")) return "ask";
59
+
60
+ switch (mode) {
61
+ case "yolo":
62
+ return "allow";
63
+ case "auto":
64
+ // A rule the user wrote by hand is an instruction, not a default.
65
+ return verdict.action === "ask" && verdict.rule.startsWith("user:") ? "ask" : "allow";
66
+ case "approve":
67
+ return verdict.action;
68
+ case "strict":
69
+ return verdict.action === "allow" && obviouslySafe ? "allow" : "ask";
70
+ }
71
+ }
72
+
73
+ /** Explains a mode-driven decision in the confirmation dialog. */
74
+ export function askTitle(mode: Mode, verdict: RuleHit): string {
75
+ if (verdict.rule.startsWith("secret:")) return "Command touches secret material";
76
+ if (mode === "strict" && verdict.action === "allow") return "Strict mode — unrecognised command";
77
+ return "Destructive command";
78
+ }
package/src/types.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  * No imports from pi packages: src/ typechecks and runs standalone.
4
4
  */
5
5
 
6
- export type Mode = "guard" | "yolo";
6
+ /** Four positions on the safety gradient; see src/modes.ts (v0.4). */
7
+ export type Mode = "yolo" | "auto" | "approve" | "strict";
7
8
 
8
9
  export type RuleAction = "allow" | "ask" | "block";
9
10