deel-local-cli 1.10.0 → 1.13.0
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/README.ko.md +1167 -1166
- package/README.md +1224 -1223
- package/bin/deel.js +1 -1
- package/package.json +2 -2
- package/src/acp/map.js +367 -362
- package/src/acp/serve.js +810 -769
- package/src/agent/budget.js +156 -153
- package/src/agent/commit.js +534 -511
- package/src/agent/compact.js +158 -31
- package/src/agent/effort.js +308 -197
- package/src/agent/evolve.js +263 -213
- package/src/agent/filemem.js +16 -2
- package/src/agent/loop.js +1728 -1516
- package/src/agent/memory.js +156 -152
- package/src/agent/mention.js +210 -210
- package/src/agent/recall.js +222 -209
- package/src/agent/session.js +1056 -948
- package/src/backend/adapter.js +1355 -798
- package/src/backend/cachemark.js +223 -0
- package/src/backend/detect.js +327 -288
- package/src/backend/http.js +460 -458
- package/src/backend/mcp.js +407 -395
- package/src/backend/price.js +260 -197
- package/src/backend/probe.js +487 -473
- package/src/backend/quota.js +274 -157
- package/src/backend/retry.js +30 -6
- package/src/backend/tokens.js +59 -0
- package/src/backend/toolfit.js +352 -325
- package/src/backend/wire.js +715 -0
- package/src/commands.js +3184 -2998
- package/src/config.js +283 -244
- package/src/i18n/en.js +545 -520
- package/src/i18n/ja.js +498 -473
- package/src/i18n/ko.js +592 -567
- package/src/i18n/zh.js +498 -473
- package/src/oneshot.js +625 -549
- package/src/pack/tar.js +154 -132
- package/src/pack/zip.js +235 -217
- package/src/plugins/manage.js +416 -336
- package/src/providers/bedrock.js +17 -0
- package/src/repl.js +2616 -2515
- package/src/safety/audit.js +148 -129
- package/src/safety/guard.js +641 -526
- package/src/safety/network.js +200 -157
- package/src/tools/fastgrep.js +229 -214
- package/src/tools/fsutil.js +291 -250
- package/src/tools/index.js +2560 -2523
- package/src/tools/jobs.js +876 -795
- package/src/tools/label.js +52 -0
- package/src/tools/spawn.js +213 -99
- package/src/tools/verify.js +358 -357
- package/src/tools/webfetch.js +418 -393
- package/src/ui/export.js +233 -217
- package/src/ui/pick.js +115 -115
- package/src/ui/status.js +617 -610
package/src/safety/guard.js
CHANGED
|
@@ -1,526 +1,641 @@
|
|
|
1
|
-
// 자율 실행의 울타리.
|
|
2
|
-
// 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
|
|
3
|
-
// (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
|
|
4
|
-
import { resolve, relative, isAbsolute, sep, dirname, basename } from 'node:path';
|
|
5
|
-
import { realpathSync, existsSync } from 'node:fs';
|
|
6
|
-
import { homedir } from 'node:os';
|
|
7
|
-
import { 내부살림 } from '../tools/fsutil.js';
|
|
8
|
-
|
|
9
|
-
export class ScopeError extends Error {}
|
|
10
|
-
export class BlockedError extends Error {}
|
|
11
|
-
|
|
12
|
-
/*
|
|
13
|
-
* ── 같은 이름이 두 가지 모양으로 온다 (맥의 한글 폴더) ──────────────────
|
|
14
|
-
*
|
|
15
|
-
* 맥은 파일 이름을 **자모 분리형(NFD)** 으로 저장한다. `챗` 은 디스크에
|
|
16
|
-
* `ᄎ + ᅢ + ᆺ` 세 글자로 들어가 있다. 반면 모델이 글로 쓰는 한글은 합쳐진
|
|
17
|
-
* 모양(NFC)이다. 눈에는 똑같이 보이는데 **자바스크립트 문자열로는 다르다.**
|
|
18
|
-
*
|
|
19
|
-
* '오후'.length → 2 (NFC, 모델이 쓴 것)
|
|
20
|
-
* '오후'.normalize('NFD') → 4 (맥이 준 것)
|
|
21
|
-
*
|
|
22
|
-
* 울타리는 이 둘을 글자로만 견줬다. 그래서 한글이 든 폴더에서 deel 을 켜면
|
|
23
|
-
* **제 작업 폴더 안의 파일이 전부 범위 밖으로 튕겼다.** 실제로 이런 화면이
|
|
24
|
-
* 나왔다 — 뿌리 자신을 가리키는데도:
|
|
25
|
-
*
|
|
26
|
-
* ❉ Outline(**\/*.py)
|
|
27
|
-
* └ 작업 범위 밖입니다: /Users/me/.Trash/Archive 오후 3.05.41
|
|
28
|
-
*
|
|
29
|
-
* 그러고는 같은 호출이 다음 판에 그냥 됐다. 어느 쪽 모양으로 오느냐에 따라
|
|
30
|
-
* 갈렸기 때문이다. 사람 눈에는 "됐다 안 됐다 하는" 것으로만 보인다.
|
|
31
|
-
*
|
|
32
|
-
* **견줄 때만** 한 모양으로 맞춘다. 돌려주는 경로는 원래 것 그대로 둔다 —
|
|
33
|
-
* 리눅스 파일시스템은 바이트를 그대로 보므로 NFC 와 NFD 가 **다른 파일**이고,
|
|
34
|
-
* 거기서 바꿔 버리면 멀쩡한 파일을 못 찾게 된다. 맥은 찾을 때 두 모양을
|
|
35
|
-
* 같은 것으로 보므로 원래 것을 그대로 넘겨도 열린다.
|
|
36
|
-
*/
|
|
37
|
-
const 같은꼴 = (s) => String(s).normalize('NFC');
|
|
38
|
-
|
|
39
|
-
/** 이 절대경로가 base 밖인가. */
|
|
40
|
-
function 밖(base, abs) {
|
|
41
|
-
const rel = relative(같은꼴(base), 같은꼴(abs));
|
|
42
|
-
return rel.startsWith('..' + sep) || rel === '..' || (isAbsolute(rel) && rel !== '');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* 링크를 따라간 진짜 자리.
|
|
47
|
-
*
|
|
48
|
-
* 윈도우에는 정션(junction)이 흔하다. 작업 폴더 안에 바깥을 가리키는 정션이
|
|
49
|
-
* 하나 있으면, 경로 글자만 봐서는 범위 안인데 실제로 닿는 곳은 바깥이다.
|
|
50
|
-
* 글자로만 검사하던 울타리가 그 한 칸으로 통째로 열린다.
|
|
51
|
-
*
|
|
52
|
-
* 아직 없는 파일도 검사해야 한다 — 새로 쓰는 자리가 그렇다. 그래서 있는
|
|
53
|
-
* 자리가 나올 때까지 위로 올라가 거기서 링크를 풀고, 남은 이름을 다시 붙인다.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
{ re:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
},
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
*
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
return
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (!
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
1
|
+
// 자율 실행의 울타리.
|
|
2
|
+
// 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
|
|
3
|
+
// (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
|
|
4
|
+
import { resolve, relative, isAbsolute, sep, dirname, basename } from 'node:path';
|
|
5
|
+
import { realpathSync, existsSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { 내부살림 } from '../tools/fsutil.js';
|
|
8
|
+
|
|
9
|
+
export class ScopeError extends Error {}
|
|
10
|
+
export class BlockedError extends Error {}
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* ── 같은 이름이 두 가지 모양으로 온다 (맥의 한글 폴더) ──────────────────
|
|
14
|
+
*
|
|
15
|
+
* 맥은 파일 이름을 **자모 분리형(NFD)** 으로 저장한다. `챗` 은 디스크에
|
|
16
|
+
* `ᄎ + ᅢ + ᆺ` 세 글자로 들어가 있다. 반면 모델이 글로 쓰는 한글은 합쳐진
|
|
17
|
+
* 모양(NFC)이다. 눈에는 똑같이 보이는데 **자바스크립트 문자열로는 다르다.**
|
|
18
|
+
*
|
|
19
|
+
* '오후'.length → 2 (NFC, 모델이 쓴 것)
|
|
20
|
+
* '오후'.normalize('NFD') → 4 (맥이 준 것)
|
|
21
|
+
*
|
|
22
|
+
* 울타리는 이 둘을 글자로만 견줬다. 그래서 한글이 든 폴더에서 deel 을 켜면
|
|
23
|
+
* **제 작업 폴더 안의 파일이 전부 범위 밖으로 튕겼다.** 실제로 이런 화면이
|
|
24
|
+
* 나왔다 — 뿌리 자신을 가리키는데도:
|
|
25
|
+
*
|
|
26
|
+
* ❉ Outline(**\/*.py)
|
|
27
|
+
* └ 작업 범위 밖입니다: /Users/me/.Trash/Archive 오후 3.05.41
|
|
28
|
+
*
|
|
29
|
+
* 그러고는 같은 호출이 다음 판에 그냥 됐다. 어느 쪽 모양으로 오느냐에 따라
|
|
30
|
+
* 갈렸기 때문이다. 사람 눈에는 "됐다 안 됐다 하는" 것으로만 보인다.
|
|
31
|
+
*
|
|
32
|
+
* **견줄 때만** 한 모양으로 맞춘다. 돌려주는 경로는 원래 것 그대로 둔다 —
|
|
33
|
+
* 리눅스 파일시스템은 바이트를 그대로 보므로 NFC 와 NFD 가 **다른 파일**이고,
|
|
34
|
+
* 거기서 바꿔 버리면 멀쩡한 파일을 못 찾게 된다. 맥은 찾을 때 두 모양을
|
|
35
|
+
* 같은 것으로 보므로 원래 것을 그대로 넘겨도 열린다.
|
|
36
|
+
*/
|
|
37
|
+
const 같은꼴 = (s) => String(s).normalize('NFC');
|
|
38
|
+
|
|
39
|
+
/** 이 절대경로가 base 밖인가. */
|
|
40
|
+
function 밖(base, abs) {
|
|
41
|
+
const rel = relative(같은꼴(base), 같은꼴(abs));
|
|
42
|
+
return rel.startsWith('..' + sep) || rel === '..' || (isAbsolute(rel) && rel !== '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 링크를 따라간 진짜 자리.
|
|
47
|
+
*
|
|
48
|
+
* 윈도우에는 정션(junction)이 흔하다. 작업 폴더 안에 바깥을 가리키는 정션이
|
|
49
|
+
* 하나 있으면, 경로 글자만 봐서는 범위 안인데 실제로 닿는 곳은 바깥이다.
|
|
50
|
+
* 글자로만 검사하던 울타리가 그 한 칸으로 통째로 열린다.
|
|
51
|
+
*
|
|
52
|
+
* 아직 없는 파일도 검사해야 한다 — 새로 쓰는 자리가 그렇다. 그래서 있는
|
|
53
|
+
* 자리가 나올 때까지 위로 올라가 거기서 링크를 풀고, 남은 이름을 다시 붙인다.
|
|
54
|
+
*
|
|
55
|
+
* ── 왜 native 를 먼저 부르나: 윈도우의 8.3 단축명 ─────────────────────
|
|
56
|
+
*
|
|
57
|
+
* 윈도우는 여덟 자가 넘는 이름에 `LONGDI~1` 같은 짧은 별명을 같이 만든다.
|
|
58
|
+
* `realpathSync` 는 링크는 풀지만 이 별명은 **안 편다.** 그래서 같은 자리를
|
|
59
|
+
* 한쪽은 `RUNNER~1`, 다른 쪽은 `runneradmin` 으로 부르게 되고, 견주면
|
|
60
|
+
* 남남이 된다. `realpathSync.native` 는 OS 에게 물어보므로 긴 이름으로 편다.
|
|
61
|
+
*
|
|
62
|
+
* 이게 남 일이 아니다 — 윈도우 사용자 이름이 여덟 자만 넘으면 `%TEMP%` 가
|
|
63
|
+
* 통째로 단축명이 된다(`C:\Users\RUNNER~1\AppData\Local\Temp`). git 은 긴
|
|
64
|
+
* 이름으로 답하는데 우리는 짧은 이름으로 들고 있으면, 같은 파일이 「저장소
|
|
65
|
+
* 밖」 으로 걸러진다. 여기 울타리에서는 막히는 쪽이라 안전하지만,
|
|
66
|
+
* agent/commit.js 에서는 **담을 것이 없다** 가 되어 저장소 전부로 물러섰다.
|
|
67
|
+
*
|
|
68
|
+
* native 가 없거나 실패하는 자리(옛 판·이상한 파일 시스템)에서는 여태 쓰던
|
|
69
|
+
* 것으로 물러선다. 두 자를 안 쓰는 것이 중요하지, 어느 쪽인지가 중요한 게
|
|
70
|
+
* 아니다 — 견주는 양쪽이 같은 자를 쓰기만 하면 된다.
|
|
71
|
+
*/
|
|
72
|
+
const 풀기 = (p) => {
|
|
73
|
+
try { return realpathSync.native(p); } catch { /* 없거나 못 물어보는 자리 */ }
|
|
74
|
+
return realpathSync(p);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function 진짜자리(p) {
|
|
78
|
+
const 남은 = [];
|
|
79
|
+
let cur = p;
|
|
80
|
+
for (let i = 0; i < 64; i++) {
|
|
81
|
+
try {
|
|
82
|
+
const r = 풀기(cur);
|
|
83
|
+
return 남은.length ? resolve(r, ...남은) : r;
|
|
84
|
+
} catch { /* 아직 없는 자리 — 한 칸 위로 */ }
|
|
85
|
+
const 위 = dirname(cur);
|
|
86
|
+
if (위 === cur) return p; // 뿌리까지 갔는데도 없다. 글자 그대로 본다.
|
|
87
|
+
남은.unshift(basename(cur));
|
|
88
|
+
cur = 위;
|
|
89
|
+
}
|
|
90
|
+
return p;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `/c/Users/…` → `C:/Users/…`. 윈도우에서만, 드라이브 글자 한 자짜리 첫 칸만. */
|
|
94
|
+
export function MSYS풀기(p) {
|
|
95
|
+
if (process.platform !== 'win32') return p;
|
|
96
|
+
const m = /^\/([a-zA-Z])(\/|$)/.exec(p);
|
|
97
|
+
return m ? `${m[1].toUpperCase()}:${p.slice(2) || '/'}` : p;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 작업 범위 — deel 를 띄운 폴더. 그 밖은 읽기도 쓰기도 막는다.
|
|
101
|
+
export function makeScope(root) {
|
|
102
|
+
const base = resolve(root);
|
|
103
|
+
// 뿌리 자신도 링크 아래 있을 수 있다(맥의 /tmp 가 그렇다). 그래서 양쪽 다 푼 값으로 견준다.
|
|
104
|
+
let _진짜뿌리 = null;
|
|
105
|
+
const 진짜뿌리 = () => (_진짜뿌리 ??= 진짜자리(base));
|
|
106
|
+
return {
|
|
107
|
+
root: base,
|
|
108
|
+
resolve(p) {
|
|
109
|
+
if (!p || typeof p !== 'string') throw new ScopeError('경로가 비었습니다');
|
|
110
|
+
// Git Bash 꼴 절대 경로(/c/Users/…)는 윈도우에서 C:\Users\… 다. 안 풀면 지금
|
|
111
|
+
// 드라이브 밑의 \c\Users\… 로 읽혀, 안에 있는 파일이 '범위 밖' 으로 막힌다.
|
|
112
|
+
const 경로 = MSYS풀기(p);
|
|
113
|
+
const abs = isAbsolute(경로) ? resolve(경로) : resolve(base, 경로);
|
|
114
|
+
if (밖(base, abs)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
|
|
115
|
+
// 글자로는 안인데 링크를 따라가면 밖인 자리. 정션 하나로 울타리가 열린다.
|
|
116
|
+
//
|
|
117
|
+
// 여기 견주기도 한 모양으로 맞춘다. 맥에서 realpathSync 는 디스크에 있는
|
|
118
|
+
// 자모 분리형을 돌려주므로, 안 맞추면 링크가 하나도 없는 평범한 한글
|
|
119
|
+
// 폴더가 매번 '링크를 따라간 자리가 다르다' 로 잡힌다.
|
|
120
|
+
const 진짜 = 진짜자리(abs);
|
|
121
|
+
if (같은꼴(진짜) !== 같은꼴(abs) && 밖(진짜뿌리(), 진짜)) {
|
|
122
|
+
throw new ScopeError(`작업 범위 밖입니다 (링크가 밖을 가리킵니다): ${p}\n 실제 자리: ${진짜}\n 범위: ${base}`);
|
|
123
|
+
}
|
|
124
|
+
return abs;
|
|
125
|
+
},
|
|
126
|
+
show(abs) {
|
|
127
|
+
// 여기도 맞춰야 한다. 안 맞추면 제 폴더 안의 파일이
|
|
128
|
+
// `../../../Users/me/Archive 오후/main.py` 처럼 보인다.
|
|
129
|
+
const rel = relative(같은꼴(base), 같은꼴(abs));
|
|
130
|
+
return rel === '' ? '.' : rel.split(sep).join('/');
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 되돌릴 수 없는 것만 막는다. 목록이 길어지면 도구가 쓸모없어진다.
|
|
136
|
+
const BLOCKED = [
|
|
137
|
+
// `/c` · `/c/` 는 Git Bash 에서 C:\ 다 — 윈도우에서 bash 를 쓰게 되면서 닿게 된 자리.
|
|
138
|
+
{ re: /\brm\s+(-[a-z]*[rR][a-z]*f|-[a-z]*f[a-z]*[rR])\b[^|;&]*\s(\/|\/[a-z]\/?|~|\$HOME)\s*$/i, why: '뿌리 폴더를 통째로 지우려 합니다' },
|
|
139
|
+
{ re: /\b(mkfs|fdisk|diskpart)\b/i, why: '디스크를 초기화하는 명령입니다' },
|
|
140
|
+
{ re: /\bformat\s+[a-z]:/i, why: '드라이브를 포맷하는 명령입니다' },
|
|
141
|
+
// 윈도우 짝을 빠뜨리면 안 된다 — deel 은 윈도우에서 주로 돈다.
|
|
142
|
+
//
|
|
143
|
+
// 옛 규칙은 스위치가 **차례대로** 오고 끝에 `\*` 가 붙은 모양만 봤다.
|
|
144
|
+
// 그래서 `del /f /s /q C:\` 와 `rd /q /s C:\` 가 그대로 새어 나갔다.
|
|
145
|
+
// 스위치는 아무 차례로나 오므로 차례를 안 따진다.
|
|
146
|
+
{ re: /\b(rd|rmdir)\b[^|;&]*\s\/s\b/i, why: '폴더를 통째로 지웁니다 — 정션이 있으면 원본까지 딸려 갑니다' },
|
|
147
|
+
{ re: /\bdel\b[^|;&]*\s\/[sq]\b[^|;&]*(\\\*|\s[a-z]:\\?\s*$)/i, why: '하위 폴더까지 전부 지웁니다' },
|
|
148
|
+
// 파워셸도 같은 일을 한다. 드라이브 뿌리나 집 폴더를 통째로 미는 것만 본다 —
|
|
149
|
+
// 폴더 하나 지우는 평범한 Remove-Item 까지 막으면 도구를 못 쓴다.
|
|
150
|
+
// 가운데 `(-?[a-z]*\s*)*` 는 양쪽 다 빈 문자열을 허용해서, 안 맞는 긴 입력이
|
|
151
|
+
// 들어오면 되돌아가는 경우의 수가 기하급수로 늘어난다 — guard 검사 자체가
|
|
152
|
+
// 멈춰 버린다(ReDoS). 각 겹이 최소 한 글자는 먹게 고쳐서 그 길을 막는다.
|
|
153
|
+
{
|
|
154
|
+
re: /Remove-Item\b[^|;&]*\s-(Recurse|r)\b[^|;&]*\s(?:-?[a-z]+\s+)*([a-z]:\\?|~|\$HOME|\$env:USERPROFILE)\s*$/i,
|
|
155
|
+
why: '드라이브나 집 폴더를 통째로 지웁니다',
|
|
156
|
+
},
|
|
157
|
+
{ re: /git\s+push\b[^|;&]*--force(?!-with-lease)/i, why: '원격 이력을 덮어씁니다 (--force-with-lease 를 쓰세요)' },
|
|
158
|
+
// 낱말만 보면 안 된다. `node scripts/shutdown.js` 나 `npm run reboot` 이
|
|
159
|
+
// '시스템을 끕니다' 로 막히고 감사기록에까지 남았다. 명령의 **첫 낱말**일 때만 본다.
|
|
160
|
+
{ re: /(^|[|;&]\s*)(sudo\s+)?(shutdown|reboot|halt|poweroff)\b/i, why: '시스템을 끕니다' },
|
|
161
|
+
// 포크 폭탄.
|
|
162
|
+
//
|
|
163
|
+
// 껐다 켜면 기계는 돌아오니 '되돌릴 수 없는 것' 은 아니다. 그런데 되돌아오지
|
|
164
|
+
// 않는 것이 하나 있다 — **아직 안 적은 것들.** 기계가 굳으면 열어 둔 것을
|
|
165
|
+
// 저장할 수도, 하던 대화를 끝낼 수도 없다. 그래서 목록에 넣는다.
|
|
166
|
+
//
|
|
167
|
+
// 이름은 아무거나 될 수 있으니(`:` 든 `bomb` 든) 이름 자체를 보지 않고 **모양**을
|
|
168
|
+
// 본다: 함수를 만들고, 그 안에서 **저를 불러 곧바로 파이프나 뒤로 보내기에**
|
|
169
|
+
// 물리고, 밖에서 그 이름을 부른다. 뒷갈이(\2)로 같은 이름인지까지 본다.
|
|
170
|
+
//
|
|
171
|
+
// '저를 부른 **바로 뒤**가 | 나 &' 인 것이 열쇠다. 그냥 '이름이 몸 안에 있고
|
|
172
|
+
// & 도 있다' 로 보면 `up(){ docker compose up -d & }; up` 같은 평범한 함수가
|
|
173
|
+
// 걸린다 — 거기서는 up 뒤에 `-d` 가 온다.
|
|
174
|
+
//
|
|
175
|
+
// 이름 앞뒤로 낱말 경계도 본다. 그래야 `tee -a out.log` 의 log 나
|
|
176
|
+
// `deploy_app` 의 deploy 를 저를 부르는 것으로 잘못 읽지 않는다.
|
|
177
|
+
// 앞자리에 따옴표와 여는 괄호도 넣는다. 안 넣으면 `bash -c ":(){ :|:& };:"` 처럼
|
|
178
|
+
// 한 겹 싸는 것만으로 그대로 새어 나간다.
|
|
179
|
+
{
|
|
180
|
+
re: /(^|[|;&\s"'(])([\w.:]{1,16})\s*\(\s*\)\s*\{[^}]*(?<![\w.])\2(?![\w.])\s*[|&][^}]*\}\s*;?\s*(?<![\w.])\2(?![\w.])/,
|
|
181
|
+
why: '스스로를 끝없이 불려 기계를 멈춰 세웁니다 (포크 폭탄)',
|
|
182
|
+
},
|
|
183
|
+
// 같은 것을 한 줄로 적는 오래된 방법. 이 모양으로 쓸 다른 일이 없다.
|
|
184
|
+
{ re: /\bfork\s+while\s+fork\b/i, why: '스스로를 끝없이 불려 기계를 멈춰 세웁니다 (포크 폭탄)' },
|
|
185
|
+
{ re: /curl[^|]*\|\s*(ba)?sh/i, why: '받은 스크립트를 그대로 실행합니다' },
|
|
186
|
+
{ re: /\biwr\b[^|]*\|\s*iex\b/i, why: '받은 스크립트를 그대로 실행합니다' },
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
export function checkCommand(cmd) {
|
|
190
|
+
const s = String(cmd);
|
|
191
|
+
for (const b of BLOCKED) {
|
|
192
|
+
if (b.re.test(s)) throw new BlockedError(`${b.why}\n 막힌 명령: ${s.slice(0, 120)}`);
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 명령줄에 적힌 경로들을 뽑아 울타리 안인지 본다.
|
|
199
|
+
*
|
|
200
|
+
* 왜 필요한가:
|
|
201
|
+
* Read 도구는 .deel/config.json 을 막는다. 게이트웨이 열쇠가 그 안에 있기
|
|
202
|
+
* 때문이다. 그런데 Bash 로는 `type .deel\config.json` 한 줄이면 그냥 읽혔다.
|
|
203
|
+
* 읽힌 열쇠는 대화에 실려 게이트웨이로 나가고, 세션 기록으로 디스크에도 남는다.
|
|
204
|
+
* 열쇠를 그 열쇠의 주인에게 보내는 셈이다.
|
|
205
|
+
*
|
|
206
|
+
* 범위도 마찬가지였다. 시스템 프롬프트는 '이 폴더 밖은 읽지도 쓰지도 못한다'
|
|
207
|
+
* 고 말하는데, Bash 한 줄로 `cat ../../비밀` 이 됐다. 말이 거짓이 되면
|
|
208
|
+
* 모델은 그 말을 안 믿고, 사용자는 울타리가 있다고 믿는다. 둘 다 나쁘다.
|
|
209
|
+
*
|
|
210
|
+
* 어떻게 고르나:
|
|
211
|
+
* 경로처럼 보이는 낱말만 본다(/ 나 \ 가 있거나, ~ 또는 드라이브로 시작).
|
|
212
|
+
* 그리고 **밖으로 나가는 것만** 막는다. 안에서 도는 것은 전부 통과다.
|
|
213
|
+
* 그래서 sed 의 's/a/b/' 나 npm 의 some/pkg 같은 것은 걸리지 않는다 —
|
|
214
|
+
* 글자로는 경로처럼 보여도 풀어 보면 폴더 안이기 때문이다.
|
|
215
|
+
*/
|
|
216
|
+
export function checkPaths(cmd, scope) {
|
|
217
|
+
if (!scope) return true;
|
|
218
|
+
// 폴더를 옮기고 나면 뒤 낱말은 전부 맨 이름이라 아래 검사에 안 걸린다.
|
|
219
|
+
// 그래서 옮기는 것부터 먼저 본다 (폴더옮김검사 머리말).
|
|
220
|
+
const 옮긴자리 = 폴더옮김검사(String(cmd), scope);
|
|
221
|
+
for (const t of 경로낱말(String(cmd))) {
|
|
222
|
+
// 자료가 아닌 자리는 울타리를 안 묻는다 (봐주는자리 머리말).
|
|
223
|
+
if (봐주는자리(t)) continue;
|
|
224
|
+
// cd 가 가리킨 자리는 위에서 **옮겨 간 자리 기준**으로 이미 봤다. 여기서
|
|
225
|
+
// 또 보면 뿌리 기준으로 풀려서 `cd src/a && cd ../..` 가 밖으로 읽힌다.
|
|
226
|
+
if (옮긴자리.has(t)) continue;
|
|
227
|
+
let abs;
|
|
228
|
+
try { abs = scope.resolve(t); }
|
|
229
|
+
catch (e) {
|
|
230
|
+
if (e instanceof ScopeError) {
|
|
231
|
+
throw new ScopeError(`${e.message}\n 이 명령 안에 있습니다: ${String(cmd).slice(0, 120)}`);
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const 이유 = 내부살림(abs);
|
|
236
|
+
if (이유) throw new BlockedError(`${이유}\n 이 명령 안에 있습니다: ${String(cmd).slice(0, 120)}`);
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 셸이 알아서 풀어 주는 자리표. 우리도 같이 풀어야 실제로 닿는 곳을 본다.
|
|
242
|
+
function 자리표풀기(s) {
|
|
243
|
+
return s
|
|
244
|
+
.replace(/^~(?=[/\\]|$)/, homedir())
|
|
245
|
+
/*
|
|
246
|
+
* 파워셸은 `$env:이름` 으로 적는다.
|
|
247
|
+
*
|
|
248
|
+
* 이 꼴을 안 풀면 아래 경로낱말() 이 「못 푼 자리표」 로 보고 그 낱말을
|
|
249
|
+
* **통째로 건너뛴다.** 그래서 윈도우에서 이 한 줄이 울타리를 그냥 지나갔다:
|
|
250
|
+
*
|
|
251
|
+
* pwsh -Command "Get-Content $env:USERPROFILE\.deel\config.json"
|
|
252
|
+
*
|
|
253
|
+
* 셸이 실제로 푸는 값을 우리도 그대로 푼다. 우리가 모르는 이름이면 안
|
|
254
|
+
* 건드린다 — 그건 아래에서 「못 푼 것」 으로 걸러진다.
|
|
255
|
+
*/
|
|
256
|
+
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (전체, 이름) => {
|
|
257
|
+
const v = process.env[이름] ?? process.env[String(이름).toUpperCase()];
|
|
258
|
+
return typeof v === 'string' && v ? v : 전체;
|
|
259
|
+
})
|
|
260
|
+
.replace(/\$\{?HOME\}?|%USERPROFILE%|\$\{?USERPROFILE\}?/gi, homedir());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 이 낱말은 경로가 아니라 **코드 조각**인가.
|
|
265
|
+
*
|
|
266
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────
|
|
267
|
+
*
|
|
268
|
+
* 여기 오는 것은 셸 명령줄인데, 모델은 그 안에 자바스크립트를 통째로 넣는다
|
|
269
|
+
* (`node -e "…"` · `node - <<'NODE'`). 그러면 코드 안의 슬래시가 전부 경로로
|
|
270
|
+
* 보인다. 실제로 이렇게 막혔다 —
|
|
271
|
+
*
|
|
272
|
+
* ▶ Bash(node - <<'NODE' const fs=require('fs'); …)
|
|
273
|
+
* └ 막힘 — 작업 범위 밖입니다: /data:image\/[\s\S]*?base64,[^"']+/g,'data:…'
|
|
274
|
+
* ▶ Bash(node -e "…")
|
|
275
|
+
* └ 막힘 — 작업 범위 밖입니다: /div
|
|
276
|
+
*
|
|
277
|
+
* 앞엣것은 정규식 리터럴이고 뒤엣것은 `</div>` 가 잘려 온 것이다. 둘 다 파일과
|
|
278
|
+
* 아무 상관이 없는데 명령 **전체**가 거절됐다. 모델은 왜 막혔는지 모르니
|
|
279
|
+
* 따옴표만 바꿔 가며 대여섯 번을 다시 시도했다 — 한 요청이 몇 분씩 길어진 것의
|
|
280
|
+
* 큰 몫이 이것이었다.
|
|
281
|
+
*
|
|
282
|
+
* ── 울타리를 얼마나 무르게 하나 ─────────────────────────────────────────
|
|
283
|
+
*
|
|
284
|
+
* 여기서 넘긴 낱말은 검사를 안 받는다. 그래서 넘기는 기준을 좁게 잡는다 —
|
|
285
|
+
* **실수로 밖을 건드리는** 경로는 `/Users/남/것` 처럼 평범하게 생겼지, 정규식
|
|
286
|
+
* 메타문자나 따옴표를 달고 오지 않는다. 반대로 일부러 뚫으려는 사람은 이
|
|
287
|
+
* 검사가 있든 없든 `cd ..` 나 `$(…)` 로 지나간다 — 이건 원래 사고를 막는
|
|
288
|
+
* 턱이지 봉인이 아니다(파일 도구 쪽 울타리는 그대로다).
|
|
289
|
+
*
|
|
290
|
+
* 글로브(`*` `?`)는 일부러 안 넘긴다. `rm /Users/남/*` 은 진짜 경로이고,
|
|
291
|
+
* 그건 계속 걸려야 한다.
|
|
292
|
+
*/
|
|
293
|
+
export function 코드조각인가(낱말) {
|
|
294
|
+
// 따옴표가 낱말 **안**에 섞였다 = 코드를 가운데서 자른 것이다.
|
|
295
|
+
// 파일 이름에 따옴표를 쓰는 사람은 없다시피 하고, 있어도 셸에서 못 쓴다.
|
|
296
|
+
if (/["'`]/.test(낱말)) return true;
|
|
297
|
+
|
|
298
|
+
// `/` 로 시작하는데 정규식 메타문자가 들어 있다 = 정규식 리터럴.
|
|
299
|
+
// 진짜 경로에는 이런 글자가 거의 안 들어간다.
|
|
300
|
+
if (낱말.startsWith('/') && /[\\[\](){}|^$+]/.test(낱말)) return true;
|
|
301
|
+
|
|
302
|
+
/*
|
|
303
|
+
* 빗금만 남은 조각.
|
|
304
|
+
*
|
|
305
|
+
* `s.replace(/<\/div>/g, '')` 한 줄이 `/` · `\/div` · `/g,` 셋으로 잘린다
|
|
306
|
+
* (낱말 나누기가 `<` `>` 를 경계로 쓴다). 그중 `/` 는 뿌리 폴더로 풀려서
|
|
307
|
+
* **작업 범위 밖**이 되고, 그 한 조각 때문에 명령 전체가 거절됐다.
|
|
308
|
+
*
|
|
309
|
+
* 뿌리를 통째로 미는 명령이 걱정이라면 그건 여기 몫이 아니다 —
|
|
310
|
+
* `rm -rf /` 는 checkCommand 의 BLOCKED 가 따로, 더 확실하게 잡는다.
|
|
311
|
+
*/
|
|
312
|
+
if (/^[/\\]+$/.test(낱말)) return true;
|
|
313
|
+
// 빗금을 백슬래시로 흘린 것(`\/div`)도 정규식이지 경로가 아니다.
|
|
314
|
+
if (낱말.includes('\\/')) return true;
|
|
315
|
+
|
|
316
|
+
/*
|
|
317
|
+
* 한 마디짜리 절대 경로(`/div`)는 **디스크에 있을 때만** 본다.
|
|
318
|
+
*
|
|
319
|
+
* `</div>` `<br/>` 같은 태그가 잘려 오면 이 꼴이 된다. 그런 이름의 최상위
|
|
320
|
+
* 폴더는 없다. 반대로 `/tmp` `/etc` 는 실제로 있으므로 그대로 검사한다 —
|
|
321
|
+
* 없다고 다 넘기면 `mkdir /남의것` 이 지나가지만, 그건 변경성 명령이라
|
|
322
|
+
* checkCommand 가 따로 잡는다.
|
|
323
|
+
*/
|
|
324
|
+
if (/^\/[^/\\]+$/.test(낱말)) {
|
|
325
|
+
/*
|
|
326
|
+
* 못 물어봤으면 **경로로 친다.**
|
|
327
|
+
*
|
|
328
|
+
* 여기가 `catch { return true; }` 였다. `true` 는 「코드 조각이다」 —
|
|
329
|
+
* 곧 이 낱말은 울타리 검사를 **안 받는다**. 디스크에 못 물어본 것을
|
|
330
|
+
* 열어 주는 쪽으로 떨어진 셈이다.
|
|
331
|
+
*
|
|
332
|
+
* 두 실수의 값이 다르다. 코드 조각을 경로로 잘못 보면 멀쩡한 명령이
|
|
333
|
+
* 한 번 막히고, 사람이 보고 다시 친다. 경로를 코드 조각으로 잘못 보면
|
|
334
|
+
* 울타리가 없는 것과 같고, 그건 아무 표시도 안 난다. 모를 때는 좁은
|
|
335
|
+
* 쪽으로 간다.
|
|
336
|
+
*/
|
|
337
|
+
try { return !existsSync(낱말); } catch { return false; }
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/*
|
|
343
|
+
* ── 울타리 밖이지만 막을 까닭이 없는 자리 ───────────────────────────────
|
|
344
|
+
*
|
|
345
|
+
* 울타리는 **사람의 자료**를 지키려고 있다. 그런데 두 가지가 자료가 아닌데도
|
|
346
|
+
* 같이 막혀서, 모델이 할 수 있는 일을 못 하게 만들고 있었다.
|
|
347
|
+
*
|
|
348
|
+
* ▶ Bash(soffice --convert-to txt 보고서.pptx > /dev/null 2>&1)
|
|
349
|
+
* └ 막힘 — 작업 범위 밖입니다: /dev/null
|
|
350
|
+
* ▶ Bash(ls -l /usr/bin/strings)
|
|
351
|
+
* └ 막힘 — 작업 범위 밖입니다: /usr/bin/strings
|
|
352
|
+
*
|
|
353
|
+
* 앞엣것은 **아무것도 저장되지 않는 자리**다. `2>/dev/null` 은 셸 명령 절반이
|
|
354
|
+
* 달고 다니는데, 그 한 조각 때문에 명령 전체가 거절됐다. 뒤엣것은 **이 PC 에
|
|
355
|
+
* 무엇이 깔렸는지 보는 것**이다. 그걸 못 보면 모델은 우회로를 못 찾는다 —
|
|
356
|
+
* 못 읽는 문서를 만났을 때 변환기가 있는지조차 확인할 수 없다.
|
|
357
|
+
*
|
|
358
|
+
* 실제로 그래서 헛돌았다. 문서를 못 읽었고, 변환하려니 막혔고, 남은 길이
|
|
359
|
+
* 없으니 같은 문을 계속 두드렸다. 1.5.8 에서 한 번 겪은 것과 같은 모양인데
|
|
360
|
+
* 그때는 걸린 명령 하나만 풀고 같은 갈래를 안 훑었다.
|
|
361
|
+
*
|
|
362
|
+
* **넓히는 것은 이 둘뿐이다.** 남의 홈 · /etc · /var · /tmp 는 그대로 막는다.
|
|
363
|
+
* 프로그램이 든 폴더는 자료가 아니고, 거기 쓰는 것은 어차피 OS 가 막는다.
|
|
364
|
+
*/
|
|
365
|
+
const 버리는자리 = new Set([
|
|
366
|
+
'/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty',
|
|
367
|
+
'nul', 'nul:', '\\\\.\\nul', // 윈도우
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 프로그램이 사는 자리. 자료가 아니라 실행파일이다.
|
|
372
|
+
*
|
|
373
|
+
* **전부 소문자로 적는다** — 견줄 때 소문자로 낮춰서 보기 때문이다.
|
|
374
|
+
* `/Applications` 처럼 대문자를 섞어 적으면 그 줄만 조용히 안 걸린다.
|
|
375
|
+
*/
|
|
376
|
+
/** 안에 폴더를 더 두는 자리. 여기만 깊이를 안 따진다. */
|
|
377
|
+
const 꾸러미자리 = new Set(['/applications', 'c:/program files', 'c:/program files (x86)']);
|
|
378
|
+
|
|
379
|
+
const 프로그램자리 = [
|
|
380
|
+
'/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/libexec',
|
|
381
|
+
'/usr/local/bin', '/usr/local/sbin',
|
|
382
|
+
'/opt/homebrew/bin', '/opt/homebrew/sbin', '/opt/local/bin',
|
|
383
|
+
'/snap/bin', '/applications', // 맥은 앱 꾸러미 안에 실행파일이 있다
|
|
384
|
+
/*
|
|
385
|
+
* 윈도우는 **실행파일 폴더만** 적는다.
|
|
386
|
+
*
|
|
387
|
+
* 여기가 `'c:/windows'` 한 줄이었다. 그 아래에는 실행파일만 있는 게
|
|
388
|
+
* 아니다 — `System32/drivers/etc/hosts`(그물 설정), `win.ini`,
|
|
389
|
+
* `System32/config`(레지스트리 하이브), 그리고 누구나 쓸 수 있는
|
|
390
|
+
* `C:/Windows/Temp` 가 전부 그 안이다. 이 목록에 걸리면 checkPaths 가
|
|
391
|
+
* 그 낱말을 **통째로 건너뛴다**(continue) — 즉 울타리가 없는 것과 같다.
|
|
392
|
+
*
|
|
393
|
+
* 유닉스 쪽은 `/bin`·`/usr/bin` 처럼 실행파일만 든 폴더라 이 문제가 없었고,
|
|
394
|
+
* 그래서 한 줄이 넓다는 것이 눈에 안 띄었다. 판마다 폭이 다르면 그건
|
|
395
|
+
* 넓은 쪽이 진짜 폭이다.
|
|
396
|
+
*/
|
|
397
|
+
'c:/windows/system32', 'c:/windows/syswow64',
|
|
398
|
+
'c:/program files', 'c:/program files (x86)',
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 이 경로는 울타리를 안 물어도 되는 자리인가.
|
|
403
|
+
*
|
|
404
|
+
* @returns {string|null} 봐 주는 까닭. 아니면 null
|
|
405
|
+
*/
|
|
406
|
+
export function 봐주는자리(경로) {
|
|
407
|
+
const p = String(경로 ?? '').replace(/\\/g, '/');
|
|
408
|
+
const 낮춤 = p.toLowerCase().replace(/\/+$/, '');
|
|
409
|
+
if (버리는자리.has(낮춤)) return '아무것도 저장되지 않는 자리';
|
|
410
|
+
// 한 칸 아래까지만 본다. `/usr/bin/strings` 는 되고 `/usr/bin/../../Users/남` 은
|
|
411
|
+
// 여기 오기 전에 resolve 로 펴져서 이 목록에 안 걸린다.
|
|
412
|
+
for (const 자리 of 프로그램자리) {
|
|
413
|
+
if (낮춤 === 자리) return '프로그램이 있는 자리';
|
|
414
|
+
if (!낮춤.startsWith(`${자리}/`)) continue;
|
|
415
|
+
/*
|
|
416
|
+
* **얼마나 깊이까지** 봐주나.
|
|
417
|
+
*
|
|
418
|
+
* 머리말은 「한 칸 아래까지만 본다」 라고 적어 뒀는데 코드는 깊이를 안
|
|
419
|
+
* 봤다. 그래서 `c:/windows/system32` 를 목록에 넣는 순간 그 아래가 통째로
|
|
420
|
+
* 열린다 — `system32/drivers/etc/hosts`(그물 설정)와 `system32/config`
|
|
421
|
+
* (레지스트리 하이브)가 다 그 안이다. 실행파일 폴더만 적어 놓고도 자료를
|
|
422
|
+
* 통째로 내주는 셈이다.
|
|
423
|
+
*
|
|
424
|
+
* 실행파일은 그 폴더에 **바로** 놓인다(`/usr/bin/strings`,
|
|
425
|
+
* `system32/cmd.exe`). 그러니 기본은 한 칸이다.
|
|
426
|
+
*
|
|
427
|
+
* 꾸러미 자리는 다르다. 맥 앱은 `/Applications/X.app/Contents/MacOS/x`
|
|
428
|
+
* 이고 윈도우 프로그램은 `C:/Program Files/Git/bin/git.exe` 라, 여기서
|
|
429
|
+
* 한 칸으로 자르면 멀쩡히 쓰던 명령이 다시 막힌다.
|
|
430
|
+
*/
|
|
431
|
+
const 나머지 = 낮춤.slice(자리.length + 1);
|
|
432
|
+
if (꾸러미자리.has(자리) || !나머지.includes('/')) return '프로그램이 있는 자리';
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 명령줄에서 경로처럼 보이는 낱말만 골라낸다. */
|
|
438
|
+
export function 경로낱말(cmd) {
|
|
439
|
+
const out = [];
|
|
440
|
+
// 따옴표 안은 통째로 한 낱말, 밖은 공백과 셸 기호로 자른다.
|
|
441
|
+
const re = /"([^"]*)"|'([^']*)'|[^\s|;&<>()]+/g;
|
|
442
|
+
let m;
|
|
443
|
+
while ((m = re.exec(cmd))) {
|
|
444
|
+
let t = m[1] ?? m[2] ?? m[0];
|
|
445
|
+
if (!t) continue;
|
|
446
|
+
if (t.startsWith('-')) continue; // 옵션
|
|
447
|
+
/*
|
|
448
|
+
* 주소는 이 울타리가 볼 것이 아니다 — 그물로 나가는 것은 safety/network.js
|
|
449
|
+
* 가 본다. 딱 하나 **`file:` 만 빼고**: 그건 주소 꼴로 적은 이 PC 의
|
|
450
|
+
* 경로다. 여기가 스킴을 다 넘기고 있어서 이 한 줄이 그냥 지나갔다:
|
|
451
|
+
*
|
|
452
|
+
* curl.exe file:///C:/Users/사용자/.deel/config.json
|
|
453
|
+
*
|
|
454
|
+
* `file:` 은 경로로 되돌려 아래 검사를 그대로 받게 한다.
|
|
455
|
+
*/
|
|
456
|
+
if (/^file:/i.test(t)) {
|
|
457
|
+
t = t.replace(/^file:(\/\/)?(localhost)?/i, '');
|
|
458
|
+
// `file:///C:/…` 는 빗금이 하나 남는다. 드라이브 앞의 그것만 떼어 낸다.
|
|
459
|
+
t = t.replace(/^\/(?=[a-zA-Z]:)/, '');
|
|
460
|
+
try { t = decodeURIComponent(t); } catch { /* 못 풀면 적힌 그대로 본다 */ }
|
|
461
|
+
if (!t) continue;
|
|
462
|
+
} else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) {
|
|
463
|
+
continue; // 주소(http://, ftp:// …)
|
|
464
|
+
}
|
|
465
|
+
// 리디렉션이 붙어 온 것(>out.txt)은 기호를 떼고 본다.
|
|
466
|
+
t = t.replace(/^[<>]+/, '');
|
|
467
|
+
if (!t) continue;
|
|
468
|
+
const 풀린 = 자리표풀기(t);
|
|
469
|
+
const 경로같나 = 풀린.includes('/') || 풀린.includes('\\') || /^[a-zA-Z]:$/.test(풀린);
|
|
470
|
+
if (!경로같나) continue;
|
|
471
|
+
/*
|
|
472
|
+
* 남은 자리표가 있으면(우리가 못 푼 것) **어디로 갈지 모른다.** 울타리
|
|
473
|
+
* (범위 밖인가)는 그래서 못 묻는다 — 억지로 풀면 엉뚱한 자리를 막는다.
|
|
474
|
+
*
|
|
475
|
+
* 그런데 여태 그럴 때 낱말을 통째로 버렸다. 울타리를 못 묻는 것과
|
|
476
|
+
* **살림 파일인지도 안 보는 것**은 다른 이야기다. 살림은 자리가 아니라
|
|
477
|
+
* 이름으로도 알아본다(`…/.deel/config.json`). 자리표가 앞에 붙어 있어도
|
|
478
|
+
* 그 꼬리는 그대로 읽힌다.
|
|
479
|
+
*
|
|
480
|
+
* 그래서 못 푼 것은 **이름 검사만** 받고 자리 검사는 건너뛴다. 걸리면
|
|
481
|
+
* 아래 checkPaths 가 내부살림() 으로 막는다.
|
|
482
|
+
*/
|
|
483
|
+
if (/[$%]/.test(풀린)) {
|
|
484
|
+
if (내부살림(풀린)) out.push(풀린);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (코드조각인가(풀린)) continue;
|
|
488
|
+
out.push(풀린);
|
|
489
|
+
}
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/*
|
|
494
|
+
* ── 폴더를 옮기면 그 뒤로는 볼 글자가 없다 ─────────────────────────────
|
|
495
|
+
*
|
|
496
|
+
* 위 경로낱말() 은 **경로처럼 생긴 것**만 고른다 — 빗금이 있거나 드라이브로
|
|
497
|
+
* 시작하는 것. 그래서 `cd .deel` 다음에 오는 `config.json` 은 낱말 후보로도
|
|
498
|
+
* 안 뽑혔고, 아무 검사도 안 받았다. 화면에서는 이랬다 —
|
|
499
|
+
*
|
|
500
|
+
* ▶ Bash(type .deel\config.json) └ 막힘 (열쇠가 든 파일입니다)
|
|
501
|
+
* ▶ Bash(cd .deel && type config.json) └ 그냥 읽혔다
|
|
502
|
+
*
|
|
503
|
+
* 같은 파일인데 한 줄은 막고 한 줄은 통과다. 그러면 잠근 뜻이 없을 뿐 아니라
|
|
504
|
+
* 더 나쁘다 — 시스템 프롬프트와 화면은 「막혀 있다」 고 말하는데 사실이
|
|
505
|
+
* 아니게 된다. 그 말이 거짓이 되면 모델은 그 말을 안 믿고, 사용자는 울타리가
|
|
506
|
+
* 있다고 믿는다. (checkPaths 머리말에 적어 둔 것과 같은 까닭이다.)
|
|
507
|
+
*
|
|
508
|
+
* 그래서 **들어가는 것 자체**를 위반으로 본다. 들어간 뒤에는 우리가 볼 수
|
|
509
|
+
* 있는 글자가 남지 않아서, 거기서 막을 방법이 아예 없기 때문이다.
|
|
510
|
+
*
|
|
511
|
+
* 여기서도 넓히지 않는다 — 안에서 도는 cd 는 전부 그대로 돈다. 모델이 하는
|
|
512
|
+
* 일의 절반이 `cd 하위폴더 && 무엇` 이라, 그걸 막으면 도구를 못 쓴다.
|
|
513
|
+
*/
|
|
514
|
+
const 폴더옮김 = /^(cd|chdir|pushd|sl|set-location|push-location)$/i;
|
|
515
|
+
const 되돌리기 = /^(popd|pop-location)$/i;
|
|
516
|
+
/** 셸을 한 겹 씌워 부르는 것들. 안 들여다보면 `sh -c "cd .deel …"` 로 그냥 지나간다. */
|
|
517
|
+
const 셸꼴 = /^(?:.*[/\\])?(bash|sh|zsh|dash|ksh|fish|cmd|powershell|pwsh)(\.exe)?$/i;
|
|
518
|
+
|
|
519
|
+
/** 명령을 실행 단위로 자른다. 따옴표 안의 `;` 는 글자지 이음매가 아니다. */
|
|
520
|
+
function 마디들(cmd) {
|
|
521
|
+
const 마디 = [];
|
|
522
|
+
let 지금 = '';
|
|
523
|
+
let 따옴 = null;
|
|
524
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
525
|
+
const c = cmd[i];
|
|
526
|
+
if (따옴) { 지금 += c; if (c === 따옴) 따옴 = null; continue; }
|
|
527
|
+
if (c === '"' || c === "'") { 따옴 = c; 지금 += c; continue; }
|
|
528
|
+
if (c === '\n' || c === ';') { 마디.push(지금); 지금 = ''; continue; }
|
|
529
|
+
if (c === '&' || c === '|') {
|
|
530
|
+
마디.push(지금); 지금 = '';
|
|
531
|
+
if (cmd[i + 1] === c) i++; // && · ||
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
지금 += c;
|
|
535
|
+
}
|
|
536
|
+
마디.push(지금);
|
|
537
|
+
return 마디;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** 한 마디를 낱말로. 따옴표는 벗기되, 벗겼다는 것은 남긴다(셸 한 겹을 알아보려고). */
|
|
541
|
+
function 낱말들(마디) {
|
|
542
|
+
const out = [];
|
|
543
|
+
const re = /"([^"]*)"|'([^']*)'|[^\s]+/g;
|
|
544
|
+
let m;
|
|
545
|
+
while ((m = re.exec(마디))) {
|
|
546
|
+
const 안 = m[1] ?? m[2];
|
|
547
|
+
out.push({ 글: 안 ?? m[0], 쌌나: 안 != null });
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* cd 가 어디로 가라는 것인가. 모르면 null — 모르는 것을 막으면 안 된다.
|
|
554
|
+
*
|
|
555
|
+
* 옵션은 건너뛴다: 파워셸의 `-Path`·`-LiteralPath`, 윈도우 cmd 의 `/d`.
|
|
556
|
+
* `/d` 만 콕 집어 본다 — `/etc` 도 빗금으로 시작하지만 그건 진짜 갈 자리다.
|
|
557
|
+
*/
|
|
558
|
+
function 갈자리(뒤) {
|
|
559
|
+
for (const w of 뒤) {
|
|
560
|
+
if (!w.글) continue;
|
|
561
|
+
if (w.글.startsWith('-')) continue;
|
|
562
|
+
if (/^\/d$/i.test(w.글)) continue;
|
|
563
|
+
return w.글;
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 폴더를 옮기는 명령을 따라가며, 옮겨 간 자리가 울타리 안인지 본다.
|
|
570
|
+
*
|
|
571
|
+
* @returns {Set<string>} 여기서 이미 본 cd 목적지들. 부르는 쪽은 이걸 두 번
|
|
572
|
+
* 보지 않는다 — 뿌리 기준으로 다시 풀면 `cd src/a && cd ../..` 가 밖이 된다.
|
|
573
|
+
*/
|
|
574
|
+
function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new Set()) {
|
|
575
|
+
let 여기 = 시작 ?? scope.root;
|
|
576
|
+
const 쌓은것 = [];
|
|
577
|
+
for (const 마디 of 마디들(cmd)) {
|
|
578
|
+
const 낱말 = 낱말들(마디);
|
|
579
|
+
if (!낱말.length) continue;
|
|
580
|
+
const 첫 = 낱말[0].글;
|
|
581
|
+
|
|
582
|
+
// 셸을 한 겹 씌운 것은 안을 들여다본다. 그 안의 cd 는 딴 살림이라
|
|
583
|
+
// (하위 셸이다) 우리 자리는 안 바뀐다 — 결과만 받고 여기는 그대로 둔다.
|
|
584
|
+
if (깊이 < 2 && 셸꼴.test(첫)) {
|
|
585
|
+
for (const w of 낱말.slice(1)) if (w.쌌나) 폴더옮김검사(w.글, scope, 여기, 깊이 + 1, 본것);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (되돌리기.test(첫)) { 여기 = 쌓은것.pop() ?? 여기; continue; }
|
|
589
|
+
if (!폴더옮김.test(첫)) continue;
|
|
590
|
+
|
|
591
|
+
const 목적 = 갈자리(낱말.slice(1));
|
|
592
|
+
// 인자 없는 cd(집으로) · `cd -`(직전 자리) 는 어디로 가는지 여기서 모른다.
|
|
593
|
+
// 모르는 것을 막으면 멀쩡한 명령이 죽는다.
|
|
594
|
+
if (목적 == null || 목적 === '-') continue;
|
|
595
|
+
const 풀린 = 자리표풀기(목적);
|
|
596
|
+
// 못 푼 자리표가 남아 있으면(`$BUILD_DIR`) 역시 어디로 갈지 모른다.
|
|
597
|
+
if (/[$%]/.test(풀린)) continue;
|
|
598
|
+
|
|
599
|
+
const 열린 = MSYS풀기(풀린);
|
|
600
|
+
const abs = isAbsolute(열린) ? resolve(열린) : resolve(여기, 열린);
|
|
601
|
+
본것.add(풀린);
|
|
602
|
+
|
|
603
|
+
// 프로그램이 있는 자리로 들어가는 것은 자료를 만지는 일이 아니다.
|
|
604
|
+
// 푼 값이 아니라 **적힌 글자**로 본다 — 윈도우에서 resolve('/usr/bin') 은
|
|
605
|
+
// `C:\usr\bin` 이 되어 목록에 안 걸린다 (checkPaths 도 낱말로 본다).
|
|
606
|
+
if (봐주는자리(풀린)) { 여기 = abs; continue; }
|
|
607
|
+
try { scope.resolve(abs); }
|
|
608
|
+
catch (e) {
|
|
609
|
+
if (e instanceof ScopeError) {
|
|
610
|
+
throw new ScopeError(`${e.message}\n 이 명령 안에 있습니다: ${cmd.slice(0, 120)}`);
|
|
611
|
+
}
|
|
612
|
+
throw e;
|
|
613
|
+
}
|
|
614
|
+
// `.deel` 폴더 자신은 내부살림() 이 안 잡는다 — 그건 그 **안의 파일**을
|
|
615
|
+
// 보는 검사다. 여기서는 폴더에 발을 들이는 것이 곧 위반이다.
|
|
616
|
+
const 조각 = abs.replace(/\\/g, '/').split('/');
|
|
617
|
+
const 이유 = 조각.includes('.deel')
|
|
618
|
+
? 'deel 자신의 살림 폴더입니다. 그 안에 게이트웨이 열쇠가 든 config.json 이 있어'
|
|
619
|
+
+ ' 들어가지 않습니다 — 들어가면 뒤 명령이 맨 이름만으로 그 파일에 닿습니다.'
|
|
620
|
+
+ ' 연결 상태가 궁금하면 사용자에게 /status 를 쳐 보라고 하세요.'
|
|
621
|
+
: 내부살림(abs);
|
|
622
|
+
if (이유) throw new BlockedError(`${이유}\n 이 명령 안에 있습니다: ${cmd.slice(0, 120)}`);
|
|
623
|
+
|
|
624
|
+
if (/^(pushd|push-location)$/i.test(첫)) 쌓은것.push(여기);
|
|
625
|
+
여기 = abs;
|
|
626
|
+
}
|
|
627
|
+
return 본것;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* 변경성 동작은 실패해도 다시 실행하지 않는다 — 두 번 실행되면 사고다. (RPA 에서 얻은 원칙)
|
|
632
|
+
*
|
|
633
|
+
* 이것도 **첫 낱말**일 때만 본다. 전에는 아무 데나 있으면 걸렸다.
|
|
634
|
+
* 그래서 `node scripts/copy.js` 나 `grep -r "del" src/` 가 '변경성 명령' 이 되어
|
|
635
|
+
* 두 번째 시도가 막혔다. 진짜로 파일을 옮기는 명령과 이름만 스친 것은 다르다.
|
|
636
|
+
*/
|
|
637
|
+
const MUTATING = /(^|[|;&]\s*)(sudo\s+)?(git\s+(commit|push|merge|rebase|reset)|npm\s+(publish|install)|pip\s+install|mv|cp|del|rm|move|copy|curl\s+-X\s*(POST|PUT|DELETE|PATCH))\b/i;
|
|
638
|
+
|
|
639
|
+
export function isMutating(cmd) {
|
|
640
|
+
return MUTATING.test(String(cmd));
|
|
641
|
+
}
|