@huseynsnmz/pi-modes 0.2.1 → 0.2.2
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/CHANGELOG.md +10 -0
- package/README.md +4 -1
- package/package.json +1 -1
- package/skills/review-permissions/SKILL.md +12 -0
- package/skills/review-permissions/scripts/apply.mjs +85 -14
- package/skills/review-permissions/scripts/collect.mjs +67 -2
- package/src/index.ts +26 -3
- package/src/sandbox.ts +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.2]
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- With `pi-sandbox` installed, yolo only runs inside the sandbox and always keeps deny rules on.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- The `review-permissions` skill lists what `pi-sandbox` blocked and adds your picks to `sandbox.json`.
|
|
12
|
+
|
|
3
13
|
## [0.2.1]
|
|
4
14
|
|
|
5
15
|
### Added
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Requires Pi 1.5 or newer.
|
|
|
21
21
|
1. Install it:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
pi install npm:@huseynsnmz/pi-modes@0.2.
|
|
24
|
+
pi install npm:@huseynsnmz/pi-modes@0.2.2
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
2. Move it above `@gotgenes/pi-permission-system`, because `pi install` adds packages last. Otherwise, in `plan` and `read-only` you may be asked to approve an action that is then blocked anyway. It stays blocked either way, and the extension warns you at startup. Skip this step if you don't use that package.
|
|
@@ -71,6 +71,7 @@ jq '.authorizerChain = ((.authorizerChain // []) + ["pi-modes"] | unique)' "$f"
|
|
|
71
71
|
What it does and doesn't cover:
|
|
72
72
|
|
|
73
73
|
- **`deny` rules still block, unless you choose otherwise.** A link only answers questions the rules left open. In the warning, `d` turns them off for this run. The agent then gets separate tools (`yolo_bash`, `yolo_read`, `yolo_write`, `yolo_edit`) that your rules don't name, and the footer says `deny rules off`. Other tools, like MCP, stay under the rules, and so do subagents. A top-level `"*": "deny"` still blocks these tools. Leaving yolo puts everything back.
|
|
74
|
+
- **With `pi-sandbox`, yolo stays inside the sandbox.** Deny rules can't be turned off, because the tools that ignore them would run outside it. Yolo won't start with the sandbox off, and if you turn the sandbox off during yolo, the next tool call is blocked and you're back in `normal`. Load `pi-modes` first in `packages`, or yolo is refused. `pi-sandbox`'s own prompts still appear in yolo, and Enter on one picks "Allow for this session".
|
|
74
75
|
- **Subagents are covered.** Their prompts come to your session, and yolo answers them.
|
|
75
76
|
- **Access outside the project can still ask.** The permission system doesn't let any link approve its `external_directory` and `path` surfaces. To stop those prompts too, set those rules to `allow` yourself. That applies in every mode.
|
|
76
77
|
- **The review log says who approved.** Approvals are recorded under the link name `pi-modes`.
|
|
@@ -87,6 +88,8 @@ The package includes a skill that turns repeated approvals into allowlist rules.
|
|
|
87
88
|
|
|
88
89
|
It reads the permission system's review log and your past chats, and groups what you approved and what ran. The agent rates each group and shows you the list, and only the rules you pick are added. The scripts refuse rules that are too broad, such as `git *`, or that cover `git push`, `rm`, `curl` or an interpreter like `python`. They back up the config first and place new rules so that your `deny` rules still win. What ran in `yolo` is ignored, and so are commands that look like they hold a secret.
|
|
89
90
|
|
|
91
|
+
With `pi-sandbox`, it also lists the paths and domains the sandbox blocked. What you pick goes in `~/.pi/agent/sandbox.json`. It refuses credential folders, whole home folders, and places where a written file runs later, such as shell profiles and git hooks.
|
|
92
|
+
|
|
90
93
|
## How plan mode ends
|
|
91
94
|
|
|
92
95
|
In plan mode the agent calls the `submit_plan` tool. You then choose one of:
|
package/package.json
CHANGED
|
@@ -33,6 +33,16 @@ Give each candidate one rating. Read the examples, not only the pattern. The sug
|
|
|
33
33
|
- reads secrets
|
|
34
34
|
- a pattern so broad it covers these, like `git *` or `npm *`
|
|
35
35
|
|
|
36
|
+
### What the sandbox blocked
|
|
37
|
+
|
|
38
|
+
If `pi-sandbox` is in use, `sandbox.candidates` lists what it blocked, each a `read` path, a `write` path or a `domain`, with how often. These blocks may be the sandbox doing its job, so being blocked often is no reason to allow something. Rate them the same way:
|
|
39
|
+
|
|
40
|
+
- **Recommend:** a folder or domain the user's normal work needs, such as a package registry or a sibling project to read.
|
|
41
|
+
- **Your call:** write access outside the project, or a domain that accepts uploads. Any domain the sandbox allows can receive data, so say so.
|
|
42
|
+
- **Don't add:** credentials, a whole home folder, paste or file-sharing sites, and anything you can't connect to the work.
|
|
43
|
+
|
|
44
|
+
`sandbox.osBlocks` counts commands the OS stopped, with some of their errors in `sandbox.osExamples`. Some may be failures that have nothing to do with the sandbox. Mention the count, and if an example names a folder worth allowing, rate it like the others.
|
|
45
|
+
|
|
36
46
|
## 3. Present
|
|
37
47
|
|
|
38
48
|
Show a table with the pattern, the rating, the approval count and a one-line reason. Group by rating. Mention each `Don't add` item with its reason, so the user sees it was considered.
|
|
@@ -45,6 +55,8 @@ Ask which rules to add, and wait for the answer. Then pass exactly those:
|
|
|
45
55
|
node <skill-dir>/scripts/apply.mjs --bash 'npm test*' --bash 'git diff*' --tool 'grep'
|
|
46
56
|
```
|
|
47
57
|
|
|
58
|
+
Sandbox choices go in the same way, with `--sandbox-read PATH`, `--sandbox-write PATH` and `--sandbox-domain DOMAIN`, and land in Pi's `sandbox.json`.
|
|
59
|
+
|
|
48
60
|
The script keeps a backup next to the config, refuses unsafe patterns, and places new rules so that existing `deny` and `ask` rules keep winning. If it refuses a pattern, explain why instead of editing the config by hand.
|
|
49
61
|
|
|
50
62
|
## 5. Finish
|
|
@@ -65,32 +65,103 @@ export function applyRules(config, { bash = [], tools = [] }) {
|
|
|
65
65
|
return { config: { ...config, permission }, refusals: [] };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
const CREDENTIALS = /(?:^|\/)(?:\.ssh|\.aws|\.gnupg|\.kube|\.docker|\.netrc|\.npmrc|\.pgpass|\.git-credentials|\.pi|\.claude|\.codex|\.elfie|\.config\/gh|\.config\/gcloud|auth\.json|credentials(?:\.\w+)?|\.env(?:\.[^/]*)?)(?:\/|$)|\.(?:pem|key|p12|pfx)$|(?:^|\/)id_[a-z0-9]+$/i;
|
|
69
|
+
const RUNS_LATER = /(?:^|\/)(?:\.git\/hooks|\.git\/config|\.zshrc|\.zshenv|\.zprofile|\.bashrc|\.bash_profile|\.profile|\.gitconfig|Library\/LaunchAgents|\.config\/autostart)(?:\/|$)/;
|
|
70
|
+
|
|
71
|
+
/** Why a sandbox path must not be allowed, or undefined when it may be. */
|
|
72
|
+
export function refuseSandboxPath(kind, path, home = homedir()) {
|
|
73
|
+
const text = path.trim().replace(/(.)\/+$/, "$1");
|
|
74
|
+
if (text === "" || text === "/" || text === "~" || text === home || text === "/Users" || text === "/home" || /[*?[]/.test(text) || text.split("/").includes("..")) return "it covers far more than one folder, so name the folder";
|
|
75
|
+
if (CREDENTIALS.test(text)) return "this holds credentials";
|
|
76
|
+
if (kind === "write" && RUNS_LATER.test(text)) return "whatever is written here runs later, outside the sandbox";
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function refuseDomain(domain) {
|
|
81
|
+
const text = domain.trim().replace(/^\*\./, "");
|
|
82
|
+
if (!/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i.test(text)) return "it isn't a single domain";
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** pi-sandbox 0.7.3's defaults. They stop applying once a list is set, so a new list starts from them. */
|
|
87
|
+
const SANDBOX_DEFAULTS = {
|
|
88
|
+
read: [".", "~/.config", "~/.local", "Library"],
|
|
89
|
+
write: [".", "/tmp"],
|
|
90
|
+
domain: ["npmjs.org", "*.npmjs.org", "registry.npmjs.org", "registry.yarnpkg.com", "pypi.org", "*.pypi.org", "github.com", "*.github.com", "api.github.com", "raw.githubusercontent.com"],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export function applySandbox(config, { read = [], write = [], domains = [] }) {
|
|
94
|
+
const refusals = [
|
|
95
|
+
...read.flatMap((path) => [refuseSandboxPath("read", path)].filter(Boolean).map((why) => `${path}: ${why}`)),
|
|
96
|
+
...write.flatMap((path) => [refuseSandboxPath("write", path)].filter(Boolean).map((why) => `${path}: ${why}`)),
|
|
97
|
+
...domains.flatMap((domain) => [refuseDomain(domain)].filter(Boolean).map((why) => `${domain}: ${why}`)),
|
|
98
|
+
];
|
|
99
|
+
if (refusals.length > 0) return { refusals };
|
|
100
|
+
const add = (existing, kind, fresh) => (fresh.length === 0 ? existing : [...new Set([...(existing ?? SANDBOX_DEFAULTS[kind]), ...fresh])]);
|
|
101
|
+
const filesystem = { ...config.filesystem };
|
|
102
|
+
const network = { ...config.network };
|
|
103
|
+
const allowRead = add(filesystem.allowRead, "read", read);
|
|
104
|
+
const allowWrite = add(filesystem.allowWrite, "write", write);
|
|
105
|
+
const allowedDomains = add(network.allowedDomains, "domain", domains);
|
|
106
|
+
if (allowRead !== undefined) filesystem.allowRead = allowRead;
|
|
107
|
+
if (allowWrite !== undefined) filesystem.allowWrite = allowWrite;
|
|
108
|
+
if (allowedDomains !== undefined) network.allowedDomains = allowedDomains;
|
|
109
|
+
return { config: { ...config, filesystem, network }, refusals: [] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeWithBackup(path, config) {
|
|
113
|
+
let backup;
|
|
114
|
+
if (existsSync(path)) {
|
|
115
|
+
backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
116
|
+
copyFileSync(path, backup);
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(`${path}.tmp`, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
119
|
+
renameSync(`${path}.tmp`, path);
|
|
120
|
+
return backup;
|
|
121
|
+
}
|
|
122
|
+
|
|
68
123
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
69
124
|
const bash = [];
|
|
70
125
|
const tools = [];
|
|
126
|
+
const read = [];
|
|
127
|
+
const write = [];
|
|
128
|
+
const domains = [];
|
|
71
129
|
const args = process.argv.slice(2);
|
|
72
130
|
for (let i = 0; i < args.length; i += 2) {
|
|
73
131
|
if (args[i] === "--bash" && args[i + 1] !== undefined) bash.push(args[i + 1]);
|
|
74
132
|
else if (args[i] === "--tool" && args[i + 1] !== undefined) tools.push(args[i + 1]);
|
|
133
|
+
else if (args[i] === "--sandbox-read" && args[i + 1] !== undefined) read.push(args[i + 1]);
|
|
134
|
+
else if (args[i] === "--sandbox-write" && args[i + 1] !== undefined) write.push(args[i + 1]);
|
|
135
|
+
else if (args[i] === "--sandbox-domain" && args[i + 1] !== undefined) domains.push(args[i + 1]);
|
|
75
136
|
else {
|
|
76
|
-
console.error("usage: apply.mjs [--bash PATTERN]... [--tool NAME]...");
|
|
137
|
+
console.error("usage: apply.mjs [--bash PATTERN]... [--tool NAME]... [--sandbox-read PATH]... [--sandbox-write PATH]... [--sandbox-domain DOMAIN]...");
|
|
77
138
|
process.exit(2);
|
|
78
139
|
}
|
|
79
140
|
}
|
|
80
141
|
const agent = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
console.error(`No config at ${path}.`);
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
86
|
-
const result = applyRules(JSON.parse(readFileSync(path, "utf8")), { bash, tools });
|
|
87
|
-
if (result.refusals.length > 0) {
|
|
88
|
-
console.error(`Nothing was changed.\n${result.refusals.join("\n")}`);
|
|
142
|
+
const fail = (message) => {
|
|
143
|
+
console.error(`Nothing was changed.\n${message}`);
|
|
89
144
|
process.exit(1);
|
|
145
|
+
};
|
|
146
|
+
const path = join(agent, "extensions", "pi-permission-system", "config.json");
|
|
147
|
+
const sandboxPath = join(agent, "sandbox.json");
|
|
148
|
+
const wantsRules = bash.length > 0 || tools.length > 0;
|
|
149
|
+
const wantsSandbox = read.length > 0 || write.length > 0 || domains.length > 0;
|
|
150
|
+
if (wantsRules && !existsSync(path)) fail(`No config at ${path}.`);
|
|
151
|
+
const rules = wantsRules ? applyRules(JSON.parse(readFileSync(path, "utf8")), { bash, tools }) : { refusals: [] };
|
|
152
|
+
let sandboxConfig = {};
|
|
153
|
+
if (wantsSandbox && existsSync(sandboxPath)) {
|
|
154
|
+
try {
|
|
155
|
+
sandboxConfig = JSON.parse(readFileSync(sandboxPath, "utf8"));
|
|
156
|
+
} catch {
|
|
157
|
+
fail(`${sandboxPath} isn't plain JSON, perhaps because of comments, and writing it back would lose them. Add these by hand.`);
|
|
158
|
+
}
|
|
90
159
|
}
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
160
|
+
const sandbox = wantsSandbox ? applySandbox(sandboxConfig, { read, write, domains }) : { refusals: [] };
|
|
161
|
+
const refusals = [...rules.refusals, ...sandbox.refusals];
|
|
162
|
+
if (refusals.length > 0) fail(refusals.join("\n"));
|
|
163
|
+
const backups = [];
|
|
164
|
+
if (wantsRules) backups.push(writeWithBackup(path, rules.config));
|
|
165
|
+
if (wantsSandbox) backups.push(writeWithBackup(sandboxPath, sandbox.config));
|
|
166
|
+
console.log(JSON.stringify({ added: { bash, tools, read, write, domains }, backups: backups.filter(Boolean) }, null, 2));
|
|
96
167
|
}
|
|
@@ -107,6 +107,62 @@ export function ranCommands(entries) {
|
|
|
107
107
|
return commands;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
const SANDBOX_BLOCKS = [
|
|
111
|
+
["read", /^Sandbox: read access denied for "(.+)"$/],
|
|
112
|
+
["write", /^Sandbox: write access denied for "(.+)" \(not in allowWrite\)$/],
|
|
113
|
+
["domain", /^Network access to "(.+)" is blocked \(not in allowedDomains\)\.$/],
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What pi-sandbox blocked, from one session's chat history. Paths in denyWrite are left out, since
|
|
118
|
+
* someone chose those. What the OS stopped has no clean path to allow, so it comes as examples.
|
|
119
|
+
*/
|
|
120
|
+
export function sandboxBlocks(entries) {
|
|
121
|
+
const blocks = [];
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (entry.type !== "message" || entry.message?.role !== "toolResult" || !Array.isArray(entry.message.content)) continue;
|
|
124
|
+
const text = entry.message.content.map((part) => part.text ?? "").join("").trim();
|
|
125
|
+
if (entry.message.isError && entry.message.toolName === "bash" && text.includes("Operation not permitted")) {
|
|
126
|
+
const example = text.split("\n").find((line) => line.includes("Operation not permitted"));
|
|
127
|
+
blocks.push({ kind: "os", example, timestamp: entry.timestamp });
|
|
128
|
+
}
|
|
129
|
+
for (const [kind, shape] of SANDBOX_BLOCKS) {
|
|
130
|
+
const value = shape.exec(text)?.[1];
|
|
131
|
+
if (value !== undefined) blocks.push({ kind, value, timestamp: entry.timestamp });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return blocks;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Blocks grouped by what would have to be allowed, without what the config allows by now. */
|
|
138
|
+
export function collectSandbox(blocks, config = {}) {
|
|
139
|
+
const allowed = {
|
|
140
|
+
read: config.filesystem?.allowRead ?? [],
|
|
141
|
+
write: config.filesystem?.allowWrite ?? [],
|
|
142
|
+
domain: config.network?.allowedDomains ?? [],
|
|
143
|
+
};
|
|
144
|
+
const groups = new Map();
|
|
145
|
+
let osBlocks = 0;
|
|
146
|
+
const osExamples = new Set();
|
|
147
|
+
let skipped = 0;
|
|
148
|
+
for (const { kind, value, example, timestamp } of blocks) {
|
|
149
|
+
if (kind === "os") {
|
|
150
|
+
osBlocks += 1;
|
|
151
|
+
if (osExamples.size < MAX_EXAMPLES && !SECRET.test(example)) osExamples.add(example.slice(0, 200));
|
|
152
|
+
} else if (SECRET.test(value)) skipped += 1;
|
|
153
|
+
else if (!allowed[kind].includes(value)) {
|
|
154
|
+
const group = groups.get(`${kind} ${value}`) ?? { kind, value, blocks: 0, days: new Set(), last: "" };
|
|
155
|
+
group.blocks += 1;
|
|
156
|
+
const at = String(timestamp ?? "");
|
|
157
|
+
if (at !== "") group.days.add(at.slice(0, 10));
|
|
158
|
+
if (at > group.last) group.last = at;
|
|
159
|
+
groups.set(`${kind} ${value}`, group);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const candidates = [...groups.values()].map((group) => ({ ...group, days: group.days.size })).sort((a, b) => b.blocks - a.blocks);
|
|
163
|
+
return { candidates, osBlocks, osExamples: [...osExamples], skipped };
|
|
164
|
+
}
|
|
165
|
+
|
|
110
166
|
function parseLog(text) {
|
|
111
167
|
return text.split("\n").flatMap((line) => {
|
|
112
168
|
try {
|
|
@@ -126,7 +182,16 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
|
126
182
|
const permission = existsSync(configPath) ? (JSON.parse(readFileSync(configPath, "utf8")).permission ?? {}) : {};
|
|
127
183
|
const sessions = join(agent, "sessions");
|
|
128
184
|
const files = existsSync(sessions) ? readdirSync(sessions, { recursive: true }).map(String).filter((name) => name.endsWith(".jsonl")) : [];
|
|
129
|
-
const
|
|
185
|
+
const sessionEntries = files.map((name) => parseLog(readFileSync(join(sessions, name), "utf8")));
|
|
186
|
+
const history = sessionEntries.flatMap(ranCommands);
|
|
130
187
|
const result = collect(logFound ? parseLog(readFileSync(logPath, "utf8")) : [], permission, history);
|
|
131
|
-
|
|
188
|
+
const sandboxPath = join(agent, "sandbox.json");
|
|
189
|
+
let sandboxConfig = {};
|
|
190
|
+
try {
|
|
191
|
+
if (existsSync(sandboxPath)) sandboxConfig = JSON.parse(readFileSync(sandboxPath, "utf8"));
|
|
192
|
+
} catch {
|
|
193
|
+
// Comments are allowed there. Already-allowed entries may show up, and apply won't add them twice.
|
|
194
|
+
}
|
|
195
|
+
const sandbox = collectSandbox(sessionEntries.flatMap(sandboxBlocks), sandboxConfig);
|
|
196
|
+
console.log(JSON.stringify({ logFound, ...result, sandbox }, null, 2));
|
|
132
197
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { isKeyRelease, matchesKey, Text, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
+
import { borrowStatusReader, loadsFirst, sandboxInstalled, sandboxIsOn, type StatusReader } from "./sandbox.ts";
|
|
6
7
|
import { aliasDefinitions, DENY_OFF_PROMPT, withAliases, withoutAliases } from "./deny-off.ts";
|
|
7
8
|
import { clearGate, hasPermissionSystem, installJudgeGate, JUDGE_NAMES, judgeGated, registerYoloLink, setGateMode, YOLO_LINK } from "./judge-gate.ts";
|
|
8
9
|
import { decideToolCall, displayCommand, isMode, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, resumedMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
|
|
@@ -32,7 +33,7 @@ function chainNamesJudge(): boolean {
|
|
|
32
33
|
* Only Enter starts it, so no other key, like a stray y, can agree to it. Deny rules stay on
|
|
33
34
|
* unless the user presses d. Undefined means cancelled.
|
|
34
35
|
*/
|
|
35
|
-
async function confirmYolo(ctx: ExtensionContext, canIgnoreDeny: boolean): Promise<{ denyOff: boolean } | undefined> {
|
|
36
|
+
async function confirmYolo(ctx: ExtensionContext, canIgnoreDeny: boolean, sandboxed: boolean): Promise<{ denyOff: boolean } | undefined> {
|
|
36
37
|
let denyOff = false;
|
|
37
38
|
const agreed = await ctx.ui.custom<boolean>((tui, theme, _keys, done) => ({
|
|
38
39
|
invalidate() {},
|
|
@@ -55,7 +56,7 @@ async function confirmYolo(ctx: ExtensionContext, canIgnoreDeny: boolean): Promi
|
|
|
55
56
|
"",
|
|
56
57
|
denyOff
|
|
57
58
|
? red(theme.bold("Deny rules: IGNORED for bash, read, write and edit. Secrets you protected can be read."))
|
|
58
|
-
: `Deny rules: ${theme.bold("respected")}`,
|
|
59
|
+
: `Deny rules: ${theme.bold("respected")}${sandboxed ? `, and everything stays inside the ${theme.bold("sandbox")}` : ""}`,
|
|
59
60
|
"",
|
|
60
61
|
`${red(theme.bold("Enter"))} start ${canIgnoreDeny ? `${theme.fg("dim", denyOff ? "d respect deny rules" : "d ignore deny rules")} ` : ""}${theme.fg("dim", "Esc cancel")}`,
|
|
61
62
|
];
|
|
@@ -73,6 +74,14 @@ function loadOrderIsLate(): boolean {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
function packagesLoadThisFirst(): boolean {
|
|
78
|
+
try {
|
|
79
|
+
return loadsFirst(JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")).packages);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
function restoredMode(ctx: ExtensionContext): Mode {
|
|
77
86
|
let mode: Mode = "normal";
|
|
78
87
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
@@ -92,6 +101,7 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
92
101
|
let disposeYoloLink: (() => void) | undefined;
|
|
93
102
|
let denyOff = false;
|
|
94
103
|
let replacedTools: string[] = [];
|
|
104
|
+
let statuses: StatusReader | undefined;
|
|
95
105
|
|
|
96
106
|
for (const tool of aliasDefinitions(() => mode === "yolo" && denyOff)) pi.registerTool(tool);
|
|
97
107
|
|
|
@@ -147,7 +157,13 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
147
157
|
ctx.ui.notify(`Yolo needs "${YOLO_LINK}" in authorizerChain in pi-permission-system's config.json, then a restart. See the README.`, "error");
|
|
148
158
|
return;
|
|
149
159
|
}
|
|
150
|
-
|
|
160
|
+
// The tools that ignore deny rules can't reach the sandbox's bash, so they'd run outside it.
|
|
161
|
+
const sandboxed = sandboxInstalled(pi.getCommands());
|
|
162
|
+
if (sandboxed && !sandboxIsOn(statuses)) {
|
|
163
|
+
ctx.ui.notify("Yolo only runs inside the sandbox. Turn it on with /sandbox-enable. If it is on, move pi-modes first in packages in settings.json.", "error");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const agreed = await confirmYolo(ctx, hasPermissionSystem(ctx.sessionManager.getSessionId()) && !sandboxed, sandboxed);
|
|
151
167
|
if (agreed === undefined) return;
|
|
152
168
|
denyOff = agreed.denyOff;
|
|
153
169
|
}
|
|
@@ -157,6 +173,7 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
157
173
|
pi.on("session_start", (_event, ctx) => {
|
|
158
174
|
apply(restoredMode(ctx), ctx);
|
|
159
175
|
if (ctx.mode === "tui") {
|
|
176
|
+
if (sandboxInstalled(pi.getCommands()) && packagesLoadThisFirst()) statuses = borrowStatusReader(ctx.ui as never);
|
|
160
177
|
if (loadOrderIsLate()) {
|
|
161
178
|
ctx.ui.notify(
|
|
162
179
|
"pi-modes loads after pi-permission-system, so you may be asked to approve actions that are then blocked. Move it first in packages in settings.json.",
|
|
@@ -176,6 +193,7 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
176
193
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
177
194
|
stopListening?.();
|
|
178
195
|
stopListening = undefined;
|
|
196
|
+
statuses = undefined;
|
|
179
197
|
disposeYoloLink?.();
|
|
180
198
|
disposeYoloLink = undefined;
|
|
181
199
|
clearGate(ctx.sessionManager.getSessionId());
|
|
@@ -190,6 +208,11 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
190
208
|
});
|
|
191
209
|
|
|
192
210
|
pi.on("tool_call", async (event, ctx) => {
|
|
211
|
+
if (mode === "yolo" && sandboxInstalled(pi.getCommands()) && !sandboxIsOn(statuses)) {
|
|
212
|
+
change("normal", ctx);
|
|
213
|
+
if (ctx.hasUI) ctx.ui.notify("The sandbox went off, so yolo ended.", "warning");
|
|
214
|
+
return { block: true, reason: "Yolo ended because the sandbox is off." };
|
|
215
|
+
}
|
|
193
216
|
const decision = decideToolCall(mode, event.toolName, event.input);
|
|
194
217
|
if (decision.action === "allow") return undefined;
|
|
195
218
|
if (decision.action === "block") return { block: true, reason: decision.reason };
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-sandbox offers no way to ask whether it is on, but it keeps a status while it is. Only the
|
|
3
|
+
* footer's data can read statuses, and Pi only hands that to whoever sets a footer. So this sets
|
|
4
|
+
* one for a moment at the start, which has to happen before any other extension sets its own.
|
|
5
|
+
*/
|
|
6
|
+
const COMMAND = "sandbox-enable";
|
|
7
|
+
const STATUS = "sandbox";
|
|
8
|
+
|
|
9
|
+
export type StatusReader = { getExtensionStatuses(): ReadonlyMap<string, string> };
|
|
10
|
+
|
|
11
|
+
export function sandboxInstalled(commands: readonly { name: string }[]): boolean {
|
|
12
|
+
return commands.some((command) => command.name === COMMAND);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Without a reader the sandbox counts as off, so yolo is refused rather than run unconfined. */
|
|
16
|
+
export function sandboxIsOn(reader: StatusReader | undefined): boolean {
|
|
17
|
+
return Boolean(reader?.getExtensionStatuses().get(STATUS));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function loadsFirst(packages: unknown): boolean {
|
|
21
|
+
if (!Array.isArray(packages)) return false;
|
|
22
|
+
const first: unknown = packages[0];
|
|
23
|
+
const source = typeof first === "object" && first !== null ? (first as { source?: unknown }).source : first;
|
|
24
|
+
return typeof source === "string" && /pi-modes(?:@[^/]*)?\/?$/.test(source);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type FooterSetter = { setFooter(factory: ((tui: never, theme: never, data: StatusReader) => { render(): string[]; invalidate(): void }) | undefined): void };
|
|
28
|
+
|
|
29
|
+
export function borrowStatusReader(ui: FooterSetter): StatusReader | undefined {
|
|
30
|
+
let reader: StatusReader | undefined;
|
|
31
|
+
ui.setFooter((_tui, _theme, data) => {
|
|
32
|
+
reader = data;
|
|
33
|
+
return { render: () => [], invalidate() {} };
|
|
34
|
+
});
|
|
35
|
+
ui.setFooter(undefined);
|
|
36
|
+
return reader;
|
|
37
|
+
}
|