pi-crew 0.2.25 → 0.3.1
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 +32 -0
- package/README.md +100 -32
- package/docs/TEST_MATRIX.md +17 -15
- package/docs/feature-analysis-subagent4.md +305 -0
- package/docs/pi-subagent4-comparison.md +261 -0
- package/package.json +1 -1
- package/src/agents/discover-agents.ts +74 -4
- package/src/extension/register.ts +28 -27
- package/src/extension/registration/subagent-tools.ts +7 -0
- package/src/extension/registration/team-tool.ts +7 -0
- package/src/extension/run-import.ts +13 -4
- package/src/extension/team-tool.ts +29 -2
- package/src/runtime/background-runner.ts +15 -10
- package/src/runtime/child-pi.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +17 -2
- package/src/runtime/iteration-hooks.ts +31 -6
- package/src/runtime/live-agent-manager.ts +1 -1
- package/src/runtime/role-permission.ts +7 -1
- package/src/runtime/tool-progress.ts +281 -0
- package/src/tools/safe-bash-extension.ts +95 -0
- package/src/tools/safe-bash.ts +188 -0
- package/src/ui/tool-render.ts +331 -0
- package/src/worktree/worktree-manager.ts +29 -2
- package/test-lastActivityAt.mjs +167 -0
- package/test-tp.mjs +12 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
9
10
|
import { resolveShellForScript } from "../utils/resolve-shell.ts";
|
|
10
11
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
11
12
|
import { DENIED_METRIC_NAMES } from "./metric-parser.ts";
|
|
@@ -56,6 +57,25 @@ const MAX_STDOUT_BYTES = 8192;
|
|
|
56
57
|
/** Hook execution timeout in milliseconds (30 seconds). */
|
|
57
58
|
const HOOK_TIMEOUT_MS = 30_000;
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Validates that a hook script path is within an allowed directory.
|
|
62
|
+
* Allowed paths:
|
|
63
|
+
* - Relative paths starting with ".hooks/" (case-sensitive)
|
|
64
|
+
* - Absolute paths under $HOME/.pi/hooks/
|
|
65
|
+
* All other paths are rejected to prevent arbitrary script execution.
|
|
66
|
+
* @param hookPath - The hook script path to validate
|
|
67
|
+
* @returns true if the path is allowed, false otherwise
|
|
68
|
+
*/
|
|
69
|
+
export function isAllowedHookPath(hookPath: string): boolean {
|
|
70
|
+
if (!hookPath || hookPath.trim().length === 0) return false;
|
|
71
|
+
if (!path.isAbsolute(hookPath)) {
|
|
72
|
+
const normalized = path.normalize(hookPath);
|
|
73
|
+
return normalized === ".hooks" || normalized.startsWith(".hooks/");
|
|
74
|
+
}
|
|
75
|
+
const homeHooks = path.join(process.env.HOME ?? "", "", ".pi", "hooks");
|
|
76
|
+
return hookPath === homeHooks || hookPath.startsWith(homeHooks + path.sep);
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
/**
|
|
60
80
|
* Create a not-fired result for when the hook script is absent or not executable.
|
|
61
81
|
*/
|
|
@@ -113,9 +133,9 @@ function isScriptRunnable(scriptPath: string): boolean {
|
|
|
113
133
|
* Spawns `bash <script>` with the hook payload as JSON on stdin.
|
|
114
134
|
* Captures stdout (capped at 8KB) and stderr. Enforces a 30-second timeout.
|
|
115
135
|
*
|
|
116
|
-
* **Security note:**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
136
|
+
* **Security note:** Hook paths are restricted to `.hooks/` relative paths
|
|
137
|
+
* or `$HOME/.pi/hooks/` absolute paths. All other paths are rejected before
|
|
138
|
+
* execution.
|
|
119
139
|
*
|
|
120
140
|
* @param payload - Structured hook payload
|
|
121
141
|
* @param hookScriptPath - Absolute or relative path to the hook script
|
|
@@ -126,7 +146,12 @@ export async function runIterationHook(
|
|
|
126
146
|
hookScriptPath: string,
|
|
127
147
|
options?: { timeoutMs?: number },
|
|
128
148
|
): Promise<HookResult> {
|
|
129
|
-
if (!
|
|
149
|
+
if (!isAllowedHookPath(hookScriptPath)) {
|
|
150
|
+
return { fired: false, stdout: "", stderr: "hook path not allowed: " + hookScriptPath, exitCode: null, timedOut: false, durationMs: 0 };
|
|
151
|
+
}
|
|
152
|
+
// Resolve relative paths relative to cwd
|
|
153
|
+
const resolvedScript = path.isAbsolute(hookScriptPath) ? hookScriptPath : path.join(payload.cwd, hookScriptPath);
|
|
154
|
+
if (!isScriptRunnable(resolvedScript)) {
|
|
130
155
|
return notFiredResult();
|
|
131
156
|
}
|
|
132
157
|
|
|
@@ -136,7 +161,7 @@ export async function runIterationHook(
|
|
|
136
161
|
const stderrChunks: Buffer[] = [];
|
|
137
162
|
|
|
138
163
|
return new Promise<HookResult>((resolve) => {
|
|
139
|
-
const { command, args } = resolveShellForScript(
|
|
164
|
+
const { command, args } = resolveShellForScript(resolvedScript);
|
|
140
165
|
const child = spawn(command, args, {
|
|
141
166
|
cwd: payload.cwd,
|
|
142
167
|
env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_*"] }), PI_CREW_HOOK: "1" },
|
|
@@ -264,4 +289,4 @@ export function hookLogEntry(
|
|
|
264
289
|
}
|
|
265
290
|
|
|
266
291
|
return entry;
|
|
267
|
-
}
|
|
292
|
+
}
|
|
@@ -145,12 +145,12 @@ export async function terminateLiveAgent(agentIdOrTaskId: string, status: CrewAg
|
|
|
145
145
|
if (!handle) return undefined;
|
|
146
146
|
handle.status = status;
|
|
147
147
|
handle.updatedAt = new Date().toISOString();
|
|
148
|
-
liveAgents.delete(handle.agentId);
|
|
149
148
|
try { if (eventLogFn && eventsPath) eventLogFn(eventsPath, { type: "live_agent.terminated", runId: handle.runId, taskId: handle.taskId, message: `Live agent terminated: ${handle.agent} status=${status}`, data: { agentId: handle.agentId, status, role: handle.role, workspaceId: handle.workspaceId } }); } catch { /* non-critical */ }
|
|
150
149
|
try {
|
|
151
150
|
await handle.session.abort?.();
|
|
152
151
|
} finally {
|
|
153
152
|
safeDisposeLiveSession(handle);
|
|
153
|
+
liveAgents.delete(handle.agentId); // Move AFTER abort completes to prevent race
|
|
154
154
|
}
|
|
155
155
|
return handle;
|
|
156
156
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSensitivePath } from "./sensitive-paths.ts";
|
|
2
|
+
|
|
1
3
|
export type RolePermissionMode = "read_only" | "workspace_write" | "danger_full_access" | "explicit_confirm";
|
|
2
4
|
|
|
3
5
|
const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "verifier", "analyst", "critic", "planner", "writer"]);
|
|
@@ -21,8 +23,12 @@ export function isReadOnlyCommand(command: string): boolean {
|
|
|
21
23
|
return READ_ONLY_COMMANDS.has(first) && !/\s(-i|--in-place)\b|\s>{1,2}\s|\brm\b|\bmv\b|\bcp\b|\b(?:npm|pnpm|yarn|bun)\s+(install|add|ci|remove)\b|\bgit\s+(commit|push|merge|rebase|reset|checkout|clean)\b/.test(command);
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
export function checkRolePermission(role: string, command: string): PermissionCheckResult {
|
|
26
|
+
export function checkRolePermission(role: string, command: string, filePath?: string): PermissionCheckResult {
|
|
25
27
|
const mode = permissionForRole(role);
|
|
28
|
+
// Also block access to known sensitive paths even for read-only commands
|
|
29
|
+
if (filePath && isSensitivePath(filePath)) {
|
|
30
|
+
return { allowed: false, mode, reason: `Path '${filePath}' is sensitive (credentials, SSH keys, etc.) — access denied for all roles.` };
|
|
31
|
+
}
|
|
26
32
|
if (mode === "read_only" && !isReadOnlyCommand(command)) return { allowed: false, mode, reason: `Role '${role}' is read-only and command may modify state.` };
|
|
27
33
|
return { allowed: true, mode };
|
|
28
34
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Progress Event System
|
|
3
|
+
*
|
|
4
|
+
* Provides real-time visibility into tool execution within child Pi workers.
|
|
5
|
+
*
|
|
6
|
+
* Event flow:
|
|
7
|
+
* 1. Child Pi emits JSON events on stdout (tool_execution_start, tool_execution_end, etc.)
|
|
8
|
+
* 2. child-pi.ts parses these events and passes them to onJsonEvent callback
|
|
9
|
+
* 3. task-runner.ts calls applyAgentProgressEvent() to update task state
|
|
10
|
+
* 4. This module provides structured types and utilities for the event system
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CrewAgentProgress } from "../state/types.ts";
|
|
14
|
+
|
|
15
|
+
// ── Event Types ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface ToolExecutionStartEvent {
|
|
18
|
+
type: "tool_execution_start";
|
|
19
|
+
toolName: string;
|
|
20
|
+
toolCallId: string;
|
|
21
|
+
args?: Record<string, unknown>;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolExecutionEndEvent {
|
|
26
|
+
type: "tool_execution_end";
|
|
27
|
+
toolName: string;
|
|
28
|
+
toolCallId: string;
|
|
29
|
+
result?: unknown;
|
|
30
|
+
timestamp: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ToolExecutionUpdateEvent {
|
|
34
|
+
type: "tool_execution_update";
|
|
35
|
+
toolName: string;
|
|
36
|
+
toolCallId: string;
|
|
37
|
+
partialResult?: unknown;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ToolExecutionErrorEvent {
|
|
42
|
+
type: "tool_execution_error" | "tool_execution_failed";
|
|
43
|
+
toolName: string;
|
|
44
|
+
toolCallId: string;
|
|
45
|
+
error?: string;
|
|
46
|
+
timestamp: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MessageEndEvent {
|
|
50
|
+
type: "message_end";
|
|
51
|
+
message: {
|
|
52
|
+
role: "assistant" | "user" | "system";
|
|
53
|
+
content?: unknown[];
|
|
54
|
+
usage?: UsageStats;
|
|
55
|
+
model?: string;
|
|
56
|
+
stopReason?: string;
|
|
57
|
+
};
|
|
58
|
+
timestamp: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UsageStats {
|
|
62
|
+
input: number;
|
|
63
|
+
output: number;
|
|
64
|
+
cacheRead?: number;
|
|
65
|
+
cacheWrite?: number;
|
|
66
|
+
cost?: { total: number };
|
|
67
|
+
turns?: number;
|
|
68
|
+
totalTokens?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Union type of all tool progress events
|
|
72
|
+
export type ToolProgressEvent =
|
|
73
|
+
| ToolExecutionStartEvent
|
|
74
|
+
| ToolExecutionEndEvent
|
|
75
|
+
| ToolExecutionUpdateEvent
|
|
76
|
+
| ToolExecutionErrorEvent
|
|
77
|
+
| MessageEndEvent;
|
|
78
|
+
|
|
79
|
+
// ── Event Utilities ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract tool name from any event type
|
|
83
|
+
*/
|
|
84
|
+
export function getToolName(event: ToolProgressEvent): string | undefined {
|
|
85
|
+
if ("toolName" in event) return event.toolName;
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if event indicates tool is running
|
|
91
|
+
*/
|
|
92
|
+
export function isToolRunning(event: ToolProgressEvent): boolean {
|
|
93
|
+
return event.type === "tool_execution_start";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if event indicates tool completed
|
|
98
|
+
*/
|
|
99
|
+
export function isToolComplete(event: ToolProgressEvent): boolean {
|
|
100
|
+
return event.type === "tool_execution_end";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if event indicates tool failed
|
|
105
|
+
*/
|
|
106
|
+
export function isToolError(event: ToolProgressEvent): boolean {
|
|
107
|
+
return event.type === "tool_execution_error" || event.type === "tool_execution_failed";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get usage stats from message_end event
|
|
112
|
+
*/
|
|
113
|
+
export function getUsage(event: ToolProgressEvent): UsageStats | undefined {
|
|
114
|
+
if (event.type === "message_end" && event.message?.usage) {
|
|
115
|
+
return event.message.usage as UsageStats;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Progress Display ──────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export interface ToolProgressDisplay {
|
|
123
|
+
/** Current/last tool being executed */
|
|
124
|
+
currentTool?: string;
|
|
125
|
+
/** Preview of tool arguments (truncated) */
|
|
126
|
+
currentToolArgs?: string;
|
|
127
|
+
/** When tool started */
|
|
128
|
+
currentToolStartedAt?: string;
|
|
129
|
+
/** All recent tools with their args */
|
|
130
|
+
recentTools: Array<{
|
|
131
|
+
tool: string;
|
|
132
|
+
args?: string;
|
|
133
|
+
startedAt?: string;
|
|
134
|
+
endedAt?: string;
|
|
135
|
+
status: "running" | "done" | "error";
|
|
136
|
+
}>;
|
|
137
|
+
/** Token usage snapshot */
|
|
138
|
+
tokens?: number;
|
|
139
|
+
/** Context window usage percentage */
|
|
140
|
+
contextPercent?: number;
|
|
141
|
+
/** Total tool count */
|
|
142
|
+
toolCount: number;
|
|
143
|
+
/** Last activity timestamp */
|
|
144
|
+
lastActivityAt?: string;
|
|
145
|
+
/** Activity state */
|
|
146
|
+
activityState: "active" | "idle" | "done";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Format tool progress for display
|
|
151
|
+
*/
|
|
152
|
+
export function formatToolProgress(progress: CrewAgentProgress, maxContextTokens = 128000): ToolProgressDisplay {
|
|
153
|
+
const recentTools = progress.recentTools.map((t) => ({
|
|
154
|
+
tool: t.tool,
|
|
155
|
+
args: t.args,
|
|
156
|
+
startedAt: t.startedAt,
|
|
157
|
+
endedAt: t.endedAt,
|
|
158
|
+
status: t.endedAt ? "done" : "running" as const,
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
// If there's a currentTool but no endedAt, it's still running
|
|
162
|
+
const currentRunning = progress.recentTools.find(
|
|
163
|
+
(t) => !t.endedAt && t.tool === progress.currentTool,
|
|
164
|
+
);
|
|
165
|
+
if (currentRunning && progress.currentTool) {
|
|
166
|
+
recentTools.push({
|
|
167
|
+
tool: progress.currentTool,
|
|
168
|
+
args: progress.currentToolArgs,
|
|
169
|
+
startedAt: progress.currentToolStartedAt,
|
|
170
|
+
status: "running",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const tokens = progress.tokens ?? 0;
|
|
175
|
+
const contextPercent = maxContextTokens > 0 ? Math.round((tokens / maxContextTokens) * 100) : 0;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
currentTool: progress.currentTool,
|
|
179
|
+
currentToolArgs: progress.currentToolArgs,
|
|
180
|
+
currentToolStartedAt: progress.currentToolStartedAt,
|
|
181
|
+
recentTools,
|
|
182
|
+
tokens,
|
|
183
|
+
contextPercent,
|
|
184
|
+
toolCount: progress.toolCount,
|
|
185
|
+
lastActivityAt: progress.lastActivityAt,
|
|
186
|
+
activityState: progress.activityState as "active" | "idle" | "done",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Format a single line summary of current tool
|
|
192
|
+
*/
|
|
193
|
+
export function formatCurrentToolLine(progress: CrewAgentProgress): string {
|
|
194
|
+
if (!progress.currentTool) return "";
|
|
195
|
+
|
|
196
|
+
const args = progress.currentToolArgs
|
|
197
|
+
? ` ${progress.currentToolArgs.slice(0, 50)}${progress.currentToolArgs.length > 50 ? "..." : ""}`
|
|
198
|
+
: "";
|
|
199
|
+
|
|
200
|
+
const toolCount = progress.toolCount > 0 ? ` (${progress.toolCount})` : "";
|
|
201
|
+
|
|
202
|
+
return `${progress.currentTool}${args}${toolCount}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Format token usage for display
|
|
207
|
+
*/
|
|
208
|
+
export function formatTokenUsage(progress: CrewAgentProgress, maxTokens = 128000): string {
|
|
209
|
+
const tokens = progress.tokens ?? 0;
|
|
210
|
+
const percent = maxTokens > 0 ? Math.round((tokens / maxTokens) * 100) : 0;
|
|
211
|
+
return `${tokens.toLocaleString()} / ${maxTokens.toLocaleString()} (${percent}%)`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Progress Bar Rendering ────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export interface ProgressBarOptions {
|
|
217
|
+
width?: number;
|
|
218
|
+
showPercent?: boolean;
|
|
219
|
+
showCount?: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Render a progress bar for tool execution
|
|
224
|
+
*/
|
|
225
|
+
export function renderProgressBar(
|
|
226
|
+
progress: CrewAgentProgress,
|
|
227
|
+
options: ProgressBarOptions = {},
|
|
228
|
+
): string {
|
|
229
|
+
const width = options.width ?? 20;
|
|
230
|
+
const showPercent = options.showPercent ?? true;
|
|
231
|
+
const showCount = options.showCount ?? true;
|
|
232
|
+
|
|
233
|
+
// Calculate based on recent tools (max 10)
|
|
234
|
+
const recentCount = Math.min(progress.recentTools.length, 10);
|
|
235
|
+
const filled = Math.round((recentCount / 10) * width);
|
|
236
|
+
const empty = width - filled;
|
|
237
|
+
|
|
238
|
+
const bar = "█".repeat(filled) + "░".repeat(empty);
|
|
239
|
+
const percent = showPercent ? ` ${progress.toolCount} tools` : "";
|
|
240
|
+
const tokens = progress.tokens
|
|
241
|
+
? ` | ${(progress.tokens / 1000).toFixed(1)}k tokens`
|
|
242
|
+
: "";
|
|
243
|
+
|
|
244
|
+
return `[${bar}]${percent}${tokens}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Event Filtering ──────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Filter events to only tool execution events
|
|
251
|
+
*/
|
|
252
|
+
export function filterToolEvents(events: ToolProgressEvent[]): ToolProgressEvent[] {
|
|
253
|
+
return events.filter(
|
|
254
|
+
(e) =>
|
|
255
|
+
e.type === "tool_execution_start" ||
|
|
256
|
+
e.type === "tool_execution_end" ||
|
|
257
|
+
e.type === "tool_execution_update" ||
|
|
258
|
+
e.type === "tool_execution_error" ||
|
|
259
|
+
e.type === "tool_execution_failed",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get events for a specific tool
|
|
265
|
+
*/
|
|
266
|
+
export function getEventsForTool(
|
|
267
|
+
events: ToolProgressEvent[],
|
|
268
|
+
toolName: string,
|
|
269
|
+
): ToolProgressEvent[] {
|
|
270
|
+
return events.filter((e) => {
|
|
271
|
+
if ("toolName" in e) return e.toolName === toolName;
|
|
272
|
+
return false;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Check if any event indicates an error
|
|
278
|
+
*/
|
|
279
|
+
export function hasError(events: ToolProgressEvent[]): boolean {
|
|
280
|
+
return events.some(isToolError);
|
|
281
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe Bash Extension for pi-crew
|
|
3
|
+
* Wraps the built-in bash tool with dangerous command blocking
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* 1. Enable in config: { "tools": { "bash": { "safeMode": true } } }
|
|
7
|
+
* 2. Or use via agent config: { "extensions": ["path/to/safe-bash-extension.ts"] }
|
|
8
|
+
* 3. Or set env var: PI_CREW_SAFE_BASH=true
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { createBashTool } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import { Type } from "@sinclair/typebox";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
// Dangerous command patterns to block
|
|
17
|
+
const DANGEROUS_PATTERNS = [
|
|
18
|
+
// rm -rf on root or home
|
|
19
|
+
/\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
|
|
20
|
+
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
|
|
21
|
+
// Privilege escalation
|
|
22
|
+
/\bsudo\b/,
|
|
23
|
+
/\bsu\s+root\b/,
|
|
24
|
+
// Filesystem destruction
|
|
25
|
+
/\bmkfs\b/,
|
|
26
|
+
/\bdd\s+if=/,
|
|
27
|
+
// Fork bomb
|
|
28
|
+
/:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
|
|
29
|
+
// Device writing
|
|
30
|
+
/>\s*\/dev\/[sh]d[a-z]/,
|
|
31
|
+
/\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
|
|
32
|
+
/\bchown\s+(-[a-zA-Z]+\s+)?root/,
|
|
33
|
+
// Pipe to shell (download and execute)
|
|
34
|
+
/\bcurl\s.*\|\s*(ba)?sh/i,
|
|
35
|
+
/\bwget\s.*\|\s*(ba)?sh/i,
|
|
36
|
+
// System shutdown/reboot
|
|
37
|
+
/\bshutdown\b/,
|
|
38
|
+
/\breboot\b/,
|
|
39
|
+
/\binit\s+0\b/,
|
|
40
|
+
// Kill critical processes
|
|
41
|
+
/\bkill\s+-9\s+1\b/,
|
|
42
|
+
/\bkillall\b/,
|
|
43
|
+
// Encoded commands
|
|
44
|
+
/\|\s*base64\s+-d/,
|
|
45
|
+
// Network to shell
|
|
46
|
+
/\bbash\s+-i\s+>\s*\&/,
|
|
47
|
+
// /etc/passwd manipulation
|
|
48
|
+
/\becho\s+.*>\s*\/etc\/passwd/,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function isDangerous(command: string): string | null {
|
|
52
|
+
const normalized = command.replace(/\\\n/g, " ").replace(/\s+/g, " ").trim();
|
|
53
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
54
|
+
if (pattern.test(normalized)) {
|
|
55
|
+
return `Command blocked: matches dangerous pattern \`${pattern}\``;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function safeBashExtension(pi: ExtensionAPI): void {
|
|
62
|
+
const cwd = pi.context?.cwd || process.cwd();
|
|
63
|
+
const bashTool = createBashTool(cwd);
|
|
64
|
+
|
|
65
|
+
pi.registerTool({
|
|
66
|
+
name: "safe_bash",
|
|
67
|
+
label: "Safe Bash",
|
|
68
|
+
description:
|
|
69
|
+
"Execute a bash command safely. Blocks dangerous commands like `rm -rf /`, `sudo`, `curl | sh`, etc.",
|
|
70
|
+
parameters: Type.Object({
|
|
71
|
+
command: Type.String({ description: "Bash command to execute" }),
|
|
72
|
+
timeout: Type.Optional(
|
|
73
|
+
Type.Number({ description: "Timeout in seconds (optional)" }),
|
|
74
|
+
),
|
|
75
|
+
description: Type.Optional(
|
|
76
|
+
Type.String({ description: "Description of what this command does (optional)" }),
|
|
77
|
+
),
|
|
78
|
+
}),
|
|
79
|
+
async execute(toolCallId, params, signal, onUpdate) {
|
|
80
|
+
const danger = isDangerous(params.command);
|
|
81
|
+
if (danger) {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text" as const,
|
|
86
|
+
text: `🚫 ${danger}\n\nIf you need to run this command, use the regular 'bash' tool instead, but be careful!`,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Safe - delegate to real bash tool
|
|
92
|
+
return bashTool.execute(toolCallId, params, signal, onUpdate);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe Bash Tool for pi-crew
|
|
3
|
+
* Wraps bash with dangerous command blocking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Type } from "@sinclair/typebox";
|
|
7
|
+
|
|
8
|
+
// Dangerous command patterns to block
|
|
9
|
+
const DANGEROUS_PATTERNS = [
|
|
10
|
+
// rm -rf / or rm -rf ~ (catastrophic root/home deletion)
|
|
11
|
+
/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s*)+(\/|~)(\s*$)/,
|
|
12
|
+
/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s*)+(\/|~)($|\s)/,
|
|
13
|
+
// Privilege escalation
|
|
14
|
+
/\bsudo\b/,
|
|
15
|
+
/\bsu\s+root\b/,
|
|
16
|
+
// Filesystem destruction
|
|
17
|
+
/\bmkfs\b/,
|
|
18
|
+
/\bdd\s+if=/,
|
|
19
|
+
// Fork bomb
|
|
20
|
+
/^:\s*\(\s*\)\s*\{.*\|.*&.*\}\s*;.*$/,
|
|
21
|
+
// Device writing
|
|
22
|
+
/>\s*\/dev\/[sh]d[a-z]/,
|
|
23
|
+
/\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
|
|
24
|
+
/\bchown\s+(-[a-zA-Z]+\s+)?root/,
|
|
25
|
+
// Pipe to shell (download and execute)
|
|
26
|
+
/\bcurl\s.*\|\s*(ba)?sh/i,
|
|
27
|
+
/\bwget\s.*\|\s*(ba)?sh/i,
|
|
28
|
+
// System shutdown/reboot
|
|
29
|
+
/\bshutdown\b/,
|
|
30
|
+
/\breboot\b/,
|
|
31
|
+
/\binit\s+0\b/,
|
|
32
|
+
// Kill critical processes
|
|
33
|
+
/\bkill\s+-9\s+1\b/,
|
|
34
|
+
/\bkillall\b/,
|
|
35
|
+
// Encoded commands
|
|
36
|
+
/\|\s*base64\s+-d/,
|
|
37
|
+
/\|\s*python.*-c/,
|
|
38
|
+
/\|\s*perl.*-e/,
|
|
39
|
+
/\|\s*ruby.*-e/,
|
|
40
|
+
// Network to shell
|
|
41
|
+
/\bbash\s+-i\s+>\s*\&/,
|
|
42
|
+
/\bexec\s+.*bash/,
|
|
43
|
+
// /etc/passwd manipulation
|
|
44
|
+
/\becho\s+.*>\s*\/etc\/passwd/,
|
|
45
|
+
/\bcat\s+.*>\s*\/etc\/passwd/,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export interface SafeBashOptions {
|
|
49
|
+
/** Enable/disable safe mode. Default: true */
|
|
50
|
+
enabled?: boolean;
|
|
51
|
+
/** Additional patterns to block */
|
|
52
|
+
additionalPatterns?: RegExp[];
|
|
53
|
+
/** Patterns to allow (overrides blocked) */
|
|
54
|
+
allowPatterns?: RegExp[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_ENABLED = true;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a command is dangerous
|
|
61
|
+
* @returns Error message if dangerous, null if safe
|
|
62
|
+
*/
|
|
63
|
+
export function isDangerous(command: string, options: SafeBashOptions = {}): string | null {
|
|
64
|
+
const { enabled = DEFAULT_ENABLED, additionalPatterns = [], allowPatterns = [] } = options;
|
|
65
|
+
|
|
66
|
+
if (!enabled) return null;
|
|
67
|
+
|
|
68
|
+
// Normalize: remove line continuations, collapse whitespace
|
|
69
|
+
const normalized = command.replace(/\\\n/g, " ").replace(/\s+/g, " ").trim();
|
|
70
|
+
|
|
71
|
+
// Check allow patterns first (overrides)
|
|
72
|
+
for (const pattern of allowPatterns) {
|
|
73
|
+
if (pattern.test(normalized)) {
|
|
74
|
+
return null; // Explicitly allowed
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check dangerous patterns
|
|
79
|
+
const allPatterns = [...DANGEROUS_PATTERNS, ...additionalPatterns];
|
|
80
|
+
for (const pattern of allPatterns) {
|
|
81
|
+
if (pattern.test(normalized)) {
|
|
82
|
+
return `Command blocked by safe_bash: matches dangerous pattern \`${pattern}\``;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate a bash command before execution
|
|
91
|
+
* Throws if dangerous
|
|
92
|
+
*/
|
|
93
|
+
export function validateCommand(command: string, options: SafeBashOptions = {}): void {
|
|
94
|
+
const danger = isDangerous(command, options);
|
|
95
|
+
if (danger) {
|
|
96
|
+
throw new Error(danger);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a safe bash tool wrapper
|
|
102
|
+
* Returns an object with validation function and patterns for integration
|
|
103
|
+
*/
|
|
104
|
+
export function createSafeBash(options: SafeBashOptions = {}) {
|
|
105
|
+
return {
|
|
106
|
+
/**
|
|
107
|
+
* Validate a command. Throws if dangerous.
|
|
108
|
+
*/
|
|
109
|
+
validate(command: string): void {
|
|
110
|
+
validateCommand(command, options);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if a command is dangerous without throwing
|
|
115
|
+
*/
|
|
116
|
+
check(command: string): string | null {
|
|
117
|
+
return isDangerous(command, options);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get all active patterns (for debugging/config display)
|
|
122
|
+
*/
|
|
123
|
+
getPatterns(): { dangerous: RegExp[]; additional: RegExp[]; allow: RegExp[] } {
|
|
124
|
+
return {
|
|
125
|
+
dangerous: [...DANGEROUS_PATTERNS],
|
|
126
|
+
additional: options.additionalPatterns || [],
|
|
127
|
+
allow: options.allowPatterns || [],
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if safe mode is enabled
|
|
133
|
+
*/
|
|
134
|
+
isEnabled(): boolean {
|
|
135
|
+
return options.enabled !== false;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Common safe commands that are often blocked but might be needed
|
|
142
|
+
* These can be used in allowPatterns for specific use cases
|
|
143
|
+
*/
|
|
144
|
+
export const COMMON_SAFE_PATTERNS = {
|
|
145
|
+
// Safe rm with specific paths
|
|
146
|
+
safeRm: /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?((?![\/~])\/)?(tmp|cache|node_modules|dist|build)\//,
|
|
147
|
+
// Safe git operations
|
|
148
|
+
safeGit: /\bgit\s+(clone|pull|push|commit|add|status|diff|log|branch|checkout|merge|rebase)/,
|
|
149
|
+
// Safe npm/yarn/pnpm
|
|
150
|
+
safePackage: /\b(npm|yarn|pnpm|bun)\s+(install|run|test|build|start|dev)/,
|
|
151
|
+
// Safe file read
|
|
152
|
+
safeRead: /\b(cat|head|tail|less|more|grep|find|ls)\s/,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Preset configurations for different trust levels
|
|
157
|
+
*/
|
|
158
|
+
export const SAFE_BASH_PRESETS = {
|
|
159
|
+
/** Maximum security - block everything suspicious */
|
|
160
|
+
strict: {
|
|
161
|
+
enabled: true,
|
|
162
|
+
additionalPatterns: [],
|
|
163
|
+
allowPatterns: [],
|
|
164
|
+
},
|
|
165
|
+
/** Moderate - allow common dev operations */
|
|
166
|
+
development: {
|
|
167
|
+
enabled: true,
|
|
168
|
+
additionalPatterns: [],
|
|
169
|
+
allowPatterns: [COMMON_SAFE_PATTERNS.safePackage],
|
|
170
|
+
},
|
|
171
|
+
/** Minimal - only block catastrophic commands */
|
|
172
|
+
permissive: {
|
|
173
|
+
enabled: true,
|
|
174
|
+
additionalPatterns: [],
|
|
175
|
+
allowPatterns: [
|
|
176
|
+
COMMON_SAFE_PATTERNS.safeRm,
|
|
177
|
+
COMMON_SAFE_PATTERNS.safeGit,
|
|
178
|
+
COMMON_SAFE_PATTERNS.safePackage,
|
|
179
|
+
COMMON_SAFE_PATTERNS.safeRead,
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
/** No safety checks */
|
|
183
|
+
disabled: {
|
|
184
|
+
enabled: false,
|
|
185
|
+
additionalPatterns: [],
|
|
186
|
+
allowPatterns: [],
|
|
187
|
+
},
|
|
188
|
+
};
|