@pify/yolo 0.1.0 → 0.2.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 +19 -0
- package/extensions/yolo.ts +83 -10
- package/package.json +1 -1
- 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:
|
package/extensions/yolo.ts
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
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).
|
|
@@ -26,7 +32,7 @@ import { execFileSync } from "node:child_process";
|
|
|
26
32
|
import { readFileSync } from "node:fs";
|
|
27
33
|
import { join } from "node:path";
|
|
28
34
|
|
|
29
|
-
import { evaluateCommand, parseUserRules } from "../src/rules.ts";
|
|
35
|
+
import { evaluateCommand, evaluatePath, parseUserRules } from "../src/rules.ts";
|
|
30
36
|
import { formatTrail, readManifest, recordBash, recordPreImage, trailDir, undo } from "../src/trail.ts";
|
|
31
37
|
import { isRecord, type Mode, type UserRule } from "../src/types.ts";
|
|
32
38
|
|
|
@@ -71,6 +77,63 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Snapshot the working tree into a dangling commit before a risky command.
|
|
82
|
+
* `git stash create` writes nothing to the tree, the index, or the stash
|
|
83
|
+
* list — it just gives us a sha to come back to. A ref keeps it out of gc's
|
|
84
|
+
* reach; empty output means there was nothing to save.
|
|
85
|
+
*/
|
|
86
|
+
function gitCheckpoint(cwd: string, now: number): string | null {
|
|
87
|
+
try {
|
|
88
|
+
const sha = execFileSync("git", ["stash", "create"], {
|
|
89
|
+
cwd,
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
}).trim();
|
|
94
|
+
if (!/^[0-9a-f]{7,40}$/.test(sha)) return null;
|
|
95
|
+
try {
|
|
96
|
+
execFileSync("git", ["update-ref", `refs/pify/yolo/${now}`, sha], {
|
|
97
|
+
cwd,
|
|
98
|
+
timeout: 5000,
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
// unreachable-but-recent commits still survive the default gc window
|
|
103
|
+
}
|
|
104
|
+
return sha;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Confirmation gate for a file path (secret material). */
|
|
111
|
+
async function guardPath(
|
|
112
|
+
ctx: UiContext,
|
|
113
|
+
path: string,
|
|
114
|
+
rule: string,
|
|
115
|
+
action: "ask" | "block",
|
|
116
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
117
|
+
if (action === "block") {
|
|
118
|
+
return { block: true, reason: `yolo guard blocked access to ${path} (${rule}).` };
|
|
119
|
+
}
|
|
120
|
+
if (!ctx.hasUI) {
|
|
121
|
+
return {
|
|
122
|
+
block: true,
|
|
123
|
+
reason: `yolo guard: ${path} holds secret material (${rule}) and there is no UI to confirm (fail-closed deny).`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const approved = await ctx.ui.confirm(
|
|
127
|
+
"Secret file",
|
|
128
|
+
`${path}\n\nRule: ${rule}. Allow this access?`,
|
|
129
|
+
);
|
|
130
|
+
if (approved) return undefined;
|
|
131
|
+
return {
|
|
132
|
+
block: true,
|
|
133
|
+
reason: `The user declined access to ${path} (${rule}). Continue without its contents; ask for the value you need instead.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
74
137
|
function loadUserRules(cwd: string): void {
|
|
75
138
|
try {
|
|
76
139
|
userRules = parseUserRules(JSON.parse(readFileSync(join(cwd, ".pi", "yolo.json"), "utf8")));
|
|
@@ -82,11 +145,18 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
82
145
|
// ── The gate + the trail ─────────────────────────────────────────────
|
|
83
146
|
|
|
84
147
|
pi.on("tool_call", async (event, ctx) => {
|
|
85
|
-
//
|
|
86
|
-
|
|
148
|
+
// Secret material is checked in BOTH modes: yolo trades safety for speed,
|
|
149
|
+
// not for handing credentials to a model.
|
|
150
|
+
if (event.toolName === "read" || event.toolName === "edit" || event.toolName === "write") {
|
|
87
151
|
const path = (event as { input?: { path?: unknown } }).input?.path;
|
|
88
|
-
if (typeof path === "string"
|
|
89
|
-
|
|
152
|
+
if (typeof path === "string") {
|
|
153
|
+
const verdict = evaluatePath(path, userRules);
|
|
154
|
+
if (verdict.action !== "allow") {
|
|
155
|
+
const denial = await guardPath(ctx, path, verdict.rule, verdict.action);
|
|
156
|
+
if (denial) return denial;
|
|
157
|
+
}
|
|
158
|
+
// Trail: pre-image every file mutation, in both modes.
|
|
159
|
+
if (event.toolName !== "read" && dir) recordPreImage(dir, path, Date.now());
|
|
90
160
|
}
|
|
91
161
|
return undefined;
|
|
92
162
|
}
|
|
@@ -98,13 +168,16 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
98
168
|
}
|
|
99
169
|
|
|
100
170
|
const verdict = evaluateCommand(command, userRules);
|
|
171
|
+
const touchesSecret = verdict.rule.startsWith("secret:");
|
|
101
172
|
|
|
102
|
-
// Log risky commands
|
|
103
|
-
if (dir &&
|
|
104
|
-
|
|
173
|
+
// Log risky commands, with a checkpoint of the tree as it was.
|
|
174
|
+
if (dir && verdict.action !== "allow") {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
recordBash(dir, command, ctx.cwd, gitHead(ctx.cwd), now, gitCheckpoint(ctx.cwd, now));
|
|
105
177
|
}
|
|
106
178
|
|
|
107
|
-
|
|
179
|
+
// The gate stands down in yolo mode — except for secrets.
|
|
180
|
+
if (mode === "yolo" && !touchesSecret) return undefined;
|
|
108
181
|
|
|
109
182
|
if (verdict.action === "allow") return undefined;
|
|
110
183
|
|
|
@@ -123,7 +196,7 @@ export default function yolo(pi: ExtensionAPI) {
|
|
|
123
196
|
};
|
|
124
197
|
}
|
|
125
198
|
const approved = await ctx.ui.confirm(
|
|
126
|
-
"Destructive command",
|
|
199
|
+
touchesSecret ? "Command touches secret material" : "Destructive command",
|
|
127
200
|
`${command}\n\nRule: ${verdict.rule}. Run it?`,
|
|
128
201
|
);
|
|
129
202
|
if (approved) return undefined;
|
package/package.json
CHANGED
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