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.
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/src/agents/discover-agents.ts +354 -15
- package/src/config/config.ts +732 -208
- package/src/config/types.ts +34 -5
- package/src/extension/help.ts +1 -0
- package/src/extension/register.ts +1173 -257
- package/src/extension/registration/commands.ts +15 -2
- package/src/extension/registration/team-tool.ts +1 -1
- package/src/extension/session-summary.ts +11 -1
- package/src/extension/team-tool/api.ts +4 -1
- package/src/extension/team-tool/cache-control.ts +23 -0
- package/src/extension/team-tool/cancel.ts +15 -5
- package/src/extension/team-tool/context.ts +2 -0
- package/src/extension/team-tool/handle-settings.ts +2 -0
- package/src/extension/team-tool/health-monitor.ts +563 -0
- package/src/extension/team-tool/inspect.ts +10 -3
- package/src/extension/team-tool/respond.ts +5 -2
- package/src/extension/team-tool/status.ts +4 -1
- package/src/extension/team-tool-types.ts +2 -0
- package/src/extension/team-tool.ts +901 -177
- package/src/runtime/adaptive-plan.ts +1 -1
- package/src/runtime/foreground-watchdog.ts +129 -0
- package/src/runtime/manifest-cache.ts +4 -2
- package/src/runtime/run-tracker.ts +11 -0
- package/src/runtime/runtime-policy.ts +15 -2
- package/src/runtime/skill-instructions.ts +8 -2
- package/src/runtime/stale-reconciler.ts +322 -18
- package/src/runtime/task-packet.ts +48 -1
- package/src/runtime/task-runner.ts +6 -1
- package/src/schema/config-schema.ts +1 -0
- package/src/schema/team-tool-schema.ts +204 -76
- package/src/state/state-store.ts +9 -1
- package/src/teams/discover-teams.ts +2 -1
- package/src/ui/run-event-bus.ts +2 -1
- package/src/ui/settings-overlay.ts +2 -0
- 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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
105
|
-
//
|
|
423
|
+
// SEC-005 Fix: Uses version-based cache for atomic invalidation.
|
|
424
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
106
425
|
|
|
107
426
|
const DISCOVERY_CACHE_TTL_MS = 500;
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
186
|
-
//
|
|
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
|
-
|
|
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
|
}
|