@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 +47 -11
- package/extensions/yolo.ts +169 -28
- package/package.json +1 -1
- package/src/classify.ts +157 -0
- package/src/modes.ts +78 -0
- package/src/types.ts +2 -1
package/README.md
CHANGED
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
# @pify/yolo
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
7
|
+
## Four modes, one command (v0.4)
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
|
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 #
|
|
65
|
-
/yolo
|
|
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
|
package/extensions/yolo.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pify/yolo — one toggle to auto-approve everything, with an undo trail.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* catastrophic patterns BLOCK
|
|
6
|
-
* ones ASK with the command shown (denial reasons flow back to
|
|
7
|
-
* everything else runs
|
|
8
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
package/src/classify.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|