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.
@@ -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:** The script path is user-configurable and executed with
117
- * minimal environment (PATH, HOME, USER, LANG). Only use with trusted script paths from
118
- * workspace-owned configuration. No path containment validation is performed.
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 (!isScriptRunnable(hookScriptPath)) {
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(hookScriptPath);
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
+ };