@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 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
+ }