pi-crew 0.6.1 → 0.6.4
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 +194 -0
- package/README.md +81 -33
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/docs/ui-optimization-plan.md +447 -0
- package/package.json +2 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +213 -35
- package/src/extension/registration/brief-tool-overrides.ts +400 -0
- package/src/extension/registration/commands.ts +27 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +9 -4
- package/src/extension/registration/team-tool.ts +17 -10
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +64 -14
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool-types.ts +2 -0
- package/src/extension/team-tool.ts +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +92 -70
- package/src/runtime/team-runner.ts +186 -20
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/session-state-map.ts +51 -0
- package/src/state/state-store.ts +330 -43
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/powerbar-publisher.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +5 -1
- package/src/ui/theme-adapter.ts +80 -1
- package/src/ui/tool-progress-formatter.ts +9 -5
- package/src/ui/tool-render.ts +4 -0
- package/src/ui/tool-renderers/brief-mode.ts +207 -0
- package/src/ui/tool-renderers/index.ts +627 -0
- package/src/ui/widget/index.ts +224 -0
- package/src/ui/widget/widget-formatters.ts +148 -0
- package/src/ui/widget/widget-model.ts +90 -0
- package/src/ui/widget/widget-renderer.ts +130 -0
- package/src/ui/widget/widget-types.ts +37 -0
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/guards.ts +110 -0
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/utils/env-filter.ts
CHANGED
|
@@ -1,10 +1,56 @@
|
|
|
1
1
|
import { isSecretKey } from "./redaction.ts";
|
|
2
2
|
|
|
3
|
+
// Well-known LLM provider API keys that are intentionally allowlisted in
|
|
4
|
+
// child-pi.ts and async-runner.ts for pass-through to worker processes.
|
|
5
|
+
// These are "secret" by pattern (contain KEY/API) but are safe to allowlist
|
|
6
|
+
// because they are standard provider credentials, not arbitrary secrets.
|
|
7
|
+
const KNOWN_PROVIDER_KEYS = new Set([
|
|
8
|
+
"MINIMAX_API_KEY", "MINIMAX_GROUP_ID",
|
|
9
|
+
"OPENAI_API_KEY", "OPENAI_ORG_ID",
|
|
10
|
+
"ANTHROPIC_API_KEY",
|
|
11
|
+
"GOOGLE_API_KEY", "GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
|
|
12
|
+
"AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT",
|
|
13
|
+
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION",
|
|
14
|
+
"ZEU_API_KEY", "ZERODEV_API_KEY",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function isKnownProviderKey(key: string): boolean {
|
|
18
|
+
return KNOWN_PROVIDER_KEYS.has(key);
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
export interface SanitizeEnvOptions {
|
|
4
22
|
/** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
|
|
5
23
|
allowList?: string[];
|
|
6
24
|
}
|
|
7
25
|
|
|
26
|
+
// Keywords that indicate a secret env var
|
|
27
|
+
const SECRET_SUFFIXES = ["token", "api", "key", "password", "passwd", "secret", "credential", "authorization", "private"];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a glob pattern could match secret env vars.
|
|
31
|
+
* A pattern like "PI_*" is dangerous because it could match PI_TOKEN, PI_API_KEY, etc.
|
|
32
|
+
*
|
|
33
|
+
* Exception: `PI_CREW_*` is a controlled namespace — the pi-crew codebase owns
|
|
34
|
+
* every PI_CREW_* env var (PI_CREW_PARENT_PID, PI_CREW_ADAPTIVE_REPAIR, etc.)
|
|
35
|
+
* and none of them are secrets. Allowing the glob here lets child Pi processes
|
|
36
|
+
* inherit our config without needing a per-var allowlist.
|
|
37
|
+
*/
|
|
38
|
+
function isDangerousGlob(pattern: string): boolean {
|
|
39
|
+
if (!pattern.endsWith("*")) return false;
|
|
40
|
+
const prefix = pattern.slice(0, -1); // Remove trailing *
|
|
41
|
+
if (prefix === "") return true; // Single "*" matches everything
|
|
42
|
+
// PI_CREW_* is the pi-crew controlled namespace — no secrets live here.
|
|
43
|
+
// This covers PI_CREW_*, PI_CREW_TEAMS_*, PI_CREW_AGENT_*, etc.
|
|
44
|
+
if (prefix.startsWith("PI_CREW_") || prefix === "PI_CREW") return false;
|
|
45
|
+
// Check if combining the prefix with any secret suffix would create a secret key
|
|
46
|
+
for (const suffix of SECRET_SUFFIXES) {
|
|
47
|
+
if (isSecretKey(prefix + suffix)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
8
54
|
/**
|
|
9
55
|
* Strip env vars whose keys look like secrets before passing to child processes.
|
|
10
56
|
*
|
|
@@ -14,11 +60,31 @@ export interface SanitizeEnvOptions {
|
|
|
14
60
|
export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnvOptions): Record<string, string> {
|
|
15
61
|
const filtered: Record<string, string> = {};
|
|
16
62
|
if (options?.allowList && options.allowList.length > 0) {
|
|
63
|
+
// Validate allowlist patterns don't match secrets
|
|
64
|
+
for (const pattern of options.allowList) {
|
|
65
|
+
if (isDangerousGlob(pattern)) {
|
|
66
|
+
throw new Error(`Allowlist pattern "${pattern}" could match secret env vars. Use a more specific pattern.`);
|
|
67
|
+
}
|
|
68
|
+
// Validate non-glob entries don't look like secret keys.
|
|
69
|
+
// Exception 1: if the key exists in env, it was intentionally set and
|
|
70
|
+
// should be allowed through (user knows what they're doing).
|
|
71
|
+
// Exception 2: known provider keys (MINIMAX_API_KEY, etc.) are always
|
|
72
|
+
// allowed because they are standard provider credentials explicitly
|
|
73
|
+
// listed in async-runner.ts / child-pi.ts allowlists.
|
|
74
|
+
if (!pattern.endsWith("*") && isSecretKey(pattern) && !(pattern in env) && !isKnownProviderKey(pattern)) {
|
|
75
|
+
throw new Error(`Allowlist entry "${pattern}" looks like a secret key. Use a more specific pattern.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
17
78
|
const matchers = options.allowList.map((p) => {
|
|
18
79
|
if (p.endsWith("*")) {
|
|
80
|
+
// Glob pattern: matches keys that start with the prefix AND have
|
|
81
|
+
// at least one additional character (distinguishes "PI_CREW_*" from "PI_CREW_").
|
|
82
|
+
// For example, "PI_CREW_*" matches "PI_CREW_DEPTH" but not "PI_CREW_".
|
|
83
|
+
// This ensures trailing glob patterns require extra chars, not exact-prefix-only matches.
|
|
19
84
|
const prefix = p.slice(0, -1);
|
|
20
85
|
return (k: string) => k.startsWith(prefix) && k.length > prefix.length;
|
|
21
86
|
}
|
|
87
|
+
// Exact match is case-sensitive; Unix env vars are uppercase by convention.
|
|
22
88
|
return (k: string) => k === p;
|
|
23
89
|
});
|
|
24
90
|
for (const [key, value] of Object.entries(env)) {
|
|
@@ -6,7 +6,15 @@ interface TimerApi {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const defaultTimerApi: TimerApi = {
|
|
9
|
-
setTimeout: (handler, delayMs) =>
|
|
9
|
+
setTimeout: (handler, delayMs) => {
|
|
10
|
+
const t = setTimeout(handler, delayMs);
|
|
11
|
+
// Defense in depth: never let a coalescer timer block process exit.
|
|
12
|
+
// The timer may be cleared before it fires; .unref() is idempotent.
|
|
13
|
+
if (typeof t === "object" && t && "unref" in t && typeof t.unref === "function") {
|
|
14
|
+
t.unref();
|
|
15
|
+
}
|
|
16
|
+
return t;
|
|
17
|
+
},
|
|
10
18
|
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
11
19
|
};
|
|
12
20
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized type guard library for pi-crew.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by @ayulab/runtime-core — all unknown-to-narrowed checks
|
|
5
|
+
* go through these helpers instead of inline typeof/instanceof.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Primitive guards ──────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** Narrow `unknown` to `Record<string, unknown>`. */
|
|
11
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Narrow `unknown` to `string`. */
|
|
16
|
+
export function isString(value: unknown): value is string {
|
|
17
|
+
return typeof value === "string";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Narrow `unknown` to a non-empty string. */
|
|
21
|
+
export function isNonEmptyString(value: unknown): value is string {
|
|
22
|
+
return typeof value === "string" && value.length > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Narrow `unknown` to `number` (excludes NaN). */
|
|
26
|
+
export function isNumber(value: unknown): value is number {
|
|
27
|
+
return typeof value === "number" && !Number.isNaN(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Narrow `unknown` to `boolean`. */
|
|
31
|
+
export function isBoolean(value: unknown): value is boolean {
|
|
32
|
+
return typeof value === "boolean";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Narrow `unknown` to `readonly string[]`. */
|
|
36
|
+
export function isStringArray(value: unknown): value is readonly string[] {
|
|
37
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Higher-order guard: build a guard that checks every element of an array.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const isNumberArray = isArrayOf(isNumber);
|
|
45
|
+
*/
|
|
46
|
+
export function isArrayOf<T>(
|
|
47
|
+
guard: (item: unknown) => item is T,
|
|
48
|
+
): (value: unknown) => value is readonly T[] {
|
|
49
|
+
return (value: unknown): value is readonly T[] =>
|
|
50
|
+
Array.isArray(value) && value.every(guard);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Record field extractors ───────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** Extract a string field from a record, returning `undefined` if absent. */
|
|
56
|
+
export function getStringField(value: unknown, key: string): string | undefined {
|
|
57
|
+
if (!isRecord(value)) return undefined;
|
|
58
|
+
const field = value[key];
|
|
59
|
+
return typeof field === "string" ? field : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Extract a number field from a record, returning `undefined` if absent. */
|
|
63
|
+
export function getNumberField(value: unknown, key: string): number | undefined {
|
|
64
|
+
if (!isRecord(value)) return undefined;
|
|
65
|
+
const field = value[key];
|
|
66
|
+
return typeof field === "number" && !Number.isNaN(field) ? field : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract a boolean field from a record, returning `undefined` if absent. */
|
|
70
|
+
export function getBooleanField(value: unknown, key: string): boolean | undefined {
|
|
71
|
+
if (!isRecord(value)) return undefined;
|
|
72
|
+
const field = value[key];
|
|
73
|
+
return typeof field === "boolean" ? field : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extract a nested record field from a record, returning `undefined` if absent. */
|
|
77
|
+
export function getRecordField(value: unknown, key: string): Record<string, unknown> | undefined {
|
|
78
|
+
if (!isRecord(value)) return undefined;
|
|
79
|
+
const field = value[key];
|
|
80
|
+
return isRecord(field) ? field : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Extract an array field from a record, returning `undefined` if absent. */
|
|
84
|
+
export function getArrayField(value: unknown, key: string): unknown[] | undefined {
|
|
85
|
+
if (!isRecord(value)) return undefined;
|
|
86
|
+
const field = value[key];
|
|
87
|
+
return Array.isArray(field) ? field : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Error helpers ─────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract a human-readable message from an unknown error value.
|
|
94
|
+
*
|
|
95
|
+
* Prefer this over manual `instanceof Error` checks to keep error
|
|
96
|
+
* handling uniform across the codebase.
|
|
97
|
+
*/
|
|
98
|
+
export function errorMessage(err: unknown): string {
|
|
99
|
+
return err instanceof Error ? err.message : String(err);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Utility types ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** A non-empty readonly array type. */
|
|
105
|
+
export type NonEmptyReadonlyArray<T> = readonly [T, ...T[]];
|
|
106
|
+
|
|
107
|
+
/** Narrow a readonly array to a non-empty readonly array. */
|
|
108
|
+
export function hasItems<T>(items: readonly T[]): items is NonEmptyReadonlyArray<T> {
|
|
109
|
+
return items.length > 0;
|
|
110
|
+
}
|
package/src/utils/paths.ts
CHANGED
|
@@ -9,7 +9,49 @@ export function packageRoot(): string {
|
|
|
9
9
|
|
|
10
10
|
export function userPiRoot(): string {
|
|
11
11
|
const home = process.env.PI_TEAMS_HOME?.trim() || os.homedir();
|
|
12
|
-
|
|
12
|
+
const resolved = path.join(home, ".pi", "agent");
|
|
13
|
+
|
|
14
|
+
// Reject symlinks to prevent confusion attacks where PI_TEAMS_HOME points to
|
|
15
|
+
// an attacker-controlled target via a user-owned symlink.
|
|
16
|
+
// We use lstatSync (does NOT follow) to detect symlinks before they are resolved.
|
|
17
|
+
// ENOENT is acceptable — the directory may not exist yet (caller will create it).
|
|
18
|
+
let isSymlink = false;
|
|
19
|
+
try {
|
|
20
|
+
const lstats = fs.lstatSync(resolved);
|
|
21
|
+
isSymlink = lstats.isSymbolicLink();
|
|
22
|
+
} catch (err: unknown) {
|
|
23
|
+
if (err instanceof Error && "code" in err && err.code !== "ENOENT") throw err;
|
|
24
|
+
// Path doesn't exist yet — caller will create it. Skip further validation.
|
|
25
|
+
return resolved;
|
|
26
|
+
}
|
|
27
|
+
if (isSymlink) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`userPiRoot: PI_TEAMS_HOME path "${resolved}" is a symlink. ` +
|
|
30
|
+
"Symlinks are not supported for PI_TEAMS_HOME to prevent confusion attacks. " +
|
|
31
|
+
"Set PI_TEAMS_HOME to a direct path owned by the current user.",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate that the resolved path is owned by the current user
|
|
36
|
+
// to ensure security assumptions about file permissions (0o600/0o700) hold.
|
|
37
|
+
// Skip check if the directory does not exist yet.
|
|
38
|
+
try {
|
|
39
|
+
const stats = fs.statSync(resolved);
|
|
40
|
+
if (stats.uid !== os.userInfo().uid) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`userPiRoot: PI_TEAMS_HOME path "${resolved}" is not owned by the current user (uid=${os.userInfo().uid}, found uid=${stats.uid}). ` +
|
|
43
|
+
"This violates security assumptions about file permissions. Set PI_TEAMS_HOME to a path owned by the current user, or unset it to use the default.",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
} catch (err: unknown) {
|
|
47
|
+
if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
// ENOENT from statSync means the directory was deleted between lstat and stat
|
|
51
|
+
// (race condition). This is acceptable — caller will handle.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return resolved;
|
|
13
55
|
}
|
|
14
56
|
|
|
15
57
|
const PROJECT_DIR_MARKERS = [".git", ".pi", ".crew", ".hg", ".svn", ".factory", ".omc"];
|
|
@@ -48,8 +90,34 @@ function hasProjectMarker(dir: string): boolean {
|
|
|
48
90
|
return false;
|
|
49
91
|
}
|
|
50
92
|
|
|
93
|
+
/** On Windows, resolve a path to its canonical long-name form.
|
|
94
|
+
* Uses realpathSync.native to get the \\?\ long-name path, then strips
|
|
95
|
+
* the prefix for path.relative compatibility. */
|
|
96
|
+
function canonicalizePath(p: string): string {
|
|
97
|
+
try {
|
|
98
|
+
const r = fs.realpathSync.native(p);
|
|
99
|
+
return r.startsWith("\\\\?\\") ? r.slice(4) : r;
|
|
100
|
+
} catch {
|
|
101
|
+
try { return fs.realpathSync(p); } catch { return p; }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
51
105
|
export function findRepoRoot(cwd: string): string | undefined {
|
|
52
|
-
|
|
106
|
+
// Resolve symlinks before walking to prevent malicious symlinks from bypassing
|
|
107
|
+
// home/temp boundary checks. If the path doesn't exist (e.g., caller passed
|
|
108
|
+
// a non-existent CWD like /tmp/no-such-dir), fall back to the lexical path
|
|
109
|
+
// and let computeRepoRoot handle the rest. ENOENT here is common for
|
|
110
|
+
// newly-created test directories and shouldn't propagate as a crash.
|
|
111
|
+
let startKey: string;
|
|
112
|
+
try {
|
|
113
|
+
startKey = fs.realpathSync(cwd);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
116
|
+
startKey = path.resolve(cwd);
|
|
117
|
+
} else {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
53
121
|
const cached = projectRootCache.get(startKey);
|
|
54
122
|
if (cached && Date.now() - cached.cachedAt < PROJECT_ROOT_CACHE_TTL_MS) {
|
|
55
123
|
// Re-insert to refresh LRU position.
|
|
@@ -82,18 +150,25 @@ function computeRepoRoot(start: string): string | undefined {
|
|
|
82
150
|
}
|
|
83
151
|
|
|
84
152
|
export function projectPiRoot(cwd: string): string {
|
|
85
|
-
|
|
153
|
+
const repoRoot = findRepoRoot(cwd) ?? cwd;
|
|
154
|
+
const piDir = path.join(repoRoot, ".pi");
|
|
155
|
+
// Use realpathSync to resolve any symlinks before returning to prevent
|
|
156
|
+
// config from being read from unexpected locations.
|
|
157
|
+
if (fs.existsSync(piDir)) return fs.realpathSync(piDir);
|
|
158
|
+
return piDir;
|
|
86
159
|
}
|
|
87
160
|
|
|
88
161
|
export function projectCrewRoot(cwd: string): string {
|
|
89
162
|
const repoRoot = findRepoRoot(cwd) ?? cwd;
|
|
90
163
|
const crewDir = path.join(repoRoot, ".crew");
|
|
91
164
|
// Keep an existing .crew/ stable even when .pi/ exists for project config.
|
|
92
|
-
|
|
165
|
+
// Use canonicalizePath to get long-name form on Windows, matching
|
|
166
|
+
// what worktree operations and resolveRealContainedPath expect.
|
|
167
|
+
if (fs.existsSync(crewDir)) return canonicalizePath(crewDir);
|
|
93
168
|
// Legacy reuse: if .pi/ already exists for the project, namespace under .pi/teams/
|
|
94
169
|
// to avoid creating a parallel .crew/ alongside an existing pi project layout.
|
|
95
170
|
const piDir = path.join(repoRoot, ".pi");
|
|
96
|
-
if (fs.existsSync(piDir)) return path.join(piDir, "teams");
|
|
171
|
+
if (fs.existsSync(piDir)) return path.join(canonicalizePath(piDir), "teams");
|
|
97
172
|
return crewDir;
|
|
98
173
|
}
|
|
99
174
|
|
package/src/utils/redaction.ts
CHANGED
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g;
|
|
8
8
|
|
|
9
9
|
// Linear-time secret key detection
|
|
10
|
+
// IMPORTANT: This function must maintain linear-time guarantees.
|
|
11
|
+
// The fast-path regex uses simple string alternatives with anchors only (no quantifiers),
|
|
12
|
+
// and the linear scan iterates through characters once. If either path is replaced with
|
|
13
|
+
// a more complex regex, catastrophic backtracking (ReDoS) could result.
|
|
14
|
+
// Any modifications must preserve O(n) complexity where n = keyName.length.
|
|
10
15
|
export function isSecretKey(keyName: string): boolean {
|
|
11
|
-
// Fast path: common secret key names
|
|
16
|
+
// Fast path: common secret key names (safe anchored regex, no backtracking)
|
|
12
17
|
const lower = keyName.toLowerCase();
|
|
13
18
|
if (/^(token|apikey|api_key|password|secret|credential|authorization|privatekey|private_key)$/.test(lower)) {
|
|
14
19
|
return true;
|
package/src/utils/safe-paths.ts
CHANGED
|
@@ -16,30 +16,299 @@ export function resolveContainedPath(baseDir: string, targetPath: string): strin
|
|
|
16
16
|
}
|
|
17
17
|
const base = path.resolve(baseDir);
|
|
18
18
|
const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
|
|
19
|
-
|
|
19
|
+
// On Windows, paths are case-insensitive and short-name (8.3) aliases may
|
|
20
|
+
// differ from long-name forms (e.g. C:\Users\RUNNER~1 vs C:\Users\runneradmin).
|
|
21
|
+
// We normalize both paths to their canonical form by resolving through
|
|
22
|
+
// realpathSync, walking up ancestors for non-existent paths.
|
|
23
|
+
const baseNorm = process.platform === "win32" ? resolveWindowsCanonical(base) : base;
|
|
24
|
+
const resolvedNorm = process.platform === "win32" ? resolveWindowsCanonical(resolved) : resolved;
|
|
25
|
+
const relative = process.platform === "win32"
|
|
26
|
+
? path.relative(baseNorm.toLowerCase(), resolvedNorm.toLowerCase())
|
|
27
|
+
: path.relative(base, resolved);
|
|
20
28
|
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
21
29
|
return resolved;
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
/**
|
|
33
|
+
* On Windows, resolve a path to its canonical (long-name) form.
|
|
34
|
+
* Walks up ancestors until finding one that exists, then joins back down.
|
|
35
|
+
* This handles paths where intermediate directories don't exist yet but
|
|
36
|
+
* their ancestors do (and may use short-name aliases).
|
|
37
|
+
*/
|
|
38
|
+
function resolveWindowsCanonical(p: string): string {
|
|
39
|
+
try {
|
|
40
|
+
// Use regular realpathSync (not .native) to preserve the input path form.
|
|
41
|
+
// On Windows CI, .native always returns long-name (runneradmin) while
|
|
42
|
+
// non-native preserves short-name (RUNNER~1). Using non-native ensures
|
|
43
|
+
// the returned form matches what os.tmpdir() and mkdtempSync produce.
|
|
44
|
+
let real = fs.realpathSync(p);
|
|
45
|
+
// Guard against NTFS internal paths (e.g. C:\$Extend\$Deleted)
|
|
46
|
+
if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
|
|
47
|
+
return real;
|
|
48
|
+
} catch {
|
|
49
|
+
// Fallback: try realpathSync (non-native) which may succeed where .native fails
|
|
50
|
+
try {
|
|
51
|
+
const real = fs.realpathSync(p);
|
|
52
|
+
return real;
|
|
53
|
+
} catch { /* proceed to ancestor walk */ }
|
|
54
|
+
// Walk up to find the deepest existing ancestor
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
let current = p;
|
|
57
|
+
while (current !== path.dirname(current)) {
|
|
58
|
+
try {
|
|
59
|
+
// Use non-native to preserve input path form
|
|
60
|
+
let real = fs.realpathSync(current);
|
|
61
|
+
// Guard against NTFS internal paths
|
|
62
|
+
if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
|
|
63
|
+
// Found existing ancestor — join with remaining parts in reverse order
|
|
64
|
+
// (parts were pushed bottom-up, so iterate from last to first)
|
|
65
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
66
|
+
real = path.join(real, parts[i]);
|
|
67
|
+
}
|
|
68
|
+
return real;
|
|
69
|
+
} catch { /* keep walking */ }
|
|
70
|
+
parts.push(path.basename(current));
|
|
71
|
+
current = path.dirname(current);
|
|
72
|
+
}
|
|
73
|
+
// Couldn't resolve any ancestor — return original
|
|
74
|
+
return p;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a target path to its real (symlink-resolved) absolute path while
|
|
80
|
+
* guaranteeing the result stays inside `baseDir`.
|
|
81
|
+
*
|
|
82
|
+
* ## Security model — asymmetric ancestor handling
|
|
83
|
+
*
|
|
84
|
+
* `baseDir` and `targetPath` are validated with different policies because
|
|
85
|
+
* they play different roles:
|
|
86
|
+
*
|
|
87
|
+
* - **baseDir** (the container): all ancestors MUST exist and MUST NOT be
|
|
88
|
+
* symlinks. We refuse to operate if any component is missing or symlinked,
|
|
89
|
+
* because a symlinked container could point the caller outside the
|
|
90
|
+
* intended trust boundary (e.g. `/var/run -> /run` resolving into an
|
|
91
|
+
* attacker-controlled directory).
|
|
92
|
+
*
|
|
93
|
+
* - **targetPath** (the contained file): the FINAL component may be
|
|
94
|
+
* non-existent (for write operations creating a new file) and EXISTING
|
|
95
|
+
* ancestors of the target may also be missing. We DO require that any
|
|
96
|
+
* ancestor that DOES exist must not be a symlink — an attacker who can
|
|
97
|
+
* create a directory in the container must not be able to redirect the
|
|
98
|
+
* file being created.
|
|
99
|
+
*
|
|
100
|
+
* This asymmetry is intentional: callers that need to create a new file
|
|
101
|
+
* pass a non-existent targetPath. Callers that operate on an existing file
|
|
102
|
+
* get full symlink protection. Callers MUST NOT pass a symlinked
|
|
103
|
+
* intermediate component; if you need to, use `resolveContainedPath`
|
|
104
|
+
* instead (which only checks the resolved path, not the chain).
|
|
105
|
+
*
|
|
106
|
+
* Throws on:
|
|
107
|
+
* - null byte in targetPath
|
|
108
|
+
* - targetPath resolves outside baseDir
|
|
109
|
+
* - any existing ancestor (base or target) is a symlink
|
|
110
|
+
* - baseDir itself does not exist
|
|
111
|
+
*
|
|
112
|
+
* Returns the resolved real path on success, or the resolved (but not
|
|
113
|
+
* realpathed) path when the target does not exist yet.
|
|
114
|
+
*
|
|
115
|
+
* NOTE: There is a race condition window between validation and use where an
|
|
116
|
+
* attacker could create a directory component after validation but before the
|
|
117
|
+
* file is created. Callers MUST create parent directories atomically
|
|
118
|
+
* (e.g., mkdirSync with { recursive: true }) and use O_CREAT | O_NOFOLLOW | O_EXCL
|
|
119
|
+
* for atomic file creation, as atomicWriteFile does. This ensures the entire
|
|
120
|
+
* operation is atomic and prevents TOCTOU attacks.
|
|
121
|
+
*/
|
|
24
122
|
export function resolveRealContainedPath(baseDir: string, targetPath: string): string {
|
|
123
|
+
if (targetPath.includes('\0')) {
|
|
124
|
+
throw new Error(`Security: path contains null byte`);
|
|
125
|
+
}
|
|
25
126
|
const resolved = resolveContainedPath(baseDir, targetPath);
|
|
127
|
+
|
|
128
|
+
// Open baseDir with O_NOFOLLOW to atomically validate no symlinks in the path.
|
|
129
|
+
// O_NOFOLLOW makes the open fail with ELOOP if any path component is a symlink.
|
|
130
|
+
let baseFd: number | undefined;
|
|
131
|
+
try {
|
|
132
|
+
baseFd = fs.openSync(baseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const errCode = (error as NodeJS.ErrnoException).code;
|
|
135
|
+
if (errCode === "ENOENT") {
|
|
136
|
+
// baseDir doesn't exist yet — create it and retry
|
|
137
|
+
try {
|
|
138
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
139
|
+
baseFd = fs.openSync(baseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
140
|
+
} catch (retryError) {
|
|
141
|
+
throw new Error(`Cannot open base directory ${baseDir}: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
|
|
142
|
+
}
|
|
143
|
+
} else if (errCode === "ELOOP") {
|
|
144
|
+
// On macOS, system directories like /var → /private/var contain symlinks.
|
|
145
|
+
// If baseDir is under such a path, resolve through realpath and retry.
|
|
146
|
+
if (process.platform === "darwin") {
|
|
147
|
+
try {
|
|
148
|
+
const realBaseDir = fs.realpathSync(baseDir);
|
|
149
|
+
if (realBaseDir !== baseDir) {
|
|
150
|
+
baseFd = fs.openSync(realBaseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
151
|
+
baseDir = realBaseDir; // update for later use
|
|
152
|
+
// Fall through to fstatSync below
|
|
153
|
+
}
|
|
154
|
+
} catch { /* throw original */ }
|
|
155
|
+
}
|
|
156
|
+
if (baseFd === undefined) throw new Error("Refusing to resolve: baseDir path contains a symlink: " + baseDir);
|
|
157
|
+
} else {
|
|
158
|
+
throw new Error(`Cannot open base directory ${baseDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
26
161
|
let realBase: string;
|
|
27
|
-
let realTarget: string;
|
|
28
162
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
163
|
+
const stat = fs.fstatSync(baseFd);
|
|
164
|
+
if (!stat.isDirectory()) throw new Error(`baseDir ${baseDir} is not a directory`);
|
|
165
|
+
// Use regular realpathSync (not .native) to preserve input path form.
|
|
166
|
+
// On Windows CI, .native always returns long-name form (runneradmin)
|
|
167
|
+
// while non-native preserves short-name (RUNNER~1). Using non-native
|
|
168
|
+
// ensures the result matches what the caller passed in.
|
|
169
|
+
realBase = fs.realpathSync(baseDir);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// baseDir MUST exist and be resolvable for the containment guarantee to hold.
|
|
172
|
+
// Callers creating new directories must create baseDir atomically (e.g.,
|
|
173
|
+
// mkdirSync with { recursive: true }) BEFORE calling this function, and use
|
|
174
|
+
// O_NOFOLLOW|O_CREAT|O_EXCL for the actual file creation to ensure atomicity.
|
|
175
|
+
// The safe-paths validation and the file creation are two separate operations
|
|
176
|
+
// with a gap between them — callers must close this gap with atomic primitives.
|
|
177
|
+
throw new Error(`Cannot resolve real path of base directory ${baseDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
178
|
+
} finally {
|
|
179
|
+
fs.closeSync(baseFd);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Walk the ancestor chain of the resolved target path, using O_NOFOLLOW
|
|
183
|
+
// on each ancestor to atomically validate none are symlinks.
|
|
184
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW;
|
|
185
|
+
const O_RDONLY = fs.constants.O_RDONLY;
|
|
186
|
+
const resolvedParts = resolved.split(path.sep);
|
|
187
|
+
let resolvedAccumulated = "";
|
|
188
|
+
if (resolvedParts[0] === "") resolvedAccumulated = "/"; // Unix root
|
|
189
|
+
for (let i = 1; i < resolvedParts.length; i++) {
|
|
190
|
+
if (resolvedParts[i] === "") continue;
|
|
191
|
+
resolvedAccumulated = path.join(resolvedAccumulated, resolvedParts[i]);
|
|
192
|
+
try {
|
|
193
|
+
const fd = fs.openSync(resolvedAccumulated, O_RDONLY | O_NOFOLLOW);
|
|
194
|
+
fs.closeSync(fd);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if ((error as NodeJS.ErrnoException).code === "ELOOP") {
|
|
197
|
+
// On macOS, /var → /private/var, /tmp → /private/tmp, /etc → /private/etc
|
|
198
|
+
// are system symlinks managed by the OS. Allow them.
|
|
199
|
+
if (process.platform === "darwin") {
|
|
200
|
+
const resolvedSymlink = resolvedAccumulated;
|
|
201
|
+
const knownDarwinSymlinks = ["/var", "/tmp", "/etc", "/private/var", "/private/tmp", "/private/etc"];
|
|
202
|
+
if (knownDarwinSymlinks.includes(resolvedSymlink)) continue;
|
|
203
|
+
}
|
|
204
|
+
throw new Error("Refusing to resolve: target path ancestor is a symlink: " + resolvedAccumulated);
|
|
205
|
+
}
|
|
206
|
+
// EPERM on Windows when opening a directory — skip validation
|
|
207
|
+
if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") continue;
|
|
208
|
+
// ENOENT means component doesn't exist — that's OK. Only existing symlinks
|
|
209
|
+
// are a security risk (symlinks to attacker-controlled targets). Non-existent
|
|
210
|
+
// paths can be created by the caller and don't pose a symlink risk.
|
|
211
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
212
|
+
// For the final component (target itself), ENOENT is expected for non-existent targets.
|
|
213
|
+
if (i === resolvedParts.length - 1) continue;
|
|
214
|
+
// For non-final components (parent directories), ENOENT is also acceptable —
|
|
215
|
+
// the caller will create them before the write operation if needed.
|
|
216
|
+
// We only need to ensure no existing path component is a symlink.
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Open the target with O_NOFOLLOW to catch any symlinks.
|
|
222
|
+
// ENOENT is acceptable for write operations — the file may not exist yet.
|
|
223
|
+
let targetFd: number;
|
|
224
|
+
try {
|
|
225
|
+
targetFd = fs.openSync(resolved, O_RDONLY | O_NOFOLLOW);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Refusing to resolve: target path is a symlink: " + resolved);
|
|
228
|
+
// EPERM on Windows when opening a directory — treat as non-existent
|
|
229
|
+
if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") return resolved;
|
|
230
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
231
|
+
// Target doesn't exist yet — that's OK for write operations.
|
|
232
|
+
// All ancestors have been validated above (no symlinks).
|
|
233
|
+
// The caller will create the file with atomic primitives.
|
|
234
|
+
return resolved;
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Cannot open ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
|
|
32
237
|
}
|
|
238
|
+
|
|
239
|
+
let realTarget: string;
|
|
33
240
|
try {
|
|
34
|
-
|
|
241
|
+
// Use regular realpathSync (not .native) to preserve input path form.
|
|
242
|
+
realTarget = fs.realpathSync(resolved);
|
|
35
243
|
} catch (targetError) {
|
|
36
244
|
if ((targetError as NodeJS.ErrnoException).code === "ENOENT") {
|
|
37
|
-
|
|
245
|
+
// Target doesn't exist yet — this is OK for write operations.
|
|
246
|
+
// Return the resolved path so the caller can create the file.
|
|
247
|
+
// We already validated all ancestors are not symlinks above.
|
|
248
|
+
return resolved;
|
|
38
249
|
}
|
|
39
250
|
throw new Error(`Cannot resolve real path of ${resolved}: ${targetError instanceof Error ? targetError.message : String(targetError)}`);
|
|
251
|
+
} finally {
|
|
252
|
+
fs.closeSync(targetFd);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Re-validate the ancestor chain of the resolved path to catch any TOCTOU
|
|
256
|
+
// races that occurred between the initial O_NOFOLLOW validation and the
|
|
257
|
+
// realpathSync call. An attacker could have replaced a validated ancestor
|
|
258
|
+
// with a symlink during this window.
|
|
259
|
+
//
|
|
260
|
+
// Skip the final path component (realTarget itself) — we just successfully
|
|
261
|
+
// realpathSync'd it, so it exists. Re-validating it can spuriously fail on
|
|
262
|
+
// Windows where the resolved path uses short-name (8.3) form that
|
|
263
|
+
// openSync cannot reopen, or where the realpathSync result differs in
|
|
264
|
+
// case/separator form from the original.
|
|
265
|
+
//
|
|
266
|
+
// Walk via path.dirname which is portable across all platforms and
|
|
267
|
+
// correctly handles extended-length (\\?\), UNC (\\server\share), and
|
|
268
|
+
// short-name paths on Windows without manual parsing.
|
|
269
|
+
let ancestor = path.dirname(realTarget);
|
|
270
|
+
while (ancestor && ancestor !== path.dirname(ancestor)) {
|
|
271
|
+
try {
|
|
272
|
+
const fd = fs.openSync(ancestor, O_RDONLY | O_NOFOLLOW);
|
|
273
|
+
fs.closeSync(fd);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const errCode = (error as NodeJS.ErrnoException).code;
|
|
276
|
+
if (errCode === "ELOOP") throw new Error("Refusing to resolve: TOCTOU race detected, path became a symlink: " + ancestor);
|
|
277
|
+
// Windows: EPERM can occur when opening system directories (e.g. C:\)
|
|
278
|
+
// or NTFS internal paths ($Extend/$Deleted). Skip and continue walking.
|
|
279
|
+
if (process.platform === "win32" && errCode === "EPERM") {
|
|
280
|
+
if (ancestor.includes("$Extend") || ancestor.includes("$Deleted")) {
|
|
281
|
+
// NTFS internal path — stop walking, we've reached the filesystem root
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
// System directory — continue walking up
|
|
285
|
+
ancestor = path.dirname(ancestor);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (errCode !== "ENOENT") throw error;
|
|
289
|
+
// ENOENT on an ancestor of realTarget after realpathSync is concerning
|
|
290
|
+
// — the path existed when we validated it but now doesn't. This could
|
|
291
|
+
// indicate a race or attack. For safety, treat this as an error.
|
|
292
|
+
throw new Error(`Cannot validate resolved path: ${ancestor} disappeared after realpathSync: ${error instanceof Error ? error.message : String(error)}`);
|
|
293
|
+
}
|
|
294
|
+
ancestor = path.dirname(ancestor);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Verify the resolved real path is still within baseDir.
|
|
298
|
+
// On Windows, realpathSync.native may return different short/long-name forms
|
|
299
|
+
// for the same physical directory depending on how the path was opened.
|
|
300
|
+
// Use resolveWindowsCanonical (same as resolveContainedPath) to normalize
|
|
301
|
+
// both paths consistently before comparison.
|
|
302
|
+
if (process.platform === "win32") {
|
|
303
|
+
const normBase = resolveWindowsCanonical(realBase).replace(/\\/g, "/").toLowerCase();
|
|
304
|
+
const normTarget = resolveWindowsCanonical(realTarget).replace(/\\/g, "/").toLowerCase();
|
|
305
|
+
if (!normTarget.startsWith(normBase + "/") && normBase !== normTarget) {
|
|
306
|
+
throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
const relative = path.relative(realBase, realTarget);
|
|
310
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
40
311
|
}
|
|
41
|
-
const relative = path.relative(realBase, realTarget);
|
|
42
|
-
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
43
312
|
return realTarget;
|
|
44
313
|
}
|
|
45
314
|
|
|
@@ -47,7 +316,9 @@ export function resolveContainedRelativePath(baseDir: string, relativePath: stri
|
|
|
47
316
|
if (relativePath.includes('\0')) {
|
|
48
317
|
throw new Error(`Security: path contains null byte: ${kind}`);
|
|
49
318
|
}
|
|
50
|
-
const normalized = relativePath.
|
|
319
|
+
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
320
|
+
// Detect Windows absolute paths (C:\, \\server\share) that path.isAbsolute may miss after normalization
|
|
321
|
+
if (/^[A-Za-z]:/.test(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
|
|
51
322
|
if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
|
|
52
323
|
return resolveContainedPath(baseDir, path.resolve(baseDir, normalized));
|
|
53
324
|
}
|