@huseynsnmz/pi-suite 0.2.4 → 0.2.5
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 +20 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/board/README.md +1 -1
- package/packages/frame/README.md +1 -1
- package/packages/memory/README.md +1 -1
- package/packages/memory/src/mastermind.ts +5 -3
- package/packages/memory/src/playbook.ts +7 -0
- package/packages/modes/README.md +3 -3
- package/packages/modes/src/index.ts +9 -5
- package/packages/modes/src/judge-gate.ts +19 -6
- package/packages/modes/src/review/apply.mjs +10 -5
- package/packages/modes/src/workdir.ts +77 -19
- package/packages/router/README.md +1 -1
- package/packages/subagent/src/index.ts +16 -7
- package/packages/web/README.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.5]
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
|
|
7
|
+
- `auto`: a path Pi rewrites before writing, such as `@.git/hooks/pre-commit`, `@/etc/x` or `file:///etc/x`, went past the project check. These now go to the judge.
|
|
8
|
+
- `auto`: `.GIT` and `.Github` on a case-insensitive disk went past the guarded folder check.
|
|
9
|
+
- The `cd` rules matched `cd <project>/../x`, paths the shell works out later, and symlinks out of the project. They now ask. Projects that already have the rules get the new ones at the next start.
|
|
10
|
+
- An agent started in the default mode, not its parent's, so a parent in `normal` could have a child in `auto`. It now takes its parent's mode. From `plan` it's `read-only`, and from `yolo` it's `normal`.
|
|
11
|
+
- A tool named `agent` from another package no longer skips the judge and your review.
|
|
12
|
+
- `/review-permissions` refuses `cd*`, which also matches `cdk deploy`.
|
|
13
|
+
|
|
14
|
+
### Fixes
|
|
15
|
+
|
|
16
|
+
- A `cd` to a folder that isn't there could run somewhere else: `cd build && rm -rf *` in a folder with no `build` went to the project's top `build`. It now fails as in a shell.
|
|
17
|
+
- The bash folder is kept across `/reload` and `/resume`, and follows moves in `/tree`.
|
|
18
|
+
- With no `bash` rules and a catch-all of `allow`, adding the `cd` rules made every command ask. `/review-permissions` had the same problem. Rules now start from your catch-all.
|
|
19
|
+
- `cd` rules work in projects with a space in the path.
|
|
20
|
+
- `/mastermind` no longer marks sessions as reviewed when the answer was cut off or can't be read, and a retry keeps the proposals it didn't repeat.
|
|
21
|
+
- Non-English text in an agent's answer can't be garbled where it's split across chunks.
|
|
22
|
+
|
|
3
23
|
## [0.2.4]
|
|
4
24
|
|
|
5
25
|
### Features
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ There's no setting per project, so a repository can't choose where your code goe
|
|
|
25
25
|
Requires Pi 1.5 or newer.
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
pi install npm:@huseynsnmz/pi-suite@0.2.
|
|
28
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.5
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
If you installed any of these separately, remove them first:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huseynsnmz/pi-suite",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "One Pi package for all of it: modes and permissions, a framed screen, an agent board, memory, web tools with a separate page reader, and a model router. Turn off what you don't want with /suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/packages/board/README.md
CHANGED
package/packages/frame/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { COMPACT_SYSTEM, compactPrompt, GLOBAL, parseProposals, PLAYBOOK_CHARS, readPlaybook, REVIEW_SYSTEM, reviewPrompt, writePlaybook } from "./playbook.ts";
|
|
7
|
+
import { COMPACT_SYSTEM, compactPrompt, GLOBAL, parseProposals, PLAYBOOK_CHARS, readPlaybook, readProposals, REVIEW_SYSTEM, reviewPrompt, writePlaybook } from "./playbook.ts";
|
|
8
8
|
import { nextBatch } from "./review.ts";
|
|
9
9
|
import { hasSecret } from "./secrets.ts";
|
|
10
10
|
import type { MemoryStore } from "./store.ts";
|
|
@@ -31,16 +31,18 @@ async function ask(ctx: ExtensionContext, chosen: Mastermind, systemPrompt: stri
|
|
|
31
31
|
).result();
|
|
32
32
|
// Not the provider's message, as some repeat part of the request, and that's your conversations.
|
|
33
33
|
if (response.stopReason === "error" || response.stopReason === "aborted") throw new Error(`${chosen.model} failed (${response.stopReason})`);
|
|
34
|
+
if (response.stopReason === "length") throw new Error(`${chosen.model} ran out of room before it finished, so nothing was marked as reviewed. Try a lower reasoning level in /router.`);
|
|
34
35
|
return response.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/** The model has one chance to fit the limit. After that it's yours to trim in the editor. */
|
|
38
39
|
async function propose(ctx: ExtensionContext, chosen: Mastermind, system: string, text: string, allowed: ReadonlySet<string>): Promise<Map<string, string>> {
|
|
39
|
-
const first =
|
|
40
|
+
const first = readProposals(await ask(ctx, chosen, system, text), allowed);
|
|
40
41
|
if (![...first.values()].some((doc) => doc.length > PLAYBOOK_CHARS)) return first;
|
|
41
42
|
const again = `${text}\n\nYour last answer went over the limit of ${PLAYBOOK_CHARS} characters:\n\n${[...first].map(([scope, doc]) => `<playbook scope="${scope}">${doc}</playbook>`).join("\n")}\n\nAnswer again within the limit.`;
|
|
42
43
|
const second = parseProposals(await ask(ctx, chosen, system, again), allowed);
|
|
43
|
-
|
|
44
|
+
// It may answer again for only the ones that were too long.
|
|
45
|
+
return new Map([...first, ...second]);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function why(text: string): string | undefined {
|
|
@@ -82,6 +82,13 @@ export function compactPrompt(playbooks: Map<string, string>): string {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** What the model proposes, by scope. Only scopes it was given can come back, so it can't write to any other folder. */
|
|
85
|
+
/** A reply that's neither rules nor NONE was cut off or went wrong, and must not count as having nothing to say. */
|
|
86
|
+
export function readProposals(reply: string, allowed: ReadonlySet<string>): Map<string, string> {
|
|
87
|
+
const proposals = parseProposals(reply, allowed);
|
|
88
|
+
if (proposals.size === 0 && !/\bNONE\W*$/.test(reply)) throw new Error("Its answer couldn't be read, so nothing was marked as reviewed. Try again.");
|
|
89
|
+
return proposals;
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
export function parseProposals(reply: string, allowed: ReadonlySet<string>): Map<string, string> {
|
|
86
93
|
const proposals = new Map<string, string>();
|
|
87
94
|
for (const match of reply.matchAll(/<playbook scope="([^"]*)">([\s\S]*?)<\/playbook>/g)) {
|
package/packages/modes/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Requires Pi 1.5 or newer.
|
|
|
23
23
|
1. Install it:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
pi install npm:@huseynsnmz/pi-suite@0.2.
|
|
26
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.5
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
2. Move pi-suite 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.
|
|
@@ -122,9 +122,9 @@ In `auto`, `edit` and `write` on files in the project skip the judge, so you are
|
|
|
122
122
|
|
|
123
123
|
`/review-permissions` also reads the judge's decisions from the review log, and says why `auto` keeps asking.
|
|
124
124
|
|
|
125
|
-
**Moving around:** bash keeps its folder, as in Claude Code. A `cd` at the start of a command carries over to later commands while it stays in the project, and the command's result tells the model so. A move outside runs once, and then the result says `Shell cwd was reset`. Later commands run as `cd <folder> && <command>`, which the permission system follows, so paths are checked from the right place. A `cd` it can't follow, such as `cd "$DIR"`, works for that command only. `read`, `edit` and `write` paths don't move.
|
|
125
|
+
**Moving around:** bash keeps its folder, as in Claude Code. A `cd` at the start of a command carries over to later commands while it stays in the project, and the command's result tells the model so. A move outside runs once, and then the result says `Shell cwd was reset`. The folder is saved in the session, so it's still there after `/reload` or `/resume`, and moving in `/tree` goes back to the folder that point was in. A folder that isn't there fails as it would in a shell, unless the model is repeating the move it just made. Later commands run as `cd <folder> && <command>`, which the permission system follows, so paths are checked from the right place. A `cd` it can't follow, such as `cd "$DIR"`, works for that command only. `read`, `edit` and `write` paths don't move.
|
|
126
126
|
|
|
127
|
-
The permission system judges each part of a chain, so with `"*": "ask"`, every `cd x && go test` would ask about the `cd`. The first time you start Pi in a project, pi-modes allows `cd` inside that project in your bash rules, keeps a backup, and tells you. The rest of the command is still checked, and so is a `cd` anywhere else
|
|
127
|
+
The permission system judges each part of a chain, so with `"*": "ask"`, every `cd x && go test` would ask about the `cd`. The first time you start Pi in a project, pi-modes allows `cd` inside that project in your bash rules, keeps a backup, and tells you. The rest of the command is still checked, and so is a `cd` anywhere else, one it can't read, such as `cd "$DIR"`, or one that leaves through `..` or a symlink. A blanket `"cd *": "allow"` is not the same: the permission system doesn't follow a new file's name into the folder a `cd` reached, so `cd /elsewhere && touch new` would go unchecked. It won't overwrite a rule of your own, and if you remove the rules, they stay removed. If the permission system loaded before pi-suite, restart Pi once for them to apply. Without a permission judge, add `"agent": "allow"` too, or you'll be asked about each `agent` call.
|
|
128
128
|
|
|
129
129
|
Without a classifier, `auto` behaves like `normal`, and the extension warns you when you switch to it.
|
|
130
130
|
|
|
@@ -9,10 +9,10 @@ import { gitRisk } from "./git-safety.ts";
|
|
|
9
9
|
import { registerInterview } from "./interview.ts";
|
|
10
10
|
import { registerReviewTools } from "./review/tools.ts";
|
|
11
11
|
import { aliasDefinitions, DENY_OFF_PROMPT, withAliases, withoutAliases } from "./deny-off.ts";
|
|
12
|
-
import { addCdRules, registerWorkdir } from "./workdir.ts";
|
|
13
|
-
import { clearGate, hasPermissionSystem, installJudgeGate, JUDGE_NAMES, judgeGated, noteEditPaths, registerYoloLink, setGateCwd, setGateMode, YOLO_LINK } from "./judge-gate.ts";
|
|
12
|
+
import { addCdGuards, addCdRules, registerWorkdir } from "./workdir.ts";
|
|
13
|
+
import { clearGate, hasPermissionSystem, installJudgeGate, JUDGE_NAMES, judgeGated, noteEditPaths, registerYoloLink, setGateAgentOurs, setGateCwd, setGateMode, YOLO_LINK } from "./judge-gate.ts";
|
|
14
14
|
import { readSection, writeSection } from "../../../src/settings.ts";
|
|
15
|
-
import { decideToolCall, displayCommand, fromExpectedPackage, notReadOnly, isMode, scriptedChange, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, resumedMode, startingMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
|
|
15
|
+
import { AGENT_TOOL, decideToolCall, displayCommand, fromExpectedPackage, notReadOnly, isMode, scriptedChange, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, resumedMode, startingMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
|
|
16
16
|
|
|
17
17
|
const STATUS_KEY = "pi-modes";
|
|
18
18
|
const DENY_STATUS_KEY = "pi-modes-deny";
|
|
@@ -108,7 +108,7 @@ function restoredMode(ctx: ExtensionContext): Mode {
|
|
|
108
108
|
const saved: unknown = (entry.data as { mode?: unknown } | undefined)?.mode;
|
|
109
109
|
if (isMode(saved)) mode = saved;
|
|
110
110
|
}
|
|
111
|
-
if (mode === undefined && process.env.
|
|
111
|
+
if (mode === undefined && process.env.PI_SUBAGENT_CHILD === "1") return startingMode(process.env.PI_MODES_START); // Its parent's say, not the default.
|
|
112
112
|
return mode === undefined ? startingMode(readConfig().defaultMode) : resumedMode(mode);
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -119,7 +119,9 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
119
119
|
const project = realpathSync(process.cwd());
|
|
120
120
|
const cdFolders = readConfig().cdRuleFolders;
|
|
121
121
|
const handled = Array.isArray(cdFolders) ? (cdFolders as string[]) : [];
|
|
122
|
-
const
|
|
122
|
+
const isChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
123
|
+
if (!isChild && handled.includes(project)) addCdGuards(getAgentDir(), project);
|
|
124
|
+
const cdRule = isChild || handled.includes(project) ? { handled: false } : addCdRules(getAgentDir(), project);
|
|
123
125
|
if (cdRule.handled) writeSection(getAgentDir(), "modes", { ...readConfig(), cdRuleFolders: [...handled, project] });
|
|
124
126
|
let cdNotice = cdRule.notice;
|
|
125
127
|
|
|
@@ -148,6 +150,8 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
148
150
|
if (next !== "yolo") denyOff = false;
|
|
149
151
|
setGateMode(ctx.sessionManager.getSessionId(), next);
|
|
150
152
|
setGateCwd(ctx.sessionManager.getSessionId(), ctx.cwd);
|
|
153
|
+
const agentTool = pi.getAllTools().find((tool) => tool.name === AGENT_TOOL);
|
|
154
|
+
setGateAgentOurs(ctx.sessionManager.getSessionId(), agentTool !== undefined && fromExpectedPackage(AGENT_TOOL, agentTool.sourceInfo.path));
|
|
151
155
|
let others = withoutAliases(pi.getActiveTools().filter((name) => name !== SUBMIT_PLAN_TOOL), replacedTools);
|
|
152
156
|
replacedTools = [];
|
|
153
157
|
if (denyOff) ({ active: others, replaced: replacedTools } = withAliases(others));
|
|
@@ -39,6 +39,8 @@ interface SessionState {
|
|
|
39
39
|
mode: Mode;
|
|
40
40
|
judgeGated: boolean;
|
|
41
41
|
cwd?: string;
|
|
42
|
+
/** Whether the `agent` tool is pi-suite's own. Another package's could do anything. */
|
|
43
|
+
agentIsOurs?: boolean;
|
|
42
44
|
/** Where the model's latest edits and writes go, by call id, as an ask doesn't say. */
|
|
43
45
|
editPaths?: Map<string, string>;
|
|
44
46
|
}
|
|
@@ -46,6 +48,12 @@ interface SessionState {
|
|
|
46
48
|
/** Inside a project, but they run code or change what Pi does, so the judge still looks. */
|
|
47
49
|
const GUARDED = new Set([".git", ".pi", ".claude", ".husky", ".github", ".vscode", ".envrc"]);
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Pi's edit and write tools rewrite these before opening the file, so the path as written isn't the one written to.
|
|
53
|
+
* The judge sees them rather than this copying Pi's rules, which could drift.
|
|
54
|
+
*/
|
|
55
|
+
const REWRITTEN_BY_PI = /^@|^file:|[\u00A0\u2000-\u200A\u202F\u205F\u3000]/;
|
|
56
|
+
|
|
49
57
|
/** The path with symlinks followed, as far as it exists. Undefined for a link that leads nowhere. */
|
|
50
58
|
function realTarget(path: string): string | undefined {
|
|
51
59
|
if (existsSync(path)) return realpathSync(path);
|
|
@@ -63,19 +71,20 @@ function realTarget(path: string): string | undefined {
|
|
|
63
71
|
/** An edit or write to an ordinary file in the project: what `auto` lets through without asking anyone. */
|
|
64
72
|
export function isProjectEdit(details: unknown, cwd: string | undefined): boolean {
|
|
65
73
|
const { toolName, path } = (details ?? {}) as { toolName?: unknown; path?: unknown };
|
|
66
|
-
if ((toolName !== "edit" && toolName !== "write") || typeof path !== "string" || cwd === undefined) return false;
|
|
74
|
+
if ((toolName !== "edit" && toolName !== "write") || typeof path !== "string" || cwd === undefined || REWRITTEN_BY_PI.test(path)) return false;
|
|
67
75
|
const expanded = path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
68
76
|
const target = realTarget(resolve(cwd, expanded));
|
|
69
77
|
const root = realTarget(cwd);
|
|
70
78
|
if (target === undefined || root === undefined) return false;
|
|
71
79
|
const inside = relative(root, target);
|
|
72
80
|
if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return false;
|
|
73
|
-
|
|
81
|
+
// Lowercase, as macOS finds `.GIT` at `.git` by default.
|
|
82
|
+
return !inside.split(sep).some((part) => GUARDED.has(part.toLowerCase()));
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
/** It does nothing by itself: what the agent then does is still checked. */
|
|
77
|
-
function needsNoAsk(details: unknown): boolean {
|
|
78
|
-
return (details as { toolName?: unknown } | undefined)?.toolName === AGENT_TOOL;
|
|
86
|
+
function needsNoAsk(state: SessionState, details: unknown): boolean {
|
|
87
|
+
return state.agentIsOurs === true && (details as { toolName?: unknown } | undefined)?.toolName === AGENT_TOOL;
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
function autoAllows(state: SessionState, details: unknown, log: unknown): boolean {
|
|
@@ -113,7 +122,7 @@ function gateService(store: Store, sessionId: string, service: ServiceLike): voi
|
|
|
113
122
|
stateFor(store, sessionId).judgeGated = true;
|
|
114
123
|
return register(name, async (details, query, log) => {
|
|
115
124
|
const state = stateFor(store, sessionId);
|
|
116
|
-
if (needsNoAsk(details)) return { kind: "allow" };
|
|
125
|
+
if (needsNoAsk(state, details)) return { kind: "allow" };
|
|
117
126
|
if (state.mode !== "auto") return { kind: "defer" };
|
|
118
127
|
return autoAllows(state, details, log) ? { kind: "allow" } : authorize(details, query, log);
|
|
119
128
|
});
|
|
@@ -146,7 +155,7 @@ export function registerYoloLink(sessionId: string, store: Store = globalThis as
|
|
|
146
155
|
if (typeof service?.registerAuthorizer !== "function") return undefined;
|
|
147
156
|
return service.registerAuthorizer(YOLO_LINK, async (details, _query, log) => {
|
|
148
157
|
const state = stateFor(store, sessionId);
|
|
149
|
-
return needsNoAsk(details) || state.mode === "yolo" || autoAllows(state, details, log) ? { kind: "allow" } : { kind: "defer" };
|
|
158
|
+
return needsNoAsk(state, details) || state.mode === "yolo" || autoAllows(state, details, log) ? { kind: "allow" } : { kind: "defer" };
|
|
150
159
|
});
|
|
151
160
|
}
|
|
152
161
|
|
|
@@ -175,6 +184,10 @@ export function setGateCwd(sessionId: string, cwd: string, store: Store = global
|
|
|
175
184
|
stateFor(store, sessionId).cwd = cwd;
|
|
176
185
|
}
|
|
177
186
|
|
|
187
|
+
export function setGateAgentOurs(sessionId: string, ours: boolean, store: Store = globalThis as unknown as Store): void {
|
|
188
|
+
stateFor(store, sessionId).agentIsOurs = ours;
|
|
189
|
+
}
|
|
190
|
+
|
|
178
191
|
export function judgeGated(sessionId: string, store: Store = globalThis as unknown as Store): boolean {
|
|
179
192
|
return stateFor(store, sessionId).judgeGated;
|
|
180
193
|
}
|
|
@@ -17,6 +17,7 @@ const REFUSED_SURFACES = new Set(["bash", "path", "external_directory", "mcp", "
|
|
|
17
17
|
export function refuseBash(pattern) {
|
|
18
18
|
const text = pattern.trim();
|
|
19
19
|
if (text === "" || text.startsWith("*") || text.startsWith("?")) return "it matches any command";
|
|
20
|
+
if (text === "cd*") return "it also matches cdk and other commands, so write `cd *`";
|
|
20
21
|
if (/[\n;&|`$<>()]/.test(text)) return "it holds shell syntax, and a rule matches one command";
|
|
21
22
|
const words = text.split(/\s+/);
|
|
22
23
|
const bare = words.length === 1 && text.endsWith("*") ? text.slice(0, -1) : words.length === 2 && words[1] === "*" ? words[0] : undefined;
|
|
@@ -36,15 +37,18 @@ export function refuseTool(tool) {
|
|
|
36
37
|
* Rules are order-dependent and the last match wins, so new allows go straight after the
|
|
37
38
|
* catch-all. Every existing `deny` or `ask` comes later and keeps overriding them.
|
|
38
39
|
*/
|
|
39
|
-
export function withBashAllows(rules, patterns) {
|
|
40
|
+
export function withBashAllows(rules, patterns, guards = []) {
|
|
40
41
|
const entries = Object.entries(rules ?? { "*": "ask" });
|
|
41
42
|
const fresh = patterns.filter((pattern) => !entries.some(([existing]) => existing === pattern));
|
|
42
43
|
const at = entries[0]?.[0] === "*" ? 1 : 0;
|
|
43
44
|
const next = [...entries.slice(0, at), ...fresh.map((pattern) => [pattern, "allow"]), ...entries.slice(at)];
|
|
45
|
+
// Guards ask about part of what those allows match, so they go after them, wherever they are.
|
|
46
|
+
const last = Math.max(...patterns.map((pattern) => next.findIndex(([existing]) => existing === pattern)));
|
|
47
|
+
next.splice(last + 1, 0, ...guards.filter((pattern) => !entries.some(([existing]) => existing === pattern)).map((pattern) => [pattern, "ask"]));
|
|
44
48
|
return Object.fromEntries(next);
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
export function applyRules(config, { bash = [], tools = [] }) {
|
|
51
|
+
export function applyRules(config, { bash = [], tools = [], guards = [] }) {
|
|
48
52
|
const refusals = [
|
|
49
53
|
...bash.flatMap((pattern) => {
|
|
50
54
|
const why = refuseBash(pattern);
|
|
@@ -60,7 +64,8 @@ export function applyRules(config, { bash = [], tools = [] }) {
|
|
|
60
64
|
if (bash.length > 0) {
|
|
61
65
|
if (permission.bash !== undefined && (typeof permission.bash !== "object" || permission.bash === null))
|
|
62
66
|
return { refusals: [`bash: its rule is the single word "${permission.bash}", so convert it to a map by hand`] };
|
|
63
|
-
|
|
67
|
+
// With no bash rules, bash follows the catch-all, so a new map has to start from it.
|
|
68
|
+
permission.bash = withBashAllows(permission.bash ?? { "*": permission["*"] ?? "ask" }, bash, guards);
|
|
64
69
|
}
|
|
65
70
|
for (const tool of tools) if (permission[tool] !== "deny") permission[tool] = "allow";
|
|
66
71
|
return { config: { ...config, permission }, refusals: [] };
|
|
@@ -121,13 +126,13 @@ function writeWithBackup(path, config) {
|
|
|
121
126
|
return backup;
|
|
122
127
|
}
|
|
123
128
|
|
|
124
|
-
function planChoices(agent, { bash = [], tools = [], read = [], write = [], domains = [] }) {
|
|
129
|
+
function planChoices(agent, { bash = [], tools = [], guards = [], read = [], write = [], domains = [] }) {
|
|
125
130
|
const path = join(agent, "extensions", "pi-permission-system", "config.json");
|
|
126
131
|
const sandboxPath = join(agent, "sandbox.json");
|
|
127
132
|
const wantsRules = bash.length > 0 || tools.length > 0;
|
|
128
133
|
const wantsSandbox = read.length > 0 || write.length > 0 || domains.length > 0;
|
|
129
134
|
if (wantsRules && !existsSync(path)) return { refusals: [`No config at ${path}.`] };
|
|
130
|
-
const rules = wantsRules ? applyRules(JSON.parse(readFileSync(path, "utf8")), { bash, tools }) : { refusals: [] };
|
|
135
|
+
const rules = wantsRules ? applyRules(JSON.parse(readFileSync(path, "utf8")), { bash, tools, guards }) : { refusals: [] };
|
|
131
136
|
let sandboxConfig = {};
|
|
132
137
|
if (wantsSandbox && existsSync(sandboxPath)) {
|
|
133
138
|
try {
|
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import {
|
|
10
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { applyChoices, checkChoices } from "./review/apply.mjs";
|
|
12
12
|
import { ruleFor } from "./review/collect.mjs";
|
|
13
13
|
|
|
14
14
|
const LEADING_CD = /^\s*cd\s+(?:'([^']*)'|"([^"$`\\]*)"|([^\s;&|`$<>()'"\\]+))(?=\s*(?:&&|;|\n|$))/;
|
|
15
|
+
/** A cd anywhere in a chain, with a path that's plain text. */
|
|
16
|
+
const ANY_CD = /(^|&&|\|\||[;|\n({]|\b(?:then|do|else)\b)(\s*cd\s+)(?:'([^']*)'|"([^"$`\\]*)"|([^\s;&|`$<>()'"\\]+))(?=\s*(?:&&|\|\||[;|\n)}]|$))/g;
|
|
17
|
+
const WORKDIR_ENTRY = "pi-modes-workdir";
|
|
18
|
+
const PLAIN = /^[\w@%+=:,./-]+$/;
|
|
15
19
|
/** Bare when it can be, as a rule like `cd /work/app/*` only matches the path as written. */
|
|
16
|
-
const quoted = (text: string) => (
|
|
20
|
+
const quoted = (text: string) => (PLAIN.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`);
|
|
17
21
|
|
|
18
22
|
function folderAt(path: string): string | undefined {
|
|
19
23
|
try {
|
|
@@ -23,24 +27,41 @@ function folderAt(path: string): string | undefined {
|
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Where a cd goes. A model often repeats a move it already made, so a path that isn't here counts if it's how the
|
|
32
|
+
* current folder ends. Nothing else is guessed: `cd build && rm -rf *` has to fail if there's no build folder here.
|
|
33
|
+
*/
|
|
27
34
|
export function cdTarget(root: string, here: string | undefined, target: string): string | undefined {
|
|
28
35
|
if (target === "-") return undefined;
|
|
29
36
|
const path = target === "~" || target.startsWith("~/") ? join(homedir(), target.slice(1)) : target;
|
|
30
37
|
const from = here ?? root;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return undefined;
|
|
38
|
+
const folder = folderAt(resolve(from, path));
|
|
39
|
+
if (folder !== undefined) return folder;
|
|
40
|
+
const repeat = normalize(`${path}/`).slice(0, -1);
|
|
41
|
+
const below = relative(root, from);
|
|
42
|
+
return below === repeat || below.endsWith(`${sep}${repeat}`) ? from : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A cd rule for the project also matches `cd <root>/link` and `cd <root>/../x`, which leave it. Written as where they
|
|
47
|
+
* really go, the rule no longer matches, and the permission system asks.
|
|
48
|
+
*/
|
|
49
|
+
export function realCds(root: string, command: string): string {
|
|
50
|
+
return command.replace(ANY_CD, (all, before: string, cd: string, single?: string, double?: string, bare?: string) => {
|
|
51
|
+
const path = single ?? double ?? bare ?? "";
|
|
52
|
+
if (!path.startsWith(`${root}/`)) return all;
|
|
53
|
+
const real = folderAt(path) ?? resolve(path);
|
|
54
|
+
return real === path ? all : `${before}${cd}${quoted(real)}`;
|
|
55
|
+
});
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
/** The command to really run, and the folder it moves to if it starts with a cd. */
|
|
39
59
|
export function planCommand(root: string, here: string | undefined, command: string): { command: string; move?: string } {
|
|
40
60
|
const match = LEADING_CD.exec(command);
|
|
41
61
|
const move = match === null ? undefined : cdTarget(root, here, match[1] ?? match[2] ?? match[3] ?? "");
|
|
42
|
-
if (match !== null && move !== undefined) return { command: `cd ${quoted(move)}${command.slice(match[0].length)}
|
|
43
|
-
|
|
62
|
+
if (match !== null && move !== undefined) return { command: realCds(root, `cd ${quoted(move)}${command.slice(match[0].length)}`), move };
|
|
63
|
+
const rest = realCds(root, command);
|
|
64
|
+
return { command: here === undefined ? rest : `cd ${quoted(here)} && ${rest}` };
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
export function isInside(root: string, folder: string): boolean {
|
|
@@ -50,7 +71,30 @@ export function isInside(root: string, folder: string): boolean {
|
|
|
50
71
|
|
|
51
72
|
/** A cd inside the project, as typed or as its full path. Nothing shorter is safe: the permission system doesn't follow a new file's name into the folder a cd reached. */
|
|
52
73
|
export function cdRules(root: string): string[] {
|
|
53
|
-
return [`cd ${root}`, `cd ${root}
|
|
74
|
+
return [`cd ${word(root)}`, `cd ${word(root, "/*")}`];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What the rules above match but can't vouch for: a way out of the project, or text the shell turns into a path. */
|
|
78
|
+
export function cdGuards(root: string): string[] {
|
|
79
|
+
return ["..", "$", "`", "\\"].map((risky) => `cd ${word(root, `/*${risky}*`)}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The folder as bash gets it, as a rule only matches what's written. */
|
|
83
|
+
function word(root: string, tail = ""): string {
|
|
84
|
+
return PLAIN.test(root) ? `${root}${tail}` : `'${`${root}${tail}`.replaceAll("'", "'\\''")}'`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Rules added before the guards existed get them. Nothing else changes, and removed rules stay removed. */
|
|
88
|
+
export function addCdGuards(agentDir: string, root: string): void {
|
|
89
|
+
const path = join(agentDir, "extensions", "pi-permission-system", "config.json");
|
|
90
|
+
try {
|
|
91
|
+
const bash = JSON.parse(readFileSync(path, "utf8")).permission?.bash;
|
|
92
|
+
const rules = cdRules(root);
|
|
93
|
+
if (typeof bash !== "object" || bash === null || bash[rules[1] as string] !== "allow" || cdGuards(root).every((guard) => guard in bash)) return;
|
|
94
|
+
applyChoices(agentDir, { bash: rules, guards: cdGuards(root) });
|
|
95
|
+
} catch {
|
|
96
|
+
// Not ours to fix. It's tried again next start.
|
|
97
|
+
}
|
|
54
98
|
}
|
|
55
99
|
|
|
56
100
|
/**
|
|
@@ -62,25 +106,37 @@ export function addCdRules(agentDir: string, root: string): { handled: boolean;
|
|
|
62
106
|
const path = join(agentDir, "extensions", "pi-permission-system", "config.json");
|
|
63
107
|
if (root === "/" || !existsSync(path)) return { handled: false };
|
|
64
108
|
try {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
109
|
+
const permission = JSON.parse(readFileSync(path, "utf8")).permission ?? {};
|
|
110
|
+
const { bash } = permission;
|
|
111
|
+
// With no bash rules, bash follows the catch-all, which is ask when that isn't set either.
|
|
112
|
+
const asks = bash === undefined ? (permission["*"] ?? "ask") === "ask" : typeof bash === "object" && bash !== null && ruleFor(bash, `cd ${root}/x`) === "ask";
|
|
67
113
|
if (!asks) return { handled: true };
|
|
68
114
|
const rules = cdRules(root);
|
|
69
115
|
if (checkChoices(agentDir, { bash: rules }).length > 0) return { handled: true }; // A path a rule can't hold.
|
|
70
|
-
applyChoices(agentDir, { bash: rules });
|
|
116
|
+
applyChoices(agentDir, { bash: rules, guards: cdGuards(root) });
|
|
71
117
|
return { handled: true, notice: `pi-modes allowed cd inside this project in your bash rules, so you aren't asked about each one. The rest of each command, and a cd anywhere else, are still checked. Remove them from ${path} if you'd rather be asked.` };
|
|
72
118
|
} catch {
|
|
73
119
|
return { handled: false }; // Not ours to fix. It's tried again next start.
|
|
74
120
|
}
|
|
75
121
|
}
|
|
76
122
|
|
|
123
|
+
/** The folder this point of the session was in, so what the model was told stays true after a reload, a resume or a move in the tree. */
|
|
124
|
+
export function savedFolder(root: string, entries: readonly { type: string; customType?: string; data?: unknown }[]): string | undefined {
|
|
125
|
+
let saved: unknown;
|
|
126
|
+
for (const entry of entries) if (entry.type === "custom" && entry.customType === WORKDIR_ENTRY) saved = (entry.data as { folder?: unknown } | undefined)?.folder;
|
|
127
|
+
if (typeof saved !== "string" || saved === root || !isInside(root, saved)) return undefined;
|
|
128
|
+
return folderAt(saved) === saved ? saved : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
77
131
|
export function registerWorkdir(pi: ExtensionAPI) {
|
|
78
132
|
let here: string | undefined;
|
|
79
133
|
const moves = new Map<string, string>();
|
|
80
|
-
|
|
81
|
-
here =
|
|
134
|
+
const restore = (_event: unknown, ctx: ExtensionContext) => {
|
|
135
|
+
here = savedFolder(realpathSync(ctx.cwd), ctx.sessionManager.getBranch());
|
|
82
136
|
moves.clear();
|
|
83
|
-
}
|
|
137
|
+
};
|
|
138
|
+
pi.on("session_start", restore);
|
|
139
|
+
pi.on("session_tree", restore);
|
|
84
140
|
pi.on("tool_result", (event, ctx) => {
|
|
85
141
|
const move = moves.get(event.toolCallId);
|
|
86
142
|
if (move === undefined) return undefined;
|
|
@@ -88,7 +144,9 @@ export function registerWorkdir(pi: ExtensionAPI) {
|
|
|
88
144
|
if (event.isError) return undefined;
|
|
89
145
|
const root = realpathSync(ctx.cwd);
|
|
90
146
|
const stays = isInside(root, move);
|
|
91
|
-
|
|
147
|
+
const next = stays && move !== root ? move : undefined;
|
|
148
|
+
if (next !== here) pi.appendEntry(WORKDIR_ENTRY, { folder: next ?? null });
|
|
149
|
+
here = next;
|
|
92
150
|
// Told here rather than in the system prompt, which would cost every request and replace the subscription part's prompt.
|
|
93
151
|
const note = stays ? `Bash stays in ${move} for later commands, so don't repeat the cd.` : `Shell cwd was reset to ${root}`;
|
|
94
152
|
return { content: [...event.content, { type: "text" as const, text: `\n${note}` }] };
|
|
@@ -9,7 +9,7 @@ Adds `router/auto` to [Pi](https://pi.dev): one choice in `/model` that uses a s
|
|
|
9
9
|
Requires Pi 1.5 or newer.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
pi install npm:@huseynsnmz/pi-suite@0.2.
|
|
12
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.5
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Log in to the providers you want with `/login`, run `/router` to choose a model for each role, then pick `router/auto` in `/model`. It's always listed there, and until `plan` and `build` are set it stops with a message telling you to run `/router`.
|
|
@@ -19,9 +19,15 @@ export function childArgs({ type, model, thinking }: ChildOptions): string[] {
|
|
|
19
19
|
return ["-p", "--no-session", ...(model === undefined ? [] : ["--model", model]), ...(thinking === undefined ? [] : ["--thinking", thinking]), ...(type === "explore" ? ["--tools", READ_ONLY_TOOLS.join(",")] : [])];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** The mode a child runs in. Plan's only way out is a person, so its explore agents just read, and yolo has to be asked for. */
|
|
23
|
+
function childMode(mode: string): string {
|
|
24
|
+
if (mode === "plan") return "read-only";
|
|
25
|
+
return mode === "yolo" ? "normal" : mode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** How the permission system and pi-router know this is a child, and pi-modes knows its mode. */
|
|
23
29
|
export function childEnv(base: NodeJS.ProcessEnv, parentSession: string, mode: string | undefined): NodeJS.ProcessEnv {
|
|
24
|
-
return { ...base, PI_SUBAGENT_CHILD: "1", PI_SUBAGENT_PARENT_SESSION: parentSession, ...(mode ===
|
|
30
|
+
return { ...base, PI_SUBAGENT_CHILD: "1", PI_SUBAGENT_PARENT_SESSION: parentSession, ...(mode === undefined ? {} : { PI_MODES_START: childMode(mode) }) };
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export function trimAnswer(answer: string): string {
|
|
@@ -29,14 +35,14 @@ export function trimAnswer(answer: string): string {
|
|
|
29
35
|
return text.length > ANSWER_CHARS ? `${text.slice(0, ANSWER_CHARS)}\n[The answer was cut at ${ANSWER_CHARS} characters.]` : text;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
function runChild(cwd: string,
|
|
38
|
+
export function runChild(cwd: string, command: string[], env: NodeJS.ProcessEnv, prompt: string, signal: AbortSignal | undefined): Promise<string> {
|
|
33
39
|
return new Promise((resolve, reject) => {
|
|
34
|
-
// Pi may run from a compiled binary, with no script to name.
|
|
35
|
-
const script = process.argv[1];
|
|
36
|
-
const command = script !== undefined && existsSync(script) ? [script, ...args] : args;
|
|
37
40
|
const child = spawn(process.execPath, command, { cwd, env, stdio: ["pipe", "pipe", "pipe"], signal });
|
|
38
41
|
let out = "";
|
|
39
42
|
let err = "";
|
|
43
|
+
// A character can be split across two chunks.
|
|
44
|
+
child.stdout.setEncoding("utf8");
|
|
45
|
+
child.stderr.setEncoding("utf8");
|
|
40
46
|
child.stdout.on("data", (data) => (out += data));
|
|
41
47
|
child.stderr.on("data", (data) => (err = (err + data).slice(-ERROR_CHARS)));
|
|
42
48
|
child.stdin.on("error", () => {}); // It can exit before reading. Its exit code says why.
|
|
@@ -69,7 +75,10 @@ export default function piSubagent(pi: ExtensionAPI): void {
|
|
|
69
75
|
pi.events.emit("pi-modes:mode", probe);
|
|
70
76
|
const model = ctx.model === undefined ? undefined : `${ctx.model.provider}/${ctx.model.id}`;
|
|
71
77
|
const args = childArgs({ type: params.type ?? "explore", model, thinking: pi.getThinkingLevel() });
|
|
72
|
-
|
|
78
|
+
// Pi may run from a compiled binary, with no script to name.
|
|
79
|
+
const script = process.argv[1];
|
|
80
|
+
const command = script !== undefined && existsSync(script) ? [script, ...args] : args;
|
|
81
|
+
const answer = await runChild(ctx.cwd, command, childEnv(process.env, ctx.sessionManager.getSessionId(), probe.mode), params.prompt, signal);
|
|
73
82
|
return { content: [{ type: "text" as const, text: answer }], details: {} };
|
|
74
83
|
},
|
|
75
84
|
});
|
package/packages/web/README.md
CHANGED
|
@@ -9,7 +9,7 @@ A web page can hold instructions aimed at the AI reading it. So a separate model
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
pi install npm:@huseynsnmz/pi-suite@0.2.
|
|
12
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.5
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
`web_fetch` works straight away. It reads pages with the session's model, unless you set `readerModel` or use [pi-router](https://github.com/huseynsnmz/pi-suite/tree/main/packages/router), which can choose the reader. `web_search` stays off until you choose where searches go.
|