agentvibes 4.0.0 → 4.2.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 (42) hide show
  1. package/.claude/config/audio-effects.cfg +3 -2
  2. package/.claude/config/background-music-position.txt +1 -1
  3. package/.claude/hooks/audio-processor.sh +87 -43
  4. package/.claude/hooks/bmad-speak.sh +184 -27
  5. package/.claude/hooks/play-tts-enhanced.sh +40 -5
  6. package/.claude/hooks/play-tts-macos.sh +29 -6
  7. package/.claude/hooks/play-tts-piper.sh +174 -67
  8. package/.claude/hooks/play-tts-soprano.sh +42 -6
  9. package/.claude/hooks/play-tts-ssh-remote.sh +117 -38
  10. package/.claude/hooks/play-tts.sh +12 -9
  11. package/.claude/hooks/session-start-tts.sh +10 -0
  12. package/.claude/hooks/stop-tts.sh +84 -0
  13. package/.claude/hooks/tts-queue-worker.sh +51 -20
  14. package/.claude/hooks/tts-queue.sh +37 -8
  15. package/.claude/hooks/voice-manager.sh +5 -1
  16. package/CLAUDE.md +0 -11
  17. package/README.md +176 -78
  18. package/RELEASE_NOTES.md +1197 -60
  19. package/bin/agentvibes-voice-browser.js +35 -21
  20. package/mcp-server/server.py +36 -0
  21. package/package.json +1 -3
  22. package/src/console/app.js +23 -5
  23. package/src/console/constants/personalities.js +44 -0
  24. package/src/console/footer-config.js +8 -0
  25. package/src/console/navigation.js +3 -1
  26. package/src/console/tabs/agents-tab.js +1219 -72
  27. package/src/console/tabs/install-tab.js +2 -1
  28. package/src/console/tabs/placeholder-tab.js +9 -1
  29. package/src/console/tabs/receiver-tab.js +1212 -0
  30. package/src/console/tabs/settings-tab.js +33 -323
  31. package/src/console/widgets/destroy-list.js +25 -0
  32. package/src/console/widgets/format-utils.js +89 -0
  33. package/src/console/widgets/notice.js +55 -0
  34. package/src/console/widgets/personality-picker.js +185 -0
  35. package/src/console/widgets/reverb-picker.js +94 -0
  36. package/src/console/widgets/track-picker.js +285 -0
  37. package/src/installer.js +54 -2
  38. package/src/services/agent-voice-store.js +282 -22
  39. package/src/services/config-service.js +24 -0
  40. package/src/services/navigation-service.js +1 -1
  41. package/src/utils/music-file-validator.js +41 -31
  42. package/templates/agentvibes-receiver.sh +431 -111
@@ -2,8 +2,23 @@
2
2
  * AgentVibes Agent Voice Store
3
3
  * Epic 11: Stories 11.1, 11.3, 11.5
4
4
  *
5
- * Manages global BMAD agent voice assignments at ~/.agentvibes/bmad-voice-map.json.
5
+ * Manages global BMAD agent voice/audio profile assignments at ~/.agentvibes/bmad-voice-map.json.
6
6
  * All path operations use path.resolve() to prevent traversal.
7
+ *
8
+ * Store format:
9
+ * {
10
+ * "partyMode": true,
11
+ * "voiceMap": { "architect": "en_GB-alan-medium" }, // legacy compat
12
+ * "agents": {
13
+ * "architect": {
14
+ * "voice": "en_GB-alan-medium",
15
+ * "pretext": "Winston, Architect here.",
16
+ * "reverbPreset": "cathedral",
17
+ * "personality": "normal",
18
+ * "backgroundMusic": { "track": "soft_piano.mp3", "volume": 30, "enabled": true }
19
+ * }
20
+ * }
21
+ * }
7
22
  */
8
23
 
9
24
  import fs from 'node:fs';
@@ -11,11 +26,66 @@ import os from 'node:os';
11
26
  import path from 'node:path';
12
27
 
13
28
  // ---------------------------------------------------------------------------
14
- // Single-voice provider detection (story 11.3)
29
+ // Agent ID validation prevents prototype pollution via __proto__ / constructor keys
30
+
31
+ const VALID_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/i;
32
+
33
+ /**
34
+ * Validate an agent ID is safe for use as an object property key.
35
+ * Rejects __proto__, constructor, toString, etc.
36
+ * @param {string} id
37
+ * @returns {boolean}
38
+ */
39
+ function _isValidAgentId(id) {
40
+ if (!id || typeof id !== 'string') return false;
41
+ if (!VALID_AGENT_ID.test(id)) return false;
42
+ // Explicit blocklist for prototype pollution vectors
43
+ const blocked = new Set(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty']);
44
+ return !blocked.has(id);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Advisory file locking for atomic read-modify-write
49
+
50
+ /**
51
+ * Acquire an advisory lock via exclusive file open.
52
+ * Retries briefly on EEXIST; returns fd on success, null on timeout.
53
+ */
54
+ function _acquireLock(lockPath) {
55
+ const maxRetries = 20;
56
+ const retryMs = 50;
57
+ for (let i = 0; i < maxRetries; i++) {
58
+ try {
59
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
60
+ return fd;
61
+ } catch (err) {
62
+ if (err.code !== 'EEXIST') return null;
63
+ // Stale lock detection: if lock file is older than 5 seconds, remove it
64
+ try {
65
+ const stat = fs.statSync(lockPath);
66
+ if (Date.now() - stat.mtimeMs > 5000) {
67
+ try { fs.unlinkSync(lockPath); } catch {}
68
+ }
69
+ } catch {}
70
+ // Brief sync delay (10ms) — acceptable for a single-threaded TUI lock retry
71
+ const buf = new SharedArrayBuffer(4);
72
+ Atomics.wait(new Int32Array(buf), 0, 0, retryMs);
73
+ }
74
+ }
75
+ return null; // Proceed without lock rather than blocking forever
76
+ }
15
77
 
16
78
  /**
17
- * Known providers that only have one voice (limits party mode usefulness).
79
+ * Release advisory lock.
18
80
  */
81
+ function _releaseLock(fd, lockPath) {
82
+ try { if (fd != null) fs.closeSync(fd); } catch {}
83
+ try { fs.unlinkSync(lockPath); } catch {}
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Single-voice provider detection (story 11.3)
88
+
19
89
  const SINGLE_VOICE_PROVIDERS = Object.freeze(new Set(['soprano']));
20
90
 
21
91
  /**
@@ -28,16 +98,108 @@ export function isSingleVoiceProvider(provider) {
28
98
  }
29
99
 
30
100
  // ---------------------------------------------------------------------------
31
- // BMAD agent scanner (story 11.5)
101
+ // BMAD agent manifest parser
102
+
103
+ /**
104
+ * Parse the BMAD agent-manifest.csv to get rich agent metadata.
105
+ * Returns agents filtered to core and bmm modules only.
106
+ *
107
+ * @param {string} projectRoot
108
+ * @returns {{ id: string, displayName: string, title: string, icon: string, module: string }[]}
109
+ */
110
+ export function parseBmadManifest(projectRoot) {
111
+ const safeRoot = path.resolve(projectRoot ?? process.cwd());
112
+ const manifestPath = path.resolve(safeRoot, '_bmad', '_config', 'agent-manifest.csv');
113
+
114
+ if (!fs.existsSync(manifestPath)) return [];
115
+
116
+ try {
117
+ const raw = fs.readFileSync(manifestPath, 'utf8');
118
+ const lines = raw.split('\n').filter(l => l.trim());
119
+ if (lines.length < 2) return [];
120
+
121
+ // Parse CSV header
122
+ const headers = _parseCSVLine(lines[0]);
123
+ const nameIdx = headers.indexOf('name');
124
+ const displayIdx = headers.indexOf('displayName');
125
+ const titleIdx = headers.indexOf('title');
126
+ const iconIdx = headers.indexOf('icon');
127
+ const moduleIdx = headers.indexOf('module');
128
+
129
+ if (nameIdx < 0) return [];
130
+
131
+ const agents = [];
132
+ for (let i = 1; i < lines.length; i++) {
133
+ const cols = _parseCSVLine(lines[i]);
134
+ const module = cols[moduleIdx] ?? '';
135
+
136
+ // Filter to core and bmm modules only
137
+ if (module !== 'core' && module !== 'bmm') continue;
138
+
139
+ const agentId = cols[nameIdx] ?? '';
140
+ if (!_isValidAgentId(agentId)) continue;
141
+
142
+ agents.push({
143
+ id: agentId,
144
+ displayName: cols[displayIdx] ?? cols[nameIdx] ?? '',
145
+ title: cols[titleIdx] ?? '',
146
+ icon: cols[iconIdx] ?? '',
147
+ module,
148
+ });
149
+ }
150
+
151
+ return agents.sort((a, b) => a.id.localeCompare(b.id));
152
+ } catch {
153
+ return [];
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Simple CSV line parser that handles quoted fields.
159
+ * @param {string} line
160
+ * @returns {string[]}
161
+ */
162
+ function _parseCSVLine(line) {
163
+ const result = [];
164
+ let current = '';
165
+ let inQuotes = false;
166
+
167
+ for (let i = 0; i < line.length; i++) {
168
+ const ch = line[i];
169
+ if (ch === '"') {
170
+ if (inQuotes && line[i + 1] === '"') {
171
+ current += '"';
172
+ i++;
173
+ } else {
174
+ inQuotes = !inQuotes;
175
+ }
176
+ } else if (ch === ',' && !inQuotes) {
177
+ result.push(current.trim());
178
+ current = '';
179
+ } else {
180
+ current += ch;
181
+ }
182
+ }
183
+ result.push(current.trim());
184
+ return result;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // BMAD agent scanner (story 11.5) — fallback when manifest is unavailable
32
189
 
33
190
  /**
34
191
  * Scan for BMAD agents in the project root.
35
- * Looks in `_bmad/bmm/agents/` then `.bmad/agents/`.
192
+ * Prefers manifest-based discovery; falls back to directory scan.
36
193
  *
37
194
  * @param {string} projectRoot
38
- * @returns {{ id: string, displayName: string }[]}
195
+ * @returns {{ id: string, displayName: string, title: string, icon: string, module: string }[]}
39
196
  */
40
197
  export function scanBmadAgents(projectRoot) {
198
+ // Try manifest first
199
+ const fromManifest = parseBmadManifest(projectRoot);
200
+ if (fromManifest.length > 0) return fromManifest;
201
+
202
+ // Fallback: directory scan
41
203
  const safeRoot = path.resolve(projectRoot ?? process.cwd());
42
204
  const candidateDirs = [
43
205
  path.resolve(safeRoot, '_bmad', 'bmm', 'agents'),
@@ -56,7 +218,7 @@ export function scanBmadAgents(projectRoot) {
56
218
  .split('-')
57
219
  .map(w => w.charAt(0).toUpperCase() + w.slice(1))
58
220
  .join(' ');
59
- return { id, displayName };
221
+ return { id, displayName, title: '', icon: '', module: 'bmm' };
60
222
  })
61
223
  .sort((a, b) => a.id.localeCompare(b.id));
62
224
  } catch {
@@ -66,8 +228,26 @@ export function scanBmadAgents(projectRoot) {
66
228
  return [];
67
229
  }
68
230
 
231
+ /**
232
+ * Detect whether BMAD is installed in the project.
233
+ * @param {string} projectRoot
234
+ * @returns {boolean}
235
+ */
236
+ export function isBmadDetected(projectRoot) {
237
+ const safeRoot = path.resolve(projectRoot ?? process.cwd());
238
+ const manifestPath = path.resolve(safeRoot, '_bmad', '_config', 'agent-manifest.csv');
239
+ if (fs.existsSync(manifestPath)) return true;
240
+
241
+ // Fallback checks
242
+ const dirs = [
243
+ path.resolve(safeRoot, '_bmad', 'bmm', 'agents'),
244
+ path.resolve(safeRoot, '.bmad', 'agents'),
245
+ ];
246
+ return dirs.some(d => fs.existsSync(d));
247
+ }
248
+
69
249
  // ---------------------------------------------------------------------------
70
- // AgentVoiceStore class (story 11.1)
250
+ // AgentVoiceStore class
71
251
 
72
252
  export class AgentVoiceStore {
73
253
  /**
@@ -80,12 +260,12 @@ export class AgentVoiceStore {
80
260
  }
81
261
 
82
262
  /**
83
- * Read the full voice map.
84
- * @returns {{ voiceMap: object, partyMode: boolean }}
263
+ * Read the full store.
264
+ * @returns {{ voiceMap: object, partyMode: boolean, agents: object }}
85
265
  */
86
266
  _readStore() {
87
267
  if (!fs.existsSync(this._filePath)) {
88
- return { voiceMap: {}, partyMode: false };
268
+ return { voiceMap: {}, partyMode: false, agents: {} };
89
269
  }
90
270
  try {
91
271
  const raw = fs.readFileSync(this._filePath, 'utf8');
@@ -93,41 +273,61 @@ export class AgentVoiceStore {
93
273
  return {
94
274
  voiceMap: data.voiceMap ?? {},
95
275
  partyMode: data.partyMode ?? false,
276
+ agents: data.agents ?? {},
96
277
  };
97
278
  } catch {
98
- return { voiceMap: {}, partyMode: false };
279
+ return { voiceMap: {}, partyMode: false, agents: {} };
99
280
  }
100
281
  }
101
282
 
102
283
  /**
103
- * Atomically write store data.
104
- * @param {{ voiceMap: object, partyMode: boolean }} data
284
+ * Atomically write store data with advisory file locking.
285
+ * @param {{ voiceMap: object, partyMode: boolean, agents: object }} data
105
286
  */
106
287
  _writeStore(data) {
107
288
  const dir = path.dirname(this._filePath);
108
- fs.mkdirSync(dir, { recursive: true });
109
- const tmpPath = `${this._filePath}.tmp`;
110
- fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
111
- fs.renameSync(tmpPath, this._filePath);
112
- fs.chmodSync(this._filePath, 0o600);
289
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
290
+ // Ensure directory is user-only even if it already existed
291
+ try { fs.chmodSync(dir, 0o700); } catch {}
292
+
293
+ // Advisory lock: prevent concurrent read-modify-write from clobbering data
294
+ const lockPath = `${this._filePath}.lock`;
295
+ const lockFd = _acquireLock(lockPath);
296
+ try {
297
+ const tmpPath = `${this._filePath}.tmp`;
298
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
299
+ fs.renameSync(tmpPath, this._filePath);
300
+ fs.chmodSync(this._filePath, 0o600);
301
+ } finally {
302
+ _releaseLock(lockFd, lockPath);
303
+ }
113
304
  }
114
305
 
115
306
  /**
116
- * Get the agent → voice ID map.
307
+ * Get the agent → voice ID map (legacy compat).
308
+ * Merges voiceMap with agents[id].voice for backward compat.
117
309
  * @returns {object}
118
310
  */
119
311
  getVoiceMap() {
120
- return this._readStore().voiceMap;
312
+ const store = this._readStore();
313
+ const merged = { ...store.voiceMap };
314
+ for (const [id, profile] of Object.entries(store.agents)) {
315
+ if (profile.voice && !merged[id]) merged[id] = profile.voice;
316
+ }
317
+ return merged;
121
318
  }
122
319
 
123
320
  /**
124
- * Assign a voice to an agent.
321
+ * Assign a voice to an agent (legacy compat — also updates agent profile).
125
322
  * @param {string} agentId
126
323
  * @param {string} voiceId
127
324
  */
128
325
  setVoice(agentId, voiceId) {
326
+ if (!_isValidAgentId(agentId)) return;
129
327
  const store = this._readStore();
130
328
  store.voiceMap[agentId] = voiceId;
329
+ if (!store.agents[agentId]) store.agents[agentId] = {};
330
+ store.agents[agentId].voice = voiceId;
131
331
  this._writeStore(store);
132
332
  }
133
333
 
@@ -136,8 +336,10 @@ export class AgentVoiceStore {
136
336
  * @param {string} agentId
137
337
  */
138
338
  resetVoice(agentId) {
339
+ if (!_isValidAgentId(agentId)) return;
139
340
  const store = this._readStore();
140
341
  delete store.voiceMap[agentId];
342
+ if (store.agents[agentId]) delete store.agents[agentId].voice;
141
343
  this._writeStore(store);
142
344
  }
143
345
 
@@ -158,6 +360,64 @@ export class AgentVoiceStore {
158
360
  store.partyMode = Boolean(enabled);
159
361
  this._writeStore(store);
160
362
  }
363
+
364
+ // -------------------------------------------------------------------------
365
+ // Per-agent profile API
366
+
367
+ /**
368
+ * Get the full profile for an agent. Missing fields are undefined (caller merges with global).
369
+ * @param {string} agentId
370
+ * @returns {{ voice?: string, pretext?: string, reverbPreset?: string, personality?: string, backgroundMusic?: object }}
371
+ */
372
+ getAgentProfile(agentId) {
373
+ if (!_isValidAgentId(agentId)) return {};
374
+ const store = this._readStore();
375
+ const profile = { ...(store.agents[agentId] ?? {}) };
376
+ // Compat: if voice is only in voiceMap, include it
377
+ if (!profile.voice && store.voiceMap[agentId]) {
378
+ profile.voice = store.voiceMap[agentId];
379
+ }
380
+ return profile;
381
+ }
382
+
383
+ /**
384
+ * Set (merge) profile fields for an agent. Only provided fields are updated.
385
+ * @param {string} agentId
386
+ * @param {{ voice?: string, pretext?: string, reverbPreset?: string, personality?: string, backgroundMusic?: object }} partial
387
+ */
388
+ setAgentProfile(agentId, partial) {
389
+ if (!_isValidAgentId(agentId)) return;
390
+ const store = this._readStore();
391
+ if (!store.agents[agentId]) store.agents[agentId] = {};
392
+ Object.assign(store.agents[agentId], partial);
393
+ // Keep voiceMap in sync
394
+ if (partial.voice) store.voiceMap[agentId] = partial.voice;
395
+ this._writeStore(store);
396
+ }
397
+
398
+ /**
399
+ * Reset all profile settings for an agent.
400
+ * @param {string} agentId
401
+ */
402
+ resetAgentProfile(agentId) {
403
+ if (!_isValidAgentId(agentId)) return;
404
+ const store = this._readStore();
405
+ delete store.agents[agentId];
406
+ delete store.voiceMap[agentId];
407
+ this._writeStore(store);
408
+ }
409
+
410
+ /**
411
+ * Generate a default pretext for an agent.
412
+ * @param {string} displayName - e.g. "Winston"
413
+ * @param {string} title - e.g. "Architect"
414
+ * @returns {string}
415
+ */
416
+ static getDefaultPretext(displayName, title) {
417
+ if (!displayName) return '';
418
+ if (!title) return `${displayName} here.`;
419
+ return `${displayName}, ${title} here.`;
420
+ }
161
421
  }
162
422
 
163
423
  export default AgentVoiceStore;
@@ -122,6 +122,7 @@ export class ConfigService {
122
122
  */
123
123
  saveAllToGlobal(data) {
124
124
  this._writeConfigAtomic(this.getGlobalConfigPath(), data);
125
+ this._syncToTextFiles(path.resolve(this._homeDir, '.claude'), data);
125
126
  }
126
127
 
127
128
  /**
@@ -131,6 +132,7 @@ export class ConfigService {
131
132
  */
132
133
  saveAllToLocal(data) {
133
134
  this._writeConfigAtomic(this.getLocalConfigPath(), data);
135
+ this._syncToTextFiles(path.resolve(this._projectRoot, '.claude'), data);
134
136
  }
135
137
 
136
138
  // ---------------------------------------------------------------------------
@@ -170,6 +172,28 @@ export class ConfigService {
170
172
  // ---------------------------------------------------------------------------
171
173
  // Private helpers
172
174
 
175
+ /**
176
+ * Sync config.json values to .claude/ text files that TTS scripts read.
177
+ * Only writes files that the config has values for. Silently ignores errors.
178
+ * @param {string} claudeDir - Path to .claude/ directory
179
+ * @param {object} data - Config data object
180
+ */
181
+ _syncToTextFiles(claudeDir, data) {
182
+ if (!claudeDir || !data) return;
183
+ try {
184
+ if (!fs.existsSync(claudeDir)) return;
185
+ if (data.voice) {
186
+ fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), String(data.voice));
187
+ }
188
+ if (data.provider) {
189
+ fs.writeFileSync(path.join(claudeDir, 'tts-provider.txt'), String(data.provider));
190
+ }
191
+ if (data.verbosity) {
192
+ fs.writeFileSync(path.join(claudeDir, 'tts-verbosity.txt'), String(data.verbosity));
193
+ }
194
+ } catch { /* best-effort sync */ }
195
+ }
196
+
173
197
  /**
174
198
  * Read and parse a JSON config file.
175
199
  * Returns null if the file does not exist or is not a regular file.
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  /** Ordered list of all tab IDs — used for cycling and validation */
10
- export const TAB_ORDER = ['install', 'settings', 'voices', 'music', 'readme', 'help'];
10
+ export const TAB_ORDER = ['install', 'settings', 'voices', 'music', 'agents', 'receiver', 'readme', 'help'];
11
11
 
12
12
  export class NavigationService {
13
13
  /**
@@ -72,40 +72,19 @@ export function isPathSafe(userPath, userHomeDir = null) {
72
72
  };
73
73
  }
74
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
- };
75
+ // SECURITY: Use lstatSync first to detect symlinks before following them (#131)
76
+ let lstats;
77
+ try {
78
+ lstats = fs.lstatSync(resolvedPath);
79
+ } catch (err) {
80
+ if (err.code === 'ENOENT') {
81
+ return { isValid: false, error: `File not found: ${userPath}`, resolvedPath: null };
82
+ }
83
+ return { isValid: false, error: `Cannot access file: ${err.message}`, resolvedPath: null };
105
84
  }
106
85
 
107
86
  // Check if symlink - if so, verify target is also within home directory
108
- if (stats.isSymbolicLink()) {
87
+ if (lstats.isSymbolicLink()) {
109
88
  try {
110
89
  const targetPath = fs.realpathSync(resolvedPath);
111
90
  const isTargetWithinHome = targetPath === resolvedHome ||
@@ -127,6 +106,37 @@ export function isPathSafe(userPath, userHomeDir = null) {
127
106
  }
128
107
  }
129
108
 
109
+ // Get file stats (follows symlinks for regular file check)
110
+ const stats = fs.statSync(resolvedPath);
111
+
112
+ // Check if it's a regular file (not directory, special file, etc)
113
+ if (!stats.isFile()) {
114
+ return {
115
+ isValid: false,
116
+ error: `Path must be a regular file, not a ${stats.isDirectory() ? 'directory' : 'special file'}`,
117
+ resolvedPath: null
118
+ };
119
+ }
120
+
121
+ // CRITICAL: Verify file ownership (CLAUDE.md requirement)
122
+ // Prevent other users from planting malicious files
123
+ // SECURITY: Fail-secure on platforms where getuid is unavailable (#131)
124
+ const currentUserId = process.getuid ? process.getuid() : null;
125
+ if (currentUserId === null) {
126
+ return {
127
+ isValid: false,
128
+ error: 'Security validation failed: unable to verify file ownership on this platform',
129
+ resolvedPath: null
130
+ };
131
+ }
132
+ if (stats.uid !== currentUserId) {
133
+ return {
134
+ isValid: false,
135
+ error: 'Security validation failed: file not owned by current user',
136
+ resolvedPath: null
137
+ };
138
+ }
139
+
130
140
  // Check if file is readable
131
141
  try {
132
142
  fs.accessSync(resolvedPath, fs.constants.R_OK);