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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1] — Post-v0.6.0 Security Hardening + Test Coverage (2026-06-04)
4
+
5
+ ### Highlights
6
+ - **42+ security issues fixed** — 7 CRITICAL, 10 HIGH, 11 MEDIUM, 14 post-restart review findings
7
+ - **~1,900 new tests** across 113+ test files — total suite now ~4,600 tests
8
+ - **38 dead exports cleaned** across 19 modules
9
+ - **12 `any` types replaced** with proper TypeScript types
10
+ - **Full battle-testing** — 2 Pi restart cycles, all team types, management operations verified
11
+
12
+ ### Security Fixes (CRITICAL)
13
+ - `async-runner.ts`: Environment variable leak in child process — sanitized with `sanitizeEnvSecrets()`
14
+ - `verification-gates.ts`: Shell injection via user-controlled strings — switched to `execFileSync`
15
+ - `sandbox.ts`: `String.fromCharCode` bypass — added `constructor` to `FORBIDDEN_PATTERNS`
16
+ - `locks.ts`: Timing-unsafe comparison on lock tokens — replaced with constant-time compare
17
+ - `event-log.ts`: Request IDs logged in plaintext — now hashed before logging
18
+ - `team-runner.ts`: Missing heartbeat for long-running tasks — added 30s heartbeat writer
19
+ - `worktree-manager.ts`: Environment secrets leaked to git subprocesses — `sanitizeEnvSecrets()`
20
+
21
+ ### Security Fixes (HIGH)
22
+ - `preStepScript` symlink traversal — `fs.realpathSync` before path containment check
23
+ - `childEnvAllowList` wildcard patterns (`LC_*`, `XDG_*`) could leak secrets
24
+ - Event log sync/async race condition — route sync `appendEvent` through async queue
25
+ - Subagent record validation — `sanitizePersistedRecord()` with allow-listed fields
26
+ - Verification gate redirect — allow single `>` for `2>&1`, block `>>` and `<[^&]`
27
+ - `allowPatterns` validation — reject patterns matching empty strings
28
+
29
+ ### Security Fixes (MEDIUM)
30
+ - `logInternalError` import paths normalized across all modules
31
+ - `Object.freeze()` narrowing fix — use `Readonly<{...}>` explicit types
32
+ - NTFS mtime granularity — write-first, `utimes`-after for cache invalidation
33
+ - Windows path separators — platform-agnostic assertions in tests
34
+ - `executeUnchecked` visibility — `__test_executeUnchecked` export pattern
35
+ - `seedPaths` containment — `normalizeSeedPaths()` validates paths stay within `repoRoot`
36
+
37
+ ### Code Quality
38
+ - 38 dead/unused exports removed across 19 source modules
39
+ - 12 `any` types replaced with proper interfaces
40
+ - `enforceLabelCap` MRU correctness — `delete`-then-`set` to maintain Map insertion order
41
+ - `readIfSmall` bounded reads — `Buffer.alloc` + `fs.readSync` instead of `readFileSync`
42
+
43
+ ### Test Coverage
44
+ - 113 new test files, ~1,900 new test cases
45
+ - Modules now covered: config, extension, workflow, subagent, observability, runtime, graph,
46
+ heartbeat, permissions, state, locks, event-log, safe-bash, sandbox, verification-gates,
47
+ async-runner, team-runner, background-runner, worktree, fingerprint, BM25 search, and more
48
+ - Windows CI verified: path separators, `npx.cmd` resolution, NTFS mtime all pass
49
+ - Test runner wrapper (`scripts/test-runner.mjs`) ensures non-zero exit on failures
50
+
51
+ ### Stats
52
+ - Test suite: ~4,600 pass, 0 fail
53
+ - TypeScript: 0 errors
54
+ - Lines added since v0.6.0: 22,520 (742 src + 21,777 test)
55
+ - Files changed: 204
56
+ - Security issues fixed: 42+
57
+ - Audit rounds: 42 (including post-v0.6.0 battle-testing)
58
+
59
+ ## [0.6.0] — Source Tour Patterns + 15 New Modules (2026-06-03)
60
+
61
+ ### Highlights
62
+ - **15 upstream patterns implemented** from 63-repository source tour
63
+ - **10 new source modules** (2,267 LOC): chain-parser, run-drift, intercom-bridge,
64
+ plan-templates, task-id, context-retrieval, intermediate-store, fingerprint,
65
+ memory-store, observation-store
66
+ - **37 skills reviewed** with origin fields, all passing validation
67
+
3
68
  ## [0.5.22] — Remaining Issues from Ultimate Sweep (2026-06-03)
4
69
 
5
70
  ### Highlights
package/README.md CHANGED
@@ -9,22 +9,24 @@ npm: pi-crew
9
9
  repo: https://github.com/baphuongna/pi-crew
10
10
  ```
11
11
 
12
- **v0.5.22**: See [CHANGELOG.md](CHANGELOG.md).
12
+ **v0.6.1**: See [CHANGELOG.md](CHANGELOG.md).
13
13
 
14
- ### Security highlights (v0.5.22)
14
+ ### Security highlights (v0.6.1)
15
15
 
16
- - **ReDoS-free secret redaction** — linear-time scanning in `redaction.ts`; no catastrophic backtracking
17
- - **v8.deserialize hardened** — `BINARY_MAGIC` header guards on registry binaries prevent untrusted-file RCE
18
- - **Cache lock protection** — `withFileLockSync` and atomic writes across `run-cache.ts` and `state-store.ts`
19
- - **Shell injection prevented** — `execFileSync` with array args everywhere (no shell-interpreted strings)
16
+ - **42+ security issues fixed** — 7 CRITICAL, 10 HIGH, 11 MEDIUM, 14 post-restart findings
17
+ - **Timing-safe token comparison** — constant-time compare for lock tokens and request IDs
18
+ - **Environment leak prevention** — `sanitizeEnvSecrets()` on all child process spawns
19
+ - **Shell injection hardened** — `execFileSync` with array args; blocked `String.fromCharCode` bypass
20
+ - **ReDoS-free secret redaction** — linear-time scanning in `redaction.ts`
21
+ - **Sandbox prototype isolation** — `Object.freeze` scoped to VM context; `constructor` pattern blocked
22
+ - **Symlink traversal prevention** — `fs.realpathSync` before path containment checks
20
23
  - **Safe-bash line-continuation hardening** — `$\n(evil)` command substitution bypass blocked
21
- - **Sandbox prototype isolation** — `Object.freeze` scoped to VM context (not host process)
22
24
  - **Path traversal mitigated** — `resolveContainedPath`/`resolveRealContainedPath` across all file ops
23
- - **TOCTOU-free file ops** — atomic `mkdirSync` in `crew-init.ts`; `realpath`-based path validation
24
25
  - **Memory leaks capped** — Maps, Sets, arrays bounded with eviction across all modules
25
- - **Inline secret detection** — `token=`, `api_key=`, `password=` patterns redacted at event/mailbox boundaries
26
- - **CI exit code enforced** — `test-runner.mjs` wrapper ensures non-zero exit on failures
27
- - **38 audit rounds, 160+ issues fixed**3 CRITICAL + 6 HIGH + 3 MEDIUM security issues resolved
26
+ - **Event log race conditions fixed** — sync/async queue unification
27
+ - **Subagent record sanitization** — allow-listed field persistence
28
+ - **~1,900 new tests**, 113 test filestotal suite ~4,600 tests, 0 failures
29
+ - **42+ audit rounds, 160+ issues fixed** across all severity levels
28
30
 
29
31
  See [SECURITY-ISSUES.md](SECURITY-ISSUES.md) for the full list (SEC-001 – SEC-007 all marked fixed).
30
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -69,7 +69,8 @@ export function getAgentSessionOptions(role: string): {
69
69
  * @param agent - The agent configuration
70
70
  * @param role - The role name to use for tool restrictions (defaults to agent.name)
71
71
  */
72
- export function buildAgentSessionOptions(
72
+ /** @internal */
73
+ function buildAgentSessionOptions(
73
74
  agent: AgentConfig,
74
75
  role?: string,
75
76
  ): {
@@ -4,13 +4,15 @@
4
4
 
5
5
  import type { RunMetrics } from "../state/run-metrics.ts";
6
6
 
7
- export interface FeedbackLoopStats {
7
+ /** @internal */
8
+ interface FeedbackLoopStats {
8
9
  runsObserved: number;
9
10
  avgSuccessRate: number;
10
11
  recommendations: string[];
11
12
  }
12
13
 
13
- export class FeedbackLoop {
14
+ /** @internal */
15
+ class FeedbackLoop {
14
16
  private runs: RunMetrics[] = [];
15
17
  private static readonly MAX_RUNS = 1000;
16
18
 
@@ -32,6 +32,8 @@ function requestId(raw: unknown): string | undefined {
32
32
 
33
33
  function reply(events: EventBusLike, channel: string, id: string | undefined, payload: RpcReply): void {
34
34
  if (!id) return;
35
+ // SECURITY: Validate requestId format to prevent channel injection.
36
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return;
35
37
  events.emit(`${channel}:reply:${id}`, payload);
36
38
  }
37
39
 
@@ -59,6 +61,36 @@ function isAllowedRpcOperation(operation: string): boolean {
59
61
  return RPC_ALLOWED_OPERATIONS.has(operation);
60
62
  }
61
63
 
64
+ // SECURITY (HIGH #4 fix): In-memory rate limiter for RPC run requests.
65
+ // Prevents any extension from spawning unlimited child processes.
66
+ const RPC_RATE_LIMIT_MAX = 5; // Max 5 RPC run requests...
67
+ const RPC_RATE_LIMIT_WINDOW_MS = 60_000; // ...per 60 seconds
68
+ const rpcRunTimestamps: number[] = [];
69
+
70
+ function checkRpcRateLimit(): { allowed: boolean; retryAfterMs?: number } {
71
+ const now = Date.now();
72
+ // Evict entries older than the window
73
+ const cutoff = now - RPC_RATE_LIMIT_WINDOW_MS;
74
+ while (rpcRunTimestamps.length > 0 && rpcRunTimestamps[0] < cutoff) {
75
+ rpcRunTimestamps.shift();
76
+ }
77
+ if (rpcRunTimestamps.length >= RPC_RATE_LIMIT_MAX) {
78
+ const oldestInWindow = rpcRunTimestamps[0];
79
+ const retryAfterMs = oldestInWindow + RPC_RATE_LIMIT_WINDOW_MS - now;
80
+ return { allowed: false, retryAfterMs: Math.max(retryAfterMs, 1000) };
81
+ }
82
+ return { allowed: true };
83
+ }
84
+
85
+ function recordRpcRun(): void {
86
+ rpcRunTimestamps.push(Date.now());
87
+ }
88
+
89
+ /** Reset the RPC rate limiter. Used primarily for testing. */
90
+ export function resetRpcRateLimit(): void {
91
+ rpcRunTimestamps.length = 0;
92
+ }
93
+
62
94
  function isAllowedRpcRunParams(params: TeamToolParamsValue): { ok: boolean; error?: string } {
63
95
  // SECURITY: Require explicit intent for any RPC-initiated run creation.
64
96
  // This prevents malicious extensions from spawning child Pi processes silently.
@@ -83,6 +115,12 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => vo
83
115
  return typeof unsub === "function" ? unsub : () => {};
84
116
  }
85
117
 
118
+ // SECURITY TRUST BOUNDARY: RPC channels (pi-crew:rpc:run, pi-crew:rpc:status,
119
+ // pi-crew:rpc:live-control) are accessible to any extension on the shared event
120
+ // bus. Mitigations applied: rate limiting (RPC_RATE_LIMIT_MAX), explicit intent
121
+ // requirement for runs, operation allowlist for live-control reads, and cwd
122
+ // containment validation. A full fix requires event-bus-level origin signing.
123
+
86
124
  export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () => ExtensionContext | undefined): PiCrewRpcHandle | undefined {
87
125
  if (!events) return undefined;
88
126
  const unsubs = [
@@ -90,6 +128,16 @@ export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () =
90
128
  on(events, "pi-crew:rpc:run", async (raw) => {
91
129
  const id = requestId(raw);
92
130
  try {
131
+ // SECURITY (HIGH #4 fix): Rate limit RPC run requests
132
+ const rateLimit = checkRpcRateLimit();
133
+ if (!rateLimit.allowed) {
134
+ reply(events, "pi-crew:rpc:run", id, {
135
+ success: false,
136
+ error: `RPC run rate limit exceeded. Max ${RPC_RATE_LIMIT_MAX} requests per ${RPC_RATE_LIMIT_WINDOW_MS / 1000}s. Retry after ${Math.ceil((rateLimit.retryAfterMs ?? 60000) / 1000)}s.`,
137
+ });
138
+ return;
139
+ }
140
+ recordRpcRun();
93
141
  const ctx = getCtx();
94
142
  if (!ctx) throw new Error("No active pi-crew session context.");
95
143
  // Validate payload: only allow known fields from TeamToolParamsValue
@@ -428,7 +428,8 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
428
428
  if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
429
429
  if (selection.action === "agent-live" && await openLiveConversation(ctx, selection.runId)) continue;
430
430
  if (selection.action === "agent-live") { await notifyCommandResult(ctx, commandText({ content: [{ type: "text", text: "No live agent found for this run." }] })); continue; }
431
- const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(ctx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(ctx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(ctx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(ctx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(ctx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(ctx)) : await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(ctx));
431
+ const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(ctx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(ctx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(ctx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(ctx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(ctx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(ctx)) : // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
+ await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(ctx));
432
433
  await notifyCommandResult(ctx, commandText(result));
433
434
  return;
434
435
  }
@@ -96,9 +96,11 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
96
96
  }
97
97
  return foregroundResult;
98
98
  },
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
100
  renderCall(args: any, theme: any, context: any): any {
100
101
  return renderAgentToolCall(args, theme, context);
101
102
  },
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
104
  renderResult(result: any, options: any, theme: any, context: any): any {
103
105
  return renderAgentToolResult(result, options, theme, context);
104
106
  },
@@ -105,9 +105,11 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
105
105
  stopProgress.stop();
106
106
  }
107
107
  },
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
109
  renderCall(args: any, theme: any, context: any): any {
109
110
  return renderTeamToolCall(args, theme, context);
110
111
  },
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
113
  renderResult(result: any, options: any, theme: any, context: any): any {
112
114
  return renderTeamToolResult(result, options, theme, context);
113
115
  },
@@ -58,6 +58,7 @@ export async function openLiveConversation(ctx: ExtensionCommandContext, initial
58
58
  const handle = liveAgents.find((h) => h.runId === selected.runId && (selected.taskId ? h.taskId === selected.taskId : true));
59
59
  if (!handle) return false;
60
60
  const theme = asCrewTheme({});
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
62
  await ctx.ui.custom<undefined>((tui: any, _theme: any, _keybindings: any, done: (result: undefined) => void) => {
62
63
  const columns = tui?.terminal?.columns ?? 80;
63
64
  const rows = tui?.terminal?.rows ?? 24;
@@ -1,17 +1,29 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as os from "node:os";
4
+ import * as crypto from "node:crypto";
4
5
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
6
  import { writeArtifact } from "../state/artifact-store.ts";
6
7
  import { readEvents, type TeamEvent } from "../state/event-log.ts";
7
8
  import { redactSecrets } from "../utils/redaction.ts";
8
9
 
9
10
  /** Replace absolute paths containing home directory with ~/ */
11
+ /** Escape special regex characters in a string */
12
+ function escapeRegex(str: string): string {
13
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ }
15
+
16
+ /** Only redact home directory at path boundaries to avoid corrupting substrings */
17
+ function redactHomePathInString(str: string, home: string): string {
18
+ return str.replace(new RegExp(`(^|(?<=[:=/]))${escapeRegex(home)}`, "g"), "$1~");
19
+ }
20
+
21
+ /** Replace absolute paths containing home directory with ~/ at path boundaries only */
10
22
  function redactHomePaths<T>(obj: T): T {
11
23
  const home = os.homedir();
12
24
  if (!home) return redactSecrets(obj) as T;
13
25
  const json = JSON.stringify(obj);
14
- const safe = json.split(home).join("~");
26
+ const safe = redactHomePathInString(json, home);
15
27
  return redactSecrets(JSON.parse(safe)) as T;
16
28
  }
17
29
 
@@ -37,6 +49,9 @@ export function exportRunBundle(manifest: TeamRunManifest, tasks: TeamTaskState[
37
49
  events: safeEvents as TeamEvent[],
38
50
  artifactPaths: safeManifest.artifacts.map((artifact) => artifact.path),
39
51
  };
52
+ // Compute SHA-256 integrity hash of the bundle and store in manifest
53
+ const sha256 = crypto.createHash("sha256").update(JSON.stringify(bundle)).digest("hex");
54
+ (bundle.manifest as unknown as Record<string, unknown>).sha256 = sha256;
40
55
  const json = writeArtifact(manifest.artifactsRoot, {
41
56
  kind: "metadata",
42
57
  relativePath: "export/run-export.json",
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
3
4
  import { assertRunBundle } from "./run-bundle-schema.ts";
4
5
  import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
5
6
  import { DEFAULT_PATHS } from "../config/defaults.ts";
@@ -42,6 +43,21 @@ export function importRunBundle(cwd: string, bundlePath: string, scope: "project
42
43
  if (!isContained) throw new Error(`Import path must be within project directory or crew root: ${resolvedPath}`);
43
44
  const raw = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
44
45
  assertRunBundle(raw);
46
+
47
+ // Integrity check: verify SHA-256 hash if present in manifest
48
+ const bundleJson = fs.readFileSync(resolvedPath, "utf-8");
49
+ const parsedForHash = JSON.parse(bundleJson) as { manifest?: { sha256?: string } };
50
+ if (parsedForHash.manifest?.sha256) {
51
+ const expectedHash = parsedForHash.manifest.sha256;
52
+ // Recompute hash by stringifying the bundle without the sha256 field
53
+ const { sha256: _sha256, ...manifestWithoutHash } = parsedForHash.manifest as Record<string, unknown> & { sha256?: string };
54
+ const bundleForHash = { ...parsedForHash, manifest: manifestWithoutHash };
55
+ const recomputedHash = crypto.createHash("sha256").update(JSON.stringify(bundleForHash)).digest("hex");
56
+ if (recomputedHash !== expectedHash) {
57
+ throw new Error(`Integrity check failed: SHA-256 mismatch. Expected ${expectedHash}, got ${recomputedHash}`);
58
+ }
59
+ }
60
+
45
61
  const runId = assertSafePathId("runId", raw.manifest.runId);
46
62
  const importedAt = new Date().toISOString();
47
63
 
@@ -40,9 +40,13 @@ export function handleAnchorSet(
40
40
  const cfg = params.config ?? {};
41
41
 
42
42
  // Parse context from config
43
+ const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
43
44
  const context: Record<string, unknown> = {};
44
45
  if (cfg.context && typeof cfg.context === "object") {
45
- Object.assign(context, cfg.context as Record<string, unknown>);
46
+ const raw = cfg.context as Record<string, unknown>;
47
+ for (const [k, v] of Object.entries(raw)) {
48
+ if (!POLLUTED_KEYS.has(k)) context[k] = v;
49
+ }
46
50
  }
47
51
  if (cfg.key) {
48
52
  // Single key shorthand
@@ -25,9 +25,14 @@ import type { PiTeamsToolResult } from "../tool-result.ts";
25
25
  import { locateRunCwd } from "../team-tool.ts";
26
26
  import { configRecord, result, type TeamContext } from "./context.ts";
27
27
 
28
- function globMatch(value: string, pattern: string): boolean {
29
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\?/g, "\\?").replace(/\*/g, ".*");
30
- return new RegExp(`^${escaped}$`).test(value);
28
+ export function globMatch(value: string, pattern: string): boolean {
29
+ // Prevent ReDoS: reject excessively long patterns
30
+ if (pattern.length > 200) return false;
31
+ const regex = pattern
32
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex special chars
33
+ .replace(/\*/g, "[^/]*") // * matches non-slash characters only
34
+ .replace(/\?/g, "[^/]"); // ? matches single non-slash
35
+ return new RegExp(`^${regex}$`).test(value);
31
36
  }
32
37
 
33
38
  function safeReadContainedFile(baseDir: string, filePath: string | undefined): string | undefined {
@@ -364,7 +369,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
364
369
  const updatedTask = claimTask(task, owner);
365
370
  const tasks = loaded.tasks.map((item) => item.id === task.id ? updatedTask : item);
366
371
  saveRunTasks(loaded.manifest, tasks);
367
- appendEvent(loaded.manifest.eventsPath, { type: "task.claimed", runId: loaded.manifest.runId, taskId: task.id, data: { owner, token: updatedTask.claim?.token, leasedUntil: updatedTask.claim?.leasedUntil } });
372
+ appendEvent(loaded.manifest.eventsPath, { type: "task.claimed", runId: loaded.manifest.runId, taskId: task.id, data: { owner, token: "[REDACTED]", leasedUntil: updatedTask.claim?.leasedUntil } });
368
373
  return result(JSON.stringify(updatedTask.claim, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
369
374
  });
370
375
  } catch (error) {
@@ -1,5 +1,19 @@
1
1
  import { effectiveAutonomousConfig, parseConfig, type PiTeamsAutonomousConfig, type PiTeamsConfig } from "../../config/config.ts";
2
2
 
3
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
4
+
5
+ /** Recursively strip dangerous prototype-pollution keys from all levels of an object. */
6
+ export function sanitizeObject<T>(obj: T): T {
7
+ if (obj === null || obj === undefined || typeof obj !== "object") return obj;
8
+ if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item)) as T;
9
+ const safe: Record<string, unknown> = {};
10
+ for (const key of Object.keys(obj as Record<string, unknown>)) {
11
+ if (DANGEROUS_KEYS.has(key)) continue;
12
+ safe[key] = sanitizeObject((obj as Record<string, unknown>)[key]);
13
+ }
14
+ return safe as T;
15
+ }
16
+
3
17
  export function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
4
18
  const rootPatch = parseConfig(config).autonomous;
5
19
  if (rootPatch) return rootPatch;
@@ -11,7 +25,7 @@ export function configPatchFromConfig(config: unknown): PiTeamsConfig {
11
25
  }
12
26
 
13
27
  export function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
14
- const patch = parseConfig(rawOverride);
28
+ const patch = sanitizeObject(parseConfig(rawOverride));
15
29
  return {
16
30
  ...base,
17
31
  ...patch,
@@ -1258,7 +1258,8 @@ export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
1258
1258
  registry;
1259
1259
  }
1260
1260
 
1261
- export function getCrewGlobalRegistry(): CrewRegistry | undefined {
1261
+ /** @internal */
1262
+ function getCrewGlobalRegistry(): CrewRegistry | undefined {
1262
1263
  return (globalThis as Record<symbol | string, unknown>)[
1263
1264
  CREW_REGISTRY_KEY
1264
1265
  ] as CrewRegistry | undefined;
@@ -35,6 +35,14 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
35
35
  // environments, all hooks should set workspaceId to prevent cross-workspace access.
36
36
  const scopedHooks = hooks.filter((h) => !h.workspaceId || h.workspaceId === ctx.workspaceId);
37
37
  if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
38
+ const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
39
+ function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
40
+ const clean: Record<string, unknown> = {};
41
+ for (const [k, v] of Object.entries(data)) {
42
+ if (!POLLUTED_KEYS.has(k)) clean[k] = v;
43
+ }
44
+ return clean;
45
+ }
38
46
  const start = Date.now();
39
47
  const diagnostics: string[] = [];
40
48
  let capturedModifications: Record<string, unknown> | undefined;
@@ -45,7 +53,7 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
45
53
  return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason };
46
54
  }
47
55
  if (result.outcome === "modify" && result.data) {
48
- Object.assign(ctx, result.data);
56
+ Object.assign(ctx, sanitizeMergeData(result.data));
49
57
  capturedModifications = { ...result.data };
50
58
  }
51
59
  } catch (error) {
@@ -20,9 +20,9 @@ export type HookName =
20
20
  * - 1 = warn (non-blocking error, continue)
21
21
  * - 2 = block (blocking error, stop)
22
22
  */
23
- export const HOOK_EXIT_SUCCESS = 0 as const;
24
- export const HOOK_EXIT_WARN = 1 as const;
25
- export const HOOK_EXIT_BLOCK = 2 as const;
23
+ /** @internal */ const HOOK_EXIT_SUCCESS = 0 as const;
24
+ /** @internal */ const HOOK_EXIT_WARN = 1 as const;
25
+ /** @internal */ const HOOK_EXIT_BLOCK = 2 as const;
26
26
 
27
27
  export type HookMode = "blocking" | "non_blocking";
28
28
  export type HookOutcome = "allow" | "block" | "modify" | "diagnostic";
package/src/i18n.ts CHANGED
@@ -129,13 +129,26 @@ export function t(key: Key, params?: Params): string {
129
129
  * @example
130
130
  * addTranslations("vi", { "agent.requiresPrompt": "Agent cần prompt." })
131
131
  */
132
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
133
+
134
+ function stripDangerousKeys<T extends Record<string, unknown>>(obj: T): T {
135
+ const safe: Record<string, unknown> = {};
136
+ for (const key of Object.keys(obj)) {
137
+ if (!DANGEROUS_KEYS.has(key)) {
138
+ safe[key] = obj[key];
139
+ }
140
+ }
141
+ return safe as T;
142
+ }
143
+
132
144
  export function addTranslations(locale: string, bundle: Partial<Record<Key, string>>): void {
133
145
  if (!locale) return;
146
+ const safeBundle = stripDangerousKeys(bundle as Record<string, unknown>) as Partial<Record<Key, string>>;
134
147
  const existing = translations[locale];
135
148
  if (existing) {
136
- Object.assign(existing, bundle);
149
+ Object.assign(existing, safeBundle);
137
150
  } else {
138
- translations[locale] = { ...bundle };
151
+ translations[locale] = { ...safeBundle };
139
152
  }
140
153
  }
141
154
 
@@ -8,6 +8,78 @@ import type { MetricExporter } from "./adapter.ts";
8
8
 
9
9
  const gzipAsync = promisify(gzip);
10
10
 
11
+ /**
12
+ * SSRF protection: validate that an OTLP endpoint URL does not target
13
+ * private/reserved networks or dangerous protocols.
14
+ * Rejects: localhost, loopback, private IPs, link-local, cloud metadata,
15
+ * IPv6 private, file:// and javascript:// protocols.
16
+ * Only http:// and https:// to public hostnames are allowed.
17
+ */
18
+ export function validateEndpoint(endpoint: string): void {
19
+ let url: URL;
20
+ try {
21
+ url = new URL(endpoint);
22
+ } catch {
23
+ throw new Error(`Invalid OTLP endpoint URL: ${endpoint}`);
24
+ }
25
+
26
+ // Only allow http and https protocols
27
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
28
+ throw new Error(
29
+ `OTLP endpoint must use http:// or https:// protocol, got: ${url.protocol}`,
30
+ );
31
+ }
32
+
33
+ const hostname = url.hostname.toLowerCase();
34
+
35
+ // Reject known localhost names
36
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
37
+ throw new Error(`OTLP endpoint must not target localhost: ${endpoint}`);
38
+ }
39
+
40
+ // Reject IPv6 loopback and private
41
+ if (hostname.startsWith("[")) {
42
+ const bare = hostname.replace(/^\[|\]$/g, "");
43
+ if (bare === "::1") {
44
+ throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
45
+ }
46
+ if (bare.toLowerCase().startsWith("fd") || bare.toLowerCase().startsWith("fc")) {
47
+ throw new Error(`OTLP endpoint must not target private IPv6 address: ${endpoint}`);
48
+ }
49
+ }
50
+
51
+ // Reject IPv4 private/reserved ranges
52
+ // Match plain IPv4 addresses (not hostnames that look like IPs)
53
+ const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
54
+ if (ipv4Match) {
55
+ const octets = [Number(ipv4Match[1]), Number(ipv4Match[2]), Number(ipv4Match[3]), Number(ipv4Match[4])];
56
+ // 127.x.x.x - loopback
57
+ if (octets[0] === 127) {
58
+ throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
59
+ }
60
+ // 10.x.x.x - private class A
61
+ if (octets[0] === 10) {
62
+ throw new Error(`OTLP endpoint must not target private network (10.0.0.0/8): ${endpoint}`);
63
+ }
64
+ // 172.16.x.x - 172.31.x.x - private class B
65
+ if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
66
+ throw new Error(`OTLP endpoint must not target private network (172.16.0.0/12): ${endpoint}`);
67
+ }
68
+ // 192.168.x.x - private class C
69
+ if (octets[0] === 192 && octets[1] === 168) {
70
+ throw new Error(`OTLP endpoint must not target private network (192.168.0.0/16): ${endpoint}`);
71
+ }
72
+ // 169.254.x.x - link-local / cloud metadata
73
+ if (octets[0] === 169 && octets[1] === 254) {
74
+ throw new Error(`OTLP endpoint must not target link-local/metadata endpoint (169.254.0.0/16): ${endpoint}`);
75
+ }
76
+ // 0.x.x.x - this network
77
+ if (octets[0] === 0) {
78
+ throw new Error(`OTLP endpoint must not target this-network address (0.0.0.0/8): ${endpoint}`);
79
+ }
80
+ }
81
+ }
82
+
11
83
  // FIX (Round 15): Cap the number of snapshots per push to prevent OOM when
12
84
  // the metric registry has grown large. The OTLP HTTP spec allows many metrics
13
85
  // in one payload, but a single push > 10_000 metrics would balloon the
@@ -71,6 +143,7 @@ export class OTLPExporter implements MetricExporter {
71
143
  private readonly registry: MetricRegistry;
72
144
 
73
145
  constructor(opts: OTLPExporterOptions, registry: MetricRegistry) {
146
+ validateEndpoint(opts.endpoint);
74
147
  this.opts = opts;
75
148
  this.registry = registry;
76
149
  }
@@ -22,6 +22,7 @@ import type { TeamConfig } from "../teams/team-config.ts";
22
22
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
23
23
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
24
24
  import { refreshTaskGraphQueues } from "./task-graph-scheduler.ts";
25
+ import { getPlanTemplate, renderPlanTemplate, listPlanTemplates } from "./plan-templates.ts";
25
26
 
26
27
  export interface AdaptivePlanTask {
27
28
  role: string;
@@ -70,6 +71,29 @@ export function extractAdaptivePlanJson(text: string): string | undefined {
70
71
  }
71
72
 
72
73
  export function parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
74
+ // Check if the text is a plan template reference: "template:<name>" with optional JSON variables
75
+ const templateRefMatch = text.match(/^template:([a-zA-Z0-9_-]+)/);
76
+ if (templateRefMatch?.[1]) {
77
+ const template = getPlanTemplate(templateRefMatch[1]);
78
+ if (template) {
79
+ // Try to extract variables from the remaining text
80
+ let variables: Record<string, string> = {};
81
+ try {
82
+ const varsJson = text.slice(templateRefMatch[0].length).trim();
83
+ if (varsJson) variables = JSON.parse(varsJson);
84
+ } catch { /* use empty variables */ }
85
+ const rendered = renderPlanTemplate(templateRefMatch[1], variables);
86
+ if (rendered) {
87
+ // Convert RenderedPlan → AdaptivePlan
88
+ const phases: AdaptivePlanPhase[] = rendered.phases.map(phase => ({
89
+ name: phase.name,
90
+ tasks: [{ role: phase.role, task: phase.task }],
91
+ }));
92
+ return phases.length ? { phases } : undefined;
93
+ }
94
+ }
95
+ }
96
+
73
97
  const raw = extractAdaptivePlanJson(text);
74
98
  if (!raw) return undefined;
75
99
  let parsed: unknown;