deel-local-cli 1.5.7 → 1.6.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.en.md +24 -13
- package/README.md +23 -12
- package/bin/deel.js +10 -0
- package/package.json +2 -2
- package/src/acp/map.js +143 -0
- package/src/acp/serve.js +160 -13
- package/src/agent/commit.js +511 -0
- package/src/agent/compact.js +269 -226
- package/src/agent/loop.js +154 -12
- package/src/agent/mention.js +56 -10
- package/src/agent/review.js +192 -0
- package/src/agent/session.js +20 -2
- package/src/agent/threads.js +1 -1
- package/src/backend/adapter.js +342 -280
- package/src/backend/azure.js +151 -0
- package/src/backend/ctxsize.js +19 -0
- package/src/backend/detect.js +106 -0
- package/src/backend/http.js +350 -30
- package/src/backend/probe.js +48 -1
- package/src/backend/proxy.js +151 -0
- package/src/backend/quota.js +133 -0
- package/src/backend/retry.js +132 -0
- package/src/backend/vision.js +185 -0
- package/src/commands.js +341 -8
- package/src/completion.js +264 -0
- package/src/config.js +117 -1
- package/src/i18n/en.js +13 -1
- package/src/i18n/index.js +22 -3
- package/src/i18n/ja.js +266 -0
- package/src/i18n/ko.js +12 -0
- package/src/i18n/zh.js +266 -0
- package/src/oneshot.js +28 -3
- package/src/pack/sbom.js +25 -0
- package/src/pack/selfpack.js +9 -3
- package/src/plugins/manage.js +34 -7
- package/src/repl.js +113 -15
- package/src/report.js +26 -2
- package/src/safety/guard.js +82 -3
- package/src/safety/keystore.js +237 -0
- package/src/safety/network.js +76 -14
- package/src/safety/policy.js +209 -0
- package/src/safety/secrets.js +32 -0
- package/src/setup.js +40 -7
- package/src/tools/clipboard.js +178 -0
- package/src/tools/fastgrep.js +217 -0
- package/src/tools/fsutil.js +55 -12
- package/src/tools/ignore.js +192 -0
- package/src/tools/index.js +282 -39
- package/src/tools/jobs.js +4 -12
- package/src/tools/outline.js +3 -1
- package/src/tools/pdf.js +1326 -0
- package/src/tools/shell.js +123 -0
- package/src/tools/verify.js +20 -4
- package/src/tools/webfetch.js +18 -5
- package/src/ui/inputbox.js +2 -2
- package/src/ui/screen.js +4 -1
- package/src/ui/status.js +22 -0
package/src/safety/guard.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
|
|
3
3
|
// (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
|
|
4
4
|
import { resolve, relative, isAbsolute, sep, dirname, basename } from 'node:path';
|
|
5
|
-
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { realpathSync, existsSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { 내부살림 } from '../tools/fsutil.js';
|
|
8
8
|
|
|
@@ -68,6 +68,13 @@ function 진짜자리(p) {
|
|
|
68
68
|
return p;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** `/c/Users/…` → `C:/Users/…`. 윈도우에서만, 드라이브 글자 한 자짜리 첫 칸만. */
|
|
72
|
+
export function MSYS풀기(p) {
|
|
73
|
+
if (process.platform !== 'win32') return p;
|
|
74
|
+
const m = /^\/([a-zA-Z])(\/|$)/.exec(p);
|
|
75
|
+
return m ? `${m[1].toUpperCase()}:${p.slice(2) || '/'}` : p;
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
// 작업 범위 — deel 를 띄운 폴더. 그 밖은 읽기도 쓰기도 막는다.
|
|
72
79
|
export function makeScope(root) {
|
|
73
80
|
const base = resolve(root);
|
|
@@ -78,7 +85,10 @@ export function makeScope(root) {
|
|
|
78
85
|
root: base,
|
|
79
86
|
resolve(p) {
|
|
80
87
|
if (!p || typeof p !== 'string') throw new ScopeError('경로가 비었습니다');
|
|
81
|
-
|
|
88
|
+
// Git Bash 꼴 절대 경로(/c/Users/…)는 윈도우에서 C:\Users\… 다. 안 풀면 지금
|
|
89
|
+
// 드라이브 밑의 \c\Users\… 로 읽혀, 안에 있는 파일이 '범위 밖' 으로 막힌다.
|
|
90
|
+
const 경로 = MSYS풀기(p);
|
|
91
|
+
const abs = isAbsolute(경로) ? resolve(경로) : resolve(base, 경로);
|
|
82
92
|
if (밖(base, abs)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
|
|
83
93
|
// 글자로는 안인데 링크를 따라가면 밖인 자리. 정션 하나로 울타리가 열린다.
|
|
84
94
|
//
|
|
@@ -102,7 +112,8 @@ export function makeScope(root) {
|
|
|
102
112
|
|
|
103
113
|
// 되돌릴 수 없는 것만 막는다. 목록이 길어지면 도구가 쓸모없어진다.
|
|
104
114
|
const BLOCKED = [
|
|
105
|
-
|
|
115
|
+
// `/c` · `/c/` 는 Git Bash 에서 C:\ 다 — 윈도우에서 bash 를 쓰게 되면서 닿게 된 자리.
|
|
116
|
+
{ re: /\brm\s+(-[a-z]*[rR][a-z]*f|-[a-z]*f[a-z]*[rR])\b[^|;&]*\s(\/|\/[a-z]\/?|~|\$HOME)\s*$/i, why: '뿌리 폴더를 통째로 지우려 합니다' },
|
|
106
117
|
{ re: /\b(mkfs|fdisk|diskpart)\b/i, why: '디스크를 초기화하는 명령입니다' },
|
|
107
118
|
{ re: /\bformat\s+[a-z]:/i, why: '드라이브를 포맷하는 명령입니다' },
|
|
108
119
|
// 윈도우 짝을 빠뜨리면 안 된다 — deel 은 윈도우에서 주로 돈다.
|
|
@@ -204,6 +215,73 @@ function 자리표풀기(s) {
|
|
|
204
215
|
.replace(/\$\{?HOME\}?|%USERPROFILE%|\$\{?USERPROFILE\}?/gi, homedir());
|
|
205
216
|
}
|
|
206
217
|
|
|
218
|
+
/**
|
|
219
|
+
* 이 낱말은 경로가 아니라 **코드 조각**인가.
|
|
220
|
+
*
|
|
221
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────
|
|
222
|
+
*
|
|
223
|
+
* 여기 오는 것은 셸 명령줄인데, 모델은 그 안에 자바스크립트를 통째로 넣는다
|
|
224
|
+
* (`node -e "…"` · `node - <<'NODE'`). 그러면 코드 안의 슬래시가 전부 경로로
|
|
225
|
+
* 보인다. 실제로 이렇게 막혔다 —
|
|
226
|
+
*
|
|
227
|
+
* ▶ Bash(node - <<'NODE' const fs=require('fs'); …)
|
|
228
|
+
* └ 막힘 — 작업 범위 밖입니다: /data:image\/[\s\S]*?base64,[^"']+/g,'data:…'
|
|
229
|
+
* ▶ Bash(node -e "…")
|
|
230
|
+
* └ 막힘 — 작업 범위 밖입니다: /div
|
|
231
|
+
*
|
|
232
|
+
* 앞엣것은 정규식 리터럴이고 뒤엣것은 `</div>` 가 잘려 온 것이다. 둘 다 파일과
|
|
233
|
+
* 아무 상관이 없는데 명령 **전체**가 거절됐다. 모델은 왜 막혔는지 모르니
|
|
234
|
+
* 따옴표만 바꿔 가며 대여섯 번을 다시 시도했다 — 한 요청이 몇 분씩 길어진 것의
|
|
235
|
+
* 큰 몫이 이것이었다.
|
|
236
|
+
*
|
|
237
|
+
* ── 울타리를 얼마나 무르게 하나 ─────────────────────────────────────────
|
|
238
|
+
*
|
|
239
|
+
* 여기서 넘긴 낱말은 검사를 안 받는다. 그래서 넘기는 기준을 좁게 잡는다 —
|
|
240
|
+
* **실수로 밖을 건드리는** 경로는 `/Users/남/것` 처럼 평범하게 생겼지, 정규식
|
|
241
|
+
* 메타문자나 따옴표를 달고 오지 않는다. 반대로 일부러 뚫으려는 사람은 이
|
|
242
|
+
* 검사가 있든 없든 `cd ..` 나 `$(…)` 로 지나간다 — 이건 원래 사고를 막는
|
|
243
|
+
* 턱이지 봉인이 아니다(파일 도구 쪽 울타리는 그대로다).
|
|
244
|
+
*
|
|
245
|
+
* 글로브(`*` `?`)는 일부러 안 넘긴다. `rm /Users/남/*` 은 진짜 경로이고,
|
|
246
|
+
* 그건 계속 걸려야 한다.
|
|
247
|
+
*/
|
|
248
|
+
export function 코드조각인가(낱말) {
|
|
249
|
+
// 따옴표가 낱말 **안**에 섞였다 = 코드를 가운데서 자른 것이다.
|
|
250
|
+
// 파일 이름에 따옴표를 쓰는 사람은 없다시피 하고, 있어도 셸에서 못 쓴다.
|
|
251
|
+
if (/["'`]/.test(낱말)) return true;
|
|
252
|
+
|
|
253
|
+
// `/` 로 시작하는데 정규식 메타문자가 들어 있다 = 정규식 리터럴.
|
|
254
|
+
// 진짜 경로에는 이런 글자가 거의 안 들어간다.
|
|
255
|
+
if (낱말.startsWith('/') && /[\\[\](){}|^$+]/.test(낱말)) return true;
|
|
256
|
+
|
|
257
|
+
/*
|
|
258
|
+
* 빗금만 남은 조각.
|
|
259
|
+
*
|
|
260
|
+
* `s.replace(/<\/div>/g, '')` 한 줄이 `/` · `\/div` · `/g,` 셋으로 잘린다
|
|
261
|
+
* (낱말 나누기가 `<` `>` 를 경계로 쓴다). 그중 `/` 는 뿌리 폴더로 풀려서
|
|
262
|
+
* **작업 범위 밖**이 되고, 그 한 조각 때문에 명령 전체가 거절됐다.
|
|
263
|
+
*
|
|
264
|
+
* 뿌리를 통째로 미는 명령이 걱정이라면 그건 여기 몫이 아니다 —
|
|
265
|
+
* `rm -rf /` 는 checkCommand 의 BLOCKED 가 따로, 더 확실하게 잡는다.
|
|
266
|
+
*/
|
|
267
|
+
if (/^[/\\]+$/.test(낱말)) return true;
|
|
268
|
+
// 빗금을 백슬래시로 흘린 것(`\/div`)도 정규식이지 경로가 아니다.
|
|
269
|
+
if (낱말.includes('\\/')) return true;
|
|
270
|
+
|
|
271
|
+
/*
|
|
272
|
+
* 한 마디짜리 절대 경로(`/div`)는 **디스크에 있을 때만** 본다.
|
|
273
|
+
*
|
|
274
|
+
* `</div>` `<br/>` 같은 태그가 잘려 오면 이 꼴이 된다. 그런 이름의 최상위
|
|
275
|
+
* 폴더는 없다. 반대로 `/tmp` `/etc` 는 실제로 있으므로 그대로 검사한다 —
|
|
276
|
+
* 없다고 다 넘기면 `mkdir /남의것` 이 지나가지만, 그건 변경성 명령이라
|
|
277
|
+
* checkCommand 가 따로 잡는다.
|
|
278
|
+
*/
|
|
279
|
+
if (/^\/[^/\\]+$/.test(낱말)) {
|
|
280
|
+
try { return !existsSync(낱말); } catch { return true; }
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
207
285
|
/** 명령줄에서 경로처럼 보이는 낱말만 골라낸다. */
|
|
208
286
|
export function 경로낱말(cmd) {
|
|
209
287
|
const out = [];
|
|
@@ -224,6 +302,7 @@ export function 경로낱말(cmd) {
|
|
|
224
302
|
// 남은 자리표가 있으면(우리가 못 푼 것) 어디로 갈지 모르니 검사하지 않는다.
|
|
225
303
|
// 여기서 억지로 풀면 엉뚱한 자리를 막게 된다.
|
|
226
304
|
if (/[$%]/.test(풀린)) continue;
|
|
305
|
+
if (코드조각인가(풀린)) continue;
|
|
227
306
|
out.push(풀린);
|
|
228
307
|
}
|
|
229
308
|
return out;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// 게이트웨이 열쇠를 어디에 어떻게 두나.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 필요한가 ────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 지금까지 열쇠는 `~/.deel/config.json` 에 **글자 그대로** 있었다. 저장할 때
|
|
6
|
+
// `chmod 600` 을 걸긴 하는데, 그건 NTFS 에서 아무 일도 안 한다 — 윈도우 권한은
|
|
7
|
+
// ACL 로 정해지고 chmod 는 조용히 무시된다. 그러니까 "본인만 읽게 잠갔다" 는
|
|
8
|
+
// 우리 말이 윈도우에서는 사실이 아니었다. 백업 도구·동기화 폴더·화면 공유,
|
|
9
|
+
// 아무거나 하나면 열쇠가 그대로 나간다.
|
|
10
|
+
//
|
|
11
|
+
// 사내 심사에서 제일 먼저 나오는 질문이 "열쇠는 어디에 어떻게 보관됩니까" 다.
|
|
12
|
+
// 그 답이 "홈 폴더에 평문" 이면 그 자리에서 끝난다.
|
|
13
|
+
//
|
|
14
|
+
// ── 무엇을 쓰나 ────────────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// 윈도우 DPAPI (ProtectedData, CurrentUser). 이 PC 의 **이 계정**만 푼다.
|
|
17
|
+
// 다른 계정으로 복사해 가면 못 푼다. 키를 우리가 따로 안 만들어도 된다.
|
|
18
|
+
// 맥 로그인 키체인 (security add-generic-password).
|
|
19
|
+
// 그 밖 그대로 파일 + 0600. 거짓말은 안 한다 — 화면과 심사서에 그렇게 적는다.
|
|
20
|
+
//
|
|
21
|
+
// ── 지키는 것 ──────────────────────────────────────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// **열쇠는 명령줄에 안 올린다.** 언제나 stdin 으로 넣는다. 명령줄은 같은 PC 의
|
|
24
|
+
// 다른 사용자도 프로세스 목록으로 볼 수 있고, 셸 기록에도 남는다. (엑셀 암호를
|
|
25
|
+
// 다룰 때 정해 둔 규칙과 같다.)
|
|
26
|
+
//
|
|
27
|
+
// 오갈 때는 base64 로만 주고받는다. 콘솔 인코딩이 무엇이든 글자가 안 상한다 —
|
|
28
|
+
// CP949 콘솔에서 UTF-8 열쇠를 그냥 흘려보내면 조용히 다른 글자가 된다.
|
|
29
|
+
//
|
|
30
|
+
// 못 잠그면 **잠근 척하지 않는다.** 실패를 삼키고 평문으로 두면, 사람은 잠긴
|
|
31
|
+
// 줄 알고 그 파일을 아무 데나 둔다. 그게 안 잠그는 것보다 나쁘다.
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
import { userInfo } from 'node:os';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
|
|
36
|
+
/** 잠긴 값의 꼴. `dpapi:<base64>` · `keychain:<이름>` */
|
|
37
|
+
const 꼴 = /^(dpapi|keychain):(.*)$/s;
|
|
38
|
+
|
|
39
|
+
/** 키체인에 넣을 때 쓰는 이름. 사람이 키체인 앱에서 찾아 지울 수 있어야 한다. */
|
|
40
|
+
export const 키체인이름 = 'deel-gateway-key';
|
|
41
|
+
|
|
42
|
+
let 마지막탈 = null;
|
|
43
|
+
/** 마지막으로 잠그거나 풀다 난 탈. 화면·심사서가 읽는다. */
|
|
44
|
+
export function 열쇠탈() { return 마지막탈; }
|
|
45
|
+
|
|
46
|
+
/** 이 값이 우리가 잠근 것인가. 평문 열쇠와 헷갈리면 안 된다. */
|
|
47
|
+
export function 잠긴것인가(값) { return 꼴.test(String(값 ?? '')); }
|
|
48
|
+
|
|
49
|
+
/** 윈도우 파워셸의 자리. PATH 를 안 믿는다 — 같은 이름의 다른 것이 앞에 있을 수 있다. */
|
|
50
|
+
function 파워셸() {
|
|
51
|
+
const 뿌리 = process.env.SystemRoot || 'C:\\Windows';
|
|
52
|
+
return join(뿌리, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 파워셸을 부른다. 명령은 -EncodedCommand 로 넣는다.
|
|
57
|
+
*
|
|
58
|
+
* 따옴표를 하나도 안 쓰게 되므로 cmd 가 중간에서 글자를 바꿀 자리가 없다.
|
|
59
|
+
* 열쇠는 여기 안 실린다 — 자료는 stdin 으로만 간다.
|
|
60
|
+
*/
|
|
61
|
+
let 마지막인자 = null;
|
|
62
|
+
/**
|
|
63
|
+
* 마지막으로 바깥 명령에 넘긴 **명령줄**. stdin 은 여기 안 남는다.
|
|
64
|
+
*
|
|
65
|
+
* "열쇠를 명령줄에 안 올린다" 는 말은 지켜야 뜻이 있고, 지켜지는지 밖에서
|
|
66
|
+
* 볼 수 있어야 한다. 그래서 실제로 넘긴 인자를 그대로 남긴다 — 검사가
|
|
67
|
+
* 이걸 읽고 열쇠가 섞였는지 본다.
|
|
68
|
+
*/
|
|
69
|
+
export function 마지막명령줄() { return 마지막인자; }
|
|
70
|
+
|
|
71
|
+
function 파워셸실행(스크립트, 입력) {
|
|
72
|
+
const enc = Buffer.from(스크립트, 'utf16le').toString('base64');
|
|
73
|
+
마지막인자 = [파워셸(), '-NoProfile', '-NonInteractive', '-EncodedCommand', enc];
|
|
74
|
+
const r = spawnSync(파워셸(), ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], {
|
|
75
|
+
input: 입력,
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
timeout: 20000,
|
|
79
|
+
});
|
|
80
|
+
if (r.error) return { ok: false, out: '', err: r.error.message };
|
|
81
|
+
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const 잠그는스크립트 = `
|
|
85
|
+
$ErrorActionPreference = 'Stop'
|
|
86
|
+
Add-Type -AssemblyName System.Security
|
|
87
|
+
$b = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())
|
|
88
|
+
$p = [Security.Cryptography.ProtectedData]::Protect($b, $null, 'CurrentUser')
|
|
89
|
+
[Console]::Out.Write([Convert]::ToBase64String($p))
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const 푸는스크립트 = `
|
|
93
|
+
$ErrorActionPreference = 'Stop'
|
|
94
|
+
Add-Type -AssemblyName System.Security
|
|
95
|
+
$b = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())
|
|
96
|
+
$u = [Security.Cryptography.ProtectedData]::Unprotect($b, $null, 'CurrentUser')
|
|
97
|
+
[Console]::Out.Write([Convert]::ToBase64String($u))
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
/** 맥 키체인. 넣을 때도 명령줄에 안 올린다 — `security -i` 는 명령을 stdin 으로 받는다. */
|
|
101
|
+
function 키체인넣기(글) {
|
|
102
|
+
const 계정 = userInfo().username;
|
|
103
|
+
// 값 자체는 base64 로 넣는다. 키체인 도구가 줄바꿈·따옴표를 만나면 거기서 끊긴다.
|
|
104
|
+
const 값 = Buffer.from(글, 'utf8').toString('base64');
|
|
105
|
+
const 명령 = `add-generic-password -a ${계정} -s ${키체인이름} -w ${값} -U\n`;
|
|
106
|
+
마지막인자 = ['security', '-i'];
|
|
107
|
+
const r = spawnSync('security', ['-i'], { input: 명령, encoding: 'utf8', timeout: 20000 });
|
|
108
|
+
if (r.error) return { ok: false, err: r.error.message };
|
|
109
|
+
return { ok: r.status === 0, err: (r.stderr ?? '').trim() };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function 키체인읽기() {
|
|
113
|
+
const 계정 = userInfo().username;
|
|
114
|
+
마지막인자 = ['security', 'find-generic-password', '-a', 계정, '-s', 키체인이름, '-w'];
|
|
115
|
+
const r = spawnSync('security', ['find-generic-password', '-a', 계정, '-s', 키체인이름, '-w'], {
|
|
116
|
+
encoding: 'utf8', timeout: 20000,
|
|
117
|
+
});
|
|
118
|
+
if (r.error) return { ok: false, text: '', err: r.error.message };
|
|
119
|
+
if (r.status !== 0) return { ok: false, text: '', err: (r.stderr ?? '').trim() || '키체인에 없습니다' };
|
|
120
|
+
try {
|
|
121
|
+
return { ok: true, text: Buffer.from((r.stdout ?? '').trim(), 'base64').toString('utf8') };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { ok: false, text: '', err: err.message };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 이 PC 에서 잠글 수 있나.
|
|
129
|
+
*
|
|
130
|
+
* @returns {{되나: boolean, 방식: 'dpapi'|'keychain'|'파일', 왜: string}}
|
|
131
|
+
*/
|
|
132
|
+
export function 쓸수있나() {
|
|
133
|
+
// 꺼 뒀으면 켜져 있는 척하지 않는다. 이 값을 화면 문구도 그대로 쓰기 때문에,
|
|
134
|
+
// 여기서 거짓으로 답하면 "다음 저장 때 잠급니다" 라고 해 놓고 안 잠근다.
|
|
135
|
+
if (process.env.DEEL_KEYSTORE === 'off') {
|
|
136
|
+
return { 되나: false, 방식: '파일', 왜: 'DEEL_KEYSTORE=off 로 꺼 두었습니다' };
|
|
137
|
+
}
|
|
138
|
+
if (process.platform === 'win32') {
|
|
139
|
+
const r = 파워셸실행('[Console]::Out.Write("ok")', '');
|
|
140
|
+
if (r.ok && r.out === 'ok') return { 되나: true, 방식: 'dpapi', 왜: '' };
|
|
141
|
+
return { 되나: false, 방식: '파일', 왜: r.err || '파워셸을 못 돌렸습니다 (정책으로 막혔을 수 있습니다)' };
|
|
142
|
+
}
|
|
143
|
+
if (process.platform === 'darwin') {
|
|
144
|
+
const r = spawnSync('security', ['-h'], { encoding: 'utf8', timeout: 10000 });
|
|
145
|
+
if (!r.error) return { 되나: true, 방식: 'keychain', 왜: '' };
|
|
146
|
+
return { 되나: false, 방식: '파일', 왜: 'security 명령을 못 찾았습니다' };
|
|
147
|
+
}
|
|
148
|
+
return { 되나: false, 방식: '파일', 왜: '이 운영체제에서는 파일 권한(0600)으로만 둡니다' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 잠근다. 못 잠그면 null 을 준다 — **잠근 척하지 않는다.**
|
|
153
|
+
*
|
|
154
|
+
* @param {string} 글 평문 열쇠
|
|
155
|
+
* @returns {string|null} `dpapi:…` · `keychain:…` 또는 null
|
|
156
|
+
*/
|
|
157
|
+
export function 잠그기(글) {
|
|
158
|
+
const 값 = String(글 ?? '');
|
|
159
|
+
if (!값) return null;
|
|
160
|
+
마지막탈 = null;
|
|
161
|
+
|
|
162
|
+
if (process.platform === 'win32') {
|
|
163
|
+
const r = 파워셸실행(잠그는스크립트, Buffer.from(값, 'utf8').toString('base64'));
|
|
164
|
+
if (r.ok && r.out) return `dpapi:${r.out}`;
|
|
165
|
+
마지막탈 = `열쇠를 못 잠갔습니다 — ${r.err || '파워셸이 답을 안 줬습니다'}`;
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (process.platform === 'darwin') {
|
|
169
|
+
const r = 키체인넣기(값);
|
|
170
|
+
if (r.ok) return `keychain:${키체인이름}`;
|
|
171
|
+
마지막탈 = `열쇠를 키체인에 못 넣었습니다 — ${r.err || 'security 가 실패했습니다'}`;
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 푼다.
|
|
179
|
+
*
|
|
180
|
+
* 못 풀 때가 진짜 있다 — 설정 파일만 다른 PC 로 옮겼거나, 계정을 바꿨거나,
|
|
181
|
+
* 윈도우 프로필을 다시 만든 경우다. 그때 빈 글자만 돌려주면 사람은 401 만
|
|
182
|
+
* 보고 게이트웨이를 의심한다. 그래서 왜인지를 남긴다.
|
|
183
|
+
*
|
|
184
|
+
* @returns {{ok: boolean, text: string, why: string}}
|
|
185
|
+
*/
|
|
186
|
+
export function 풀기(태그) {
|
|
187
|
+
const m = 꼴.exec(String(태그 ?? ''));
|
|
188
|
+
if (!m) return { ok: false, text: '', why: '잠긴 열쇠가 아닙니다' };
|
|
189
|
+
const [, 갈래, 값] = m;
|
|
190
|
+
마지막탈 = null;
|
|
191
|
+
|
|
192
|
+
if (갈래 === 'dpapi') {
|
|
193
|
+
if (process.platform !== 'win32') {
|
|
194
|
+
마지막탈 = '이 열쇠는 윈도우에서 잠근 것이라 여기서는 못 풉니다 — deel setup 으로 다시 넣으세요.';
|
|
195
|
+
return { ok: false, text: '', why: 마지막탈 };
|
|
196
|
+
}
|
|
197
|
+
const r = 파워셸실행(푸는스크립트, 값);
|
|
198
|
+
if (r.ok && r.out) {
|
|
199
|
+
try { return { ok: true, text: Buffer.from(r.out, 'base64').toString('utf8'), why: '' }; }
|
|
200
|
+
catch { /* 아래로 */ }
|
|
201
|
+
}
|
|
202
|
+
마지막탈 = '잠근 열쇠를 못 풉니다 — 이 PC 의 이 계정에서 잠근 것만 풀립니다. deel setup 으로 다시 넣으세요.';
|
|
203
|
+
return { ok: false, text: '', why: 마지막탈 };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (갈래 === 'keychain') {
|
|
207
|
+
if (process.platform !== 'darwin') {
|
|
208
|
+
마지막탈 = '이 열쇠는 맥 키체인에 있습니다 — 여기서는 못 읽습니다. deel setup 으로 다시 넣으세요.';
|
|
209
|
+
return { ok: false, text: '', why: 마지막탈 };
|
|
210
|
+
}
|
|
211
|
+
const r = 키체인읽기();
|
|
212
|
+
if (r.ok) return { ok: true, text: r.text, why: '' };
|
|
213
|
+
마지막탈 = `키체인에서 열쇠를 못 읽었습니다 — ${r.err}`;
|
|
214
|
+
return { ok: false, text: '', why: 마지막탈 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { ok: false, text: '', why: '모르는 잠금 방식입니다' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 화면·심사서에 그대로 쓰는 한 줄. 되는 척도 안 되는 척도 안 한다. */
|
|
221
|
+
export function 보관방식(값 = null) {
|
|
222
|
+
if (값 && 잠긴것인가(값)) {
|
|
223
|
+
return 값.startsWith('dpapi:')
|
|
224
|
+
? 'DPAPI — 이 PC 의 이 계정만 풉니다'
|
|
225
|
+
: '맥 키체인 — 이 계정의 로그인 키체인에 있습니다';
|
|
226
|
+
}
|
|
227
|
+
const 쓸수 = 쓸수있나();
|
|
228
|
+
if (값) {
|
|
229
|
+
// 평문인데 잠글 수는 있는 상태. 다음 저장에서 잠긴다.
|
|
230
|
+
return 쓸수.되나
|
|
231
|
+
? `파일에 평문 — 다음 저장 때 ${쓸수.방식 === 'dpapi' ? 'DPAPI' : '키체인'} 로 잠급니다`
|
|
232
|
+
: `파일에 평문 + 권한 0600 — ${쓸수.왜}`;
|
|
233
|
+
}
|
|
234
|
+
if (process.platform === 'win32') return 'DPAPI (윈도우) · 저장된 열쇠 없음';
|
|
235
|
+
if (process.platform === 'darwin') return '맥 키체인 · 저장된 열쇠 없음';
|
|
236
|
+
return '파일 권한 0600 · 저장된 열쇠 없음';
|
|
237
|
+
}
|
package/src/safety/network.js
CHANGED
|
@@ -20,13 +20,32 @@ export class NetBlocked extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
const LOCAL = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '
|
|
23
|
+
const LOCAL = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '::']);
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
/*
|
|
26
|
+
* 이 컴퓨터·사내망 주소인가. WebFetch 가 안 읽을 곳, 오프라인 잠금이 그래도 열어 줄 곳.
|
|
27
|
+
*
|
|
28
|
+
* 글자만 보면 빠져나가는 철자가 있었다 — `localhost.`(끝에 점)와 `[::ffff:127.0.0.1]`
|
|
29
|
+
* (IPv4 를 IPv6 에 싼 것)은 둘 다 127.0.0.1 에 붙는데 '바깥' 으로 읽혔다. 되돌림이
|
|
30
|
+
* 그리로 가면 사내 서비스를 읽게 된다. 그래서 먼저 철자를 편다: 끝 점을 떼고, 대괄호를
|
|
31
|
+
* 벗기고, ::ffff: 로 싼 IPv4 는 점 네 개 꼴로 되돌린 뒤 본다.
|
|
32
|
+
* 169.254.* (링크 로컬 — 클라우드 메타데이터가 여기 산다)와 IPv6 의 fe80:: · fc00::/7 도
|
|
33
|
+
* 이 컴퓨터·사내망으로 친다.
|
|
34
|
+
*/
|
|
35
|
+
export const isLocalHost = (h) => {
|
|
36
|
+
let s = String(h ?? '').trim().toLowerCase().replace(/\.$/, '').replace(/^\[|\]$/g, '');
|
|
37
|
+
if (s.startsWith('::ffff:')) {
|
|
38
|
+
const 뒤 = s.slice(7);
|
|
39
|
+
const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(뒤);
|
|
40
|
+
s = hex
|
|
41
|
+
? [hex[1], hex[2]].flatMap((x) => { const n = parseInt(x, 16); return [n >> 8, n & 255]; }).join('.')
|
|
42
|
+
: 뒤;
|
|
43
|
+
}
|
|
44
|
+
if (LOCAL.has(s)) return true;
|
|
45
|
+
if (/^(127|10|0)\./.test(s) || /^192\.168\./.test(s) || /^172\.(1[6-9]|2\d|3[01])\./.test(s) || /^169\.254\./.test(s)) return true;
|
|
46
|
+
if (/^fe[89ab][0-9a-f]:/.test(s) || /^f[cd][0-9a-f]{2}:/.test(s)) return true;
|
|
47
|
+
return false;
|
|
48
|
+
};
|
|
30
49
|
|
|
31
50
|
function originOf(url) {
|
|
32
51
|
const u = new URL(url);
|
|
@@ -42,32 +61,74 @@ const gate = {
|
|
|
42
61
|
enforced: true,
|
|
43
62
|
};
|
|
44
63
|
|
|
45
|
-
|
|
64
|
+
/*
|
|
65
|
+
* 모델 연결 주소를 허용 목록에 올린다. **이전에 올린 것은 지운다.**
|
|
66
|
+
*
|
|
67
|
+
* 여럿을 한 번에 줄 수 있다. 다만 그건 "하나씩 더 쌓는" 길이 아니다 — 부르는
|
|
68
|
+
* 쪽이 지금 열려 있어야 할 것 **전부**를 한 번에 말하는 것이다. 쌓는 길을
|
|
69
|
+
* 내주면 여기저기서 한 줄씩 더하다가 자물쇠가 자물쇠가 아니게 된다.
|
|
70
|
+
*
|
|
71
|
+
* 여럿이 필요한 자리는 하나다. 사람이 스킴을 안 적은 주소를 넣었을 때,
|
|
72
|
+
* 자리마다 기본으로 붙이는 스킴이 달라서(로컬은 http, Azure 는 https) 둘 다
|
|
73
|
+
* 열어 둬야 우리가 만든 주소를 우리 자물쇠가 막지 않는다. 호스트는 사람이
|
|
74
|
+
* 적어 넣은 그 하나뿐이라 넓어지는 것이 아니다.
|
|
75
|
+
*/
|
|
46
76
|
export function allowEndpoint(baseUrl) {
|
|
47
77
|
gate.allow.clear();
|
|
48
|
-
|
|
78
|
+
for (const u of Array.isArray(baseUrl) ? baseUrl : [baseUrl]) {
|
|
79
|
+
if (u) gate.allow.add(originOf(u));
|
|
80
|
+
}
|
|
49
81
|
return [...gate.allow];
|
|
50
82
|
}
|
|
51
83
|
|
|
52
84
|
/** 잠깐 한 곳을 더 연다. 되돌리는 함수를 준다 — 반드시 finally 에서 부른다. */
|
|
85
|
+
/*
|
|
86
|
+
* 잠깐 연다. 돌려주는 함수로 닫는다.
|
|
87
|
+
*
|
|
88
|
+
* 같은 집을 여럿이 겹쳐 열 수 있다 — WebFetch 다섯 개가 한 집에 줄을 서면
|
|
89
|
+
* 첫 것이 닫을 때 나머지 넷이 막히면 안 된다. 그래서 센다. 마지막이 닫을 때만
|
|
90
|
+
* 정말 닫고, 원래(allowEndpoint 로) 열려 있던 집은 끝까지 안 닫는다.
|
|
91
|
+
*/
|
|
92
|
+
const 잠깐연것 = new Map(); // origin → { n, 원래 }
|
|
53
93
|
export function allowTemporarily(url) {
|
|
54
94
|
const o = originOf(url);
|
|
55
|
-
const
|
|
95
|
+
const 칸 = 잠깐연것.get(o) ?? { n: 0, 원래: gate.allow.has(o) };
|
|
96
|
+
칸.n += 1;
|
|
97
|
+
잠깐연것.set(o, 칸);
|
|
56
98
|
gate.allow.add(o);
|
|
57
|
-
|
|
99
|
+
let 닫았나 = false;
|
|
100
|
+
return () => {
|
|
101
|
+
if (닫았나) return; // 두 번 닫아도 남의 몫을 닫지 않는다
|
|
102
|
+
닫았나 = true;
|
|
103
|
+
칸.n -= 1;
|
|
104
|
+
if (칸.n > 0) return;
|
|
105
|
+
잠깐연것.delete(o);
|
|
106
|
+
if (!칸.원래) gate.allow.delete(o);
|
|
107
|
+
};
|
|
58
108
|
}
|
|
59
109
|
|
|
60
110
|
export function setOffline(on) { gate.offline = !!on; return gate.offline; }
|
|
61
111
|
export function isOffline() { return gate.offline; }
|
|
62
112
|
export function allowed() { return [...gate.allow]; }
|
|
63
113
|
export function contacted() { return gate.log.slice(); }
|
|
64
|
-
export function resetNet() { gate.allow.clear(); gate.log.length = 0; gate.offline = false; }
|
|
114
|
+
export function resetNet() { gate.allow.clear(); 잠깐연것.clear(); gate.log.length = 0; gate.offline = false; 마지막경유 = null; }
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
* 거쳐 간 프록시.
|
|
118
|
+
*
|
|
119
|
+
* 프록시는 허용 목록에 오르는 자리가 아니다 — 목적지는 여전히 그 한 자리이고,
|
|
120
|
+
* 프록시는 거기까지 가는 **길**이다. 그래도 패킷이 실제로 닿는 첫 기계는 프록시라서,
|
|
121
|
+
* 심사서와 화면에는 "이 프록시를 거친다" 가 적혀야 한다. 어디를 거쳤는지 여기 남긴다.
|
|
122
|
+
*/
|
|
123
|
+
let 마지막경유 = null;
|
|
124
|
+
export function 프록시경유() { return 마지막경유; }
|
|
65
125
|
|
|
66
126
|
/**
|
|
67
127
|
* 이 주소로 나가도 되는가. 안 되면 던진다.
|
|
68
128
|
* 통과한 것은 기록에 남는다 — "무엇이 어디로 갔나" 를 나중에 보여 주기 위해서다.
|
|
129
|
+
* @param {string|null} 거쳐 이 요청이 거칠 프록시 주소. 없으면 직접 간다.
|
|
69
130
|
*/
|
|
70
|
-
export function checkUrl(url) {
|
|
131
|
+
export function checkUrl(url, 거쳐 = null) {
|
|
71
132
|
let u;
|
|
72
133
|
try { u = new URL(url); } catch { throw new NetBlocked(url, '주소 형식이 아닙니다.'); }
|
|
73
134
|
|
|
@@ -89,7 +150,8 @@ export function checkUrl(url) {
|
|
|
89
150
|
}
|
|
90
151
|
|
|
91
152
|
const seen = gate.log.find((x) => x.origin === origin);
|
|
92
|
-
if (seen) seen.n++;
|
|
93
|
-
else gate.log.push({ origin, n: 1, local });
|
|
153
|
+
if (seen) { seen.n++; if (거쳐) seen.via = 거쳐; }
|
|
154
|
+
else gate.log.push({ origin, n: 1, local, via: 거쳐 ?? null });
|
|
155
|
+
if (거쳐) 마지막경유 = { url: 거쳐 };
|
|
94
156
|
return true;
|
|
95
157
|
}
|