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
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.
|
|
12
|
+
**v0.6.1**: See [CHANGELOG.md](CHANGELOG.md).
|
|
13
13
|
|
|
14
|
-
### Security highlights (v0.
|
|
14
|
+
### Security highlights (v0.6.1)
|
|
15
15
|
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **Shell injection
|
|
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
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
-
|
|
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 files — total 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
|
@@ -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
|
-
|
|
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
|
-
|
|
7
|
+
/** @internal */
|
|
8
|
+
interface FeedbackLoopStats {
|
|
8
9
|
runsObserved: number;
|
|
9
10
|
avgSuccessRate: number;
|
|
10
11
|
recommendations: string[];
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
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)) :
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|
package/src/hooks/registry.ts
CHANGED
|
@@ -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) {
|
package/src/hooks/types.ts
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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,
|
|
149
|
+
Object.assign(existing, safeBundle);
|
|
137
150
|
} else {
|
|
138
|
-
translations[locale] = { ...
|
|
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;
|