agentvibes 3.5.9 → 4.0.0

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 (71) hide show
  1. package/.agentvibes/bmad/bmad-voices-enabled.flag +0 -0
  2. package/.agentvibes/bmad/bmad-voices.md +69 -0
  3. package/.claude/config/audio-effects.cfg +1 -1
  4. package/.claude/config/background-music-position.txt +1 -27
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/audio-processor.sh +32 -17
  7. package/.claude/hooks/bmad-speak-enhanced.sh +5 -5
  8. package/.claude/hooks/bmad-speak.sh +4 -4
  9. package/.claude/hooks/bmad-voice-manager.sh +8 -8
  10. package/.claude/hooks/clawdbot-receiver-SECURE.sh +23 -25
  11. package/.claude/hooks/clawdbot-receiver.sh +28 -4
  12. package/.claude/hooks/language-manager.sh +1 -1
  13. package/.claude/hooks/path-resolver.sh +60 -0
  14. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -0
  15. package/.claude/hooks/play-tts-piper.sh +82 -24
  16. package/.claude/hooks/play-tts-ssh-remote.sh +13 -15
  17. package/.claude/hooks/play-tts.sh +16 -5
  18. package/.claude/hooks/session-start-tts.sh +26 -56
  19. package/.claude/hooks/soprano-gradio-synth.py +1 -1
  20. package/.claude/hooks/verbosity-manager.sh +10 -4
  21. package/.claude/settings.json +1 -1
  22. package/CLAUDE.md +129 -104
  23. package/README.md +418 -10
  24. package/RELEASE_NOTES.md +60 -1036
  25. package/bin/agentvibes-voice-browser.js +1827 -0
  26. package/bin/agentvibes.js +100 -0
  27. package/mcp-server/server.py +67 -3
  28. package/package.json +11 -2
  29. package/src/console/app.js +806 -0
  30. package/src/console/audio-env.js +123 -0
  31. package/src/console/brand-colors.js +13 -0
  32. package/src/console/footer-config.js +42 -0
  33. package/src/console/modals/.gitkeep +0 -0
  34. package/src/console/modals/modal-overlay.js +247 -0
  35. package/src/console/navigation.js +60 -0
  36. package/src/console/tabs/.gitkeep +0 -0
  37. package/src/console/tabs/agents-tab.js +369 -0
  38. package/src/console/tabs/help-tab.js +261 -0
  39. package/src/console/tabs/install-tab.js +990 -0
  40. package/src/console/tabs/music-tab.js +997 -0
  41. package/src/console/tabs/placeholder-tab.js +45 -0
  42. package/src/console/tabs/readme-tab.js +267 -0
  43. package/src/console/tabs/settings-tab.js +3949 -0
  44. package/src/console/tabs/voices-tab.js +1574 -0
  45. package/src/installer/music-file-input.js +304 -0
  46. package/src/installer.js +1353 -676
  47. package/src/services/.gitkeep +0 -0
  48. package/src/services/agent-voice-store.js +163 -0
  49. package/src/services/config-service.js +240 -0
  50. package/src/services/navigation-service.js +123 -0
  51. package/src/services/provider-service.js +132 -0
  52. package/src/services/verbosity-service.js +157 -0
  53. package/src/utils/audio-duration-validator.js +298 -0
  54. package/src/utils/audio-format-validator.js +277 -0
  55. package/src/utils/dependency-checker.js +3 -3
  56. package/src/utils/file-ownership-verifier.js +358 -0
  57. package/src/utils/music-file-validator.js +275 -0
  58. package/src/utils/preview-list-prompt.js +136 -0
  59. package/src/utils/provider-validator.js +144 -132
  60. package/src/utils/secure-music-storage.js +412 -0
  61. package/templates/agentvibes-receiver.sh +11 -7
  62. package/voice-assignments.json +8245 -0
  63. package/.claude/config/background-music-volume.txt +0 -1
  64. package/.claude/config/background-music.cfg +0 -1
  65. package/.claude/config/background-music.txt +0 -1
  66. package/.claude/config/tts-speech-rate.txt +0 -1
  67. package/.claude/config/tts-verbosity.txt +0 -1
  68. package/.claude/hooks/bmad-party-manager.sh +0 -225
  69. package/.claude/hooks/stop.sh +0 -38
  70. package/.claude/piper-voices-dir.txt +0 -1
  71. package/.mcp.json +0 -34
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Music File Validator - Security-Critical Path and File Validation
3
+ * Story 4.1: Path Validation and Security Hardening
4
+ *
5
+ * CRITICAL: All functions enforce CLAUDE.md security requirements:
6
+ * - Use path.resolve() for all path operations
7
+ * - Verify file ownership before processing external files
8
+ * - Prevent path traversal attacks via traversal patterns, symlinks, etc.
9
+ * - Validate file exists, is readable, and owned by current user
10
+ *
11
+ * @module music-file-validator
12
+ * @requires fs
13
+ * @requires path
14
+ * @requires os
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+
21
+ /**
22
+ * Story 4.1: Validate that a file path is safe and within user's home directory
23
+ *
24
+ * Security checks:
25
+ * 1. Resolves path to absolute form (prevents ../ tricks)
26
+ * 2. Validates path is within home directory
27
+ * 3. Rejects symlinks pointing outside home
28
+ * 4. Verifies file ownership matches current user
29
+ * 5. Checks file is readable
30
+ *
31
+ * @param {string} userPath - Path provided by user (absolute or relative)
32
+ * @param {string} userHomeDir - User's home directory (default: process.env.HOME)
33
+ * @returns {Object} { isValid: boolean, error: string|null, resolvedPath: string|null }
34
+ * @throws {Error} If path validation encounters unexpected errors
35
+ */
36
+ export function isPathSafe(userPath, userHomeDir = null) {
37
+ try {
38
+ // Parameter validation
39
+ if (!userPath || typeof userPath !== 'string') {
40
+ return {
41
+ isValid: false,
42
+ error: 'Path must be a non-empty string',
43
+ resolvedPath: null
44
+ };
45
+ }
46
+
47
+ // Use provided home dir or default to environment
48
+ const homeDir = userHomeDir || (process.env.HOME || process.env.USERPROFILE);
49
+ if (!homeDir) {
50
+ return {
51
+ isValid: false,
52
+ error: 'Unable to determine home directory',
53
+ resolvedPath: null
54
+ };
55
+ }
56
+
57
+ // CRITICAL: Use path.resolve() to get absolute path (CLAUDE.md requirement)
58
+ // This prevents directory traversal attacks like "../../etc/passwd"
59
+ const resolvedPath = path.resolve(userPath);
60
+ const resolvedHome = path.resolve(homeDir);
61
+
62
+ // Check if path is within home directory
63
+ // Must either match exactly OR start with home + separator (prevents /home/user2 bypass)
64
+ const isWithinHome = resolvedPath === resolvedHome ||
65
+ resolvedPath.startsWith(resolvedHome + path.sep);
66
+
67
+ if (!isWithinHome) {
68
+ return {
69
+ isValid: false,
70
+ error: `Security validation failed: path must be within home directory (${resolvedHome})`,
71
+ resolvedPath: null
72
+ };
73
+ }
74
+
75
+ // Check if file exists
76
+ if (!fs.existsSync(resolvedPath)) {
77
+ return {
78
+ isValid: false,
79
+ error: `File not found: ${userPath}`,
80
+ resolvedPath: null
81
+ };
82
+ }
83
+
84
+ // Get file stats
85
+ const stats = fs.statSync(resolvedPath);
86
+
87
+ // Check if it's a regular file (not directory, symlink, etc)
88
+ if (!stats.isFile()) {
89
+ return {
90
+ isValid: false,
91
+ error: `Path must be a regular file, not a ${stats.isDirectory() ? 'directory' : 'special file'}`,
92
+ resolvedPath: null
93
+ };
94
+ }
95
+
96
+ // CRITICAL: Verify file ownership (CLAUDE.md requirement)
97
+ // Prevent other users from planting malicious files
98
+ const currentUserId = process.getuid ? process.getuid() : null;
99
+ if (currentUserId !== null && stats.uid !== currentUserId) {
100
+ return {
101
+ isValid: false,
102
+ error: 'Security validation failed: file not owned by current user',
103
+ resolvedPath: null
104
+ };
105
+ }
106
+
107
+ // Check if symlink - if so, verify target is also within home directory
108
+ if (stats.isSymbolicLink()) {
109
+ try {
110
+ const targetPath = fs.realpathSync(resolvedPath);
111
+ const isTargetWithinHome = targetPath === resolvedHome ||
112
+ targetPath.startsWith(resolvedHome + path.sep);
113
+
114
+ if (!isTargetWithinHome) {
115
+ return {
116
+ isValid: false,
117
+ error: 'Security validation failed: symlink target must be within home directory',
118
+ resolvedPath: null
119
+ };
120
+ }
121
+ } catch (err) {
122
+ return {
123
+ isValid: false,
124
+ error: `Failed to resolve symlink: ${err.message}`,
125
+ resolvedPath: null
126
+ };
127
+ }
128
+ }
129
+
130
+ // Check if file is readable
131
+ try {
132
+ fs.accessSync(resolvedPath, fs.constants.R_OK);
133
+ } catch (err) {
134
+ return {
135
+ isValid: false,
136
+ error: `File is not readable: ${err.message}`,
137
+ resolvedPath: null
138
+ };
139
+ }
140
+
141
+ // All checks passed
142
+ return {
143
+ isValid: true,
144
+ error: null,
145
+ resolvedPath
146
+ };
147
+
148
+ } catch (err) {
149
+ // Unexpected error during validation
150
+ return {
151
+ isValid: false,
152
+ error: `Unexpected validation error: ${err.message}`,
153
+ resolvedPath: null
154
+ };
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Verify file size is within acceptable limits for music files
160
+ *
161
+ * @param {string} filePath - Path to file (must already be validated with isPathSafe)
162
+ * @param {number} maxSizeBytes - Maximum file size in bytes (default: 50MB)
163
+ * @returns {Object} { isValid: boolean, error: string|null, sizeBytes: number }
164
+ */
165
+ export function validateFileSize(filePath, maxSizeBytes = 50 * 1024 * 1024) {
166
+ try {
167
+ if (!fs.existsSync(filePath)) {
168
+ return {
169
+ isValid: false,
170
+ error: 'File does not exist',
171
+ sizeBytes: 0
172
+ };
173
+ }
174
+
175
+ const stats = fs.statSync(filePath);
176
+ const sizeBytes = stats.size;
177
+
178
+ if (sizeBytes > maxSizeBytes) {
179
+ const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
180
+ const maxMB = (maxSizeBytes / 1024 / 1024).toFixed(2);
181
+ return {
182
+ isValid: false,
183
+ error: `File size (${sizeMB}MB) exceeds maximum (${maxMB}MB)`,
184
+ sizeBytes
185
+ };
186
+ }
187
+
188
+ if (sizeBytes === 0) {
189
+ return {
190
+ isValid: false,
191
+ error: 'File is empty',
192
+ sizeBytes
193
+ };
194
+ }
195
+
196
+ return {
197
+ isValid: true,
198
+ error: null,
199
+ sizeBytes
200
+ };
201
+
202
+ } catch (err) {
203
+ return {
204
+ isValid: false,
205
+ error: `Error checking file size: ${err.message}`,
206
+ sizeBytes: 0
207
+ };
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Get secure temp directory for audio file operations
213
+ * Uses XDG_RUNTIME_DIR if available (follows CLAUDE.md requirement)
214
+ * Falls back to user-specific /tmp directory
215
+ *
216
+ * @param {string} prefix - Directory name prefix (default: 'agentvibes-music')
217
+ * @returns {string} Secure temp directory path
218
+ */
219
+ export function getSecureTempDir(prefix = 'agentvibes-music') {
220
+ const xdgRuntime = process.env.XDG_RUNTIME_DIR;
221
+
222
+ if (xdgRuntime && fs.existsSync(xdgRuntime)) {
223
+ return path.join(xdgRuntime, `${prefix}-${process.pid}`);
224
+ }
225
+
226
+ // Fallback to user-specific /tmp
227
+ const userTmp = path.join(os.tmpdir(), `${prefix}-${process.env.USER || 'user'}`);
228
+ return userTmp;
229
+ }
230
+
231
+ /**
232
+ * Create secure temp directory with restrictive permissions
233
+ *
234
+ * @param {string} dirPath - Directory path to create
235
+ * @returns {Object} { success: boolean, error: string|null, dirPath: string|null }
236
+ */
237
+ export function createSecureTempDir(dirPath) {
238
+ try {
239
+ // Create directory if it doesn't exist
240
+ if (!fs.existsSync(dirPath)) {
241
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
242
+ } else {
243
+ // If exists, verify permissions are restrictive
244
+ const stats = fs.statSync(dirPath);
245
+ // Check if world-readable (mode & 0o077 should be 0 for secure)
246
+ if ((stats.mode & 0o077) !== 0) {
247
+ return {
248
+ success: false,
249
+ error: 'Temp directory has insecure permissions',
250
+ dirPath: null
251
+ };
252
+ }
253
+ }
254
+
255
+ return {
256
+ success: true,
257
+ error: null,
258
+ dirPath
259
+ };
260
+
261
+ } catch (err) {
262
+ return {
263
+ success: false,
264
+ error: `Failed to create secure temp directory: ${err.message}`,
265
+ dirPath: null
266
+ };
267
+ }
268
+ }
269
+
270
+ export default {
271
+ isPathSafe,
272
+ validateFileSize,
273
+ getSecureTempDir,
274
+ createSecureTempDir
275
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Custom Inquirer List Prompt with Spacebar Preview
3
+ * Uses wrapper approach with readline keypress events
4
+ */
5
+
6
+ import readline from 'node:readline';
7
+ import { execSync } from 'node:child_process';
8
+
9
+ /**
10
+ * Wrapper for inquirer list prompt that adds spacebar preview
11
+ * @param {Object} inquirer - Inquirer instance
12
+ * @param {Object} config - Prompt configuration
13
+ * @param {Function} config.onPreview - Callback for preview (receives selected value)
14
+ * @returns {Promise} Inquirer prompt promise
15
+ */
16
+ export async function createPreviewListPrompt(inquirer, config) {
17
+ const { onPreview, ...promptConfig } = config;
18
+
19
+ // Set up keypress listener
20
+ let keypressListener = null;
21
+
22
+ // Track playing state
23
+ let currentlyPlaying = null;
24
+ let audioProcess = null;
25
+
26
+ // Initialize currentSelection to match the default value
27
+ let currentSelection = 0;
28
+ if (promptConfig.default) {
29
+ const defaultIndex = promptConfig.choices.findIndex(c => c.value === promptConfig.default);
30
+ if (defaultIndex !== -1) {
31
+ currentSelection = defaultIndex;
32
+ }
33
+ }
34
+
35
+ // Function to stop currently playing audio
36
+ const stopAudio = () => {
37
+ // Kill the specific process if we have it
38
+ // SECURITY: Only kill our own process, never use pkill which affects all users
39
+ if (audioProcess) {
40
+ try {
41
+ audioProcess.kill('SIGKILL');
42
+ audioProcess = null;
43
+ } catch (e) {
44
+ // Process might have already finished or already killed
45
+ }
46
+ }
47
+
48
+ currentlyPlaying = null;
49
+ };
50
+
51
+ if (onPreview && process.stdin.isTTY) {
52
+ readline.emitKeypressEvents(process.stdin);
53
+ // SECURITY: Wrap in try-catch to prevent terminal corruption on error
54
+ try {
55
+ if (process.stdin.setRawMode) {
56
+ process.stdin.setRawMode(true);
57
+ }
58
+ } catch (e) {
59
+ // Failed to set raw mode, continue without it
60
+ }
61
+
62
+ keypressListener = async (str, key) => {
63
+ // Track current selection based on arrow keys
64
+ if (key && key.name === 'down') {
65
+ currentSelection = Math.min(currentSelection + 1, promptConfig.choices.length - 1);
66
+ } else if (key && key.name === 'up') {
67
+ currentSelection = Math.max(currentSelection - 1, 0);
68
+ }
69
+
70
+ if (key && key.name === 'space') {
71
+ // Get the current item (don't filter - use actual index)
72
+ const currentChoice = promptConfig.choices[currentSelection];
73
+
74
+ // Only preview if it's a valid choice (not separator, not special item)
75
+ if (currentChoice && currentChoice.value && !currentChoice.value.startsWith('__')) {
76
+
77
+ // Toggle: if same voice pressed twice, stop it
78
+ if (currentlyPlaying === currentChoice.value) {
79
+ stopAudio();
80
+ return;
81
+ }
82
+
83
+ // CRITICAL: Stop previous voice BEFORE starting new one
84
+ if (currentlyPlaying) {
85
+ stopAudio();
86
+ // Small delay to ensure kill takes effect
87
+ await new Promise(resolve => setTimeout(resolve, 100));
88
+ }
89
+
90
+ currentlyPlaying = currentChoice.value;
91
+
92
+ // Call onPreview and store process handle immediately
93
+ try {
94
+ const result = await onPreview(currentChoice.value);
95
+ // Store the process handle - onPreview should return the spawn() result
96
+ audioProcess = result;
97
+ } catch (err) {
98
+ console.error(`[Preview] Error playing sample:`, err.message);
99
+ currentlyPlaying = null;
100
+ audioProcess = null;
101
+ }
102
+ }
103
+ }
104
+ };
105
+
106
+ process.stdin.on('keypress', keypressListener);
107
+ }
108
+
109
+ try {
110
+ // Run the standard list prompt
111
+ const result = await inquirer.prompt([{
112
+ ...promptConfig,
113
+ type: 'list'
114
+ }]);
115
+
116
+ return result;
117
+ } finally {
118
+ // Stop any playing audio
119
+ stopAudio();
120
+
121
+ // Clean up keypress listener
122
+ if (keypressListener) {
123
+ process.stdin.removeListener('keypress', keypressListener);
124
+ // SECURITY: Wrap in try-catch to ensure cleanup always completes
125
+ try {
126
+ if (process.stdin.setRawMode) {
127
+ process.stdin.setRawMode(false);
128
+ }
129
+ } catch (e) {
130
+ // Failed to restore raw mode, but we're exiting anyway
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ export default createPreviewListPrompt;