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
|
@@ -61,7 +61,7 @@ class SubprocessToolRegistryImpl implements SubprocessToolRegistry {
|
|
|
61
61
|
|
|
62
62
|
export const subprocessToolRegistry: SubprocessToolRegistry = new SubprocessToolRegistryImpl();
|
|
63
63
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/** @internal Reset the global singleton registry (for test isolation). */
|
|
65
|
+
function resetSubprocessToolRegistry(): void {
|
|
66
66
|
subprocessToolRegistry.clear();
|
|
67
67
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type { TeamRunManifest, TaskPacket, TaskScope, VerificationContract } from "../state/types.ts";
|
|
3
3
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
4
|
+
import { generateTaskHashId } from "./task-id.ts";
|
|
4
5
|
|
|
5
6
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
6
7
|
// SEC-007 Fix: Workflow Step Task Sanitization
|
|
@@ -81,8 +82,19 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
|
|
|
81
82
|
const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
|
|
82
83
|
// SEC-007: Sanitize task text before inserting into task packet
|
|
83
84
|
const sanitizedTask = sanitizeTaskText(input.step.task);
|
|
85
|
+
const sanitizedGoal = sanitizeTaskText(input.manifest.goal);
|
|
86
|
+
|
|
87
|
+
// Generate a deterministic hash-based task ID for traceability and logging.
|
|
88
|
+
// Uses goal + step ID + run ID as content parts.
|
|
89
|
+
// TODO: Once TaskPacket type gains a hashId field, include this in the packet.
|
|
90
|
+
const _taskHashId = generateTaskHashId([
|
|
91
|
+
input.manifest.goal,
|
|
92
|
+
input.step.id,
|
|
93
|
+
input.manifest.runId,
|
|
94
|
+
]);
|
|
95
|
+
|
|
84
96
|
return {
|
|
85
|
-
objective: sanitizedTask.replaceAll("{goal}",
|
|
97
|
+
objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal),
|
|
86
98
|
scope,
|
|
87
99
|
scopePath,
|
|
88
100
|
repo: path.basename(input.manifest.cwd) || input.manifest.cwd,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
3
4
|
import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
|
|
4
5
|
import type {
|
|
@@ -272,6 +273,11 @@ export async function runTeamTask(
|
|
|
272
273
|
if (input.step.preStepScript) {
|
|
273
274
|
const scriptTimeout = input.step.preStepTimeout ?? 30_000;
|
|
274
275
|
const scriptArgs = input.step.preStepArgs ?? [];
|
|
276
|
+
// SECURITY: Validate preStepScript path is contained within cwd
|
|
277
|
+
const resolved = path.resolve(manifest.cwd, input.step.preStepScript);
|
|
278
|
+
if (!resolved.startsWith(path.resolve(manifest.cwd) + path.sep) && resolved !== path.resolve(manifest.cwd)) {
|
|
279
|
+
throw new Error(`Security: preStepScript path escapes working directory: ${input.step.preStepScript}`);
|
|
280
|
+
}
|
|
275
281
|
try {
|
|
276
282
|
const { execFileSync } = await import("node:child_process");
|
|
277
283
|
preStepOutput = execFileSync(input.step.preStepScript, scriptArgs, {
|
|
@@ -526,6 +532,9 @@ export async function runTeamTask(
|
|
|
526
532
|
collectedJsonEvents.push(
|
|
527
533
|
event as Record<string, unknown>,
|
|
528
534
|
);
|
|
535
|
+
if (collectedJsonEvents.length > 1000) {
|
|
536
|
+
collectedJsonEvents.splice(0, collectedJsonEvents.length - 1000);
|
|
537
|
+
}
|
|
529
538
|
// Accumulate lifetime usage via message_end events (survives compaction)
|
|
530
539
|
if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
|
|
531
540
|
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
|
|
@@ -16,7 +16,8 @@ export function addUsage(into: LifetimeUsage, delta: { input?: number; output?:
|
|
|
16
16
|
if (typeof delta.cacheWrite === "number") into.cacheWrite += delta.cacheWrite;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** @internal */
|
|
20
|
+
function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
|
|
20
21
|
if (!state) return emptyLifetimeUsage();
|
|
21
22
|
return {
|
|
22
23
|
input: state.input ?? 0,
|
|
@@ -59,7 +60,8 @@ export const getTaskUsage = getTrackedTaskUsage;
|
|
|
59
60
|
export const getRunUsage = getTrackedTaskUsage;
|
|
60
61
|
export const clearAllTaskUsage = clearAllTrackedTaskUsage;
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
/** @internal */
|
|
64
|
+
function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
|
|
63
65
|
const total = emptyLifetimeUsage();
|
|
64
66
|
for (const task of tasks) {
|
|
65
67
|
const tracked = getTrackedTaskUsage(task.id);
|
|
@@ -38,35 +38,61 @@ export interface PhaseGateBundle {
|
|
|
38
38
|
* Sequential enforcement: each phase must pass before proceeding.
|
|
39
39
|
*/
|
|
40
40
|
export const NPM_TYPESCRIPT_GATES: Array<{ name: string; command: string; critical: boolean }> = [
|
|
41
|
-
{ name: "build", command: "npm run build 2>&1
|
|
42
|
-
{ name: "typecheck", command: "npx tsc --noEmit 2>&1
|
|
43
|
-
{ name: "lint", command: "npm run lint 2>&1
|
|
44
|
-
{ name: "tests", command: "npm test 2>&1
|
|
41
|
+
{ name: "build", command: "npm run build 2>&1", critical: true },
|
|
42
|
+
{ name: "typecheck", command: "npx tsc --noEmit 2>&1", critical: true },
|
|
43
|
+
{ name: "lint", command: "npm run lint 2>&1", critical: false },
|
|
44
|
+
{ name: "tests", command: "npm test 2>&1", critical: true },
|
|
45
45
|
];
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Cargo/Rust project phase gates.
|
|
49
49
|
*/
|
|
50
50
|
export const CARGO_RUST_GATES: Array<{ name: string; command: string; critical: boolean }> = [
|
|
51
|
-
{ name: "check", command: "cargo check 2>&1
|
|
52
|
-
{ name: "test", command: "cargo test 2>&1
|
|
53
|
-
{ name: "clippy", command: "cargo clippy 2>&1
|
|
51
|
+
{ name: "check", command: "cargo check 2>&1", critical: true },
|
|
52
|
+
{ name: "test", command: "cargo test 2>&1", critical: true },
|
|
53
|
+
{ name: "clippy", command: "cargo clippy 2>&1", critical: false },
|
|
54
54
|
];
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Execute a single command and capture output.
|
|
58
58
|
*/
|
|
59
|
+
/** Characters/patterns that indicate dangerous shell metacharacters. */
|
|
60
|
+
const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\b(eval|exec)\b|>>|<[^&])/;
|
|
61
|
+
// Note: single `>` is NOT blocked here because `2>&1` is a safe redirect used by built-in gates.
|
|
62
|
+
// `>>` (append) is still blocked. `<` without `&` (input redirect) is still blocked.
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate a verification gate command is safe to execute.
|
|
66
|
+
* Rejects commands with shell metacharacters that could enable injection.
|
|
67
|
+
* Allows: pipes (|), redirection of stderr (2>&1), and basic npm/cargo/npx commands.
|
|
68
|
+
*/
|
|
69
|
+
function validateGateCommand(command: string): void {
|
|
70
|
+
const normalized = command
|
|
71
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // ANSI escape sequences
|
|
72
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // control chars
|
|
73
|
+
.replace(/\\\n/g, ' ') // escaped newlines
|
|
74
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
75
|
+
.trim();
|
|
76
|
+
if (DANGEROUS_SHELL_PATTERNS.test(normalized)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Security: verification gate command rejected (dangerous shell pattern): ${command}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
async function executeCommand(
|
|
60
84
|
command: string,
|
|
61
85
|
cwd: string,
|
|
62
86
|
timeoutMs: number = 120000,
|
|
63
87
|
): Promise<{ exitCode: number | null; output: string; durationMs: number }> {
|
|
88
|
+
// SECURITY: Validate command before shell execution to prevent injection.
|
|
89
|
+
validateGateCommand(command);
|
|
90
|
+
|
|
64
91
|
const start = Date.now();
|
|
65
92
|
let output = "";
|
|
66
93
|
let exitCode: number | null = null;
|
|
67
94
|
|
|
68
95
|
return new Promise((resolve) => {
|
|
69
|
-
// Use shell to handle compound commands
|
|
70
96
|
const shell = spawn("sh", ["-c", command], {
|
|
71
97
|
cwd,
|
|
72
98
|
timeout: timeoutMs,
|
|
@@ -313,7 +339,8 @@ export function computeGreenLevelFromResults(
|
|
|
313
339
|
* Create a verification gate report artifact.
|
|
314
340
|
* Formatted for human review per ECC verification-loop pattern.
|
|
315
341
|
*/
|
|
316
|
-
|
|
342
|
+
/** @internal */
|
|
343
|
+
function createVerificationGateReport(
|
|
317
344
|
taskId: string,
|
|
318
345
|
contract: VerificationContract,
|
|
319
346
|
results: VerificationCommandResult[],
|
package/src/state/contracts.ts
CHANGED
|
@@ -28,7 +28,8 @@ export const TEAM_TASK_STATUS_TRANSITIONS: Readonly<Record<TeamTaskStatus, reado
|
|
|
28
28
|
needs_attention: ["queued", "running"],
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** @internal */
|
|
32
|
+
const TEAM_EVENT_TYPES = [
|
|
32
33
|
"run.created",
|
|
33
34
|
"run.queued",
|
|
34
35
|
"run.planning",
|
package/src/state/event-log.ts
CHANGED
|
@@ -80,7 +80,11 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
|
|
|
80
80
|
const lockDir = `${eventsPath}.lock`;
|
|
81
81
|
const pidFile = path.join(lockDir, "pid");
|
|
82
82
|
const start = Date.now();
|
|
83
|
-
|
|
83
|
+
// SECURITY (HIGH #2 fix): Reduced from 120s to 5s to prevent blocking the
|
|
84
|
+
// event loop indefinitely. 500 retries × 10ms = 5s max. After timeout, we
|
|
85
|
+
// throw a clear error instead of blocking forever. This ensures AbortSignal
|
|
86
|
+
// handlers, SIGTERM, and graceful shutdown can fire within seconds.
|
|
87
|
+
const timeout = 5000;
|
|
84
88
|
const staleMs = 10000;
|
|
85
89
|
let acquired = false;
|
|
86
90
|
while (true) {
|
|
@@ -91,10 +95,12 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
|
|
|
91
95
|
break;
|
|
92
96
|
} catch {
|
|
93
97
|
if (Date.now() - start > timeout) {
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
// SECURITY (HIGH #2 fix): Throw instead of continuing without lock.
|
|
99
|
+
// Previously this logged and broke out of the loop, executing the
|
|
100
|
+
// operation without lock protection. Now we throw so callers can retry.
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Event log lock timeout for ${eventsPath}: could not acquire lock within ${timeout}ms`,
|
|
103
|
+
);
|
|
98
104
|
}
|
|
99
105
|
// Stale detection: if the owning process is dead, remove the stale lock.
|
|
100
106
|
try {
|
|
@@ -217,6 +223,11 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
|
|
|
217
223
|
// --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
|
|
218
224
|
const asyncQueues = new Map<string, Promise<unknown>>();
|
|
219
225
|
|
|
226
|
+
/** Reset event log mode (for testing only). */
|
|
227
|
+
export function resetEventLogMode(): void {
|
|
228
|
+
asyncQueues.clear();
|
|
229
|
+
}
|
|
230
|
+
|
|
220
231
|
/**
|
|
221
232
|
* Append an event to the event log using non-blocking async I/O.
|
|
222
233
|
*
|
|
@@ -80,7 +80,8 @@ crewHooks.register("run_completed", async (event) => {
|
|
|
80
80
|
/**
|
|
81
81
|
* Get instinct-based recommendations.
|
|
82
82
|
*/
|
|
83
|
-
|
|
83
|
+
/** @internal */
|
|
84
|
+
async function getInstinctRecommendations() {
|
|
84
85
|
try {
|
|
85
86
|
const store = await getStore();
|
|
86
87
|
return store.getInstincts().filter((i: { confidence: number }) => i.confidence >= 0.6);
|
package/src/state/locks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import type { TeamRunManifest } from "./types.ts";
|
|
5
5
|
import { DEFAULT_LOCKS } from "../config/defaults.ts";
|
|
6
6
|
import { sleepSync } from "../utils/sleep.ts";
|
|
@@ -103,9 +103,16 @@ function readLockToken(filePath: string): string | undefined {
|
|
|
103
103
|
*
|
|
104
104
|
* With token matching, A's release is a no-op for B's lock.
|
|
105
105
|
*/
|
|
106
|
+
function timingSafeTokenMatch(a: string, b: string): boolean {
|
|
107
|
+
const bufA = Buffer.from(String(a));
|
|
108
|
+
const bufB = Buffer.from(String(b));
|
|
109
|
+
if (bufA.length !== bufB.length) return false;
|
|
110
|
+
return timingSafeEqual(bufA, bufB);
|
|
111
|
+
}
|
|
112
|
+
|
|
106
113
|
function releaseLock(filePath: string, token: string): void {
|
|
107
114
|
const stored = readLockToken(filePath);
|
|
108
|
-
if (stored === undefined || stored
|
|
115
|
+
if (stored === undefined || timingSafeTokenMatch(stored, token)) {
|
|
109
116
|
try {
|
|
110
117
|
fs.rmSync(filePath, { force: true });
|
|
111
118
|
} catch {
|
package/src/state/state-store.ts
CHANGED
|
@@ -208,7 +208,8 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
|
|
|
208
208
|
* intended use case. Single-update + read-update loops (e.g.
|
|
209
209
|
* persistSingleTaskUpdate) should keep using saveRunTasks.
|
|
210
210
|
*/
|
|
211
|
-
|
|
211
|
+
/** @internal */
|
|
212
|
+
function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
|
|
212
213
|
atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
|
|
213
214
|
invalidateRunCache(manifest.stateRoot);
|
|
214
215
|
}
|
|
@@ -226,7 +227,8 @@ export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTa
|
|
|
226
227
|
* This is acceptable because crash recovery detects and repairs
|
|
227
228
|
* inconsistent state on next session start.
|
|
228
229
|
*/
|
|
229
|
-
|
|
230
|
+
/** @internal */
|
|
231
|
+
async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
|
|
230
232
|
await withRunLock(manifest, async () => {
|
|
231
233
|
await Promise.all([
|
|
232
234
|
atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest),
|
package/src/state/task-claims.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { TeamTaskState } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
export interface TaskClaimState {
|
|
@@ -18,8 +18,15 @@ export function isTaskClaimExpired(claim: TaskClaimState | undefined, now = new
|
|
|
18
18
|
return Number.isFinite(parsed) ? parsed <= now.getTime() : true;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function timingSafeTokenMatch(a: string, b: string): boolean {
|
|
22
|
+
const bufA = Buffer.from(String(a));
|
|
23
|
+
const bufB = Buffer.from(String(b));
|
|
24
|
+
if (bufA.length !== bufB.length) return false;
|
|
25
|
+
return timingSafeEqual(bufA, bufB);
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
export function canUseTaskClaim(task: Pick<TeamTaskState, "claim">, owner: string, token: string, now = new Date()): boolean {
|
|
22
|
-
return task.claim?.owner === owner && task.claim.token
|
|
29
|
+
return task.claim?.owner === owner && timingSafeTokenMatch(task.claim.token, token) && !isTaskClaimExpired(task.claim, now);
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export function claimTask<T extends TeamTaskState>(task: T, owner: string, leaseMs?: number, now = new Date()): T {
|
package/src/tools/safe-bash.ts
CHANGED
|
@@ -39,7 +39,8 @@ const DANGEROUS_PATTERNS = [
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Linear-time check if command contains a dangerous rm pattern like "rm -rf /" or "rm -rf ~"
|
|
42
|
-
* Replaces O(n²) regex backtracking with O(n) string scanning
|
|
42
|
+
* Replaces O(n²) regex backtracking with O(n) string scanning.
|
|
43
|
+
* Expanded to also block: rm -rf /etc/*, rm --recursive --force /, rm -rf ~/.ssh, etc.
|
|
43
44
|
*/
|
|
44
45
|
function matchesDangerousRm(command: string): boolean {
|
|
45
46
|
let pos = 0;
|
|
@@ -56,32 +57,75 @@ function matchesDangerousRm(command: string): boolean {
|
|
|
56
57
|
// Must be followed by whitespace
|
|
57
58
|
const afterRm = rmIdx + 2;
|
|
58
59
|
if (afterRm >= len || /\s/.test(command[afterRm])) {
|
|
59
|
-
// Found "rm " - now check for
|
|
60
|
+
// Found "rm " - now check for recursive/force flags
|
|
60
61
|
let p = afterRm + 1;
|
|
62
|
+
let hasR = false;
|
|
63
|
+
let hasF = false;
|
|
61
64
|
while (p < len) {
|
|
62
65
|
// Skip whitespace
|
|
63
66
|
while (p < len && /\s/.test(command[p])) p++;
|
|
64
67
|
if (p >= len) break;
|
|
65
|
-
// Check for
|
|
66
|
-
if (command[p] !== "-")
|
|
67
|
-
p++;
|
|
68
|
-
let hasR = false, hasF = false;
|
|
69
|
-
while (p < len && /[a-zA-Z]/.test(command[p])) {
|
|
70
|
-
if (command[p] === "r" || command[p] === "R") hasR = true;
|
|
71
|
-
if (command[p] === "f" || command[p] === "F") hasF = true;
|
|
68
|
+
// Check for short flags (-r, -f, -rf, -R, -F, etc.)
|
|
69
|
+
if (command[p] === "-" && p + 1 < len && /[a-zA-Z]/.test(command[p + 1]) && command[p + 1] !== "-") {
|
|
72
70
|
p++;
|
|
71
|
+
while (p < len && /[a-zA-Z]/.test(command[p])) {
|
|
72
|
+
if (command[p] === "r" || command[p] === "R") hasR = true;
|
|
73
|
+
if (command[p] === "f" || command[p] === "F") hasF = true;
|
|
74
|
+
p++;
|
|
75
|
+
}
|
|
76
|
+
// Skip whitespace after flag
|
|
77
|
+
while (p < len && /\s/.test(command[p])) p++;
|
|
78
|
+
continue;
|
|
73
79
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
// Check for long flags (--recursive, --force)
|
|
81
|
+
if (command[p] === "-" && p + 1 < len && command[p + 1] === "-") {
|
|
82
|
+
p += 2;
|
|
83
|
+
const flagStart = p;
|
|
84
|
+
while (p < len && /[a-zA-Z]/.test(command[p])) p++;
|
|
85
|
+
const flagName = command.slice(flagStart, p);
|
|
86
|
+
if (flagName === "recursive") hasR = true;
|
|
87
|
+
if (flagName === "force") hasF = true;
|
|
88
|
+
// Skip whitespace after flag
|
|
89
|
+
while (p < len && /\s/.test(command[p])) p++;
|
|
90
|
+
continue;
|
|
83
91
|
}
|
|
92
|
+
// Not a flag — stop parsing flags
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
// Must have both -r and -f (or equivalents) to be dangerous
|
|
96
|
+
if (!hasR || !hasF) {
|
|
97
|
+
pos = rmIdx + 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Now check if followed by dangerous targets
|
|
101
|
+
if (p >= len) {
|
|
102
|
+
pos = rmIdx + 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Block: ~ (home directory references)
|
|
106
|
+
const charAtP = command[p];
|
|
107
|
+
if (charAtP === "~") return true; // Home directory reference
|
|
108
|
+
// Block: / (root or dangerous system paths)
|
|
109
|
+
if (charAtP === "/") {
|
|
110
|
+
// Exact root '/' with nothing after
|
|
111
|
+
if (p + 1 >= len || /\s/.test(command[p + 1]) || command[p + 1] === ";") return true;
|
|
112
|
+
// Block dangerous system paths
|
|
113
|
+
const rest = command.slice(p);
|
|
114
|
+
if (/^\/etc[\/\s;]/.test(rest) || rest === "/etc") return true;
|
|
115
|
+
if ((/^\/var\/(?!tmp)/.test(rest)) || rest === "/var") return true;
|
|
116
|
+
if (/^\/usr[\/\s;]/.test(rest) || rest === "/usr") return true;
|
|
117
|
+
if (/^\/boot[\/\s;]/.test(rest) || rest === "/boot") return true;
|
|
118
|
+
if (/^\/sys[\/\s;]/.test(rest) || rest === "/sys") return true;
|
|
119
|
+
if (/^\/proc[\/\s;]/.test(rest) || rest === "/proc") return true;
|
|
120
|
+
if (/^\/dev[\/\s;]/.test(rest) || rest === "/dev") return true;
|
|
121
|
+
if (/^\/root[\/\s;]/.test(rest) || rest === "/root") return true;
|
|
122
|
+
if (/^\/home[\/\s;]/.test(rest) || rest === "/home") return true;
|
|
123
|
+
// /tmp/ and other non-system absolute paths are allowed
|
|
84
124
|
}
|
|
125
|
+
// Check for sensitive relative paths: .ssh, .gnupg
|
|
126
|
+
const rest = command.slice(p);
|
|
127
|
+
if (/^\.ssh[\/\\\s;]/.test(rest)) return true;
|
|
128
|
+
if (/^\.gnupg[\/\\\s;]/.test(rest)) return true;
|
|
85
129
|
}
|
|
86
130
|
pos = rmIdx + 1;
|
|
87
131
|
}
|
|
@@ -172,8 +216,13 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
|
|
|
172
216
|
}
|
|
173
217
|
}
|
|
174
218
|
|
|
175
|
-
// Normalize: remove line continuations, collapse whitespace
|
|
176
|
-
const normalized = command
|
|
219
|
+
// Normalize: strip ANSI escapes and control chars, remove line continuations, collapse whitespace
|
|
220
|
+
const normalized = command
|
|
221
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // strip ANSI escapes
|
|
222
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // strip control chars
|
|
223
|
+
.replace(/\\\n/g, " ")
|
|
224
|
+
.replace(/\s+/g, " ")
|
|
225
|
+
.trim();
|
|
177
226
|
|
|
178
227
|
// Check allow patterns first (overrides)
|
|
179
228
|
for (const pattern of allowPatterns) {
|
|
@@ -11,24 +11,29 @@ export type {
|
|
|
11
11
|
// Using AgentEndEvent and AgentStartEvent instead
|
|
12
12
|
|
|
13
13
|
// Type guards for pi-crew usage
|
|
14
|
-
|
|
14
|
+
/** @internal */
|
|
15
|
+
function isToolEvent(event: AgentSessionEvent): boolean {
|
|
15
16
|
return event.type === "tool_execution_start" ||
|
|
16
17
|
event.type === "tool_execution_update" ||
|
|
17
18
|
event.type === "tool_execution_end";
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
/** @internal */
|
|
22
|
+
function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
|
|
21
23
|
return event.type === "agent_start" || event.type === "agent_end";
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
/** @internal */
|
|
27
|
+
function isCompactionEvent(event: AgentSessionEvent): boolean {
|
|
25
28
|
return event.type === "compaction_start" || event.type === "compaction_end";
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
/** @internal */
|
|
32
|
+
function isRetryEvent(event: AgentSessionEvent): boolean {
|
|
29
33
|
return event.type === "auto_retry_start" || event.type === "auto_retry_end";
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
/** @internal */
|
|
37
|
+
function isQueueEvent(event: AgentSessionEvent): boolean {
|
|
33
38
|
return event.type === "queue_update";
|
|
34
39
|
}
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -21,7 +21,8 @@ export const DASHBOARD_KEYS = {
|
|
|
21
21
|
notification: { dismissAll: ["H"] },
|
|
22
22
|
} as const;
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** @internal */
|
|
25
|
+
const KEY_RESERVED = new Set<string>([
|
|
25
26
|
...DASHBOARD_KEYS.close,
|
|
26
27
|
...DASHBOARD_KEYS.select,
|
|
27
28
|
...Object.values(DASHBOARD_KEYS.root).flat(),
|
|
@@ -111,7 +111,8 @@ export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: str
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
/** @internal */
|
|
115
|
+
function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
|
|
115
116
|
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
116
117
|
if (!loaded) return undefined;
|
|
117
118
|
return readCrewAgents(loaded.manifest).find((agent) => agent.status === "running" || agent.status === "queued")?.taskId;
|
package/src/ui/status-colors.ts
CHANGED
|
@@ -47,7 +47,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/** @internal */
|
|
51
|
+
function colorForActivity(activityState: string | undefined): CrewThemeColor {
|
|
51
52
|
if (activityState === "needs_attention") return "warning";
|
|
52
53
|
if (activityState === "stale") return "error";
|
|
53
54
|
return "dim";
|
|
@@ -22,7 +22,8 @@ function buildCliTheme(theme: CrewTheme): Record<string, (text: string) => strin
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/** @internal */
|
|
26
|
+
function detectLanguageFromPath(filePath: string): string | undefined {
|
|
26
27
|
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
27
28
|
if (!ext) return undefined;
|
|
28
29
|
return languageMap[ext];
|
package/src/ui/tool-render.ts
CHANGED
|
@@ -29,6 +29,15 @@ export interface AgentToolResultDetails {
|
|
|
29
29
|
results?: Array<{ agentId?: string; status?: string; output?: string; error?: string }>;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Combined type for renderAgentToolResult — handles both nested details and flat result shapes */
|
|
33
|
+
interface AgentResultData extends AgentToolResultDetails {
|
|
34
|
+
agentId?: string;
|
|
35
|
+
status?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
output?: string;
|
|
38
|
+
runId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
33
42
|
|
|
34
43
|
export function formatTokens(n: number): string {
|
|
@@ -45,7 +54,8 @@ export function formatDuration(ms: number): string {
|
|
|
45
54
|
return s > 0 ? `${m}m${s}s` : `${m}m`;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
/** @internal */
|
|
58
|
+
function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
|
|
49
59
|
if (!contextWindow) return `${formatTokens(tokens)} ctx`;
|
|
50
60
|
const pct = (tokens / contextWindow) * 100;
|
|
51
61
|
const maxStr = contextWindow >= 1_000_000 ? `${(contextWindow / 1_000_000).toFixed(1)}M` : `${Math.round(contextWindow / 1000)}k`;
|
|
@@ -294,7 +304,7 @@ export function renderAgentToolResult(
|
|
|
294
304
|
_options: unknown, theme: Theme, _context: unknown,
|
|
295
305
|
): Component {
|
|
296
306
|
// Handle both nested details and flattened result shape
|
|
297
|
-
const d = (result
|
|
307
|
+
const d = (result.details ?? result) as AgentResultData;
|
|
298
308
|
const c = new Container();
|
|
299
309
|
const w = 116;
|
|
300
310
|
|
|
@@ -351,7 +361,7 @@ export function renderAgentToolResult(
|
|
|
351
361
|
function extractText(content: unknown[] | undefined): string {
|
|
352
362
|
if (!content) return "(no output)";
|
|
353
363
|
if (!Array.isArray(content)) return String(content);
|
|
354
|
-
return content.filter((c:
|
|
364
|
+
return content.filter((c): c is Record<string, unknown> => typeof c === "object" && c !== null && (c as Record<string, unknown>).type === "text").map((c) => String((c as Record<string, unknown>).text ?? "")).join("\n") || "(no output)";
|
|
355
365
|
}
|
|
356
366
|
|
|
357
367
|
function parseArgs(argsStr: string | undefined): Record<string, unknown> {
|
package/src/utils/fs-watch.ts
CHANGED
|
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { FSWatcher, WatchListener } from "node:fs";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** @internal */
|
|
6
|
+
const FS_WATCH_RETRY_DELAY_MS = 5000;
|
|
6
7
|
|
|
7
8
|
export function closeWatcher(watcher: FSWatcher | null | undefined): void {
|
|
8
9
|
if (!watcher) {
|
|
@@ -85,4 +86,5 @@ export function watchCrewState(
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
// Re-export path helper so callers don't pull node:path just for join.
|
|
88
|
-
|
|
89
|
+
/** @internal */
|
|
90
|
+
const joinPath = path.join;
|
package/src/utils/gh-protocol.ts
CHANGED
|
@@ -473,7 +473,8 @@ export function resolveGitHubUrl(parsed: Parsed, scheme: "issue" | "pr", cwd: st
|
|
|
473
473
|
* Resolve a raw `issue://` or `pr://` URL string.
|
|
474
474
|
* Convenience wrapper combining parse + resolve.
|
|
475
475
|
*/
|
|
476
|
-
|
|
476
|
+
/** @internal */
|
|
477
|
+
function resolveGitHubProtocol(raw: string, scheme: "issue" | "pr", cwd: string): GhResult<unknown> {
|
|
477
478
|
const parsed = parseGitHubUrl(raw, scheme);
|
|
478
479
|
return resolveGitHubUrl(parsed, scheme, cwd);
|
|
479
480
|
}
|
package/src/utils/safe-paths.ts
CHANGED
|
@@ -11,6 +11,9 @@ export function assertSafePathId(kind: string, value: string): string {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function resolveContainedPath(baseDir: string, targetPath: string): string {
|
|
14
|
+
if (targetPath.includes('\0')) {
|
|
15
|
+
throw new Error(`Security: path contains null byte`);
|
|
16
|
+
}
|
|
14
17
|
const base = path.resolve(baseDir);
|
|
15
18
|
const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
|
|
16
19
|
const relative = path.relative(base, resolved);
|
|
@@ -41,6 +44,9 @@ export function resolveRealContainedPath(baseDir: string, targetPath: string): s
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export function resolveContainedRelativePath(baseDir: string, relativePath: string, kind = "path"): string {
|
|
47
|
+
if (relativePath.includes('\0')) {
|
|
48
|
+
throw new Error(`Security: path contains null byte: ${kind}`);
|
|
49
|
+
}
|
|
44
50
|
const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
|
|
45
51
|
if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
|
|
46
52
|
return resolveContainedPath(baseDir, path.resolve(baseDir, normalized));
|