pi-crew 0.3.7 → 0.3.9

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 (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +1 -1
  3. package/src/agents/discover-agents.ts +354 -15
  4. package/src/config/config.ts +732 -208
  5. package/src/config/types.ts +34 -5
  6. package/src/extension/help.ts +1 -0
  7. package/src/extension/register.ts +1173 -257
  8. package/src/extension/registration/commands.ts +15 -2
  9. package/src/extension/registration/team-tool.ts +1 -1
  10. package/src/extension/session-summary.ts +11 -1
  11. package/src/extension/team-tool/api.ts +4 -1
  12. package/src/extension/team-tool/cache-control.ts +23 -0
  13. package/src/extension/team-tool/cancel.ts +15 -5
  14. package/src/extension/team-tool/context.ts +2 -0
  15. package/src/extension/team-tool/handle-settings.ts +2 -0
  16. package/src/extension/team-tool/health-monitor.ts +563 -0
  17. package/src/extension/team-tool/inspect.ts +10 -3
  18. package/src/extension/team-tool/respond.ts +5 -2
  19. package/src/extension/team-tool/status.ts +4 -1
  20. package/src/extension/team-tool-types.ts +2 -0
  21. package/src/extension/team-tool.ts +901 -177
  22. package/src/runtime/adaptive-plan.ts +1 -1
  23. package/src/runtime/foreground-watchdog.ts +129 -0
  24. package/src/runtime/manifest-cache.ts +4 -2
  25. package/src/runtime/run-tracker.ts +11 -0
  26. package/src/runtime/runtime-policy.ts +15 -2
  27. package/src/runtime/skill-instructions.ts +8 -2
  28. package/src/runtime/stale-reconciler.ts +322 -18
  29. package/src/runtime/task-packet.ts +48 -1
  30. package/src/runtime/task-runner.ts +6 -1
  31. package/src/schema/config-schema.ts +1 -0
  32. package/src/schema/team-tool-schema.ts +204 -76
  33. package/src/state/state-store.ts +9 -1
  34. package/src/teams/discover-teams.ts +2 -1
  35. package/src/ui/run-event-bus.ts +2 -1
  36. package/src/ui/settings-overlay.ts +2 -0
  37. package/src/workflows/discover-workflows.ts +5 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.8] — Zombie Run Auto-Repair & Test Stability (2026-05-25)
4
+
5
+ ### Features
6
+ - **Periodic auto-repair timer** — `autoRepairIntervalMs` in `CrewReliabilityConfig` (default 60s, 0 to disable) calls `reconcileAllStaleRuns` via `configureObservability`. Timer uses `.unref()` to avoid blocking Node exit; cleaned up on session shutdown.
7
+ - **`wait` action** — New `team action='wait'` polls a running team until completion. Accepts `runId` (required), `config.timeoutMs` (default 300 000 ms), and `config.pollIntervalMs` (default 2 000 ms). Returns run status, summary, and per-task statuses. Resolves via `waitForRun` in `run-tracker.ts`.
8
+
9
+ ### Bug Fixes
10
+ - **No-PID zombie run repair** — Runs without async PID (e.g. live-session /tmp workspaces) previously waited 24h for repair. Now `stale-reconciler` checks if ALL running tasks have heartbeats stale >5min (`NO_PID_HEARTBEAT_STALE_MS`) and repairs immediately.
11
+ - **Orphaned /tmp workspace cleanup** — `reconcileOrphanedTempWorkspaces()` scans `/tmp/pi-crew-*` for stale `running` manifests and auto-cancels them. Runs every 5min alongside per-CWD reconciliation.
12
+ - **Live-session test hang at depth > 0** — `runtime-policy.ts` now skips child-process override when `PI_CREW_MOCK_LIVE_SESSION='success'`, preventing tests from spawning real pi processes that hung indefinitely.
13
+
14
+ ### Tests
15
+ - New `test/unit/auto-repair-timer.test.ts` (5 test cases for zombie reconciliation).
16
+ - New `test/fixtures/test-tempdir.ts` — tracks temp dirs with `test.after()` cleanup.
17
+ - Updated `live-session-context.test.ts` and `live-session-runtime.test.ts` to use tracked temp dirs and `PI_CREW_DEPTH=0`.
18
+ - Updated `stale-reconciler.test.ts` for new reconciliation paths.
19
+
3
20
  ## [0.3.0] — Phase 3a+3b: Discovery Cache, Dynamic Agent Registry, Rich TUI Rendering (2026-05-23)
4
21
 
5
22
  ### Phase 3a: Agent Discovery Cache
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -6,6 +6,215 @@ import { parseCsv, parseFrontmatter } from "../utils/frontmatter.ts";
6
6
  import { logInternalError } from "../utils/internal-error.ts";
7
7
  import { packageRoot, projectCrewRoot, userPiRoot } from "../utils/paths.ts";
8
8
 
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+ // SEC-001 Fix: Protected Agent Names Blocklist
11
+ // Prevents privilege escalation via agent shadowing attacks.
12
+ // See: SECURITY-ISSUES.md SEC-001
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+ // SEC-005 Fix: Version-based Cache for Atomic Invalidation
17
+ // Uses a global version counter for atomic cache invalidation instead of
18
+ // relying on TTL alone. This eliminates race conditions where concurrent
19
+ // callers might get stale cached snapshots.
20
+ // See: SECURITY-ISSUES.md SEC-005
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+
23
+
24
+ /** Version counter for atomic cache invalidation. Incremented on every mutation. */
25
+ let cacheVersion = 0;
26
+
27
+ /** Get current cache version. Used for atomic cache stamping. */
28
+ export function getCacheVersion(): number {
29
+ return cacheVersion;
30
+ }
31
+
32
+ /**
33
+ * Increment cache version for atomic invalidation.
34
+ * All cached entries with versions older than this are considered stale.
35
+ */
36
+ function incrementCacheVersion(): void {
37
+ cacheVersion++;
38
+ }
39
+
40
+ /** Exact match blocklist for protected builtin agent names. */
41
+ const PROTECTED_AGENT_NAMES = new Set([
42
+ "executor",
43
+ "test-engineer",
44
+ "explorer",
45
+ "planner",
46
+ "analyst",
47
+ "critic",
48
+ "reviewer",
49
+ "verifier",
50
+ "writer",
51
+ "security-reviewer",
52
+ ]);
53
+
54
+ /**
55
+ * Pattern blocklist for agent names that would likely confuse or deceive
56
+ * workflows looking for builtin agents.
57
+ *
58
+ * Covers:
59
+ * - Name variations: "executor-v2", "my-executor", "custom-executor"
60
+ * - Misspellings that could be typo-squatted: "execultor", "explroer"
61
+ * - Prefix/suffix combinations with protected names
62
+ */
63
+ const PROTECTED_AGENT_PATTERNS: Array<{ pattern: RegExp; example: string }> = [
64
+ // Exact variations with delimiters
65
+ { pattern: /^executor[-_]?v?[0-9]/i, example: "executor-v2, executor_1" },
66
+ { pattern: /^test[-_]?engineer/i, example: "test-engineer-proxy" },
67
+ { pattern: /^explorer[-_]/i, example: "explorer-debug" },
68
+ { pattern: /^planner[-_]/i, example: "planner-v3" },
69
+ // Generic prefixes that could impersonate builtins
70
+ { pattern: /^(my|custom|new|local)[-_](executor|test[-_]?engineer|explorer|planner)$/i, example: "my-executor" },
71
+ { pattern: /^(executor|test[-_]?engineer|explorer|planner)[-_]?(proxy|hook|override)$/i, example: "executor-override" },
72
+ // Common typosquatting patterns (intentional misspellings)
73
+ { pattern: /^exec[au]t[o0]r$/i, example: "execator" },
74
+ { pattern: /^expl[o0]rer$/i, example: "explorer" },
75
+ { pattern: /^plann[ae]r$/i, example: "plannar" },
76
+ // Suffixes that indicate override意图
77
+ { pattern: /^(executor|test[-_]?engineer|explorer|planner)[-_]?(override|replacement|shadow)$/i, example: "executor-override" },
78
+ ];
79
+
80
+ /**
81
+ * Check if an agent name matches any protected pattern.
82
+ * Returns the matched pattern description for error messages.
83
+ */
84
+ function matchProtectedPattern(name: string): string | null {
85
+ const key = name.toLowerCase();
86
+ for (const { pattern, example } of PROTECTED_AGENT_PATTERNS) {
87
+ if (pattern.test(key)) {
88
+ return `pattern "${pattern}" (example: ${example})`;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Security event types for audit logging.
96
+ */
97
+ interface SecurityEvent {
98
+ type: "AGENT_REGISTRATION_BLOCKED" | "PROJECT_AGENT_SHADOW_WARNING";
99
+ name: string;
100
+ reason: string;
101
+ timestamp: number;
102
+ }
103
+
104
+ /**
105
+ * Security event log. In production, this should be sent to a security SIEM.
106
+ */
107
+ const securityEventLog: SecurityEvent[] = [];
108
+
109
+ /**
110
+ * Log a security event for audit purposes.
111
+ * TODO: In production, integrate with project's logging infrastructure
112
+ * (e.g., send to SIEM, log aggregator, or security webhook).
113
+ */
114
+ function logSecurityEvent(event: SecurityEvent): void {
115
+ securityEventLog.push(event);
116
+
117
+ // Console output for development/debugging (redacted in production)
118
+ const prefix = "\x1b[33m[SECURITY]\x1b[0m"; // Yellow warning
119
+ console.warn(
120
+ `${prefix} ${event.type}: agent="${event.name}" reason="${event.reason}" time=${new Date(event.timestamp).toISOString()}`
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Get recent security events (for debugging/testing).
126
+ */
127
+ export function getSecurityEventLog(): readonly SecurityEvent[] {
128
+ return securityEventLog;
129
+ }
130
+
131
+ /**
132
+ * Clear security event log (for testing).
133
+ */
134
+ export function clearSecurityEventLog(): void {
135
+ securityEventLog.length = 0;
136
+ }
137
+
138
+ /**
139
+ * Security check: throws if the agent name is protected.
140
+ *
141
+ * Checks in order:
142
+ * 1. Exact match against PROTECTED_AGENT_NAMES
143
+ * 2. Pattern match against PROTECTED_AGENT_PATTERNS
144
+ *
145
+ * Throws with detailed error message on violation.
146
+ * Logs the event to securityEventLog for audit.
147
+ */
148
+ function assertAgentNameAllowed(name: string): void {
149
+ const key = name.toLowerCase();
150
+
151
+ // Check 1: Exact match
152
+ if (PROTECTED_AGENT_NAMES.has(key)) {
153
+ logSecurityEvent({
154
+ type: "AGENT_REGISTRATION_BLOCKED",
155
+ name,
156
+ reason: `exact_match:${key}`,
157
+ timestamp: Date.now(),
158
+ });
159
+ throw new Error(
160
+ `SECURITY: Cannot register agent '${name}': protected builtin name. ` +
161
+ `Dynamic agents cannot shadow builtin agents (executor, explorer, planner, etc.) to prevent privilege escalation.`
162
+ );
163
+ }
164
+
165
+ // Check 2: Pattern match (custom-executor, my-planner, etc.)
166
+ const matchedPattern = matchProtectedPattern(key);
167
+ if (matchedPattern !== null) {
168
+ logSecurityEvent({
169
+ type: "AGENT_REGISTRATION_BLOCKED",
170
+ name,
171
+ reason: `pattern_match:${matchedPattern}`,
172
+ timestamp: Date.now(),
173
+ });
174
+ throw new Error(
175
+ `SECURITY: Cannot register agent '${name}': name matches protected pattern (${matchedPattern}). ` +
176
+ `This pattern is blocked to prevent privilege escalation via similar-named agents.`
177
+ );
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Check if a project agent name would shadow a builtin agent.
183
+ * Logs a warning if so, but does NOT block (project agents can be legitimate overrides).
184
+ *
185
+ * Called during agent discovery to flag potential security concerns.
186
+ */
187
+ function checkProjectAgentShadowsBuiltin(name: string): void {
188
+ const key = name.toLowerCase();
189
+
190
+ // Check exact match
191
+ if (PROTECTED_AGENT_NAMES.has(key)) {
192
+ logSecurityEvent({
193
+ type: "PROJECT_AGENT_SHADOW_WARNING",
194
+ name,
195
+ reason: "project_shadows_protected_builtin",
196
+ timestamp: Date.now(),
197
+ });
198
+ console.warn(
199
+ `\x1b[33m[SECURITY WARNING]\x1b[0m Project agent "${name}" shadows a protected builtin. ` +
200
+ `This agent will be loaded but builtin agents take priority. ` +
201
+ `If this is intentional, consider using a different name.`
202
+ );
203
+ return;
204
+ }
205
+
206
+ // Check pattern match
207
+ const matchedPattern = matchProtectedPattern(key);
208
+ if (matchedPattern !== null) {
209
+ logSecurityEvent({
210
+ type: "PROJECT_AGENT_SHADOW_WARNING",
211
+ name,
212
+ reason: `project_shadows_pattern:${matchedPattern}`,
213
+ timestamp: Date.now(),
214
+ });
215
+ }
216
+ }
217
+
9
218
  export interface AgentDiscoveryResult {
10
219
  builtin: AgentConfig[];
11
220
  user: AgentConfig[];
@@ -28,6 +237,101 @@ function parseContextMode(value: string | undefined): "fresh" | "fork" | undefin
28
237
  return value === "fresh" || value === "fork" ? value : undefined;
29
238
  }
30
239
 
240
+ // ═══════════════════════════════════════════════════════════════════════════
241
+ // SEC-002 Fix: Agent System Prompt Sanitization
242
+ // Prevents prompt injection via malicious agent files.
243
+ // See: SECURITY-ISSUES.md SEC-002
244
+ // ═══════════════════════════════════════════════════════════════════════════
245
+
246
+ /**
247
+ * Trust levels for agent source classification.
248
+ * Determines how strictly to sanitize the system prompt.
249
+ */
250
+ type TrustLevel = "builtin" | "user" | "project";
251
+
252
+ /**
253
+ * Convert ResourceSource to TrustLevel for sanitization.
254
+ */
255
+ function sourceToTrustLevel(source: ResourceSource): TrustLevel {
256
+ switch (source) {
257
+ case "builtin":
258
+ return "builtin";
259
+ case "user":
260
+ return "user";
261
+ case "project":
262
+ return "project";
263
+ default:
264
+ return "project";
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Sanitize agent system prompt content to reduce prompt injection risk.
270
+ *
271
+ * Uses OWASP Agent Memory Guard-inspired patterns:
272
+ * - Strip zero-width Unicode (potential bypass vectors)
273
+ * - Strip HTML/JS comments and script tags
274
+ * - Strip known prompt injection directives
275
+ * - Strip encoded payloads (base64, hex)
276
+ * - Collapse excessive whitespace
277
+ *
278
+ * Trust levels affect sanitization strictness:
279
+ * - builtin: Minimal sanitization (trusted source)
280
+ * - user: Standard sanitization
281
+ * - project: Strict sanitization (untrusted source)
282
+ */
283
+ export function sanitizeAgentSystemPrompt(
284
+ content: string,
285
+ source: ResourceSource
286
+ ): string {
287
+ const trustLevel = sourceToTrustLevel(source);
288
+ let sanitized = content;
289
+
290
+ // 1. Strip zero-width and invisible Unicode characters (all trust levels)
291
+ sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF]/g, "");
292
+
293
+ // 2. Strip HTML/JS comments (instruction hiding) — all trust levels
294
+ sanitized = sanitized.replace(/<!--[\s\S]*?-->|<\/?script[^>]*>/gi, "");
295
+
296
+ // 3. Strip known prompt injection directive patterns — user and project
297
+ if (trustLevel !== "builtin") {
298
+ // Strip lines that look like system directives
299
+ sanitized = sanitized.replace(
300
+ /^\s*(?:SYSTEM|INSTRUCTION|IGNORE(?:\s+ALL)?\s+(?:PREVIOUS|INSTRUCTIONS)?|OVERRIDE|YOUR\s+ROLE\s+IS|MALICIOUS|BACKDOOR)\s*:.*$/gim,
301
+ ""
302
+ );
303
+
304
+ // Strip embedded instruction patterns in brackets
305
+ sanitized = sanitized.replace(/\[(?:SYSTEM|INSTRUCTION|OVERRIDE|MALICIOUS)\s*:[^\]]*\]/gi, "");
306
+
307
+ // Strip base64/hex-encoded command payloads
308
+ sanitized = sanitized.replace(/\b(base64|base32|hex)\s*['":]\s*([A-Za-z0-9+\/=]{20,})/gi, "[encoded-command-redacted]");
309
+
310
+ // Strip eval/exec patterns with encoded content
311
+ sanitized = sanitized.replace(/\b(eval|exec|spawn|subprocess)\s*\(\s*(?:base64|Buffer\.from)\s*\(/gi, "[suspicious-call-redacted]");
312
+
313
+ // Strip markdown that attempts to hide instructions
314
+ sanitized = sanitized.replace(/```\s*(?:system|instruction|prompt)\n[\s\S]*?```/gi, "");
315
+ }
316
+
317
+ // 4. Project-level strict sanitization
318
+ if (trustLevel === "project") {
319
+ // Strip YAML-like assignment patterns that could override behavior
320
+ sanitized = sanitized.replace(/^\s*(?:role|persona|behavior|directive)\s*[=:].*$/gim, "");
321
+
322
+ // Strip potential exfiltration patterns
323
+ sanitized = sanitized.replace(/\b(write|append)\s+.*(?:secrets?|keys?|token|credential)/gi, "[suspicious-write-redacted]");
324
+
325
+ // Strip network exfiltration patterns
326
+ sanitized = sanitized.replace(/\b(fetch|curl|wget|axios)\s+.*(?:exfil|steal|leak|send)/gi, "[suspicious-network-redacted]");
327
+ }
328
+
329
+ // 5. Collapse multiple blank lines (cleanup after removals)
330
+ sanitized = sanitized.replace(/\n{3,}/g, "\n\n");
331
+
332
+ return sanitized.trim();
333
+ }
334
+
31
335
  function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig | undefined {
32
336
  try {
33
337
  const content = fs.readFileSync(filePath, "utf-8");
@@ -39,12 +343,18 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
39
343
  const avoidWhen = parseCsv(frontmatter.avoidWhen);
40
344
  const cost = parseCost(frontmatter.cost);
41
345
  const category = frontmatter.category?.trim() || undefined;
346
+
347
+ // SEC-002: Sanitize system prompt based on source trust level
348
+ const rawSystemPrompt = body.trim();
349
+ const systemPrompt = sanitizeAgentSystemPrompt(rawSystemPrompt, source);
350
+
42
351
  return {
43
352
  name,
44
353
  description,
45
354
  source,
46
355
  filePath,
47
- systemPrompt: body.trim(),
356
+ systemPrompt,
357
+ // ... rest unchanged
48
358
  model: frontmatter.model === "false" ? undefined : frontmatter.model || undefined,
49
359
  fallbackModels: parseCsv(frontmatter.fallbackModels),
50
360
  thinking: frontmatter.thinking === "false" ? undefined : frontmatter.thinking || undefined,
@@ -70,11 +380,20 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
70
380
 
71
381
  function readAgentDir(dir: string, source: ResourceSource): AgentConfig[] {
72
382
  if (!fs.existsSync(dir)) return [];
73
- return fs.readdirSync(dir)
383
+ const agents = fs.readdirSync(dir)
74
384
  .filter((entry) => entry.endsWith(".md") && !entry.endsWith(".team.md") && !entry.endsWith(".workflow.md"))
75
385
  .map((entry) => parseAgentFile(path.join(dir, entry), source))
76
386
  .filter((agent): agent is AgentConfig => agent !== undefined)
77
387
  .sort((a, b) => a.name.localeCompare(b.name));
388
+
389
+ // SEC-001: Warn about project agents that shadow protected builtins
390
+ if (source === "project") {
391
+ for (const agent of agents) {
392
+ checkProjectAgentShadowsBuiltin(agent.name);
393
+ }
394
+ }
395
+
396
+ return agents;
78
397
  }
79
398
 
80
399
  function applyAgentOverrides(agents: AgentConfig[], cwd: string, loadedConfig?: LoadedPiTeamsConfig): AgentConfig[] {
@@ -101,22 +420,30 @@ function applyAgentOverrides(agents: AgentConfig[], cwd: string, loadedConfig?:
101
420
  }
102
421
 
103
422
  // ─── Agent Discovery Cache (Phase 3a) ────────────────────────────────────
104
- // Caches discoverAgents results by cwd with a short TTL to avoid repeated
105
- // disk I/O when multiple callers request agents for the same project.
423
+ // SEC-005 Fix: Uses version-based cache for atomic invalidation.
424
+ // ═══════════════════════════════════════════════════════════════════════════
106
425
 
107
426
  const DISCOVERY_CACHE_TTL_MS = 500;
108
- const discoveryCache = new Map<string, { result: AgentDiscoveryResult; expiresAt: number }>();
427
+ interface CachedDiscoveryEntry {
428
+ result: AgentDiscoveryResult;
429
+ expiresAt: number;
430
+ cacheVersion: number; // SEC-005: Version stamp for atomic invalidation
431
+ }
432
+ const discoveryCache = new Map<string, CachedDiscoveryEntry>();
109
433
  const DISCOVERY_CACHE_MAX_ENTRIES = 32;
110
434
 
111
435
  function pruneDiscoveryCache(): void {
112
436
  const now = Date.now();
437
+ const currentVersion = cacheVersion;
113
438
  for (const [key, entry] of discoveryCache) {
114
- if (entry.expiresAt <= now) discoveryCache.delete(key);
439
+ if (entry.expiresAt <= now || entry.cacheVersion < currentVersion) {
440
+ discoveryCache.delete(key);
441
+ }
115
442
  }
116
443
  }
117
444
 
118
- /** Invalidate cached discovery result for a given cwd (or all if omitted). */
119
445
  export function invalidateAgentDiscoveryCache(cwd?: string): void {
446
+ incrementCacheVersion();
120
447
  if (cwd) {
121
448
  discoveryCache.delete(cwd);
122
449
  } else {
@@ -126,8 +453,10 @@ export function invalidateAgentDiscoveryCache(cwd?: string): void {
126
453
 
127
454
  export function discoverAgents(cwd: string): AgentDiscoveryResult {
128
455
  pruneDiscoveryCache();
456
+ const currentVersion = cacheVersion;
129
457
  const cached = discoveryCache.get(cwd);
130
- if (cached && cached.expiresAt > Date.now()) {
458
+ // SEC-005: Check both TTL expiry AND version stamp
459
+ if (cached && cached.expiresAt > Date.now() && cached.cacheVersion >= currentVersion) {
131
460
  return cached.result;
132
461
  }
133
462
  const loaded = loadConfig(cwd);
@@ -136,7 +465,8 @@ export function discoverAgents(cwd: string): AgentDiscoveryResult {
136
465
  user: applyAgentOverrides(readAgentDir(path.join(userPiRoot(), "agents"), "user"), cwd, loaded),
137
466
  project: applyAgentOverrides(readAgentDir(path.join(projectCrewRoot(cwd), "agents"), "project"), cwd, loaded),
138
467
  };
139
- discoveryCache.set(cwd, { result, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS });
468
+ // SEC-005: Store with current version stamp
469
+ discoveryCache.set(cwd, { result, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS, cacheVersion: currentVersion });
140
470
  while (discoveryCache.size > DISCOVERY_CACHE_MAX_ENTRIES) {
141
471
  const oldest = discoveryCache.keys().next().value;
142
472
  if (oldest !== undefined) discoveryCache.delete(oldest);
@@ -150,13 +480,15 @@ export function discoverAgents(cwd: string): AgentDiscoveryResult {
150
480
 
151
481
  const dynamicAgents = new Map<string, AgentConfig>();
152
482
 
153
- /** Register a dynamic agent at runtime. Throws if already registered. */
483
+ /** Register a dynamic agent at runtime. Throws if already registered or if name is protected. */
154
484
  export function registerDynamicAgent(config: AgentConfig): void {
155
485
  const key = config.name.toLowerCase();
486
+ // Security check: prevent shadowing of builtin agents (SEC-001)
487
+ assertAgentNameAllowed(config.name);
156
488
  if (dynamicAgents.has(key)) {
157
489
  throw new Error(`Agent already registered: ${config.name}`);
158
490
  }
159
- dynamicAgents.set(key, { ...config, source: config.source ?? "project" });
491
+ dynamicAgents.set(key, { ...config, source: "dynamic" }); // Always "dynamic" — cannot be spoofed
160
492
  invalidateAgentDiscoveryCache();
161
493
  }
162
494
 
@@ -174,7 +506,8 @@ export function listDynamicAgents(): AgentConfig[] {
174
506
  return [...dynamicAgents.values()];
175
507
  }
176
508
 
177
- export function allAgents(discovery: AgentDiscoveryResult): AgentConfig[] {
509
+ export function allAgents(discovery: AgentDiscoveryResult | undefined): AgentConfig[] {
510
+ if (!discovery) return [];
178
511
  const byName = new Map<string, AgentConfig>();
179
512
  // Priority for disambiguation (security): project < builtin < user.
180
513
  // Project config cannot override trusted builtins (security-hardening).
@@ -182,10 +515,16 @@ export function allAgents(discovery: AgentDiscoveryResult): AgentConfig[] {
182
515
  for (const agent of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
183
516
  byName.set(agent.name.toLowerCase(), agent);
184
517
  }
185
- // Dynamic agents (registered at runtime) take highest precedence.
186
- // They can override any discovered agent (project/builtin/user).
518
+ // Dynamic agents only fill gaps they cannot override builtin/user agents.
519
+ // SECURITY: Dynamic agents are less trusted (registered at runtime by extensions/hooks).
520
+ // They are only used if no builtin/user agent with the same name exists.
187
521
  for (const agent of dynamicAgents.values()) {
188
- byName.set(agent.name.toLowerCase(), agent);
522
+ const key = agent.name.toLowerCase();
523
+ if (!byName.has(key)) {
524
+ byName.set(key, agent);
525
+ }
526
+ // NOTE: If an agent with the same name exists, the dynamic version is ignored.
527
+ // This prevents privilege escalation via agent shadowing (SEC-001).
189
528
  }
190
529
  return [...byName.values()].filter((agent) => !agent.disabled).sort((a, b) => a.name.localeCompare(b.name));
191
530
  }