pi-crew 0.2.24 → 0.3.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/CHANGELOG.md +13 -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 -26
- package/src/extension/registration/commands.ts +1 -0
- package/src/extension/registration/subagent-tools.ts +7 -0
- package/src/extension/registration/team-tool.ts +7 -0
- package/src/extension/team-tool.ts +29 -2
- package/src/runtime/heartbeat-watcher.ts +17 -2
- package/src/runtime/runtime-resolver.ts +11 -3
- package/src/runtime/task-runner/live-executor.ts +11 -4
- 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/test-bugs-all.mjs +85 -0
- package/test-lastActivityAt.mjs +167 -0
- package/test-tp.mjs +12 -0
|
@@ -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
|
+
};
|