@probelabs/probe 0.6.0-rc206 → 0.6.0-rc208
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/bin/binaries/{probe-v0.6.0-rc206-aarch64-apple-darwin.tar.gz → probe-v0.6.0-rc208-aarch64-apple-darwin.tar.gz} +0 -0
- package/bin/binaries/probe-v0.6.0-rc208-aarch64-unknown-linux-musl.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc208-x86_64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc208-x86_64-pc-windows-msvc.zip +0 -0
- package/bin/binaries/probe-v0.6.0-rc208-x86_64-unknown-linux-musl.tar.gz +0 -0
- package/build/agent/ProbeAgent.js +144 -2
- package/build/agent/bashPermissions.js +88 -7
- package/build/agent/index.js +450 -18
- package/build/agent/mcp/client.js +234 -4
- package/build/agent/mcp/config.js +87 -0
- package/build/agent/mcp/xmlBridge.js +15 -5
- package/build/agent/simpleTelemetry.js +26 -0
- package/build/tools/bash.js +5 -3
- package/build/tools/common.js +31 -0
- package/cjs/agent/ProbeAgent.cjs +428 -18
- package/cjs/agent/simpleTelemetry.cjs +22 -0
- package/cjs/index.cjs +450 -18
- package/package.json +1 -1
- package/src/agent/ProbeAgent.js +144 -2
- package/src/agent/bashPermissions.js +88 -7
- package/src/agent/mcp/client.js +234 -4
- package/src/agent/mcp/config.js +87 -0
- package/src/agent/mcp/xmlBridge.js +15 -5
- package/src/agent/simpleTelemetry.js +26 -0
- package/src/tools/bash.js +5 -3
- package/src/tools/common.js +31 -0
- package/bin/binaries/probe-v0.6.0-rc206-aarch64-unknown-linux-musl.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc206-x86_64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc206-x86_64-pc-windows-msvc.zip +0 -0
- package/bin/binaries/probe-v0.6.0-rc206-x86_64-unknown-linux-musl.tar.gz +0 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -91,6 +91,45 @@ const MAX_TOOL_ITERATIONS = (() => {
|
|
|
91
91
|
})();
|
|
92
92
|
const MAX_HISTORY_MESSAGES = 100;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Extract tool name from wrapped_tool:toolName format
|
|
96
|
+
* @param {string} wrappedToolError - Error string in format 'wrapped_tool:toolName'
|
|
97
|
+
* @returns {string} The extracted tool name or 'unknown' if format is invalid
|
|
98
|
+
*/
|
|
99
|
+
function extractWrappedToolName(wrappedToolError) {
|
|
100
|
+
if (!wrappedToolError || typeof wrappedToolError !== 'string') {
|
|
101
|
+
return 'unknown';
|
|
102
|
+
}
|
|
103
|
+
const colonIndex = wrappedToolError.indexOf(':');
|
|
104
|
+
return colonIndex !== -1 ? wrappedToolError.slice(colonIndex + 1) : 'unknown';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if an error indicates a wrapped tool format error
|
|
109
|
+
* @param {string|null} error - Error from detectUnrecognizedToolCall
|
|
110
|
+
* @returns {boolean} True if it's a wrapped tool error
|
|
111
|
+
*/
|
|
112
|
+
function isWrappedToolError(error) {
|
|
113
|
+
return error && typeof error === 'string' && error.startsWith('wrapped_tool:');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create error message for wrapped tool format issues
|
|
118
|
+
* @param {string} wrappedToolName - The tool name that was incorrectly wrapped
|
|
119
|
+
* @returns {string} User-friendly error message with correct format instructions
|
|
120
|
+
*/
|
|
121
|
+
function createWrappedToolErrorMessage(wrappedToolName) {
|
|
122
|
+
return `Your response contained an incorrectly formatted tool call (${wrappedToolName} wrapped in XML tags). This cannot be used.
|
|
123
|
+
|
|
124
|
+
Please use the CORRECT format:
|
|
125
|
+
|
|
126
|
+
<${wrappedToolName}>
|
|
127
|
+
Your content here
|
|
128
|
+
</${wrappedToolName}>
|
|
129
|
+
|
|
130
|
+
Do NOT wrap in other tags like <api_call>, <tool_name>, <function>, etc.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
94
133
|
// Supported image file extensions (imported from shared config)
|
|
95
134
|
|
|
96
135
|
// Maximum image file size (20MB) to prevent OOM attacks
|
|
@@ -2542,6 +2581,11 @@ Follow these instructions carefully:
|
|
|
2542
2581
|
}
|
|
2543
2582
|
}
|
|
2544
2583
|
|
|
2584
|
+
// Circuit breaker for repeated format errors
|
|
2585
|
+
let lastFormatErrorType = null;
|
|
2586
|
+
let sameFormatErrorCount = 0;
|
|
2587
|
+
const MAX_REPEATED_FORMAT_ERRORS = 3;
|
|
2588
|
+
|
|
2545
2589
|
// Tool iteration loop (only for non-CLI engines like Vercel/Anthropic/OpenAI)
|
|
2546
2590
|
while (currentIteration < maxIterations && !completionAttempted) {
|
|
2547
2591
|
currentIteration++;
|
|
@@ -2835,7 +2879,28 @@ Follow these instructions carefully:
|
|
|
2835
2879
|
);
|
|
2836
2880
|
|
|
2837
2881
|
if (lastAssistantMessage) {
|
|
2838
|
-
|
|
2882
|
+
const prevContent = lastAssistantMessage.content;
|
|
2883
|
+
|
|
2884
|
+
// Check for patterns indicating a failed/wrapped tool call attempt
|
|
2885
|
+
// Use detectUnrecognizedToolCall for consistent detection logic
|
|
2886
|
+
const wrappedToolError = detectUnrecognizedToolCall(prevContent, validTools);
|
|
2887
|
+
|
|
2888
|
+
if (isWrappedToolError(wrappedToolError)) {
|
|
2889
|
+
// Previous response was a broken tool call attempt - don't reuse it
|
|
2890
|
+
const wrappedToolName = extractWrappedToolName(wrappedToolError);
|
|
2891
|
+
if (this.debug) {
|
|
2892
|
+
console.log(`[DEBUG] Previous response contains wrapped tool '${wrappedToolName}' - rejecting for __PREVIOUS_RESPONSE__`);
|
|
2893
|
+
}
|
|
2894
|
+
currentMessages.push({ role: 'assistant', content: assistantResponseContent });
|
|
2895
|
+
currentMessages.push({
|
|
2896
|
+
role: 'user',
|
|
2897
|
+
content: createWrappedToolErrorMessage(wrappedToolName)
|
|
2898
|
+
});
|
|
2899
|
+
completionAttempted = false;
|
|
2900
|
+
continue; // Don't use broken response, continue the loop
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
finalResult = prevContent;
|
|
2839
2904
|
if (this.debug) console.log(`[DEBUG] Using previous response as completion: ${finalResult.substring(0, 100)}...`);
|
|
2840
2905
|
} else {
|
|
2841
2906
|
finalResult = 'Error: No previous response found to use as completion.';
|
|
@@ -3165,7 +3230,32 @@ Follow these instructions carefully:
|
|
|
3165
3230
|
const unrecognizedTool = detectUnrecognizedToolCall(assistantResponseContent, validTools);
|
|
3166
3231
|
|
|
3167
3232
|
let reminderContent;
|
|
3168
|
-
if (unrecognizedTool) {
|
|
3233
|
+
if (isWrappedToolError(unrecognizedTool)) {
|
|
3234
|
+
// AI wrapped a valid tool name in arbitrary XML tags - provide clear format error
|
|
3235
|
+
const wrappedToolName = extractWrappedToolName(unrecognizedTool);
|
|
3236
|
+
if (this.debug) {
|
|
3237
|
+
console.log(`[DEBUG] Detected wrapped tool '${wrappedToolName}' in assistant response - wrong XML format.`);
|
|
3238
|
+
}
|
|
3239
|
+
const toolError = new ParameterError(
|
|
3240
|
+
`Tool '${wrappedToolName}' found but in WRONG FORMAT - do not wrap tools in other XML tags.`,
|
|
3241
|
+
{
|
|
3242
|
+
suggestion: `Use the tool tag DIRECTLY without any wrapper:
|
|
3243
|
+
|
|
3244
|
+
CORRECT FORMAT:
|
|
3245
|
+
<${wrappedToolName}>
|
|
3246
|
+
<param>value</param>
|
|
3247
|
+
</${wrappedToolName}>
|
|
3248
|
+
|
|
3249
|
+
WRONG (what you did - do not wrap in other tags):
|
|
3250
|
+
<api_call><tool_name>${wrappedToolName}</tool_name>...</api_call>
|
|
3251
|
+
<function>${wrappedToolName}</function>
|
|
3252
|
+
<call name="${wrappedToolName}">...</call>
|
|
3253
|
+
|
|
3254
|
+
Remove ALL wrapper tags and use <${wrappedToolName}> directly as the outermost tag.`
|
|
3255
|
+
}
|
|
3256
|
+
);
|
|
3257
|
+
reminderContent = `<tool_result>\n${formatErrorForAI(toolError)}\n</tool_result>`;
|
|
3258
|
+
} else if (unrecognizedTool) {
|
|
3169
3259
|
// AI tried to use a tool that's not available - provide clear error
|
|
3170
3260
|
if (this.debug) {
|
|
3171
3261
|
console.log(`[DEBUG] Detected unrecognized tool '${unrecognizedTool}' in assistant response.`);
|
|
@@ -3175,6 +3265,33 @@ Follow these instructions carefully:
|
|
|
3175
3265
|
});
|
|
3176
3266
|
reminderContent = `<tool_result>\n${formatErrorForAI(toolError)}\n</tool_result>`;
|
|
3177
3267
|
} else {
|
|
3268
|
+
// No tool call detected at all - check if this is the last iteration
|
|
3269
|
+
// On the last iteration, if the AI gave a substantive response without using
|
|
3270
|
+
// attempt_completion, accept it as the final answer rather than losing the content
|
|
3271
|
+
if (currentIteration >= maxIterations) {
|
|
3272
|
+
// Clean up the response - remove thinking tags
|
|
3273
|
+
let cleanedResponse = assistantResponseContent;
|
|
3274
|
+
// Remove <thinking>...</thinking> blocks
|
|
3275
|
+
cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '').trim();
|
|
3276
|
+
// Also remove unclosed thinking tags
|
|
3277
|
+
cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*$/gi, '').trim();
|
|
3278
|
+
|
|
3279
|
+
// Only use if there's substantial content (not just a failed tool call attempt)
|
|
3280
|
+
const hasSubstantialContent = cleanedResponse.length > 50 &&
|
|
3281
|
+
!cleanedResponse.includes('<api_call>') &&
|
|
3282
|
+
!cleanedResponse.includes('<tool_name>') &&
|
|
3283
|
+
!cleanedResponse.includes('<function>');
|
|
3284
|
+
|
|
3285
|
+
if (hasSubstantialContent) {
|
|
3286
|
+
if (this.debug) {
|
|
3287
|
+
console.log(`[DEBUG] Max iterations reached - accepting AI response as final answer (${cleanedResponse.length} chars)`);
|
|
3288
|
+
}
|
|
3289
|
+
finalResult = cleanedResponse;
|
|
3290
|
+
completionAttempted = true;
|
|
3291
|
+
break;
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3178
3295
|
// Standard reminder - no tool call detected at all
|
|
3179
3296
|
reminderContent = `Please use one of the available tools to help answer the question, or use attempt_completion if you have enough information to provide a final answer.
|
|
3180
3297
|
|
|
@@ -3206,6 +3323,31 @@ Note: <attempt_complete></attempt_complete> reuses your PREVIOUS assistant messa
|
|
|
3206
3323
|
console.log(`[DEBUG] No tool call detected in assistant response. Prompting for tool use.`);
|
|
3207
3324
|
}
|
|
3208
3325
|
}
|
|
3326
|
+
|
|
3327
|
+
// Circuit breaker: track repeated format errors and break early
|
|
3328
|
+
// For wrapped_tool errors, track them as a category (any wrapped_tool counts)
|
|
3329
|
+
// For other errors, track the exact error type
|
|
3330
|
+
if (unrecognizedTool) {
|
|
3331
|
+
const isWrapped = isWrappedToolError(unrecognizedTool);
|
|
3332
|
+
const errorCategory = isWrapped ? 'wrapped_tool' : unrecognizedTool;
|
|
3333
|
+
|
|
3334
|
+
if (errorCategory === lastFormatErrorType) {
|
|
3335
|
+
sameFormatErrorCount++;
|
|
3336
|
+
if (sameFormatErrorCount >= MAX_REPEATED_FORMAT_ERRORS) {
|
|
3337
|
+
const errorDesc = isWrapped ? 'wrapped tool format' : unrecognizedTool;
|
|
3338
|
+
console.error(`[ERROR] Format error category '${errorCategory}' repeated ${sameFormatErrorCount} times. Breaking loop early to prevent infinite iteration.`);
|
|
3339
|
+
finalResult = `Error: Unable to complete request. The AI model repeatedly used incorrect tool call format (${errorDesc}). Please try rephrasing your question or using a different model.`;
|
|
3340
|
+
break;
|
|
3341
|
+
}
|
|
3342
|
+
} else {
|
|
3343
|
+
lastFormatErrorType = errorCategory;
|
|
3344
|
+
sameFormatErrorCount = 1;
|
|
3345
|
+
}
|
|
3346
|
+
} else {
|
|
3347
|
+
// Reset counter if it's a different kind of "no tool call" situation
|
|
3348
|
+
lastFormatErrorType = null;
|
|
3349
|
+
sameFormatErrorCount = 0;
|
|
3350
|
+
}
|
|
3209
3351
|
}
|
|
3210
3352
|
|
|
3211
3353
|
// Keep message history manageable
|
|
@@ -85,9 +85,11 @@ export class BashPermissionChecker {
|
|
|
85
85
|
* @param {boolean} [config.disableDefaultAllow] - Disable default allow list
|
|
86
86
|
* @param {boolean} [config.disableDefaultDeny] - Disable default deny list
|
|
87
87
|
* @param {boolean} [config.debug] - Enable debug logging
|
|
88
|
+
* @param {Object} [config.tracer] - Optional tracer for telemetry
|
|
88
89
|
*/
|
|
89
90
|
constructor(config = {}) {
|
|
90
91
|
this.debug = config.debug || false;
|
|
92
|
+
this.tracer = config.tracer || null;
|
|
91
93
|
|
|
92
94
|
// Build allow patterns
|
|
93
95
|
this.allowPatterns = [];
|
|
@@ -122,6 +124,27 @@ export class BashPermissionChecker {
|
|
|
122
124
|
if (this.debug) {
|
|
123
125
|
console.log(`[BashPermissions] Total patterns - Allow: ${this.allowPatterns.length}, Deny: ${this.denyPatterns.length}`);
|
|
124
126
|
}
|
|
127
|
+
|
|
128
|
+
// Record initialization event
|
|
129
|
+
this.recordBashEvent('permissions.initialized', {
|
|
130
|
+
allowPatternCount: this.allowPatterns.length,
|
|
131
|
+
denyPatternCount: this.denyPatterns.length,
|
|
132
|
+
hasCustomAllowPatterns: !!(config.allow && config.allow.length > 0),
|
|
133
|
+
hasCustomDenyPatterns: !!(config.deny && config.deny.length > 0),
|
|
134
|
+
disableDefaultAllow: !!config.disableDefaultAllow,
|
|
135
|
+
disableDefaultDeny: !!config.disableDefaultDeny
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Record a bash telemetry event if tracer is available
|
|
141
|
+
* @param {string} eventType - Event type (e.g., 'permission.checked', 'permission.denied')
|
|
142
|
+
* @param {Object} data - Event data
|
|
143
|
+
*/
|
|
144
|
+
recordBashEvent(eventType, data = {}) {
|
|
145
|
+
if (this.tracer && typeof this.tracer.recordBashEvent === 'function') {
|
|
146
|
+
this.tracer.recordBashEvent(eventType, data);
|
|
147
|
+
}
|
|
125
148
|
}
|
|
126
149
|
|
|
127
150
|
/**
|
|
@@ -131,11 +154,17 @@ export class BashPermissionChecker {
|
|
|
131
154
|
*/
|
|
132
155
|
check(command) {
|
|
133
156
|
if (!command || typeof command !== 'string') {
|
|
134
|
-
|
|
157
|
+
const result = {
|
|
135
158
|
allowed: false,
|
|
136
159
|
reason: 'Invalid or empty command',
|
|
137
160
|
command: command
|
|
138
161
|
};
|
|
162
|
+
this.recordBashEvent('permission.denied', {
|
|
163
|
+
command: String(command),
|
|
164
|
+
reason: result.reason,
|
|
165
|
+
isComplex: false
|
|
166
|
+
});
|
|
167
|
+
return result;
|
|
139
168
|
}
|
|
140
169
|
|
|
141
170
|
// Check if this is a complex command
|
|
@@ -150,19 +179,32 @@ export class BashPermissionChecker {
|
|
|
150
179
|
const parsed = parseCommand(command);
|
|
151
180
|
|
|
152
181
|
if (parsed.error) {
|
|
153
|
-
|
|
182
|
+
const result = {
|
|
154
183
|
allowed: false,
|
|
155
184
|
reason: parsed.error,
|
|
156
185
|
command: command
|
|
157
186
|
};
|
|
187
|
+
this.recordBashEvent('permission.denied', {
|
|
188
|
+
command,
|
|
189
|
+
reason: result.reason,
|
|
190
|
+
isComplex: false,
|
|
191
|
+
parseError: true
|
|
192
|
+
});
|
|
193
|
+
return result;
|
|
158
194
|
}
|
|
159
195
|
|
|
160
196
|
if (!parsed.command) {
|
|
161
|
-
|
|
197
|
+
const result = {
|
|
162
198
|
allowed: false,
|
|
163
199
|
reason: 'No valid command found',
|
|
164
200
|
command: command
|
|
165
201
|
};
|
|
202
|
+
this.recordBashEvent('permission.denied', {
|
|
203
|
+
command,
|
|
204
|
+
reason: result.reason,
|
|
205
|
+
isComplex: false
|
|
206
|
+
});
|
|
207
|
+
return result;
|
|
166
208
|
}
|
|
167
209
|
|
|
168
210
|
if (this.debug) {
|
|
@@ -173,24 +215,39 @@ export class BashPermissionChecker {
|
|
|
173
215
|
// Check deny patterns first (deny takes precedence)
|
|
174
216
|
if (matchesAnyPattern(parsed, this.denyPatterns)) {
|
|
175
217
|
const matchedPatterns = this.denyPatterns.filter(pattern => matchesPattern(parsed, pattern));
|
|
176
|
-
|
|
218
|
+
const result = {
|
|
177
219
|
allowed: false,
|
|
178
220
|
reason: `Command matches deny pattern: ${matchedPatterns[0]}`,
|
|
179
221
|
command: command,
|
|
180
222
|
parsed: parsed,
|
|
181
223
|
matchedPatterns: matchedPatterns
|
|
182
224
|
};
|
|
225
|
+
this.recordBashEvent('permission.denied', {
|
|
226
|
+
command,
|
|
227
|
+
parsedCommand: parsed.command,
|
|
228
|
+
reason: 'matches_deny_pattern',
|
|
229
|
+
matchedPattern: matchedPatterns[0],
|
|
230
|
+
isComplex: false
|
|
231
|
+
});
|
|
232
|
+
return result;
|
|
183
233
|
}
|
|
184
234
|
|
|
185
235
|
// Check allow patterns
|
|
186
236
|
if (this.allowPatterns.length > 0) {
|
|
187
237
|
if (!matchesAnyPattern(parsed, this.allowPatterns)) {
|
|
188
|
-
|
|
238
|
+
const result = {
|
|
189
239
|
allowed: false,
|
|
190
240
|
reason: 'Command not in allow list',
|
|
191
241
|
command: command,
|
|
192
242
|
parsed: parsed
|
|
193
243
|
};
|
|
244
|
+
this.recordBashEvent('permission.denied', {
|
|
245
|
+
command,
|
|
246
|
+
parsedCommand: parsed.command,
|
|
247
|
+
reason: 'not_in_allow_list',
|
|
248
|
+
isComplex: false
|
|
249
|
+
});
|
|
250
|
+
return result;
|
|
194
251
|
}
|
|
195
252
|
}
|
|
196
253
|
|
|
@@ -206,6 +263,12 @@ export class BashPermissionChecker {
|
|
|
206
263
|
console.log(`[BashPermissions] ALLOWED - command passed all checks`);
|
|
207
264
|
}
|
|
208
265
|
|
|
266
|
+
this.recordBashEvent('permission.allowed', {
|
|
267
|
+
command,
|
|
268
|
+
parsedCommand: parsed.command,
|
|
269
|
+
isComplex: false
|
|
270
|
+
});
|
|
271
|
+
|
|
209
272
|
return result;
|
|
210
273
|
}
|
|
211
274
|
|
|
@@ -235,13 +298,20 @@ export class BashPermissionChecker {
|
|
|
235
298
|
if (this.debug) {
|
|
236
299
|
console.log(`[BashPermissions] DENIED - matches complex deny pattern: ${pattern}`);
|
|
237
300
|
}
|
|
238
|
-
|
|
301
|
+
const result = {
|
|
239
302
|
allowed: false,
|
|
240
303
|
reason: `Command matches deny pattern: ${pattern}`,
|
|
241
304
|
command: command,
|
|
242
305
|
isComplex: true,
|
|
243
306
|
matchedPatterns: [pattern]
|
|
244
307
|
};
|
|
308
|
+
this.recordBashEvent('permission.denied', {
|
|
309
|
+
command,
|
|
310
|
+
reason: 'matches_deny_pattern',
|
|
311
|
+
matchedPattern: pattern,
|
|
312
|
+
isComplex: true
|
|
313
|
+
});
|
|
314
|
+
return result;
|
|
245
315
|
}
|
|
246
316
|
}
|
|
247
317
|
|
|
@@ -251,12 +321,18 @@ export class BashPermissionChecker {
|
|
|
251
321
|
if (this.debug) {
|
|
252
322
|
console.log(`[BashPermissions] ALLOWED - matches complex allow pattern: ${pattern}`);
|
|
253
323
|
}
|
|
254
|
-
|
|
324
|
+
const result = {
|
|
255
325
|
allowed: true,
|
|
256
326
|
command: command,
|
|
257
327
|
isComplex: true,
|
|
258
328
|
matchedPattern: pattern
|
|
259
329
|
};
|
|
330
|
+
this.recordBashEvent('permission.allowed', {
|
|
331
|
+
command,
|
|
332
|
+
matchedPattern: pattern,
|
|
333
|
+
isComplex: true
|
|
334
|
+
});
|
|
335
|
+
return result;
|
|
260
336
|
}
|
|
261
337
|
}
|
|
262
338
|
|
|
@@ -264,6 +340,11 @@ export class BashPermissionChecker {
|
|
|
264
340
|
if (this.debug) {
|
|
265
341
|
console.log(`[BashPermissions] DENIED - no matching complex pattern found`);
|
|
266
342
|
}
|
|
343
|
+
this.recordBashEvent('permission.denied', {
|
|
344
|
+
command,
|
|
345
|
+
reason: 'no_matching_complex_pattern',
|
|
346
|
+
isComplex: true
|
|
347
|
+
});
|
|
267
348
|
return {
|
|
268
349
|
allowed: false,
|
|
269
350
|
reason: 'Complex shell commands require explicit allow patterns (e.g., "cd * && git *")',
|