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.
Files changed (65) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +13 -11
  3. package/package.json +1 -1
  4. package/src/agents/agent-config.ts +2 -1
  5. package/src/benchmark/feedback-loop.ts +4 -2
  6. package/src/extension/cross-extension-rpc.ts +48 -0
  7. package/src/extension/registration/commands.ts +2 -1
  8. package/src/extension/registration/subagent-tools.ts +2 -0
  9. package/src/extension/registration/team-tool.ts +2 -0
  10. package/src/extension/registration/viewers.ts +1 -0
  11. package/src/extension/run-export.ts +16 -1
  12. package/src/extension/run-import.ts +16 -0
  13. package/src/extension/team-tool/anchor.ts +5 -1
  14. package/src/extension/team-tool/api.ts +9 -4
  15. package/src/extension/team-tool/config-patch.ts +15 -1
  16. package/src/extension/team-tool.ts +2 -1
  17. package/src/hooks/registry.ts +9 -1
  18. package/src/hooks/types.ts +3 -3
  19. package/src/i18n.ts +15 -2
  20. package/src/observability/exporters/otlp-exporter.ts +73 -0
  21. package/src/runtime/adaptive-plan.ts +24 -0
  22. package/src/runtime/agent-control.ts +6 -3
  23. package/src/runtime/async-runner.ts +58 -3
  24. package/src/runtime/background-runner.ts +1 -1
  25. package/src/runtime/chain-runner.ts +58 -0
  26. package/src/runtime/child-pi.ts +1 -1
  27. package/src/runtime/crew-agent-records.ts +4 -3
  28. package/src/runtime/cross-extension-rpc.ts +34 -8
  29. package/src/runtime/diagnostic-export.ts +3 -4
  30. package/src/runtime/dynamic-script-runner.ts +7 -7
  31. package/src/runtime/foreground-watchdog.ts +2 -2
  32. package/src/runtime/live-agent-manager.ts +6 -3
  33. package/src/runtime/live-irc.ts +4 -2
  34. package/src/runtime/parallel-utils.ts +2 -1
  35. package/src/runtime/post-checks.ts +10 -3
  36. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  37. package/src/runtime/sandbox.ts +26 -20
  38. package/src/runtime/semaphore.ts +2 -1
  39. package/src/runtime/settings-store.ts +14 -2
  40. package/src/runtime/skill-effectiveness.ts +4 -2
  41. package/src/runtime/skill-instructions.ts +4 -1
  42. package/src/runtime/subagent-manager.ts +20 -2
  43. package/src/runtime/subprocess-tool-registry.ts +2 -2
  44. package/src/runtime/task-packet.ts +13 -1
  45. package/src/runtime/task-runner.ts +9 -0
  46. package/src/runtime/usage-tracker.ts +4 -2
  47. package/src/runtime/verification-gates.ts +36 -9
  48. package/src/state/contracts.ts +2 -1
  49. package/src/state/event-log.ts +16 -5
  50. package/src/state/hook-instinct-bridge.ts +2 -1
  51. package/src/state/locks.ts +9 -2
  52. package/src/state/state-store.ts +4 -2
  53. package/src/state/task-claims.ts +9 -2
  54. package/src/tools/safe-bash.ts +69 -20
  55. package/src/types/new-api-types.ts +10 -5
  56. package/src/ui/keybinding-map.ts +2 -1
  57. package/src/ui/run-action-dispatcher.ts +2 -1
  58. package/src/ui/status-colors.ts +2 -1
  59. package/src/ui/syntax-highlight.ts +2 -1
  60. package/src/ui/tool-render.ts +13 -3
  61. package/src/utils/fs-watch.ts +4 -2
  62. package/src/utils/gh-protocol.ts +2 -1
  63. package/src/utils/safe-paths.ts +6 -0
  64. package/src/worktree/cleanup.ts +8 -5
  65. 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
- /** H3: Reset the global singleton registry (for test isolation). */
65
- export function resetSubprocessToolRegistry(): void {
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}", input.manifest.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
- export function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
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
- export function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
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 || true", critical: true },
42
- { name: "typecheck", command: "npx tsc --noEmit 2>&1 || true", critical: true },
43
- { name: "lint", command: "npm run lint 2>&1 || true", critical: false },
44
- { name: "tests", command: "npm test 2>&1 || true", critical: true },
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 || true", critical: true },
52
- { name: "test", command: "cargo test 2>&1 || true", critical: true },
53
- { name: "clippy", command: "cargo clippy 2>&1 || true", critical: false },
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
- export function createVerificationGateReport(
342
+ /** @internal */
343
+ function createVerificationGateReport(
317
344
  taskId: string,
318
345
  contract: VerificationContract,
319
346
  results: VerificationCommandResult[],
@@ -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
- export const TEAM_EVENT_TYPES = [
31
+ /** @internal */
32
+ const TEAM_EVENT_TYPES = [
32
33
  "run.created",
33
34
  "run.queued",
34
35
  "run.planning",
@@ -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
- const timeout = 120000; // 120s timeout for slow CI environments
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
- // Log error and continue without lock lock is held by live process.
95
- // Stale detection will clean up dead locks on next attempt.
96
- logInternalError("event-log.lock-timeout", new Error(`Event log lock timeout for ${eventsPath}`), `lockDir=${lockDir}`);
97
- break;
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
- export async function getInstinctRecommendations() {
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);
@@ -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 === token) {
115
+ if (stored === undefined || timingSafeTokenMatch(stored, token)) {
109
116
  try {
110
117
  fs.rmSync(filePath, { force: true });
111
118
  } catch {
@@ -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
- export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
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
- export async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
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),
@@ -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 === token && !isTaskClaimExpired(task.claim, now);
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 {
@@ -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 -rf flags followed by / or ~
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 flag
66
- if (command[p] !== "-") break;
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
- if (!hasR && !hasF) break; // Flag must have r or f
75
- // Skip whitespace after flag
76
- while (p < len && /\s/.test(command[p])) p++;
77
- }
78
- // Now check if followed by / or ~ (end or whitespace)
79
- if (p < len && (command[p] === "/" || command[p] === "~")) {
80
- const afterSlash = p + 1;
81
- if (afterSlash >= len || /\s/.test(command[afterSlash]) || command[afterSlash] === ";") {
82
- return true; // Dangerous!
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.replace(/\\\n/g, " ").replace(/\s+/g, " ").trim();
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
- export function isToolEvent(event: AgentSessionEvent): boolean {
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
- export function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
21
+ /** @internal */
22
+ function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
21
23
  return event.type === "agent_start" || event.type === "agent_end";
22
24
  }
23
25
 
24
- export function isCompactionEvent(event: AgentSessionEvent): boolean {
26
+ /** @internal */
27
+ function isCompactionEvent(event: AgentSessionEvent): boolean {
25
28
  return event.type === "compaction_start" || event.type === "compaction_end";
26
29
  }
27
30
 
28
- export function isRetryEvent(event: AgentSessionEvent): boolean {
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
- export function isQueueEvent(event: AgentSessionEvent): boolean {
36
+ /** @internal */
37
+ function isQueueEvent(event: AgentSessionEvent): boolean {
33
38
  return event.type === "queue_update";
34
39
  }
@@ -21,7 +21,8 @@ export const DASHBOARD_KEYS = {
21
21
  notification: { dismissAll: ["H"] },
22
22
  } as const;
23
23
 
24
- export const KEY_RESERVED = new Set<string>([
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
- export function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
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;
@@ -47,7 +47,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
47
47
  }
48
48
  }
49
49
 
50
- export function colorForActivity(activityState: string | undefined): CrewThemeColor {
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
- export function detectLanguageFromPath(filePath: string): string | undefined {
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];
@@ -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
- export function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
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 as any).details ?? 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: any) => c?.type === "text").map((c: any) => c?.text ?? "").join("\n") || "(no output)";
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> {
@@ -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
- export const FS_WATCH_RETRY_DELAY_MS = 5000;
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
- export const joinPath = path.join;
89
+ /** @internal */
90
+ const joinPath = path.join;
@@ -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
- export function resolveGitHubProtocol(raw: string, scheme: "issue" | "pr", cwd: string): GhResult<unknown> {
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
  }
@@ -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));