@illusoryai/pi-orchestration-guard 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/README.md +18 -0
- package/extensions/orchestrator-guard.ts +319 -0
- package/install.mjs +62 -0
- package/package.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @illusoryai/pi-guard
|
|
2
|
+
|
|
3
|
+
Orchestrator mode enforcement — restricts bash commands to read-only operations in orchestrator mode.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
| Type | Name | Description |
|
|
8
|
+
|------|------|-------------|
|
|
9
|
+
| Extension | `orchestrator-guard.ts` | Blocks write commands (mkdir, cp, rm, etc.) in orchestrator mode |
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:@illusoryai/pi-guard
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Optional — only for projects requiring strict orchestrator/implement mode separation.
|
|
18
|
+
Private package — requires npm auth configured in `~/.npmrc`.
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator Guard Extension
|
|
3
|
+
*
|
|
4
|
+
* Enforces the orchestrator/worker role boundary. In orchestrator mode (default),
|
|
5
|
+
* blocks direct code implementation — the model must delegate via subagents.
|
|
6
|
+
*
|
|
7
|
+
* Modes:
|
|
8
|
+
* - Orchestrator (default): read-only + subagent delegation. edit/write blocked.
|
|
9
|
+
* - Implement: full tool access for direct implementation.
|
|
10
|
+
*
|
|
11
|
+
* Commands:
|
|
12
|
+
* /implement [reason] — switch to implement mode
|
|
13
|
+
* /orchestrate — switch back to orchestrator mode
|
|
14
|
+
*
|
|
15
|
+
* Also enforces always-on forbidden commands (e.g., git push origin main).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
// --- Forbidden patterns (blocked in ALL modes) ---
|
|
21
|
+
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { execSync as execSyncNode } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
function isGraphiteRepo(): boolean {
|
|
26
|
+
try {
|
|
27
|
+
const root = execSyncNode("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3000 }).trim();
|
|
28
|
+
return existsSync(`${root}/.graphite_id`);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ALWAYS_FORBIDDEN: { pattern: RegExp; reason: string; check?: () => boolean }[] = [
|
|
35
|
+
{
|
|
36
|
+
pattern: /\bgit\s+push\s+(?:--[^\s]+\s+)*origin\s+main\b/,
|
|
37
|
+
reason: "Direct push to main is forbidden. Use Graphite PRs (gt submit).",
|
|
38
|
+
check: isGraphiteRepo,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
pattern: /\bgit\s+push\s+(?:--[^\s]+\s+)*origin\s+master\b/,
|
|
42
|
+
reason: "Direct push to master is forbidden. Use Graphite PRs (gt submit).",
|
|
43
|
+
check: isGraphiteRepo,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// --- Orchestrator mode: bash commands that are allowed (read-only / diagnostic) ---
|
|
48
|
+
|
|
49
|
+
const SAFE_BASH_PATTERNS: RegExp[] = [
|
|
50
|
+
// Git read-only
|
|
51
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|stash\s+list|worktree\s+list|rev-parse|ls-files|blame)/,
|
|
52
|
+
/^\s*git\s+worktree\s+(list|prune)/,
|
|
53
|
+
// Graphite read-only
|
|
54
|
+
/^\s*gt\s+(log|status|ls|branch|trunk|info)/,
|
|
55
|
+
// File exploration
|
|
56
|
+
/^\s*ls\b/,
|
|
57
|
+
/^\s*cat\b/,
|
|
58
|
+
/^\s*head\b/,
|
|
59
|
+
/^\s*tail\b/,
|
|
60
|
+
/^\s*wc\b/,
|
|
61
|
+
/^\s*find\b/,
|
|
62
|
+
/^\s*grep\b/,
|
|
63
|
+
/^\s*rg\b/,
|
|
64
|
+
/^\s*fd\b/,
|
|
65
|
+
/^\s*tree\b/,
|
|
66
|
+
/^\s*file\b/,
|
|
67
|
+
/^\s*stat\b/,
|
|
68
|
+
/^\s*du\b/,
|
|
69
|
+
/^\s*df\b/,
|
|
70
|
+
/^\s*pwd\b/,
|
|
71
|
+
/^\s*which\b/,
|
|
72
|
+
/^\s*echo\b/,
|
|
73
|
+
/^\s*printenv\b/,
|
|
74
|
+
/^\s*env\b/,
|
|
75
|
+
// Path utilities
|
|
76
|
+
/^\s*realpath\b/,
|
|
77
|
+
/^\s*dirname\b/,
|
|
78
|
+
/^\s*basename\b/,
|
|
79
|
+
// Diff (read-only comparison)
|
|
80
|
+
/^\s*diff\b/,
|
|
81
|
+
// Process / system info
|
|
82
|
+
/^\s*ps\b/,
|
|
83
|
+
/^\s*whoami\b/,
|
|
84
|
+
/^\s*hostname\b/,
|
|
85
|
+
/^\s*uname\b/,
|
|
86
|
+
/^\s*date\b/,
|
|
87
|
+
// Cargo read-only
|
|
88
|
+
/^\s*cargo\s+(check|clippy|fmt\s+--check|test\s+--no-run|metadata|tree)/,
|
|
89
|
+
// TypeScript type-check only
|
|
90
|
+
/^\s*tsc\s+--noEmit\b/,
|
|
91
|
+
// npm read-only
|
|
92
|
+
/^\s*npm\s+(ls|list)\b/,
|
|
93
|
+
// Journalctl / systemctl status (read-only)
|
|
94
|
+
/^\s*journalctl\b/,
|
|
95
|
+
/^\s*systemctl\s+status\b/,
|
|
96
|
+
// cd (no side effects)
|
|
97
|
+
/^\s*cd\b/,
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if a bash command is safe (read-only) for orchestrator mode.
|
|
102
|
+
*
|
|
103
|
+
* Note: Uses regex splitting on shell operators (&&, ||, ;, |). Does not handle
|
|
104
|
+
* operators inside quoted strings (e.g., `grep "foo|bar"`). Acceptable for the
|
|
105
|
+
* common case; false positives are blocked and can be delegated via subagents.
|
|
106
|
+
*/
|
|
107
|
+
function findUnsafeBashSegment(command: string): string | undefined {
|
|
108
|
+
const segments = command.split(/\s*(?:&&|\|\||[;|])\s*/).map((s) => s.trim());
|
|
109
|
+
for (const segment of segments) {
|
|
110
|
+
if (!segment) continue;
|
|
111
|
+
if (!SAFE_BASH_PATTERNS.some((p) => p.test(segment))) {
|
|
112
|
+
return segment;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getLastEntry<T>(entries: { type: string; customType?: string; data?: unknown }[], customType: string): T | undefined {
|
|
119
|
+
const entry = entries
|
|
120
|
+
.filter((e) => e.type === "custom" && e.customType === customType)
|
|
121
|
+
.pop();
|
|
122
|
+
return entry?.data as T | undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default function orchestratorGuard(pi: ExtensionAPI) {
|
|
126
|
+
let orchestratorMode = true;
|
|
127
|
+
let implementReason = "";
|
|
128
|
+
|
|
129
|
+
// --- State persistence ---
|
|
130
|
+
|
|
131
|
+
function persistState(): void {
|
|
132
|
+
pi.appendEntry("orchestrator-guard", {
|
|
133
|
+
orchestratorMode,
|
|
134
|
+
implementReason,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- UI helpers ---
|
|
139
|
+
|
|
140
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
141
|
+
if (orchestratorMode) {
|
|
142
|
+
ctx.ui.setStatus(
|
|
143
|
+
"orch-guard",
|
|
144
|
+
ctx.ui.theme.fg("warning", "⏸ orchestrator") + ctx.ui.theme.fg("dim", " (edit/write blocked)"),
|
|
145
|
+
);
|
|
146
|
+
} else {
|
|
147
|
+
ctx.ui.setStatus(
|
|
148
|
+
"orch-guard",
|
|
149
|
+
ctx.ui.theme.fg("success", "⚡ implement") +
|
|
150
|
+
(implementReason ? ctx.ui.theme.fg("dim", ` — ${implementReason}`) : ""),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Commands ---
|
|
156
|
+
|
|
157
|
+
pi.registerCommand("implement", {
|
|
158
|
+
description: "Switch to implement mode (full tool access)",
|
|
159
|
+
handler: async (args, ctx) => {
|
|
160
|
+
if (!orchestratorMode) {
|
|
161
|
+
ctx.ui.notify("Already in implement mode.", "info");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
implementReason = args?.trim() || "";
|
|
165
|
+
orchestratorMode = false;
|
|
166
|
+
updateStatus(ctx);
|
|
167
|
+
persistState();
|
|
168
|
+
ctx.ui.notify(
|
|
169
|
+
`Switched to implement mode.${implementReason ? ` Reason: ${implementReason}` : ""}\nUse /orchestrate to return.`,
|
|
170
|
+
"info",
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
pi.registerCommand("orchestrate", {
|
|
176
|
+
description: "Switch to orchestrator mode (read-only, delegate via subagents)",
|
|
177
|
+
handler: async (_args, ctx) => {
|
|
178
|
+
if (orchestratorMode) {
|
|
179
|
+
ctx.ui.notify("Already in orchestrator mode.", "info");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
orchestratorMode = true;
|
|
183
|
+
implementReason = "";
|
|
184
|
+
updateStatus(ctx);
|
|
185
|
+
persistState();
|
|
186
|
+
ctx.ui.notify("Switched to orchestrator mode. edit/write blocked. Use subagents to delegate.", "info");
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// --- Tool call interception ---
|
|
191
|
+
|
|
192
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
193
|
+
// 1. Always-on forbidden commands (regardless of mode)
|
|
194
|
+
if (event.toolName === "bash") {
|
|
195
|
+
const command = event.input.command as string;
|
|
196
|
+
for (const rule of ALWAYS_FORBIDDEN) {
|
|
197
|
+
if (rule.pattern.test(command) && (!rule.check || rule.check())) {
|
|
198
|
+
if (ctx.hasUI) ctx.ui.notify(`Blocked: ${rule.reason}`, "warning");
|
|
199
|
+
return { block: true, reason: rule.reason };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 2. If in implement mode, allow everything else
|
|
205
|
+
if (!orchestratorMode) return undefined;
|
|
206
|
+
|
|
207
|
+
// 3. Orchestrator mode: block edit and write
|
|
208
|
+
if (event.toolName === "edit") {
|
|
209
|
+
return {
|
|
210
|
+
block: true,
|
|
211
|
+
reason: [
|
|
212
|
+
"BLOCKED: You are in orchestrator mode. Direct file editing is not allowed.",
|
|
213
|
+
"",
|
|
214
|
+
"To make code changes, delegate via subagent:",
|
|
215
|
+
' subagent({ agent: "td-worker", task: "Edit file X to do Y" })',
|
|
216
|
+
"",
|
|
217
|
+
"Or ask the human to switch modes:",
|
|
218
|
+
' "Should I implement this directly? Use /implement to allow."',
|
|
219
|
+
].join("\n"),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (event.toolName === "write") {
|
|
224
|
+
return {
|
|
225
|
+
block: true,
|
|
226
|
+
reason: [
|
|
227
|
+
"BLOCKED: You are in orchestrator mode. Direct file writing is not allowed.",
|
|
228
|
+
"",
|
|
229
|
+
"To create/modify files, delegate via subagent:",
|
|
230
|
+
' subagent({ agent: "td-worker", task: "Create file X with Y" })',
|
|
231
|
+
"",
|
|
232
|
+
"Or ask the human to switch modes:",
|
|
233
|
+
' "Should I implement this directly? Use /implement to allow."',
|
|
234
|
+
].join("\n"),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 4. Orchestrator mode: filter bash commands
|
|
239
|
+
if (event.toolName === "bash") {
|
|
240
|
+
const command = event.input.command as string;
|
|
241
|
+
const unsafeSegment = findUnsafeBashSegment(command);
|
|
242
|
+
if (unsafeSegment) {
|
|
243
|
+
return {
|
|
244
|
+
block: true,
|
|
245
|
+
reason: [
|
|
246
|
+
`BLOCKED: Bash command not allowed in orchestrator mode.`,
|
|
247
|
+
`Command: ${command}`,
|
|
248
|
+
`Unrecognized segment: ${unsafeSegment}`,
|
|
249
|
+
"",
|
|
250
|
+
"Allowed: git read-only, ls, cat, grep, find, diff, cargo check/clippy, tsc --noEmit.",
|
|
251
|
+
"",
|
|
252
|
+
"To run this command, either:",
|
|
253
|
+
' 1. Delegate: subagent({ agent: "td-worker", task: "Run X" })',
|
|
254
|
+
' 2. Switch mode: "/implement to allow direct commands"',
|
|
255
|
+
].join("\n"),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return undefined;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// --- System prompt injection ---
|
|
264
|
+
|
|
265
|
+
pi.on("before_agent_start", async (event) => {
|
|
266
|
+
if (!orchestratorMode) return undefined;
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
systemPrompt:
|
|
270
|
+
(event.systemPrompt || "") +
|
|
271
|
+
`
|
|
272
|
+
|
|
273
|
+
## ⏸ Orchestrator Mode Active
|
|
274
|
+
|
|
275
|
+
You are in ORCHESTRATOR mode. You coordinate and delegate — you do NOT implement directly.
|
|
276
|
+
The edit and write tools are blocked. Modifying bash commands are blocked.
|
|
277
|
+
|
|
278
|
+
### What you CAN do
|
|
279
|
+
- Read files and run diagnostic commands (git status, ls, grep, cargo check)
|
|
280
|
+
- Use the **subagent** tool to delegate tasks to worker agents
|
|
281
|
+
- Use **/run <agent> <task>** to quickly delegate a task
|
|
282
|
+
- Use **/chain** to run multi-step agent pipelines
|
|
283
|
+
- Use obsidian, linkup_web_search, linkup_web_fetch (fetch URLs), and other coordination tools
|
|
284
|
+
- When a URL is provided, use **linkup_web_fetch** first to fetch its content directly — don't search for it
|
|
285
|
+
- Analyze, plan, and present findings to the human
|
|
286
|
+
|
|
287
|
+
### What you CANNOT do (will be blocked)
|
|
288
|
+
- edit or write tools
|
|
289
|
+
- Bash commands that modify files or run builds
|
|
290
|
+
- Any direct code implementation
|
|
291
|
+
|
|
292
|
+
### How to delegate
|
|
293
|
+
Subagents:
|
|
294
|
+
/run td-worker <task description>
|
|
295
|
+
/chain scout -> td-worker
|
|
296
|
+
subagent({ agent: "td-worker", task: "..." })
|
|
297
|
+
|
|
298
|
+
### If the task is trivial
|
|
299
|
+
Tell the human: "This is a small fix I could do directly. Use /implement to allow."
|
|
300
|
+
Do NOT attempt to work around the blocks.
|
|
301
|
+
`,
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// --- Session restore ---
|
|
306
|
+
|
|
307
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
308
|
+
// Restore persisted state
|
|
309
|
+
const entries = ctx.sessionManager.getEntries();
|
|
310
|
+
const state = getLastEntry<{ orchestratorMode: boolean; implementReason?: string }>(entries, "orchestrator-guard");
|
|
311
|
+
|
|
312
|
+
if (state) {
|
|
313
|
+
orchestratorMode = state.orchestratorMode ?? true;
|
|
314
|
+
implementReason = state.implementReason ?? "";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
updateStatus(ctx);
|
|
318
|
+
});
|
|
319
|
+
}
|
package/install.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @illusoryai/pi-guard postinstall
|
|
4
|
+
* Auto-installs peer dependencies and registers them in pi settings.
|
|
5
|
+
* Add entries to PEERS array when this package gains peer deps.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const PEERS = [];
|
|
14
|
+
|
|
15
|
+
if (PEERS.length === 0) process.exit(0);
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const globalModules = join(__dirname, "..", "..");
|
|
19
|
+
|
|
20
|
+
for (const peer of PEERS) {
|
|
21
|
+
const peerPath = join(globalModules, peer);
|
|
22
|
+
if (!existsSync(peerPath)) {
|
|
23
|
+
console.log(`[pi-guard] Installing peer: ${peer}`);
|
|
24
|
+
try {
|
|
25
|
+
execSync(`npm install -g ${peer}`, { stdio: "pipe" });
|
|
26
|
+
console.log(`[pi-guard] Installed ${peer}`);
|
|
27
|
+
} catch {
|
|
28
|
+
console.warn(
|
|
29
|
+
`[pi-guard] Could not auto-install ${peer}. Run: pi install npm:${peer}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const home = process.env.SUDO_USER
|
|
36
|
+
? join("/home", process.env.SUDO_USER)
|
|
37
|
+
: homedir();
|
|
38
|
+
const settingsPath = join(home, ".pi", "agent", "settings.json");
|
|
39
|
+
|
|
40
|
+
if (existsSync(settingsPath)) {
|
|
41
|
+
try {
|
|
42
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
43
|
+
const packages = settings.packages || [];
|
|
44
|
+
let changed = false;
|
|
45
|
+
|
|
46
|
+
for (const peer of PEERS) {
|
|
47
|
+
const entry = `npm:${peer}`;
|
|
48
|
+
if (!packages.includes(entry)) {
|
|
49
|
+
packages.push(entry);
|
|
50
|
+
changed = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (changed) {
|
|
55
|
+
settings.packages = packages;
|
|
56
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
57
|
+
console.log("[pi-guard] Updated pi settings with peer deps");
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Don't fail install on settings update error
|
|
61
|
+
}
|
|
62
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@illusoryai/pi-orchestration-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Orchestrator mode enforcement for pi — blocks direct implementation, enforces subagent delegation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"postinstall": "node install.mjs"
|
|
12
|
+
},
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"extensions/orchestrator-guard.ts"
|
|
16
|
+
],
|
|
17
|
+
"skills": []
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@mariozechner/pi-ai": "*",
|
|
21
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
22
|
+
}
|
|
23
|
+
}
|