@goodandready/dsh-approval-gate 0.1.16 → 0.1.18

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/lib/paths.js CHANGED
@@ -1,320 +1,378 @@
1
- import fs from "node:fs";
2
- // lib/paths.js
3
- // Cross-platform path normalization, home expansion, canonicalization, and path boundary traversal protection for dsh-approval-gate.
4
-
5
- const PROTECTED_BASENAMES = new Set([
6
- ".bashrc",
7
- ".bash_profile",
8
- ".bash_login",
9
- ".profile",
10
- ".zshrc",
11
- ".zprofile",
12
- ".zshenv",
13
- ".cshrc",
14
- "cordis.patch.yml",
15
- ]);
16
-
17
- /**
18
- * Normalizes Windows and POSIX path separators to uniform forward slashes.
19
- * Collapses redundant slashes and standardizes Windows drive letters.
20
- *
21
- * @param {string} p
22
- * @returns {string}
23
- */
24
- export function normalizeSeparators(p) {
25
- if (typeof p !== "string") return "";
26
- let s = p.trim();
27
- if (s === "") return "";
28
-
29
- // Check UNC network share (e.g. \\server\share or //server/share)
30
- const isUnc = /^([\\/]{2})([^\\/]+)/.test(s);
31
-
32
- // Convert all backslashes to forward slashes
33
- s = s.replace(/\\/g, "/");
34
-
35
- // Collapse multiple consecutive slashes
36
- if (isUnc) {
37
- s = "//" + s.slice(2).replace(/\/+/g, "/");
38
- } else {
39
- s = s.replace(/\/+/g, "/");
40
- }
41
-
42
- // Normalize Windows drive letter: c:/ -> C:/
43
- s = s.replace(/^([a-zA-Z]):(\/|$)/, (_, drive, rest) => `${drive.toUpperCase()}:${rest}`);
44
-
45
- // Strip trailing slash unless root / or C:/
46
- if (s.length > 1 && s.endsWith("/")) {
47
- if (!/^(?:\/|[A-Za-z]:\/)$/.test(s)) {
48
- s = s.slice(0, -1);
49
- }
50
- }
51
-
52
- return s;
53
- }
54
-
55
- /**
56
- * Expand `~`, `${HOME}`, `$HOME`, and `%USERPROFILE%` at the start of or within a path.
57
- *
58
- * @param {string} p - Path or string containing path references
59
- * @param {Record<string, string|undefined>} [env] - Environment dictionary (defaults to process.env)
60
- * @returns {string} Normalized path with home expanded
61
- */
62
- export function expandHome(p, env = process.env) {
63
- if (typeof p !== "string" || p.trim() === "") return p;
64
- const home = (env && (env.HOME || env.USERPROFILE)) || "";
65
- if (!home) return p;
66
- const cleanHome = home.replace(/[/\\]+$/, "");
67
-
68
- let res = p;
69
- // Expand leading ~ or ~/ or ~\
70
- res = res.replace(/^~(?=$|[/\\])/, cleanHome);
71
- // Expand ${HOME} and $HOME
72
- res = res.replace(/\$\{HOME\}|\$HOME(?=\$|[\/\\])/g, cleanHome);
73
- // Expand %USERPROFILE% (Windows)
74
- res = res.replace(/%USERPROFILE%/gi, cleanHome);
75
- return res;
76
- }
77
-
78
- /**
79
- * Canonicalizes a path resolving dot and dot-dot segments without disk I/O.
80
- *
81
- * @param {string} p
82
- * @param {{ env?: Record<string, string|undefined> }} [options]
83
- * @returns {string}
84
- */
85
- export function canonicalizeFsPath(p, options = {}) {
86
- if (typeof p !== "string") return "";
87
- let s = expandHome(p, options.env);
88
- s = normalizeSeparators(s);
89
- if (!s) return "";
90
-
91
- // Check prefix: / (root), C:/ (Windows root), //server/share (UNC), or relative
92
- let prefix = "";
93
- if (s.startsWith("//")) {
94
- const m = s.match(/^(\/\/[^/]+\/[^/]+)(?:\/|$)/);
95
- if (m) {
96
- prefix = m[1];
97
- s = s.slice(prefix.length);
98
- }
99
- } else if (/^[A-Za-z]:\//.test(s)) {
100
- prefix = s.slice(0, 3);
101
- s = s.slice(3);
102
- } else if (s.startsWith("/")) {
103
- prefix = "/";
104
- s = s.slice(1);
105
- }
106
-
107
- const rawSegments = s.split("/");
108
- const stack = [];
109
-
110
- for (const seg of rawSegments) {
111
- if (seg === "" || seg === ".") continue;
112
- if (seg === "..") {
113
- if (stack.length > 0 && stack[stack.length - 1] !== "..") {
114
- stack.pop();
115
- } else if (!prefix) {
116
- stack.push("..");
117
- }
118
- // If there is an absolute prefix (e.g. / or C:/), .. at root cannot escape above root
119
- } else {
120
- stack.push(seg);
121
- }
122
- }
123
-
124
- const resolvedBody = stack.join("/");
125
- if (!prefix) {
126
- return resolvedBody || ".";
127
- }
128
- if (!resolvedBody) {
129
- return prefix;
130
- }
131
- return prefix.endsWith("/") ? `${prefix}${resolvedBody}` : `${prefix}/${resolvedBody}`;
132
- }
133
-
134
- /**
135
- * Checks whether a path contains directory traversal sequences attempting to escape baseDir.
136
- *
137
- * @param {string} p
138
- * @param {string} [baseDir]
139
- * @returns {boolean}
140
- */
141
- export function isPathTraversal(p, baseDir = "/workspace") {
142
- if (typeof p !== "string" || p.trim() === "") return false;
143
-
144
- // Detect null byte injection
145
- if (p.includes("\0") || /%00/i.test(p)) return true;
146
-
147
- // Detect encoded traversal sequences
148
- if (/(?:%2e|%2f|%5c|\.\.)(?:%2e|%2f|%5c|\.\.)/i.test(p)) return true;
149
-
150
- const norm = normalizeSeparators(p);
151
- if (/^\/dev\/(?:null|zero|stdout|stderr|fd\/\d+)$/.test(norm)) {
152
- return false;
153
- }
154
-
155
- const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(norm);
156
-
157
- const normBase = canonicalizeFsPath(baseDir);
158
- if (isAbsolute) {
159
- // For absolute path, it is a traversal only if caller specifies a strict baseDir and candidate is outside
160
- const candidate = canonicalizeFsPath(p);
161
- if (baseDir && baseDir !== "/" && normBase !== "/") {
162
- if (candidate === normBase) return false;
163
- return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
164
- }
165
- return false;
166
- }
167
-
168
- // Relative path traversal checking
169
- const candidate = canonicalizeFsPath(`${normBase}/${p}`);
170
- if (candidate === normBase) return false;
171
- return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
172
- }
173
-
174
- /**
175
- * Safely resolves a path relative to a base directory, verifying boundaries.
176
- *
177
- * @param {string} p
178
- * @param {string} [baseDir]
179
- * @returns {{ safe: boolean, path: string, canonical: string, reason?: string }}
180
- */
181
- export function resolveSafePath(p, baseDir = "/workspace") {
182
- if (typeof p !== "string" || p.trim() === "") {
183
- return { safe: false, reason: "empty-path", path: p, canonical: "" };
184
- }
185
-
186
- const normBase = canonicalizeFsPath(baseDir);
187
-
188
- if (isPathTraversal(p, baseDir)) {
189
- return {
190
- safe: false,
191
- reason: "path-traversal-escape",
192
- path: p,
193
- canonical: canonicalizeFsPath(p),
194
- };
195
- }
196
-
197
- const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
198
- const resolved = isAbsolute ? canonicalizeFsPath(p) : canonicalizeFsPath(`${normBase}/${p}`);
199
-
200
- if (isAbsolute && baseDir && normBase !== "/" && !resolved.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`) && resolved !== normBase) {
201
- return {
202
- safe: false,
203
- reason: "path-traversal-escape",
204
- path: p,
205
- canonical: resolved,
206
- };
207
- }
208
-
209
- // Inspect filesystem symlinks if the target file or its parent directory exists
210
- try {
211
- if (fs.existsSync(resolved)) {
212
- const real = fs.realpathSync(resolved);
213
- const normReal = canonicalizeFsPath(real);
214
- if (!normReal.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`) && normReal !== normBase) {
215
- return {
216
- safe: false,
217
- reason: "symlink-traversal-escape",
218
- path: p,
219
- canonical: normReal,
220
- };
221
- }
222
- return {
223
- safe: true,
224
- path: p,
225
- canonical: normReal,
226
- };
227
- }
228
- } catch {
229
- // Non-filesystem paths or missing directories fall back safely to lexical canonical
230
- }
231
-
232
- return {
233
- safe: true,
234
- path: p,
235
- canonical: resolved,
236
- };
237
- }
238
-
239
- /**
240
- * Checks whether a path targets protected system, git, ssh, or secret configuration.
241
- *
242
- * @param {string} p
243
- * @param {Record<string, string|undefined>} [env]
244
- * @returns {boolean}
245
- */
246
- export function isProtectedConfigPath(p, env = process.env) {
247
- if (typeof p !== "string" || p.trim() === "") return false;
248
-
249
- const expanded = expandHome(p, env);
250
- const normalized = canonicalizeFsPath(expanded);
251
- const lower = normalized.toLowerCase();
252
-
253
- // 1. .git internal tree
254
- if (
255
- lower === ".git" ||
256
- lower.startsWith(".git/") ||
257
- lower.endsWith("/.git") ||
258
- lower.includes("/.git/")
259
- ) {
260
- return true;
261
- }
262
-
263
- // 2. .ssh directory and key files
264
- if (
265
- lower === ".ssh" ||
266
- lower.startsWith(".ssh/") ||
267
- lower.endsWith("/.ssh") ||
268
- lower.includes("/.ssh/")
269
- ) {
270
- return true;
271
- }
272
-
273
- // 3. System auth and security files
274
- if (
275
- lower === "/etc/sudoers" ||
276
- lower.includes("/etc/sudoers.d/") ||
277
- lower === "/etc/shadow" ||
278
- lower === "/etc/gshadow" ||
279
- lower.includes("/etc/pam.d/")
280
- ) {
281
- return true;
282
- }
283
-
284
- // 4. Shell startup files
285
- const parts = normalized.split("/");
286
- const base = parts[parts.length - 1];
287
- const baseLower = base.toLowerCase();
288
-
289
- if (PROTECTED_BASENAMES.has(baseLower)) {
290
- return true;
291
- }
292
-
293
- // 5. Env files (.env, .env.local, .env.prod)
294
- if (baseLower === ".env" || baseLower.startsWith(".env.")) {
295
- return true;
296
- }
297
-
298
- // 6. Credentials and settings
299
- if (/^credentials\.ya?ml$/i.test(base) || /^settings\.ya?ml$/i.test(base)) {
300
- return true;
301
- }
302
-
303
- // 7. Certificates and keys
304
- if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) {
305
- return true;
306
- }
307
-
308
- // 8. SSH key filenames anywhere
309
- if (/^(id_rsa|id_ed25519|id_ecdsa|authorized_keys2?)$/i.test(base)) {
310
- return true;
311
- }
312
-
313
- // 9. Generic secrets / tokens / passwords
314
- if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(baseLower)) {
315
- return true;
316
- }
317
-
318
- return false;
319
- }
320
-
1
+ import fs from "node:fs";
2
+ // lib/paths.js
3
+ // Cross-platform path normalization, home expansion, canonicalization, and path boundary traversal protection for dsh-approval-gate.
4
+
5
+ const PROTECTED_BASENAMES = new Set([
6
+ ".bashrc",
7
+ ".bash_profile",
8
+ ".bash_login",
9
+ ".profile",
10
+ ".zshrc",
11
+ ".zprofile",
12
+ ".zshenv",
13
+ ".cshrc",
14
+ "cordis.patch.yml",
15
+ ]);
16
+
17
+ /**
18
+ * Normalizes Windows and POSIX path separators to uniform forward slashes.
19
+ * Collapses redundant slashes and standardizes Windows drive letters.
20
+ *
21
+ * @param {string} p
22
+ * @returns {string}
23
+ */
24
+ export function normalizeSeparators(p) {
25
+ if (typeof p !== "string") return "";
26
+ let s = p.trim();
27
+ if (s === "") return "";
28
+
29
+ // Check UNC network share (e.g. \\server\share or //server/share)
30
+ const isUnc = /^([\\/]{2})([^\\/]+)/.test(s);
31
+
32
+ // Convert all backslashes to forward slashes
33
+ s = s.replace(/\\/g, "/");
34
+
35
+ // Collapse multiple consecutive slashes
36
+ if (isUnc) {
37
+ s = "//" + s.slice(2).replace(/\/+/g, "/");
38
+ } else {
39
+ s = s.replace(/\/+/g, "/");
40
+ }
41
+
42
+ // Normalize Windows drive letter: c:/ -> C:/
43
+ s = s.replace(/^([a-zA-Z]):(\/|$)/, (_, drive, rest) => `${drive.toUpperCase()}:${rest}`);
44
+
45
+ // Strip trailing slash unless root / or C:/
46
+ if (s.length > 1 && s.endsWith("/")) {
47
+ if (!/^(?:\/|[A-Za-z]:\/)$/.test(s)) {
48
+ s = s.slice(0, -1);
49
+ }
50
+ }
51
+
52
+ return s;
53
+ }
54
+
55
+ /**
56
+ * Expand `~`, `${HOME}`, `$HOME`, and `%USERPROFILE%` at the start of or within a path.
57
+ *
58
+ * @param {string} p - Path or string containing path references
59
+ * @param {Record<string, string|undefined>} [env] - Environment dictionary (defaults to process.env)
60
+ * @returns {string} Normalized path with home expanded
61
+ */
62
+ export function expandHome(p, env = process.env) {
63
+ if (typeof p !== "string" || p.trim() === "") return p;
64
+ const home = (env && (env.HOME || env.USERPROFILE)) || "";
65
+ if (!home) return p;
66
+ const cleanHome = home.replace(/[/\\]+$/, "");
67
+
68
+ let res = p;
69
+ // Expand leading ~ or ~/ or ~\
70
+ res = res.replace(/^~(?=$|[/\\])/, cleanHome);
71
+ // Expand ${HOME} and $HOME
72
+ res = res.replace(/\$\{HOME\}|\$HOME(?=\$|[\/\\])/g, cleanHome);
73
+ // Expand %USERPROFILE% (Windows)
74
+ res = res.replace(/%USERPROFILE%/gi, cleanHome);
75
+ return res;
76
+ }
77
+
78
+ /**
79
+ * Canonicalizes a path resolving dot and dot-dot segments without disk I/O.
80
+ *
81
+ * @param {string} p
82
+ * @param {{ env?: Record<string, string|undefined> }} [options]
83
+ * @returns {string}
84
+ */
85
+ export function canonicalizeFsPath(p, options = {}) {
86
+ if (typeof p !== "string") return "";
87
+ let s = expandHome(p, options.env);
88
+ s = normalizeSeparators(s);
89
+ if (!s) return "";
90
+
91
+ // Check prefix: / (root), C:/ (Windows root), //server/share (UNC), or relative
92
+ let prefix = "";
93
+ if (s.startsWith("//")) {
94
+ const m = s.match(/^(\/\/[^/]+\/[^/]+)(?:\/|$)/);
95
+ if (m) {
96
+ prefix = m[1];
97
+ s = s.slice(prefix.length);
98
+ }
99
+ } else if (/^[A-Za-z]:\//.test(s)) {
100
+ prefix = s.slice(0, 3);
101
+ s = s.slice(3);
102
+ } else if (s.startsWith("/")) {
103
+ prefix = "/";
104
+ s = s.slice(1);
105
+ }
106
+
107
+ const rawSegments = s.split("/");
108
+ const stack = [];
109
+
110
+ for (const seg of rawSegments) {
111
+ if (seg === "" || seg === ".") continue;
112
+ if (seg === "..") {
113
+ if (stack.length > 0 && stack[stack.length - 1] !== "..") {
114
+ stack.pop();
115
+ } else if (!prefix) {
116
+ stack.push("..");
117
+ }
118
+ // If there is an absolute prefix (e.g. / or C:/), .. at root cannot escape above root
119
+ } else {
120
+ stack.push(seg);
121
+ }
122
+ }
123
+
124
+ const resolvedBody = stack.join("/");
125
+ if (!prefix) {
126
+ return resolvedBody || ".";
127
+ }
128
+ if (!resolvedBody) {
129
+ return prefix;
130
+ }
131
+ return prefix.endsWith("/") ? `${prefix}${resolvedBody}` : `${prefix}/${resolvedBody}`;
132
+ }
133
+
134
+ /**
135
+ * Checks whether a path contains directory traversal sequences attempting to escape baseDir.
136
+ *
137
+ * @param {string} p
138
+ * @param {string} [baseDir]
139
+ * @returns {boolean}
140
+ */
141
+ export function isPathTraversal(p, baseDir = "/workspace") {
142
+ if (typeof p !== "string" || p.trim() === "") return false;
143
+
144
+ // Detect null byte injection
145
+ if (p.includes("\0") || /%00/i.test(p)) return true;
146
+
147
+ // Detect encoded traversal sequences
148
+ if (/(?:%2e|%2f|%5c|\.\.)(?:%2e|%2f|%5c|\.\.)/i.test(p)) return true;
149
+
150
+ const norm = normalizeSeparators(p);
151
+ if (/^\/dev\/(?:null|zero|stdout|stderr|fd\/\d+)$/.test(norm)) {
152
+ return false;
153
+ }
154
+
155
+ const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(norm);
156
+
157
+ const normBase = canonicalizeFsPath(baseDir);
158
+ if (isAbsolute) {
159
+ // For absolute path, it is a traversal only if caller specifies a strict baseDir and candidate is outside
160
+ const candidate = canonicalizeFsPath(p);
161
+ if (baseDir && baseDir !== "/" && normBase !== "/") {
162
+ if (candidate === normBase) return false;
163
+ return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
164
+ }
165
+ return false;
166
+ }
167
+
168
+ // Relative path traversal checking
169
+ const candidate = canonicalizeFsPath(`${normBase}/${p}`);
170
+ if (candidate === normBase) return false;
171
+ return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
172
+ }
173
+
174
+ /**
175
+ * Safely resolves a path relative to a base directory, verifying boundaries.
176
+ *
177
+ * @param {string} p
178
+ * @param {string} [baseDir]
179
+ * @returns {{ safe: boolean, path: string, canonical: string, reason?: string }}
180
+ */
181
+ export function resolveSafePath(p, baseDir = "/workspace") {
182
+ if (typeof p !== "string" || p.trim() === "") {
183
+ return { safe: false, reason: "empty-path", path: p, canonical: "" };
184
+ }
185
+
186
+ const normBase = canonicalizeFsPath(baseDir);
187
+
188
+ if (isPathTraversal(p, baseDir)) {
189
+ return {
190
+ safe: false,
191
+ reason: "path-traversal-escape",
192
+ path: p,
193
+ canonical: canonicalizeFsPath(p),
194
+ };
195
+ }
196
+
197
+ const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
198
+ const resolved = isAbsolute ? canonicalizeFsPath(p) : canonicalizeFsPath(`${normBase}/${p}`);
199
+
200
+ if (isAbsolute && baseDir && normBase !== "/" && !resolved.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`) && resolved !== normBase) {
201
+ return {
202
+ safe: false,
203
+ reason: "path-traversal-escape",
204
+ path: p,
205
+ canonical: resolved,
206
+ };
207
+ }
208
+
209
+ // Inspect filesystem symlinks if the target file or its parent directory exists
210
+ try {
211
+ if (fs.existsSync(resolved)) {
212
+ const real = fs.realpathSync(resolved);
213
+ const normReal = canonicalizeFsPath(real);
214
+ if (!normReal.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`) && normReal !== normBase) {
215
+ return {
216
+ safe: false,
217
+ reason: "symlink-traversal-escape",
218
+ path: p,
219
+ canonical: normReal,
220
+ };
221
+ }
222
+ return {
223
+ safe: true,
224
+ path: p,
225
+ canonical: normReal,
226
+ };
227
+ }
228
+ } catch {
229
+ // Non-filesystem paths or missing directories fall back safely to lexical canonical
230
+ }
231
+
232
+ return {
233
+ safe: true,
234
+ path: p,
235
+ canonical: resolved,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Checks whether a path targets protected system, git, ssh, or secret configuration.
241
+ *
242
+ * @param {string} p
243
+ * @param {Record<string, string|undefined>} [env]
244
+ * @returns {boolean}
245
+ */
246
+ export function isProtectedConfigPath(p, env = process.env) {
247
+ if (typeof p !== "string" || p.trim() === "") return false;
248
+
249
+ const expanded = expandHome(p, env);
250
+ const normalized = canonicalizeFsPath(expanded);
251
+ const lower = normalized.toLowerCase();
252
+
253
+ // 1. .git internal tree
254
+ if (
255
+ lower === ".git" ||
256
+ lower.startsWith(".git/") ||
257
+ lower.endsWith("/.git") ||
258
+ lower.includes("/.git/")
259
+ ) {
260
+ return true;
261
+ }
262
+
263
+ // 2. .ssh directory and key files
264
+ if (
265
+ lower === ".ssh" ||
266
+ lower.startsWith(".ssh/") ||
267
+ lower.endsWith("/.ssh") ||
268
+ lower.includes("/.ssh/")
269
+ ) {
270
+ return true;
271
+ }
272
+
273
+ // 3. System auth and security files
274
+ if (
275
+ lower === "/etc/sudoers" ||
276
+ lower.includes("/etc/sudoers.d/") ||
277
+ lower === "/etc/shadow" ||
278
+ lower === "/etc/gshadow" ||
279
+ lower.includes("/etc/pam.d/")
280
+ ) {
281
+ return true;
282
+ }
283
+
284
+ // 4. Shell startup files
285
+ const parts = normalized.split("/");
286
+ const base = parts[parts.length - 1];
287
+ const baseLower = base.toLowerCase();
288
+
289
+ if (PROTECTED_BASENAMES.has(baseLower)) {
290
+ return true;
291
+ }
292
+
293
+ // 5. Env files (.env, .env.local, .env.prod)
294
+ if (baseLower === ".env" || baseLower.startsWith(".env.")) {
295
+ return true;
296
+ }
297
+
298
+ // 6. Credentials and settings
299
+ if (/^credentials\.ya?ml$/i.test(base) || /^settings\.ya?ml$/i.test(base)) {
300
+ return true;
301
+ }
302
+
303
+ // 7. Certificates and keys
304
+ if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) {
305
+ return true;
306
+ }
307
+
308
+ // 8. SSH key filenames anywhere
309
+ if (/^(id_rsa|id_ed25519|id_ecdsa|authorized_keys2?)$/i.test(base)) {
310
+ return true;
311
+ }
312
+
313
+ // 9. Generic secrets / tokens / passwords
314
+ if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(baseLower)) {
315
+ return true;
316
+ }
317
+
318
+ return false;
319
+ }
320
+
321
+ /**
322
+ * Resolves the isolated project-specific temporary root directory (#102).
323
+ *
324
+ * @param {string} [workspaceDir]
325
+ * @returns {string}
326
+ */
327
+ export function resolveTempRoot(workspaceDir) {
328
+ const ws = normalizeSeparators(workspaceDir || process.cwd());
329
+ return `${ws}/.dsh/tmp`;
330
+ }
331
+
332
+ /**
333
+ * Checks if a path targets global host temporary directories (#102).
334
+ *
335
+ * @param {string} p
336
+ * @returns {boolean}
337
+ */
338
+ export function isGlobalTempPath(p) {
339
+ if (typeof p !== "string") return false;
340
+ const s = normalizeSeparators(p);
341
+ const lower = s.toLowerCase();
342
+ return (
343
+ lower === "/tmp" ||
344
+ lower.startsWith("/tmp/") ||
345
+ lower === "/var/tmp" ||
346
+ lower.startsWith("/var/tmp/") ||
347
+ lower === "/dev/shm" ||
348
+ lower.startsWith("/dev/shm/") ||
349
+ /^[a-z]:\/windows\/temp(\/|$)/i.test(s)
350
+ );
351
+ }
352
+
353
+ /**
354
+ * Checks whether a file write target complies with project tempRoots isolation (#102).
355
+ *
356
+ * @param {string} targetPath
357
+ * @param {string} [workspaceDir]
358
+ * @returns {{ isolated: boolean, suggestedPath?: string, reason?: string }}
359
+ */
360
+ export function checkTempIsolation(targetPath, workspaceDir) {
361
+ if (!isGlobalTempPath(targetPath)) {
362
+ return { isolated: true };
363
+ }
364
+ const ws = workspaceDir || process.cwd();
365
+ const tempRoot = resolveTempRoot(ws);
366
+ const norm = normalizeSeparators(targetPath);
367
+ if (norm.startsWith(tempRoot)) {
368
+ return { isolated: true };
369
+ }
370
+ const parts = norm.split("/").filter(Boolean);
371
+ const filename = parts.pop() || "tempfile";
372
+ return {
373
+ isolated: false,
374
+ suggestedPath: `${tempRoot}/${filename}`,
375
+ reason: `Target path ${targetPath} targets global /tmp; should use project tempRoot: ${tempRoot}`,
376
+ };
377
+ }
378
+