pi-crew 0.5.2 → 0.5.6
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 +183 -0
- package/README.md +17 -1
- package/docs/architecture.md +2 -0
- package/docs/bugs/cross-session-notification-leakage.md +82 -0
- package/docs/coding-agent-optimization.md +268 -0
- package/docs/deep-review-report.md +384 -0
- package/docs/distillation/cybersecurity-patterns.md +294 -0
- package/docs/migration-v0.4-v0.5.md +208 -0
- package/docs/optimization-plan.md +642 -0
- package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
- package/docs/pi-mono-opportunities.md +969 -0
- package/docs/pi-mono-review.md +291 -0
- package/docs/skills/REFERENCE.md +144 -0
- package/package.json +12 -9
- package/skills/artifact-analysis-loop/SKILL.md +302 -0
- package/skills/async-worker-recovery/SKILL.md +19 -1
- package/skills/child-pi-spawning/SKILL.md +19 -6
- package/skills/context-artifact-hygiene/SKILL.md +19 -2
- package/skills/delegation-patterns/SKILL.md +68 -3
- package/skills/detection-pipeline-design/SKILL.md +285 -0
- package/skills/event-log-tracing/SKILL.md +20 -6
- package/skills/git-master/SKILL.md +20 -6
- package/skills/hunting-investigation-loop/SKILL.md +401 -0
- package/skills/incident-playbook-construction/SKILL.md +383 -0
- package/skills/live-agent-lifecycle/SKILL.md +20 -6
- package/skills/mailbox-interactive/SKILL.md +19 -6
- package/skills/model-routing-context/SKILL.md +19 -1
- package/skills/multi-perspective-review/SKILL.md +19 -4
- package/skills/observability-reliability/SKILL.md +19 -2
- package/skills/orchestration/SKILL.md +20 -2
- package/skills/ownership-session-security/SKILL.md +20 -2
- package/skills/pi-extension-lifecycle/SKILL.md +20 -2
- package/skills/post-mortem/SKILL.md +7 -2
- package/skills/read-only-explorer/SKILL.md +20 -6
- package/skills/requirements-to-task-packet/SKILL.md +23 -3
- package/skills/resource-discovery-config/SKILL.md +20 -2
- package/skills/runtime-state-reader/SKILL.md +20 -2
- package/skills/safe-bash/SKILL.md +21 -6
- package/skills/scrutinize/SKILL.md +20 -2
- package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
- package/skills/security-review/SKILL.md +560 -0
- package/skills/state-mutation-locking/SKILL.md +22 -2
- package/skills/systematic-debugging/SKILL.md +8 -6
- package/skills/threat-hypothesis-framework/SKILL.md +175 -0
- package/skills/ui-render-performance/SKILL.md +20 -2
- package/skills/verification-before-done/SKILL.md +17 -2
- package/skills/widget-rendering/SKILL.md +21 -6
- package/skills/workspace-isolation/SKILL.md +20 -6
- package/skills/worktree-isolation/SKILL.md +20 -6
- package/src/agents/agent-config.ts +40 -1
- package/src/benchmark/benchmark-runner.ts +45 -0
- package/src/benchmark/feedback-loop.ts +5 -0
- package/src/config/config.ts +32 -5
- package/src/config/role-tools.ts +82 -0
- package/src/config/suggestions.ts +8 -0
- package/src/config/types.ts +4 -0
- package/src/extension/async-notifier.ts +10 -1
- package/src/extension/crew-cleanup.ts +114 -0
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/extension/notification-router.ts +18 -0
- package/src/extension/register.ts +27 -19
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/team-tool/anchor.ts +201 -0
- package/src/extension/team-tool/api.ts +2 -1
- package/src/extension/team-tool/auto-summarize.ts +154 -0
- package/src/extension/team-tool/run.ts +42 -7
- package/src/extension/team-tool.ts +44 -2
- package/src/hooks/registry.ts +1 -3
- package/src/observability/event-bus.ts +69 -0
- package/src/observability/event-to-metric.ts +0 -2
- package/src/runtime/anchor-manager.ts +473 -0
- package/src/runtime/async-runner.ts +8 -4
- package/src/runtime/auto-summarize.ts +350 -0
- package/src/runtime/background-runner.ts +10 -3
- package/src/runtime/budget-tracker.ts +354 -0
- package/src/runtime/chain-runner.ts +507 -0
- package/src/runtime/child-pi.ts +123 -35
- package/src/runtime/crash-recovery.ts +5 -4
- package/src/runtime/crew-agent-runtime.ts +1 -0
- package/src/runtime/custom-tools/irc-tool.ts +13 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
- package/src/runtime/delivery-coordinator.ts +10 -3
- package/src/runtime/dynamic-script-runner.ts +482 -0
- package/src/runtime/foreground-control.ts +87 -17
- package/src/runtime/handoff-manager.ts +589 -0
- package/src/runtime/hidden-handoff.ts +424 -0
- package/src/runtime/live-agent-manager.ts +20 -4
- package/src/runtime/live-session-runtime.ts +39 -4
- package/src/runtime/manifest-cache.ts +2 -1
- package/src/runtime/model-resolver.ts +16 -4
- package/src/runtime/phase-tracker.ts +373 -0
- package/src/runtime/pi-args.ts +11 -1
- package/src/runtime/pi-json-output.ts +31 -0
- package/src/runtime/pipeline-runner.ts +514 -0
- package/src/runtime/progress-tracker.ts +124 -0
- package/src/runtime/retry-runner.ts +354 -0
- package/src/runtime/sandbox.ts +252 -0
- package/src/runtime/scheduler.ts +7 -2
- package/src/runtime/skill-effectiveness.ts +473 -0
- package/src/runtime/skill-instructions.ts +37 -3
- package/src/runtime/subagent-manager.ts +1 -1
- package/src/runtime/task-graph.ts +11 -1
- package/src/runtime/task-runner.ts +92 -18
- package/src/runtime/team-runner.ts +13 -12
- package/src/runtime/tool-progress.ts +10 -3
- package/src/runtime/verification-gates.ts +367 -0
- package/src/schema/team-tool-schema.ts +37 -0
- package/src/skills/discover-skills.ts +5 -0
- package/src/state/active-run-registry.ts +9 -2
- package/src/state/contracts.ts +9 -0
- package/src/state/crew-init.ts +3 -3
- package/src/state/decision-ledger.ts +98 -55
- package/src/state/event-log-rotation.ts +2 -2
- package/src/state/event-log.ts +144 -10
- package/src/state/hook-instinct-bridge.ts +5 -5
- package/src/state/mailbox.ts +10 -0
- package/src/state/run-cache.ts +18 -8
- package/src/state/state-store.ts +3 -1
- package/src/state/types.ts +4 -0
- package/src/tools/safe-bash-extension.ts +1 -0
- package/src/tools/safe-bash.ts +152 -20
- package/src/types/new-api-types.ts +34 -0
- package/src/ui/agent-management-overlay.ts +5 -1
- package/src/ui/crew-widget.ts +29 -15
- package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
- package/src/ui/powerbar-publisher.ts +101 -7
- package/src/ui/tool-render.ts +15 -15
- package/src/ui/transcript-cache.ts +13 -0
- package/src/utils/bm25-search.ts +16 -8
- package/src/utils/env-filter.ts +8 -5
- package/src/utils/redaction.ts +169 -15
- package/src/utils/session-utils.ts +52 -0
- package/src/utils/sse-parser.ts +10 -1
- package/src/worktree/cleanup.ts +6 -1
- package/src/worktree/worktree-manager.ts +32 -13
- package/workflows/chain.workflow.md +252 -0
- package/workflows/pipeline.workflow.md +27 -0
package/src/ui/tool-render.ts
CHANGED
|
@@ -90,12 +90,12 @@ export function renderTeamToolCall(
|
|
|
90
90
|
if (!context.expanded) {
|
|
91
91
|
const preview = goal.length > 60 ? goal.slice(0, 60) + "…" : goal;
|
|
92
92
|
return new Text(
|
|
93
|
-
`${theme.fg("toolTitle", theme.bold("team"))}
|
|
93
|
+
`${theme.fg("toolTitle", theme.bold("team"))} action=${theme.fg("accent", `'${action}'`)}${team}${theme.fg("dim", preview ? ` "${preview.replace(/\n/g, " ")}"` : "")}`,
|
|
94
94
|
0, 0,
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
const c = context.lastComponent instanceof Container ? (context.lastComponent.clear(), context.lastComponent) : new Container();
|
|
98
|
-
c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("team"))}
|
|
98
|
+
c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("team"))} action=${theme.fg("accent", `'${action}'`)}${team}`, 0, 0));
|
|
99
99
|
if (goal) { c.addChild(new Spacer(1)); c.addChild(new Text(theme.fg("text", goal), 0, 0)); }
|
|
100
100
|
return c;
|
|
101
101
|
}
|
|
@@ -110,13 +110,13 @@ export function renderAgentToolCall(
|
|
|
110
110
|
if (!context.expanded) {
|
|
111
111
|
const preview = prompt.length > 60 ? prompt.slice(0, 60) + "…" : prompt;
|
|
112
112
|
return new Text(
|
|
113
|
-
`${theme.fg("toolTitle", theme.bold("agent"))}
|
|
113
|
+
`${theme.fg("toolTitle", theme.bold("agent"))} ${theme.fg("accent", agentName)}${theme.fg("dim", preview ? ` "${preview.replace(/\n/g, " ")}"` : "")}`,
|
|
114
114
|
0, 0,
|
|
115
115
|
);
|
|
116
116
|
}
|
|
117
117
|
const c = context.lastComponent instanceof Container ? (context.lastComponent.clear(), context.lastComponent) : new Container();
|
|
118
|
-
const cwdLabel = args.cwd ? theme.fg("dim", `
|
|
119
|
-
c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("agent"))}
|
|
118
|
+
const cwdLabel = args.cwd ? theme.fg("dim", ` (cwd: ${args.cwd})`) : "";
|
|
119
|
+
c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("agent"))} ${theme.fg("accent", agentName)}${cwdLabel}`, 0, 0));
|
|
120
120
|
if (prompt) { c.addChild(new Spacer(1)); c.addChild(new Text(theme.fg("text", prompt), 0, 0)); }
|
|
121
121
|
return c;
|
|
122
122
|
}
|
|
@@ -153,21 +153,21 @@ export function renderAgentProgress(
|
|
|
153
153
|
const stats = `${prog?.toolCount ?? record.toolUses ?? 0} tools · ${formatDuration(durationMs)}`;
|
|
154
154
|
const modelStr = record.model ? ` (${record.model})` : "";
|
|
155
155
|
const roleLabel = record.role || record.agent || "agent";
|
|
156
|
-
addLine(`${icon}
|
|
156
|
+
addLine(`${icon} ${theme.fg("toolTitle", theme.bold(roleLabel))}${theme.fg("dim", modelStr)} — ${theme.fg("dim", stats)}`);
|
|
157
157
|
|
|
158
158
|
// Current tool (running)
|
|
159
159
|
if (isRunning && prog?.currentTool) {
|
|
160
160
|
const toolLabel = formatToolPreview(prog.currentTool, parseArgs(prog.currentToolArgs));
|
|
161
|
-
addLine(theme.fg("warning", `▸
|
|
161
|
+
addLine(theme.fg("warning", `▸ ${prog.currentTool}: ${toolLabel}`));
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// Recent tools log
|
|
165
165
|
if (prog?.recentTools?.length) {
|
|
166
166
|
for (const tool of prog.recentTools) {
|
|
167
|
-
const detail = tool.args ? `:
|
|
167
|
+
const detail = tool.args ? `: ${tool.args}` : "";
|
|
168
168
|
const line = tool.endedAt
|
|
169
169
|
? theme.fg("muted", ` ${tool.tool}${detail}`)
|
|
170
|
-
: theme.fg("warning", `▸
|
|
170
|
+
: theme.fg("warning", `▸ ${tool.tool}${detail}`);
|
|
171
171
|
addLine(line);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
@@ -231,7 +231,7 @@ export function renderTeamToolResult(
|
|
|
231
231
|
if ((result as any).runId) parts.push(`runId=${(result as any).runId}`);
|
|
232
232
|
if ((result as any).error) parts.push(theme.fg("error", `error`));
|
|
233
233
|
if ((result as any).goal && parts.length === 0) parts.push(theme.fg("dim", truncLine((result as any).goal, 116)));
|
|
234
|
-
return new Text(parts.join("
|
|
234
|
+
return new Text(parts.join(" · "), 0, 0);
|
|
235
235
|
}
|
|
236
236
|
// No details found, fall back to content
|
|
237
237
|
const text = extractText(result?.content).slice(0, 200);
|
|
@@ -253,7 +253,7 @@ export function renderTeamToolResult(
|
|
|
253
253
|
if (d.error) parts.push(theme.fg("error", `error=${d.error}`));
|
|
254
254
|
if (d.goal) parts.push(theme.fg("dim", truncLine(d.goal, 116)));
|
|
255
255
|
if (parts.length === 0) return new Text(theme.fg("muted", "(no output)"), 0, 0);
|
|
256
|
-
return new Text(parts.join("
|
|
256
|
+
return new Text(parts.join(" · "), 0, 0);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/** agent tool result: shows agent output rows with status icons */
|
|
@@ -275,9 +275,9 @@ export function renderAgentToolResult(
|
|
|
275
275
|
: item.status === "running" ? theme.fg("warning", "⟳")
|
|
276
276
|
: theme.fg("dim", "○");
|
|
277
277
|
const label = item.agentId || "agent";
|
|
278
|
-
c.addChild(new Text(`${icon}
|
|
278
|
+
c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
|
|
279
279
|
if (item.error) {
|
|
280
|
-
c.addChild(new Text(theme.fg("error", ` Error:
|
|
280
|
+
c.addChild(new Text(theme.fg("error", ` Error: ${item.error}`), 0, 0));
|
|
281
281
|
} else if (item.output) {
|
|
282
282
|
for (const line of item.output.split("\n").slice(0, 5))
|
|
283
283
|
c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
|
|
@@ -293,9 +293,9 @@ export function renderAgentToolResult(
|
|
|
293
293
|
: d.status === "running" ? theme.fg("warning", "⟳")
|
|
294
294
|
: theme.fg("dim", "○");
|
|
295
295
|
const label = d.agentId;
|
|
296
|
-
c.addChild(new Text(`${icon}
|
|
296
|
+
c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
|
|
297
297
|
if (d.error) {
|
|
298
|
-
c.addChild(new Text(theme.fg("error", ` Error:
|
|
298
|
+
c.addChild(new Text(theme.fg("error", ` Error: ${d.error}`), 0, 0));
|
|
299
299
|
} else if (d.output) {
|
|
300
300
|
for (const line of d.output.split("\n").slice(0, 5))
|
|
301
301
|
c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
|
|
@@ -19,6 +19,7 @@ export interface TranscriptReadOptions {
|
|
|
19
19
|
|
|
20
20
|
const TRANSCRIPT_CACHE_TTL_MS = 500;
|
|
21
21
|
const DEFAULT_TAIL_BYTES = 256 * 1024;
|
|
22
|
+
const MAX_CACHE_SIZE = 100;
|
|
22
23
|
const transcriptCache = new Map<string, TranscriptCacheEntry>();
|
|
23
24
|
|
|
24
25
|
function cacheKey(path: string, options: Required<Pick<TranscriptReadOptions, "full">> & { maxTailBytes: number }): string {
|
|
@@ -85,6 +86,18 @@ export function readTranscriptLinesCached(path: string, parse: (text: string) =>
|
|
|
85
86
|
truncated: read.truncated,
|
|
86
87
|
};
|
|
87
88
|
transcriptCache.set(key, entry);
|
|
89
|
+
// Evict oldest entry if cache exceeds max size
|
|
90
|
+
if (transcriptCache.size > MAX_CACHE_SIZE) {
|
|
91
|
+
let oldestKey: string | null = null;
|
|
92
|
+
let oldestParsedAt = Infinity;
|
|
93
|
+
for (const [k, v] of transcriptCache.entries()) {
|
|
94
|
+
if (v.parsedAt < oldestParsedAt) {
|
|
95
|
+
oldestParsedAt = v.parsedAt;
|
|
96
|
+
oldestKey = k;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (oldestKey) transcriptCache.delete(oldestKey);
|
|
100
|
+
}
|
|
88
101
|
return lines;
|
|
89
102
|
} catch {
|
|
90
103
|
return previous?.lines ?? [];
|
package/src/utils/bm25-search.ts
CHANGED
|
@@ -46,17 +46,17 @@ export class BM25Search<T extends SearchDocument> {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Compute document frequency for a query term using
|
|
50
|
-
*
|
|
49
|
+
* Compute document frequency for a query term using indexOf for better performance.
|
|
50
|
+
* Uses linear-time substring matching instead of regex to avoid ReDoS.
|
|
51
51
|
*/
|
|
52
52
|
private df(term: string): number {
|
|
53
|
-
const
|
|
54
|
-
const re = new RegExp(escaped, "g");
|
|
53
|
+
const termLower = term.toLowerCase();
|
|
55
54
|
let count = 0;
|
|
56
55
|
for (const doc of this.documents) {
|
|
57
56
|
for (const field of Object.keys(this.fieldWeights)) {
|
|
58
57
|
const text = (doc.fields[field] ?? "").toLowerCase();
|
|
59
|
-
|
|
58
|
+
// Use indexOf for linear-time substring search
|
|
59
|
+
if (text.includes(termLower)) {
|
|
60
60
|
count++;
|
|
61
61
|
break;
|
|
62
62
|
}
|
|
@@ -81,11 +81,19 @@ export class BM25Search<T extends SearchDocument> {
|
|
|
81
81
|
let fieldScore = 0;
|
|
82
82
|
|
|
83
83
|
for (const term of queryTerms) {
|
|
84
|
-
|
|
85
|
-
const
|
|
84
|
+
// Use indexOf for linear-time substring counting instead of regex
|
|
85
|
+
const termLower = term.toLowerCase();
|
|
86
|
+
let tf = 0;
|
|
87
|
+
let pos = 0;
|
|
88
|
+
while ((pos = textLower.indexOf(termLower, pos)) !== -1) {
|
|
89
|
+
tf++;
|
|
90
|
+
pos += termLower.length;
|
|
91
|
+
// Cap tf to prevent runaway on repeated patterns
|
|
92
|
+
if (tf > 100) break;
|
|
93
|
+
}
|
|
86
94
|
if (tf === 0) continue;
|
|
87
95
|
|
|
88
|
-
const df = this.df(
|
|
96
|
+
const df = this.df(termLower);
|
|
89
97
|
if (df === 0) continue;
|
|
90
98
|
|
|
91
99
|
const idf = Math.log((this.N - df + 0.5) / (df + 0.5) + 1);
|
package/src/utils/env-filter.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isSecretKey } from "./redaction.ts";
|
|
2
2
|
|
|
3
3
|
export interface SanitizeEnvOptions {
|
|
4
4
|
/** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
|
|
@@ -8,14 +8,17 @@ export interface SanitizeEnvOptions {
|
|
|
8
8
|
/**
|
|
9
9
|
* Strip env vars whose keys look like secrets before passing to child processes.
|
|
10
10
|
*
|
|
11
|
-
* Default mode (no allowList): deny-list using
|
|
11
|
+
* Default mode (no allowList): deny-list using isSecretKey.
|
|
12
12
|
* When allowList is provided, only keys matching the allow-list are preserved.
|
|
13
13
|
*/
|
|
14
14
|
export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnvOptions): Record<string, string> {
|
|
15
15
|
const filtered: Record<string, string> = {};
|
|
16
16
|
if (options?.allowList && options.allowList.length > 0) {
|
|
17
17
|
const matchers = options.allowList.map((p) => {
|
|
18
|
-
if (p.endsWith("*"))
|
|
18
|
+
if (p.endsWith("*")) {
|
|
19
|
+
const prefix = p.slice(0, -1);
|
|
20
|
+
return (k: string) => k.startsWith(prefix) && k.length > prefix.length;
|
|
21
|
+
}
|
|
19
22
|
return (k: string) => k === p;
|
|
20
23
|
});
|
|
21
24
|
for (const [key, value] of Object.entries(env)) {
|
|
@@ -24,7 +27,7 @@ export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnv
|
|
|
24
27
|
return filtered;
|
|
25
28
|
}
|
|
26
29
|
for (const [key, value] of Object.entries(env)) {
|
|
27
|
-
if (value !== undefined && !
|
|
30
|
+
if (value !== undefined && !isSecretKey(key)) filtered[key] = value;
|
|
28
31
|
}
|
|
29
32
|
return filtered;
|
|
30
|
-
}
|
|
33
|
+
}
|
package/src/utils/redaction.ts
CHANGED
|
@@ -1,26 +1,180 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ReDoS-resistant pattern matching for secret detection.
|
|
3
|
+
* Uses linear-time scan instead of complex regex to prevent catastrophic backtracking.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Pattern for PEM private keys (possessive quantifier prevents backtracking)
|
|
7
|
+
export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g;
|
|
8
|
+
|
|
9
|
+
// Linear-time secret key detection
|
|
10
|
+
export function isSecretKey(keyName: string): boolean {
|
|
11
|
+
// Fast path: common secret key names
|
|
12
|
+
const lower = keyName.toLowerCase();
|
|
13
|
+
if (/^(token|apikey|api_key|password|secret|credential|authorization|privatekey|private_key)$/.test(lower)) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
// Linear scan for prefix characters followed by keywords
|
|
17
|
+
const prefixes = "_.-";
|
|
18
|
+
const keywords = ["token", "api", "key", "password", "passwd", "secret", "credential", "authorization", "private"];
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < keyName.length; i++) {
|
|
21
|
+
if (prefixes.includes(keyName[i])) {
|
|
22
|
+
const remaining = keyName.substring(i + 1).toLowerCase();
|
|
23
|
+
for (const kw of keywords) {
|
|
24
|
+
if (remaining.startsWith(kw)) {
|
|
25
|
+
const afterKw = remaining.substring(kw.length);
|
|
26
|
+
if (afterKw === "" || prefixes.includes(afterKw[0]) || /[a-zA-Z0-9]/.test(afterKw[0])) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Linear-time Authorization header redaction
|
|
37
|
+
export function redactAuthHeader(line: string): string {
|
|
38
|
+
const lower = line.toLowerCase();
|
|
39
|
+
const authIdx = lower.indexOf("authorization:");
|
|
40
|
+
if (authIdx === -1) return line;
|
|
41
|
+
|
|
42
|
+
// Verify word boundary - must be at start of line or preceded by whitespace/comma/brace
|
|
43
|
+
if (authIdx > 0) {
|
|
44
|
+
const before = line[authIdx - 1];
|
|
45
|
+
if (before !== ' ' && before !== ',' && before !== '{' && before !== '[' && before !== '"' && before !== '\r' && before !== '\n') {
|
|
46
|
+
return line; // Not a word boundary
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check if this is followed by Bearer token (don't redact Bearer tokens separately)
|
|
51
|
+
// Look for "Bearer" after "authorization:"
|
|
52
|
+
const afterAuth = lower.substring(authIdx + 14).trimStart();
|
|
53
|
+
if (!afterAuth.startsWith('bearer ')) {
|
|
54
|
+
// No Bearer token, this is a regular Authorization header - redact it
|
|
55
|
+
let end = authIdx + 14;
|
|
56
|
+
while (end < line.length && line[end] !== "\r" && line[end] !== "\n") {
|
|
57
|
+
end++;
|
|
58
|
+
}
|
|
59
|
+
return line.substring(0, end) + " ***" + (end < line.length ? line.substring(end) : "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// It's a Bearer token format - don't redact here, let redactBearerTokens handle it
|
|
63
|
+
return line;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Linear-time Bearer token redaction
|
|
67
|
+
export function redactBearerTokens(line: string): string {
|
|
68
|
+
const upper = line.toUpperCase();
|
|
69
|
+
const result: string[] = [];
|
|
70
|
+
let i = 0;
|
|
71
|
+
|
|
72
|
+
while (i < line.length) {
|
|
73
|
+
if (upper.startsWith("BEARER ", i)) {
|
|
74
|
+
// Check word boundary: preceded by start, space, comma, brace, or newline
|
|
75
|
+
if (i > 0) {
|
|
76
|
+
const before = line[i - 1];
|
|
77
|
+
if (before !== ' ' && before !== ',' && before !== '{' && before !== '[' && before !== '"' && before !== '\r' && before !== '\n') {
|
|
78
|
+
result.push(line[i]);
|
|
79
|
+
i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Found "Bearer " - now find the token
|
|
85
|
+
const bearerPrefix = line.substring(i, i + 7); // "Bearer "
|
|
86
|
+
let j = i + 7;
|
|
87
|
+
let tokenLen = 0;
|
|
88
|
+
while (j < line.length && tokenLen < 200 && /[A-Za-z0-9._~+/-]/.test(line[j])) {
|
|
89
|
+
j++;
|
|
90
|
+
tokenLen++;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (tokenLen >= 8) {
|
|
94
|
+
// Replace with Bearer + *** (redact the token)
|
|
95
|
+
result.push(bearerPrefix + "***");
|
|
96
|
+
i = j;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
result.push(line[i]);
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result.join("");
|
|
105
|
+
}
|
|
6
106
|
|
|
7
107
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
108
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
9
|
-
// Exclude built-in types whose Object.entries() would produce empty arrays.
|
|
10
109
|
if (value instanceof Date || value instanceof RegExp || value instanceof Error || value instanceof Map || value instanceof Set) return false;
|
|
11
110
|
return true;
|
|
12
111
|
}
|
|
13
112
|
|
|
14
|
-
function
|
|
15
|
-
|
|
113
|
+
export function redactSecretString(value: string): string {
|
|
114
|
+
let result = value;
|
|
115
|
+
|
|
116
|
+
// Replace PEM private keys
|
|
117
|
+
result = result.replace(PEM_PRIVATE_KEY_PATTERN, "***");
|
|
118
|
+
|
|
119
|
+
// Replace Authorization headers (non-Bearer format)
|
|
120
|
+
result = redactAuthHeader(result);
|
|
121
|
+
|
|
122
|
+
// Replace Bearer tokens
|
|
123
|
+
result = redactBearerTokens(result);
|
|
124
|
+
|
|
125
|
+
// Replace inline secrets: key=value or key:value patterns
|
|
126
|
+
result = redactInlineSecrets(result);
|
|
127
|
+
|
|
128
|
+
return result;
|
|
16
129
|
}
|
|
17
130
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
131
|
+
// Linear-time inline secret redaction: token=xxx, api_key=xxx, etc.
|
|
132
|
+
function redactInlineSecrets(value: string): string {
|
|
133
|
+
const result: string[] = [];
|
|
134
|
+
let i = 0;
|
|
135
|
+
|
|
136
|
+
while (i < value.length) {
|
|
137
|
+
// Look for pattern: word_chars + = or : + non-whitespace_value
|
|
138
|
+
// Check for secret key followed by = or :
|
|
139
|
+
let j = i;
|
|
140
|
+
let keyLen = 0;
|
|
141
|
+
|
|
142
|
+
// Collect key characters (alphanumeric, underscore, hyphen)
|
|
143
|
+
while (j < value.length && /[a-zA-Z0-9_-]/.test(value[j])) {
|
|
144
|
+
j++;
|
|
145
|
+
keyLen++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (keyLen > 0 && j < value.length && (value[j] === '=' || value[j] === ':')) {
|
|
149
|
+
const key = value.substring(i, i + keyLen);
|
|
150
|
+
|
|
151
|
+
// Check if this is a secret key
|
|
152
|
+
if (isSecretKey(key)) {
|
|
153
|
+
// Find the value (everything after = or : until space, comma, or end)
|
|
154
|
+
const sep = value[j];
|
|
155
|
+
let k = j + 1;
|
|
156
|
+
let valLen = 0;
|
|
157
|
+
while (k < value.length && valLen < 500 && value[k] !== ' ' && value[k] !== ',' && value[k] !== ';' && value[k] !== '"' && value[k] !== '"' && value[k] !== '\r' && value[k] !== '\n') {
|
|
158
|
+
k++;
|
|
159
|
+
valLen++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Only redact if there's actual content
|
|
163
|
+
if (valLen > 0) {
|
|
164
|
+
result.push(key);
|
|
165
|
+
result.push(sep);
|
|
166
|
+
result.push("***");
|
|
167
|
+
i = k;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
result.push(value[i]);
|
|
174
|
+
i++;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return result.join("");
|
|
24
178
|
}
|
|
25
179
|
|
|
26
180
|
export function redactSecrets(value: unknown, keyName = ""): unknown {
|
|
@@ -41,4 +195,4 @@ export function redactJsonLine(line: string): string {
|
|
|
41
195
|
} catch {
|
|
42
196
|
return redactSecretString(line);
|
|
43
197
|
}
|
|
44
|
-
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session ID utilities for pi-crew / pi session alignment.
|
|
3
|
+
*
|
|
4
|
+
* pi's session IDs use the format:
|
|
5
|
+
* ^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$
|
|
6
|
+
*
|
|
7
|
+
* This module provides utilities to generate valid pi session IDs
|
|
8
|
+
* that align with pi-crew run IDs for easy cross-referencing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate session ID format per pi's requirements.
|
|
13
|
+
* Format: ^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$
|
|
14
|
+
*/
|
|
15
|
+
export function assertValidSessionId(id: string): void {
|
|
16
|
+
if (!id || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Invalid session id: must be non-empty, alphanumeric with '-', '_', '.' and start/end with alphanumeric`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert a pi-crew run ID to a valid pi session ID.
|
|
25
|
+
*
|
|
26
|
+
* - Strips non-alphanumeric characters
|
|
27
|
+
* - Lowercases
|
|
28
|
+
* - Prefixes with "crew-"
|
|
29
|
+
* - Truncates to 16 chars for safety
|
|
30
|
+
*
|
|
31
|
+
* @param runId - The pi-crew run ID (e.g., "team_20260528133725_02e05cc5480d0175")
|
|
32
|
+
* @returns Valid pi session ID (e.g., "crew-team20260528133")
|
|
33
|
+
*/
|
|
34
|
+
export function toPiSessionId(runId: string): string {
|
|
35
|
+
// Strip non-alphanumeric, lowercase, prefix with "crew-"
|
|
36
|
+
const sanitized = runId.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
37
|
+
return `crew-${sanitized.slice(0, 16)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate and convert a run ID to a pi session ID.
|
|
42
|
+
* Returns the session ID if valid, or undefined if conversion would produce invalid ID.
|
|
43
|
+
*/
|
|
44
|
+
export function safeToPiSessionId(runId: string): string | undefined {
|
|
45
|
+
try {
|
|
46
|
+
const sessionId = toPiSessionId(runId);
|
|
47
|
+
assertValidSessionId(sessionId);
|
|
48
|
+
return sessionId;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/utils/sse-parser.ts
CHANGED
|
@@ -8,6 +8,9 @@ export interface ServerSentEvent {
|
|
|
8
8
|
/** L1: Maximum number of raw lines before discarding an oversized event. */
|
|
9
9
|
const MAX_EVENT_LINES = 1000;
|
|
10
10
|
|
|
11
|
+
/** L2: Maximum data size per line to prevent unbounded memory usage. */
|
|
12
|
+
const MAX_DATA_SIZE = 100000; // 100KB per line
|
|
13
|
+
|
|
11
14
|
/** Read newline-delimited lines from a text ReadableStream, buffering partial chunks. */
|
|
12
15
|
async function* readLines(
|
|
13
16
|
stream: ReadableStream<string>,
|
|
@@ -97,13 +100,19 @@ export async function* readSseEvents(
|
|
|
97
100
|
|
|
98
101
|
currentRaw.push(line);
|
|
99
102
|
|
|
100
|
-
// L1: Guard against unbounded memory growth
|
|
103
|
+
// L1: Guard against unbounded memory growth (line count)
|
|
101
104
|
if (currentRaw.length > MAX_EVENT_LINES) {
|
|
102
105
|
const evt = flush();
|
|
103
106
|
if (evt) yield evt;
|
|
104
107
|
continue;
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
// L2: Guard against unbounded memory growth (data size per line)
|
|
111
|
+
if (value.length > MAX_DATA_SIZE) {
|
|
112
|
+
// Truncate oversized data to prevent memory issues
|
|
113
|
+
value = value.slice(0, MAX_DATA_SIZE);
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
if (field === "event") {
|
|
108
117
|
currentEvent = value;
|
|
109
118
|
} else if (field === "data") {
|
package/src/worktree/cleanup.ts
CHANGED
|
@@ -59,7 +59,12 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
|
|
|
59
59
|
// Commit changes to a branch instead of just preserving the worktree
|
|
60
60
|
try {
|
|
61
61
|
execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
|
|
62
|
-
|
|
62
|
+
let safeDesc = entry.name.slice(0, 200);
|
|
63
|
+
// SECURITY: Strip any newlines that could be injected via a malicious worktree name
|
|
64
|
+
// to prevent newline injection in git commit messages
|
|
65
|
+
if (safeDesc.includes("\n")) {
|
|
66
|
+
safeDesc = safeDesc.replace(/[\r\n]+/g, " ");
|
|
67
|
+
}
|
|
63
68
|
execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
|
|
64
69
|
// Create branch in the main repo pointing to this worktree's HEAD
|
|
65
70
|
try {
|
|
@@ -128,19 +128,38 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
|
|
|
128
128
|
return [];
|
|
129
129
|
}
|
|
130
130
|
const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs");
|
|
131
|
-
//
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
shell:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
131
|
+
// For .bat/.cmd files on Windows, execute via cmd.exe /c directly
|
|
132
|
+
const isBatchFile = hookPath.endsWith(".bat") || hookPath.endsWith(".cmd");
|
|
133
|
+
// SECURITY: Never use shell:true — prevents command injection from untrusted hooks.
|
|
134
|
+
// Non-node, non-batch hooks on Windows will fail to execute rather than
|
|
135
|
+
// running through a shell that could interpret malicious filenames.
|
|
136
|
+
const useShell = false;
|
|
137
|
+
if (process.platform === "win32" && !nodeHook && !isBatchFile) {
|
|
138
|
+
logInternalError("worktree.setupHook.windowsNoShell", new Error("Non-node, non-batch hook skipped on Windows (shell:true disabled for security)"), `hook=${hookPath}`);
|
|
139
|
+
}
|
|
140
|
+
const result = isBatchFile
|
|
141
|
+
? spawnSync("cmd.exe", ["/c", hookPath], {
|
|
142
|
+
cwd: worktreePath,
|
|
143
|
+
encoding: "utf-8",
|
|
144
|
+
input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
|
|
145
|
+
timeout: cfg.setupHookTimeoutMs ?? 30_000,
|
|
146
|
+
shell: false, // cmd.exe /c handles batch files safely
|
|
147
|
+
env: sanitizeEnvSecrets(process.env, {
|
|
148
|
+
allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
|
|
149
|
+
}),
|
|
150
|
+
windowsHide: true,
|
|
151
|
+
})
|
|
152
|
+
: spawnSync(nodeHook ? process.execPath : hookPath, nodeHook ? [hookPath] : [], {
|
|
153
|
+
cwd: worktreePath,
|
|
154
|
+
encoding: "utf-8",
|
|
155
|
+
input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
|
|
156
|
+
timeout: cfg.setupHookTimeoutMs ?? 30_000,
|
|
157
|
+
shell: useShell,
|
|
158
|
+
env: sanitizeEnvSecrets(process.env, {
|
|
159
|
+
allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
|
|
160
|
+
}),
|
|
161
|
+
windowsHide: true,
|
|
162
|
+
});
|
|
144
163
|
if (result.error) throw new Error(`worktree setup hook failed: ${result.error.message}`);
|
|
145
164
|
if (result.status !== 0) throw new Error(`worktree setup hook failed with exit code ${result.status}: ${result.stderr || result.stdout || "no output"}`);
|
|
146
165
|
const trimmed = result.stdout.trim();
|