deel-local-cli 1.20.12 → 2.0.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.
Files changed (155) hide show
  1. package/README.ko.md +96 -77
  2. package/README.md +119 -80
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +62 -13
  7. package/src/acp/serve.js +338 -30
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +506 -32
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +40 -9
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
@@ -132,10 +132,134 @@ export function makeScope(root) {
132
132
  };
133
133
  }
134
134
 
135
+ /*
136
+ * 「뿌리」 로 치는 목표 한 낱말. rm · find · chmod · rsync 규칙이 같이 쓴다 — 규칙마다 따로 적었더니
137
+ * 한쪽만 `/*` 를 받고 한쪽은 `~` 를 빠뜨렸다(2.0.0 3회차 Gemini).
138
+ *
139
+ * `/` · `/*` · `/etc` · `/etc/` 뿌리와 그 바로 아래 한 칸 — 코드조각인가() 가 한 마디짜리를
140
+ * 코드로 넘기므로 여기서 잡아야 한다(`rm -rf /etc`).
141
+ * `~` · `$HOME` · `${HOME}` 집 폴더
142
+ * `C:` · `C:\` · `C:/` 윈도우 드라이브
143
+ *
144
+ * 따옴표를 둘러도 같다. 뒤에 글자가 더 붙으면(`/tmp/x` · `~/Downloads`) 안 걸린다 — 부르는 규칙이
145
+ * 뒤에 빈칸이나 끝을 요구한다.
146
+ *
147
+ * ── 빗금을 **겹쳐** 적은 뿌리 (사냥5 R5-H1) ─────────────────────────────
148
+ *
149
+ * 빗금을 한 자로만 받았다(`\/`). POSIX 는 겹친 빗금을 하나로 읽으니 `//` · `///` 도 `/` 다 —
150
+ *
151
+ * rm -rf // 통과 ← checkCommand 도 checkPaths 도
152
+ * find // -delete 통과 ←
153
+ * rsync -a --delete x/ // 통과 ←
154
+ *
155
+ * checkPaths 가 받아 줄 수도 없었다. 코드조각인가() 가 빗금만 남은 낱말을 넘기기 때문인데, 그건
156
+ * 코드 속 `// 주석` 이 낱말로 잘려 오는 자리라 풀 수가 없다(그 머리말). 그러니 여기가 **유일한**
157
+ * 턱이다. 뿌리 빗금과 한 칸 뒤 빗금을 둘 다 여러 자로 받는다 — `//.` · `///etc` · `//*` 도 같이 걸린다.
158
+ *
159
+ * ── `~이름` 은 **남의 집**이다 (사냥5 R5-M1) ────────────────────────────
160
+ *
161
+ * 셸은 `~root` 를 root 의 집으로, `~admin/` 을 admin 의 집으로 푼다. `~` 만 받았더니
162
+ * `rm -Rf ~root` 가 지나갔다. 이름은 글자나 밑줄로 시작할 때만 받는다 — `~1` · `~+` 은 셸의
163
+ * 다른 뜻이고, 가운데 물결(`a~b` · `PROGRA~1` · `HEAD~1`)은 앞에 빈칸이 오지 않아 애초에 안 닿는다.
164
+ */
165
+ const 뿌리목표 = String.raw`["']?(?:\/+(?:[\w.-]+\/*)?|~(?:[A-Za-z_][\w.-]*)?\/*|\$\{?HOME\}?\/*|[A-Za-z]:[\\/]*)\*?["']?`;
166
+
167
+ /*
168
+ * ── cmd·파워셸에는 **다른 뿌리목표**를 쓴다 (사냥8) ──────────────────────
169
+ *
170
+ * 위 뿌리목표 의 첫 갈래 `\/+(?:[\w.-]+\/*)?` 는 「뿌리 또는 뿌리 바로 아래
171
+ * 한 칸」 이다(`/` · `/usr`). 유닉스에서는 맞는 말인데, cmd 에서 `/s` · `/q`
172
+ * 는 **스위치**지 경로가 아니다. 그래서 이 한 줄이 막혔다 —
173
+ *
174
+ * rd /s /q node_modules 막힘 ← `/q` 가 「뿌리 아래 한 칸」 으로 읽혔다
175
+ * rm -rf node_modules 통과 (같은 일, 다른 셸)
176
+ * del /q build\a.txt 막힘 ← 같은 까닭
177
+ *
178
+ * 짝이 안 맞으면 그건 규칙이 아니라 우연이다. 그래서 윈도우 쪽은 빗금 뒤에
179
+ * 이름을 **안 받는다** — `rmdir /s /q /` 처럼 빗금만 있는 것은 그대로 막고,
180
+ * `/s` 는 스위치로 지나간다. 대신 별(`C:\*` · `/*` · `~/*`)과 역빗금 뿌리를
181
+ * 받는다. 파워셸에서 `\` 하나는 지금 드라이브의 뿌리다.
182
+ *
183
+ * 「뿌리 바로 아래 한 칸」 은 **드라이브 쪽에** 둔다 — `C:\Windows` ·
184
+ * `C:\Users` 는 유닉스의 `/usr` 와 같은 자리다. 두 칸부터는 안 본다
185
+ * (`C:\proj\tmp` 는 그대로 돈다).
186
+ */
187
+ /*
188
+ * 2차 눈(Gemini)이 셋을 더 짚었다 — 전부 「적는 법만 다른 같은 자리」 다.
189
+ *
190
+ * rmdir /s /q \\?\C:\ 긴 경로 접두사 (윈도우가 그대로 받는다)
191
+ * rd /s /q \\.\C:\ 장치 이름 공간
192
+ * rd /s /q %SystemDrive%\ cmd 가 푸는 자리표 — `C:` 다
193
+ *
194
+ * 자리표는 **뿌리를 가리키는 것만** 적는다. `%TEMP%` · `%BUILD_DIR%` 는 안
195
+ * 넣는다 — `rd /s /q %TEMP%` 는 사람이 실제로 하는 청소다.
196
+ */
197
+ // 파워셸은 중괄호 꼴도 받는다 — `${env:SystemDrive}` (3차 눈 codex).
198
+ const 윈자리표이름 = String.raw`(?:SystemDrive|SystemRoot|windir|HOMEDRIVE|USERPROFILE|ProgramFiles(?:\(x86\))?|ProgramData|ALLUSERSPROFILE|PUBLIC)`;
199
+ const 윈자리표 = String.raw`(?:\$\{env:${윈자리표이름}\}|\$env:${윈자리표이름}|%${윈자리표이름}%)`;
200
+ /*
201
+ * 드라이브 뒤에 이름이 오려면 **가름표가 하나는** 있어야 한다 (3차 눈 codex).
202
+ * `C:temp` 는 뿌리가 아니라 「C 드라이브의 지금 자리 아래 temp」 라, 이것까지
203
+ * 막으면 멀쩡한 청소가 막힌다. `C:` 와 `C:\` 와 `C:\Users` 는 그대로 막는다.
204
+ */
205
+ const 윈뿌리목표 = String.raw`["']?(?:(?:[\\/]{2}[?.][\\/])?[A-Za-z]:(?:[\\/]+(?:[\w.-]+[\\/]*)?)?|[\\/]+|~(?:[A-Za-z_][\w.-]*)?[\\/]*|\$\{?HOME\}?[\\/]*|${윈자리표}[\\/]*)\*?["']?`;
206
+
135
207
  // 되돌릴 수 없는 것만 막는다. 목록이 길어지면 도구가 쓸모없어진다.
136
208
  const BLOCKED = [
137
209
  // `/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: '뿌리 폴더를 통째로 지우려 합니다' },
210
+ /*
211
+ * ── 스위치를 **한 낱말에 몰아 적은 사람만** 막고 있었다 ────────────────
212
+ *
213
+ * 옛 규칙은 `r` 과 `f` 가 **한 토막 안에** 같이 있는 것만 봤다
214
+ * (`-[a-z]*r[a-z]*f`). 셸에서 뜻이 하나도 안 바뀌는 다른 적는 법이 전부
215
+ * 그대로 나갔다:
216
+ *
217
+ * rm -rf / 막힘
218
+ * rm -r -f / 통과 ← 나눠 적은 것
219
+ * rm -f -r / 통과 ← 차례만 바꾼 것
220
+ * rm --recursive --force / 통과 ← 긴 꼴
221
+ *
222
+ * 이 파일이 `git push --force` 에서 이미 겪고 적어 둔 것과 같은 부류다 —
223
+ * 「막았다」 가 아니라 「내가 예로 적은 모양으로 적은 사람만 막았다」.
224
+ *
225
+ * 그래서 차례도 토막 수도 안 따진다. 이 마디 안에 되풀이 스위치가
226
+ * **있고**(앞보기 하나), 밀어붙이기 스위치도 **있으면**(앞보기 둘) 본다.
227
+ * 짧은 꼴(`-r` · `-rf` · `-fr`)과 긴 꼴(`--recursive`)이 `--?[a-z]*r` 한
228
+ * 갈래에 다 들어온다.
229
+ */
230
+ /*
231
+ * 목표를 **글 끝**에만 묶어 뒀더니 `rm -rf /*` · `rm -rf / *` · `rm -rf "/*"` 가 그대로 나갔다
232
+ * (2.0.0 3회차 사냥). 셸은 `/*` 를 뿌리 아래 전부로 펴서 지운다. 목표 뒤에 별 하나와 따옴표를
233
+ * 받고, 끝이 아니라 **빈칸이나 끝**이 오면 된다 — `/tmp` · `~/Downloads` 는 뒤에 글자가 붙어 안 걸린다.
234
+ */
235
+ /*
236
+ * 스위치는 **낱말째** 본다. `--?[a-z]*r` 로 봤더니 `rm --force /a` · `rm -f --verbose /a` 의
237
+ * `force`·`verbose` 안 r 이 「재귀」 로 읽혀 파일 하나 지우는 명령이 막혔다(3회차 Gemini).
238
+ * 그리고 `-f` 는 안 묻는다 — 모델이 도는 자리는 터미널이 아니라서 `rm -r ~` 도 묻지 않고 지운다.
239
+ */
240
+ {
241
+ re: new RegExp(String.raw`\brm\b(?=[^|;&]*\s(?:-[a-z]*r[a-z]*|--recursive)(?=\s|$))[^|;&]*\s${뿌리목표}(?=\s|$)`, 'i'),
242
+ why: '뿌리 폴더를 통째로 지우려 합니다',
243
+ },
244
+ /*
245
+ * ── 뿌리를 미는 것은 rm 만이 아니다 (3회차 사냥) ───────────────────────
246
+ *
247
+ * 코드조각인가() 는 맨 `/` 를 검사에서 넘기면서 「뿌리를 미는 명령은 BLOCKED 가 따로,
248
+ * 더 확실하게 잡는다」 고 약속했다. 그런데 여기에는 rm 계열밖에 없었다:
249
+ *
250
+ * find / -delete 통과 ←
251
+ * chmod -R 777 / 통과 ←
252
+ * rsync -a --delete x/ / 통과 ←
253
+ *
254
+ * 뿌리(`/` · `~` · `$HOME`)를 **목표로** 할 때만 본다. `find . -delete` · `chmod -R 755 ./dist` 는
255
+ * 그대로 지나간다.
256
+ */
257
+ // find 는 경로 앞에 `-L`·`-H`·`-P` 가 올 수 있고, 지우개는 `/bin/rm` 처럼 자리째 적기도 한다.
258
+ { re: new RegExp(String.raw`\bfind\b(?:\s+-[HLPO]\w*)*\s+${뿌리목표}(?=\s)(?=[^|;&]*\s-(?:delete\b|exec(?:dir)?\s+(?:\S*\/)?rm\b))`, 'i'), why: '뿌리 폴더 아래를 통째로 지웁니다' },
259
+ // 대소문자를 가린다 — chmod 의 `-r` 은 재귀가 아니라 「읽기 권한 빼기」 다. `-Rf` 처럼 붙여 적은 것도 받는다.
260
+ { re: new RegExp(String.raw`\b(?:chmod|chown|chgrp)\b(?=[^|;&]*\s(?:-[a-zA-Z]*R[a-zA-Z]*|--recursive)(?=\s|$))[^|;&]*\s${뿌리목표}(?=\s|$)`), why: '뿌리 폴더 전체의 권한·주인을 바꿉니다' },
261
+ // rsync 는 **마지막** 낱말이 받는 쪽이다. `rsync -a --delete / backup/` 은 뿌리를 떠 오는 것이라 안 막는다. `--del` 은 줄임꼴.
262
+ { re: new RegExp(String.raw`\brsync\b(?=[^|;&]*\s--del(?:ete[\w-]*)?(?=[\s=]|$))[^|;&]*\s${뿌리목표}\s*$`), why: '뿌리 폴더를 복사본에 맞춰 지웁니다' },
139
263
  { re: /\b(mkfs|fdisk|diskpart)\b/i, why: '디스크를 초기화하는 명령입니다' },
140
264
  { re: /\bformat\s+[a-z]:/i, why: '드라이브를 포맷하는 명령입니다' },
141
265
  // 윈도우 짝을 빠뜨리면 안 된다 — deel 은 윈도우에서 주로 돈다.
@@ -143,8 +267,27 @@ const BLOCKED = [
143
267
  // 옛 규칙은 스위치가 **차례대로** 오고 끝에 `\*` 가 붙은 모양만 봤다.
144
268
  // 그래서 `del /f /s /q C:\` 와 `rd /q /s C:\` 가 그대로 새어 나갔다.
145
269
  // 스위치는 아무 차례로나 오므로 차례를 안 따진다.
146
- { re: /\b(rd|rmdir)\b[^|;&]*\s\/s\b/i, why: '폴더를 통째로 지웁니다 — 정션이 있으면 원본까지 딸려 갑니다' },
147
- { re: /\bdel\b[^|;&]*\s\/[sq]\b[^|;&]*(\\\*|\s[a-z]:\\?\s*$)/i, why: '하위 폴더까지 전부 지웁니다' },
270
+ /*
271
+ * ── 스위치만 보고 **목표를 안 봤다** (8회차 사냥) ──────────────────────
272
+ *
273
+ * rd 규칙은 `/s` 가 있기만 하면 막았다. 그런데 `/s` 는 「하위까지」 라는
274
+ * 뜻일 뿐 어디를 지우는지와 아무 상관이 없다. 그래서 이 한 줄이 막혔다 —
275
+ *
276
+ * rd /s /q node_modules 막힘 ← 유닉스 짝은 통과인데
277
+ * rm -rf node_modules 통과 (위 rm 규칙이 뿌리목표 를 본다)
278
+ *
279
+ * 같은 일을 하는 두 명령이 셸이 다르다는 까닭만으로 다른 답을 받았다.
280
+ * 짝이 안 맞으면 그건 규칙이 아니라 우연이다. 여기도 위 rm·find·chmod 와
281
+ * 같은 잣대를 쓴다 — 스위치는 **앞보기**로 있는지만 보고(차례를 안 따진다),
282
+ * 막는 판단은 목표가 뿌리목표 일 때만.
283
+ *
284
+ * del 도 같은 자리에서 반대로 샜다. 스위치가 경로보다 **앞**에 오는 모양만
285
+ * 봐서 `del C:\ /f /s /q` 가 그냥 지나갔다 — 위 주석이 「스위치는 아무
286
+ * 차례로나 온다」 고 적어 두고도 목표 쪽은 차례에 묶여 있었다.
287
+ * `\*` 로 끝나는 옛 갈래(`del /s /q D:\proj\*`)는 그대로 둔다.
288
+ */
289
+ { re: new RegExp(String.raw`\b(?:rd|rmdir)\b(?=[^|;&]*\s\/s\b)[^|;&]*\s${윈뿌리목표}(?=\s|$)`, 'i'), why: '폴더를 통째로 지웁니다 — 정션이 있으면 원본까지 딸려 갑니다' },
290
+ { re: new RegExp(String.raw`\bdel\b(?=[^|;&]*\s\/[sq]\b)[^|;&]*(?:\\\*|\s${윈뿌리목표}(?=\s|$))`, 'i'), why: '하위 폴더까지 전부 지웁니다' },
148
291
  // 파워셸도 같은 일을 한다. 드라이브 뿌리나 집 폴더를 통째로 미는 것만 본다 —
149
292
  // 폴더 하나 지우는 평범한 Remove-Item 까지 막으면 도구를 못 쓴다.
150
293
  // 가운데 `(-?[a-z]*\s*)*` 는 양쪽 다 빈 문자열을 허용해서, 안 맞는 긴 입력이
@@ -182,7 +325,13 @@ const BLOCKED = [
182
325
  * 목표에 `[\\/]` 한 갈래를 더 둔 까닭: 파워셸에서 `/` 나 `\` 하나만
183
326
  * 적으면 **지금 드라이브의 뿌리**다. 둘 다 2차 리뷰가 짚었다.
184
327
  */
185
- re: /Remove-Item\b(?=[^|;&]*\s-r[a-z]*\b)[^|;&]*\s(?:(?:\$env:USERPROFILE|\$HOME|[a-z]:|~)[\\/]?|[\\/])(?=\s|$)/i,
328
+ /*
329
+ * 별(`*`)도 받는다 (사냥8). 여기가 `[\\/]?` 로 끝나 있어서 `C:\*` · `/*`
330
+ * · `~/*` 가 전부 지나갔다 — 셸이 별을 펴서 그 아래를 통째로 지우는데,
331
+ * 우리는 「빗금으로 **끝나는** 것」 만 뿌리로 봤다. 위 rm 규칙은 이미
332
+ * 뿌리목표 로 별을 받고 있었다. 한쪽만 받으면 그건 규칙이 아니라 우연이다.
333
+ */
334
+ re: new RegExp(String.raw`Remove-Item\b(?=[^|;&]*\s-r[a-z]*\b)[^|;&]*\s${윈뿌리목표}(?=\s|$)`, 'i'),
186
335
  why: '드라이브나 집 폴더를 통째로 지웁니다',
187
336
  },
188
337
  /*
@@ -221,8 +370,49 @@ const BLOCKED = [
221
370
  },
222
371
  // 같은 것을 한 줄로 적는 오래된 방법. 이 모양으로 쓸 다른 일이 없다.
223
372
  { re: /\bfork\s+while\s+fork\b/i, why: '스스로를 끝없이 불려 기계를 멈춰 세웁니다 (포크 폭탄)' },
224
- { re: /curl[^|]*\|\s*(ba)?sh/i, why: '받은 스크립트를 그대로 실행합니다' },
225
- { re: /\biwr\b[^|]*\|\s*iex\b/i, why: '받은 스크립트를 그대로 실행합니다' },
373
+ /*
374
+ * 받아서 곧바로 셸에 먹이는 것.
375
+ *
376
+ * 여기도 **우리가 예로 적은 철자만** 보고 있었다. 받아 오는 쪽은 `curl`
377
+ * 하나였고, 먹이는 쪽은 파이프 **바로 다음 낱말**이 `sh`·`bash` 일 때만
378
+ * 이었다. 셸에서 뜻이 같은 다른 적는 법이 다 지나갔다:
379
+ *
380
+ * curl http://x | bash 막힘
381
+ * wget -qO- http://x | sh 통과 ← 받아 오는 다른 이름
382
+ * curl http://x | sudo bash 통과 ← 사이에 낱말 하나
383
+ * curl http://x | zsh 통과 ← 다른 셸
384
+ *
385
+ * 받아 오는 이름을 한 갈래로 묶고, 파이프와 셸 사이에 `sudo`·`env` 가
386
+ * 끼는 것을 받아 준다. 셸 이름도 흔한 것을 다 적는다.
387
+ */
388
+ {
389
+ /*
390
+ * 셸을 **자리째** 적는 것도 같은 셸이다 (사냥8).
391
+ *
392
+ * curl http://x | bash 막힘
393
+ * curl http://x | /bin/bash 통과 ← 같은 프로그램, 적는 법만 다르다
394
+ * wget -qO- http://x | /usr/bin/sh 통과 ←
395
+ *
396
+ * 앞에 폴더가 붙는 것만 받는다. 이름 **안**에 sh 가 든 것(`hash.sh` ·
397
+ * `tee` )은 여전히 안 걸린다 — 앞 조각은 반드시 빗금으로 끝나야 하고
398
+ * 셸 이름은 그 뒤 첫 글자부터 시작해야 한다.
399
+ */
400
+ /*
401
+ * 3차 눈(codex)이 두 갈래를 더 찾았다 — 둘 다 셸을 **자리로 적은** 것이다.
402
+ *
403
+ * curl … | /usr/bin/env bash 감싸는 것도 자리로 적는다
404
+ * curl … | "C:\Program Files\Git\bin\bash.exe" 빈칸과 드라이브가 든 자리
405
+ *
406
+ * 감싸는 낱말 앞에도 자리를 받고, 셸 이름 앞의 자리는 따옴표 안이면 빈칸도
407
+ * 받는다. 끝은 낱말째여야 한다 — 그래야 `fish.py` 나 `hash.sh` 를 안 문다.
408
+ */
409
+ re: /\b(?:curl|wget|fetch|aria2c)\b[^|]*\|\s*(?:["']?(?:[^|"'\s]*[\\/])?(?:sudo|doas|env|nohup|command)["']?\s+(?:-\S+\s+|\S+=\S+\s+)*)*["']?(?:[^|"'\n]*[\\/])?(?:ba|z|k|da|c|tc|fi)?sh(?:\.exe)?(?=["'\s]|$)/i,
410
+ why: '받은 스크립트를 그대로 실행합니다',
411
+ },
412
+ {
413
+ re: /\b(?:iwr|irm|invoke-webrequest|invoke-restmethod|curl|wget)\b[^|]*\|\s*(?:iex|invoke-expression)\b/i,
414
+ why: '받은 스크립트를 그대로 실행합니다',
415
+ },
226
416
  ];
227
417
 
228
418
  /*
@@ -327,6 +517,11 @@ export function checkPaths(cmd, scope) {
327
517
  // cd 가 가리킨 자리는 위에서 **옮겨 간 자리 기준**으로 이미 봤다. 여기서
328
518
  // 또 보면 뿌리 기준으로 풀려서 `cd src/a && cd ../..` 가 밖으로 읽힌다.
329
519
  if (옮긴자리.has(t)) continue;
520
+ // 어디인지 모르는 이전 폴더는 밖으로 본다 (경로낱말 의 `~-` 머리말).
521
+ if (/^~-(?=[/\\]|$)/.test(t)) {
522
+ throw new ScopeError(`\`~-\` 는 셸의 이전 폴더(OLDPWD)라 어디인지 알 수 없어 작업 범위 밖으로 봅니다: ${t}`
523
+ + `\n 이 명령 안에 있습니다: ${String(cmd).slice(0, 120)}`);
524
+ }
330
525
  let abs;
331
526
  try { abs = scope.resolve(t); }
332
527
  catch (e) {
@@ -345,6 +540,25 @@ export function checkPaths(cmd, scope) {
345
540
  function 자리표풀기(s) {
346
541
  return s
347
542
  .replace(/^~(?=[/\\]|$)/, homedir())
543
+ /*
544
+ * `~+` 는 셸의 **지금 폴더**(PWD)다 — `.` 과 같은 대접을 한다 (사냥5 지킴 Gemini).
545
+ * 안 풀면 작업 폴더 아래 `./~+` 라는 이름으로 읽혔다. (`~-` 는 아래 경로낱말·checkPaths 가
546
+ * 따로 본다 — 어디인지 모르는 자리라 풀 값이 없다.)
547
+ */
548
+ .replace(/^~\+(?=[/\\]|$)/, '.')
549
+ /*
550
+ * `~이름` 은 **그 사람의 집**이다 (사냥5 R5-M1).
551
+ *
552
+ * 이 줄이 없어서 `cat ~admin/.ssh/id_rsa` 의 `~admin/…` 이 글자 그대로 남았고, scope.resolve 는
553
+ * 그것을 작업 폴더 **아래** `./~admin/.ssh/id_rsa` 로 읽어 범위 안이라고 했다. 셸은 admin 의
554
+ * 집으로 가는데.
555
+ *
556
+ * 남의 집이 정확히 어디인지는 여기서 모른다(passwd 를 안 읽는다). 대신 **내 집 옆자리**
557
+ * (`/home/admin` · `C:\Users\admin`)로 푼다 — 울타리가 물을 것은 「작업 폴더 밖인가」 하나이고,
558
+ * 남의 집은 어느 쪽으로 풀든 내 작업 폴더 밖이다. 이름은 글자·밑줄로 시작할 때만 받는다
559
+ * (`~1` · `~+` 은 셸의 다른 뜻). 가운데 물결(`./~backup` · `PROGRA~1`)은 `^` 에 안 닿는다.
560
+ */
561
+ .replace(/^~([A-Za-z_][\w.-]*)(?=[/\\]|$)/, (전체, 이름) => resolve(dirname(homedir()), 이름))
348
562
  /*
349
563
  * 파워셸은 `$env:이름` 으로 적는다.
350
564
  *
@@ -360,7 +574,24 @@ function 자리표풀기(s) {
360
574
  const v = process.env[이름] ?? process.env[String(이름).toUpperCase()];
361
575
  return typeof v === 'string' && v ? v : 전체;
362
576
  })
363
- .replace(/\$\{?HOME\}?|%USERPROFILE%|\$\{?USERPROFILE\}?/gi, homedir());
577
+ /*
578
+ * 자리표 이름은 **낱말째** 끝나야 한다 (사냥8).
579
+ *
580
+ * `\$\{?HOME\}?` 가 낱말 경계를 안 봐서 `$HOME` 이 남의 이름 앞머리를
581
+ * 먹었다. 셸에서는 전혀 다른 변수다 —
582
+ *
583
+ * cat $HOME_DIR/x → C:\Users\나_DIR/x 「범위 밖」 (거짓 경고)
584
+ * cat $HOMEPATH/x → C:\Users\나PATH/x 「범위 밖」 (거짓 경고)
585
+ * cp a $USERPROFILE_BAK/b → C:\Users\나_BAK/b 「범위 밖」 (거짓 경고)
586
+ *
587
+ * 셋 다 우리가 값을 모르는 변수다. 모르는 것은 아래에서 「못 푼 자리표」 로
588
+ * 걸러져 낱말째 건너뛴다 — 그게 여태 약속이었는데 이 줄이 반쯤 풀어
589
+ * **아는 척**을 하고 있었다. 중괄호 꼴(`${HOME}`)은 제 닫는 괄호가
590
+ * 경계라 그대로 받는다.
591
+ */
592
+ // 낱말 경계는 **글자 전부**로 본다 — `\w` 는 아스키뿐이라 `$HOME한글` 의
593
+ // `$HOME` 을 먹었다. 파워셸은 한글 변수 이름을 받는다 (3차 눈 codex).
594
+ .replace(/\$\{HOME\}|\$HOME(?![\p{L}\p{N}_])|%USERPROFILE%|\$\{USERPROFILE\}|\$USERPROFILE(?![\p{L}\p{N}_])/giu, homedir());
364
595
  }
365
596
 
366
597
  /**
@@ -411,6 +642,11 @@ export function 코드조각인가(낱말) {
411
642
  *
412
643
  * 뿌리를 통째로 미는 명령이 걱정이라면 그건 여기 몫이 아니다 —
413
644
  * `rm -rf /` 는 checkCommand 의 BLOCKED 가 따로, 더 확실하게 잡는다.
645
+ *
646
+ * `//` · `///` 도 여기서 계속 넘긴다(사냥5 R5-H1). 이 약속이 `/` 한 자만 지키고 있어서
647
+ * `rm -rf //` 가 양쪽을 다 지나갔는데, 고칠 자리는 여기가 아니라 뿌리목표 였다. 여기서
648
+ * 빗금 두 자를 경로로 치면 heredoc 으로 넣은 코드의 `// 주석` 이 낱말로 잘려 와 **뿌리 = 범위 밖**
649
+ * 이 되고, 코드를 넣는 명령이 전부 막힌다 — 이 함수가 생긴 까닭 그대로다.
414
650
  */
415
651
  if (/^[/\\]+$/.test(낱말)) return true;
416
652
  // 빗금을 백슬래시로 흘린 것(`\/div`)도 정규식이지 경로가 아니다.
@@ -467,7 +703,10 @@ export function 코드조각인가(낱말) {
467
703
  */
468
704
  const 버리는자리 = new Set([
469
705
  '/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty',
470
- 'nul', 'nul:', '\\\\.\\nul', // 윈도우
706
+ // 윈도우. `\\.\nul` 은 여기 안 적는다 — 아래 봐주는자리() 가 역빗금을
707
+ // 빗금으로 바꾼 뒤에 견주므로 이 철자는 **한 번도 안 맞는다.** 그 꼴은
708
+ // 장치 이름 공간 껍데기를 벗겨 `nul` 로 만들어 이 줄에 닿게 한다.
709
+ 'nul', 'nul:',
471
710
  ]);
472
711
 
473
712
  /**
@@ -501,18 +740,102 @@ const 프로그램자리 = [
501
740
  'c:/program files', 'c:/program files (x86)',
502
741
  ];
503
742
 
743
+ /*
744
+ * ── 목록에 없는 자리에 사는 node (2.0.0 16회차) ─────────────────────────
745
+ *
746
+ * 위 목록은 **OS 가 깔아 준 자리**만 적는다. 그런데 요즘 node 는 거기 안
747
+ * 산다 — nvm 은 `~/.nvm/versions/node/v22.14.0/bin`, fnm·volta·asdf 는
748
+ * 저마다 다른 자리, 깃허브 러너는 `/opt/hostedtoolcache/node/24.20.0/x64/bin`
749
+ * 이다. 그래서 **같은 프로그램이 철자에 따라 갈렸다** —
750
+ *
751
+ * node x.js 그대로 돈다
752
+ * /opt/hostedtoolcache/node/24.20.0/x64/bin/node x.js 「작업 범위 밖입니다」
753
+ *
754
+ * 같은 파일이다. 막은 것이 아니라 **거짓 경고**이고, 이 판 내내 잡아 온
755
+ * 「내가 예로 적어 둔 철자로 적은 사람만 막았다」 와 같은 부류다.
756
+ * (리눅스 CI 에서 test/abort-tools.test.js 세 자리가 이것으로 빨개졌다.)
757
+ *
758
+ * 자리를 하나씩 더 적어 넣으면 다음 도구가 나올 때 또 샌다. 그래서 목록을
759
+ * 늘리는 대신 **지금 우리를 돌리고 있는 그 node 의 폴더**를 받는다. 거기에
760
+ * 프로그램이 있다는 것은 물어볼 것도 없이 참이다 — 우리가 거기서 떴다.
761
+ * 깊이는 나머지와 같은 한 칸이라 `bin/node`·`bin/npm`·`bin/npx` 까지고,
762
+ * 뿌리(`/` · `c:/`)로 나오면 안 쓴다 — 그건 온 디스크를 여는 것이다.
763
+ */
764
+ function 나를돌린자리() {
765
+ let p;
766
+ try { p = dirname(process.execPath); } catch { return null; }
767
+ if (!p) return null;
768
+ const 낮춤 = p.replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '');
769
+ if (!낮춤 || 낮춤 === '' || /^[a-z]:$/.test(낮춤)) return null;
770
+ return 낮춤;
771
+ }
772
+
504
773
  /**
505
774
  * 이 경로는 울타리를 안 물어도 되는 자리인가.
506
775
  *
507
776
  * @returns {string|null} 봐 주는 까닭. 아니면 null
508
777
  */
778
+ /*
779
+ * `.` 과 `..` 를 펴서 견줄 수 있는 꼴로 만든다.
780
+ *
781
+ * ── 왜 여기서 펴야 하나 ────────────────────────────────────────────────
782
+ *
783
+ * 아래 봐주는자리() 는 **글자 앞머리**로 견줬고, 그 머리말은 이렇게 적혀
784
+ * 있었다 — 「`/usr/bin/../../Users/남` 은 여기 오기 전에 resolve 로 펴져서
785
+ * 이 목록에 안 걸린다」. 그런 자는 없었다. checkPaths 는 **날 낱말**을
786
+ * 그대로 넘기고(guard.js 의 `봐주는자리(t)`), 걸리면 `continue` 로 울타리
787
+ * 검사를 통째로 건너뛴다.
788
+ *
789
+ * 그래서 이 넉 줄이 다 통과했다 —
790
+ *
791
+ * type "C:\Program Files\..\..\Users\나\.deel\config.json"
792
+ * cat "C:/Program Files (x86)/../../Users/나/.ssh/id_rsa"
793
+ * cat /Applications/../Users/나/.ssh/id_rsa
794
+ * cd "C:/Program Files/../.." && dir
795
+ *
796
+ * `/usr/bin` 이 안 새던 것은 깊이 한 칸 규칙에 우연히 걸려서였지 이 머리말
797
+ * 때문이 아니었다. 꾸러미자리(`Program Files`·`Applications`)는 깊이를 안
798
+ * 보므로 그 아래로 **무한정** 열려 있었다.
799
+ *
800
+ * 화면에는 아무 말도 안 난다. 건너뛰기는 `continue` 라서 「이 낱말은 안
801
+ * 봤습니다」 를 적는 자리가 없다. 열쇠가 든 config.json 이 그대로 읽혀
802
+ * 대화에 실리고 `.deel/sessions/*.jsonl` 에 남는다.
803
+ *
804
+ * 폄은 글자로 한다. 디스크에 안 물어본다 — 봐주는자리는 없는 경로에도
805
+ * 답해야 하고, 여기서 존재를 물으면 답이 기계마다 달라진다.
806
+ */
807
+ function 점펴기(p) {
808
+ const 조각 = p.split('/');
809
+ const out = [];
810
+ for (const s of 조각) {
811
+ if (s === '.') continue;
812
+ // 맨 앞 조각은 뿌리 표시(`` 나 `c:`)라 남긴다. 그 위로는 못 올라간다.
813
+ if (s === '..') { if (out.length > 1) out.pop(); continue; }
814
+ out.push(s);
815
+ }
816
+ return out.join('/');
817
+ }
818
+
509
819
  export function 봐주는자리(경로) {
510
- const p = String(경로 ?? '').replace(/\\/g, '/');
820
+ const 빗금 = String(경로 ?? '').replace(/\\/g, '/');
821
+ /*
822
+ * `\\.\nul` 은 윈도우의 **장치 이름 공간**이다 (사냥8). `nul` 과 같은
823
+ * 자리인데 여기서 한 번도 안 걸렸다 — 역빗금을 빗금으로 바꾸면 `//./nul`
824
+ * 이 되고, 점펴기 가 가운데 `.` 을 「지금 폴더」 로 읽어 지워 `//nul` 이
825
+ * 되기 때문이다. 그래서 `type a.txt > \\.\nul` 이 「범위 밖」 으로 막혔다.
826
+ *
827
+ * 껍데기만 벗기고 **안은 그대로 본다**. `\\.\C:\Users\남\.deel` 은 벗겨도
828
+ * `c:/users/남/.deel` 이라 아래 어느 목록에도 안 걸리고, 그대로 울타리
829
+ * 검사를 받는다 — 여는 길이 아니다.
830
+ */
831
+ const 장치 = /^\/\/\.\/(.+)$/.exec(빗금);
832
+ const p = 점펴기(장치 ? 장치[1] : 빗금);
511
833
  const 낮춤 = p.toLowerCase().replace(/\/+$/, '');
512
834
  if (버리는자리.has(낮춤)) return '아무것도 저장되지 않는 자리';
513
835
  // 한 칸 아래까지만 본다. `/usr/bin/strings` 는 되고 `/usr/bin/../../Users/남` 은
514
836
  // 여기 오기 전에 resolve 로 펴져서 이 목록에 안 걸린다.
515
- for (const 자리 of 프로그램자리) {
837
+ const 내자리 = 나를돌린자리();
838
+ for (const 자리 of (내자리 && !프로그램자리.includes(내자리) ? [...프로그램자리, 내자리] : 프로그램자리)) {
516
839
  if (낮춤 === 자리) return '프로그램이 있는 자리';
517
840
  if (!낮춤.startsWith(`${자리}/`)) continue;
518
841
  /*
@@ -537,6 +860,10 @@ export function 봐주는자리(경로) {
537
860
  return null;
538
861
  }
539
862
 
863
+ // 값이 경로가 아니라 **글**인 긴 옵션. 아래 경로낱말() 이 `--이름=값` 의 값을 떼어 볼 때 뺀다
864
+ // (`git commit --message=../notes` · `git log --format=%h/%s`). curl 의 `--data=@파일` 은 파일을 읽으니 안 뺀다.
865
+ const 글받는옵션 = /^(?:message|format|pretty|title|body|subject|description|grep|author|committer|label|comment|notes?)$/i;
866
+
540
867
  /** 명령줄에서 경로처럼 보이는 낱말만 골라낸다. */
541
868
  export function 경로낱말(cmd) {
542
869
  const out = [];
@@ -546,6 +873,46 @@ export function 경로낱말(cmd) {
546
873
  while ((m = re.exec(cmd))) {
547
874
  let t = m[1] ?? m[2] ?? m[0];
548
875
  if (!t) continue;
876
+ /*
877
+ * 이름 뒤에 붙은 경로는 값만 떼어 본다 (2.0.0 3회차 사냥).
878
+ *
879
+ * 옵션을 통째로 넘겼더니 `cat .deel/config.json` 은 막히는데 `dd if=.deel/config.json` 과
880
+ * `tar --directory=/etc` · `cp --target-directory=/etc` 는 지나갔다 — 같은 파일, 같은 자리다.
881
+ * 긴 옵션의 값과 dd 의 `if=` · `of=` 만 뗀다. 아무 `이름=값` 이나 떼면 `NODE_PATH=/usr/lib/node
882
+ * npm test` 같은 환경변수 앞머리가 범위 밖으로 막힌다.
883
+ */
884
+ const 붙은값 = /^--([A-Za-z][\w-]*)=(.+)$/.exec(t) ?? /^(if|of)=(.+)$/.exec(t);
885
+ if (붙은값) {
886
+ if (글받는옵션.test(붙은값[1])) continue; // `--message=../notes` 는 글이다
887
+ /*
888
+ * 따옴표를 벗긴다 (사냥8). 위 낱말 자르개는 `"` 로 **시작하는** 것만
889
+ * 한 낱말로 묶으므로, `dd if="../secret.txt"` 는 통째로 한 낱말이 되고
890
+ * 값에 따옴표가 그대로 남았다. 그러면 `"../secret.txt` 라는 이름의
891
+ * 파일을 찾게 되어 `..` 이 한 번도 안 풀렸다 —
892
+ *
893
+ * dd if=../secret.txt 막힘
894
+ * dd if="../secret.txt" 통과 ← 셸에서는 같은 파일이다
895
+ * tar --directory="/남의것" 통과 ←
896
+ */
897
+ /*
898
+ * 따옴표는 **전부** 뗀다 (3차 눈 codex).
899
+ *
900
+ * 바깥 한 쌍만 뗐더니 반만 감싼 것이 그대로 지나갔다 —
901
+ * `tar --directory=../".." -cf out.tar .` 는 셸이 `../..` 로 넘긴다.
902
+ * checkCommand 도 규칙을 「따옴표 뺀 판」 에 한 번 더 먹이는데
903
+ * (위 따옴표뺀), 이 자리만 바깥 한 쌍에 묶여 있었다.
904
+ */
905
+ t = 붙은값[2].replace(/["'`]/g, '');
906
+ }
907
+ /*
908
+ * 컨테이너 볼륨 `./x:/app` — 콜론 뒤는 컨테이너 **안** 자리다. 이 PC 쪽(앞)만 본다.
909
+ * 통째로 봤더니 `docker run -v ./x:/app` 이 「범위 밖」 으로 막혔다(3회차 사냥, 1.20 부터).
910
+ * 주소(`https://`)·`file:` 과 드라이브(`C:/` · `\\?\C:/`)는 건드리지 않는다 — 아래가 따로 본다.
911
+ */
912
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(t) && !/^file:/i.test(t)) {
913
+ const 볼륨 = /^([^:]{2,}):\/[^:]*(?::[a-z,]+)?$/i.exec(t);
914
+ if (볼륨 && !/^[\\/]{2}[?.][\\/][A-Za-z]$/.test(볼륨[1])) t = 볼륨[1];
915
+ }
549
916
  if (t.startsWith('-')) continue; // 옵션
550
917
  /*
551
918
  * 주소는 이 울타리가 볼 것이 아니다 — 그물로 나가는 것은 safety/network.js
@@ -586,6 +953,12 @@ export function 경로낱말(cmd) {
586
953
  * 남의 파일이다. 넓히다 반대로 베면 모델이 까닭 없이 무언가를 못 하게
587
954
  * 되는데, 그것을 신고해 줄 사람이 없다.
588
955
  */
956
+ /*
957
+ * `~-` 는 셸의 **이전 폴더**(OLDPWD)다. deel 을 띄우기 전에 어디 있었는지는 우리가 모르고,
958
+ * 작업 범위 밖일 수 있다. 안 풀면 `./~-` 라는 이름으로 읽혀 `rm -rf ~-` 가 범위 안으로
959
+ * 통과했다(사냥5 지킴 Gemini). 빗금이 없어도 자리로 올려 checkPaths 가 밖으로 막게 한다.
960
+ */
961
+ if (/^~-(?=[/\\]|$)/.test(풀린)) { out.push(풀린); continue; }
589
962
  const 살림이름 = 풀린 === '.deel' || 풀린.toLowerCase() === '.deel';
590
963
  const 경로같나 = 풀린.includes('/') || 풀린.includes('\\') || /^[a-zA-Z]:$/.test(풀린) || 살림이름;
591
964
  if (!경로같나) continue;
@@ -608,6 +981,39 @@ export function 경로낱말(cmd) {
608
981
  if (코드조각인가(풀린)) continue;
609
982
  out.push(풀린);
610
983
  }
984
+ /*
985
+ * ── 낱말 **가운데** 따옴표를 두르면 울타리가 통째로 비켰다 ────────────
986
+ *
987
+ * 코드조각인가() 는 따옴표가 낱말 안에 섞여 있으면 「코드를 가운데서 자른
988
+ * 것」 으로 보고 그 낱말을 **후보에서 뺀다.** 그 판단의 근거는 「파일
989
+ * 이름에 따옴표를 쓰는 사람은 없다」 인데, 여기서 보는 것은 파일 이름이
990
+ * 아니라 **셸에 적힌 낱말**이다. 셸에서는 이 셋이 같은 파일이다:
991
+ *
992
+ * cat ../../secret.txt 막힘
993
+ * cat ../../'secret.txt' 통과 ← 같은 파일
994
+ * cat ../../secret".txt" 통과 ← 같은 파일
995
+ *
996
+ * 따옴표를 통째로 두른 것만 위 낱말 나누기가 벗겨 준다. 가운데만 두른
997
+ * 것은 안 벗겨지고, 그대로 코드 조각으로 몰려 검사를 안 받는다.
998
+ *
999
+ * 그래서 따옴표를 **뗀 꼴도 후보에 넣는다.** checkCommand 가 이미 쓰는
1000
+ * 방법과 같다 — 막는 쪽으로만 늘어난다. 뗐더니 아무것도 안 남거나 여전히
1001
+ * 코드 조각이면 그냥 안 들어간다.
1002
+ */
1003
+ for (const m of String(cmd).matchAll(/"([^"]*)"|'([^']*)'|[^\s|;&<>()]+/g)) {
1004
+ // 통째로 두른 것은 위 낱말 나누기가 이미 벗겨서 봤다. 여기서 또 보면
1005
+ // `"C:/Program Files/Git/bin/git.exe"` 가 빈칸에서 잘려 `C:/Program` 이
1006
+ // 범위 밖으로 읽힌다 — 막는 쪽으로 늘리려다 멀쩡한 명령을 막는 자리다.
1007
+ if (m[1] !== undefined || m[2] !== undefined) continue;
1008
+ const 날것 = m[0];
1009
+ if (!/["'`]/.test(날것)) continue; // 따옴표가 없으면 위에서 이미 봤다
1010
+ const 뗀것 = 자리표풀기(날것.replace(/["'`]/g, ''));
1011
+ if (!뗀것 || 뗀것.startsWith('-')) continue;
1012
+ if (!뗀것.includes('/') && !뗀것.includes('\\')) continue;
1013
+ if (/[$%]/.test(뗀것)) { if (내부살림(뗀것)) out.push(뗀것); continue; }
1014
+ if (코드조각인가(뗀것)) continue;
1015
+ if (!out.includes(뗀것)) out.push(뗀것);
1016
+ }
611
1017
  return out;
612
1018
  }
613
1019
 
@@ -698,7 +1104,35 @@ function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new
698
1104
  for (const 마디 of 마디들(cmd)) {
699
1105
  const 낱말 = 낱말들(마디);
700
1106
  if (!낱말.length) continue;
701
- const 첫 = 낱말[0].글;
1107
+ /*
1108
+ * ── cmd 에서 `cd..` 는 `cd ..` 다 (사냥8) ────────────────────────────
1109
+ *
1110
+ * 폴더옮김 은 첫 낱말이 **딱** `cd` 일 때만 봤다. 그런데 cmd.exe 는
1111
+ * 빈칸을 안 요구한다 — `cd..` · `cd..\..` · `chdir..` 이 전부 같은 일을
1112
+ * 한다. 그래서 이 셋이 울타리를 그냥 지나갔다:
1113
+ *
1114
+ * cd .. && type secret.txt 막힘
1115
+ * cd.. && type secret.txt 통과 ← 빈칸 한 칸 차이
1116
+ *
1117
+ * `/d` 스위치가 붙어 오는 것(`cd/d ..`)도 같이 편다. 다만 `cdk synth`
1118
+ * 처럼 `cd` 로 **시작만** 하는 다른 명령은 안 삼킨다 — 뒤에 올 수 있는
1119
+ * 것을 `/d` 와 점으로만 못박았다.
1120
+ */
1121
+ const 붙은첫 = /^(cd|chdir|pushd)(?:\/d)?(\.[.\\/]*)?$/i.exec(낱말[0].글);
1122
+ const 첫 = 붙은첫 ? 붙은첫[1] : 낱말[0].글;
1123
+ /*
1124
+ * 뒤꽁무니의 역빗금은 **여기서 눕힌다** (2.0.0 16회차).
1125
+ *
1126
+ * `cd..\..` 는 cmd 의 철자라 칸막이가 `\` 로 온다. 윈도우에서는 resolve
1127
+ * 가 그걸 칸막이로 읽어 두 칸 위로 가지만, 리눅스·맥에서는 `..\..` 가
1128
+ * **한 칸짜리 폴더 이름**이라 제자리에 머문다 — 같은 글자가 판에 따라
1129
+ * 다른 뜻이 되고, 울타리는 한쪽에서만 섰다. (리눅스 CI 에서 이 검사
1130
+ * 하나가 「통과했습니다」 로 빨개진 것이 이것이다.)
1131
+ *
1132
+ * 위 무늬가 이 조각을 점과 빗금으로만 못박아 뒀으니 여기서 눕혀도
1133
+ * 진짜 파일 이름을 다칠 일이 없다 — 이 자리에는 이름이 못 온다.
1134
+ */
1135
+ const 붙은목적 = 붙은첫?.[2] ? 붙은첫[2].replace(/\\/g, '/') : null;
702
1136
 
703
1137
  // 셸을 한 겹 씌운 것은 안을 들여다본다. 그 안의 cd 는 딴 살림이라
704
1138
  // (하위 셸이다) 우리 자리는 안 바뀐다 — 결과만 받고 여기는 그대로 둔다.
@@ -709,7 +1143,7 @@ function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new
709
1143
  if (되돌리기.test(첫)) { 여기 = 쌓은것.pop() ?? 여기; continue; }
710
1144
  if (!폴더옮김.test(첫)) continue;
711
1145
 
712
- const 목적 = 갈자리(낱말.slice(1));
1146
+ const 목적 = 붙은목적 ?? 갈자리(낱말.slice(1));
713
1147
  // 인자 없는 cd(집으로) · `cd -`(직전 자리) 는 어디로 가는지 여기서 모른다.
714
1148
  // 모르는 것을 막으면 멀쩡한 명령이 죽는다.
715
1149
  if (목적 == null || 목적 === '-') continue;
@@ -754,11 +1188,47 @@ function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new
754
1188
  * 이것도 **첫 낱말**일 때만 본다. 전에는 아무 데나 있으면 걸렸다.
755
1189
  * 그래서 `node scripts/copy.js` 나 `grep -r "del" src/` 가 '변경성 명령' 이 되어
756
1190
  * 두 번째 시도가 막혔다. 진짜로 파일을 옮기는 명령과 이름만 스친 것은 다르다.
1191
+ *
1192
+ * ── 지우기·옮기기 무리는 **셸이 달라도** 같은 잣대 (6회차 Gemini 쓰나6n O1·O2) ──────
1193
+ *
1194
+ * confirm 모드는 `Bash && isMutating` 일 때만 묻는다(agent/loop.js). rm · del · mv · move 는 걸렸는데
1195
+ * PowerShell 의 Remove-Item · cmd 의 rmdir · rd · ren · erase · git rm · git mv · git clean · find -delete 는
1196
+ * 안 걸려서, 셸로 PowerShell 이 골라진 판(tools/shell.js)에서는 지우기가 **안 묻고** 돌고 재실행 거부도 없었다.
1197
+ * 아래 셸쓰기무늬 머리말이 걱정한 것은 `echo x > log` 류 **쓰기**를 넣는 일이고, 이것들은 이미 있는 rm · del 과
1198
+ * 같은 무리다. 첫 자리만 보는 것은 그대로라 `node scripts/rename.js` · `npm run rd` 는 안 걸린다.
1199
+ */
1200
+ /*
1201
+ * ── 앞 빈칸 한 칸에 이 규칙이 통째로 꺼졌다 (사냥8) ─────────────────────
1202
+ *
1203
+ * 머리는 `(^|[|;&]\s*)` 였다. 글 맨 앞은 **빈칸이 하나도 없을 때만** 이라는
1204
+ * 뜻이다. 그런데 부르는 쪽(agent/loop.js)은 모델이 준 글을 안 다듬고 그대로
1205
+ * 넘긴다. 모델은 줄바꿈과 들여쓰기를 곧잘 붙인다 —
1206
+ *
1207
+ * isMutating('rm -rf src') true
1208
+ * isMutating(' rm -rf src') false ← 빈칸 한 칸
1209
+ * isMutating('\n git push') false ← 줄바꿈 하나
1210
+ *
1211
+ * 거짓이면 재실행 거부도, 엄격 모드의 물음도 통째로 안 돈다. 막는 규칙이
1212
+ * 아니라 **여는 규칙**이라 화면에는 아무 말도 안 난다.
1213
+ */
1214
+ // 줄바꿈도 이음매다 — `echo prep\ngit push` 가 안 걸렸다 (3차 눈 codex).
1215
+ const MUTATING = /(^\s*|[|;&\n\r]\s*)(sudo\s+)?(git\s+(commit|push|merge|rebase|reset|rm|mv|clean)|npm\s+(publish|install)|pip\s+install|mv|cp|del|erase|rm|rmdir|rd|ren|rename|unlink|shred|move|copy|remove-item|rename-item|ri|rni|find\b[^|;&]*\s-delete|curl\s+-X\s*(POST|PUT|DELETE|PATCH))\b/i;
1216
+
1217
+ /*
1218
+ * 따옴표 **안**은 셸의 이음매가 아니다 (2차 눈이 짚음).
1219
+ *
1220
+ * curl "https://example.com?page=1&del=true"
1221
+ *
1222
+ * 이 한 줄의 `&del` 이 「지우기 명령이 이어 붙었다」 로 읽혀, 멀쩡한 조회가
1223
+ * 변경성으로 걸렸다 — 재실행 거부에 막히고 엄격 모드에서 승인 창이 떴다.
1224
+ * 위 checkCommand 는 이미 따옴표를 가려 마디를 자르는데(마디들) 여기만
1225
+ * 날글을 봤다. 길이를 지켜 가리므로 뒤 규칙의 자리 계산은 안 흔들린다.
757
1226
  */
758
- 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;
1227
+ const 따옴안가리기 = (s) => String(s ?? '')
1228
+ .replace(/"[^"]*"|'[^']*'/g, (m) => m[0] + 'x'.repeat(m.length - 2) + m[0]);
759
1229
 
760
1230
  export function isMutating(cmd) {
761
- return MUTATING.test(String(cmd));
1231
+ return MUTATING.test(따옴안가리기(cmd));
762
1232
  }
763
1233
 
764
1234
  /*
@@ -806,8 +1276,30 @@ const 셸쓰기무늬 = [
806
1276
  /\btruncate\b/i,
807
1277
  /\bdd\b[^|;&]*\bof=/i,
808
1278
  /--write\b/i, // prettier --write 류
1279
+ /\bprettier\b[^|;&]*\s-w\b/i, // prettier -w (줄임꼴 — --write 만 보고 놓쳤다 · 6회차 Gemini 쓰나6n O3)
809
1280
  /\bpatch\b/i,
810
- /\bgit\s+(checkout\s+--|restore)\b/i, // 작업 트리를 되돌리는 것도 파일을 바꾼다
1281
+ // 작업 트리를 되돌리는 것도 파일을 바꾼다. `--` 뒤는 `\b` 로 못 잰다 — `-` 와 공백 사이는 경계가 아니라서
1282
+ // `git checkout -- f.js` 는 절대 안 걸리고 `git checkout --help` 만 걸렸다 (6회차 — Claude 가 읽다 찾음).
1283
+ /\bgit\s+(checkout\s+--(?=\s|$)|restore\b)/i,
1284
+ /*
1285
+ * 한 줄짜리 프로그램 (사냥8).
1286
+ *
1287
+ * 위 머리말이 「모델이 파일을 고치는 흔한 방법」 으로 `node -e "…"` 와
1288
+ * `python -c "…"` 를 이미 적어 두고도 목록에는 한 줄도 없었다 — 적어 둔
1289
+ * 것과 코드가 어긋난 자리다. 그 안에서 무엇을 하는지는 안 읽는다. 여기서
1290
+ * 넉넉히 잡아 손해는 안 떠도 될 스냅샷 한 장이고, 안 잡으면 그 턴이 기록을
1291
+ * 한 줄도 안 남겨 /undo 가 **앞의 무관한 턴**을 되돌린다.
1292
+ *
1293
+ * 첫 낱말이 아니라 마디 안 어디에 있어도 본다 — `cd x && node -e "…"` 가
1294
+ * 흔하다. 대신 `-e` · `-c` 는 낱말째여야 해서 `--help` 나 `-eval` 은 안 걸린다.
1295
+ */
1296
+ /*
1297
+ * 2차 눈(Gemini)이 짚었다 — `-p`(`--print`) 도 코드를 돌리고, 셸은 플래그와
1298
+ * 값 사이 빈칸을 요구하지 않는다(`python -c"…"`). 윈도우에는 기본 런처 `py`
1299
+ * 도 있다. 여기는 넉넉한 쪽이 옳은 자리라 셋 다 받는다.
1300
+ */
1301
+ /\bnode(?:\.exe)?\b[^|;&]*\s--?(?:e|p|-eval|-print)(?=[\s="']|$)/i,
1302
+ /\b(?:python[\d.]*|py)(?:\.exe)?\b[^|;&]*\s-c(?=[\s="']|$)/i,
811
1303
  ];
812
1304
 
813
1305
  /** 이 명령이 파일 내용을 바꿀 낌새인가. **스냅샷 판단에만** 쓴다. */