vigthoria-cli 1.13.18 → 1.13.22

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 (185) hide show
  1. package/completions/_vigthoria +49 -0
  2. package/completions/vigthoria.bash +6 -0
  3. package/completions/vigthoria.fish +41 -0
  4. package/dist/commands/auth.d.ts +1 -0
  5. package/dist/commands/auth.js +84 -135
  6. package/dist/commands/background.d.ts +0 -2
  7. package/dist/commands/background.js +26 -34
  8. package/dist/commands/bridge.js +45 -41
  9. package/dist/commands/cancel.d.ts +1 -0
  10. package/dist/commands/cancel.js +22 -16
  11. package/dist/commands/chat.d.ts +5 -42
  12. package/dist/commands/chat.js +462 -1122
  13. package/dist/commands/coding-registration.d.ts +4 -0
  14. package/dist/commands/coding-registration.js +24 -0
  15. package/dist/commands/config.js +32 -42
  16. package/dist/commands/conversation-registration.d.ts +4 -0
  17. package/dist/commands/conversation-registration.js +122 -0
  18. package/dist/commands/deploy.d.ts +8 -2
  19. package/dist/commands/deploy.js +92 -90
  20. package/dist/commands/device.d.ts +1 -2
  21. package/dist/commands/device.js +37 -21
  22. package/dist/commands/doctor-registration.d.ts +4 -0
  23. package/dist/commands/doctor-registration.js +200 -0
  24. package/dist/commands/edit.js +22 -33
  25. package/dist/commands/explain.js +7 -10
  26. package/dist/commands/fork.d.ts +1 -2
  27. package/dist/commands/fork.js +72 -22
  28. package/dist/commands/game.d.ts +1 -1
  29. package/dist/commands/game.js +17 -6
  30. package/dist/commands/generate.js +7 -9
  31. package/dist/commands/history.js +11 -7
  32. package/dist/commands/hub.d.ts +2 -2
  33. package/dist/commands/hub.js +34 -35
  34. package/dist/commands/legion.d.ts +0 -1
  35. package/dist/commands/legion.js +62 -79
  36. package/dist/commands/music.d.ts +1 -2
  37. package/dist/commands/music.js +62 -27
  38. package/dist/commands/platform-registration.d.ts +4 -0
  39. package/dist/commands/platform-registration.js +45 -0
  40. package/dist/commands/preview.d.ts +0 -1
  41. package/dist/commands/preview.js +82 -35
  42. package/dist/commands/product-run-registration.d.ts +4 -0
  43. package/dist/commands/product-run-registration.js +490 -0
  44. package/dist/commands/replay.d.ts +1 -2
  45. package/dist/commands/replay.js +15 -11
  46. package/dist/commands/repo.d.ts +14 -7
  47. package/dist/commands/repo.js +245 -196
  48. package/dist/commands/review.js +9 -12
  49. package/dist/commands/security.d.ts +1 -2
  50. package/dist/commands/security.js +12 -6
  51. package/dist/commands/update-registration.d.ts +3 -0
  52. package/dist/commands/update-registration.js +338 -0
  53. package/dist/commands/v4-registration.d.ts +4 -0
  54. package/dist/commands/v4-registration.js +24 -0
  55. package/dist/commands/v4.d.ts +1 -1
  56. package/dist/commands/v4.js +46 -8
  57. package/dist/commands/wallet.d.ts +1 -3
  58. package/dist/commands/wallet.js +18 -45
  59. package/dist/commands/workflow.d.ts +2 -0
  60. package/dist/commands/workflow.js +52 -39
  61. package/dist/index.js +122 -1498
  62. package/dist/utils/agent-stream-state.d.ts +27 -0
  63. package/dist/utils/agent-stream-state.js +93 -0
  64. package/dist/utils/agentRoute.d.ts +2 -1
  65. package/dist/utils/agentRoute.js +22 -9
  66. package/dist/utils/agentRunOutcome.d.ts +16 -0
  67. package/dist/utils/agentRunOutcome.js +37 -2
  68. package/dist/utils/api-client-factory.d.ts +8 -0
  69. package/dist/utils/api-client-factory.js +8 -0
  70. package/dist/utils/api.d.ts +45 -269
  71. package/dist/utils/api.js +664 -3210
  72. package/dist/utils/auth-session.d.ts +41 -0
  73. package/dist/utils/auth-session.js +224 -0
  74. package/dist/utils/brain-hub-client.d.ts +0 -4
  75. package/dist/utils/brain-hub-client.js +6 -3
  76. package/dist/utils/bridge-client.js +2 -0
  77. package/dist/utils/capability-health-service.d.ts +48 -0
  78. package/dist/utils/capability-health-service.js +64 -0
  79. package/dist/utils/chat-prompt-policy.d.ts +18 -0
  80. package/dist/utils/chat-prompt-policy.js +61 -0
  81. package/dist/utils/chat-tool-call-parser.d.ts +11 -0
  82. package/dist/utils/chat-tool-call-parser.js +71 -0
  83. package/dist/utils/cli-state.d.ts +22 -4
  84. package/dist/utils/cli-state.js +105 -47
  85. package/dist/utils/code-operations-service.d.ts +104 -0
  86. package/dist/utils/code-operations-service.js +837 -0
  87. package/dist/utils/codebase-indexer.d.ts +1 -0
  88. package/dist/utils/codebase-indexer.js +28 -8
  89. package/dist/utils/command-contract.d.ts +61 -0
  90. package/dist/utils/command-contract.js +149 -0
  91. package/dist/utils/command-policy.d.ts +10 -0
  92. package/dist/utils/command-policy.js +54 -0
  93. package/dist/utils/config.d.ts +13 -5
  94. package/dist/utils/config.js +227 -99
  95. package/dist/utils/context-ranker.js +11 -2
  96. package/dist/utils/context-state.d.ts +39 -0
  97. package/dist/utils/context-state.js +52 -0
  98. package/dist/utils/deckEvents.d.ts +0 -5
  99. package/dist/utils/deckEvents.js +3 -2
  100. package/dist/utils/desktop-bridge-client.js +2 -0
  101. package/dist/utils/direct-output-policy.d.ts +3 -0
  102. package/dist/utils/direct-output-policy.js +76 -0
  103. package/dist/utils/durable-json.d.ts +57 -0
  104. package/dist/utils/durable-json.js +319 -0
  105. package/dist/utils/fastAgentRouter.d.ts +6 -7
  106. package/dist/utils/fastAgentRouter.js +21 -30
  107. package/dist/utils/files.js +21 -13
  108. package/dist/utils/frontend-preview-service.d.ts +76 -0
  109. package/dist/utils/frontend-preview-service.js +482 -0
  110. package/dist/utils/goaEvents.d.ts +0 -4
  111. package/dist/utils/goaEvents.js +5 -2
  112. package/dist/utils/logger.d.ts +2 -1
  113. package/dist/utils/logger.js +27 -20
  114. package/dist/utils/mcp-context-client.d.ts +24 -0
  115. package/dist/utils/mcp-context-client.js +58 -0
  116. package/dist/utils/model-governance.d.ts +18 -0
  117. package/dist/utils/model-governance.js +75 -0
  118. package/dist/utils/model-transport-service.d.ts +44 -0
  119. package/dist/utils/model-transport-service.js +111 -0
  120. package/dist/utils/mutation-journal.d.ts +42 -0
  121. package/dist/utils/mutation-journal.js +387 -0
  122. package/dist/utils/network-policy.d.ts +24 -0
  123. package/dist/utils/network-policy.js +202 -0
  124. package/dist/utils/operator-client.d.ts +45 -0
  125. package/dist/utils/operator-client.js +159 -0
  126. package/dist/utils/post-write-validator.js +7 -9
  127. package/dist/utils/preview-screenshot-adapter.d.ts +13 -0
  128. package/dist/utils/preview-screenshot-adapter.js +36 -0
  129. package/dist/utils/process-policy.d.ts +23 -0
  130. package/dist/utils/process-policy.js +284 -0
  131. package/dist/utils/project-memory.d.ts +13 -3
  132. package/dist/utils/project-memory.js +70 -17
  133. package/dist/utils/prompt-workspace-resolver.d.ts +14 -0
  134. package/dist/utils/prompt-workspace-resolver.js +64 -0
  135. package/dist/utils/release-install.d.ts +11 -0
  136. package/dist/utils/release-install.js +155 -0
  137. package/dist/utils/release-policy.d.ts +64 -0
  138. package/dist/utils/release-policy.js +293 -0
  139. package/dist/utils/requestIntent.js +22 -3
  140. package/dist/utils/runtime-capability.d.ts +8 -0
  141. package/dist/utils/runtime-capability.js +16 -0
  142. package/dist/utils/secret-policy.d.ts +20 -0
  143. package/dist/utils/secret-policy.js +160 -0
  144. package/dist/utils/session.d.ts +34 -2
  145. package/dist/utils/session.js +354 -110
  146. package/dist/utils/tool-approval-service.d.ts +17 -0
  147. package/dist/utils/tool-approval-service.js +78 -0
  148. package/dist/utils/tool-capability-providers.d.ts +57 -0
  149. package/dist/utils/tool-capability-providers.js +57 -0
  150. package/dist/utils/tools.d.ts +2 -23
  151. package/dist/utils/tools.js +216 -508
  152. package/dist/utils/update-policy.d.ts +18 -0
  153. package/dist/utils/update-policy.js +137 -0
  154. package/dist/utils/v3-agent-client.d.ts +40 -0
  155. package/dist/utils/v3-agent-client.js +165 -0
  156. package/dist/utils/v3-workspace-service.d.ts +28 -0
  157. package/dist/utils/v3-workspace-service.js +279 -0
  158. package/dist/utils/vigflow-client.d.ts +95 -0
  159. package/dist/utils/vigflow-client.js +105 -0
  160. package/dist/utils/workspace-boundary.d.ts +45 -0
  161. package/dist/utils/workspace-boundary.js +377 -0
  162. package/dist/utils/workspace-brain-service.d.ts +2 -0
  163. package/dist/utils/workspace-brain-service.js +11 -1
  164. package/dist/utils/workspace-cache.js +37 -15
  165. package/dist/utils/workspace-stream.js +35 -10
  166. package/install.ps1 +86 -143
  167. package/install.sh +76 -81
  168. package/package.json +44 -9
  169. package/release-policy.json +43 -0
  170. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +1 -1
  171. package/scripts/release/generate-completions.mjs +24 -0
  172. package/scripts/release/install-release.mjs +10 -0
  173. package/scripts/release/publish-cli-release.mjs +116 -0
  174. package/scripts/release/publish-cli-release.sh +2 -70
  175. package/scripts/release/resolve-release-manifest.mjs +26 -0
  176. package/scripts/release/test-balanced-model-live.sh +42 -0
  177. package/scripts/release/validate-live-service-gates.sh +90 -0
  178. package/scripts/release/validate-no-go-gates.sh +135 -172
  179. package/scripts/release/verify-runtime-consistency.mjs +4 -1
  180. package/dist/commands/index.d.ts +0 -12
  181. package/dist/commands/index.js +0 -194
  182. package/dist/commands/update.d.ts +0 -9
  183. package/dist/commands/update.js +0 -301
  184. package/dist/utils/templateInstantPath.d.ts +0 -18
  185. package/dist/utils/templateInstantPath.js +0 -228
@@ -15,18 +15,22 @@
15
15
  */
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
- import { execSync, spawn } from 'child_process';
18
+ import { execFileSync, spawn } from 'child_process';
19
19
  import { createRequire } from 'node:module';
20
+ import { fileURLToPath } from 'node:url';
20
21
  import chalk from 'chalk';
21
22
  // ESM shim — re-create CommonJS-style `require()` so the inline
22
23
  // `require('os')` / `require('minimatch')` / `require('glob')` calls
23
24
  // inside lazy code paths continue to work unchanged in ESM mode.
24
25
  const require = createRequire(import.meta.url);
25
26
  import { CH } from './logger.js';
26
- import { isServerRuntime } from './api.js';
27
+ import { assertContainedAbsolutePath } from './workspace-boundary.js';
28
+ import { assertProcessNetworkAllowed, buildApprovalScope, hardenGitArguments, parseGitArguments, parseSupportedProcess, safeGitEnvironment, } from './process-policy.js';
29
+ import { containsHighConfidenceSecret, isSensitivePath, safeChildProcessEnv } from './secret-policy.js';
30
+ import { isOfflineNetworkMode } from './network-policy.js';
31
+ import { ToolApprovalService } from './tool-approval-service.js';
32
+ import { DelegationToolProvider, FilesystemToolProvider, NetworkBrowserToolProvider, ProcessToolProvider, RepositoryToolProvider, SearchToolProvider, SshToolProvider, ToolCapabilityRegistry, } from './tool-capability-providers.js';
27
33
  const STREAM_RESPONSE_MAX_YIELD_CHARS = 32 * 1024;
28
- const POWERSHELL_SAFE_PATH_PATTERN = /^[A-Za-z0-9_:\\/.\-\s]+$/;
29
- const POWERSHELL_SAFE_INCLUDE_PATTERN = /^[A-Za-z0-9_*?.\-]+$/;
30
34
  const SSH_SAFE_HOST_PATTERN = /^[A-Za-z0-9.-]+$/;
31
35
  function getSshAllowedHosts() {
32
36
  const configured = String(process.env.VIGTHORIA_SSH_ALLOWED_HOSTS || '')
@@ -58,6 +62,9 @@ function resolveWindowsInstallerPath() {
58
62
  export async function installUpdateWindows() {
59
63
  const installerPath = resolveWindowsInstallerPath();
60
64
  try {
65
+ if (isOfflineNetworkMode()) {
66
+ return { success: false, platform: 'windows', error: 'Offline mode prohibits launching the update installer' };
67
+ }
61
68
  if (!fs.existsSync(installerPath)) {
62
69
  return { success: false, platform: 'windows', error: 'ENOENT' };
63
70
  }
@@ -68,9 +75,18 @@ export async function installUpdateWindows() {
68
75
  detached: true,
69
76
  stdio: 'ignore',
70
77
  windowsHide: false,
78
+ env: safeChildProcessEnv(),
79
+ });
80
+ const timer = setTimeout(() => {
81
+ child.kill();
82
+ reject(new Error('Windows update installer did not start within 10 seconds'));
83
+ }, 10_000);
84
+ child.once('error', (error) => {
85
+ clearTimeout(timer);
86
+ reject(error);
71
87
  });
72
- child.once('error', reject);
73
88
  child.once('spawn', () => {
89
+ clearTimeout(timer);
74
90
  child.unref();
75
91
  resolve();
76
92
  });
@@ -89,7 +105,6 @@ export async function* robustifyStreamResponse(res) {
89
105
  const MAX_YIELD_CHARS = STREAM_RESPONSE_MAX_YIELD_CHARS;
90
106
  const MAX_BUFFER_CHARS = 256 * 1024;
91
107
  const body = res?.body ?? res;
92
- const decoder = new TextDecoder('utf-8');
93
108
  let reader = null;
94
109
  let lineBuffer = '';
95
110
  const emitContent = async function* (type, content) {
@@ -330,6 +345,8 @@ export class AgenticTools {
330
345
  cwd;
331
346
  permissionCallback;
332
347
  autoApprove;
348
+ approvals;
349
+ capabilityRegistry;
333
350
  undoStack = [];
334
351
  maxUndoStack = 50;
335
352
  retryConfig = {
@@ -353,17 +370,6 @@ export class AgenticTools {
353
370
  }
354
371
  return `External tool call failed in ${toolName} during ${operation}: ${String(error)}`;
355
372
  }
356
- externalToolFailure(toolName, operation, error, suggestion) {
357
- const message = this.formatExternalToolError(toolName, operation, error);
358
- this.logger.debug(message);
359
- this.logger.warn(message);
360
- return {
361
- success: false,
362
- error: message,
363
- suggestion: suggestion || 'Review the command, arguments, permissions, and environment, then retry.',
364
- canRetry: true,
365
- };
366
- }
367
373
  cleanupAfterToolError(toolName, operation, cleanup) {
368
374
  try {
369
375
  cleanup();
@@ -374,29 +380,6 @@ export class AgenticTools {
374
380
  this.logger.warn(message);
375
381
  }
376
382
  }
377
- runExternalCommand(toolName, operation, command, options, cleanup) {
378
- if (!this.isNonEmptyString(toolName)) {
379
- throw new Error('Invalid tool execution: toolName must be a non-empty string.');
380
- }
381
- if (!this.isNonEmptyString(operation)) {
382
- throw new Error(`Invalid external command for ${toolName}: operation must be a non-empty string.`);
383
- }
384
- if (!this.isNonEmptyString(command)) {
385
- throw new Error(`Invalid external command for ${toolName} during ${operation}: command must be a non-empty string.`);
386
- }
387
- try {
388
- return execSync(command, options);
389
- }
390
- catch (error) {
391
- if (cleanup) {
392
- this.cleanupAfterToolError(toolName, operation, cleanup);
393
- }
394
- const message = this.formatExternalToolError(toolName, operation, error);
395
- const wrapped = new Error(message);
396
- wrapped.cause = error;
397
- throw wrapped;
398
- }
399
- }
400
383
  isNonEmptyString(value) {
401
384
  return typeof value === 'string' && value.trim().length > 0;
402
385
  }
@@ -422,19 +405,6 @@ export class AgenticTools {
422
405
  }
423
406
  }
424
407
  }
425
- requireNonEmptyString(value, fieldName, toolName) {
426
- if (!this.isNonEmptyString(value)) {
427
- throw new Error(`Invalid arguments for ${toolName}: ${fieldName} is required and must be a non-empty string.`);
428
- }
429
- return value;
430
- }
431
- requireArgsObject(args, toolName) {
432
- this.assertStringRecord(args, `Invalid arguments for ${toolName}: args`);
433
- }
434
- // Session-based tool approvals - remembers which tools user approved for this turn
435
- sessionApprovedTools = new Set();
436
- // Persistent permissions - tool allowlists per project
437
- static permissionsFile = path.join(process.env.HOME || process.env.USERPROFILE || '~', '.vigthoria', 'permissions.json');
438
408
  constructor(logger, cwd, permissionCallback, autoApprove = false) {
439
409
  if (!logger) {
440
410
  throw new Error('AgenticTools initialization failed: logger is required.');
@@ -449,9 +419,23 @@ export class AgenticTools {
449
419
  throw new Error('AgenticTools initialization failed: autoApprove must be a boolean.');
450
420
  }
451
421
  this.logger = logger;
452
- this.cwd = cwd;
422
+ this.cwd = fs.realpathSync(path.resolve(cwd));
453
423
  this.permissionCallback = permissionCallback;
454
424
  this.autoApprove = autoApprove;
425
+ this.approvals = new ToolApprovalService(this.cwd, logger);
426
+ this.capabilityRegistry = new ToolCapabilityRegistry([
427
+ new FilesystemToolProvider({
428
+ read_file: (args) => this.readFile(args), write_file: (args) => this.writeFile(args),
429
+ edit_file: (args) => this.editFile(args), list_dir: (args) => this.listDir(args),
430
+ glob: (args) => this.glob(args), multi_edit: (args) => this.multiEdit(args),
431
+ }),
432
+ new ProcessToolProvider({ bash: (args) => this.bash(args) }),
433
+ new SearchToolProvider({ grep: (args) => this.grep(args), codebase_search: (args) => this.codebaseSearch(args) }),
434
+ new RepositoryToolProvider({ git: (args) => this.git(args), repo: (args) => this.repo(args) }),
435
+ new NetworkBrowserToolProvider({ fetch_url: (args) => this.fetchUrl(args), browser: (args) => this.browserTool(args) }),
436
+ new SshToolProvider({ ssh_exec: (args) => this.sshExec(args) }),
437
+ new DelegationToolProvider({ task: (args) => this.task(args) }),
438
+ ]);
455
439
  }
456
440
  getErrorMessage(error) {
457
441
  if (error instanceof Error) {
@@ -467,85 +451,17 @@ export class AgenticTools {
467
451
  return String(error);
468
452
  }
469
453
  }
470
- assertToolCall(call) {
471
- if (!call || typeof call !== 'object') {
472
- throw new Error('Invalid tool call: call must be an object.');
473
- }
474
- if (typeof call.tool !== 'string' || call.tool.trim().length === 0) {
475
- throw new Error('Invalid tool call: tool name must be a non-empty string.');
476
- }
477
- if (!call.args || typeof call.args !== 'object' || Array.isArray(call.args)) {
478
- throw new Error(`Invalid tool call for ${call.tool}: args must be an object.`);
479
- }
480
- for (const [key, value] of Object.entries(call.args)) {
481
- if (typeof key !== 'string' || key.trim().length === 0) {
482
- throw new Error(`Invalid tool call for ${call.tool}: argument names must be non-empty strings.`);
483
- }
484
- if (typeof value !== 'string') {
485
- throw new Error(`Invalid argument "${key}" for ${call.tool}: expected string, received ${typeof value}.`);
486
- }
487
- }
488
- }
489
- /**
490
- * Load persistent permissions for the current project
491
- */
492
- loadPersistentPermissions() {
493
- try {
494
- if (fs.existsSync(AgenticTools.permissionsFile)) {
495
- return JSON.parse(fs.readFileSync(AgenticTools.permissionsFile, 'utf-8'));
496
- }
497
- }
498
- catch (error) {
499
- const message = this.formatExternalToolError('permissions', `read ${AgenticTools.permissionsFile}`, error);
500
- this.logger.debug(message);
501
- this.logger.warn(message);
502
- }
503
- return {};
504
- }
505
- /**
506
- * Save a persistent permission for a tool in the current project
507
- */
508
- savePersistentPermission(toolName) {
509
- const permissions = this.loadPersistentPermissions();
510
- const projectKey = this.cwd;
511
- if (!permissions[projectKey]) {
512
- permissions[projectKey] = { tools: [], updatedAt: new Date().toISOString() };
513
- }
514
- if (!permissions[projectKey].tools.includes(toolName)) {
515
- permissions[projectKey].tools.push(toolName);
516
- permissions[projectKey].updatedAt = new Date().toISOString();
517
- }
518
- try {
519
- const dir = path.dirname(AgenticTools.permissionsFile);
520
- if (!fs.existsSync(dir))
521
- fs.mkdirSync(dir, { recursive: true });
522
- fs.writeFileSync(AgenticTools.permissionsFile, JSON.stringify(permissions, null, 2), 'utf-8');
523
- }
524
- catch (error) {
525
- const message = this.formatExternalToolError('permissions', `write ${AgenticTools.permissionsFile}`, error);
526
- this.logger.debug(message);
527
- this.logger.warn(message);
528
- }
529
- }
530
- /**
531
- * Check if a tool has persistent permission for the current project
532
- */
533
- hasPersistentPermission(toolName) {
534
- const permissions = this.loadPersistentPermissions();
535
- const projectKey = this.cwd;
536
- return permissions[projectKey]?.tools?.includes(toolName) || false;
537
- }
538
454
  /**
539
455
  * Clear session-approved tools (call this at the start of each new AI turn)
540
456
  */
541
457
  clearSessionApprovals() {
542
- this.sessionApprovedTools.clear();
458
+ this.approvals.clearSession();
543
459
  }
544
460
  /**
545
461
  * Get currently approved tools for this session
546
462
  */
547
463
  getSessionApprovedTools() {
548
- return Array.from(this.sessionApprovedTools);
464
+ return this.approvals.sessionKeys();
549
465
  }
550
466
  /**
551
467
  * Get the undo stack for inspection
@@ -647,7 +563,7 @@ export class AgenticTools {
647
563
  },
648
564
  {
649
565
  name: 'bash',
650
- description: 'Run a shell command',
566
+ description: 'Run one supported process using a parsed argument vector (no shell syntax)',
651
567
  parameters: [
652
568
  { name: 'command', description: 'Shell command to execute', required: true },
653
569
  { name: 'cwd', description: 'Working directory', required: false },
@@ -835,11 +751,17 @@ export class AgenticTools {
835
751
  }
836
752
  // Check permission for dangerous/modifying actions
837
753
  if (tool.requiresPermission && !this.autoApprove) {
838
- // Check persistent permissions first (project-scoped), then session
839
- if (this.hasPersistentPermission(normalizedCall.tool)) {
754
+ let approvalScope;
755
+ try {
756
+ approvalScope = buildApprovalScope(normalizedCall.tool, normalizedCall.args, this.cwd);
757
+ }
758
+ catch (error) {
759
+ return this.createErrorResult(ToolErrorType.INVALID_ARGS, this.getErrorMessage(error));
760
+ }
761
+ if (this.approvals.hasPersistent(approvalScope)) {
840
762
  this.logger.info(`${call.tool}: Auto-approved (persistent)`);
841
763
  }
842
- else if (this.sessionApprovedTools.has(normalizedCall.tool)) {
764
+ else if (this.approvals.hasSession(approvalScope)) {
843
765
  // Already approved - skip permission prompt
844
766
  this.logger.info(`${call.tool}: Auto-approved (batch)`);
845
767
  }
@@ -856,17 +778,16 @@ export class AgenticTools {
856
778
  // 'persist' (typed 'p') = approve permanently for this project
857
779
  // 'y' or 'yes' = approve this single request only
858
780
  if (approved === 'batch') {
859
- this.sessionApprovedTools.add(normalizedCall.tool);
781
+ this.approvals.recordSession(approvalScope);
860
782
  }
861
783
  else if (approved === 'persist') {
862
- this.sessionApprovedTools.add(normalizedCall.tool);
863
- this.savePersistentPermission(normalizedCall.tool);
864
- this.logger.info(`${normalizedCall.tool}: Saved as persistent permission`);
784
+ this.approvals.recordPersistent(approvalScope);
785
+ this.logger.info(`${normalizedCall.tool}: Saved exact operation as persistent permission`);
865
786
  }
866
787
  }
867
788
  }
868
789
  // Execute with retry logic for applicable operations
869
- return this.executeWithRetry(normalizedCall, tool);
790
+ return this.executeWithRetry(normalizedCall);
870
791
  }
871
792
  normalizeToolCall(call) {
872
793
  const tool = String(call.tool || '').trim();
@@ -912,7 +833,7 @@ export class AgenticTools {
912
833
  /**
913
834
  * Execute tool with automatic retry for transient failures
914
835
  */
915
- async executeWithRetry(call, tool) {
836
+ async executeWithRetry(call) {
916
837
  let lastError = null;
917
838
  for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
918
839
  try {
@@ -980,40 +901,7 @@ export class AgenticTools {
980
901
  message,
981
902
  };
982
903
  }
983
- switch (call.tool) {
984
- case 'read_file':
985
- return this.readFile(call.args);
986
- case 'write_file':
987
- return this.writeFile(call.args);
988
- case 'edit_file':
989
- return this.editFile(call.args);
990
- case 'bash':
991
- return this.bash(call.args);
992
- case 'grep':
993
- return this.grep(call.args);
994
- case 'list_dir':
995
- return this.listDir(call.args);
996
- case 'glob':
997
- return this.glob(call.args);
998
- case 'git':
999
- return this.git(call.args);
1000
- case 'repo':
1001
- return this.repo(call.args);
1002
- case 'fetch_url':
1003
- return this.fetchUrl(call.args);
1004
- case 'browser':
1005
- return this.browserTool(call.args);
1006
- case 'ssh_exec':
1007
- return this.sshExec(call.args);
1008
- case 'task':
1009
- return this.task(call.args);
1010
- case 'multi_edit':
1011
- return this.multiEdit(call.args);
1012
- case 'codebase_search':
1013
- return this.codebaseSearch(call.args);
1014
- default:
1015
- return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Tool not implemented: ${call.tool}`);
1016
- }
904
+ return await this.capabilityRegistry.execute(call);
1017
905
  }
1018
906
  catch (error) {
1019
907
  const context = `Tool '${call.tool}' failed while executing with args ${this.safeStringifyArgs(call.args)}`;
@@ -1216,7 +1104,7 @@ export class AgenticTools {
1216
1104
  msg += chalk.red(' The AI is requesting to execute commands on your system.\n');
1217
1105
  }
1218
1106
  // Add batch approval hint
1219
- msg += `\n${chalk.gray('Respond:')} ${chalk.white('[y]es')} ${chalk.gray('/')} ${chalk.white('[n]o')} ${chalk.gray('/')} ${chalk.cyan('[a]pprove all')} ${chalk.cyan(call.tool)} ${chalk.gray('for this turn')}\n`;
1107
+ msg += `\n${chalk.gray('Respond:')} ${chalk.white('[y]es')} ${chalk.gray('/')} ${chalk.white('[n]o')} ${chalk.gray('/')} ${chalk.cyan('[a]pprove this exact operation')} ${chalk.gray('for this turn')}\n`;
1220
1108
  return msg;
1221
1109
  }
1222
1110
  sleep(ms) {
@@ -1227,6 +1115,9 @@ export class AgenticTools {
1227
1115
  * Read file with enhanced error handling and suggestions
1228
1116
  */
1229
1117
  readFile(args) {
1118
+ if (isSensitivePath(args.path)) {
1119
+ return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, `Sensitive file is excluded from agent context: ${path.basename(args.path)}`);
1120
+ }
1230
1121
  const filePath = this.resolvePath(args.path);
1231
1122
  if (!fs.existsSync(filePath)) {
1232
1123
  // Try to find similar files
@@ -1322,7 +1213,7 @@ export class AgenticTools {
1322
1213
  };
1323
1214
  this.undoStack.push(undoOp);
1324
1215
  // Limit undo stack size
1325
- if (this.undoStack.length > 50) {
1216
+ if (this.undoStack.length > this.maxUndoStack) {
1326
1217
  this.undoStack.shift();
1327
1218
  }
1328
1219
  const isNew = originalContent === null;
@@ -1395,7 +1286,7 @@ export class AgenticTools {
1395
1286
  };
1396
1287
  this.undoStack.push(undoOp);
1397
1288
  // Limit undo stack size
1398
- if (this.undoStack.length > 50) {
1289
+ if (this.undoStack.length > this.maxUndoStack) {
1399
1290
  this.undoStack.shift();
1400
1291
  }
1401
1292
  // Show syntax-highlighted diff
@@ -1459,7 +1350,7 @@ export class AgenticTools {
1459
1350
  return this.formatExternalToolError('syntax_check', `write temporary file ${tempFile}`, writeError);
1460
1351
  }
1461
1352
  try {
1462
- this.runExternalCommand('syntax_check', `node --check ${tempFile}`, `node --check "${tempFile}"`, { stdio: 'pipe' });
1353
+ execFileSync(process.execPath, ['--check', tempFile], { stdio: 'pipe', timeout: 10_000, windowsHide: true, env: safeChildProcessEnv() });
1463
1354
  return null;
1464
1355
  }
1465
1356
  catch (error) {
@@ -1615,30 +1506,21 @@ export class AgenticTools {
1615
1506
  }
1616
1507
  try {
1617
1508
  const startTime = Date.now();
1618
- // Build exec options based on platform
1509
+ const processRequest = parseSupportedProcess(args.command);
1619
1510
  const execOptions = {
1620
1511
  cwd,
1621
1512
  timeout,
1622
- encoding: 'utf-8',
1513
+ encoding: 'utf8',
1623
1514
  maxBuffer: 10 * 1024 * 1024, // 10MB
1624
1515
  stdio: ['pipe', 'pipe', 'pipe'],
1625
1516
  // Ensure consistent environment
1626
- env: {
1627
- ...process.env,
1628
- // Prevent locale issues
1629
- LC_ALL: 'C',
1630
- LANG: 'C',
1631
- },
1517
+ env: safeChildProcessEnv(process.env, { LC_ALL: 'C', LANG: 'C' }),
1632
1518
  };
1633
- // Set shell based on platform for consistent behavior
1634
- if (platform === 'win32') {
1635
- execOptions.shell = true; // Use default cmd.exe on Windows
1636
- }
1637
- else {
1638
- // Use /bin/sh on Unix-like systems (more portable than bash)
1639
- execOptions.shell = '/bin/sh';
1640
- }
1641
- const output = this.runExternalCommand('bash', args.command, args.command, execOptions);
1519
+ const output = execFileSync(processRequest.executable, processRequest.args, {
1520
+ ...execOptions,
1521
+ encoding: 'utf8',
1522
+ stdio: ['pipe', 'pipe', 'pipe'],
1523
+ });
1642
1524
  const duration = Date.now() - startTime;
1643
1525
  return {
1644
1526
  success: true,
@@ -1651,8 +1533,23 @@ export class AgenticTools {
1651
1533
  }
1652
1534
  catch (error) {
1653
1535
  // Command failed but may have output
1654
- const output = error.stdout || '';
1655
- const stderr = error.stderr || '';
1536
+ // runExternalCommand deliberately wraps child_process failures so every
1537
+ // external tool gets a consistent message. Preserve the underlying
1538
+ // ExecException here; otherwise stdout/stderr/exit status disappear and
1539
+ // remote executors receive only the unhelpful "Command failed" wrapper.
1540
+ const processError = error?.cause && typeof error.cause === 'object'
1541
+ ? error.cause
1542
+ : error;
1543
+ const decodeProcessText = (value) => {
1544
+ if (Buffer.isBuffer(value))
1545
+ return value.toString('utf8');
1546
+ return typeof value === 'string' ? value : '';
1547
+ };
1548
+ const output = decodeProcessText(processError?.stdout);
1549
+ const stderr = decodeProcessText(processError?.stderr);
1550
+ const exitStatus = Number.isInteger(processError?.status)
1551
+ ? ` (exit ${processError.status})`
1552
+ : '';
1656
1553
  // Provide helpful error messages for common cases
1657
1554
  if (error.killed) {
1658
1555
  return this.createErrorResult(ToolErrorType.TIMEOUT, `Command timed out after ${timeout / 1000}s`, 'Try increasing the timeout or breaking the command into smaller parts.');
@@ -1667,9 +1564,9 @@ export class AgenticTools {
1667
1564
  return {
1668
1565
  success: false,
1669
1566
  output,
1670
- error: stderr || error.message,
1567
+ error: stderr || `${error.message}${exitStatus}`,
1671
1568
  suggestion: 'Check command syntax and try again.',
1672
- canRetry: !error.killed,
1569
+ canRetry: !processError?.killed,
1673
1570
  };
1674
1571
  }
1675
1572
  }
@@ -1685,7 +1582,7 @@ export class AgenticTools {
1685
1582
  if (platform === 'win32') {
1686
1583
  return this.grepWindows(args, searchPath);
1687
1584
  }
1688
- return this.grepUnix(args, searchPath, platform);
1585
+ return this.grepUnix(args, searchPath);
1689
1586
  }
1690
1587
  /**
1691
1588
  * Windows grep: rg > Select-String > Node-native
@@ -1693,96 +1590,51 @@ export class AgenticTools {
1693
1590
  grepWindows(args, searchPath) {
1694
1591
  // 1. Try ripgrep (rg) first — best cross-platform search tool
1695
1592
  try {
1696
- execSync('rg --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
1593
+ execFileSync('rg', ['--version'], { encoding: 'utf-8', timeout: 5000, stdio: 'pipe', windowsHide: true, env: safeChildProcessEnv() });
1697
1594
  return this.grepWithRg(args, searchPath);
1698
1595
  }
1699
1596
  catch (error) {
1700
1597
  this.logger.debug(this.formatExternalToolError('grep', 'checking ripgrep availability on Windows', error));
1701
1598
  }
1702
- // 2. Try PowerShell Select-String
1703
- try {
1704
- return this.grepWithSelectString(args, searchPath);
1705
- }
1706
- catch (error) {
1707
- this.logger.debug(this.formatExternalToolError('grep', 'running PowerShell Select-String fallback', error));
1708
- }
1709
- // 3. Fall back to Node-native recursive file scanner
1599
+ // Fall back to the Node-native scanner. Avoid constructing a PowerShell
1600
+ // program from model-controlled patterns.
1710
1601
  return this.grepNodeNative(args, searchPath);
1711
1602
  }
1712
1603
  /**
1713
1604
  * Unix grep: rg > system grep (BSD/GNU)
1714
1605
  */
1715
- grepUnix(args, searchPath, platform) {
1606
+ grepUnix(args, searchPath) {
1716
1607
  // Try ripgrep first if available
1717
1608
  try {
1718
- this.runExternalCommand('grep', 'checking ripgrep availability on Unix', 'rg --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
1609
+ execFileSync('rg', ['--version'], { encoding: 'utf-8', timeout: 5000, stdio: 'pipe', windowsHide: true, env: safeChildProcessEnv() });
1719
1610
  return this.grepWithRg(args, searchPath);
1720
1611
  }
1721
1612
  catch (error) {
1722
1613
  this.logger.debug(this.formatExternalToolError('grep', 'checking ripgrep availability on Unix', error));
1723
1614
  // rg not available, fall through to system grep
1724
1615
  }
1725
- const isMac = platform === 'darwin';
1726
- let cmd;
1727
- if (isMac) {
1728
- cmd = args.include
1729
- ? `grep -rn --include="${args.include}" "${args.pattern}" "${searchPath}"`
1730
- : `grep -rn "${args.pattern}" "${searchPath}"`;
1731
- }
1732
- else {
1733
- cmd = args.include
1734
- ? `grep -rn --color=never --include="${args.include}" "${args.pattern}" "${searchPath}"`
1735
- : `grep -rn --color=never "${args.pattern}" "${searchPath}"`;
1736
- }
1737
- try {
1738
- const output = this.runExternalCommand('grep', 'system grep search', cmd, {
1739
- cwd: this.cwd,
1740
- encoding: 'utf-8',
1741
- maxBuffer: 5 * 1024 * 1024,
1742
- timeout: 30000,
1743
- env: { ...process.env, GREP_OPTIONS: '' },
1744
- });
1745
- return {
1746
- success: true,
1747
- output: output.trim(),
1748
- metadata: { searchStatus: 'search_matches_found' },
1749
- };
1750
- }
1751
- catch (error) {
1752
- // Distinguish real "no matches" (exit 1 + no stderr) from command failure
1753
- const stderr = (error.stderr || '').toString();
1754
- if (error.status === 1 && !stderr.trim()) {
1755
- return {
1756
- success: true,
1757
- output: 'No matches found',
1758
- metadata: { searchStatus: 'search_no_matches' },
1759
- };
1760
- }
1761
- // Real failure: command not found, permission denied, etc.
1762
- return {
1763
- success: false,
1764
- error: stderr || error.message,
1765
- suggestion: 'The grep command failed. Install ripgrep (rg) for best cross-platform search.',
1766
- canRetry: true,
1767
- metadata: { searchStatus: 'search_failed' },
1768
- };
1769
- }
1616
+ return this.grepNodeNative(args, searchPath);
1770
1617
  }
1771
1618
  /**
1772
1619
  * Search using ripgrep (rg) — works on all platforms
1773
1620
  */
1774
1621
  grepWithRg(args, searchPath) {
1775
- let cmd = `rg -n --no-heading`;
1776
- if (args.include) {
1777
- cmd += ` -g "${args.include}"`;
1778
- }
1779
- cmd += ` "${args.pattern}" "${searchPath}"`;
1622
+ const rgArgs = [
1623
+ '-n', '--no-heading',
1624
+ '-g', '!.git/**', '-g', '!.vigthoria/**', '-g', '!.env', '-g', '!.env.*',
1625
+ '-g', '!**/credentials*', '-g', '!**/secrets*', '-g', '!**/*.pem', '-g', '!**/*.key',
1626
+ ];
1627
+ if (args.include)
1628
+ rgArgs.push('-g', args.include);
1629
+ rgArgs.push('--', args.pattern, searchPath);
1780
1630
  try {
1781
- const output = this.runExternalCommand('grep', 'ripgrep search', cmd, {
1631
+ const output = execFileSync('rg', rgArgs, {
1782
1632
  cwd: this.cwd,
1783
- encoding: 'utf-8',
1633
+ encoding: 'utf8',
1784
1634
  maxBuffer: 5 * 1024 * 1024,
1785
1635
  timeout: 30000,
1636
+ windowsHide: true,
1637
+ env: safeChildProcessEnv(),
1786
1638
  });
1787
1639
  return {
1788
1640
  success: true,
@@ -1801,56 +1653,6 @@ export class AgenticTools {
1801
1653
  throw error; // Let caller try next backend
1802
1654
  }
1803
1655
  }
1804
- /**
1805
- * Search using PowerShell Select-String (Windows)
1806
- */
1807
- grepWithSelectString(args, searchPath) {
1808
- // Normalize path for PowerShell
1809
- const psPath = searchPath.replace(/\//g, '\\');
1810
- if (!POWERSHELL_SAFE_PATH_PATTERN.test(psPath)) {
1811
- return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'Unsafe search path for PowerShell backend', 'Use a normal workspace path containing only letters, numbers, separators, dot, dash, and spaces.');
1812
- }
1813
- const includeArg = args.include ? String(args.include) : '*';
1814
- if (!POWERSHELL_SAFE_INCLUDE_PATTERN.test(includeArg)) {
1815
- return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'Unsafe include filter for PowerShell backend', 'Use simple wildcard filters like *.ts or *.py.');
1816
- }
1817
- const includeFilter = `-Include "${includeArg}"`;
1818
- const escapedPattern = args.pattern.replace(/'/g, "''");
1819
- const cmd = `powershell -NoProfile -Command "Get-ChildItem -Path '${psPath}' -Recurse -File ${includeFilter} | Select-String -Pattern '${escapedPattern}' | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }"`;
1820
- try {
1821
- const output = execSync(cmd, {
1822
- cwd: this.cwd,
1823
- encoding: 'utf-8',
1824
- maxBuffer: 5 * 1024 * 1024,
1825
- timeout: 60000,
1826
- });
1827
- const trimmed = output.trim();
1828
- if (!trimmed) {
1829
- return {
1830
- success: true,
1831
- output: 'No matches found',
1832
- metadata: { searchStatus: 'search_no_matches', backend: 'select-string' },
1833
- };
1834
- }
1835
- return {
1836
- success: true,
1837
- output: trimmed,
1838
- metadata: { searchStatus: 'search_matches_found', backend: 'select-string' },
1839
- };
1840
- }
1841
- catch (error) {
1842
- const stderr = (error.stderr || '').toString();
1843
- // Select-String returns exit code 1 for no matches when used in pipeline
1844
- if (error.status === 1 && !stderr.trim()) {
1845
- return {
1846
- success: true,
1847
- output: 'No matches found',
1848
- metadata: { searchStatus: 'search_no_matches', backend: 'select-string' },
1849
- };
1850
- }
1851
- throw error; // Let caller try next backend
1852
- }
1853
- }
1854
1656
  /**
1855
1657
  * Pure Node.js recursive file search — reliable on all platforms, no external deps
1856
1658
  */
@@ -1897,6 +1699,9 @@ export class AgenticTools {
1897
1699
  }
1898
1700
  if (!entry.isFile())
1899
1701
  continue;
1702
+ const relativePath = path.relative(this.cwd, fullPath).replace(/\\/g, '/');
1703
+ if (isSensitivePath(relativePath))
1704
+ continue;
1900
1705
  // Check include pattern
1901
1706
  if (includeGlob) {
1902
1707
  const matches = minimatch
@@ -1915,7 +1720,6 @@ export class AgenticTools {
1915
1720
  const lines = content.split('\n');
1916
1721
  for (let i = 0; i < lines.length; i++) {
1917
1722
  if (regex.test(lines[i])) {
1918
- const relativePath = path.relative(this.cwd, fullPath).replace(/\\/g, '/');
1919
1723
  results.push(`${relativePath}:${i + 1}:${lines[i]}`);
1920
1724
  if (results.length >= maxResults)
1921
1725
  break;
@@ -1957,7 +1761,7 @@ export class AgenticTools {
1957
1761
  return { success: false, error: `Directory not found: ${args.path}` };
1958
1762
  }
1959
1763
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1960
- const output = entries.map(e => {
1764
+ const output = entries.filter((entry) => !isSensitivePath(path.join(args.path || '.', entry.name))).map(e => {
1961
1765
  const suffix = e.isDirectory() ? '/' : '';
1962
1766
  return e.name + suffix;
1963
1767
  }).join('\n');
@@ -1966,8 +1770,17 @@ export class AgenticTools {
1966
1770
  glob(args) {
1967
1771
  const { globSync } = require('glob');
1968
1772
  try {
1969
- const files = globSync(args.pattern, { cwd: this.cwd });
1970
- return { success: true, output: files.join('\n') };
1773
+ const files = globSync(args.pattern, { cwd: this.cwd, follow: false, nodir: true });
1774
+ const contained = files.filter((file) => {
1775
+ try {
1776
+ this.resolvePath(file);
1777
+ return !isSensitivePath(file);
1778
+ }
1779
+ catch {
1780
+ return false;
1781
+ }
1782
+ });
1783
+ return { success: true, output: contained.join('\n') };
1971
1784
  }
1972
1785
  catch (error) {
1973
1786
  return { success: false, error: error.message };
@@ -1975,10 +1788,13 @@ export class AgenticTools {
1975
1788
  }
1976
1789
  git(args) {
1977
1790
  try {
1978
- const output = execSync(`git ${args.args}`, {
1791
+ const gitArgs = parseGitArguments(args.args);
1792
+ const output = execFileSync('git', hardenGitArguments(gitArgs), {
1979
1793
  cwd: this.cwd,
1980
1794
  encoding: 'utf-8',
1981
1795
  timeout: 60000,
1796
+ windowsHide: true,
1797
+ env: safeChildProcessEnv(safeGitEnvironment()),
1982
1798
  });
1983
1799
  return { success: true, output: output.trim() };
1984
1800
  }
@@ -1997,158 +1813,39 @@ export class AgenticTools {
1997
1813
  const targetPath = args.path || this.cwd;
1998
1814
  const description = args.description || '';
1999
1815
  try {
2000
- // Push action uses direct API call to Community server (bypasses interactive CLI)
2001
- if (action === 'push') {
2002
- if (!project) {
2003
- return {
2004
- success: false,
2005
- error: 'Project name is required for push',
2006
- suggestion: 'Provide a project name, e.g., repo action=push project=my-project'
2007
- };
2008
- }
2009
- // Load auth config
2010
- const configPath = path.join(process.env.HOME || '/root', '.config', 'vigthoria-cli', 'config.json');
2011
- if (!fs.existsSync(configPath)) {
2012
- return { success: false, error: 'Not logged in. Run vigthoria login first.' };
2013
- }
2014
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
2015
- const authToken = config.authToken;
2016
- const apiBase = config.apiUrl || 'https://coder.vigthoria.io';
2017
- if (!authToken) {
2018
- return { success: false, error: 'No auth token found. Run vigthoria login first.' };
2019
- }
2020
- // Build files array
2021
- let filesArray = [];
2022
- if (args.files) {
2023
- // AI passed files directly
2024
- let filesInput;
2025
- if (typeof args.files === 'string') {
2026
- try {
2027
- filesInput = JSON.parse(args.files);
2028
- }
2029
- catch {
2030
- filesInput = args.files;
2031
- }
2032
- }
2033
- else {
2034
- filesInput = args.files;
2035
- }
2036
- if (Array.isArray(filesInput)) {
2037
- // Already in [{path, content}] format
2038
- filesArray = filesInput.map((f) => ({ path: f.path || f.name || 'file', content: String(f.content || '') }));
2039
- }
2040
- else if (typeof filesInput === 'object' && filesInput !== null) {
2041
- // {filename: content} format - convert
2042
- for (const [filePath, content] of Object.entries(filesInput)) {
2043
- filesArray.push({ path: filePath, content: String(content) });
2044
- }
2045
- }
2046
- }
2047
- // If no files from AI, read from disk
2048
- if (filesArray.length === 0 && fs.existsSync(targetPath)) {
2049
- const readDir = (dir, base) => {
2050
- const entries = fs.readdirSync(dir, { withFileTypes: true });
2051
- for (const entry of entries) {
2052
- const fullPath = path.join(dir, entry.name);
2053
- const relPath = path.relative(base, fullPath);
2054
- // Skip hidden dirs, node_modules, common non-essential dirs
2055
- if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__')
2056
- continue;
2057
- if (entry.isDirectory()) {
2058
- readDir(fullPath, base);
2059
- }
2060
- else if (entry.isFile()) {
2061
- try {
2062
- const content = fs.readFileSync(fullPath, 'utf-8');
2063
- filesArray.push({ path: relPath, content });
2064
- }
2065
- catch { /* skip binary/unreadable files */ }
2066
- }
2067
- }
2068
- };
2069
- const stat = fs.statSync(targetPath);
2070
- if (stat.isDirectory()) {
2071
- readDir(targetPath, targetPath);
2072
- }
2073
- else {
2074
- filesArray.push({ path: path.basename(targetPath), content: fs.readFileSync(targetPath, 'utf-8') });
1816
+ // Every repository action invokes the canonical CLI route through an
1817
+ // argument vector. This prevents the agent tool from developing a second
1818
+ // credential, containment, mutation-retry, and payload policy.
1819
+ // Project,
1820
+ // user, permission, and destination values never enter a shell string.
1821
+ let cliArgs;
1822
+ const validRepoIdentifier = (value) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
1823
+ if (project && !validRepoIdentifier(project)) {
1824
+ return { success: false, error: 'Project name contains unsupported characters' };
1825
+ }
1826
+ switch (action) {
1827
+ case 'push':
1828
+ if (!project) {
1829
+ return {
1830
+ success: false,
1831
+ error: 'Project name is required for push',
1832
+ suggestion: 'Provide a project name, e.g., repo action=push project=my-project',
1833
+ };
2075
1834
  }
2076
- }
2077
- if (filesArray.length === 0) {
2078
- return { success: false, error: 'No files to push. Provide files parameter or a valid path.' };
2079
- }
2080
- // POST to Community API via Coder proxy (fallback to direct Community server)
2081
- const body = JSON.stringify({
2082
- projectName: project,
2083
- description: description || `${project} - pushed via Vigthoria AI`,
2084
- visibility,
2085
- files: filesArray,
2086
- commitMessage: `Push from Vigthoria AI: ${filesArray.length} file(s)`
2087
- });
2088
- const headers = {
2089
- 'Authorization': `Bearer ${authToken}`,
2090
- 'Content-Type': 'application/json'
2091
- };
2092
- // Try proxy first; direct Community server is only reachable when running on the
2093
- // Vigthoria backend itself (isServerRuntime), never expose this fallback to remote users.
2094
- const pushUrls = [`${apiBase}/api/community-repo/push`];
2095
- if (isServerRuntime()) {
2096
- const directHost = ['local', 'host'].join('');
2097
- pushUrls.push(`http://${directHost}:9000/api/repo/push`);
2098
- }
2099
- let result;
2100
- let lastError = '';
2101
- for (const pushUrl of pushUrls) {
2102
- try {
2103
- const response = await fetch(pushUrl, { method: 'POST', headers, body });
2104
- result = await response.json();
2105
- if (response.ok && result.success)
2106
- break;
2107
- if (result.code === 'REPOSITORY_SECURITY_REVIEW') {
2108
- return {
2109
- success: false,
2110
- error: result.error || 'Repository security review required',
2111
- metadata: {
2112
- code: result.code,
2113
- review: {
2114
- reviewId: result.reviewId,
2115
- status: result.status,
2116
- riskScore: result.riskScore,
2117
- findings: result.findings || [],
2118
- actionItems: result.actionItems || [],
2119
- retryCommand: result.retryCommand,
2120
- },
2121
- },
2122
- suggestion: `Inspect with "vigthoria repo review ${result.reviewId}" and retry corrected content with "${result.retryCommand || `vigthoria repo push --retry ${result.reviewId}`}".`,
2123
- };
2124
- }
2125
- lastError = result.error || result.message || `Status ${response.status}`;
2126
- result = null;
1835
+ if (args.files) {
1836
+ return {
1837
+ success: false,
1838
+ error: 'Inline repository payloads are prohibited. Write approved files inside the workspace before pushing.',
1839
+ suggestion: 'Use write_file for each approved workspace file, then call repo action=push with path.',
1840
+ };
2127
1841
  }
2128
- catch (e) {
2129
- lastError = e.message;
2130
- result = null;
1842
+ if (!['private', 'restricted', 'public'].includes(visibility)) {
1843
+ return { success: false, error: 'Repository visibility is invalid' };
2131
1844
  }
2132
- }
2133
- if (!result || !result.success) {
2134
- return {
2135
- success: false,
2136
- error: lastError || 'Push failed on all endpoints',
2137
- suggestion: 'Check your authentication and try again. Run vigthoria login to refresh your token.'
2138
- };
2139
- }
2140
- return {
2141
- success: true,
2142
- output: `Successfully pushed "${project}" to Vigthoria Community!\n` +
2143
- `Action: ${result.action || 'created'}\n` +
2144
- `Files: ${result.filesWritten || filesArray.length}\n` +
2145
- `URL: ${result.url || `https://community.vigthoria.io/showcase/${result.projectId}`}`,
2146
- metadata: { action: 'push', project, filesWritten: result.filesWritten || filesArray.length, url: result.url }
2147
- };
2148
- }
2149
- // All other actions use CLI commands
2150
- let command;
2151
- switch (action) {
1845
+ cliArgs = ['repo', 'push', this.resolvePath(targetPath), '--name', project, '--visibility', visibility, '--yes'];
1846
+ if (description)
1847
+ cliArgs.push('--description', description);
1848
+ break;
2152
1849
  case 'pull':
2153
1850
  if (!project) {
2154
1851
  return {
@@ -2157,10 +1854,10 @@ export class AgenticTools {
2157
1854
  suggestion: 'Provide a project name, e.g., repo action=pull project=my-project'
2158
1855
  };
2159
1856
  }
2160
- command = `vigthoria repo pull "${project}"`;
1857
+ cliArgs = ['repo', 'pull', project];
2161
1858
  break;
2162
1859
  case 'list':
2163
- command = 'vigthoria repo list';
1860
+ cliArgs = ['repo', 'list'];
2164
1861
  break;
2165
1862
  case 'status':
2166
1863
  if (!project) {
@@ -2170,7 +1867,7 @@ export class AgenticTools {
2170
1867
  suggestion: 'Provide a project name, e.g., repo action=status project=my-project'
2171
1868
  };
2172
1869
  }
2173
- command = `vigthoria repo status "${project}"`;
1870
+ cliArgs = ['repo', 'status', project];
2174
1871
  break;
2175
1872
  case 'share':
2176
1873
  if (!project || !args.username) {
@@ -2181,7 +1878,10 @@ export class AgenticTools {
2181
1878
  };
2182
1879
  }
2183
1880
  const permission = args.permission || 'read';
2184
- command = `vigthoria repo share "${project}" "${args.username}" --permission ${permission}`;
1881
+ if (!validRepoIdentifier(args.username) || !['read', 'write', 'admin'].includes(permission)) {
1882
+ return { success: false, error: 'Repository username or permission is invalid' };
1883
+ }
1884
+ cliArgs = ['repo', 'share', project, args.username, '--permission', permission];
2185
1885
  break;
2186
1886
  case 'delete':
2187
1887
  if (!project) {
@@ -2191,7 +1891,7 @@ export class AgenticTools {
2191
1891
  suggestion: 'Provide a project name, e.g., repo action=delete project=my-project'
2192
1892
  };
2193
1893
  }
2194
- command = `vigthoria repo delete "${project}" --force`;
1894
+ cliArgs = ['repo', 'delete', project, '--force'];
2195
1895
  break;
2196
1896
  case 'clone':
2197
1897
  if (!project) {
@@ -2201,7 +1901,7 @@ export class AgenticTools {
2201
1901
  suggestion: 'Provide a project name, e.g., repo action=clone project=my-project path=/path/to/target'
2202
1902
  };
2203
1903
  }
2204
- command = `vigthoria repo clone "${project}" "${targetPath}"`;
1904
+ cliArgs = ['repo', 'clone', project, this.resolvePath(targetPath)];
2205
1905
  break;
2206
1906
  default:
2207
1907
  return {
@@ -2210,11 +1910,12 @@ export class AgenticTools {
2210
1910
  suggestion: 'Available actions: push, pull, list, status, share, delete, clone'
2211
1911
  };
2212
1912
  }
2213
- const output = execSync(command, {
1913
+ const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
1914
+ const output = execFileSync(process.execPath, [cliEntry, ...cliArgs], {
2214
1915
  cwd: this.cwd,
2215
1916
  encoding: 'utf-8',
2216
1917
  timeout: 120000,
2217
- env: { ...process.env, FORCE_COLOR: '0' }
1918
+ env: safeChildProcessEnv(process.env, { FORCE_COLOR: '0' })
2218
1919
  });
2219
1920
  return {
2220
1921
  success: true,
@@ -2508,25 +2209,26 @@ export class AgenticTools {
2508
2209
  }
2509
2210
  }
2510
2211
  try {
2511
- const os = require('os');
2512
- const platform = os.platform();
2513
- let sshCommand;
2514
- let execOptions = {
2515
- encoding: 'utf-8',
2212
+ const processRequest = parseSupportedProcess(command);
2213
+ const remoteExecutable = path.basename(processRequest.executable);
2214
+ const sshArgs = [
2215
+ '-o', 'BatchMode=yes',
2216
+ '-o', 'StrictHostKeyChecking=accept-new',
2217
+ '-o', 'ConnectTimeout=10',
2218
+ normalizedHost,
2219
+ '--',
2220
+ remoteExecutable,
2221
+ ...processRequest.args,
2222
+ ];
2223
+ assertProcessNetworkAllowed('ssh', sshArgs);
2224
+ const execOptions = {
2225
+ encoding: 'utf8',
2516
2226
  timeout,
2517
2227
  maxBuffer: 10 * 1024 * 1024,
2228
+ windowsHide: true,
2229
+ env: safeChildProcessEnv(),
2518
2230
  };
2519
- if (platform === 'win32') {
2520
- // On Windows, use the ssh command from OpenSSH
2521
- sshCommand = `ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 ${host} "${command.replace(/"/g, '\\"')}"`;
2522
- execOptions.shell = true;
2523
- }
2524
- else {
2525
- // On Unix-like systems
2526
- sshCommand = `ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 ${host} '${command.replace(/'/g, "'\\''")}'`;
2527
- execOptions.shell = '/bin/sh';
2528
- }
2529
- const output = execSync(sshCommand, execOptions);
2231
+ const output = execFileSync('ssh', sshArgs, execOptions);
2530
2232
  return {
2531
2233
  success: true,
2532
2234
  output: output.trim(),
@@ -2610,23 +2312,25 @@ export class AgenticTools {
2610
2312
  else {
2611
2313
  resolvedPath = path.normalize(path.join(this.cwd, safeInput));
2612
2314
  }
2613
- // SECURITY CHECK: Ensure path is within workspace
2614
- if (!resolvedPath.startsWith(workspaceRoot + path.sep) && resolvedPath !== workspaceRoot) {
2615
- // Path is outside workspace - force it back to workspace
2616
- this.logger.warn(`Security: Blocked access to path outside workspace: ${p}`);
2617
- // Return the sanitized relative path within workspace
2618
- const basename = path.basename(stripWorkspacePrefix(normalizedInput) || normalizedInput);
2619
- return path.join(this.cwd, basename);
2620
- }
2621
- return resolvedPath;
2315
+ return assertContainedAbsolutePath(workspaceRoot, resolvedPath, {
2316
+ allowMissing: true,
2317
+ allowRoot: true,
2318
+ });
2622
2319
  }
2623
2320
  /**
2624
2321
  * Check if a path is within the allowed workspace
2625
2322
  */
2626
2323
  isPathWithinWorkspace(p) {
2627
- const resolvedPath = path.normalize(path.isAbsolute(p) ? p : path.join(this.cwd, p));
2628
- const workspaceRoot = path.normalize(this.cwd);
2629
- return resolvedPath.startsWith(workspaceRoot + path.sep) || resolvedPath === workspaceRoot;
2324
+ try {
2325
+ assertContainedAbsolutePath(this.cwd, path.isAbsolute(p) ? p : path.join(this.cwd, p), {
2326
+ allowMissing: true,
2327
+ allowRoot: true,
2328
+ });
2329
+ return true;
2330
+ }
2331
+ catch {
2332
+ return false;
2333
+ }
2630
2334
  }
2631
2335
  // ═══════════════════════════════════════════════════════════════
2632
2336
  // TASK (Sub-Agent) Tool
@@ -2660,10 +2364,10 @@ export class AgenticTools {
2660
2364
  try {
2661
2365
  for (let turn = 0; turn < maxSubTurns; turn++) {
2662
2366
  // Import api dynamically to avoid circular dependency
2663
- const { APIClient } = await import('./api.js');
2367
+ const { createAPIClient } = await import('./api-client-factory.js');
2664
2368
  const { Config } = await import('./config.js');
2665
2369
  const config = new Config();
2666
- const api = new APIClient(config, this.logger);
2370
+ const api = createAPIClient(config, this.logger);
2667
2371
  const response = await api.chat(messages, 'code');
2668
2372
  const assistantMessage = response.message || '';
2669
2373
  messages.push({ role: 'assistant', content: assistantMessage });
@@ -2822,6 +2526,9 @@ export class AgenticTools {
2822
2526
  if (ignorePatterns.includes(entry.name) || entry.name.startsWith('.'))
2823
2527
  continue;
2824
2528
  const fullPath = path.join(currentDir, entry.name);
2529
+ const relativePath = path.relative(this.cwd, fullPath).replace(/\\/g, '/');
2530
+ if (isSensitivePath(relativePath))
2531
+ continue;
2825
2532
  if (entry.isDirectory()) {
2826
2533
  walk(fullPath, depth + 1);
2827
2534
  }
@@ -2830,13 +2537,15 @@ export class AgenticTools {
2830
2537
  const basename = entry.name;
2831
2538
  // Simple glob matching for extension patterns like *.ts, *.js
2832
2539
  const globPattern = pattern.replace(/\*\*\//g, '(.*/)?').replace(/\*/g, '[^/]*').replace(/\?/g, '.');
2833
- if (new RegExp(globPattern, 'i').test(basename) || fullPath.includes(pattern.replace(/\*/g, ''))) {
2834
- files.push(fullPath);
2835
- }
2540
+ if (!new RegExp(globPattern, 'i').test(basename) && !fullPath.includes(pattern.replace(/\*/g, '')))
2541
+ continue;
2836
2542
  }
2837
- else {
2838
- files.push(fullPath);
2543
+ try {
2544
+ const content = fs.readFileSync(fullPath, 'utf8');
2545
+ if (!containsHighConfidenceSecret(content))
2546
+ files.push(fullPath);
2839
2547
  }
2548
+ catch { /* unreadable files are excluded from agent context */ }
2840
2549
  }
2841
2550
  }
2842
2551
  };
@@ -2913,12 +2622,12 @@ export class AgenticTools {
2913
2622
  if (includePattern)
2914
2623
  rgArgs.push('-g', includePattern);
2915
2624
  rgArgs.push('--', query, this.cwd);
2916
- const isWin = process.platform === 'win32';
2917
- const quote = (s) => isWin ? `"${s}"` : `'${s}'`;
2918
- const rgOutput = execSync(`rg ${rgArgs.map(a => quote(a)).join(' ')}`, {
2625
+ const rgOutput = execFileSync('rg', rgArgs, {
2919
2626
  encoding: 'utf-8',
2920
2627
  timeout: 15000,
2921
2628
  maxBuffer: 5 * 1024 * 1024,
2629
+ windowsHide: true,
2630
+ env: safeChildProcessEnv(),
2922
2631
  }).trim();
2923
2632
  for (const line of rgOutput.split('\n').slice(0, maxResults - results.length)) {
2924
2633
  if (!line.trim())
@@ -3032,7 +2741,6 @@ export class AgenticTools {
3032
2741
  .replace(/\t/g, '\\t'); // Escape tabs
3033
2742
  // Escape literal newlines inside strings (but not \n which is already escaped)
3034
2743
  // We need to be careful - only escape newlines that are inside quoted strings
3035
- const parts = [];
3036
2744
  let inString = false;
3037
2745
  let currentPart = '';
3038
2746
  for (let i = 0; i < fixed.length; i++) {