deel-local-cli 1.5.8 → 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.
Files changed (57) hide show
  1. package/README.en.md +23 -13
  2. package/README.md +22 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +78 -6
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +13 -2
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +226 -36
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
@@ -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
+ }
@@ -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', '[::1]']);
23
+ const LOCAL = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '::']);
24
24
 
25
- export const isLocalHost = (h) => LOCAL.has(String(h).toLowerCase()) ||
26
- /^127\./.test(h) ||
27
- /^10\./.test(h) ||
28
- /^192\.168\./.test(h) ||
29
- /^172\.(1[6-9]|2\d|3[01])\./.test(h);
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
- if (baseUrl) gate.allow.add(originOf(baseUrl));
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 had = gate.allow.has(o);
95
+ const = 잠깐연것.get(o) ?? { n: 0, 원래: gate.allow.has(o) };
96
+ 칸.n += 1;
97
+ 잠깐연것.set(o, 칸);
56
98
  gate.allow.add(o);
57
- return () => { if (!had) gate.allow.delete(o); };
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
  }
@@ -0,0 +1,209 @@
1
+ // 무엇을 늘 허락하고 무엇을 절대 안 할지, 그리고 그것을 누가 정하는지.
2
+ //
3
+ // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
+ //
5
+ // 지금까지 승인은 세 가지 모드뿐이었다 — 안 묻거나(auto), 파일을 바꾸는
6
+ // 명령만 묻거나(confirm), 바꾸는 도구를 다 묻거나(strict). 그 사이가 없다.
7
+ //
8
+ // `npm test` 는 하루에 스무 번 돌린다. 스무 번 묻는다.
9
+ // `curl` 은 한 번도 돌게 하고 싶지 않다. 그런데 물어보기만 한다 — 물어보는
10
+ // 것은 막는 것이 아니다. 사람은 스무 번 y 를 친 손으로 스물한 번째도 친다.
11
+ //
12
+ // 그래서 규칙을 적어 둘 수 있게 한다.
13
+ //
14
+ // "permissions": {
15
+ // "allow": ["Bash(npm test*)", "Read", "Grep"],
16
+ // "deny": ["Bash(curl*)", "Bash(rm -rf*)", "WebFetch"]
17
+ // }
18
+ //
19
+ // ── 그리고 그것을 누가 정하는가 ────────────────────────────────────────
20
+ //
21
+ // 설정 파일은 **쓰는 사람의 것**이다. 지우고 고칠 수 있다. 그래서 회사가
22
+ // "이번 배포 동안은 이 게이트웨이만" 이라고 정해야 할 때 쓸 자리가 아니다.
23
+ // 그 자리가 관리 정책 파일이다 — 사용자가 못 고치는 곳에 둔다.
24
+ //
25
+ // %ProgramData%\deel\policy.json /etc/deel/policy.json $DEEL_POLICY
26
+ //
27
+ // 정책은 설정을 **이긴다.** 그리고 정책은 넓히지 못한다 — 금지를 더할 수는
28
+ // 있어도 사용자가 적어 둔 금지를 풀어 주지는 못한다. 관리자가 실수로,
29
+ // 또는 누가 정책 파일을 바꿔치기해서 안전장치가 헐거워지는 길을 안 낸다.
30
+ //
31
+ // ── 순서 ───────────────────────────────────────────────────────────────
32
+ //
33
+ // 금지 > 허락 > 모드
34
+ //
35
+ // 금지가 제일 세다. 허락과 금지에 같이 걸리면 금지다. 둘 다 아니면 예전처럼
36
+ // 모드가 정한다. 그리고 무엇 때문에 막혔는지 **어디에 적힌 규칙인지까지**
37
+ // 화면에 말한다 — 그 말이 없으면 사람은 제 설정을 고칠 수도, 관리자에게
38
+ // 무엇을 풀어 달라고 할 수도 없다.
39
+ import { readFileSync, existsSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+
42
+ /** 관리 정책 파일을 찾을 자리들. 앞에서부터 처음 있는 것 하나만 쓴다. */
43
+ export function 정책자리(env = process.env, platform = process.platform) {
44
+ const 것들 = [];
45
+ if (env.DEEL_POLICY) 것들.push(env.DEEL_POLICY);
46
+ if (platform === 'win32') {
47
+ 것들.push(join(env.ProgramData || 'C:\\ProgramData', 'deel', 'policy.json'));
48
+ } else {
49
+ 것들.push('/etc/deel/policy.json');
50
+ }
51
+ return 것들;
52
+ }
53
+
54
+ let 읽은정책 = null; // { 값, 곳 } — 한 번 읽고 이 프로세스 동안 안 다시 읽는다
55
+
56
+ /*
57
+ * 관리 정책을 읽는다.
58
+ *
59
+ * 못 읽거나 망가져 있으면 **없는 것으로 친다.** 여기서 프로그램을 멈추면
60
+ * 정책 파일 하나가 깨진 것으로 그 PC 의 deel 이 통째로 안 뜬다. 대신 왜
61
+ * 못 읽었는지를 들고 있다가 화면에 적는다 — 조용히 무시하면 관리자는
62
+ * 정책이 걸린 줄 알고, 사용자는 안 걸린 채로 쓴다.
63
+ */
64
+ export function 정책읽기({ env = process.env, platform = process.platform, 다시 = false } = {}) {
65
+ if (읽은정책 && !다시) return 읽은정책;
66
+ for (const 곳 of 정책자리(env, platform)) {
67
+ if (!existsSync(곳)) continue;
68
+ try {
69
+ const 값 = JSON.parse(readFileSync(곳, 'utf8'));
70
+ 읽은정책 = { 값: 값 && typeof 값 === 'object' ? 값 : {}, 곳, 탈: null };
71
+ return 읽은정책;
72
+ } catch (err) {
73
+ 읽은정책 = { 값: {}, 곳, 탈: `정책 파일을 못 읽었습니다 (${err.message})` };
74
+ return 읽은정책;
75
+ }
76
+ }
77
+ 읽은정책 = { 값: {}, 곳: null, 탈: null };
78
+ return 읽은정책;
79
+ }
80
+
81
+ /** 검사가 원래대로 돌려놓을 때. */
82
+ export function 정책잊기() { 읽은정책 = null; }
83
+
84
+ /*
85
+ * `Tool(무늬)` 를 읽는다.
86
+ *
87
+ * Bash — Bash 는 전부
88
+ * Bash(npm test*) — 명령이 `npm test` 로 시작하는 것
89
+ * Bash(*rm -rf*) — 어디에 있든 그 글자가 든 것
90
+ * Read(src/**) — 경로 무늬
91
+ *
92
+ * 무늬가 없으면 그 도구 전체다. 별표만 특별하고 나머지는 글자 그대로다 —
93
+ * 정규식을 받으면 적는 사람이 실수하기 쉽고, 실수한 금지 규칙은 안 걸린다.
94
+ */
95
+ export function 규칙읽기(줄) {
96
+ const s = String(줄 ?? '').trim();
97
+ if (!s) return null;
98
+ const m = /^([A-Za-z_][\w-]*)\s*\((.*)\)\s*$/s.exec(s);
99
+ if (!m) return { 도구: s, 무늬: null, 원문: s };
100
+ return { 도구: m[1], 무늬: m[2].trim(), 원문: s };
101
+ }
102
+
103
+ function 무늬맞나(무늬, 값) {
104
+ if (!무늬) return true;
105
+ const 글 = String(값 ?? '');
106
+ // 별표만 뜻을 갖는다. 나머지는 글자 그대로.
107
+ const re = new RegExp(`^${무늬.split('*').map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`, 's');
108
+ return re.test(글);
109
+ }
110
+
111
+ /*
112
+ * 이 규칙이 이 호출에 걸리나.
113
+ *
114
+ * 무엇을 무늬에 맞춰 보는지는 도구마다 다르다. Bash 는 명령, 파일 도구는
115
+ * 경로다. 도구가 여러 인자를 받는 경우 **첫 번째로 뜻이 통하는 것**을 본다.
116
+ */
117
+ export function 걸리나(규칙, 도구, 인자) {
118
+ if (!규칙 || 규칙.도구 !== 도구) return false;
119
+ if (!규칙.무늬) return true;
120
+ const a = 인자 ?? {};
121
+ const 볼것 = [a.command, a.file_path, a.path, a.pattern, a.url, a.query]
122
+ .filter((x) => typeof x === 'string');
123
+ if (!볼것.length) return false;
124
+ return 볼것.some((v) => 무늬맞나(규칙.무늬, v));
125
+ }
126
+
127
+ function 목록(값) {
128
+ return (Array.isArray(값) ? 값 : []).map(규칙읽기).filter(Boolean);
129
+ }
130
+
131
+ /**
132
+ * 지금 걸려 있는 규칙 전부. 어디에 적힌 것인지를 같이 들고 다닌다.
133
+ *
134
+ * @returns {{allow:Array, deny:Array, 정책곳:string|null, 탈:string|null, baseUrl:string|null, offline:boolean}}
135
+ */
136
+ export function 규칙모으기(cfg, { env = process.env, platform = process.platform } = {}) {
137
+ const 정책 = 정책읽기({ env, platform });
138
+ const p = 정책.값?.permissions ?? {};
139
+ const c = cfg?.permissions ?? {};
140
+ return {
141
+ allow: [
142
+ ...목록(c.allow).map((r) => ({ ...r, 출처: '설정' })),
143
+ // 정책은 허락도 적을 수 있다 — 사내 표준 명령을 미리 풀어 두는 자리다.
144
+ ...목록(p.allow).map((r) => ({ ...r, 출처: '관리 정책' })),
145
+ ],
146
+ deny: [
147
+ ...목록(c.deny).map((r) => ({ ...r, 출처: '설정' })),
148
+ ...목록(p.deny).map((r) => ({ ...r, 출처: '관리 정책' })),
149
+ ],
150
+ 정책곳: 정책.곳,
151
+ 탈: 정책.탈,
152
+ baseUrl: typeof 정책.값?.baseUrl === 'string' ? 정책.값.baseUrl : null,
153
+ offline: 정책.값?.offline === true,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * 이 도구 호출을 어떻게 할까.
159
+ *
160
+ * @returns {{답: 'deny'|'allow'|'모름', 출처: string|null, 규칙: string|null}}
161
+ * deny — 하지 않는다. 물어보지도 않는다.
162
+ * allow — 모드가 뭐든 안 묻고 한다.
163
+ * 모름 — 예전대로 모드가 정한다.
164
+ */
165
+ export function 어떻게할까(규칙들, 도구, 인자) {
166
+ // 금지가 먼저다. 허락과 금지에 같이 걸리면 금지다 — 반대로 하면 규칙 하나를
167
+ // 잘못 적어 둔 것으로 금지가 통째로 무력해진다.
168
+ for (const r of 규칙들?.deny ?? []) {
169
+ if (걸리나(r, 도구, 인자)) return { 답: 'deny', 출처: r.출처, 규칙: r.원문 };
170
+ }
171
+ for (const r of 규칙들?.allow ?? []) {
172
+ if (걸리나(r, 도구, 인자)) return { 답: 'allow', 출처: r.출처, 규칙: r.원문 };
173
+ }
174
+ return { 답: '모름', 출처: null, 규칙: null };
175
+ }
176
+
177
+ /**
178
+ * 늘 허락할 것에 한 줄 더한다 (ACP 의 '이건 앞으로 묻지 마세요').
179
+ *
180
+ * 이미 금지에 적혀 있으면 **안 더한다.** 더해 봐야 금지가 이기니 아무 일도
181
+ * 안 일어나는데, 목록에는 허락으로 적혀 있어 사람이 풀린 줄 안다.
182
+ * 그 어긋남이 규칙표를 못 믿게 만든다.
183
+ */
184
+ export function 늘허락(cfg, 줄, 규칙들 = null) {
185
+ const 규칙 = 규칙읽기(줄);
186
+ if (!규칙) return { ok: false, 왜: '규칙이 비었습니다' };
187
+ if (규칙들) {
188
+ for (const d of 규칙들.deny ?? []) {
189
+ if (d.도구 === 규칙.도구 && (!d.무늬 || d.무늬 === 규칙.무늬)) {
190
+ return { ok: false, 왜: `${d.출처}의 금지(${d.원문})와 부딪칩니다 — 금지가 이깁니다` };
191
+ }
192
+ }
193
+ }
194
+ cfg.permissions = cfg.permissions ?? {};
195
+ cfg.permissions.allow = Array.isArray(cfg.permissions.allow) ? cfg.permissions.allow : [];
196
+ if (!cfg.permissions.allow.includes(규칙.원문)) cfg.permissions.allow.push(규칙.원문);
197
+ return { ok: true, 규칙: 규칙.원문 };
198
+ }
199
+
200
+ /** 화면에 한 줄로 적을 말. 아무것도 안 걸려 있으면 빈 글. */
201
+ export function 규칙말(규칙들) {
202
+ if (!규칙들) return '';
203
+ const 조각 = [];
204
+ if (규칙들.allow.length) 조각.push(`허락 ${규칙들.allow.length}`);
205
+ if (규칙들.deny.length) 조각.push(`금지 ${규칙들.deny.length}`);
206
+ if (규칙들.정책곳) 조각.push(`관리 정책 ${규칙들.정책곳}`);
207
+ if (규칙들.탈) 조각.push(규칙들.탈);
208
+ return 조각.join(' · ');
209
+ }
@@ -196,6 +196,38 @@ export function 아는열쇠(것들) {
196
196
  return out.sort((a, b) => b.length - a.length);
197
197
  }
198
198
 
199
+ /*
200
+ * 주소에 실린 자격증명을 가린다.
201
+ *
202
+ * 게이트웨이 주소는 화면·진단 보고서·대화 기록 파일에 그대로 적힌다. 보통은
203
+ * 그래도 되는데, 물음표 뒤에 열쇠를 싣는 앞단이 있다 —
204
+ * Azure API Management 는 `?subscription-key=`, Azure Functions 앞단은
205
+ * `?code=` 로 받는다. 사람이 포털에서 복사한 주소를 그대로 넣으라고 우리가
206
+ * 권하고 있으니, 그런 주소가 들어오는 것은 예외가 아니라 예정된 일이다.
207
+ *
208
+ * 그래서 물음표 뒤의 **값은 전부** 가린다. 이름은 남긴다 — 어떤 것이 붙어
209
+ * 있는지는 사람이 봐야 하고, 이름만으로는 아무것도 못 연다.
210
+ *
211
+ * 예외를 두지 않는다. `api-version` 만 남기고 나머지를 가리는 식으로 목록을
212
+ * 관리하기 시작하면, 새로 나온 이름 하나가 조용히 평문으로 새어 나간다.
213
+ */
214
+ export function 주소가리기(주소) {
215
+ const s = String(주소 ?? '');
216
+ const i = s.indexOf('?');
217
+ if (i < 0) return s;
218
+ const 앞 = s.slice(0, i);
219
+ const 뒤 = s.slice(i + 1);
220
+ const 가린 = 뒤.split('&').map((쌍) => {
221
+ const j = 쌍.indexOf('=');
222
+ if (j < 0) return 쌍;
223
+ const 이름 = 쌍.slice(0, j);
224
+ // 판 번호는 비밀이 아니고, 이게 안 보이면 무엇이 잘못됐는지 알 수 없다.
225
+ if (/^api-version$/i.test(이름)) return 쌍;
226
+ return `${이름}=«가림»`;
227
+ }).join('&');
228
+ return `${앞}?${가린}`;
229
+ }
230
+
199
231
  /**
200
232
  * 이 도구의 결과를 가려도 되는가.
201
233
  *