@probelabs/probe 0.6.0-rc255 → 0.6.0-rc257

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.
Files changed (31) hide show
  1. package/README.md +5 -5
  2. package/bin/binaries/probe-v0.6.0-rc257-aarch64-apple-darwin.tar.gz +0 -0
  3. package/bin/binaries/probe-v0.6.0-rc257-aarch64-unknown-linux-musl.tar.gz +0 -0
  4. package/bin/binaries/probe-v0.6.0-rc257-x86_64-apple-darwin.tar.gz +0 -0
  5. package/bin/binaries/{probe-v0.6.0-rc255-x86_64-pc-windows-msvc.zip → probe-v0.6.0-rc257-x86_64-pc-windows-msvc.zip} +0 -0
  6. package/bin/binaries/probe-v0.6.0-rc257-x86_64-unknown-linux-musl.tar.gz +0 -0
  7. package/build/agent/FallbackManager.js +4 -4
  8. package/build/agent/ProbeAgent.js +23 -17
  9. package/build/agent/bashDefaults.js +175 -97
  10. package/build/agent/bashPermissions.js +98 -45
  11. package/build/agent/index.js +335 -205
  12. package/build/agent/mcp/xmlBridge.js +3 -2
  13. package/build/agent/schemaUtils.js +127 -0
  14. package/build/tools/bash.js +2 -2
  15. package/build/tools/common.js +20 -3
  16. package/cjs/agent/ProbeAgent.cjs +343 -203
  17. package/cjs/index.cjs +343 -203
  18. package/package.json +1 -1
  19. package/src/agent/FallbackManager.js +4 -4
  20. package/src/agent/ProbeAgent.js +23 -17
  21. package/src/agent/bashDefaults.js +175 -97
  22. package/src/agent/bashPermissions.js +98 -45
  23. package/src/agent/index.js +4 -4
  24. package/src/agent/mcp/xmlBridge.js +3 -2
  25. package/src/agent/schemaUtils.js +127 -0
  26. package/src/tools/bash.js +2 -2
  27. package/src/tools/common.js +20 -3
  28. package/bin/binaries/probe-v0.6.0-rc255-aarch64-apple-darwin.tar.gz +0 -0
  29. package/bin/binaries/probe-v0.6.0-rc255-aarch64-unknown-linux-musl.tar.gz +0 -0
  30. package/bin/binaries/probe-v0.6.0-rc255-x86_64-apple-darwin.tar.gz +0 -0
  31. package/bin/binaries/probe-v0.6.0-rc255-x86_64-unknown-linux-musl.tar.gz +0 -0
@@ -79,9 +79,19 @@ function matchesAnyPattern(parsedCommand, patterns) {
79
79
  export class BashPermissionChecker {
80
80
  /**
81
81
  * Create a permission checker
82
+ *
83
+ * Priority order (highest to lowest):
84
+ * 1. Custom deny — always blocks (user explicitly blocked it)
85
+ * 2. Custom allow — overrides default deny (user explicitly allowed it)
86
+ * 3. Default deny — blocks by default
87
+ * 4. Allow list — allows recognized safe commands
88
+ *
89
+ * This means `--bash-allow "git:push"` overrides the default deny for git:push
90
+ * without requiring `--no-default-bash-deny`.
91
+ *
82
92
  * @param {Object} config - Configuration options
83
- * @param {string[]} [config.allow] - Additional allow patterns
84
- * @param {string[]} [config.deny] - Additional deny patterns
93
+ * @param {string[]} [config.allow] - Additional allow patterns (override default deny)
94
+ * @param {string[]} [config.deny] - Additional deny patterns (always win)
85
95
  * @param {boolean} [config.disableDefaultAllow] - Disable default allow list
86
96
  * @param {boolean} [config.disableDefaultDeny] - Disable default deny list
87
97
  * @param {boolean} [config.debug] - Enable debug logging
@@ -90,38 +100,19 @@ export class BashPermissionChecker {
90
100
  constructor(config = {}) {
91
101
  this.debug = config.debug || false;
92
102
  this.tracer = config.tracer || null;
93
-
94
- // Build allow patterns
95
- this.allowPatterns = [];
96
- if (!config.disableDefaultAllow) {
97
- this.allowPatterns.push(...DEFAULT_ALLOW_PATTERNS);
98
- if (this.debug) {
99
- console.log(`[BashPermissions] Added ${DEFAULT_ALLOW_PATTERNS.length} default allow patterns`);
100
- }
101
- }
102
- if (config.allow && Array.isArray(config.allow)) {
103
- this.allowPatterns.push(...config.allow);
104
- if (this.debug) {
105
- console.log(`[BashPermissions] Added ${config.allow.length} custom allow patterns:`, config.allow);
106
- }
107
- }
108
103
 
109
- // Build deny patterns
110
- this.denyPatterns = [];
111
- if (!config.disableDefaultDeny) {
112
- this.denyPatterns.push(...DEFAULT_DENY_PATTERNS);
113
- if (this.debug) {
114
- console.log(`[BashPermissions] Added ${DEFAULT_DENY_PATTERNS.length} default deny patterns`);
115
- }
116
- }
117
- if (config.deny && Array.isArray(config.deny)) {
118
- this.denyPatterns.push(...config.deny);
119
- if (this.debug) {
120
- console.log(`[BashPermissions] Added ${config.deny.length} custom deny patterns:`, config.deny);
121
- }
122
- }
104
+ // Separate default and custom patterns for priority-based resolution
105
+ this.defaultAllowPatterns = config.disableDefaultAllow ? [] : [...DEFAULT_ALLOW_PATTERNS];
106
+ this.customAllowPatterns = (config.allow && Array.isArray(config.allow)) ? [...config.allow] : [];
107
+ this.allowPatterns = [...this.defaultAllowPatterns, ...this.customAllowPatterns];
108
+
109
+ this.defaultDenyPatterns = config.disableDefaultDeny ? [] : [...DEFAULT_DENY_PATTERNS];
110
+ this.customDenyPatterns = (config.deny && Array.isArray(config.deny)) ? [...config.deny] : [];
111
+ this.denyPatterns = [...this.defaultDenyPatterns, ...this.customDenyPatterns];
123
112
 
124
113
  if (this.debug) {
114
+ console.log(`[BashPermissions] Default allow: ${this.defaultAllowPatterns.length}, Custom allow: ${this.customAllowPatterns.length}`);
115
+ console.log(`[BashPermissions] Default deny: ${this.defaultDenyPatterns.length}, Custom deny: ${this.customDenyPatterns.length}`);
125
116
  console.log(`[BashPermissions] Total patterns - Allow: ${this.allowPatterns.length}, Deny: ${this.denyPatterns.length}`);
126
117
  }
127
118
 
@@ -129,8 +120,8 @@ export class BashPermissionChecker {
129
120
  this.recordBashEvent('permissions.initialized', {
130
121
  allowPatternCount: this.allowPatterns.length,
131
122
  denyPatternCount: this.denyPatterns.length,
132
- hasCustomAllowPatterns: !!(config.allow && config.allow.length > 0),
133
- hasCustomDenyPatterns: !!(config.deny && config.deny.length > 0),
123
+ hasCustomAllowPatterns: this.customAllowPatterns.length > 0,
124
+ hasCustomDenyPatterns: this.customDenyPatterns.length > 0,
134
125
  disableDefaultAllow: !!config.disableDefaultAllow,
135
126
  disableDefaultDeny: !!config.disableDefaultDeny
136
127
  });
@@ -212,9 +203,18 @@ export class BashPermissionChecker {
212
203
  console.log(`[BashPermissions] Parsed: ${parsed.command} with args: [${parsed.args.join(', ')}]`);
213
204
  }
214
205
 
215
- // Check deny patterns first (deny takes precedence)
216
- if (matchesAnyPattern(parsed, this.denyPatterns)) {
217
- const matchedPatterns = this.denyPatterns.filter(pattern => matchesPattern(parsed, pattern));
206
+ // Priority-based permission check:
207
+ // 1. Custom deny always wins
208
+ // 2. Custom allow overrides default deny
209
+ // 3. Default deny blocks
210
+ // 4. Allow list permits
211
+
212
+ // Step 1: Custom deny always wins
213
+ if (matchesAnyPattern(parsed, this.customDenyPatterns)) {
214
+ const matchedPatterns = this.customDenyPatterns.filter(pattern => matchesPattern(parsed, pattern));
215
+ if (this.debug) {
216
+ console.log(`[BashPermissions] DENIED - matches custom deny pattern: ${matchedPatterns[0]}`);
217
+ }
218
218
  const result = {
219
219
  allowed: false,
220
220
  reason: `Command matches deny pattern: ${matchedPatterns[0]}`,
@@ -227,12 +227,40 @@ export class BashPermissionChecker {
227
227
  parsedCommand: parsed.command,
228
228
  reason: 'matches_deny_pattern',
229
229
  matchedPattern: matchedPatterns[0],
230
- isComplex: false
230
+ isComplex: false,
231
+ isCustomDeny: true
231
232
  });
232
233
  return result;
233
234
  }
234
235
 
235
- // Check allow patterns
236
+ // Step 2: Custom allow overrides default deny
237
+ const matchesCustomAllow = matchesAnyPattern(parsed, this.customAllowPatterns);
238
+
239
+ // Step 3: Default deny (skipped if custom allow matches)
240
+ if (!matchesCustomAllow && matchesAnyPattern(parsed, this.defaultDenyPatterns)) {
241
+ const matchedPatterns = this.defaultDenyPatterns.filter(pattern => matchesPattern(parsed, pattern));
242
+ if (this.debug) {
243
+ console.log(`[BashPermissions] DENIED - matches default deny pattern: ${matchedPatterns[0]}`);
244
+ }
245
+ const result = {
246
+ allowed: false,
247
+ reason: `Command matches deny pattern: ${matchedPatterns[0]}`,
248
+ command: command,
249
+ parsed: parsed,
250
+ matchedPatterns: matchedPatterns
251
+ };
252
+ this.recordBashEvent('permission.denied', {
253
+ command,
254
+ parsedCommand: parsed.command,
255
+ reason: 'matches_deny_pattern',
256
+ matchedPattern: matchedPatterns[0],
257
+ isComplex: false,
258
+ isCustomDeny: false
259
+ });
260
+ return result;
261
+ }
262
+
263
+ // Step 4: Check allow patterns
236
264
  if (this.allowPatterns.length > 0) {
237
265
  if (!matchesAnyPattern(parsed, this.allowPatterns)) {
238
266
  const result = {
@@ -256,17 +284,23 @@ export class BashPermissionChecker {
256
284
  allowed: true,
257
285
  command: command,
258
286
  parsed: parsed,
259
- isComplex: false
287
+ isComplex: false,
288
+ overriddenDeny: matchesCustomAllow && matchesAnyPattern(parsed, this.defaultDenyPatterns)
260
289
  };
261
290
 
262
291
  if (this.debug) {
263
- console.log(`[BashPermissions] ALLOWED - command passed all checks`);
292
+ if (result.overriddenDeny) {
293
+ console.log(`[BashPermissions] ALLOWED - custom allow overrides default deny`);
294
+ } else {
295
+ console.log(`[BashPermissions] ALLOWED - command passed all checks`);
296
+ }
264
297
  }
265
298
 
266
299
  this.recordBashEvent('permission.allowed', {
267
300
  command,
268
301
  parsedCommand: parsed.command,
269
- isComplex: false
302
+ isComplex: false,
303
+ overriddenDeny: result.overriddenDeny || false
270
304
  });
271
305
 
272
306
  return result;
@@ -477,10 +511,25 @@ export class BashPermissionChecker {
477
511
  break;
478
512
  }
479
513
 
480
- // Check against deny patterns
481
- if (matchesAnyPattern(parsed, this.denyPatterns)) {
514
+ // Check using same priority logic as simple commands:
515
+ // 1. Custom deny always wins
516
+ if (matchesAnyPattern(parsed, this.customDenyPatterns)) {
517
+ if (this.debug) {
518
+ console.log(`[BashPermissions] Component "${component}" matches custom deny pattern`);
519
+ }
520
+ allAllowed = false;
521
+ deniedComponent = component;
522
+ deniedReason = 'Component matches deny pattern';
523
+ break;
524
+ }
525
+
526
+ // 2. Custom allow overrides default deny
527
+ const componentMatchesCustomAllow = matchesAnyPattern(parsed, this.customAllowPatterns);
528
+
529
+ // 3. Default deny (skipped if custom allow matches)
530
+ if (!componentMatchesCustomAllow && matchesAnyPattern(parsed, this.defaultDenyPatterns)) {
482
531
  if (this.debug) {
483
- console.log(`[BashPermissions] Component "${component}" matches deny pattern`);
532
+ console.log(`[BashPermissions] Component "${component}" matches default deny pattern`);
484
533
  }
485
534
  allAllowed = false;
486
535
  deniedComponent = component;
@@ -488,7 +537,7 @@ export class BashPermissionChecker {
488
537
  break;
489
538
  }
490
539
 
491
- // Check against allow patterns
540
+ // 4. Check allow patterns
492
541
  if (!matchesAnyPattern(parsed, this.allowPatterns)) {
493
542
  if (this.debug) {
494
543
  console.log(`[BashPermissions] Component "${component}" not in allow list`);
@@ -567,6 +616,10 @@ export class BashPermissionChecker {
567
616
  return {
568
617
  allowPatterns: this.allowPatterns.length,
569
618
  denyPatterns: this.denyPatterns.length,
619
+ customAllowPatterns: this.customAllowPatterns.length,
620
+ customDenyPatterns: this.customDenyPatterns.length,
621
+ defaultAllowPatterns: this.defaultAllowPatterns.length,
622
+ defaultDenyPatterns: this.defaultDenyPatterns.length,
570
623
  totalPatterns: this.allowPatterns.length + this.denyPatterns.length
571
624
  };
572
625
  }
@@ -623,9 +623,9 @@ class ProbeAgentMcpServer {
623
623
  // Retry once with correction prompt
624
624
  const correctionPrompt = createJsonCorrectionPrompt(result, schema, validation.error);
625
625
  try {
626
- result = await agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true });
626
+ result = await agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true, _maxIterationsOverride: 3 });
627
627
  result = cleanSchemaResponse(result);
628
-
628
+
629
629
  // Validate again after correction
630
630
  const finalValidation = validateJsonResponse(result);
631
631
  if (!finalValidation.isValid && args.debug) {
@@ -971,11 +971,11 @@ async function main() {
971
971
  try {
972
972
  if (appTracer) {
973
973
  result = await appTracer.withSpan('agent.json_correction',
974
- () => agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true }),
974
+ () => agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true, _maxIterationsOverride: 3 }),
975
975
  { 'original_error': validation.error }
976
976
  );
977
977
  } else {
978
- result = await agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true });
978
+ result = await agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true, _maxIterationsOverride: 3 });
979
979
  }
980
980
  result = cleanSchemaResponse(result);
981
981
 
@@ -6,6 +6,7 @@
6
6
  import { MCPClientManager } from './client.js';
7
7
  import { loadMCPConfiguration } from './config.js';
8
8
  import { processXmlWithThinkingAndRecovery } from '../xmlParsingUtils.js';
9
+ import { unescapeXmlEntities } from '../../tools/common.js';
9
10
 
10
11
  /**
11
12
  * Convert MCP tool to XML definition format
@@ -111,7 +112,7 @@ export function parseXmlMcpToolCall(xmlString, mcpToolNames = []) {
111
112
  let match;
112
113
  while ((match = paramPattern.exec(content)) !== null) {
113
114
  const [, paramName, paramValue] = match;
114
- params[paramName] = paramValue.trim();
115
+ params[paramName] = unescapeXmlEntities(paramValue.trim());
115
116
  }
116
117
  }
117
118
 
@@ -393,7 +394,7 @@ function parseNativeXmlTool(xmlString, toolName) {
393
394
  const [, paramName, paramValue] = match;
394
395
  // Skip if this is the params tag itself (MCP format)
395
396
  if (paramName !== 'params') {
396
- params[paramName] = paramValue.trim();
397
+ params[paramName] = unescapeXmlEntities(paramValue.trim());
397
398
  }
398
399
  }
399
400
 
@@ -603,6 +603,17 @@ export function validateJsonResponse(response, options = {}) {
603
603
  }
604
604
  }
605
605
 
606
+ // Quick recovery: try to extract a valid JSON prefix before returning error.
607
+ // This handles the common case where AI returns valid JSON followed by markdown content,
608
+ // e.g., "Unexpected non-whitespace character after JSON at position 477" (issue #447).
609
+ const prefixResult = tryExtractValidJsonPrefix(responseToValidate, { schema, debug });
610
+ if (prefixResult && prefixResult.isValid) {
611
+ if (debug) {
612
+ console.log(`[DEBUG] JSON validation: Recovered valid JSON prefix (${prefixResult.extracted.length} chars) from response with trailing content`);
613
+ }
614
+ return { isValid: true, parsed: prefixResult.parsed };
615
+ }
616
+
606
617
  // Create enhanced error message with context snippet
607
618
  let enhancedError = error.message;
608
619
  let errorContext = null;
@@ -667,6 +678,122 @@ ${pointer} here`;
667
678
  }
668
679
  }
669
680
 
681
+ /**
682
+ * Try to extract a valid JSON prefix from a string that has trailing non-JSON content.
683
+ * This handles the common case where an AI returns valid JSON followed by markdown text.
684
+ *
685
+ * Uses bracket-matching to find the end of the first complete JSON object/array,
686
+ * then validates the prefix with JSON.parse(). Optionally validates against a schema.
687
+ *
688
+ * @param {string} response - The full response string
689
+ * @param {Object} [options] - Options
690
+ * @param {Object|string} [options.schema] - JSON schema to validate against
691
+ * @param {boolean} [options.debug=false] - Enable debug logging
692
+ * @returns {Object|null} - {isValid: true, parsed: Object, extracted: string} or null if no valid prefix found
693
+ */
694
+ export function tryExtractValidJsonPrefix(response, options = {}) {
695
+ const { schema = null, debug = false } = options;
696
+
697
+ if (!response || typeof response !== 'string') {
698
+ return null;
699
+ }
700
+
701
+ const trimmed = response.trim();
702
+ if (trimmed.length === 0) {
703
+ return null;
704
+ }
705
+
706
+ // Must start with { or [
707
+ const firstChar = trimmed[0];
708
+ if (firstChar !== '{' && firstChar !== '[') {
709
+ return null;
710
+ }
711
+
712
+ // First, check if the full string already parses - no need to extract prefix
713
+ try {
714
+ JSON.parse(trimmed);
715
+ return null; // Full string is valid JSON, no extraction needed
716
+ } catch {
717
+ // Expected - continue with prefix extraction
718
+ }
719
+
720
+ // Find the end of the first complete JSON object/array using bracket matching
721
+ const openChar = firstChar;
722
+ const closeChar = openChar === '{' ? '}' : ']';
723
+ let depth = 0;
724
+ let inString = false;
725
+ let escapeNext = false;
726
+ let endPos = -1;
727
+
728
+ for (let i = 0; i < trimmed.length; i++) {
729
+ const char = trimmed[i];
730
+
731
+ if (escapeNext) {
732
+ escapeNext = false;
733
+ continue;
734
+ }
735
+
736
+ if (char === '\\' && inString) {
737
+ escapeNext = true;
738
+ continue;
739
+ }
740
+
741
+ if (char === '"') {
742
+ inString = !inString;
743
+ continue;
744
+ }
745
+
746
+ if (inString) {
747
+ continue;
748
+ }
749
+
750
+ if (char === openChar) {
751
+ depth++;
752
+ } else if (char === closeChar) {
753
+ depth--;
754
+ if (depth === 0) {
755
+ endPos = i + 1;
756
+ break;
757
+ }
758
+ }
759
+ }
760
+
761
+ if (endPos <= 0 || endPos >= trimmed.length) {
762
+ return null; // No complete JSON found, or JSON spans the entire string
763
+ }
764
+
765
+ // Check that there's actual non-whitespace trailing content
766
+ const remainder = trimmed.substring(endPos).trim();
767
+ if (remainder.length === 0) {
768
+ return null; // Only whitespace after JSON, no trailing content issue
769
+ }
770
+
771
+ // Try to parse the prefix
772
+ const prefix = trimmed.substring(0, endPos);
773
+ try {
774
+ const parsed = JSON.parse(prefix);
775
+
776
+ if (debug) {
777
+ console.log(`[DEBUG] tryExtractValidJsonPrefix: Extracted valid JSON prefix (${prefix.length} chars), stripped trailing content (${remainder.length} chars)`);
778
+ }
779
+
780
+ // If schema provided, validate against it
781
+ if (schema) {
782
+ const schemaValidation = validateJsonResponse(prefix, { debug, schema });
783
+ if (!schemaValidation.isValid) {
784
+ if (debug) {
785
+ console.log(`[DEBUG] tryExtractValidJsonPrefix: Prefix is valid JSON but fails schema validation: ${schemaValidation.error}`);
786
+ }
787
+ return null;
788
+ }
789
+ }
790
+
791
+ return { isValid: true, parsed, extracted: prefix };
792
+ } catch {
793
+ return null;
794
+ }
795
+ }
796
+
670
797
  /**
671
798
  * Validate that the cleaned response is valid XML if expected
672
799
  * @param {string} response - Cleaned response
package/src/tools/bash.js CHANGED
@@ -146,8 +146,8 @@ Common reasons:
146
146
  2. The command is not in the allow list (not a recognized safe command)
147
147
 
148
148
  If you believe this command should be allowed, you can:
149
- - Use the --bash-allow option to add specific patterns
150
- - Use the --no-default-bash-deny flag to remove default restrictions (not recommended)
149
+ - Use the --bash-allow option to add specific patterns (overrides default deny list)
150
+ Example: --bash-allow "git:push" allows git push while keeping all other deny rules
151
151
 
152
152
  For code exploration, try these safe alternatives:
153
153
  - ls, cat, head, tail for file operations
@@ -521,6 +521,23 @@ function getValidParamsForTool(toolName) {
521
521
  return [];
522
522
  }
523
523
 
524
+ /**
525
+ * Unescape standard XML entities in a string value.
526
+ * Order matters: &amp; must be decoded LAST to avoid double-decoding
527
+ * (e.g., &amp;lt; should become &lt;, not <).
528
+ * @param {string} str - The string to unescape
529
+ * @returns {string} The unescaped string
530
+ */
531
+ export function unescapeXmlEntities(str) {
532
+ if (typeof str !== 'string') return str;
533
+ return str
534
+ .replace(/&lt;/g, '<')
535
+ .replace(/&gt;/g, '>')
536
+ .replace(/&quot;/g, '"')
537
+ .replace(/&apos;/g, "'")
538
+ .replace(/&amp;/g, '&');
539
+ }
540
+
524
541
  // Simple XML parser helper - safer string-based approach
525
542
  export function parseXmlToolCall(xmlString, validTools = DEFAULT_VALID_TOOLS) {
526
543
  // Find the tool that appears EARLIEST in the string
@@ -609,10 +626,10 @@ export function parseXmlToolCall(xmlString, validTools = DEFAULT_VALID_TOOLS) {
609
626
  paramCloseIndex = nextTagIndex;
610
627
  }
611
628
 
612
- let paramValue = innerContent.substring(
629
+ let paramValue = unescapeXmlEntities(innerContent.substring(
613
630
  paramOpenIndex + paramOpenTag.length,
614
631
  paramCloseIndex
615
- ).trim();
632
+ ).trim());
616
633
 
617
634
  // Basic type inference (can be improved)
618
635
  if (paramValue.toLowerCase() === 'true') {
@@ -633,7 +650,7 @@ export function parseXmlToolCall(xmlString, validTools = DEFAULT_VALID_TOOLS) {
633
650
 
634
651
  // Special handling for attempt_completion - use entire inner content as result
635
652
  if (toolName === 'attempt_completion') {
636
- params['result'] = innerContent.trim();
653
+ params['result'] = unescapeXmlEntities(innerContent.trim());
637
654
  // Remove command parameter if it was parsed by generic logic above (legacy compatibility)
638
655
  if (params.command) {
639
656
  delete params.command;