@pify/yolo 0.2.0 → 0.3.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
@@ -45,6 +45,31 @@ Always on, in both modes:
45
45
  - Risky bash commands are logged with cwd, timestamp, and git HEAD.
46
46
  - `/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
47
 
48
+ ## AI classifier (v0.3, opt-in)
49
+
50
+ `/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.
51
+
52
+ Two rules keep it honest:
53
+
54
+ - **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.
55
+ - **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.
56
+
57
+ Obviously-safe commands (`git status`, `ls`, `cat`, `bun test`, …) skip the call entirely, so the cost lands only on unfamiliar ones.
58
+
59
+ Measured over OpenRouter on six commands (three genuinely destructive, three read-only):
60
+
61
+ | Model | Correct | Unreadable → no opinion |
62
+ |---|---|---|
63
+ | GPT-5.6 luna | 6/6 | 0 |
64
+ | GPT-5.5 | 6/6 | 0 |
65
+ | Claude Opus 4.8 | 6/6 | 0 |
66
+ | GPT-5.6 terra / sol | 5/6 | 1 |
67
+ | Claude Opus 5 | 4/6 | 1 |
68
+ | Gemini 3.1 Pro | 2/6 | 4 |
69
+ | Qwen3 235B | 3/6 | 3 |
70
+
71
+ 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
+
48
73
  ## Custom rules
49
74
 
50
75
  `.pi/yolo.json` — wildcard patterns, last-match-wins, may retune ASK/ALLOW but never the BLOCK floor:
@@ -65,6 +90,7 @@ Always on, in both modes:
65
90
  /yolo status # mode, rule count, trail size
66
91
  /yolo trail # recent trail entries
67
92
  /yolo undo 3 # restore the newest 3 file pre-images
93
+ /yolo classifier on # let a model flag unfamiliar commands (v0.3)
68
94
  ```
69
95
 
70
96
  ## License
@@ -24,7 +24,11 @@
24
24
  * /yolo session toggle (valdo766hi).
25
25
  */
26
26
  import {
27
+ DefaultResourceLoader,
28
+ SessionManager,
29
+ createAgentSession,
27
30
  getAgentDir,
31
+ type AgentSession,
28
32
  type ExtensionAPI,
29
33
  type ExtensionContext,
30
34
  } from "@earendil-works/pi-coding-agent";
@@ -32,16 +36,29 @@ import { execFileSync } from "node:child_process";
32
36
  import { readFileSync } from "node:fs";
33
37
  import { join } from "node:path";
34
38
 
39
+ import {
40
+ CLASSIFY_SYSTEM_PROMPT,
41
+ applyClassification,
42
+ buildClassifyPrompt,
43
+ needsClassification,
44
+ parseClassification,
45
+ type Classification,
46
+ } from "../src/classify.ts";
35
47
  import { evaluateCommand, evaluatePath, parseUserRules } from "../src/rules.ts";
36
48
  import { formatTrail, readManifest, recordBash, recordPreImage, trailDir, undo } from "../src/trail.ts";
37
49
  import { isRecord, type Mode, type UserRule } from "../src/types.ts";
38
50
 
39
51
  const MODE_ENTRY = "yolo-mode";
52
+ const CLASSIFIER_ENTRY = "yolo-classifier";
53
+ /** In front of every bash call: a slow answer costs seconds, not minutes. */
54
+ const CLASSIFY_TIMEOUT_MS = 20_000;
40
55
 
41
56
  type UiContext = ExtensionContext;
42
57
 
43
58
  export default function yolo(pi: ExtensionAPI) {
44
59
  let mode: Mode = "guard";
60
+ /** Opt-in: layer 3 costs a model call on unfamiliar commands. */
61
+ let classifierEnabled = false;
45
62
  let userRules: UserRule[] = [];
46
63
  let dir = "";
47
64
 
@@ -134,6 +151,56 @@ export default function yolo(pi: ExtensionAPI) {
134
151
  };
135
152
  }
136
153
 
154
+ /**
155
+ * Ask a model whether an unmatched command is risky. Short timeout: this
156
+ * sits in front of every bash call, so a slow answer must cost the session
157
+ * seconds, not minutes — and a timeout is simply "no opinion".
158
+ */
159
+ async function classifyCommand(ctx: UiContext, command: string): Promise<Classification> {
160
+ let session: AgentSession | null = null;
161
+ try {
162
+ const created = await createAgentSession({
163
+ sessionManager: SessionManager.inMemory(ctx.cwd),
164
+ model: ctx.model as never,
165
+ tools: [],
166
+ resourceLoader: new DefaultResourceLoader({
167
+ cwd: ctx.cwd,
168
+ agentDir: getAgentDir(),
169
+ noExtensions: true,
170
+ noPromptTemplates: true,
171
+ noThemes: true,
172
+ // Replace the coding-agent prompt rather than append to it: with
173
+ // the default prompt in place, models answer a classification
174
+ // request with a markdown explanation instead of the JSON line.
175
+ systemPrompt: CLASSIFY_SYSTEM_PROMPT.join(" "),
176
+ } as never),
177
+ });
178
+ session = created.session;
179
+ await session.prompt(buildClassifyPrompt(command, ctx.cwd), {
180
+ signal: AbortSignal.timeout(CLASSIFY_TIMEOUT_MS),
181
+ } as never);
182
+ const messages = session.messages as Array<{ role?: string; content?: Array<{ type?: string; text?: string }> }>;
183
+ const last = [...messages].reverse().find((m) => m.role === "assistant");
184
+ const text = (last?.content ?? [])
185
+ .filter((part) => part.type === "text" && typeof part.text === "string")
186
+ .map((part) => part.text)
187
+ .join("");
188
+ return parseClassification(text);
189
+ } catch (err) {
190
+ return {
191
+ risk: "safe",
192
+ reason: `classifier unavailable (${err instanceof Error ? err.message : String(err)})`,
193
+ fallback: true,
194
+ };
195
+ } finally {
196
+ try {
197
+ session?.dispose();
198
+ } catch {
199
+ // best-effort
200
+ }
201
+ }
202
+ }
203
+
137
204
  function loadUserRules(cwd: string): void {
138
205
  try {
139
206
  userRules = parseUserRules(JSON.parse(readFileSync(join(cwd, ".pi", "yolo.json"), "utf8")));
@@ -167,7 +234,17 @@ export default function yolo(pi: ExtensionAPI) {
167
234
  return { block: true, reason: "yolo guard: bash call without a command (fail-closed)." };
168
235
  }
169
236
 
170
- const verdict = evaluateCommand(command, userRules);
237
+ let verdict = evaluateCommand(command, userRules);
238
+
239
+ // Layer 3: a model looks at what the regexes had no opinion about. It can
240
+ // only escalate allow → ask, so a talked-into-it classifier cannot open
241
+ // the gate, and a broken one leaves the deterministic verdict standing.
242
+ if (classifierEnabled && verdict.action === "allow" && needsClassification(command)) {
243
+ const classification = await classifyCommand(ctx, command);
244
+ const escalated = applyClassification(verdict.action, classification);
245
+ if (escalated.rule) verdict = { action: "ask", rule: escalated.rule };
246
+ }
247
+
171
248
  const touchesSecret = verdict.rule.startsWith("secret:");
172
249
 
173
250
  // Log risky commands, with a checkpoint of the tree as it was.
@@ -217,22 +294,30 @@ export default function yolo(pi: ExtensionAPI) {
217
294
  dir = trailDir(getAgentDir(), ctx.cwd);
218
295
  loadUserRules(ctx.cwd);
219
296
  mode = "guard";
297
+ classifierEnabled = false;
220
298
  for (const entry of ctx.sessionManager.getBranch()) {
221
299
  const e = entry as { type?: string; customType?: string; data?: unknown };
222
300
  if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
223
301
  if (e.data.mode === "yolo" || e.data.mode === "guard") mode = e.data.mode;
224
302
  }
303
+ if (e.type === "custom" && e.customType === CLASSIFIER_ENTRY && isRecord(e.data)) {
304
+ if (typeof e.data.enabled === "boolean") classifierEnabled = e.data.enabled;
305
+ }
225
306
  }
226
307
  updateFooter(ctx);
227
308
  });
228
309
 
229
310
  pi.on("session_tree", async (_event, ctx) => {
230
311
  mode = "guard";
312
+ classifierEnabled = false;
231
313
  for (const entry of ctx.sessionManager.getBranch()) {
232
314
  const e = entry as { type?: string; customType?: string; data?: unknown };
233
315
  if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
234
316
  if (e.data.mode === "yolo" || e.data.mode === "guard") mode = e.data.mode;
235
317
  }
318
+ if (e.type === "custom" && e.customType === CLASSIFIER_ENTRY && isRecord(e.data)) {
319
+ if (typeof e.data.enabled === "boolean") classifierEnabled = e.data.enabled;
320
+ }
236
321
  }
237
322
  updateFooter(ctx);
238
323
  });
@@ -244,7 +329,7 @@ export default function yolo(pi: ExtensionAPI) {
244
329
  // ── Command ──────────────────────────────────────────────────────────
245
330
 
246
331
  pi.registerCommand("yolo", {
247
- description: "Toggle auto-approve: /yolo [on|off|status|trail|undo [n]]",
332
+ description: "Toggle auto-approve: /yolo [on|off|status|trail|undo [n]|classifier on|off]",
248
333
  handler: async (args, ctx) => {
249
334
  const [route, countRaw] = (args ?? "").trim().toLowerCase().split(/\s+/);
250
335
  switch (route || "toggle") {
@@ -264,12 +349,40 @@ export default function yolo(pi: ExtensionAPI) {
264
349
  [
265
350
  `Mode: ${mode === "yolo" ? "⚡ YOLO (gate off)" : "🛡 guard"}`,
266
351
  `User rules: ${userRules.length} (.pi/yolo.json)`,
352
+ `AI classifier: ${classifierEnabled ? "on" : "off"} (/yolo classifier on)`,
267
353
  `Trail: ${entries.length} entries — /yolo trail to view, /yolo undo [n] to restore`,
268
354
  ].join("\n"),
269
355
  "info",
270
356
  );
271
357
  return;
272
358
  }
359
+ case "classifier": {
360
+ const value = (countRaw ?? "").toLowerCase();
361
+ if (value !== "on" && value !== "off") {
362
+ if (ctx.hasUI) {
363
+ ctx.ui.notify(
364
+ [
365
+ `AI classifier: ${classifierEnabled ? "on" : "off"}.`,
366
+ "When on, commands no rule matched are read by a model, which can escalate them to a confirmation — never to an approval.",
367
+ "Usage: /yolo classifier <on|off>",
368
+ ].join("\n"),
369
+ "info",
370
+ );
371
+ }
372
+ return;
373
+ }
374
+ classifierEnabled = value === "on";
375
+ pi.appendEntry(CLASSIFIER_ENTRY, { enabled: classifierEnabled });
376
+ if (ctx.hasUI) {
377
+ ctx.ui.notify(
378
+ classifierEnabled
379
+ ? "AI classifier ON — unmatched commands get a second opinion before they run."
380
+ : "AI classifier OFF.",
381
+ "info",
382
+ );
383
+ }
384
+ return;
385
+ }
273
386
  case "trail": {
274
387
  if (!ctx.hasUI) return;
275
388
  ctx.ui.notify(formatTrail(readManifest(dir), 20), "info");
@@ -305,7 +418,7 @@ export default function yolo(pi: ExtensionAPI) {
305
418
  return;
306
419
  }
307
420
  default:
308
- if (ctx.hasUI) ctx.ui.notify("Usage: /yolo [on|off|status|trail|undo [n]]", "warning");
421
+ if (ctx.hasUI) ctx.ui.notify("Usage: /yolo [on|off|status|trail|undo [n]|classifier on|off]", "warning");
309
422
  }
310
423
  },
311
424
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/yolo",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
+ }