@pify/yolo 0.3.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,7 +48,7 @@ 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.
@@ -66,7 +75,7 @@ Measured over OpenRouter on six commands (three genuinely destructive, three rea
66
75
  | GPT-5.6 terra / sol | 5/6 | 1 |
67
76
  | Claude Opus 5 | 4/6 | 1 |
68
77
  | Gemini 3.1 Pro | 2/6 | 4 |
69
- | Qwen3 235B | 3/6 | 3 |
78
+ | Qwen3 235B | 2/6 | 4 |
70
79
 
71
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.
72
81
 
@@ -86,8 +95,9 @@ Every miss fell back to *allow* — no run ever downgraded a command the rules h
86
95
  ## Commands
87
96
 
88
97
  ```
89
- /yolo # toggle guard yolo
90
- /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
91
101
  /yolo trail # recent trail entries
92
102
  /yolo undo 3 # restore the newest 3 file pre-images
93
103
  /yolo classifier on # let a model flag unfamiliar commands (v0.3)
@@ -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 —
@@ -46,8 +47,19 @@ import {
46
47
  } from "../src/classify.ts";
47
48
  import { evaluateCommand, evaluatePath, parseUserRules } from "../src/rules.ts";
48
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";
49
58
  import { isRecord, type Mode, type UserRule } from "../src/types.ts";
50
59
 
60
+ /** Today's guard, under its new name. */
61
+ const DEFAULT_MODE: Mode = "approve";
62
+
51
63
  const MODE_ENTRY = "yolo-mode";
52
64
  const CLASSIFIER_ENTRY = "yolo-classifier";
53
65
  /** In front of every bash call: a slow answer costs seconds, not minutes. */
@@ -56,7 +68,7 @@ const CLASSIFY_TIMEOUT_MS = 20_000;
56
68
  type UiContext = ExtensionContext;
57
69
 
58
70
  export default function yolo(pi: ExtensionAPI) {
59
- let mode: Mode = "guard";
71
+ let mode: Mode = DEFAULT_MODE;
60
72
  /** Opt-in: layer 3 costs a model call on unfamiliar commands. */
61
73
  let classifierEnabled = false;
62
74
  let userRules: UserRule[] = [];
@@ -64,7 +76,7 @@ export default function yolo(pi: ExtensionAPI) {
64
76
 
65
77
  function updateFooter(ctx: UiContext): void {
66
78
  if (!ctx.hasUI) return;
67
- ctx.ui.setStatus("yolo", mode === "yolo" ? "⚡ YOLO" : undefined);
79
+ ctx.ui.setStatus("yolo", MODE_BADGES[mode]);
68
80
  }
69
81
 
70
82
  function setMode(ctx: UiContext, next: Mode): void {
@@ -73,9 +85,10 @@ export default function yolo(pi: ExtensionAPI) {
73
85
  updateFooter(ctx);
74
86
  if (ctx.hasUI) {
75
87
  ctx.ui.notify(
76
- next === "yolo"
77
- ? "⚡ YOLO on — everything auto-approves. The undo trail keeps recording; /yolo to turn the guard back on."
78
- : "🛡 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"),
79
92
  next === "yolo" ? "warning" : "info",
80
93
  );
81
94
  }
@@ -245,7 +258,14 @@ export default function yolo(pi: ExtensionAPI) {
245
258
  if (escalated.rule) verdict = { action: "ask", rule: escalated.rule };
246
259
  }
247
260
 
248
- const touchesSecret = verdict.rule.startsWith("secret:");
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
+ });
249
269
 
250
270
  // Log risky commands, with a checkpoint of the tree as it was.
251
271
  if (dir && verdict.action !== "allow") {
@@ -253,12 +273,9 @@ export default function yolo(pi: ExtensionAPI) {
253
273
  recordBash(dir, command, ctx.cwd, gitHead(ctx.cwd), now, gitCheckpoint(ctx.cwd, now));
254
274
  }
255
275
 
256
- // The gate stands down in yolo mode — except for secrets.
257
- if (mode === "yolo" && !touchesSecret) return undefined;
258
-
259
- if (verdict.action === "allow") return undefined;
276
+ if (action === "allow") return undefined;
260
277
 
261
- if (verdict.action === "block") {
278
+ if (action === "block") {
262
279
  return {
263
280
  block: true,
264
281
  reason: `yolo guard blocked this command (${verdict.rule}) — catastrophic patterns are never auto-approved. Do not retry it; choose a safer approach.`,
@@ -273,7 +290,7 @@ export default function yolo(pi: ExtensionAPI) {
273
290
  };
274
291
  }
275
292
  const approved = await ctx.ui.confirm(
276
- touchesSecret ? "Command touches secret material" : "Destructive command",
293
+ askTitle(mode, verdict),
277
294
  `${command}\n\nRule: ${verdict.rule}. Run it?`,
278
295
  );
279
296
  if (approved) return undefined;
@@ -293,12 +310,13 @@ export default function yolo(pi: ExtensionAPI) {
293
310
  pi.on("session_start", async (_event, ctx) => {
294
311
  dir = trailDir(getAgentDir(), ctx.cwd);
295
312
  loadUserRules(ctx.cwd);
296
- mode = "guard";
313
+ mode = DEFAULT_MODE;
297
314
  classifierEnabled = false;
298
315
  for (const entry of ctx.sessionManager.getBranch()) {
299
316
  const e = entry as { type?: string; customType?: string; data?: unknown };
300
317
  if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
301
- 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;
302
320
  }
303
321
  if (e.type === "custom" && e.customType === CLASSIFIER_ENTRY && isRecord(e.data)) {
304
322
  if (typeof e.data.enabled === "boolean") classifierEnabled = e.data.enabled;
@@ -308,12 +326,13 @@ export default function yolo(pi: ExtensionAPI) {
308
326
  });
309
327
 
310
328
  pi.on("session_tree", async (_event, ctx) => {
311
- mode = "guard";
329
+ mode = DEFAULT_MODE;
312
330
  classifierEnabled = false;
313
331
  for (const entry of ctx.sessionManager.getBranch()) {
314
332
  const e = entry as { type?: string; customType?: string; data?: unknown };
315
333
  if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
316
- 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;
317
336
  }
318
337
  if (e.type === "custom" && e.customType === CLASSIFIER_ENTRY && isRecord(e.data)) {
319
338
  if (typeof e.data.enabled === "boolean") classifierEnabled = e.data.enabled;
@@ -329,25 +348,34 @@ export default function yolo(pi: ExtensionAPI) {
329
348
  // ── Command ──────────────────────────────────────────────────────────
330
349
 
331
350
  pi.registerCommand("yolo", {
332
- description: "Toggle auto-approve: /yolo [on|off|status|trail|undo [n]|classifier on|off]",
351
+ description: "Safety gradient: /yolo [yolo|auto|approve|strict|status|trail|undo [n]|classifier on|off]",
333
352
  handler: async (args, ctx) => {
334
353
  const [route, countRaw] = (args ?? "").trim().toLowerCase().split(/\s+/);
335
354
  switch (route || "toggle") {
336
355
  case "toggle":
337
- 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");
338
358
  return;
339
359
  case "on":
340
360
  setMode(ctx, "yolo");
341
361
  return;
342
362
  case "off":
343
- 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);
344
371
  return;
345
372
  case "status": {
346
373
  if (!ctx.hasUI) return;
347
374
  const entries = readManifest(dir);
348
375
  ctx.ui.notify(
349
376
  [
350
- `Mode: ${mode === "yolo" ? "⚡ YOLO (gate off)" : "🛡 guard"}`,
377
+ `Mode: ${MODE_LABELS[mode]}`,
378
+ `Other modes: ${MODES.filter((m) => m !== mode).join(" · ")} (/yolo <mode>)`,
351
379
  `User rules: ${userRules.length} (.pi/yolo.json)`,
352
380
  `AI classifier: ${classifierEnabled ? "on" : "off"} (/yolo classifier on)`,
353
381
  `Trail: ${entries.length} entries — /yolo trail to view, /yolo undo [n] to restore`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/yolo",
3
- "version": "0.3.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",
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