pi-crew 0.6.0 → 0.6.1
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 +65 -0
- package/README.md +13 -11
- package/package.json +1 -1
- package/src/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/registration/commands.ts +2 -1
- package/src/extension/registration/subagent-tools.ts +2 -0
- package/src/extension/registration/team-tool.ts +2 -0
- package/src/extension/registration/viewers.ts +1 -0
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +9 -4
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool.ts +2 -1
- package/src/hooks/registry.ts +9 -1
- package/src/hooks/types.ts +3 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +58 -3
- package/src/runtime/background-runner.ts +1 -1
- package/src/runtime/chain-runner.ts +58 -0
- package/src/runtime/child-pi.ts +1 -1
- package/src/runtime/crew-agent-records.ts +4 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +3 -4
- package/src/runtime/dynamic-script-runner.ts +7 -7
- package/src/runtime/foreground-watchdog.ts +2 -2
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/post-checks.ts +10 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/sandbox.ts +26 -20
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +4 -2
- package/src/runtime/skill-instructions.ts +4 -1
- package/src/runtime/subagent-manager.ts +20 -2
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner.ts +9 -0
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/state/contracts.ts +2 -1
- package/src/state/event-log.ts +16 -5
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +9 -2
- package/src/state/state-store.ts +4 -2
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/run-action-dispatcher.ts +2 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/safe-paths.ts +6 -0
- package/src/worktree/cleanup.ts +8 -5
- package/src/worktree/worktree-manager.ts +1 -1
|
@@ -69,7 +69,8 @@ export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentR
|
|
|
69
69
|
return updated;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/** @internal */
|
|
73
|
+
function applyLongRunningCheck(
|
|
73
74
|
manifest: TeamRunManifest,
|
|
74
75
|
agent: CrewAgentRecord,
|
|
75
76
|
config: CrewControlConfig,
|
|
@@ -105,7 +106,8 @@ export function applyLongRunningCheck(
|
|
|
105
106
|
return updated;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
/** @internal */
|
|
110
|
+
function trackConsecutiveToolFailure(
|
|
109
111
|
manifest: TeamRunManifest,
|
|
110
112
|
agent: CrewAgentRecord,
|
|
111
113
|
toolName: string,
|
|
@@ -140,7 +142,8 @@ export function trackConsecutiveToolFailure(
|
|
|
140
142
|
return updated;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
|
|
145
|
+
/** @internal */
|
|
146
|
+
function resetConsecutiveToolFailures(
|
|
144
147
|
manifest: TeamRunManifest,
|
|
145
148
|
agent: CrewAgentRecord,
|
|
146
149
|
): void {
|
|
@@ -5,6 +5,7 @@ import * as path from "node:path";
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
7
7
|
import { appendEvent } from "../state/event-log.ts";
|
|
8
|
+
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
8
9
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
9
10
|
|
|
10
11
|
|
|
@@ -131,8 +132,62 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
131
132
|
const logPath = path.join(manifest.stateRoot, "background.log");
|
|
132
133
|
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
133
134
|
|
|
134
|
-
//
|
|
135
|
-
|
|
135
|
+
// SECURITY FIX: Use sanitizeEnvSecrets with same allow-list as child-pi.ts
|
|
136
|
+
// to prevent leaking all env vars (including secrets) to detached background runner.
|
|
137
|
+
// Previously, destructuring only removed PI_CREW_PARENT_PID but kept everything else.
|
|
138
|
+
const filteredEnv = sanitizeEnvSecrets(process.env, {
|
|
139
|
+
allowList: [
|
|
140
|
+
// Model provider API keys (same as child-pi.ts)
|
|
141
|
+
"MINIMAX_API_KEY",
|
|
142
|
+
"MINIMAX_GROUP_ID",
|
|
143
|
+
"OPENAI_API_KEY",
|
|
144
|
+
"OPENAI_ORG_ID",
|
|
145
|
+
"ANTHROPIC_API_KEY",
|
|
146
|
+
"GOOGLE_API_KEY",
|
|
147
|
+
"GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
|
|
148
|
+
"AZURE_OPENAI_API_KEY",
|
|
149
|
+
"AZURE_OPENAI_ENDPOINT",
|
|
150
|
+
"AWS_ACCESS_KEY_ID",
|
|
151
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
152
|
+
"AWS_REGION",
|
|
153
|
+
"ZEU_API_KEY",
|
|
154
|
+
"ZERODEV_API_KEY",
|
|
155
|
+
// Essential non-secret vars
|
|
156
|
+
"PATH",
|
|
157
|
+
"HOME",
|
|
158
|
+
"USER",
|
|
159
|
+
"SHELL",
|
|
160
|
+
"TERM",
|
|
161
|
+
"LANG",
|
|
162
|
+
"LC_ALL",
|
|
163
|
+
"LC_COLLATE",
|
|
164
|
+
"LC_CTYPE",
|
|
165
|
+
"LC_MESSAGES",
|
|
166
|
+
"LC_MONETARY",
|
|
167
|
+
"LC_NUMERIC",
|
|
168
|
+
"LC_TIME",
|
|
169
|
+
"XDG_CONFIG_HOME",
|
|
170
|
+
"XDG_DATA_HOME",
|
|
171
|
+
"XDG_CACHE_HOME",
|
|
172
|
+
"XDG_RUNTIME_DIR",
|
|
173
|
+
"NVM_BIN",
|
|
174
|
+
"NVM_DIR",
|
|
175
|
+
"NVM_INC",
|
|
176
|
+
"NODE_PATH",
|
|
177
|
+
"NODE_DISABLE_COLORS",
|
|
178
|
+
"NODE_EXTRA_CA_CERTS",
|
|
179
|
+
"NPM_CONFIG_REGISTRY",
|
|
180
|
+
"NPM_CONFIG_USERCONFIG",
|
|
181
|
+
"NPM_CONFIG_GLOBALCONFIG",
|
|
182
|
+
"PI_*",
|
|
183
|
+
"PI_CREW_*",
|
|
184
|
+
"PI_TEAMS_*",
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
// Block execution control vars from leaking
|
|
188
|
+
delete filteredEnv.PI_CREW_PARENT_PID;
|
|
189
|
+
delete filteredEnv.PI_CREW_EXECUTE_WORKERS;
|
|
190
|
+
delete filteredEnv.PI_TEAMS_EXECUTE_WORKERS;
|
|
136
191
|
|
|
137
192
|
const loader = resolveTypeScriptLoader();
|
|
138
193
|
if (!loader) {
|
|
@@ -159,7 +214,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
159
214
|
detached: true,
|
|
160
215
|
setsid: true,
|
|
161
216
|
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
-
env:
|
|
217
|
+
env: filteredEnv,
|
|
163
218
|
windowsHide: true,
|
|
164
219
|
} as unknown as Parameters<typeof spawn>[2];
|
|
165
220
|
const child = spawn(process.execPath, command.args, spawnOpts);
|
|
@@ -138,7 +138,7 @@ async function main(): Promise<void> {
|
|
|
138
138
|
try {
|
|
139
139
|
const logPath = path.join(_cwd, ".crew/state/runs", _runId, "background.log");
|
|
140
140
|
logFd = fs.openSync(logPath, "a");
|
|
141
|
-
const origWrite = (
|
|
141
|
+
const origWrite = (_prefix: string) => (data: unknown, ...args: unknown[]) => {
|
|
142
142
|
const msg = [data, ...args].map(String).join(" ") + "\n";
|
|
143
143
|
fs.writeSync(logFd!, msg);
|
|
144
144
|
};
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { HandoffSummary, HandoffManager, TaskPacket, TaskResult } from "./handoff-manager.ts";
|
|
14
|
+
import { parseChainDSL } from "./chain-parser.ts";
|
|
15
|
+
import type { ChainStep as DSLChainStep } from "./chain-parser.ts";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Single step in a chain.
|
|
@@ -123,10 +125,25 @@ export class ChainRunner {
|
|
|
123
125
|
* parseChain('"Research AI trends" -> "Analyze findings"')
|
|
124
126
|
* parseChain("@step1 --model claude-opus-3 -> @step2")
|
|
125
127
|
*
|
|
128
|
+
* Also supports DSL syntax from chain-parser for advanced constructs:
|
|
129
|
+
* parseChain("step1 -> parallel(step2, step3) -> step4")
|
|
130
|
+
* parseChain("step1:3 -> step2 --with-context -> step3")
|
|
131
|
+
*
|
|
126
132
|
* @param chainString - The chain string to parse
|
|
127
133
|
* @returns Parsed chain specification
|
|
128
134
|
*/
|
|
129
135
|
parseChain(chainString: string): ChainSpec {
|
|
136
|
+
// Try DSL parser first for advanced syntax (parallel groups, loop counts, flags)
|
|
137
|
+
// Falls back to the simple split parser if DSL parsing fails
|
|
138
|
+
if (this.hasDSLConstructs(chainString)) {
|
|
139
|
+
try {
|
|
140
|
+
const dslSteps = parseChainDSL(chainString);
|
|
141
|
+
return this.dslToChainSpec(dslSteps, chainString);
|
|
142
|
+
} catch {
|
|
143
|
+
// DSL parse failed; fall through to simple parser
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
const stepStrings = chainString.split("->").map(s => s.trim());
|
|
131
148
|
|
|
132
149
|
const steps: ChainStep[] = stepStrings.map((step, index) => {
|
|
@@ -337,6 +354,47 @@ export class ChainRunner {
|
|
|
337
354
|
return parsed;
|
|
338
355
|
}
|
|
339
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Detect if chainString uses DSL constructs that require chain-parser.
|
|
359
|
+
* DSL features: parallel(...), :loopCount, --with-context flag
|
|
360
|
+
*/
|
|
361
|
+
private hasDSLConstructs(chainString: string): boolean {
|
|
362
|
+
return /\bparallel\s*\(/.test(chainString) ||
|
|
363
|
+
/\w+:\d+\b/.test(chainString) ||
|
|
364
|
+
/--with-context/.test(chainString);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Convert DSL AST steps (from chain-parser) to ChainSpec.
|
|
369
|
+
*/
|
|
370
|
+
private dslToChainSpec(dslSteps: DSLChainStep[], chainString: string): ChainSpec {
|
|
371
|
+
const steps: ChainStep[] = dslSteps.map((dslStep, index) => {
|
|
372
|
+
// For parallel groups, use a synthetic step name
|
|
373
|
+
if (dslStep.parallel) {
|
|
374
|
+
return {
|
|
375
|
+
name: dslStep.name,
|
|
376
|
+
context: {
|
|
377
|
+
parallel: dslStep.parallel.map(p => ({ name: p.name, loopCount: p.loopCount, withContext: p.withContext, args: p.args })),
|
|
378
|
+
},
|
|
379
|
+
loopCount: dslStep.loopCount,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const step: ChainStep = { name: dslStep.name };
|
|
383
|
+
if (dslStep.loopCount) step.context = { ...step.context, loopCount: dslStep.loopCount };
|
|
384
|
+
if (dslStep.withContext) step.context = { ...step.context, withContext: true };
|
|
385
|
+
if (dslStep.args && dslStep.args.length > 0) step.context = { ...step.context, args: dslStep.args };
|
|
386
|
+
return step;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Extract global overrides using existing logic
|
|
390
|
+
const globalModel = this.extractGlobalFlag(chainString, "global-model");
|
|
391
|
+
const globalSkill = this.extractGlobalFlag(chainString, "global-skill");
|
|
392
|
+
const globalThinking = this.extractGlobalFlag(chainString, "global-thinking") as "fast" | "standard" | "deep" | undefined;
|
|
393
|
+
const continueOnError = this.extractGlobalFlag(chainString, "continue-on-error") === "true";
|
|
394
|
+
|
|
395
|
+
return { steps, globalModel, globalSkill, globalThinking, continueOnError };
|
|
396
|
+
}
|
|
397
|
+
|
|
340
398
|
/**
|
|
341
399
|
* Sanitize identifier to prevent injection.
|
|
342
400
|
*/
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -424,7 +424,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
424
424
|
return { exitCode: 1, stdout: "", stderr: "Mock mode requires PI_CREW_ALLOW_MOCK=1" };
|
|
425
425
|
}
|
|
426
426
|
// SECURITY: Log mock mode activation prominently for audit trail
|
|
427
|
-
|
|
427
|
+
logInternalError("child-pi.mock", new Error(`Mock mode active: ${mock}`), "NOT running real agents");
|
|
428
428
|
if (mock === "success") {
|
|
429
429
|
const stdout = `[MOCK] Success for ${input.agent.name}\n`;
|
|
430
430
|
observeStdoutChunk(input, stdout);
|
|
@@ -249,8 +249,8 @@ export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record:
|
|
|
249
249
|
atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/** Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
|
|
253
|
-
|
|
252
|
+
/** @internal Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
|
|
253
|
+
function flushPendingAgentWrites(): void {
|
|
254
254
|
flushPendingAtomicWrites();
|
|
255
255
|
}
|
|
256
256
|
|
|
@@ -353,7 +353,8 @@ export interface CrewAgentEventCursorOptions {
|
|
|
353
353
|
limit?: number;
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
-
|
|
356
|
+
/** @internal Convenience wrapper around readCrewAgentEventsCursor. */
|
|
357
|
+
function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown[] {
|
|
357
358
|
return readCrewAgentEventsCursor(manifest, taskId).events;
|
|
358
359
|
}
|
|
359
360
|
|
|
@@ -29,15 +29,19 @@ function handleRpc<P extends { requestId: string }>(
|
|
|
29
29
|
): () => void {
|
|
30
30
|
return events.on(channel, async (raw: unknown) => {
|
|
31
31
|
const params = raw as P;
|
|
32
|
+
// SECURITY: Validate requestId format to prevent channel injection.
|
|
33
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(params.requestId)) {
|
|
34
|
+
throw new Error("Security: invalid requestId format");
|
|
35
|
+
}
|
|
32
36
|
try {
|
|
33
37
|
const data = await fn(params);
|
|
34
38
|
const reply: { success: true; data?: unknown } = { success: true };
|
|
35
39
|
if (data !== undefined) reply.data = data;
|
|
36
40
|
events.emit(`${channel}:reply:${params.requestId}`, reply);
|
|
37
|
-
} catch (err:
|
|
41
|
+
} catch (err: unknown) {
|
|
38
42
|
events.emit(`${channel}:reply:${params.requestId}`, {
|
|
39
43
|
success: false,
|
|
40
|
-
error: err
|
|
44
|
+
error: err instanceof Error ? err.message : String(err),
|
|
41
45
|
});
|
|
42
46
|
}
|
|
43
47
|
});
|
|
@@ -50,21 +54,43 @@ export function registerCrewRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
50
54
|
return { version: PROTOCOL_VERSION };
|
|
51
55
|
});
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
// SECURITY TRUST BOUNDARY: crew:rpc:spawn and crew:rpc:stop are privileged
|
|
58
|
+
// operations that create or terminate child processes. Any subscriber on
|
|
59
|
+
// the shared event bus can emit these events. In a multi-extension
|
|
60
|
+
// environment, this means a malicious extension could spawn/stop agents.
|
|
61
|
+
// Mitigation: validate that the caller is the pi-crew extension by checking
|
|
62
|
+
// the request includes a known extension identifier. Log all invocations
|
|
63
|
+
// for audit. A full fix requires event-bus-level origin signing.
|
|
64
|
+
const CREW_RPC_SOURCE = "pi-crew";
|
|
65
|
+
|
|
66
|
+
function validateRpcSource(params: { requestId: string; source?: string }): boolean {
|
|
67
|
+
if (!params.source || params.source !== CREW_RPC_SOURCE) {
|
|
68
|
+
console.warn(
|
|
69
|
+
`[pi-crew SECURITY] RPC invocation from unexpected source: ${params.source ?? "(none)"}. ` +
|
|
70
|
+
`Expected '${CREW_RPC_SOURCE}'. Request may be from an untrusted extension.`,
|
|
71
|
+
);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: Record<string, unknown>; source?: string }>(
|
|
54
78
|
events,
|
|
55
79
|
"crew:rpc:spawn",
|
|
56
|
-
(
|
|
80
|
+
(params) => {
|
|
81
|
+
if (!validateRpcSource(params)) throw new Error("Unauthorized: RPC spawn requires source='pi-crew'");
|
|
57
82
|
const ctx = getCtx();
|
|
58
83
|
if (!ctx) throw new Error("No active session");
|
|
59
|
-
return { id: spawn(type, prompt, options ?? {}) };
|
|
84
|
+
return { id: spawn(params.type, params.prompt, params.options ?? {}) };
|
|
60
85
|
},
|
|
61
86
|
);
|
|
62
87
|
|
|
63
|
-
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
|
|
88
|
+
const unsubStop = handleRpc<{ requestId: string; agentId: string; source?: string }>(
|
|
64
89
|
events,
|
|
65
90
|
"crew:rpc:stop",
|
|
66
|
-
(
|
|
67
|
-
if (!
|
|
91
|
+
(params) => {
|
|
92
|
+
if (!validateRpcSource(params)) throw new Error("Unauthorized: RPC stop requires source='pi-crew'");
|
|
93
|
+
if (!abort(params.agentId)) throw new Error("Agent not found");
|
|
68
94
|
},
|
|
69
95
|
);
|
|
70
96
|
|
|
@@ -9,9 +9,9 @@ import { loadRunManifestById } from "../state/state-store.ts";
|
|
|
9
9
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
10
10
|
import { summarizeHeartbeats, type HeartbeatSummary } from "../ui/heartbeat-aggregator.ts";
|
|
11
11
|
import type { RunUiSnapshot } from "../ui/snapshot-types.ts";
|
|
12
|
-
import { redactSecrets } from "../utils/redaction.ts";
|
|
12
|
+
import { redactSecrets, isSecretKey } from "../utils/redaction.ts";
|
|
13
13
|
import { buildRecoveryLedger, type RecoveryLedgerEntry } from "./recovery-recipes.ts";
|
|
14
|
-
export { redactSecrets } from "../utils/redaction.ts";
|
|
14
|
+
export { redactSecrets, isSecretKey } from "../utils/redaction.ts";
|
|
15
15
|
|
|
16
16
|
export interface DiagnosticReport {
|
|
17
17
|
schemaVersion?: number;
|
|
@@ -37,13 +37,12 @@ export interface DiagnosticReport {
|
|
|
37
37
|
recoveryLedger: RecoveryLedgerEntry[];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const SECRET_KEY_PATTERN = /(token|key|password|secret|credential|auth)/i;
|
|
41
40
|
const ENV_DEBUG_ALLOWLIST = /^(PI_CREW_|PI_TEAMS_|PI_.*HOME|NODE_ENV|NODE_VERSION|OS|PROCESSOR|TERM|LANG|HOME|USERPROFILE|APPDATA|PLATFORM|ARCH|WIN32|DOCKER|CI|VERBOSE|DEBUG|NO_COLOR|FORCE_COLOR|NPM_CONFIG|npm_)/i;
|
|
42
41
|
|
|
43
42
|
function envRedacted(): Record<string, string> {
|
|
44
43
|
const output: Record<string, string> = {};
|
|
45
44
|
for (const [key, value] of Object.entries(process.env)) {
|
|
46
|
-
if (
|
|
45
|
+
if (isSecretKey(key)) output[key] = "***";
|
|
47
46
|
else if (typeof value === "string" && ENV_DEBUG_ALLOWLIST.test(key)) output[key] = value;
|
|
48
47
|
// All other env vars are omitted to prevent leaking sensitive paths or system topology.
|
|
49
48
|
}
|
|
@@ -484,11 +484,11 @@ export function createScriptRunner(options?: DynamicScriptOptions): DynamicScrip
|
|
|
484
484
|
/**
|
|
485
485
|
* @internal TEST ONLY — do not use in production code.
|
|
486
486
|
* Exposes DynamicScriptRunner.executeUnchecked for unit testing.
|
|
487
|
+
* Returns undefined in non-test environments to prevent production use.
|
|
487
488
|
*/
|
|
488
|
-
export
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
489
|
+
export const __test_executeUnchecked: ((runner: DynamicScriptRunner, code: string, timeout?: number) => ScriptExecutionResult) | undefined =
|
|
490
|
+
process.env.NODE_ENV === "test"
|
|
491
|
+
? (runner: DynamicScriptRunner, code: string, timeout?: number): ScriptExecutionResult => {
|
|
492
|
+
return (runner as unknown as { executeUnchecked: (code: string, timeout?: number) => ScriptExecutionResult }).executeUnchecked(code, timeout);
|
|
493
|
+
}
|
|
494
|
+
: undefined;
|
|
@@ -41,8 +41,8 @@ export function stopWatchdog(runId: string): void {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** Stop all active watchdogs. Called on session shutdown. */
|
|
45
|
-
|
|
44
|
+
/** @internal Stop all active watchdogs. Called on session shutdown. */
|
|
45
|
+
function stopAllWatchdogs(): void {
|
|
46
46
|
for (const [runId, timer] of activeWatchdogs) {
|
|
47
47
|
clearTimeout(timer);
|
|
48
48
|
}
|
|
@@ -81,7 +81,8 @@ export function listLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[
|
|
|
81
81
|
/**
|
|
82
82
|
* List only active agents (running/queued/waiting) for a specific workspace.
|
|
83
83
|
*/
|
|
84
|
-
|
|
84
|
+
/** @internal */
|
|
85
|
+
function listActiveLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[] {
|
|
85
86
|
return listActiveLiveAgents().filter((a) => a.workspaceId === workspaceId);
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -150,7 +151,8 @@ function safeDisposeLiveSession(handle: LiveAgentHandle): void {
|
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
/** @internal */
|
|
155
|
+
function removeLiveAgentHandle(agentId: string): LiveAgentHandle | undefined {
|
|
154
156
|
const handle = liveAgents.get(agentId);
|
|
155
157
|
if (!handle) return undefined;
|
|
156
158
|
liveAgents.delete(agentId);
|
|
@@ -406,7 +408,8 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
|
|
|
406
408
|
}
|
|
407
409
|
|
|
408
410
|
/** Phase 7: Get pending IRC messages for an agent (and clear them). */
|
|
409
|
-
|
|
411
|
+
/** @internal */
|
|
412
|
+
function drainIrcMessages(agentIdOrTaskId: string): IrcMessage[] {
|
|
410
413
|
const handle = getLiveAgent(agentIdOrTaskId);
|
|
411
414
|
if (!handle) return [];
|
|
412
415
|
const messages = [...handle.pendingMessages];
|
package/src/runtime/live-irc.ts
CHANGED
|
@@ -51,7 +51,8 @@ export function renderIrcPeerRoster(selfId: string, peers: Array<{ agentId: stri
|
|
|
51
51
|
/**
|
|
52
52
|
* Build the IRC system prompt section for a live-session worker.
|
|
53
53
|
*/
|
|
54
|
-
|
|
54
|
+
/** @internal */
|
|
55
|
+
function buildIrcSystemSection(selfId: string, peers: Array<{ agentId: string; status: string }>): string {
|
|
55
56
|
const roster = renderIrcPeerRoster(selfId, peers);
|
|
56
57
|
return [
|
|
57
58
|
"## Inter-Agent Communication",
|
|
@@ -66,7 +67,8 @@ export function buildIrcSystemSection(selfId: string, peers: Array<{ agentId: st
|
|
|
66
67
|
* Route an IRC message to the appropriate agent(s).
|
|
67
68
|
* Returns the list of agent IDs that received the message.
|
|
68
69
|
*/
|
|
69
|
-
|
|
70
|
+
/** @internal */
|
|
71
|
+
function routeIrcMessage(
|
|
70
72
|
message: IrcSendMessage,
|
|
71
73
|
selfId: string,
|
|
72
74
|
routing: {
|
|
@@ -63,7 +63,8 @@ export async function mapConcurrent<T, R>(items: T[], limit: number, fn: (item:
|
|
|
63
63
|
* On abort: returns partial results (may contain undefined entries).
|
|
64
64
|
* On error: throws immediately (fail-fast) and cancels remaining work.
|
|
65
65
|
*/
|
|
66
|
-
|
|
66
|
+
/** @internal */
|
|
67
|
+
async function mapConcurrentWithSignal<T, R>(
|
|
67
68
|
items: T[],
|
|
68
69
|
limit: number,
|
|
69
70
|
fn: (item: T, i: number, signal: AbortSignal) => Promise<R>,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Distilled from pi-autoresearch's post-check / backpressure pattern.
|
|
6
6
|
*/
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
+
import * as path from "node:path";
|
|
8
9
|
import { resolveShellForScript } from "../utils/resolve-shell.ts";
|
|
9
10
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
10
11
|
|
|
@@ -56,9 +57,8 @@ function resolveScriptPath(config: PostCheckConfig): string | undefined {
|
|
|
56
57
|
* If no script path is available (neither config nor env var), the check
|
|
57
58
|
* passes by default with a note.
|
|
58
59
|
*
|
|
59
|
-
* **Security note:** The script path is
|
|
60
|
-
*
|
|
61
|
-
* paths. No path containment validation is performed.
|
|
60
|
+
* **Security note:** The script path is validated to stay within `cwd`.
|
|
61
|
+
* Scripts that escape the working directory are rejected.
|
|
62
62
|
*
|
|
63
63
|
* @param config - Post-check configuration (script path and timeout)
|
|
64
64
|
* @param cwd - Working directory for script execution
|
|
@@ -77,6 +77,13 @@ export async function runPostCheck(config: PostCheckConfig, cwd: string): Promis
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// M1: Validate that the script path is contained within cwd to prevent arbitrary file execution
|
|
81
|
+
const resolved = path.resolve(cwd, scriptPath);
|
|
82
|
+
const resolvedCwd = path.resolve(cwd);
|
|
83
|
+
if (!resolved.startsWith(resolvedCwd + path.sep) && resolved !== resolvedCwd) {
|
|
84
|
+
throw new Error(`Security: PI_CREW_POST_CHECK_SCRIPT escapes cwd: ${scriptPath}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
const startTime = Date.now();
|
|
81
88
|
|
|
82
89
|
return new Promise<PostCheckResult>((resolve) => {
|
|
@@ -208,7 +208,7 @@ export function runDriftDetection(ctx: DriftContext, maxPasses = 2): DriftReport
|
|
|
208
208
|
newFindings++;
|
|
209
209
|
}
|
|
210
210
|
} catch (error) {
|
|
211
|
-
logInternalError("drift
|
|
211
|
+
logInternalError("run-drift", error, `detector=${detector.name} runId=${ctx.manifest?.runId}`);
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
package/src/runtime/sandbox.ts
CHANGED
|
@@ -21,28 +21,32 @@ const FORBIDDEN_PATTERNS = [
|
|
|
21
21
|
// Global escape vectors
|
|
22
22
|
/\bglobalThis\b/, // globalThis reference
|
|
23
23
|
/\bglobal\b/, // global reference (Node.js)
|
|
24
|
+
/\bconstructor\b/, // Block constructor chain escape: [].constructor.constructor("return process")()
|
|
24
25
|
] as const;
|
|
25
26
|
|
|
27
|
+
Object.freeze(FORBIDDEN_PATTERNS);
|
|
28
|
+
|
|
26
29
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
30
|
+
* SECURITY (HIGH #3 fix): Normalize source code before forbidden-pattern checks
|
|
31
|
+
* to prevent unicode-escape bypasses.
|
|
32
|
+
*
|
|
33
|
+
* Attackers can write `import\u0028"fs"\u0029` which compiles as
|
|
34
|
+
* `import("fs")` but does not match the regex `/import\s*\(/`.
|
|
35
|
+
*
|
|
36
|
+
* This function:
|
|
37
|
+
* 1. Strips null bytes (used to split keywords across boundaries)
|
|
38
|
+
* 2. Decodes \uXXXX escape sequences so regexes see the actual characters
|
|
29
39
|
*/
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"console",
|
|
41
|
-
// Process (limited)
|
|
42
|
-
"process",
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
|
-
Object.freeze(FORBIDDEN_PATTERNS);
|
|
40
|
+
export function normalizeCodeForValidation(code: string): string {
|
|
41
|
+
// Strip null bytes
|
|
42
|
+
let normalized = code.replace(/\0/g, "");
|
|
43
|
+
// Decode common unicode escapes: \u0028 → (
|
|
44
|
+
normalized = normalized.replace(
|
|
45
|
+
/\\u([0-9a-fA-F]{4})/g,
|
|
46
|
+
(_, hex) => String.fromCharCode(Number.parseInt(hex, 16)),
|
|
47
|
+
);
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
46
50
|
|
|
47
51
|
export interface SandboxOptions {
|
|
48
52
|
timeout?: number;
|
|
@@ -204,15 +208,17 @@ export class WorkflowSandbox {
|
|
|
204
208
|
* ensure compilation is safe.
|
|
205
209
|
*/
|
|
206
210
|
private validateScript(code: string): void {
|
|
211
|
+
// SECURITY (HIGH #3 fix): Normalize unicode escapes before pattern matching
|
|
212
|
+
const normalized = normalizeCodeForValidation(code);
|
|
207
213
|
// Check for ESM/module patterns
|
|
208
214
|
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
209
|
-
if (pattern.test(
|
|
215
|
+
if (pattern.test(normalized)) {
|
|
210
216
|
throw new Error(`Forbidden pattern detected: ${pattern.source}`);
|
|
211
217
|
}
|
|
212
218
|
}
|
|
213
219
|
|
|
214
220
|
// Check for import.meta specifically (C4)
|
|
215
|
-
if (/import\.meta/.test(
|
|
221
|
+
if (/import\.meta/.test(normalized)) {
|
|
216
222
|
throw new Error("import.meta is not allowed in sandboxed code");
|
|
217
223
|
}
|
|
218
224
|
|
package/src/runtime/semaphore.ts
CHANGED
|
@@ -88,7 +88,8 @@ export interface ParallelResult<R> {
|
|
|
88
88
|
*
|
|
89
89
|
* Adapted from oh-my-pi's `mapWithConcurrencyLimit`.
|
|
90
90
|
*/
|
|
91
|
-
|
|
91
|
+
/** @internal */
|
|
92
|
+
async function mapWithFailFast<T, R>(
|
|
92
93
|
items: T[],
|
|
93
94
|
concurrency: number,
|
|
94
95
|
fn: (item: T, index: number, signal: AbortSignal) => Promise<R>,
|
|
@@ -20,6 +20,18 @@ const MAX_TURNS_CEILING = 10_000;
|
|
|
20
20
|
const GRACE_TURNS_CEILING = 1_000;
|
|
21
21
|
const VALID_JOIN_MODES = new Set<JoinMode>(["async", "group", "smart"]);
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* M2: Validate that a scheduled job object has required fields before passing to scheduler.
|
|
25
|
+
* Prevents opaque unknown[] from reaching CrewScheduler.add() without validation.
|
|
26
|
+
*/
|
|
27
|
+
function validateScheduledJob(job: unknown): boolean {
|
|
28
|
+
if (!job || typeof job !== "object") return false;
|
|
29
|
+
const obj = job as Record<string, unknown>;
|
|
30
|
+
return typeof obj.id === "string" && obj.id.length > 0
|
|
31
|
+
&& typeof obj.scheduleType === "string"
|
|
32
|
+
&& typeof obj.enabled === "boolean";
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
function sanitizeSettings(raw: unknown): CrewSettings {
|
|
24
36
|
if (!raw || typeof raw !== "object") return {};
|
|
25
37
|
const r = raw as Record<string, unknown>;
|
|
@@ -57,9 +69,9 @@ function sanitizeSettings(raw: unknown): CrewSettings {
|
|
|
57
69
|
if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
|
|
58
70
|
out.notifierIntervalMs = r.notifierIntervalMs;
|
|
59
71
|
}
|
|
60
|
-
// Pass through scheduledJobs
|
|
72
|
+
// Pass through scheduledJobs after basic validation
|
|
61
73
|
if (Array.isArray(r.scheduledJobs)) {
|
|
62
|
-
out.scheduledJobs = r.scheduledJobs;
|
|
74
|
+
out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob);
|
|
63
75
|
}
|
|
64
76
|
return out;
|
|
65
77
|
}
|
|
@@ -374,7 +374,8 @@ export function getWeightedSkillsForRole(
|
|
|
374
374
|
* Filter skills by confidence threshold.
|
|
375
375
|
* Skills below threshold are marked as "suggest" only.
|
|
376
376
|
*/
|
|
377
|
-
|
|
377
|
+
/** @internal */
|
|
378
|
+
function filterSkillsByConfidence(
|
|
378
379
|
skillIds: string[],
|
|
379
380
|
runId: string,
|
|
380
381
|
threshold: keyof typeof CONFIDENCE_THRESHOLDS = "MODERATE",
|
|
@@ -431,7 +432,8 @@ export function registerSkillEffectivenessHooks(): void {
|
|
|
431
432
|
/**
|
|
432
433
|
* Generate a skill effectiveness report for a run.
|
|
433
434
|
*/
|
|
434
|
-
|
|
435
|
+
/** @internal */
|
|
436
|
+
function generateSkillEffectivenessReport(
|
|
435
437
|
runId: string,
|
|
436
438
|
skillIds: string[],
|
|
437
439
|
): string {
|
|
@@ -244,7 +244,10 @@ export function renderSkillInstructions(input: RenderSkillInstructionsInput & {
|
|
|
244
244
|
const confidenceNote = weighted ? ` [Confidence: ${(weighted.confidence * 100).toFixed(0)}% — ${weighted.threshold}]` : "";
|
|
245
245
|
|
|
246
246
|
const header = [`## ${safeName}`, description ? `Description: ${description}${confidenceNote}` : undefined, `Source: ${source}`].filter(Boolean).join("\n");
|
|
247
|
-
const
|
|
247
|
+
const rawContent = compactSkillContent(loaded.content);
|
|
248
|
+
// Wrap skill content with provenance markers to help LLMs distinguish skill instructions
|
|
249
|
+
const wrappedContent = `<!-- skill: ${safeName} -->\n${rawContent}\n<!-- end-skill: ${safeName} -->`;
|
|
250
|
+
const section = `${header}\n\n${wrappedContent}`;
|
|
248
251
|
if (!pushSection(section)) omittedCount += 1;
|
|
249
252
|
}
|
|
250
253
|
if (omittedCount > 0) {
|
|
@@ -88,10 +88,28 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
const ALLOWED_RECORD_FIELDS = new Set([
|
|
92
|
+
"agentId", "agentName", "subagentType", "status", "spawnedAt",
|
|
93
|
+
"completedAt", "model", "runId", "cwd", "taskId", "taskId",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
function sanitizePersistedRecord(raw: unknown): SubagentRecord | undefined {
|
|
97
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
98
|
+
const obj = raw as Record<string, unknown>;
|
|
99
|
+
if (typeof obj.agentId !== "string" || !obj.agentId) return undefined;
|
|
100
|
+
const clean: Record<string, unknown> = { agentId: obj.agentId };
|
|
101
|
+
for (const key of Object.keys(obj)) {
|
|
102
|
+
if (ALLOWED_RECORD_FIELDS.has(key) && (typeof obj[key] === "string" || typeof obj[key] === "number" || typeof obj[key] === "boolean")) {
|
|
103
|
+
clean[key] = obj[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return clean as unknown as SubagentRecord;
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRecord | undefined {
|
|
92
110
|
try {
|
|
93
|
-
const
|
|
94
|
-
return
|
|
111
|
+
const raw = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
|
|
112
|
+
return sanitizePersistedRecord(raw);
|
|
95
113
|
} catch {
|
|
96
114
|
return undefined;
|
|
97
115
|
}
|