@pify/yolo 0.1.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 +45 -0
- package/extensions/yolo.ts +199 -13
- package/package.json +1 -1
- package/src/classify.ts +157 -0
- package/src/rules.ts +68 -0
- package/src/trail.ts +5 -1
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -18,6 +18,25 @@ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify instal
|
|
|
18
18
|
|
|
19
19
|
Fail-closed everywhere: rule-evaluation errors block; ASK without a UI (headless/CI) denies.
|
|
20
20
|
|
|
21
|
+
## Secret files (v0.2)
|
|
22
|
+
|
|
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:
|
|
24
|
+
|
|
25
|
+
`.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
|
+
|
|
27
|
+
A user rule opts a project out: `{ "pattern": "*/.env", "action": "allow" }`.
|
|
28
|
+
|
|
29
|
+
## Checkpoints (v0.2)
|
|
30
|
+
|
|
31
|
+
Before every risky bash command in a git repo, the trail records a `git stash create` checkpoint — a dangling commit holding the working tree exactly as it was, kept alive under `refs/pify/yolo/`. It writes nothing to your tree, index, or stash list. `/yolo trail` prints the recovery line next to the command:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
#7 2026-09-06 11:00:12 bash git reset --hard @a1b2c3d4
|
|
35
|
+
↩ git stash apply 9f8e7d6c5b4a
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
That covers what `/yolo undo` can't: damage done by a command rather than by an `edit`/`write`.
|
|
39
|
+
|
|
21
40
|
## The undo trail
|
|
22
41
|
|
|
23
42
|
Always on, in both modes:
|
|
@@ -26,6 +45,31 @@ Always on, in both modes:
|
|
|
26
45
|
- Risky bash commands are logged with cwd, timestamp, and git HEAD.
|
|
27
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.
|
|
28
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
|
+
|
|
29
73
|
## Custom rules
|
|
30
74
|
|
|
31
75
|
`.pi/yolo.json` — wildcard patterns, last-match-wins, may retune ASK/ALLOW but never the BLOCK floor:
|
|
@@ -46,6 +90,7 @@ Always on, in both modes:
|
|
|
46
90
|
/yolo status # mode, rule count, trail size
|
|
47
91
|
/yolo trail # recent trail entries
|
|
48
92
|
/yolo undo 3 # restore the newest 3 file pre-images
|
|
93
|
+
/yolo classifier on # let a model flag unfamiliar commands (v0.3)
|
|
49
94
|
```
|
|
50
95
|
|
|
51
96
|
## License
|
package/extensions/yolo.ts
CHANGED
|
@@ -13,12 +13,22 @@
|
|
|
13
13
|
* headless ASK becomes deny. User rules in .pi/yolo.json (wildcard,
|
|
14
14
|
* last-match-wins) can retune ASK/ALLOW but never the catastrophic floor.
|
|
15
15
|
*
|
|
16
|
+
* v0.2 adds two things yolo mode deliberately does not stand down for:
|
|
17
|
+
* secret material (.env, ssh keys, cloud/registry credentials) asks before
|
|
18
|
+
* any read/edit/write or naming command, and every risky bash command gets a
|
|
19
|
+
* `git stash create` checkpoint recorded on the trail so command damage —
|
|
20
|
+
* not just file edits — has a way back.
|
|
21
|
+
*
|
|
16
22
|
* Design synthesis: three-tier rules (pi-yolo-seatbelt), fail-closed +
|
|
17
23
|
* reject-with-reason + wildcard rules (@zhushanwen/pi-permission),
|
|
18
24
|
* /yolo session toggle (valdo766hi).
|
|
19
25
|
*/
|
|
20
26
|
import {
|
|
27
|
+
DefaultResourceLoader,
|
|
28
|
+
SessionManager,
|
|
29
|
+
createAgentSession,
|
|
21
30
|
getAgentDir,
|
|
31
|
+
type AgentSession,
|
|
22
32
|
type ExtensionAPI,
|
|
23
33
|
type ExtensionContext,
|
|
24
34
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -26,16 +36,29 @@ import { execFileSync } from "node:child_process";
|
|
|
26
36
|
import { readFileSync } from "node:fs";
|
|
27
37
|
import { join } from "node:path";
|
|
28
38
|
|
|
29
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
CLASSIFY_SYSTEM_PROMPT,
|
|
41
|
+
applyClassification,
|
|
42
|
+
buildClassifyPrompt,
|
|
43
|
+
needsClassification,
|
|
44
|
+
parseClassification,
|
|
45
|
+
type Classification,
|
|
46
|
+
} from "../src/classify.ts";
|
|
47
|
+
import { evaluateCommand, evaluatePath, parseUserRules } from "../src/rules.ts";
|
|
30
48
|
import { formatTrail, readManifest, recordBash, recordPreImage, trailDir, undo } from "../src/trail.ts";
|
|
31
49
|
import { isRecord, type Mode, type UserRule } from "../src/types.ts";
|
|
32
50
|
|
|
33
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;
|
|
34
55
|
|
|
35
56
|
type UiContext = ExtensionContext;
|
|
36
57
|
|
|
37
58
|
export default function yolo(pi: ExtensionAPI) {
|
|
38
59
|
let mode: Mode = "guard";
|
|
60
|
+
/** Opt-in: layer 3 costs a model call on unfamiliar commands. */
|
|
61
|
+
let classifierEnabled = false;
|
|
39
62
|
let userRules: UserRule[] = [];
|
|
40
63
|
let dir = "";
|
|
41
64
|
|
|
@@ -71,6 +94,113 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
71
94
|
}
|
|
72
95
|
}
|
|
73
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Snapshot the working tree into a dangling commit before a risky command.
|
|
99
|
+
* `git stash create` writes nothing to the tree, the index, or the stash
|
|
100
|
+
* list — it just gives us a sha to come back to. A ref keeps it out of gc's
|
|
101
|
+
* reach; empty output means there was nothing to save.
|
|
102
|
+
*/
|
|
103
|
+
function gitCheckpoint(cwd: string, now: number): string | null {
|
|
104
|
+
try {
|
|
105
|
+
const sha = execFileSync("git", ["stash", "create"], {
|
|
106
|
+
cwd,
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
timeout: 5000,
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
}).trim();
|
|
111
|
+
if (!/^[0-9a-f]{7,40}$/.test(sha)) return null;
|
|
112
|
+
try {
|
|
113
|
+
execFileSync("git", ["update-ref", `refs/pify/yolo/${now}`, sha], {
|
|
114
|
+
cwd,
|
|
115
|
+
timeout: 5000,
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
// unreachable-but-recent commits still survive the default gc window
|
|
120
|
+
}
|
|
121
|
+
return sha;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Confirmation gate for a file path (secret material). */
|
|
128
|
+
async function guardPath(
|
|
129
|
+
ctx: UiContext,
|
|
130
|
+
path: string,
|
|
131
|
+
rule: string,
|
|
132
|
+
action: "ask" | "block",
|
|
133
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
134
|
+
if (action === "block") {
|
|
135
|
+
return { block: true, reason: `yolo guard blocked access to ${path} (${rule}).` };
|
|
136
|
+
}
|
|
137
|
+
if (!ctx.hasUI) {
|
|
138
|
+
return {
|
|
139
|
+
block: true,
|
|
140
|
+
reason: `yolo guard: ${path} holds secret material (${rule}) and there is no UI to confirm (fail-closed deny).`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const approved = await ctx.ui.confirm(
|
|
144
|
+
"Secret file",
|
|
145
|
+
`${path}\n\nRule: ${rule}. Allow this access?`,
|
|
146
|
+
);
|
|
147
|
+
if (approved) return undefined;
|
|
148
|
+
return {
|
|
149
|
+
block: true,
|
|
150
|
+
reason: `The user declined access to ${path} (${rule}). Continue without its contents; ask for the value you need instead.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
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
|
+
|
|
74
204
|
function loadUserRules(cwd: string): void {
|
|
75
205
|
try {
|
|
76
206
|
userRules = parseUserRules(JSON.parse(readFileSync(join(cwd, ".pi", "yolo.json"), "utf8")));
|
|
@@ -82,11 +212,18 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
82
212
|
// ── The gate + the trail ─────────────────────────────────────────────
|
|
83
213
|
|
|
84
214
|
pi.on("tool_call", async (event, ctx) => {
|
|
85
|
-
//
|
|
86
|
-
|
|
215
|
+
// Secret material is checked in BOTH modes: yolo trades safety for speed,
|
|
216
|
+
// not for handing credentials to a model.
|
|
217
|
+
if (event.toolName === "read" || event.toolName === "edit" || event.toolName === "write") {
|
|
87
218
|
const path = (event as { input?: { path?: unknown } }).input?.path;
|
|
88
|
-
if (typeof path === "string"
|
|
89
|
-
|
|
219
|
+
if (typeof path === "string") {
|
|
220
|
+
const verdict = evaluatePath(path, userRules);
|
|
221
|
+
if (verdict.action !== "allow") {
|
|
222
|
+
const denial = await guardPath(ctx, path, verdict.rule, verdict.action);
|
|
223
|
+
if (denial) return denial;
|
|
224
|
+
}
|
|
225
|
+
// Trail: pre-image every file mutation, in both modes.
|
|
226
|
+
if (event.toolName !== "read" && dir) recordPreImage(dir, path, Date.now());
|
|
90
227
|
}
|
|
91
228
|
return undefined;
|
|
92
229
|
}
|
|
@@ -97,14 +234,27 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
97
234
|
return { block: true, reason: "yolo guard: bash call without a command (fail-closed)." };
|
|
98
235
|
}
|
|
99
236
|
|
|
100
|
-
|
|
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
|
+
|
|
248
|
+
const touchesSecret = verdict.rule.startsWith("secret:");
|
|
101
249
|
|
|
102
|
-
// Log risky commands
|
|
103
|
-
if (dir &&
|
|
104
|
-
|
|
250
|
+
// Log risky commands, with a checkpoint of the tree as it was.
|
|
251
|
+
if (dir && verdict.action !== "allow") {
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
recordBash(dir, command, ctx.cwd, gitHead(ctx.cwd), now, gitCheckpoint(ctx.cwd, now));
|
|
105
254
|
}
|
|
106
255
|
|
|
107
|
-
|
|
256
|
+
// The gate stands down in yolo mode — except for secrets.
|
|
257
|
+
if (mode === "yolo" && !touchesSecret) return undefined;
|
|
108
258
|
|
|
109
259
|
if (verdict.action === "allow") return undefined;
|
|
110
260
|
|
|
@@ -123,7 +273,7 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
123
273
|
};
|
|
124
274
|
}
|
|
125
275
|
const approved = await ctx.ui.confirm(
|
|
126
|
-
"Destructive command",
|
|
276
|
+
touchesSecret ? "Command touches secret material" : "Destructive command",
|
|
127
277
|
`${command}\n\nRule: ${verdict.rule}. Run it?`,
|
|
128
278
|
);
|
|
129
279
|
if (approved) return undefined;
|
|
@@ -144,22 +294,30 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
144
294
|
dir = trailDir(getAgentDir(), ctx.cwd);
|
|
145
295
|
loadUserRules(ctx.cwd);
|
|
146
296
|
mode = "guard";
|
|
297
|
+
classifierEnabled = false;
|
|
147
298
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
148
299
|
const e = entry as { type?: string; customType?: string; data?: unknown };
|
|
149
300
|
if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
|
|
150
301
|
if (e.data.mode === "yolo" || e.data.mode === "guard") mode = e.data.mode;
|
|
151
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
|
+
}
|
|
152
306
|
}
|
|
153
307
|
updateFooter(ctx);
|
|
154
308
|
});
|
|
155
309
|
|
|
156
310
|
pi.on("session_tree", async (_event, ctx) => {
|
|
157
311
|
mode = "guard";
|
|
312
|
+
classifierEnabled = false;
|
|
158
313
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
159
314
|
const e = entry as { type?: string; customType?: string; data?: unknown };
|
|
160
315
|
if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
|
|
161
316
|
if (e.data.mode === "yolo" || e.data.mode === "guard") mode = e.data.mode;
|
|
162
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
|
+
}
|
|
163
321
|
}
|
|
164
322
|
updateFooter(ctx);
|
|
165
323
|
});
|
|
@@ -171,7 +329,7 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
171
329
|
// ── Command ──────────────────────────────────────────────────────────
|
|
172
330
|
|
|
173
331
|
pi.registerCommand("yolo", {
|
|
174
|
-
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]",
|
|
175
333
|
handler: async (args, ctx) => {
|
|
176
334
|
const [route, countRaw] = (args ?? "").trim().toLowerCase().split(/\s+/);
|
|
177
335
|
switch (route || "toggle") {
|
|
@@ -191,12 +349,40 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
191
349
|
[
|
|
192
350
|
`Mode: ${mode === "yolo" ? "⚡ YOLO (gate off)" : "🛡 guard"}`,
|
|
193
351
|
`User rules: ${userRules.length} (.pi/yolo.json)`,
|
|
352
|
+
`AI classifier: ${classifierEnabled ? "on" : "off"} (/yolo classifier on)`,
|
|
194
353
|
`Trail: ${entries.length} entries — /yolo trail to view, /yolo undo [n] to restore`,
|
|
195
354
|
].join("\n"),
|
|
196
355
|
"info",
|
|
197
356
|
);
|
|
198
357
|
return;
|
|
199
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
|
+
}
|
|
200
386
|
case "trail": {
|
|
201
387
|
if (!ctx.hasUI) return;
|
|
202
388
|
ctx.ui.notify(formatTrail(readManifest(dir), 20), "info");
|
|
@@ -232,7 +418,7 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
232
418
|
return;
|
|
233
419
|
}
|
|
234
420
|
default:
|
|
235
|
-
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");
|
|
236
422
|
}
|
|
237
423
|
},
|
|
238
424
|
});
|
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/rules.ts
CHANGED
|
@@ -42,6 +42,70 @@ const DESTRUCTIVE: BuiltinRule[] = [
|
|
|
42
42
|
{ name: "history-rewrite", action: "ask", re: /\bgit\s+(rebase|filter-branch|filter-repo)\b/ },
|
|
43
43
|
];
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Secret material (cc-safety-net's second pillar). Reading these is how a
|
|
47
|
+
* credential leaves the machine, so the check is on the path, not the verb —
|
|
48
|
+
* and unlike the destructive tier it still asks in yolo mode.
|
|
49
|
+
*/
|
|
50
|
+
const SECRET_PATHS: BuiltinRule[] = [
|
|
51
|
+
{ name: "env-file", action: "ask", re: /(^|\/)\.env(\.[\w-]+)*$/ },
|
|
52
|
+
{ name: "ssh-key", action: "ask", re: /(^|\/)(\.ssh\/.*|id_(rsa|dsa|ecdsa|ed25519)(_\w+)?)$/ },
|
|
53
|
+
{ name: "aws-credentials", action: "ask", re: /(^|\/)\.aws\/(credentials|config)$/ },
|
|
54
|
+
{ name: "agent-auth", action: "ask", re: /(^|\/)(\.pi\/agent\/auth\.json|\.claude\/\.credentials\.json)$/ },
|
|
55
|
+
{ name: "registry-token", action: "ask", re: /(^|\/)(\.npmrc|\.pypirc|\.netrc|_netrc|\.git-credentials)$/ },
|
|
56
|
+
{ name: "gh-hosts", action: "ask", re: /(^|\/)\.config\/gh\/hosts\.ya?ml$/ },
|
|
57
|
+
{ name: "private-key", action: "ask", re: /\.(pem|key|p12|pfx|keystore|jks)$/ },
|
|
58
|
+
{ name: "secrets-file", action: "ask", re: /(^|\/)(secrets?|credentials)\.(json|ya?ml|toml)$/ },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Example/sample templates carry no secrets — never worth a prompt. */
|
|
62
|
+
const SECRET_EXEMPT = /(^|\/)[\w.-]*\.(example|sample|template|dist)$|\.pub$/;
|
|
63
|
+
|
|
64
|
+
/** Name of the secret class this path belongs to, or null. */
|
|
65
|
+
export function secretPathKind(path: string): string | null {
|
|
66
|
+
const normalized = path.trim().replace(/\\/g, "/").replace(/^["']|["']$/g, "").toLowerCase();
|
|
67
|
+
if (!normalized || SECRET_EXEMPT.test(normalized)) return null;
|
|
68
|
+
for (const rule of SECRET_PATHS) {
|
|
69
|
+
if (rule.re.test(normalized)) return rule.name;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Secret paths named anywhere in a shell command (cat, cp, curl -T, …). */
|
|
75
|
+
export function secretPathsIn(command: string): string[] {
|
|
76
|
+
const kinds = new Set<string>();
|
|
77
|
+
for (const token of command.split(/[\s;|&()<>]+/)) {
|
|
78
|
+
const kind = secretPathKind(token);
|
|
79
|
+
if (kind) kinds.add(kind);
|
|
80
|
+
}
|
|
81
|
+
return [...kinds];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Verdict for a file path a tool is about to touch. Default is allow; secret
|
|
86
|
+
* material asks. User rules (matched against the path) get the last word, so
|
|
87
|
+
* a wildcard rule ending in `/.env` with action "allow" opts a project out.
|
|
88
|
+
*/
|
|
89
|
+
export function evaluatePath(path: string, userRules: UserRule[] = []): RuleHit {
|
|
90
|
+
try {
|
|
91
|
+
const kind = secretPathKind(path);
|
|
92
|
+
let verdict: RuleHit = kind ? { action: "ask", rule: `secret:${kind}` } : { action: "allow", rule: "default" };
|
|
93
|
+
const normalized = path.replace(/\\/g, "/");
|
|
94
|
+
for (const rule of userRules) {
|
|
95
|
+
try {
|
|
96
|
+
if (wildcardToRegex(rule.pattern).test(normalized)) {
|
|
97
|
+
verdict = { action: rule.action, rule: `user:${rule.pattern}` };
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// invalid user pattern — ignore that rule
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return verdict;
|
|
104
|
+
} catch {
|
|
105
|
+
return { action: "block", rule: "fail-closed" };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
45
109
|
/** Convert a user wildcard pattern (`git push*`) to a regex. */
|
|
46
110
|
export function wildcardToRegex(pattern: string): RegExp {
|
|
47
111
|
const escaped = pattern
|
|
@@ -74,6 +138,10 @@ export function evaluateCommand(command: string, userRules: UserRule[] = []): Ru
|
|
|
74
138
|
break;
|
|
75
139
|
}
|
|
76
140
|
}
|
|
141
|
+
if (verdict.action === "allow") {
|
|
142
|
+
const secrets = secretPathsIn(normalized);
|
|
143
|
+
if (secrets.length > 0) verdict = { action: "ask", rule: `secret:${secrets.join(",")}` };
|
|
144
|
+
}
|
|
77
145
|
for (const rule of userRules) {
|
|
78
146
|
try {
|
|
79
147
|
if (wildcardToRegex(rule.pattern).test(normalized)) {
|
package/src/trail.ts
CHANGED
|
@@ -80,6 +80,7 @@ export function recordBash(
|
|
|
80
80
|
cwd: string,
|
|
81
81
|
gitHead: string | null,
|
|
82
82
|
now: number,
|
|
83
|
+
stashSha: string | null = null,
|
|
83
84
|
): void {
|
|
84
85
|
try {
|
|
85
86
|
append(dir, {
|
|
@@ -91,6 +92,7 @@ export function recordBash(
|
|
|
91
92
|
existed: false,
|
|
92
93
|
cwd,
|
|
93
94
|
...(gitHead ? { gitHead } : {}),
|
|
95
|
+
...(stashSha ? { stashSha } : {}),
|
|
94
96
|
});
|
|
95
97
|
} catch {
|
|
96
98
|
// trail must never break the tool call
|
|
@@ -142,7 +144,9 @@ export function formatTrail(entries: TrailEntry[], limit: number): string {
|
|
|
142
144
|
if (e.type === "file") {
|
|
143
145
|
return `#${e.seq} ${when} file ${e.target}${e.existed ? "" : " (new file)"}`;
|
|
144
146
|
}
|
|
145
|
-
|
|
147
|
+
const head = e.gitHead ? ` @${e.gitHead.slice(0, 8)}` : "";
|
|
148
|
+
const stash = e.stashSha ? `\n ↩ git stash apply ${e.stashSha}` : "";
|
|
149
|
+
return `#${e.seq} ${when} bash ${e.target}${head}${stash}`;
|
|
146
150
|
})
|
|
147
151
|
.join("\n");
|
|
148
152
|
}
|
package/src/types.ts
CHANGED