@pify/yolo 0.1.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/LICENSE +21 -0
- package/README.md +53 -0
- package/extensions/yolo.ts +239 -0
- package/package.json +64 -0
- package/skills/yolo/SKILL.md +32 -0
- package/src/rules.ts +109 -0
- package/src/trail.ts +148 -0
- package/src/types.ts +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pifydev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @pify/yolo
|
|
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.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Two modes, one toggle
|
|
8
|
+
|
|
9
|
+
**🛡 guard** (default) — every bash call runs through a three-tier gate:
|
|
10
|
+
|
|
11
|
+
| Tier | Examples | Behavior |
|
|
12
|
+
|---|---|---|
|
|
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. |
|
|
14
|
+
| **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.
|
|
18
|
+
|
|
19
|
+
Fail-closed everywhere: rule-evaluation errors block; ASK without a UI (headless/CI) denies.
|
|
20
|
+
|
|
21
|
+
## The undo trail
|
|
22
|
+
|
|
23
|
+
Always on, in both modes:
|
|
24
|
+
|
|
25
|
+
- Every `edit`/`write` saves the file's **pre-image** first (per-project trail under the agent dir — survives restarts).
|
|
26
|
+
- Risky bash commands are logged with cwd, timestamp, and git HEAD.
|
|
27
|
+
- `/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
|
+
|
|
29
|
+
## Custom rules
|
|
30
|
+
|
|
31
|
+
`.pi/yolo.json` — wildcard patterns, last-match-wins, may retune ASK/ALLOW but never the BLOCK floor:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"rules": [
|
|
36
|
+
{ "pattern": "git push origin dev*", "action": "allow" },
|
|
37
|
+
{ "pattern": "npm run deploy*", "action": "ask" }
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
/yolo # toggle guard ↔ yolo
|
|
46
|
+
/yolo status # mode, rule count, trail size
|
|
47
|
+
/yolo trail # recent trail entries
|
|
48
|
+
/yolo undo 3 # restore the newest 3 file pre-images
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT © [Pify maintainers](https://github.com/pifydev)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pify/yolo — one toggle to auto-approve everything, with an undo trail.
|
|
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
|
|
9
|
+
* edit/write saves a pre-image first and risky bash commands are logged,
|
|
10
|
+
* so /yolo undo can walk file changes back even after a restart.
|
|
11
|
+
*
|
|
12
|
+
* Fail-closed everywhere (zhushanwen's rule): evaluation errors block,
|
|
13
|
+
* headless ASK becomes deny. User rules in .pi/yolo.json (wildcard,
|
|
14
|
+
* last-match-wins) can retune ASK/ALLOW but never the catastrophic floor.
|
|
15
|
+
*
|
|
16
|
+
* Design synthesis: three-tier rules (pi-yolo-seatbelt), fail-closed +
|
|
17
|
+
* reject-with-reason + wildcard rules (@zhushanwen/pi-permission),
|
|
18
|
+
* /yolo session toggle (valdo766hi).
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
getAgentDir,
|
|
22
|
+
type ExtensionAPI,
|
|
23
|
+
type ExtensionContext,
|
|
24
|
+
} from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
import { evaluateCommand, parseUserRules } from "../src/rules.ts";
|
|
30
|
+
import { formatTrail, readManifest, recordBash, recordPreImage, trailDir, undo } from "../src/trail.ts";
|
|
31
|
+
import { isRecord, type Mode, type UserRule } from "../src/types.ts";
|
|
32
|
+
|
|
33
|
+
const MODE_ENTRY = "yolo-mode";
|
|
34
|
+
|
|
35
|
+
type UiContext = ExtensionContext;
|
|
36
|
+
|
|
37
|
+
export default function yolo(pi: ExtensionAPI) {
|
|
38
|
+
let mode: Mode = "guard";
|
|
39
|
+
let userRules: UserRule[] = [];
|
|
40
|
+
let dir = "";
|
|
41
|
+
|
|
42
|
+
function updateFooter(ctx: UiContext): void {
|
|
43
|
+
if (!ctx.hasUI) return;
|
|
44
|
+
ctx.ui.setStatus("yolo", mode === "yolo" ? "⚡ YOLO" : undefined);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setMode(ctx: UiContext, next: Mode): void {
|
|
48
|
+
mode = next;
|
|
49
|
+
pi.appendEntry(MODE_ENTRY, { mode: next });
|
|
50
|
+
updateFooter(ctx);
|
|
51
|
+
if (ctx.hasUI) {
|
|
52
|
+
ctx.ui.notify(
|
|
53
|
+
next === "yolo"
|
|
54
|
+
? "⚡ YOLO on — everything auto-approves. The undo trail keeps recording; /yolo to turn the guard back on."
|
|
55
|
+
: "🛡 Guard on — catastrophic commands block, destructive ones ask.",
|
|
56
|
+
next === "yolo" ? "warning" : "info",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function gitHead(cwd: string): string | null {
|
|
62
|
+
try {
|
|
63
|
+
return execFileSync("git", ["rev-parse", "HEAD"], {
|
|
64
|
+
cwd,
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
timeout: 2000,
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
}).trim();
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function loadUserRules(cwd: string): void {
|
|
75
|
+
try {
|
|
76
|
+
userRules = parseUserRules(JSON.parse(readFileSync(join(cwd, ".pi", "yolo.json"), "utf8")));
|
|
77
|
+
} catch {
|
|
78
|
+
userRules = [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── The gate + the trail ─────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
85
|
+
// Trail: pre-image every file mutation, in both modes.
|
|
86
|
+
if (event.toolName === "edit" || event.toolName === "write") {
|
|
87
|
+
const path = (event as { input?: { path?: unknown } }).input?.path;
|
|
88
|
+
if (typeof path === "string" && dir) {
|
|
89
|
+
recordPreImage(dir, path, Date.now());
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (event.toolName !== "bash") return undefined;
|
|
95
|
+
const command = (event as { input?: { command?: unknown } }).input?.command;
|
|
96
|
+
if (typeof command !== "string") {
|
|
97
|
+
return { block: true, reason: "yolo guard: bash call without a command (fail-closed)." };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const verdict = evaluateCommand(command, userRules);
|
|
101
|
+
|
|
102
|
+
// Log risky commands (and everything in yolo mode) to the trail.
|
|
103
|
+
if (dir && (mode === "yolo" ? verdict.action !== "allow" : verdict.action !== "allow")) {
|
|
104
|
+
recordBash(dir, command, ctx.cwd, gitHead(ctx.cwd), Date.now());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (mode === "yolo") return undefined; // gate stands down; trail recorded
|
|
108
|
+
|
|
109
|
+
if (verdict.action === "allow") return undefined;
|
|
110
|
+
|
|
111
|
+
if (verdict.action === "block") {
|
|
112
|
+
return {
|
|
113
|
+
block: true,
|
|
114
|
+
reason: `yolo guard blocked this command (${verdict.rule}) — catastrophic patterns are never auto-approved. Do not retry it; choose a safer approach.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ASK tier.
|
|
119
|
+
if (!ctx.hasUI) {
|
|
120
|
+
return {
|
|
121
|
+
block: true,
|
|
122
|
+
reason: `yolo guard: '${verdict.rule}' needs confirmation but no UI is available (fail-closed deny).`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const approved = await ctx.ui.confirm(
|
|
126
|
+
"Destructive command",
|
|
127
|
+
`${command}\n\nRule: ${verdict.rule}. Run it?`,
|
|
128
|
+
);
|
|
129
|
+
if (approved) return undefined;
|
|
130
|
+
|
|
131
|
+
// Reject-with-reason: the user's why helps the agent adjust course.
|
|
132
|
+
const reason = await ctx.ui.input("Why not? (optional — sent to the agent)");
|
|
133
|
+
return {
|
|
134
|
+
block: true,
|
|
135
|
+
reason: reason?.trim()
|
|
136
|
+
? `The user declined (${verdict.rule}): ${reason.trim()}`
|
|
137
|
+
: `The user declined this command (${verdict.rule}). Choose a different approach.`,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
144
|
+
dir = trailDir(getAgentDir(), ctx.cwd);
|
|
145
|
+
loadUserRules(ctx.cwd);
|
|
146
|
+
mode = "guard";
|
|
147
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
148
|
+
const e = entry as { type?: string; customType?: string; data?: unknown };
|
|
149
|
+
if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
|
|
150
|
+
if (e.data.mode === "yolo" || e.data.mode === "guard") mode = e.data.mode;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
updateFooter(ctx);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
157
|
+
mode = "guard";
|
|
158
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
159
|
+
const e = entry as { type?: string; customType?: string; data?: unknown };
|
|
160
|
+
if (e.type === "custom" && e.customType === MODE_ENTRY && isRecord(e.data)) {
|
|
161
|
+
if (e.data.mode === "yolo" || e.data.mode === "guard") mode = e.data.mode;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
updateFooter(ctx);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
168
|
+
if (ctx.hasUI) ctx.ui.setStatus("yolo", undefined);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── Command ──────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
pi.registerCommand("yolo", {
|
|
174
|
+
description: "Toggle auto-approve: /yolo [on|off|status|trail|undo [n]]",
|
|
175
|
+
handler: async (args, ctx) => {
|
|
176
|
+
const [route, countRaw] = (args ?? "").trim().toLowerCase().split(/\s+/);
|
|
177
|
+
switch (route || "toggle") {
|
|
178
|
+
case "toggle":
|
|
179
|
+
setMode(ctx, mode === "yolo" ? "guard" : "yolo");
|
|
180
|
+
return;
|
|
181
|
+
case "on":
|
|
182
|
+
setMode(ctx, "yolo");
|
|
183
|
+
return;
|
|
184
|
+
case "off":
|
|
185
|
+
setMode(ctx, "guard");
|
|
186
|
+
return;
|
|
187
|
+
case "status": {
|
|
188
|
+
if (!ctx.hasUI) return;
|
|
189
|
+
const entries = readManifest(dir);
|
|
190
|
+
ctx.ui.notify(
|
|
191
|
+
[
|
|
192
|
+
`Mode: ${mode === "yolo" ? "⚡ YOLO (gate off)" : "🛡 guard"}`,
|
|
193
|
+
`User rules: ${userRules.length} (.pi/yolo.json)`,
|
|
194
|
+
`Trail: ${entries.length} entries — /yolo trail to view, /yolo undo [n] to restore`,
|
|
195
|
+
].join("\n"),
|
|
196
|
+
"info",
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
case "trail": {
|
|
201
|
+
if (!ctx.hasUI) return;
|
|
202
|
+
ctx.ui.notify(formatTrail(readManifest(dir), 20), "info");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
case "undo": {
|
|
206
|
+
if (!ctx.hasUI) return;
|
|
207
|
+
const count = Math.max(1, Math.min(50, Number.parseInt(countRaw ?? "1", 10) || 1));
|
|
208
|
+
const preview = readManifest(dir)
|
|
209
|
+
.filter((e) => e.type === "file")
|
|
210
|
+
.sort((a, b) => b.seq - a.seq)
|
|
211
|
+
.slice(0, count);
|
|
212
|
+
if (preview.length === 0) {
|
|
213
|
+
ctx.ui.notify("Nothing to undo — the trail has no file entries.", "warning");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const ok = await ctx.ui.confirm(
|
|
217
|
+
"Undo file changes",
|
|
218
|
+
`Restore ${preview.length} file(s) to their pre-images?\n${preview.map((e) => e.target).join("\n")}`,
|
|
219
|
+
);
|
|
220
|
+
if (!ok) return;
|
|
221
|
+
const result = undo(dir, count);
|
|
222
|
+
ctx.ui.notify(
|
|
223
|
+
[
|
|
224
|
+
result.restored.length > 0 ? `Restored: ${result.restored.join(", ")}` : "",
|
|
225
|
+
result.deleted.length > 0 ? `Deleted (were new): ${result.deleted.join(", ")}` : "",
|
|
226
|
+
result.skipped.length > 0 ? `Skipped: ${result.skipped.join(", ")}` : "",
|
|
227
|
+
]
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.join("\n") || "Nothing changed.",
|
|
230
|
+
"info",
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
if (ctx.hasUI) ctx.ui.notify("Usage: /yolo [on|off|status|trail|undo [n]]", "warning");
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pify/yolo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One toggle to auto-approve everything, with an undo trail: three-tier bash guard, file pre-images, fail-closed everywhere",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi",
|
|
9
|
+
"pify",
|
|
10
|
+
"yolo",
|
|
11
|
+
"permissions",
|
|
12
|
+
"safety"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/pifydev/yolo#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/pifydev/yolo/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/pifydev/yolo.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Pify maintainers",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.19.0"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"extensions",
|
|
30
|
+
"src",
|
|
31
|
+
"skills",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./extensions/yolo.ts"
|
|
38
|
+
],
|
|
39
|
+
"skills": [
|
|
40
|
+
"./skills"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "bun test",
|
|
46
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
58
|
+
"@types/node": "^22.10.2",
|
|
59
|
+
"typescript": "^5.7.2"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: yolo
|
|
3
|
+
description: Use when a bash command gets blocked or needs confirmation by the yolo guard, or when the user asks about undoing recent changes - explains the guard tiers, how to respond to denials, and the undo trail
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# YOLO guard & undo trail
|
|
7
|
+
|
|
8
|
+
This project has the `@pify/yolo` extension installed: a three-tier bash
|
|
9
|
+
safety gate plus an always-on undo trail of file pre-images.
|
|
10
|
+
|
|
11
|
+
## When your command is blocked or denied
|
|
12
|
+
|
|
13
|
+
- A BLOCK (catastrophic tier: `rm -rf /`, mkfs, device writes…) is final —
|
|
14
|
+
never retry or rephrase to evade it; choose a fundamentally safer
|
|
15
|
+
approach and tell the user why.
|
|
16
|
+
- An ASK denial may include the user's reason — treat it as a course
|
|
17
|
+
correction, not an obstacle. Adjust the plan accordingly.
|
|
18
|
+
- Do not suggest the user enable yolo mode to bypass a block.
|
|
19
|
+
|
|
20
|
+
## The undo trail
|
|
21
|
+
|
|
22
|
+
- Every edit/write saves the file's pre-image first, in both modes. If the
|
|
23
|
+
user asks to revert recent changes, point them to `/yolo undo [n]`
|
|
24
|
+
(or restore specific files yourself from the visible trail).
|
|
25
|
+
- Bash effects are logged but NOT undoable — before a risky-but-approved
|
|
26
|
+
command, mention what it will destroy if that's not obvious.
|
|
27
|
+
|
|
28
|
+
## Modes
|
|
29
|
+
|
|
30
|
+
- guard (default): catastrophic → block, destructive → confirm, rest runs.
|
|
31
|
+
- yolo (`/yolo`): everything auto-approves; the trail keeps recording.
|
|
32
|
+
The user toggles this — never you.
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { RuleAction, RuleHit, UserRule } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Three-tier bash safety rules (pi-yolo-seatbelt's model):
|
|
5
|
+
* BLOCK for catastrophic patterns, ASK for destructive ones, ALLOW the rest.
|
|
6
|
+
* User rules (wildcard, last-match-wins — zhushanwen semantics) can retune
|
|
7
|
+
* ASK/ALLOW, but catastrophic BLOCKs are a hard floor no rule can override.
|
|
8
|
+
* Fail-closed: anything that throws during evaluation blocks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface BuiltinRule {
|
|
12
|
+
name: string;
|
|
13
|
+
action: Exclude<RuleAction, "allow">;
|
|
14
|
+
re: RegExp;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Catastrophic — never overridable. */
|
|
18
|
+
const CATASTROPHIC: BuiltinRule[] = [
|
|
19
|
+
{ name: "rm-rf-root", action: "block", re: /\brm\s+(-\w*[rR]\w*\s+)*(-\w*[rR]\w*)\s+(["']?)(\/|\/\*)\3(\s|$)/ },
|
|
20
|
+
{ name: "rm-rf-home", action: "block", re: /\brm\s+-\w*[rR]\w*\s+(["']?)(~|\$HOME)\1(\/?)(\s|$)/ },
|
|
21
|
+
{ name: "rm-rf-git", action: "block", re: /\brm\s+-\w*[rR]\w*[fF]?\w*\s+\S*\.git(\s|$|\/)/ },
|
|
22
|
+
{ name: "mkfs", action: "block", re: /\bmkfs(\.\w+)?\b/ },
|
|
23
|
+
{ name: "dd-device", action: "block", re: /\bdd\b[^|;&]*\bof=\/dev\// },
|
|
24
|
+
{ name: "fork-bomb", action: "block", re: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/ },
|
|
25
|
+
{ name: "chmod-777-root", action: "block", re: /\bchmod\s+(-\w+\s+)*777\s+\/(\s|$)/ },
|
|
26
|
+
{ name: "write-device", action: "block", re: />\s*\/dev\/(sd[a-z]|nvme\d|disk\d)/ },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Destructive — confirmation required in guard mode; user rules may retune. */
|
|
30
|
+
const DESTRUCTIVE: BuiltinRule[] = [
|
|
31
|
+
{ name: "rm-rf", action: "ask", re: /\brm\s+-\w*([rR]\w*[fF]|[fF]\w*[rR])\w*\s/ },
|
|
32
|
+
{ name: "rm-r", action: "ask", re: /\brm\s+-\w*[rR]\w*\s/ },
|
|
33
|
+
{ name: "git-push-force", action: "ask", re: /\bgit\s+push\b[^|;&]*(\s--force(-with-lease)?\b|\s-f\b)/ },
|
|
34
|
+
{ name: "git-reset-hard", action: "ask", re: /\bgit\s+reset\s+--hard\b/ },
|
|
35
|
+
{ name: "git-clean-force", action: "ask", re: /\bgit\s+clean\b[^|;&]*\s-\w*[fdx]/ },
|
|
36
|
+
{ name: "git-branch-delete", action: "ask", re: /\bgit\s+branch\s+(-D|--delete\s+--force)\b/ },
|
|
37
|
+
{ name: "git-discard", action: "ask", re: /\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/ },
|
|
38
|
+
{ name: "pipe-to-shell", action: "ask", re: /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z|fi)?sh\b/ },
|
|
39
|
+
{ name: "find-delete", action: "ask", re: /\bfind\b[^|;&]*\s-delete\b/ },
|
|
40
|
+
{ name: "chmod-777", action: "ask", re: /\bchmod\s+(-\w+\s+)*777\b/ },
|
|
41
|
+
{ name: "truncate", action: "ask", re: /\btruncate\s+-s\s*0\b/ },
|
|
42
|
+
{ name: "history-rewrite", action: "ask", re: /\bgit\s+(rebase|filter-branch|filter-repo)\b/ },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Convert a user wildcard pattern (`git push*`) to a regex. */
|
|
46
|
+
export function wildcardToRegex(pattern: string): RegExp {
|
|
47
|
+
const escaped = pattern
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
50
|
+
.replace(/\*/g, ".*")
|
|
51
|
+
.replace(/\?/g, ".");
|
|
52
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalize(command: string): string {
|
|
56
|
+
return command.replace(/\s+/g, " ").trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function evaluateCommand(command: string, userRules: UserRule[] = []): RuleHit {
|
|
60
|
+
try {
|
|
61
|
+
const normalized = normalize(command);
|
|
62
|
+
if (!normalized) return { action: "allow", rule: "empty" };
|
|
63
|
+
|
|
64
|
+
// Hard floor: catastrophic patterns are non-negotiable.
|
|
65
|
+
for (const rule of CATASTROPHIC) {
|
|
66
|
+
if (rule.re.test(normalized)) return { action: "block", rule: rule.name };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Builtin destructive verdict, then user rules last-match-wins on top.
|
|
70
|
+
let verdict: RuleHit = { action: "allow", rule: "default" };
|
|
71
|
+
for (const rule of DESTRUCTIVE) {
|
|
72
|
+
if (rule.re.test(normalized)) {
|
|
73
|
+
verdict = { action: rule.action, rule: rule.name };
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const rule of userRules) {
|
|
78
|
+
try {
|
|
79
|
+
if (wildcardToRegex(rule.pattern).test(normalized)) {
|
|
80
|
+
verdict = { action: rule.action, rule: `user:${rule.pattern}` };
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// invalid user pattern — ignore that rule
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return verdict;
|
|
87
|
+
} catch {
|
|
88
|
+
// Fail-closed (zhushanwen's rule): evaluation errors never allow.
|
|
89
|
+
return { action: "block", rule: "fail-closed" };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseUserRules(raw: unknown): UserRule[] {
|
|
94
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
95
|
+
const rules = (raw as { rules?: unknown }).rules;
|
|
96
|
+
if (!Array.isArray(rules)) return [];
|
|
97
|
+
const valid: UserRule[] = [];
|
|
98
|
+
for (const rule of rules) {
|
|
99
|
+
if (
|
|
100
|
+
typeof rule === "object" &&
|
|
101
|
+
rule !== null &&
|
|
102
|
+
typeof (rule as UserRule).pattern === "string" &&
|
|
103
|
+
["allow", "ask", "block"].includes((rule as UserRule).action)
|
|
104
|
+
) {
|
|
105
|
+
valid.push({ pattern: (rule as UserRule).pattern, action: (rule as UserRule).action });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return valid;
|
|
109
|
+
}
|
package/src/trail.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { basename, join } from "node:path";
|
|
11
|
+
import type { TrailEntry } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The undo trail: before every edit/write the file's pre-image is saved, and
|
|
15
|
+
* risky bash commands are logged with their context. One rolling trail per
|
|
16
|
+
* project (keyed by cwd hash) under the agent dir — surviving sessions, so
|
|
17
|
+
* "undo what just happened" works even after a restart.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export function trailDir(agentDir: string, cwd: string): string {
|
|
21
|
+
const key = createHash("sha256").update(cwd.toLowerCase()).digest("hex").slice(0, 12);
|
|
22
|
+
return join(agentDir, "yolo-trail", key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function manifestPath(dir: string): string {
|
|
26
|
+
return join(dir, "manifest.jsonl");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readManifest(dir: string): TrailEntry[] {
|
|
30
|
+
try {
|
|
31
|
+
return readFileSync(manifestPath(dir), "utf8")
|
|
32
|
+
.split("\n")
|
|
33
|
+
.filter((l) => l.trim())
|
|
34
|
+
.map((l) => {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(l) as TrailEntry;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
.filter((e): e is TrailEntry => e !== null && typeof e.seq === "number");
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nextSeq(dir: string): number {
|
|
48
|
+
const entries = readManifest(dir);
|
|
49
|
+
return entries.length === 0 ? 1 : Math.max(...entries.map((e) => e.seq)) + 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function append(dir: string, entry: TrailEntry): void {
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
appendFileSync(manifestPath(dir), `${JSON.stringify(entry)}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Save a file's pre-image before it is modified. Never throws. */
|
|
58
|
+
export function recordPreImage(dir: string, filePath: string, now: number): TrailEntry | null {
|
|
59
|
+
try {
|
|
60
|
+
const seq = nextSeq(dir);
|
|
61
|
+
const existed = existsSync(filePath);
|
|
62
|
+
let saved: string | null = null;
|
|
63
|
+
if (existed) {
|
|
64
|
+
saved = `${seq}-${basename(filePath).slice(0, 80)}`;
|
|
65
|
+
mkdirSync(dir, { recursive: true });
|
|
66
|
+
writeFileSync(join(dir, saved), readFileSync(filePath));
|
|
67
|
+
}
|
|
68
|
+
const entry: TrailEntry = { seq, timestamp: now, type: "file", target: filePath, saved, existed };
|
|
69
|
+
append(dir, entry);
|
|
70
|
+
return entry;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Log a risky/mutating bash command. Never throws. */
|
|
77
|
+
export function recordBash(
|
|
78
|
+
dir: string,
|
|
79
|
+
command: string,
|
|
80
|
+
cwd: string,
|
|
81
|
+
gitHead: string | null,
|
|
82
|
+
now: number,
|
|
83
|
+
): void {
|
|
84
|
+
try {
|
|
85
|
+
append(dir, {
|
|
86
|
+
seq: nextSeq(dir),
|
|
87
|
+
timestamp: now,
|
|
88
|
+
type: "bash",
|
|
89
|
+
target: command.slice(0, 500),
|
|
90
|
+
saved: null,
|
|
91
|
+
existed: false,
|
|
92
|
+
cwd,
|
|
93
|
+
...(gitHead ? { gitHead } : {}),
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
// trail must never break the tool call
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface UndoResult {
|
|
101
|
+
restored: string[];
|
|
102
|
+
deleted: string[];
|
|
103
|
+
skipped: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Restore the newest `count` file pre-images (newest first). Files that did
|
|
108
|
+
* not exist before their change are deleted. Bash entries cannot be undone
|
|
109
|
+
* and are skipped. Returns what happened for reporting.
|
|
110
|
+
*/
|
|
111
|
+
export function undo(dir: string, count: number): UndoResult {
|
|
112
|
+
const result: UndoResult = { restored: [], deleted: [], skipped: [] };
|
|
113
|
+
const entries = readManifest(dir)
|
|
114
|
+
.filter((e) => e.type === "file")
|
|
115
|
+
.sort((a, b) => b.seq - a.seq)
|
|
116
|
+
.slice(0, count);
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
try {
|
|
120
|
+
if (entry.existed && entry.saved) {
|
|
121
|
+
writeFileSync(entry.target, readFileSync(join(dir, entry.saved)));
|
|
122
|
+
result.restored.push(entry.target);
|
|
123
|
+
} else if (!entry.existed && existsSync(entry.target)) {
|
|
124
|
+
unlinkSync(entry.target);
|
|
125
|
+
result.deleted.push(entry.target);
|
|
126
|
+
} else {
|
|
127
|
+
result.skipped.push(entry.target);
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
result.skipped.push(entry.target);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function formatTrail(entries: TrailEntry[], limit: number): string {
|
|
137
|
+
if (entries.length === 0) return "Trail is empty.";
|
|
138
|
+
const recent = entries.slice(-limit).reverse();
|
|
139
|
+
return recent
|
|
140
|
+
.map((e) => {
|
|
141
|
+
const when = new Date(e.timestamp).toISOString().replace("T", " ").slice(0, 19);
|
|
142
|
+
if (e.type === "file") {
|
|
143
|
+
return `#${e.seq} ${when} file ${e.target}${e.existed ? "" : " (new file)"}`;
|
|
144
|
+
}
|
|
145
|
+
return `#${e.seq} ${when} bash ${e.target}${e.gitHead ? ` @${e.gitHead.slice(0, 8)}` : ""}`;
|
|
146
|
+
})
|
|
147
|
+
.join("\n");
|
|
148
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local structural types for @pify/yolo.
|
|
3
|
+
* No imports from pi packages: src/ typechecks and runs standalone.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type Mode = "guard" | "yolo";
|
|
7
|
+
|
|
8
|
+
export type RuleAction = "allow" | "ask" | "block";
|
|
9
|
+
|
|
10
|
+
export interface RuleHit {
|
|
11
|
+
action: RuleAction;
|
|
12
|
+
/** Human label of the matching rule, e.g. "rm-rf-root". */
|
|
13
|
+
rule: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UserRule {
|
|
17
|
+
pattern: string;
|
|
18
|
+
action: RuleAction;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TrailEntry {
|
|
22
|
+
seq: number;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
type: "file" | "bash";
|
|
25
|
+
/** file: absolute path edited/written. bash: the command. */
|
|
26
|
+
target: string;
|
|
27
|
+
/** file: saved pre-image filename (null when the file did not exist). */
|
|
28
|
+
saved: string | null;
|
|
29
|
+
/** file: whether the file existed before the change. */
|
|
30
|
+
existed: boolean;
|
|
31
|
+
cwd?: string;
|
|
32
|
+
gitHead?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BranchEntryLike {
|
|
36
|
+
type?: string;
|
|
37
|
+
customType?: string;
|
|
38
|
+
data?: unknown;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|