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