vigthoria-cli 1.13.20 → 1.13.23

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 (187) 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 +63 -128
  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 +3 -42
  12. package/dist/commands/chat.js +378 -1123
  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 +23 -29
  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 +207 -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 +244 -198
  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 +368 -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 +124 -1589
  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 +44 -266
  71. package/dist/utils/api.js +647 -3180
  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 +220 -94
  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 +5 -6
  106. package/dist/utils/fastAgentRouter.js +17 -25
  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 +15 -0
  128. package/dist/utils/preview-screenshot-adapter.js +51 -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/runtime-temp.d.ts +76 -0
  143. package/dist/utils/runtime-temp.js +492 -0
  144. package/dist/utils/secret-policy.d.ts +20 -0
  145. package/dist/utils/secret-policy.js +160 -0
  146. package/dist/utils/session.d.ts +17 -3
  147. package/dist/utils/session.js +354 -110
  148. package/dist/utils/tool-approval-service.d.ts +17 -0
  149. package/dist/utils/tool-approval-service.js +78 -0
  150. package/dist/utils/tool-capability-providers.d.ts +57 -0
  151. package/dist/utils/tool-capability-providers.js +57 -0
  152. package/dist/utils/tools.d.ts +2 -23
  153. package/dist/utils/tools.js +226 -512
  154. package/dist/utils/update-policy.d.ts +18 -0
  155. package/dist/utils/update-policy.js +137 -0
  156. package/dist/utils/v3-agent-client.d.ts +40 -0
  157. package/dist/utils/v3-agent-client.js +165 -0
  158. package/dist/utils/v3-workspace-service.d.ts +28 -0
  159. package/dist/utils/v3-workspace-service.js +279 -0
  160. package/dist/utils/vigflow-client.d.ts +95 -0
  161. package/dist/utils/vigflow-client.js +105 -0
  162. package/dist/utils/workspace-boundary.d.ts +45 -0
  163. package/dist/utils/workspace-boundary.js +377 -0
  164. package/dist/utils/workspace-brain-service.d.ts +2 -0
  165. package/dist/utils/workspace-brain-service.js +11 -1
  166. package/dist/utils/workspace-cache.js +37 -15
  167. package/dist/utils/workspace-stream.js +35 -10
  168. package/install.ps1 +115 -142
  169. package/install.sh +112 -81
  170. package/package.json +43 -9
  171. package/release-policy.json +43 -0
  172. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +1 -1
  173. package/scripts/release/generate-completions.mjs +24 -0
  174. package/scripts/release/install-release.mjs +10 -0
  175. package/scripts/release/publish-cli-release.mjs +116 -0
  176. package/scripts/release/publish-cli-release.sh +2 -70
  177. package/scripts/release/resolve-release-manifest.mjs +26 -0
  178. package/scripts/release/test-balanced-model-live.sh +45 -0
  179. package/scripts/release/validate-live-service-gates.sh +93 -0
  180. package/scripts/release/validate-no-go-gates.sh +145 -174
  181. package/scripts/release/verify-runtime-consistency.mjs +4 -1
  182. package/dist/commands/index.d.ts +0 -12
  183. package/dist/commands/index.js +0 -194
  184. package/dist/commands/update.d.ts +0 -9
  185. package/dist/commands/update.js +0 -301
  186. package/dist/utils/templateInstantPath.d.ts +0 -18
  187. package/dist/utils/templateInstantPath.js +0 -228
@@ -15,18 +15,23 @@
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 { createRuntimeTempDirectory, removeRuntimeTempDirectory } from './runtime-temp.js';
32
+ import { ToolApprovalService } from './tool-approval-service.js';
33
+ import { DelegationToolProvider, FilesystemToolProvider, NetworkBrowserToolProvider, ProcessToolProvider, RepositoryToolProvider, SearchToolProvider, SshToolProvider, ToolCapabilityRegistry, } from './tool-capability-providers.js';
27
34
  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
35
  const SSH_SAFE_HOST_PATTERN = /^[A-Za-z0-9.-]+$/;
31
36
  function getSshAllowedHosts() {
32
37
  const configured = String(process.env.VIGTHORIA_SSH_ALLOWED_HOSTS || '')
@@ -58,6 +63,9 @@ function resolveWindowsInstallerPath() {
58
63
  export async function installUpdateWindows() {
59
64
  const installerPath = resolveWindowsInstallerPath();
60
65
  try {
66
+ if (isOfflineNetworkMode()) {
67
+ return { success: false, platform: 'windows', error: 'Offline mode prohibits launching the update installer' };
68
+ }
61
69
  if (!fs.existsSync(installerPath)) {
62
70
  return { success: false, platform: 'windows', error: 'ENOENT' };
63
71
  }
@@ -68,9 +76,18 @@ export async function installUpdateWindows() {
68
76
  detached: true,
69
77
  stdio: 'ignore',
70
78
  windowsHide: false,
79
+ env: safeChildProcessEnv(),
80
+ });
81
+ const timer = setTimeout(() => {
82
+ child.kill();
83
+ reject(new Error('Windows update installer did not start within 10 seconds'));
84
+ }, 10_000);
85
+ child.once('error', (error) => {
86
+ clearTimeout(timer);
87
+ reject(error);
71
88
  });
72
- child.once('error', reject);
73
89
  child.once('spawn', () => {
90
+ clearTimeout(timer);
74
91
  child.unref();
75
92
  resolve();
76
93
  });
@@ -89,7 +106,6 @@ export async function* robustifyStreamResponse(res) {
89
106
  const MAX_YIELD_CHARS = STREAM_RESPONSE_MAX_YIELD_CHARS;
90
107
  const MAX_BUFFER_CHARS = 256 * 1024;
91
108
  const body = res?.body ?? res;
92
- const decoder = new TextDecoder('utf-8');
93
109
  let reader = null;
94
110
  let lineBuffer = '';
95
111
  const emitContent = async function* (type, content) {
@@ -330,6 +346,8 @@ export class AgenticTools {
330
346
  cwd;
331
347
  permissionCallback;
332
348
  autoApprove;
349
+ approvals;
350
+ capabilityRegistry;
333
351
  undoStack = [];
334
352
  maxUndoStack = 50;
335
353
  retryConfig = {
@@ -353,17 +371,6 @@ export class AgenticTools {
353
371
  }
354
372
  return `External tool call failed in ${toolName} during ${operation}: ${String(error)}`;
355
373
  }
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
374
  cleanupAfterToolError(toolName, operation, cleanup) {
368
375
  try {
369
376
  cleanup();
@@ -374,29 +381,6 @@ export class AgenticTools {
374
381
  this.logger.warn(message);
375
382
  }
376
383
  }
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
384
  isNonEmptyString(value) {
401
385
  return typeof value === 'string' && value.trim().length > 0;
402
386
  }
@@ -422,19 +406,6 @@ export class AgenticTools {
422
406
  }
423
407
  }
424
408
  }
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
409
  constructor(logger, cwd, permissionCallback, autoApprove = false) {
439
410
  if (!logger) {
440
411
  throw new Error('AgenticTools initialization failed: logger is required.');
@@ -449,9 +420,23 @@ export class AgenticTools {
449
420
  throw new Error('AgenticTools initialization failed: autoApprove must be a boolean.');
450
421
  }
451
422
  this.logger = logger;
452
- this.cwd = cwd;
423
+ this.cwd = fs.realpathSync(path.resolve(cwd));
453
424
  this.permissionCallback = permissionCallback;
454
425
  this.autoApprove = autoApprove;
426
+ this.approvals = new ToolApprovalService(this.cwd, logger);
427
+ this.capabilityRegistry = new ToolCapabilityRegistry([
428
+ new FilesystemToolProvider({
429
+ read_file: (args) => this.readFile(args), write_file: (args) => this.writeFile(args),
430
+ edit_file: (args) => this.editFile(args), list_dir: (args) => this.listDir(args),
431
+ glob: (args) => this.glob(args), multi_edit: (args) => this.multiEdit(args),
432
+ }),
433
+ new ProcessToolProvider({ bash: (args) => this.bash(args) }),
434
+ new SearchToolProvider({ grep: (args) => this.grep(args), codebase_search: (args) => this.codebaseSearch(args) }),
435
+ new RepositoryToolProvider({ git: (args) => this.git(args), repo: (args) => this.repo(args) }),
436
+ new NetworkBrowserToolProvider({ fetch_url: (args) => this.fetchUrl(args), browser: (args) => this.browserTool(args) }),
437
+ new SshToolProvider({ ssh_exec: (args) => this.sshExec(args) }),
438
+ new DelegationToolProvider({ task: (args) => this.task(args) }),
439
+ ]);
455
440
  }
456
441
  getErrorMessage(error) {
457
442
  if (error instanceof Error) {
@@ -467,85 +452,17 @@ export class AgenticTools {
467
452
  return String(error);
468
453
  }
469
454
  }
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
455
  /**
539
456
  * Clear session-approved tools (call this at the start of each new AI turn)
540
457
  */
541
458
  clearSessionApprovals() {
542
- this.sessionApprovedTools.clear();
459
+ this.approvals.clearSession();
543
460
  }
544
461
  /**
545
462
  * Get currently approved tools for this session
546
463
  */
547
464
  getSessionApprovedTools() {
548
- return Array.from(this.sessionApprovedTools);
465
+ return this.approvals.sessionKeys();
549
466
  }
550
467
  /**
551
468
  * Get the undo stack for inspection
@@ -647,7 +564,7 @@ export class AgenticTools {
647
564
  },
648
565
  {
649
566
  name: 'bash',
650
- description: 'Run a shell command',
567
+ description: 'Run one supported process using a parsed argument vector (no shell syntax)',
651
568
  parameters: [
652
569
  { name: 'command', description: 'Shell command to execute', required: true },
653
570
  { name: 'cwd', description: 'Working directory', required: false },
@@ -835,11 +752,17 @@ export class AgenticTools {
835
752
  }
836
753
  // Check permission for dangerous/modifying actions
837
754
  if (tool.requiresPermission && !this.autoApprove) {
838
- // Check persistent permissions first (project-scoped), then session
839
- if (this.hasPersistentPermission(normalizedCall.tool)) {
755
+ let approvalScope;
756
+ try {
757
+ approvalScope = buildApprovalScope(normalizedCall.tool, normalizedCall.args, this.cwd);
758
+ }
759
+ catch (error) {
760
+ return this.createErrorResult(ToolErrorType.INVALID_ARGS, this.getErrorMessage(error));
761
+ }
762
+ if (this.approvals.hasPersistent(approvalScope)) {
840
763
  this.logger.info(`${call.tool}: Auto-approved (persistent)`);
841
764
  }
842
- else if (this.sessionApprovedTools.has(normalizedCall.tool)) {
765
+ else if (this.approvals.hasSession(approvalScope)) {
843
766
  // Already approved - skip permission prompt
844
767
  this.logger.info(`${call.tool}: Auto-approved (batch)`);
845
768
  }
@@ -856,17 +779,16 @@ export class AgenticTools {
856
779
  // 'persist' (typed 'p') = approve permanently for this project
857
780
  // 'y' or 'yes' = approve this single request only
858
781
  if (approved === 'batch') {
859
- this.sessionApprovedTools.add(normalizedCall.tool);
782
+ this.approvals.recordSession(approvalScope);
860
783
  }
861
784
  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`);
785
+ this.approvals.recordPersistent(approvalScope);
786
+ this.logger.info(`${normalizedCall.tool}: Saved exact operation as persistent permission`);
865
787
  }
866
788
  }
867
789
  }
868
790
  // Execute with retry logic for applicable operations
869
- return this.executeWithRetry(normalizedCall, tool);
791
+ return this.executeWithRetry(normalizedCall);
870
792
  }
871
793
  normalizeToolCall(call) {
872
794
  const tool = String(call.tool || '').trim();
@@ -912,7 +834,7 @@ export class AgenticTools {
912
834
  /**
913
835
  * Execute tool with automatic retry for transient failures
914
836
  */
915
- async executeWithRetry(call, tool) {
837
+ async executeWithRetry(call) {
916
838
  let lastError = null;
917
839
  for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
918
840
  try {
@@ -980,40 +902,7 @@ export class AgenticTools {
980
902
  message,
981
903
  };
982
904
  }
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
- }
905
+ return await this.capabilityRegistry.execute(call);
1017
906
  }
1018
907
  catch (error) {
1019
908
  const context = `Tool '${call.tool}' failed while executing with args ${this.safeStringifyArgs(call.args)}`;
@@ -1216,7 +1105,7 @@ export class AgenticTools {
1216
1105
  msg += chalk.red(' The AI is requesting to execute commands on your system.\n');
1217
1106
  }
1218
1107
  // 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`;
1108
+ 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
1109
  return msg;
1221
1110
  }
1222
1111
  sleep(ms) {
@@ -1227,6 +1116,9 @@ export class AgenticTools {
1227
1116
  * Read file with enhanced error handling and suggestions
1228
1117
  */
1229
1118
  readFile(args) {
1119
+ if (isSensitivePath(args.path)) {
1120
+ return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, `Sensitive file is excluded from agent context: ${path.basename(args.path)}`);
1121
+ }
1230
1122
  const filePath = this.resolvePath(args.path);
1231
1123
  if (!fs.existsSync(filePath)) {
1232
1124
  // Try to find similar files
@@ -1322,7 +1214,7 @@ export class AgenticTools {
1322
1214
  };
1323
1215
  this.undoStack.push(undoOp);
1324
1216
  // Limit undo stack size
1325
- if (this.undoStack.length > 50) {
1217
+ if (this.undoStack.length > this.maxUndoStack) {
1326
1218
  this.undoStack.shift();
1327
1219
  }
1328
1220
  const isNew = originalContent === null;
@@ -1395,7 +1287,7 @@ export class AgenticTools {
1395
1287
  };
1396
1288
  this.undoStack.push(undoOp);
1397
1289
  // Limit undo stack size
1398
- if (this.undoStack.length > 50) {
1290
+ if (this.undoStack.length > this.maxUndoStack) {
1399
1291
  this.undoStack.shift();
1400
1292
  }
1401
1293
  // Show syntax-highlighted diff
@@ -1450,7 +1342,14 @@ export class AgenticTools {
1450
1342
  return errors;
1451
1343
  }
1452
1344
  validateJavaScriptSyntax(source) {
1453
- const tempFile = path.join(this.cwd, `.vigthoria-temp-${Date.now()}.js`);
1345
+ let tempDirectory;
1346
+ try {
1347
+ tempDirectory = createRuntimeTempDirectory('syntax-');
1348
+ }
1349
+ catch (error) {
1350
+ return this.formatExternalToolError('syntax_check', 'allocate bounded temporary storage', error);
1351
+ }
1352
+ const tempFile = path.join(tempDirectory, 'candidate.js');
1454
1353
  try {
1455
1354
  try {
1456
1355
  fs.writeFileSync(tempFile, source, 'utf-8');
@@ -1459,7 +1358,7 @@ export class AgenticTools {
1459
1358
  return this.formatExternalToolError('syntax_check', `write temporary file ${tempFile}`, writeError);
1460
1359
  }
1461
1360
  try {
1462
- this.runExternalCommand('syntax_check', `node --check ${tempFile}`, `node --check "${tempFile}"`, { stdio: 'pipe' });
1361
+ execFileSync(process.execPath, ['--check', tempFile], { stdio: 'pipe', timeout: 10_000, windowsHide: true, env: safeChildProcessEnv() });
1463
1362
  return null;
1464
1363
  }
1465
1364
  catch (error) {
@@ -1469,9 +1368,7 @@ export class AgenticTools {
1469
1368
  }
1470
1369
  finally {
1471
1370
  this.cleanupAfterToolError('syntax_check', `remove temporary file ${tempFile}`, () => {
1472
- if (fs.existsSync(tempFile)) {
1473
- fs.unlinkSync(tempFile);
1474
- }
1371
+ removeRuntimeTempDirectory(tempDirectory);
1475
1372
  });
1476
1373
  }
1477
1374
  }
@@ -1615,30 +1512,21 @@ export class AgenticTools {
1615
1512
  }
1616
1513
  try {
1617
1514
  const startTime = Date.now();
1618
- // Build exec options based on platform
1515
+ const processRequest = parseSupportedProcess(args.command);
1619
1516
  const execOptions = {
1620
1517
  cwd,
1621
1518
  timeout,
1622
- encoding: 'utf-8',
1519
+ encoding: 'utf8',
1623
1520
  maxBuffer: 10 * 1024 * 1024, // 10MB
1624
1521
  stdio: ['pipe', 'pipe', 'pipe'],
1625
1522
  // Ensure consistent environment
1626
- env: {
1627
- ...process.env,
1628
- // Prevent locale issues
1629
- LC_ALL: 'C',
1630
- LANG: 'C',
1631
- },
1523
+ env: safeChildProcessEnv(process.env, { LC_ALL: 'C', LANG: 'C' }),
1632
1524
  };
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);
1525
+ const output = execFileSync(processRequest.executable, processRequest.args, {
1526
+ ...execOptions,
1527
+ encoding: 'utf8',
1528
+ stdio: ['pipe', 'pipe', 'pipe'],
1529
+ });
1642
1530
  const duration = Date.now() - startTime;
1643
1531
  return {
1644
1532
  success: true,
@@ -1651,8 +1539,23 @@ export class AgenticTools {
1651
1539
  }
1652
1540
  catch (error) {
1653
1541
  // Command failed but may have output
1654
- const output = error.stdout || '';
1655
- const stderr = error.stderr || '';
1542
+ // runExternalCommand deliberately wraps child_process failures so every
1543
+ // external tool gets a consistent message. Preserve the underlying
1544
+ // ExecException here; otherwise stdout/stderr/exit status disappear and
1545
+ // remote executors receive only the unhelpful "Command failed" wrapper.
1546
+ const processError = error?.cause && typeof error.cause === 'object'
1547
+ ? error.cause
1548
+ : error;
1549
+ const decodeProcessText = (value) => {
1550
+ if (Buffer.isBuffer(value))
1551
+ return value.toString('utf8');
1552
+ return typeof value === 'string' ? value : '';
1553
+ };
1554
+ const output = decodeProcessText(processError?.stdout);
1555
+ const stderr = decodeProcessText(processError?.stderr);
1556
+ const exitStatus = Number.isInteger(processError?.status)
1557
+ ? ` (exit ${processError.status})`
1558
+ : '';
1656
1559
  // Provide helpful error messages for common cases
1657
1560
  if (error.killed) {
1658
1561
  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 +1570,9 @@ export class AgenticTools {
1667
1570
  return {
1668
1571
  success: false,
1669
1572
  output,
1670
- error: stderr || error.message,
1573
+ error: stderr || `${error.message}${exitStatus}`,
1671
1574
  suggestion: 'Check command syntax and try again.',
1672
- canRetry: !error.killed,
1575
+ canRetry: !processError?.killed,
1673
1576
  };
1674
1577
  }
1675
1578
  }
@@ -1685,7 +1588,7 @@ export class AgenticTools {
1685
1588
  if (platform === 'win32') {
1686
1589
  return this.grepWindows(args, searchPath);
1687
1590
  }
1688
- return this.grepUnix(args, searchPath, platform);
1591
+ return this.grepUnix(args, searchPath);
1689
1592
  }
1690
1593
  /**
1691
1594
  * Windows grep: rg > Select-String > Node-native
@@ -1693,96 +1596,51 @@ export class AgenticTools {
1693
1596
  grepWindows(args, searchPath) {
1694
1597
  // 1. Try ripgrep (rg) first — best cross-platform search tool
1695
1598
  try {
1696
- execSync('rg --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
1599
+ execFileSync('rg', ['--version'], { encoding: 'utf-8', timeout: 5000, stdio: 'pipe', windowsHide: true, env: safeChildProcessEnv() });
1697
1600
  return this.grepWithRg(args, searchPath);
1698
1601
  }
1699
1602
  catch (error) {
1700
1603
  this.logger.debug(this.formatExternalToolError('grep', 'checking ripgrep availability on Windows', error));
1701
1604
  }
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
1605
+ // Fall back to the Node-native scanner. Avoid constructing a PowerShell
1606
+ // program from model-controlled patterns.
1710
1607
  return this.grepNodeNative(args, searchPath);
1711
1608
  }
1712
1609
  /**
1713
1610
  * Unix grep: rg > system grep (BSD/GNU)
1714
1611
  */
1715
- grepUnix(args, searchPath, platform) {
1612
+ grepUnix(args, searchPath) {
1716
1613
  // Try ripgrep first if available
1717
1614
  try {
1718
- this.runExternalCommand('grep', 'checking ripgrep availability on Unix', 'rg --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
1615
+ execFileSync('rg', ['--version'], { encoding: 'utf-8', timeout: 5000, stdio: 'pipe', windowsHide: true, env: safeChildProcessEnv() });
1719
1616
  return this.grepWithRg(args, searchPath);
1720
1617
  }
1721
1618
  catch (error) {
1722
1619
  this.logger.debug(this.formatExternalToolError('grep', 'checking ripgrep availability on Unix', error));
1723
1620
  // rg not available, fall through to system grep
1724
1621
  }
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
- }
1622
+ return this.grepNodeNative(args, searchPath);
1770
1623
  }
1771
1624
  /**
1772
1625
  * Search using ripgrep (rg) — works on all platforms
1773
1626
  */
1774
1627
  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}"`;
1628
+ const rgArgs = [
1629
+ '-n', '--no-heading',
1630
+ '-g', '!.git/**', '-g', '!.vigthoria/**', '-g', '!.env', '-g', '!.env.*',
1631
+ '-g', '!**/credentials*', '-g', '!**/secrets*', '-g', '!**/*.pem', '-g', '!**/*.key',
1632
+ ];
1633
+ if (args.include)
1634
+ rgArgs.push('-g', args.include);
1635
+ rgArgs.push('--', args.pattern, searchPath);
1780
1636
  try {
1781
- const output = this.runExternalCommand('grep', 'ripgrep search', cmd, {
1637
+ const output = execFileSync('rg', rgArgs, {
1782
1638
  cwd: this.cwd,
1783
- encoding: 'utf-8',
1639
+ encoding: 'utf8',
1784
1640
  maxBuffer: 5 * 1024 * 1024,
1785
1641
  timeout: 30000,
1642
+ windowsHide: true,
1643
+ env: safeChildProcessEnv(),
1786
1644
  });
1787
1645
  return {
1788
1646
  success: true,
@@ -1801,56 +1659,6 @@ export class AgenticTools {
1801
1659
  throw error; // Let caller try next backend
1802
1660
  }
1803
1661
  }
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
1662
  /**
1855
1663
  * Pure Node.js recursive file search — reliable on all platforms, no external deps
1856
1664
  */
@@ -1897,6 +1705,9 @@ export class AgenticTools {
1897
1705
  }
1898
1706
  if (!entry.isFile())
1899
1707
  continue;
1708
+ const relativePath = path.relative(this.cwd, fullPath).replace(/\\/g, '/');
1709
+ if (isSensitivePath(relativePath))
1710
+ continue;
1900
1711
  // Check include pattern
1901
1712
  if (includeGlob) {
1902
1713
  const matches = minimatch
@@ -1915,7 +1726,6 @@ export class AgenticTools {
1915
1726
  const lines = content.split('\n');
1916
1727
  for (let i = 0; i < lines.length; i++) {
1917
1728
  if (regex.test(lines[i])) {
1918
- const relativePath = path.relative(this.cwd, fullPath).replace(/\\/g, '/');
1919
1729
  results.push(`${relativePath}:${i + 1}:${lines[i]}`);
1920
1730
  if (results.length >= maxResults)
1921
1731
  break;
@@ -1957,7 +1767,7 @@ export class AgenticTools {
1957
1767
  return { success: false, error: `Directory not found: ${args.path}` };
1958
1768
  }
1959
1769
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1960
- const output = entries.map(e => {
1770
+ const output = entries.filter((entry) => !isSensitivePath(path.join(args.path || '.', entry.name))).map(e => {
1961
1771
  const suffix = e.isDirectory() ? '/' : '';
1962
1772
  return e.name + suffix;
1963
1773
  }).join('\n');
@@ -1966,8 +1776,17 @@ export class AgenticTools {
1966
1776
  glob(args) {
1967
1777
  const { globSync } = require('glob');
1968
1778
  try {
1969
- const files = globSync(args.pattern, { cwd: this.cwd });
1970
- return { success: true, output: files.join('\n') };
1779
+ const files = globSync(args.pattern, { cwd: this.cwd, follow: false, nodir: true });
1780
+ const contained = files.filter((file) => {
1781
+ try {
1782
+ this.resolvePath(file);
1783
+ return !isSensitivePath(file);
1784
+ }
1785
+ catch {
1786
+ return false;
1787
+ }
1788
+ });
1789
+ return { success: true, output: contained.join('\n') };
1971
1790
  }
1972
1791
  catch (error) {
1973
1792
  return { success: false, error: error.message };
@@ -1975,10 +1794,13 @@ export class AgenticTools {
1975
1794
  }
1976
1795
  git(args) {
1977
1796
  try {
1978
- const output = execSync(`git ${args.args}`, {
1797
+ const gitArgs = parseGitArguments(args.args);
1798
+ const output = execFileSync('git', hardenGitArguments(gitArgs), {
1979
1799
  cwd: this.cwd,
1980
1800
  encoding: 'utf-8',
1981
1801
  timeout: 60000,
1802
+ windowsHide: true,
1803
+ env: safeChildProcessEnv(safeGitEnvironment()),
1982
1804
  });
1983
1805
  return { success: true, output: output.trim() };
1984
1806
  }
@@ -1997,158 +1819,39 @@ export class AgenticTools {
1997
1819
  const targetPath = args.path || this.cwd;
1998
1820
  const description = args.description || '';
1999
1821
  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') });
1822
+ // Every repository action invokes the canonical CLI route through an
1823
+ // argument vector. This prevents the agent tool from developing a second
1824
+ // credential, containment, mutation-retry, and payload policy.
1825
+ // Project,
1826
+ // user, permission, and destination values never enter a shell string.
1827
+ let cliArgs;
1828
+ const validRepoIdentifier = (value) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
1829
+ if (project && !validRepoIdentifier(project)) {
1830
+ return { success: false, error: 'Project name contains unsupported characters' };
1831
+ }
1832
+ switch (action) {
1833
+ case 'push':
1834
+ if (!project) {
1835
+ return {
1836
+ success: false,
1837
+ error: 'Project name is required for push',
1838
+ suggestion: 'Provide a project name, e.g., repo action=push project=my-project',
1839
+ };
2075
1840
  }
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;
1841
+ if (args.files) {
1842
+ return {
1843
+ success: false,
1844
+ error: 'Inline repository payloads are prohibited. Write approved files inside the workspace before pushing.',
1845
+ suggestion: 'Use write_file for each approved workspace file, then call repo action=push with path.',
1846
+ };
2127
1847
  }
2128
- catch (e) {
2129
- lastError = e.message;
2130
- result = null;
1848
+ if (!['private', 'restricted', 'public'].includes(visibility)) {
1849
+ return { success: false, error: 'Repository visibility is invalid' };
2131
1850
  }
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) {
1851
+ cliArgs = ['repo', 'push', this.resolvePath(targetPath), '--name', project, '--visibility', visibility, '--yes'];
1852
+ if (description)
1853
+ cliArgs.push('--description', description);
1854
+ break;
2152
1855
  case 'pull':
2153
1856
  if (!project) {
2154
1857
  return {
@@ -2157,10 +1860,10 @@ export class AgenticTools {
2157
1860
  suggestion: 'Provide a project name, e.g., repo action=pull project=my-project'
2158
1861
  };
2159
1862
  }
2160
- command = `vigthoria repo pull "${project}"`;
1863
+ cliArgs = ['repo', 'pull', project];
2161
1864
  break;
2162
1865
  case 'list':
2163
- command = 'vigthoria repo list';
1866
+ cliArgs = ['repo', 'list'];
2164
1867
  break;
2165
1868
  case 'status':
2166
1869
  if (!project) {
@@ -2170,7 +1873,7 @@ export class AgenticTools {
2170
1873
  suggestion: 'Provide a project name, e.g., repo action=status project=my-project'
2171
1874
  };
2172
1875
  }
2173
- command = `vigthoria repo status "${project}"`;
1876
+ cliArgs = ['repo', 'status', project];
2174
1877
  break;
2175
1878
  case 'share':
2176
1879
  if (!project || !args.username) {
@@ -2181,7 +1884,10 @@ export class AgenticTools {
2181
1884
  };
2182
1885
  }
2183
1886
  const permission = args.permission || 'read';
2184
- command = `vigthoria repo share "${project}" "${args.username}" --permission ${permission}`;
1887
+ if (!validRepoIdentifier(args.username) || !['read', 'write', 'admin'].includes(permission)) {
1888
+ return { success: false, error: 'Repository username or permission is invalid' };
1889
+ }
1890
+ cliArgs = ['repo', 'share', project, args.username, '--permission', permission];
2185
1891
  break;
2186
1892
  case 'delete':
2187
1893
  if (!project) {
@@ -2191,7 +1897,7 @@ export class AgenticTools {
2191
1897
  suggestion: 'Provide a project name, e.g., repo action=delete project=my-project'
2192
1898
  };
2193
1899
  }
2194
- command = `vigthoria repo delete "${project}" --force`;
1900
+ cliArgs = ['repo', 'delete', project, '--force'];
2195
1901
  break;
2196
1902
  case 'clone':
2197
1903
  if (!project) {
@@ -2201,7 +1907,7 @@ export class AgenticTools {
2201
1907
  suggestion: 'Provide a project name, e.g., repo action=clone project=my-project path=/path/to/target'
2202
1908
  };
2203
1909
  }
2204
- command = `vigthoria repo clone "${project}" "${targetPath}"`;
1910
+ cliArgs = ['repo', 'clone', project, this.resolvePath(targetPath)];
2205
1911
  break;
2206
1912
  default:
2207
1913
  return {
@@ -2210,11 +1916,12 @@ export class AgenticTools {
2210
1916
  suggestion: 'Available actions: push, pull, list, status, share, delete, clone'
2211
1917
  };
2212
1918
  }
2213
- const output = execSync(command, {
1919
+ const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
1920
+ const output = execFileSync(process.execPath, [cliEntry, ...cliArgs], {
2214
1921
  cwd: this.cwd,
2215
1922
  encoding: 'utf-8',
2216
1923
  timeout: 120000,
2217
- env: { ...process.env, FORCE_COLOR: '0' }
1924
+ env: safeChildProcessEnv(process.env, { FORCE_COLOR: '0' })
2218
1925
  });
2219
1926
  return {
2220
1927
  success: true,
@@ -2508,25 +2215,26 @@ export class AgenticTools {
2508
2215
  }
2509
2216
  }
2510
2217
  try {
2511
- const os = require('os');
2512
- const platform = os.platform();
2513
- let sshCommand;
2514
- let execOptions = {
2515
- encoding: 'utf-8',
2218
+ const processRequest = parseSupportedProcess(command);
2219
+ const remoteExecutable = path.basename(processRequest.executable);
2220
+ const sshArgs = [
2221
+ '-o', 'BatchMode=yes',
2222
+ '-o', 'StrictHostKeyChecking=accept-new',
2223
+ '-o', 'ConnectTimeout=10',
2224
+ normalizedHost,
2225
+ '--',
2226
+ remoteExecutable,
2227
+ ...processRequest.args,
2228
+ ];
2229
+ assertProcessNetworkAllowed('ssh', sshArgs);
2230
+ const execOptions = {
2231
+ encoding: 'utf8',
2516
2232
  timeout,
2517
2233
  maxBuffer: 10 * 1024 * 1024,
2234
+ windowsHide: true,
2235
+ env: safeChildProcessEnv(),
2518
2236
  };
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);
2237
+ const output = execFileSync('ssh', sshArgs, execOptions);
2530
2238
  return {
2531
2239
  success: true,
2532
2240
  output: output.trim(),
@@ -2610,23 +2318,25 @@ export class AgenticTools {
2610
2318
  else {
2611
2319
  resolvedPath = path.normalize(path.join(this.cwd, safeInput));
2612
2320
  }
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;
2321
+ return assertContainedAbsolutePath(workspaceRoot, resolvedPath, {
2322
+ allowMissing: true,
2323
+ allowRoot: true,
2324
+ });
2622
2325
  }
2623
2326
  /**
2624
2327
  * Check if a path is within the allowed workspace
2625
2328
  */
2626
2329
  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;
2330
+ try {
2331
+ assertContainedAbsolutePath(this.cwd, path.isAbsolute(p) ? p : path.join(this.cwd, p), {
2332
+ allowMissing: true,
2333
+ allowRoot: true,
2334
+ });
2335
+ return true;
2336
+ }
2337
+ catch {
2338
+ return false;
2339
+ }
2630
2340
  }
2631
2341
  // ═══════════════════════════════════════════════════════════════
2632
2342
  // TASK (Sub-Agent) Tool
@@ -2660,10 +2370,10 @@ export class AgenticTools {
2660
2370
  try {
2661
2371
  for (let turn = 0; turn < maxSubTurns; turn++) {
2662
2372
  // Import api dynamically to avoid circular dependency
2663
- const { APIClient } = await import('./api.js');
2373
+ const { createAPIClient } = await import('./api-client-factory.js');
2664
2374
  const { Config } = await import('./config.js');
2665
2375
  const config = new Config();
2666
- const api = new APIClient(config, this.logger);
2376
+ const api = createAPIClient(config, this.logger);
2667
2377
  const response = await api.chat(messages, 'code');
2668
2378
  const assistantMessage = response.message || '';
2669
2379
  messages.push({ role: 'assistant', content: assistantMessage });
@@ -2822,6 +2532,9 @@ export class AgenticTools {
2822
2532
  if (ignorePatterns.includes(entry.name) || entry.name.startsWith('.'))
2823
2533
  continue;
2824
2534
  const fullPath = path.join(currentDir, entry.name);
2535
+ const relativePath = path.relative(this.cwd, fullPath).replace(/\\/g, '/');
2536
+ if (isSensitivePath(relativePath))
2537
+ continue;
2825
2538
  if (entry.isDirectory()) {
2826
2539
  walk(fullPath, depth + 1);
2827
2540
  }
@@ -2830,13 +2543,15 @@ export class AgenticTools {
2830
2543
  const basename = entry.name;
2831
2544
  // Simple glob matching for extension patterns like *.ts, *.js
2832
2545
  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
- }
2546
+ if (!new RegExp(globPattern, 'i').test(basename) && !fullPath.includes(pattern.replace(/\*/g, '')))
2547
+ continue;
2836
2548
  }
2837
- else {
2838
- files.push(fullPath);
2549
+ try {
2550
+ const content = fs.readFileSync(fullPath, 'utf8');
2551
+ if (!containsHighConfidenceSecret(content))
2552
+ files.push(fullPath);
2839
2553
  }
2554
+ catch { /* unreadable files are excluded from agent context */ }
2840
2555
  }
2841
2556
  }
2842
2557
  };
@@ -2913,12 +2628,12 @@ export class AgenticTools {
2913
2628
  if (includePattern)
2914
2629
  rgArgs.push('-g', includePattern);
2915
2630
  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(' ')}`, {
2631
+ const rgOutput = execFileSync('rg', rgArgs, {
2919
2632
  encoding: 'utf-8',
2920
2633
  timeout: 15000,
2921
2634
  maxBuffer: 5 * 1024 * 1024,
2635
+ windowsHide: true,
2636
+ env: safeChildProcessEnv(),
2922
2637
  }).trim();
2923
2638
  for (const line of rgOutput.split('\n').slice(0, maxResults - results.length)) {
2924
2639
  if (!line.trim())
@@ -3032,7 +2747,6 @@ export class AgenticTools {
3032
2747
  .replace(/\t/g, '\\t'); // Escape tabs
3033
2748
  // Escape literal newlines inside strings (but not \n which is already escaped)
3034
2749
  // We need to be careful - only escape newlines that are inside quoted strings
3035
- const parts = [];
3036
2750
  let inString = false;
3037
2751
  let currentPart = '';
3038
2752
  for (let i = 0; i < fixed.length; i++) {