pi-crew 0.9.19 → 0.9.21
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 +28 -0
- package/dist/build-meta.json +4725 -4677
- package/dist/index.mjs +2060 -1760
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
- package/src/extension/cross-extension-rpc.ts +162 -131
- package/src/extension/rpc-hmac.ts +236 -0
- package/src/extension/team-tool/run.ts +33 -4
- package/src/runtime/background-runner.ts +7 -0
- package/src/runtime/child-pi.ts +62 -81
- package/src/runtime/cross-extension-rpc.ts +22 -3
- package/src/runtime/dynamic-workflow-context.ts +3 -3
- package/src/runtime/task-runner.ts +4 -4
- package/src/runtime/team-runner.ts +130 -1
- package/src/runtime/tool-output-pruner.ts +5 -3
- package/src/state/event-log.ts +18 -0
- package/src/state/types.ts +5 -0
- package/src/tools/safe-bash-extension.ts +2 -2
- package/src/tools/safe-bash.ts +100 -0
- package/src/utils/env-filter.ts +49 -0
- package/src/utils/token-counter.ts +67 -0
- package/src/worktree/worktree-manager.ts +365 -1
- package/docs/A +0 -358
- package/docs/M-A +0 -357
- package/docs/Y +0 -357
- package/docs/a +0 -357
- package/docs/aA +0 -358
- package/docs//303/242mmaAAA/303/242 +0 -357
package/src/runtime/child-pi.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
|
|
|
7
7
|
import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
|
|
8
8
|
import type { WorkerExitStatus } from "../state/types.ts";
|
|
9
9
|
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
10
|
-
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
10
|
+
import { buildScopedAllowList, sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
11
11
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
12
12
|
import { redactJsonLine, redactSecretString } from "../utils/redaction.ts";
|
|
13
13
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
@@ -254,7 +254,53 @@ export interface ChildPiRunResult {
|
|
|
254
254
|
intermediateFindings?: string;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
|
|
257
|
+
// Base allowlist of non-provider env vars always passed to child workers.
|
|
258
|
+
// Provider API keys are injected dynamically via buildScopedAllowList() only
|
|
259
|
+
// when a model is assigned to the task (per-task key scoping).
|
|
260
|
+
const BASE_ALLOWLIST: string[] = [
|
|
261
|
+
"PATH",
|
|
262
|
+
"HOME",
|
|
263
|
+
"USER",
|
|
264
|
+
"SHELL",
|
|
265
|
+
"TERM",
|
|
266
|
+
"LANG",
|
|
267
|
+
"LC_ALL",
|
|
268
|
+
"LC_COLLATE",
|
|
269
|
+
"LC_CTYPE",
|
|
270
|
+
"LC_MESSAGES",
|
|
271
|
+
"LC_MONETARY",
|
|
272
|
+
"LC_NUMERIC",
|
|
273
|
+
"LC_TIME",
|
|
274
|
+
"XDG_CONFIG_HOME",
|
|
275
|
+
"XDG_DATA_HOME",
|
|
276
|
+
"XDG_CACHE_HOME",
|
|
277
|
+
"XDG_RUNTIME_DIR",
|
|
278
|
+
// Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
|
|
279
|
+
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
280
|
+
"NVM_BIN",
|
|
281
|
+
"NVM_DIR",
|
|
282
|
+
"NVM_INC",
|
|
283
|
+
"NODE_DISABLE_COLORS",
|
|
284
|
+
"NODE_EXTRA_CA_CERTS",
|
|
285
|
+
"NPM_CONFIG_REGISTRY",
|
|
286
|
+
"NPM_CONFIG_USERCONFIG",
|
|
287
|
+
"NPM_CONFIG_GLOBALCONFIG",
|
|
288
|
+
"PI_CREW_DEPTH",
|
|
289
|
+
"PI_CREW_MAX_DEPTH",
|
|
290
|
+
"PI_CREW_INHERIT_PROJECT_CONTEXT",
|
|
291
|
+
"PI_CREW_INHERIT_SKILLS",
|
|
292
|
+
"PI_CREW_KIND",
|
|
293
|
+
"PI_CREW_PARENT_PID",
|
|
294
|
+
"PI_TEAMS_DEPTH",
|
|
295
|
+
"PI_TEAMS_MAX_DEPTH",
|
|
296
|
+
"PI_TEAMS_INHERIT_PROJECT_CONTEXT",
|
|
297
|
+
"PI_TEAMS_INHERIT_SKILLS",
|
|
298
|
+
"PI_TEAMS_PI_BIN",
|
|
299
|
+
"PI_TEAMS_MOCK_CHILD_PI",
|
|
300
|
+
"PI_CREW_ALLOW_MOCK",
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
|
|
258
304
|
// SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
|
|
259
305
|
// If cwd comes from an untrusted source (user input, workspace config), a malicious cwd
|
|
260
306
|
// could cause the child process to operate in an attacker-controlled directory,
|
|
@@ -284,81 +330,12 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
|
|
|
284
330
|
// IMPORTANT: preserve model provider API keys — they are needed by the child Pi to call the LLM.
|
|
285
331
|
// Also preserve essential non-secret vars (PATH, HOME, USER, etc.) so the child process can function.
|
|
286
332
|
// Bug #12 fix: essential env vars (PATH, HOME, etc.) are always preserved so child can find npm/node.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
* authenticate with whichever provider the model routes to. Reducing keys per-child
|
|
294
|
-
* would break multi-provider functionality. Mitigations:
|
|
295
|
-
* - sanitizeEnvSecrets strips all env vars NOT on this list.
|
|
296
|
-
* - Do NOT add wildcards ("*_API_KEY") — only explicit, intended provider keys.
|
|
297
|
-
* - Consider per-task key scoping if the architecture allows it in the future.
|
|
298
|
-
*
|
|
299
|
-
* MAINTENANCE REQUIREMENT: When new secret env vars are added to the Pi ecosystem,
|
|
300
|
-
* they MUST be explicitly added to this allowlist to be passed to child processes.
|
|
301
|
-
* A CI check should fail if a secret-like env var (matching patterns like *_API_KEY,
|
|
302
|
-
* *_TOKEN, *_SECRET) is detected in the codebase but not present in this list.
|
|
303
|
-
*/
|
|
304
|
-
// NOTE: Model provider API keys are NOT needed here — child Pi uses the same
|
|
305
|
-
// config file as parent Pi. Passing keys via env is a security risk.
|
|
306
|
-
"PATH",
|
|
307
|
-
"HOME",
|
|
308
|
-
"USER",
|
|
309
|
-
"SHELL",
|
|
310
|
-
"TERM",
|
|
311
|
-
"LANG",
|
|
312
|
-
// FIX: Replaced broad wildcards (LC_*, XDG_*, NVM_*, NODE_*, npm_*) with
|
|
313
|
-
// specific names. Previously NPM_TOKEN, NODE_ENV=production, NVM_RC_VERSION
|
|
314
|
-
// all leaked through wildcards.
|
|
315
|
-
"LC_ALL",
|
|
316
|
-
"LC_COLLATE",
|
|
317
|
-
"LC_CTYPE",
|
|
318
|
-
"LC_MESSAGES",
|
|
319
|
-
"LC_MONETARY",
|
|
320
|
-
"LC_NUMERIC",
|
|
321
|
-
"LC_TIME",
|
|
322
|
-
"XDG_CONFIG_HOME",
|
|
323
|
-
"XDG_DATA_HOME",
|
|
324
|
-
"XDG_CACHE_HOME",
|
|
325
|
-
"XDG_RUNTIME_DIR",
|
|
326
|
-
// Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
|
|
327
|
-
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
328
|
-
"NVM_BIN",
|
|
329
|
-
"NVM_DIR",
|
|
330
|
-
"NVM_INC",
|
|
331
|
-
// NODE_PATH is intentionally omitted from the allowlist.
|
|
332
|
-
// NODE_PATH can reveal user environment information (e.g., NVM paths under $HOME)
|
|
333
|
-
// and the validation at lines 286-298 only filters to standard system prefixes.
|
|
334
|
-
// Removing it entirely is cleaner than best-effort filtering.
|
|
335
|
-
"NODE_DISABLE_COLORS",
|
|
336
|
-
"NODE_EXTRA_CA_CERTS",
|
|
337
|
-
"NPM_CONFIG_REGISTRY",
|
|
338
|
-
"NPM_CONFIG_USERCONFIG",
|
|
339
|
-
"NPM_CONFIG_GLOBALCONFIG",
|
|
340
|
-
// FIX: Replace PI_CREW_*/PI_TEAMS_* wildcards with explicit list of
|
|
341
|
-
// safe vars. Wildcards are fragile — any new secret var would leak.
|
|
342
|
-
// Only non-secret execution-control vars that children legitimately need.
|
|
343
|
-
"PI_CREW_DEPTH",
|
|
344
|
-
"PI_CREW_MAX_DEPTH",
|
|
345
|
-
"PI_CREW_INHERIT_PROJECT_CONTEXT",
|
|
346
|
-
"PI_CREW_INHERIT_SKILLS",
|
|
347
|
-
// PI_CREW_KIND marks this process as a crew sub-agent (vs the user's main session).
|
|
348
|
-
// doctor --zombies matches it to safely list orphaned sub-agents only.
|
|
349
|
-
"PI_CREW_KIND",
|
|
350
|
-
// PI_CREW_PARENT_PID is needed by child-pi's parent-guard (uses
|
|
351
|
-
// process.kill(pid, 0) liveness check). The PID is not a secret.
|
|
352
|
-
"PI_CREW_PARENT_PID",
|
|
353
|
-
"PI_TEAMS_DEPTH",
|
|
354
|
-
"PI_TEAMS_MAX_DEPTH",
|
|
355
|
-
"PI_TEAMS_INHERIT_PROJECT_CONTEXT",
|
|
356
|
-
"PI_TEAMS_INHERIT_SKILLS",
|
|
357
|
-
"PI_TEAMS_PI_BIN",
|
|
358
|
-
"PI_TEAMS_MOCK_CHILD_PI",
|
|
359
|
-
"PI_CREW_ALLOW_MOCK",
|
|
360
|
-
],
|
|
361
|
-
});
|
|
333
|
+
//
|
|
334
|
+
// PER-TASK KEY SCOPING: when a model is provided, only the env keys for that
|
|
335
|
+
// provider are injected (via buildScopedAllowList). When no model is given,
|
|
336
|
+
// only BASE_ALLOWLIST system vars pass through — no provider keys leak.
|
|
337
|
+
const allowList = model ? buildScopedAllowList(BASE_ALLOWLIST, [model]) : BASE_ALLOWLIST;
|
|
338
|
+
const filteredEnv = sanitizeEnvSecrets(env, { allowList });
|
|
362
339
|
// FIX: Removed delete workarounds — with explicit allowlist, these vars
|
|
363
340
|
// are no longer auto-leaked. The wildcard approach was fragile.
|
|
364
341
|
|
|
@@ -900,10 +877,14 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
900
877
|
const child = spawn(
|
|
901
878
|
spawnSpec.command,
|
|
902
879
|
spawnSpec.args,
|
|
903
|
-
buildChildPiSpawnOptions(
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
880
|
+
buildChildPiSpawnOptions(
|
|
881
|
+
input.cwd,
|
|
882
|
+
{
|
|
883
|
+
...process.env,
|
|
884
|
+
...built.env,
|
|
885
|
+
},
|
|
886
|
+
input.model,
|
|
887
|
+
),
|
|
907
888
|
);
|
|
908
889
|
if (child.pid) {
|
|
909
890
|
activeChildProcesses.set(child.pid, child);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
+
import { isHmacEnabled, extractSignaturePayload, verifyRpcSignature } from "../extension/rpc-hmac.ts";
|
|
2
3
|
|
|
3
4
|
export interface EventBus {
|
|
4
5
|
on(event: string, handler: (data: unknown) => void): () => void;
|
|
@@ -47,6 +48,25 @@ function handleRpc<P extends { requestId: string }>(
|
|
|
47
48
|
if (!/^[a-zA-Z0-9_-]+$/.test(params.requestId)) {
|
|
48
49
|
throw new Error("Security: invalid requestId format");
|
|
49
50
|
}
|
|
51
|
+
// SECURITY: HMAC signature verification (when enabled).
|
|
52
|
+
if (isHmacEnabled()) {
|
|
53
|
+
const sigPayload = extractSignaturePayload(params);
|
|
54
|
+
if (!sigPayload) {
|
|
55
|
+
throw new Error("[pi-crew HMAC] Missing HMAC signature in RPC request.");
|
|
56
|
+
}
|
|
57
|
+
// Strip HMAC fields before verification (HMAC was signed over original params)
|
|
58
|
+
const originalParams = { ...params };
|
|
59
|
+
delete (originalParams as Record<string, unknown>).hmacVersion;
|
|
60
|
+
delete (originalParams as Record<string, unknown>).hmacOrigin;
|
|
61
|
+
delete (originalParams as Record<string, unknown>).hmacTimestamp;
|
|
62
|
+
delete (originalParams as Record<string, unknown>).hmacChannel;
|
|
63
|
+
delete (originalParams as Record<string, unknown>).hmacNonce;
|
|
64
|
+
delete (originalParams as Record<string, unknown>).hmacSignature;
|
|
65
|
+
const verification = verifyRpcSignature(sigPayload, originalParams);
|
|
66
|
+
if (!verification.valid) {
|
|
67
|
+
throw new Error(`[pi-crew HMAC] ${verification.reason}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
50
70
|
try {
|
|
51
71
|
const data = await fn(params);
|
|
52
72
|
const reply: { success: true; data?: unknown } = { success: true };
|
|
@@ -72,9 +92,8 @@ export function registerCrewRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
72
92
|
// operations that create or terminate child processes. Any subscriber on
|
|
73
93
|
// the shared event bus can emit these events. In a multi-extension
|
|
74
94
|
// environment, this means a malicious extension could spawn/stop agents.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// still requires event-bus-level origin signing.
|
|
95
|
+
// Mitigations: HMAC origin signing (see rpc-hmac.ts) + per-process token
|
|
96
|
+
// (H-2) + legacy `source` identifier.
|
|
78
97
|
const CREW_RPC_SOURCE = "pi-crew";
|
|
79
98
|
const EXPECTED_TOKEN = getCrewRpcToken();
|
|
80
99
|
|
|
@@ -32,7 +32,7 @@ import { appendMailboxMessage, readMailbox } from "../state/mailbox.ts";
|
|
|
32
32
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
33
33
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
34
34
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
35
|
-
import {
|
|
35
|
+
import { cleanupAgentWorktreeAsync, prepareAgentWorktreeAsync } from "../worktree/worktree-manager.ts";
|
|
36
36
|
import { runChildPi } from "./child-pi.ts";
|
|
37
37
|
import type { DwfCheckpointState } from "./dwf-state-store.ts";
|
|
38
38
|
import { mapConcurrent } from "./parallel-utils.ts";
|
|
@@ -342,7 +342,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
342
342
|
// when worktree creation is unavailable (no git repo, dirty leader).
|
|
343
343
|
let agentCwd = manifest.cwd;
|
|
344
344
|
if (call.worktree === true) {
|
|
345
|
-
const wt =
|
|
345
|
+
const wt = await prepareAgentWorktreeAsync(manifest, `dwf-agent-${Date.now()}-${randomBytes(4).toString("hex")}`);
|
|
346
346
|
if (wt?.worktreePath) {
|
|
347
347
|
agentCwd = wt.cwd;
|
|
348
348
|
worktreePath = wt.worktreePath;
|
|
@@ -431,7 +431,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
431
431
|
// — a leak must never crash the workflow.
|
|
432
432
|
if (worktreePath) {
|
|
433
433
|
try {
|
|
434
|
-
|
|
434
|
+
await cleanupAgentWorktreeAsync(manifest, worktreePath, worktreeBranch);
|
|
435
435
|
} catch (cleanupError) {
|
|
436
436
|
logInternalError("dynamic-workflow-context.worktree-cleanup", cleanupError, `worktreePath=${worktreePath}`);
|
|
437
437
|
}
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
21
21
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
22
22
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
23
|
-
import {
|
|
23
|
+
import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync, prepareTaskWorkspaceAsync } from "../worktree/worktree-manager.ts";
|
|
24
24
|
import { reserveControlChannel } from "./agent-control.ts";
|
|
25
25
|
import { appendTaskAttentionEvent } from "./attention-events.ts";
|
|
26
26
|
import { buildSyntheticTerminalEvidence, cancellationReasonFromSignal } from "./cancellation.ts";
|
|
@@ -122,7 +122,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
122
122
|
let streamBridge: ReturnType<typeof registerStreamBridge> | undefined;
|
|
123
123
|
try {
|
|
124
124
|
streamBridge = registerStreamBridge(manifest.runId);
|
|
125
|
-
const workspace =
|
|
125
|
+
const workspace = await prepareTaskWorkspaceAsync(manifest, input.task, input.step.seedPaths);
|
|
126
126
|
const worktree =
|
|
127
127
|
workspace.worktreePath && workspace.branch
|
|
128
128
|
? {
|
|
@@ -905,7 +905,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
905
905
|
? writeArtifact(manifest.artifactsRoot, {
|
|
906
906
|
kind: "diff",
|
|
907
907
|
relativePath: `diffs/${task.id}.diff`,
|
|
908
|
-
content:
|
|
908
|
+
content: await captureWorktreeDiffAsync(workspace.worktreePath),
|
|
909
909
|
producer: task.id,
|
|
910
910
|
})
|
|
911
911
|
: undefined;
|
|
@@ -913,7 +913,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
913
913
|
? writeArtifact(manifest.artifactsRoot, {
|
|
914
914
|
kind: "metadata",
|
|
915
915
|
relativePath: `metadata/${task.id}.diff-stat.json`,
|
|
916
|
-
content: `${JSON.stringify({ ...
|
|
916
|
+
content: `${JSON.stringify({ ...(await captureWorktreeDiffStatAsync(workspace.worktreePath)), syntheticPaths: workspace.syntheticPaths ?? [], nodeModulesLinked: workspace.nodeModulesLinked ?? false }, null, 2)}\n`,
|
|
917
917
|
producer: task.id,
|
|
918
918
|
})
|
|
919
919
|
: undefined;
|
|
@@ -13,7 +13,7 @@ import { HealthStore } from "../state/health-store.ts";
|
|
|
13
13
|
import { withRunLock } from "../state/locks.ts";
|
|
14
14
|
import { loadRunManifestById, saveRunManifest, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
|
|
15
15
|
import type { ArtifactDescriptor, PolicyDecision, TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
16
|
-
import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
16
|
+
import { aggregateUsage, formatTokens, formatUsage } from "../state/usage.ts";
|
|
17
17
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
18
18
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
19
19
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
@@ -130,6 +130,60 @@ export interface ExecuteTeamRunInput {
|
|
|
130
130
|
onJsonEvent?: (taskId: string, runId: string, event: unknown) => void;
|
|
131
131
|
/** Workspace where this run was initiated — used for session-scoped live-agent visibility. */
|
|
132
132
|
workspaceId: string;
|
|
133
|
+
/** Total token budget for the run. When set, enables per-task budget enforcement. */
|
|
134
|
+
budgetTotal?: number;
|
|
135
|
+
/** Budget warning threshold as a fraction (0-1). Default: 0.8 (80%). */
|
|
136
|
+
budgetWarning?: number;
|
|
137
|
+
/** Budget abort threshold as a fraction (0-1). Default: 0.95 (95%). */
|
|
138
|
+
budgetAbort?: number;
|
|
139
|
+
/** When true, skip budget enforcement entirely. */
|
|
140
|
+
budgetUnlimited?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Result of a per-task budget check against cumulative run usage.
|
|
145
|
+
*/
|
|
146
|
+
export interface PerTaskBudgetCheckResult {
|
|
147
|
+
/** Whether the abort threshold was exceeded. */
|
|
148
|
+
abort: boolean;
|
|
149
|
+
/** Whether the warning threshold was exceeded. */
|
|
150
|
+
warning: boolean;
|
|
151
|
+
/** IDs of tasks that exceeded their fair share (>50% of remaining budget). */
|
|
152
|
+
fairShareViolators: string[];
|
|
153
|
+
/** Total tokens used so far. */
|
|
154
|
+
totalUsed: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check cumulative token usage against per-task budget thresholds.
|
|
159
|
+
* Returns a structured result — callers decide how to act (warn vs abort).
|
|
160
|
+
*
|
|
161
|
+
* Exported for unit testing.
|
|
162
|
+
*/
|
|
163
|
+
export function checkPerTaskBudget(
|
|
164
|
+
tasks: TeamTaskState[],
|
|
165
|
+
budgetTotal: number,
|
|
166
|
+
budgetWarning: number,
|
|
167
|
+
budgetAbort: number,
|
|
168
|
+
fairShareFraction = 0.5,
|
|
169
|
+
): PerTaskBudgetCheckResult {
|
|
170
|
+
const usage = aggregateUsage(tasks);
|
|
171
|
+
const totalUsed = (usage?.input ?? 0) + (usage?.output ?? 0) + (usage?.cacheWrite ?? 0);
|
|
172
|
+
const abort = totalUsed >= budgetAbort * budgetTotal;
|
|
173
|
+
const warning = !abort && totalUsed >= budgetWarning * budgetTotal;
|
|
174
|
+
const remainingBudget = Math.max(0, budgetTotal - totalUsed);
|
|
175
|
+
const fairShareThreshold = remainingBudget * fairShareFraction;
|
|
176
|
+
const fairShareViolators: string[] = [];
|
|
177
|
+
for (const task of tasks) {
|
|
178
|
+
if (!task.usage) continue;
|
|
179
|
+
const taskTotal = (task.usage.input ?? 0) + (task.usage.output ?? 0) + (task.usage.cacheWrite ?? 0);
|
|
180
|
+
// Only flag tasks that individually consumed a significant portion of the
|
|
181
|
+
// budget (>10% of total) AND exceeded the fair share of remaining budget.
|
|
182
|
+
if (fairShareThreshold > 0 && taskTotal > fairShareThreshold && taskTotal > budgetTotal * 0.1) {
|
|
183
|
+
fairShareViolators.push(task.id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { abort, warning, fairShareViolators, totalUsed };
|
|
133
187
|
}
|
|
134
188
|
|
|
135
189
|
function findStep(workflow: WorkflowConfig, task: TeamTaskState): WorkflowStep {
|
|
@@ -586,6 +640,18 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
586
640
|
input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.",
|
|
587
641
|
);
|
|
588
642
|
|
|
643
|
+
// Persist budget fields on the manifest so all subsequent saveRunManifest
|
|
644
|
+
// calls (there are many in executeTeamRunCore) preserve the budget config.
|
|
645
|
+
// Without this, the spread in updateRunStatus and other transformations
|
|
646
|
+
// can drop budget fields before they reach the persistence layer.
|
|
647
|
+
if (input.budgetTotal !== undefined) manifest.budgetTotal = input.budgetTotal;
|
|
648
|
+
if (input.budgetWarning !== undefined) manifest.budgetWarning = input.budgetWarning;
|
|
649
|
+
if (input.budgetAbort !== undefined) manifest.budgetAbort = input.budgetAbort;
|
|
650
|
+
if (input.budgetUnlimited !== undefined) manifest.budgetUnlimited = input.budgetUnlimited;
|
|
651
|
+
if (manifest.budgetTotal !== undefined && manifest.budgetTotal > 0 && !manifest.budgetUnlimited) {
|
|
652
|
+
saveRunManifest(manifest);
|
|
653
|
+
}
|
|
654
|
+
|
|
589
655
|
void registerRunPromise(manifest.runId);
|
|
590
656
|
|
|
591
657
|
// FIX (Round 15, regression): Start a team-level heartbeat so the stale
|
|
@@ -1444,6 +1510,69 @@ async function executeTeamRunCore(
|
|
|
1444
1510
|
wfMachine = { ...wfMachine, currentPhaseIndex: pi + 1 };
|
|
1445
1511
|
}
|
|
1446
1512
|
|
|
1513
|
+
// Per-task budget enforcement: check cumulative usage after each batch merge.
|
|
1514
|
+
// This prevents a single task from consuming 100% of the budget before
|
|
1515
|
+
// abort triggers (the goal-loop only checks at turn boundaries).
|
|
1516
|
+
if (input.budgetTotal !== undefined && input.budgetTotal > 0 && input.budgetUnlimited !== true) {
|
|
1517
|
+
const warnThreshold = input.budgetWarning ?? 0.8;
|
|
1518
|
+
const abortThreshold = input.budgetAbort ?? 0.95;
|
|
1519
|
+
const budgetCheck = checkPerTaskBudget(tasks, input.budgetTotal, warnThreshold, abortThreshold);
|
|
1520
|
+
|
|
1521
|
+
if (budgetCheck.abort) {
|
|
1522
|
+
const message = `Per-task budget abort threshold exceeded: ${formatTokens(budgetCheck.totalUsed)}/${formatTokens(input.budgetTotal)} (${Math.round((budgetCheck.totalUsed / input.budgetTotal) * 100)}%)`;
|
|
1523
|
+
console.warn(`[team-runner] ${message}`);
|
|
1524
|
+
await appendEventAsync(manifest.eventsPath, {
|
|
1525
|
+
type: "run.budget_abort",
|
|
1526
|
+
runId: manifest.runId,
|
|
1527
|
+
message,
|
|
1528
|
+
data: {
|
|
1529
|
+
budgetTotal: input.budgetTotal,
|
|
1530
|
+
budgetUsed: budgetCheck.totalUsed,
|
|
1531
|
+
threshold: "abort",
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
tasks = markBlocked(tasks, `Budget abort threshold exceeded: ${message}`);
|
|
1535
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
1536
|
+
manifest = updateRunStatus(manifest, "failed", message);
|
|
1537
|
+
return { manifest, tasks };
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if (budgetCheck.warning) {
|
|
1541
|
+
const message = `Per-task budget warning threshold crossed: ${formatTokens(budgetCheck.totalUsed)}/${formatTokens(input.budgetTotal)} (${Math.round((budgetCheck.totalUsed / input.budgetTotal) * 100)}%)`;
|
|
1542
|
+
console.warn(`[team-runner] ${message}`);
|
|
1543
|
+
await appendEventAsync(manifest.eventsPath, {
|
|
1544
|
+
type: "run.budget_warning",
|
|
1545
|
+
runId: manifest.runId,
|
|
1546
|
+
message,
|
|
1547
|
+
data: {
|
|
1548
|
+
budgetTotal: input.budgetTotal,
|
|
1549
|
+
budgetUsed: budgetCheck.totalUsed,
|
|
1550
|
+
threshold: "warning",
|
|
1551
|
+
},
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Fair-share warning: flag tasks that consumed >50% of remaining budget
|
|
1556
|
+
// without killing them mid-execution.
|
|
1557
|
+
for (const violatorId of budgetCheck.fairShareViolators) {
|
|
1558
|
+
const violator = tasks.find((t) => t.id === violatorId);
|
|
1559
|
+
if (!violator) continue;
|
|
1560
|
+
const taskTotal = (violator.usage?.input ?? 0) + (violator.usage?.output ?? 0) + (violator.usage?.cacheWrite ?? 0);
|
|
1561
|
+
const message = `Task '${violatorId}' consumed ${formatTokens(taskTotal)} (${Math.round((taskTotal / input.budgetTotal) * 100)}% of total budget) — exceeds fair share`;
|
|
1562
|
+
console.warn(`[team-runner.fair-share] ${message}`);
|
|
1563
|
+
await appendEventAsync(manifest.eventsPath, {
|
|
1564
|
+
type: "task.budget_fair_share",
|
|
1565
|
+
runId: manifest.runId,
|
|
1566
|
+
taskId: violatorId,
|
|
1567
|
+
message,
|
|
1568
|
+
data: {
|
|
1569
|
+
budgetTotal: input.budgetTotal,
|
|
1570
|
+
taskUsage: taskTotal,
|
|
1571
|
+
},
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1447
1576
|
const cancelledResult = results.find((item) => item.manifest.status === "cancelled");
|
|
1448
1577
|
if (cancelledResult || input.signal?.aborted) {
|
|
1449
1578
|
const reason = input.signal?.aborted ? cancellationReasonFromSignal(input.signal) : undefined;
|
|
@@ -90,8 +90,10 @@ export interface PruneResult {
|
|
|
90
90
|
// Token estimation (rough char/4 heuristic, matching gajae-code)
|
|
91
91
|
// ---------------------------------------------------------------------------
|
|
92
92
|
|
|
93
|
+
import { countTokens } from "../utils/token-counter";
|
|
94
|
+
|
|
93
95
|
function estimateTokens(text: string): number {
|
|
94
|
-
return
|
|
96
|
+
return countTokens(text);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
// ---------------------------------------------------------------------------
|
|
@@ -146,7 +148,7 @@ function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
|
|
|
146
148
|
const generic = `[Output pruned — ${tokens} tokens]`;
|
|
147
149
|
const digest = resultDigest(entry.toolName, entry.content, entry.isError);
|
|
148
150
|
if (!digest) return generic;
|
|
149
|
-
const genericTokens =
|
|
151
|
+
const genericTokens = countTokens(generic);
|
|
150
152
|
const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
|
|
151
153
|
const prefix = `[Output pruned — ${tokens} tokens; `;
|
|
152
154
|
const suffix = "]";
|
|
@@ -303,7 +305,7 @@ export function pruneToolOutputs(results: ToolResultEntry[], config: PruneConfig
|
|
|
303
305
|
entry,
|
|
304
306
|
tokens,
|
|
305
307
|
notice,
|
|
306
|
-
savings: Math.max(0, tokens -
|
|
308
|
+
savings: Math.max(0, tokens - countTokens(notice)),
|
|
307
309
|
});
|
|
308
310
|
accumulatedTokens += tokens;
|
|
309
311
|
}
|
package/src/state/event-log.ts
CHANGED
|
@@ -350,6 +350,11 @@ export function checkSequenceGaps(eventsPath: string): { missing: number }[] {
|
|
|
350
350
|
* from firing and degrading live-agent responsiveness.
|
|
351
351
|
*/
|
|
352
352
|
export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
|
|
353
|
+
// NOTE: appendEvent is a sync function that uses withEventLogLockSync (sleepSync).
|
|
354
|
+
// It cannot route through appendEventBuffered because the buffer timer requires
|
|
355
|
+
// the event loop to fire, which sleepSync blocks. Both terminal and non-terminal
|
|
356
|
+
// events use the direct sync path here. For non-terminal events, callers should
|
|
357
|
+
// prefer appendEventAsync (which routes through the buffer for coalesced writes).
|
|
353
358
|
return withEventLogLockSync(eventsPath, () => appendEventInsideLock(eventsPath, event));
|
|
354
359
|
}
|
|
355
360
|
|
|
@@ -405,6 +410,19 @@ export function resetEventLogMode(): void {
|
|
|
405
410
|
* foreground-control, etc.), prefer this over the sync `appendEvent()`.
|
|
406
411
|
*/
|
|
407
412
|
export async function appendEventAsync(eventsPath: string, event: AppendTeamEvent): Promise<TeamEvent> {
|
|
413
|
+
// Non-terminal events: route through buffer for coalesced writes (20ms default).
|
|
414
|
+
// This eliminates per-event fsync + persistSequence overhead for high-frequency events.
|
|
415
|
+
if (!TERMINAL_EVENT_TYPES.has(event.type)) {
|
|
416
|
+
const result = appendEventBuffered(eventsPath, event);
|
|
417
|
+
// FIX: appendEventBuffered uses unref()'d timers. Keep the event loop alive
|
|
418
|
+
// so the promise resolves in standalone/test contexts where nothing else
|
|
419
|
+
// keeps the loop running. Without this, the event loop drains before the
|
|
420
|
+
// timer fires and the promise never resolves.
|
|
421
|
+
const keepAlive = setInterval(() => {}, 200);
|
|
422
|
+
void result.finally(() => clearInterval(keepAlive));
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
// Terminal events: direct async path for immediate durability guarantee
|
|
408
426
|
const queueKey = eventsPath;
|
|
409
427
|
const prev = asyncQueues.get(queueKey) ?? Promise.resolve();
|
|
410
428
|
const next = prev.then(async (): Promise<TeamEvent> => {
|
package/src/state/types.ts
CHANGED
|
@@ -208,6 +208,11 @@ export interface TeamRunManifest {
|
|
|
208
208
|
runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
|
|
209
209
|
/** round-14 P1-5: typed workflow arguments accessible in .dwf.ts scripts via ctx.args<T>(). Any JSON value; default {} when unset. */
|
|
210
210
|
args?: unknown;
|
|
211
|
+
/** Per-run token budget snapshot. budgetTotal is the cap; budgetWarning/budgetAbort are fractions in [0,1] at which the team-runner emits run.budget_warning / run.budget_abort. budgetUnlimited=true opts out of enforcement. Optional for backward compat. */
|
|
212
|
+
budgetTotal?: number;
|
|
213
|
+
budgetWarning?: number;
|
|
214
|
+
budgetAbort?: number;
|
|
215
|
+
budgetUnlimited?: boolean;
|
|
211
216
|
summary?: string;
|
|
212
217
|
policyDecisions?: PolicyDecision[];
|
|
213
218
|
/** #2 (assessment): goal-achievement verdict — kills the silent false-green. */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { createBashTool } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Type } from "@sinclair/typebox";
|
|
17
|
-
import {
|
|
17
|
+
import { checkCommand } from "./safe-bash.ts";
|
|
18
18
|
|
|
19
19
|
export default function safeBashExtension(pi: ExtensionAPI): void {
|
|
20
20
|
const cwd = process.cwd();
|
|
@@ -35,7 +35,7 @@ export default function safeBashExtension(pi: ExtensionAPI): void {
|
|
|
35
35
|
),
|
|
36
36
|
}),
|
|
37
37
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
38
|
-
const danger =
|
|
38
|
+
const danger = checkCommand(params.command);
|
|
39
39
|
if (danger) {
|
|
40
40
|
return {
|
|
41
41
|
details: {},
|
package/src/tools/safe-bash.ts
CHANGED
|
@@ -403,3 +403,103 @@ export const SAFE_BASH_PRESETS = {
|
|
|
403
403
|
allowPatterns: [],
|
|
404
404
|
},
|
|
405
405
|
};
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* === Whitelist Mode (opt-in, additive) ===
|
|
409
|
+
*
|
|
410
|
+
* A deny-by-default mode that checks the first token of a command against an
|
|
411
|
+
* explicit allowlist of read-only utilities. Enabled via
|
|
412
|
+
* `PI_CREW_SAFE_BASH_MODE=whitelist`. When NOT enabled, the legacy blacklist
|
|
413
|
+
* `isDangerous()` path is used unchanged.
|
|
414
|
+
*/
|
|
415
|
+
|
|
416
|
+
/** Commands permitted under whitelist mode (read-only utilities only). */
|
|
417
|
+
const WHITELISTED_COMMANDS = new Set<string>([
|
|
418
|
+
"ls",
|
|
419
|
+
"cat",
|
|
420
|
+
"head",
|
|
421
|
+
"tail",
|
|
422
|
+
"wc",
|
|
423
|
+
"grep",
|
|
424
|
+
"find",
|
|
425
|
+
"echo",
|
|
426
|
+
"pwd",
|
|
427
|
+
"date",
|
|
428
|
+
"whoami",
|
|
429
|
+
"uname",
|
|
430
|
+
"df",
|
|
431
|
+
"du",
|
|
432
|
+
"file",
|
|
433
|
+
"stat",
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Shell operators/metacharacters that could chain or substitute a command past
|
|
438
|
+
* the first token. Their presence anywhere in the raw command causes the
|
|
439
|
+
* whitelist check to reject, so e.g. `ls; rm file` cannot smuggle `rm` through
|
|
440
|
+
* under a permitted first token.
|
|
441
|
+
*/
|
|
442
|
+
const SHELL_METACHARACTER_RE = /[|;&`()<>]|\$\(/;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Simple shell tokenizer: extract the first token (command name) of a command,
|
|
446
|
+
* respecting single and double quotes. Leading whitespace is skipped. Quote
|
|
447
|
+
* characters are not included in the returned token (`"ls"` → `ls`). An
|
|
448
|
+
* UNMATCHED quote is treated as malformed input and returns an empty string,
|
|
449
|
+
* causing `isAllowedWhitelist()` to reject the command (unmatched quotes can
|
|
450
|
+
* indicate malformed injection attempts).
|
|
451
|
+
*/
|
|
452
|
+
function shellFirstToken(command: string): string {
|
|
453
|
+
let i = 0;
|
|
454
|
+
const len = command.length;
|
|
455
|
+
while (i < len && /\s/.test(command[i])) i++;
|
|
456
|
+
let token = "";
|
|
457
|
+
while (i < len) {
|
|
458
|
+
const ch = command[i];
|
|
459
|
+
if (/\s/.test(ch)) break;
|
|
460
|
+
if (ch === "'" || ch === '"') {
|
|
461
|
+
const quote = ch;
|
|
462
|
+
i++;
|
|
463
|
+
while (i < len && command[i] !== quote) {
|
|
464
|
+
token += command[i];
|
|
465
|
+
i++;
|
|
466
|
+
}
|
|
467
|
+
// Unmatched quote → malformed input, reject by returning empty string.
|
|
468
|
+
if (i >= len) return "";
|
|
469
|
+
i++; // skip closing quote
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
token += ch;
|
|
473
|
+
i++;
|
|
474
|
+
}
|
|
475
|
+
return token;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Whitelist check (deny-by-default). Returns true only when the command's first
|
|
480
|
+
* token is in the allowlist AND no shell operators/metacharacters are present.
|
|
481
|
+
* Quoted first tokens (e.g. `"ls" -la`) are resolved before checking.
|
|
482
|
+
*/
|
|
483
|
+
export function isAllowedWhitelist(command: string): boolean {
|
|
484
|
+
if (command.trim() === "") return false;
|
|
485
|
+
if (SHELL_METACHARACTER_RE.test(command)) return false;
|
|
486
|
+
const firstToken = shellFirstToken(command);
|
|
487
|
+
if (firstToken === "") return false;
|
|
488
|
+
return WHITELISTED_COMMANDS.has(firstToken);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Current safe-bash mode, controlled by the `PI_CREW_SAFE_BASH_MODE` env var. */
|
|
492
|
+
export function getSafeBashMode(): "blacklist" | "whitelist" {
|
|
493
|
+
return process.env.PI_CREW_SAFE_BASH_MODE === "whitelist" ? "whitelist" : "blacklist";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Unified command check that dispatches on the active mode.
|
|
498
|
+
* @returns Error message if the command should be blocked, null if allowed.
|
|
499
|
+
*/
|
|
500
|
+
export function checkCommand(command: string, options: SafeBashOptions = {}): string | null {
|
|
501
|
+
if (getSafeBashMode() === "whitelist") {
|
|
502
|
+
return isAllowedWhitelist(command) ? null : "Command blocked by safe_bash whitelist: command not in allowlist";
|
|
503
|
+
}
|
|
504
|
+
return isDangerous(command, options);
|
|
505
|
+
}
|