deel-local-cli 1.8.0 → 1.12.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 (61) hide show
  1. package/README.ko.md +10 -25
  2. package/README.md +43 -51
  3. package/bin/deel.js +56 -13
  4. package/package.json +60 -60
  5. package/src/acp/serve.js +810 -732
  6. package/src/agent/budget.js +156 -153
  7. package/src/agent/compact.js +420 -296
  8. package/src/agent/effort.js +308 -177
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/evolve.js +263 -213
  11. package/src/agent/filemem.js +155 -0
  12. package/src/agent/grade.js +51 -1
  13. package/src/agent/loop.js +1707 -1349
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/session.js +1049 -832
  17. package/src/agent/store.js +186 -9
  18. package/src/agent/threads.js +26 -1
  19. package/src/backend/adapter.js +1159 -615
  20. package/src/backend/cachemark.js +150 -0
  21. package/src/backend/detect.js +327 -288
  22. package/src/backend/learn.js +24 -0
  23. package/src/backend/mcp.js +96 -5
  24. package/src/backend/probe.js +131 -61
  25. package/src/backend/quota.js +250 -133
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +37 -0
  28. package/src/backend/toolfit.js +352 -0
  29. package/src/backend/wire.js +600 -0
  30. package/src/commands.js +3135 -2918
  31. package/src/config.js +47 -2
  32. package/src/i18n/en.js +544 -466
  33. package/src/i18n/index.js +18 -0
  34. package/src/i18n/ja.js +497 -417
  35. package/src/i18n/ko.js +591 -466
  36. package/src/i18n/zh.js +497 -417
  37. package/src/lsp/client.js +49 -5
  38. package/src/oneshot.js +620 -471
  39. package/src/pack/sbom.js +30 -4
  40. package/src/pack/selfpack.js +25 -9
  41. package/src/pack/sheet.en.js +288 -0
  42. package/src/pack/tar.js +65 -2
  43. package/src/plugins/manage.js +46 -9
  44. package/src/providers/bedrock.js +17 -0
  45. package/src/repl.js +2602 -2286
  46. package/src/safety/audit.js +92 -8
  47. package/src/safety/authcmd.js +14 -3
  48. package/src/safety/guard.js +143 -0
  49. package/src/safety/keystore.js +62 -39
  50. package/src/safety/undo.js +24 -6
  51. package/src/tools/fsutil.js +265 -250
  52. package/src/tools/index.js +239 -34
  53. package/src/tools/jobs.js +158 -29
  54. package/src/tools/verify.js +358 -328
  55. package/src/tools/webfetch.js +92 -9
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/src/ui/status.js +613 -610
package/src/pack/sbom.js CHANGED
@@ -25,6 +25,8 @@
25
25
  // 적어 둔 칸이 다 있는지 본다.
26
26
  import { randomUUID } from 'node:crypto';
27
27
  import { 보관방식 } from '../safety/keystore.js';
28
+ import { 언어 } from '../i18n/index.js';
29
+ import { specEn, egressEn, keyStorageEn, auditLogEn, specSummaryEn } from './sheet.en.js';
28
30
 
29
31
  /** 우리가 내놓는 SBOM 규격. 스캐너가 이 숫자를 보고 읽는 법을 정한다. */
30
32
  export const CDX판 = '1.5';
@@ -41,7 +43,7 @@ export const CDX판 = '1.5';
41
43
  * @param {Date} o.at 만든 때 (검사가 고정할 수 있게)
42
44
  * @param {string} o.serial 문서 일련번호 (검사가 고정할 수 있게)
43
45
  */
44
- export function sbom(a, { at = new Date(), serial = null } = {}) {
46
+ export function sbom(a, { at = new Date(), serial = null, lang = 언어() } = {}) {
45
47
  const purl = `pkg:npm/${a.name}@${a.version}`;
46
48
  const 본체 = {
47
49
  type: 'application',
@@ -50,7 +52,11 @@ export function sbom(a, { at = new Date(), serial = null } = {}) {
50
52
  version: a.version,
51
53
  purl,
52
54
  licenses: a.license ? [{ license: { id: a.license } }] : [],
53
- description: '로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI',
55
+ // SBOM 남에게 내는 서류다. 화면을 영어로 켜 둔 사람이 한국어 설명이
56
+ // 박힌 문서를 심사에 낼 수는 없다.
57
+ description: lang === 'ko'
58
+ ? '로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI'
59
+ : 'A coding agent CLI for local models and in-house gateways',
54
60
  };
55
61
 
56
62
  return {
@@ -208,7 +214,20 @@ export function 감사명세() {
208
214
  /**
209
215
  * 세 가지를 한 덩이로. zip 에 이대로 들어간다.
210
216
  */
211
- export function 심사명세(a, { at = new Date() } = {}) {
217
+ export function 심사명세(a, { at = new Date(), lang = 언어() } = {}) {
218
+ /*
219
+ * 한국어가 아니면 영어 서류를 낸다 (pack/sheet.en.js).
220
+ *
221
+ * 열쇠 이름까지 영어라야 뜻이 있다. 값만 옮기고 열쇠를 `통신`·`감사기록`
222
+ * 으로 두면, 스캐너에 넣는 사람이 무엇이 무엇인지 못 읽는다 — 반쯤 옮긴
223
+ * 서류는 안 옮긴 것보다 나쁘다.
224
+ *
225
+ * 일본어·중국어도 영어로 간다. i18n 의 물러날곳(ja→en→ko)과 같은 규칙이다.
226
+ */
227
+ if (lang !== 'ko') {
228
+ const 자리 = (id) => (a.calls?.[id] ?? []).map((x) => `${x.file}:${x.line}`);
229
+ return specEn(a, at, egressEn(자리, a), keyStorageEn(보관방식(null, { lang: 'en' })), auditLogEn());
230
+ }
212
231
  return {
213
232
  만든것: a.name,
214
233
  판: a.version,
@@ -231,9 +250,16 @@ export function 심사명세(a, { at = new Date() } = {}) {
231
250
  };
232
251
  }
233
252
 
234
- /** 사람이 읽을 짧은 요약. 화면에 찍는 자리에서 쓴다. */
253
+ /**
254
+ * 사람이 읽을 짧은 요약. 화면에 찍는 자리에서 쓴다.
255
+ *
256
+ * 어느 말로 쓸지를 **명세 자체를 보고** 정한다. 언어() 를 여기서 또 물으면,
257
+ * 명세를 만든 뒤에 말이 바뀐 자리에서 요약만 딴 말이 된다 — 그러면 읽을 수
258
+ * 없는 열쇠에서 값을 꺼내려다 전부 0 으로 찍힌다.
259
+ */
235
260
  export function 명세요약(명세) {
236
261
  const m = 명세 ?? {};
262
+ if (!m.파일 && m.files) return specSummaryEn(m);
237
263
  return [
238
264
  `SBOM 부품 ${(m.파일?.length ?? 0).toLocaleString()}개 (전부 SHA-256 붙임)`,
239
265
  `의존성 ${(m.의존성?.dependencies?.length ?? 0)}개 · 설치 스크립트 ${(m.의존성?.설치스크립트?.length ?? 0)}개`,
@@ -12,6 +12,8 @@ import { join, dirname, relative } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { makeZip } from './zip.js';
14
14
  import { sbom, 심사명세 } from './sbom.js';
15
+ import { 언어 } from '../i18n/index.js';
16
+ import { reviewSheetEn, readMeEn } from './sheet.en.js';
15
17
 
16
18
  export const repoRoot = () => join(dirname(fileURLToPath(import.meta.url)), '..', '..');
17
19
 
@@ -127,7 +129,10 @@ export function audit(root = repoRoot()) {
127
129
 
128
130
  const 줄 = (n = 74) => '-'.repeat(n);
129
131
 
130
- export function reviewSheet(a, at) {
132
+ export function reviewSheet(a, at, { lang = 언어() } = {}) {
133
+ // 한국어가 아니면 영어 서류 (pack/sheet.en.js). 같은 audit() 을 쓴다 —
134
+ // 말이 달라도 숫자가 달라지면 그건 서류가 거짓말을 하는 것이다.
135
+ if (lang !== 'ko') return reviewSheetEn(a, at, PROBES);
131
136
  const L = [];
132
137
  L.push('deel 사내 반입 심사 자료');
133
138
  L.push(줄());
@@ -221,10 +226,11 @@ export function reviewSheet(a, at) {
221
226
  /**
222
227
  * 심사서 + 소스를 zip 하나로 묶는다.
223
228
  */
224
- export function packSelf(outFile, { root = repoRoot(), at = new Date() } = {}) {
229
+ export function packSelf(outFile, { root = repoRoot(), at = new Date(), lang = 언어() } = {}) {
225
230
  const a = audit(root);
226
231
  const stamp = at.toISOString().replace('T', ' ').slice(0, 19);
227
- const sheet = reviewSheet(a, stamp);
232
+ const sheet = reviewSheet(a, stamp, { lang });
233
+ const 한국어 = lang === 'ko';
228
234
 
229
235
  /*
230
236
  * 사람이 읽는 것 하나, 기계가 읽는 것 둘.
@@ -233,10 +239,20 @@ export function packSelf(outFile, { root = repoRoot(), at = new Date() } = {}) {
233
239
  * 취약점 목록을 뽑고, 운영팀은 감사기록 사양을 보고 SIEM 수집 규칙을 짠다.
234
240
  * 사람이 읽는 글은 그 어느 쪽에도 못 들어간다 — 그래서 셋을 다 넣는다.
235
241
  */
242
+ /*
243
+ * 파일 이름도 같이 옮긴다.
244
+ *
245
+ * 안이 영어인데 이름이 `반입심사서.txt` 면, 받은 사람은 열기 전에 무엇인지
246
+ * 모르고 메일에 첨부하면 이름이 깨져서 온다. 서류의 이름은 서류의 일부다.
247
+ */
248
+ const 이름 = 한국어
249
+ ? { 심사서: '반입심사서.txt', 명세: '심사명세.json', 안내: '읽어주세요.txt' }
250
+ : { 심사서: 'import-review.txt', 명세: 'audit-spec.json', 안내: 'READ-ME-FIRST.txt' };
251
+
236
252
  const entries = [
237
- { name: '반입심사서.txt', data: Buffer.from(sheet, 'utf8'), mtime: at },
238
- { name: 'sbom.cdx.json', data: Buffer.from(JSON.stringify(sbom(a, { at }), null, 2), 'utf8'), mtime: at },
239
- { name: '심사명세.json', data: Buffer.from(JSON.stringify(심사명세(a, { at }), null, 2), 'utf8'), mtime: at },
253
+ { name: 이름.심사서, data: Buffer.from(sheet, 'utf8'), mtime: at },
254
+ { name: 'sbom.cdx.json', data: Buffer.from(JSON.stringify(sbom(a, { at, lang }), null, 2), 'utf8'), mtime: at },
255
+ { name: 이름.명세, data: Buffer.from(JSON.stringify(심사명세(a, { at, lang }), null, 2), 'utf8'), mtime: at },
240
256
  ];
241
257
  for (const f of a.files) {
242
258
  entries.push({
@@ -247,8 +263,8 @@ export function packSelf(outFile, { root = repoRoot(), at = new Date() } = {}) {
247
263
  });
248
264
  }
249
265
  entries.push({
250
- name: '읽어주세요.txt',
251
- data: Buffer.from([
266
+ name: 이름.안내,
267
+ data: 한국어 ? Buffer.from([
252
268
  'deel — 로컬 모델·사내 게이트웨이 코딩 에이전트',
253
269
  '',
254
270
  '쓰는 법 (설치 절차 없음)',
@@ -265,7 +281,7 @@ export function packSelf(outFile, { root = repoRoot(), at = new Date() } = {}) {
265
281
  '',
266
282
  ' 세 파일 모두 소스를 훑어 자동으로 만든 것입니다. 손으로 적은 값이 아닙니다.',
267
283
  '',
268
- ].join('\n'), 'utf8'),
284
+ ].join('\n'), 'utf8') : Buffer.from(readMeEn(), 'utf8'),
269
285
  mtime: at,
270
286
  });
271
287
 
@@ -0,0 +1,288 @@
1
+ // 반입 심사 서류의 영어판.
2
+ //
3
+ // ── 왜 번역표가 아니라 딴 파일인가 ──────────────────────────────────────
4
+ //
5
+ // 화면 말은 열쇠 하나에 한 문장이라 i18n 표가 맞다(src/i18n). 이건 다르다.
6
+ // **서류**다. 한 장이 통째로 하나의 글이고, 절 이름·차례·줄맞춤이 같이
7
+ // 뜻을 만든다. 그걸 열쇠 이백 개로 쪼개 두면, 고칠 때마다 두 파일을 오가며
8
+ // 짜맞춰야 하고 결국 한쪽만 고친 서류가 나간다.
9
+ //
10
+ // 이 저장소는 이미 같은 판단을 한 번 했다 — README.md 와 README.ko.md 를
11
+ // 갈라 뒀다. 서류는 서류대로 통째로 쓰는 편이 읽기도 고치기도 낫다.
12
+ //
13
+ // ── 대신 어긋나지 않게 검사한다 ─────────────────────────────────────────
14
+ //
15
+ // 갈라 두면 한쪽만 고치는 날이 온다. 그래서 test/packlang.test.js 가 두 판의
16
+ // **모양**을 견준다 — 절 수, 실린 파일 수, 통신 갈래 수. 글은 달라도 되지만
17
+ // 사실이 달라지면 그건 서류가 거짓말을 하는 것이다.
18
+ //
19
+ // ── 지어내지 않는다 ────────────────────────────────────────────────────
20
+ //
21
+ // 여기 적히는 값은 전부 selfpack.js 의 audit() 이 소스를 훑어 얻은 것이다.
22
+ // 한국어판과 같은 자료를 쓴다. 영어판이라고 다른 숫자가 나오면 안 된다.
23
+
24
+ const rule = (n = 74) => '-'.repeat(n);
25
+
26
+ /** 소스를 훑는 자리들. 한국어판 PROBES 와 같은 차례여야 한다. */
27
+ const PROBE_LABELS = {
28
+ net: { label: 'Network requests', note: 'Only to the address you entered in setup (through HTTPS_PROXY when one is set)' },
29
+ exec: { label: 'External commands', note: 'Commands you or the model asked for, plus git when installing a plugin' },
30
+ listen: { label: 'Listening ports', note: 'Should be none' },
31
+ eval: { label: 'String evaluation', note: 'Should be none' },
32
+ };
33
+
34
+ /**
35
+ * 사람이 읽는 심사서 — 영어판.
36
+ *
37
+ * @param {object} a selfpack.js 의 audit()
38
+ * @param {string} at 만든 시각
39
+ * @param {Array} probes selfpack.js 의 PROBES (차례를 그대로 따른다)
40
+ */
41
+ export function reviewSheetEn(a, at, probes) {
42
+ const L = [];
43
+ L.push('deel — security review package');
44
+ L.push(rule());
45
+ L.push(`Name ${a.name}`);
46
+ L.push(`Version ${a.version}`);
47
+ L.push(`License ${a.license}`);
48
+ L.push(`Runtime Node ${a.node} (standard library only)`);
49
+ L.push(`Generated ${at}`);
50
+ L.push('');
51
+
52
+ L.push('1. Third-party dependencies');
53
+ L.push(rule());
54
+ L.push(` dependencies ${a.deps.length}${a.deps.length ? ' ' + a.deps.join(', ') : ' <- no third-party code is shipped'}`);
55
+ L.push(` devDependencies ${a.devDeps.length}`);
56
+ L.push(` external imports ${a.외부모듈.length}${a.외부모듈.length ? '' : ' <- only node: builtins and its own files'}`);
57
+ for (const x of a.외부모듈) L.push(` ${x}`);
58
+ L.push('');
59
+
60
+ L.push('2. Code that runs on install');
61
+ L.push(rule());
62
+ L.push(a.lifecycle.length
63
+ ? ` Present: ${a.lifecycle.join(', ')} <- needs review`
64
+ : ' None <- no preinstall / install / postinstall / prepare');
65
+ L.push(' Unzip and run `node bin/deel.js`. There is no install step.');
66
+ L.push('');
67
+
68
+ L.push('3. Every outbound call site (found by scanning the source)');
69
+ L.push(rule());
70
+ for (const p of probes) {
71
+ const hits = a.calls[p.id];
72
+ const w = PROBE_LABELS[p.id] ?? { label: p.id, note: '' };
73
+ L.push(` [${w.label}] ${hits.length} ${w.note}`);
74
+ for (const h of hits) L.push(` ${h.file}:${h.line} ${h.text}`);
75
+ if (!hits.length) L.push(' (none)');
76
+ }
77
+ L.push('');
78
+ L.push(' Note: no endpoint is hard-coded in the source. deel only uses the address you');
79
+ L.push(' entered in setup. Config file: ~/.deel/config.json (or DEEL_API_KEY)');
80
+ L.push('');
81
+
82
+ L.push('3-1. Four separate outbound lanes that never mix');
83
+ L.push(rule());
84
+ L.push(' [A] Model gateway - the only lane that carries your source code');
85
+ L.push(' - Address: the single endpoint chosen in deel setup');
86
+ L.push(' - That one address is the entire allow-list. Switch models and the old one closes.');
87
+ L.push(' - src/safety/network.js checks every request. If the address is not on the');
88
+ L.push(' list, the request is never built.');
89
+ L.push(' - With HTTPS_PROXY (or proxy in the config) the call goes **through** that');
90
+ L.push(' proxy (CONNECT tunnel). The destination stays the same single address, and');
91
+ L.push(' the proxy in use is printed at startup and in /status. Requests to this');
92
+ L.push(' machine (127.*, localhost) never go through the proxy.');
93
+ L.push('');
94
+ L.push(' [B] Web reading (WebFetch tool) - fetch only');
95
+ L.push(' - GET only. No request body, so source and conversation cannot leave this way.');
96
+ L.push(' - This machine and private ranges (127.*, 10.*, 192.168.*, 172.16-31.*) are refused.');
97
+ L.push(' - The lane opens only while the tool runs and closes immediately after.');
98
+ L.push(' - Every address visited is written to the audit log.');
99
+ L.push('');
100
+ L.push(' [C] Plugin install (github) - only when you type /plugin install');
101
+ L.push(' - Open only while that command runs.');
102
+ L.push('');
103
+ L.push(' [D] MCP servers - separate child processes, someone else\'s program');
104
+ L.push(' - Off by default. A server runs only if you list it in .deel/mcp.json.');
105
+ L.push(' - Unlike A/B/C we cannot see which sockets that server opens. So instead of');
106
+ L.push(' filtering its requests, --offline does not start the server at all.');
107
+ L.push('');
108
+ L.push(' Starting with --offline blocks [B], [C] and [D]; traffic stays on this machine.');
109
+ L.push(' (Verified by the network / web / mcp checks in npm test - 123 assertions.)');
110
+ L.push('');
111
+
112
+ L.push('4. Skills and plugins');
113
+ L.push(rule());
114
+ L.push(' This package contains no skills and no plugins.');
115
+ L.push(' It only reads ~/.claude, ~/.deel and the project folder on the machine it runs on.');
116
+ L.push(' (Verified by the no-bundle check in npm test.)');
117
+ L.push('');
118
+
119
+ L.push(`5. ${a.files.length} files shipped - SHA-256`);
120
+ L.push(rule());
121
+ const w = Math.max(...a.files.map((f) => f.path.length));
122
+ for (const f of a.files) {
123
+ L.push(` ${f.path.padEnd(w)} ${String(f.bytes).padStart(7)}B ${f.sha}`);
124
+ }
125
+ L.push('');
126
+ L.push(' Verify these yourself with:');
127
+ L.push(' sha256sum <file> (Linux, macOS)');
128
+ L.push(' certutil -hashfile <file> SHA256 (Windows)');
129
+ L.push('');
130
+ return L.join('\n');
131
+ }
132
+
133
+ /** zip 맨 앞에 넣는 안내 — 영어판. */
134
+ export function readMeEn() {
135
+ return [
136
+ 'deel - a coding agent CLI for local models and in-house gateways',
137
+ '',
138
+ 'How to run it (there is no install step)',
139
+ ' 1. Unzip this archive anywhere.',
140
+ ' 2. Check that Node 20 or newer is present: node -v',
141
+ ' 3. Choose a connection: node deel/bin/deel.js setup',
142
+ ' 4. Start talking: node deel/bin/deel.js',
143
+ '',
144
+ 'For the security reviewer',
145
+ ' import-review.txt The review package in prose: dependencies, install scripts,',
146
+ ' every network call site, and a SHA-256 for each file.',
147
+ ' sbom.cdx.json SBOM (CycloneDX 1.5). Feed it straight to your scanner.',
148
+ ' audit-spec.json Egress list, audit-log specification, file hashes - machine readable.',
149
+ '',
150
+ ' All three are generated by scanning the source. None of it is written by hand.',
151
+ '',
152
+ ].join('\n');
153
+ }
154
+
155
+ /**
156
+ * 심사명세 — 영어판.
157
+ *
158
+ * 한국어판(sbom.js 의 심사명세)과 **같은 자료, 같은 차례**다. 열쇠 이름까지
159
+ * 영어라야 뜻이 있다 — 스캐너에 넣는 사람이 `통신` 을 읽을 수는 없다.
160
+ */
161
+ export function specEn(a, at, 통신, 열쇠, 감사) {
162
+ return {
163
+ product: a.name,
164
+ version: a.version,
165
+ license: a.license,
166
+ runtime: `Node ${a.node} (standard library only)`,
167
+ generated: at.toISOString(),
168
+ dependencies: {
169
+ dependencies: a.deps,
170
+ devDependencies: a.devDeps,
171
+ installScripts: a.lifecycle,
172
+ externalImportsInSource: a.외부모듈,
173
+ summary: a.deps.length === 0 && a.외부모듈.length === 0
174
+ ? 'No third-party code is bundled. Only node: builtins and its own files are imported.'
175
+ : 'External modules are present - review the list above.',
176
+ },
177
+ egress: 통신,
178
+ keyStorage: 열쇠,
179
+ auditLog: 감사,
180
+ files: a.files.map((f) => ({ path: f.path, bytes: f.bytes, sha256: f.sha })),
181
+ };
182
+ }
183
+
184
+ /** 나가는 길 — 영어판. 자리(소스 줄 번호)는 부르는 쪽이 넣어 준다. */
185
+ export function egressEn(자리, a) {
186
+ return {
187
+ lanes: [
188
+ {
189
+ lane: 'Model gateway',
190
+ when: 'On every turn you type',
191
+ where: 'The single address chosen in deel setup',
192
+ what: 'The conversation, and the file contents the model read',
193
+ control: 'src/safety/network.js checks the allow-list on every request. If the address is not listed, the request is never built.',
194
+ proxy: 'With HTTPS_PROXY (or proxy in the config) the call goes through that proxy (CONNECT tunnel). The destination allow-list is unchanged, and addresses on this machine bypass the proxy.',
195
+ source: 자리('net'),
196
+ },
197
+ {
198
+ lane: 'Web reading (WebFetch)',
199
+ when: 'Only when the model calls that tool',
200
+ where: 'An address the model chose. This machine and private ranges are refused',
201
+ what: 'Nothing - GET only, so no request body is sent',
202
+ control: 'Blocked entirely under --offline.',
203
+ source: 자리('net'),
204
+ },
205
+ {
206
+ lane: 'Plugin install',
207
+ when: 'Only when you type /plugin install',
208
+ where: 'github.com',
209
+ what: 'Nothing',
210
+ control: 'Blocked entirely under --offline.',
211
+ source: 자리('net'),
212
+ },
213
+ ],
214
+ listeningPorts: {
215
+ count: (a.calls?.listen ?? []).length,
216
+ use: 'Only /preview, which serves the page you just built. It binds to 127.0.0.1 and closes when the command ends.',
217
+ source: 자리('listen'),
218
+ },
219
+ externalCommands: {
220
+ count: (a.calls?.exec ?? []).length,
221
+ use: 'Commands you or the model asked for, starting MCP servers, and git when installing a plugin.',
222
+ source: 자리('exec'),
223
+ },
224
+ stringEvaluation: {
225
+ count: (a.calls?.eval ?? []).length,
226
+ use: 'Should be none.',
227
+ source: 자리('eval'),
228
+ },
229
+ whenOffline: [
230
+ 'Web reading (WebFetch) is blocked.',
231
+ 'Plugin install is blocked.',
232
+ 'MCP servers are not started - we cannot control where a child process connects.',
233
+ 'The model gateway still works. If that address is on this machine, nothing leaves it at all.',
234
+ ],
235
+ };
236
+ }
237
+
238
+ /** 열쇠 보관 — 영어판. 이 PC 에서 실제로 쓰는 방식은 부르는 쪽이 넣어 준다. */
239
+ export function keyStorageEn(이PC에서) {
240
+ return {
241
+ where: 'profiles[].apiKey in ~/.deel/config.json (or DEEL_HOME)',
242
+ onThisMachine: 이PC에서,
243
+ methods: [
244
+ { os: 'Windows', how: 'DPAPI ProtectedData (CurrentUser) - only this account on this machine can unseal it. Copying the file elsewhere does not help.' },
245
+ { os: 'macOS', how: 'Login keychain (security add-generic-password)' },
246
+ { os: 'Other', how: 'File mode 0600. There is no OS keystore, so we say so plainly.' },
247
+ ],
248
+ howItIsPassed: 'The key never appears on a command line - it is always passed over stdin, so another user on the same machine cannot read it from the process list.',
249
+ avoidTheFile: 'Set DEEL_API_KEY (or DEEL_KEY_<profile>) and nothing is written to the file at all. The environment variable wins over the file.',
250
+ disable: 'DEEL_KEYSTORE=off - no OS keystore, file permissions only.',
251
+ };
252
+ }
253
+
254
+ /** 감사기록 사양 — 영어판. */
255
+ export function auditLogEn() {
256
+ return {
257
+ location: '<workdir>/.deel/audit.jsonl',
258
+ format: 'JSON Lines (UTF-8, one record per line)',
259
+ growth: 'Append only. Records are never edited or removed.',
260
+ retention: 'deel never deletes it. Set retention according to your own policy.',
261
+ everyLine: [
262
+ { field: 'at', meaning: 'When (ISO 8601, UTC)' },
263
+ { field: 'session', meaning: 'Which conversation - an id derived from the start time' },
264
+ { field: 'kind', meaning: 'Record type - see below' },
265
+ ],
266
+ kinds: [
267
+ { kind: 'turn', meaning: 'Something the user asked for', fields: ['text'], note: 'First 500 characters only' },
268
+ { kind: 'tool', meaning: 'A tool call', fields: ['tool', 'target', 'ok', 'note'], note: 'target is a file path, a command, or a search pattern' },
269
+ { kind: 'blocked', meaning: 'Something a safety check refused', fields: ['why', 'what'], note: 'The refused command is kept verbatim' },
270
+ { kind: 'undo', meaning: 'A rollback', fields: ['files', 'turns'] },
271
+ ],
272
+ neverRecorded: [
273
+ 'Keys and passwords. Neither apiKey from the config nor a spreadsheet password reaches the log.',
274
+ 'Whole file contents. What was touched is recorded; what was written is not.',
275
+ 'Model replies. Those live in the session transcript (.deel/sessions) instead.',
276
+ ],
277
+ };
278
+ }
279
+
280
+ /** 사람이 읽을 짧은 요약 — 영어판. */
281
+ export function specSummaryEn(m = {}) {
282
+ return [
283
+ `SBOM components ${(m.files?.length ?? 0).toLocaleString()} (each with SHA-256)`,
284
+ `Dependencies ${(m.dependencies?.dependencies?.length ?? 0)} - install scripts ${(m.dependencies?.installScripts?.length ?? 0)}`,
285
+ `Egress lanes ${(m.egress?.lanes?.length ?? 0)} - listening ports ${(m.egress?.listeningPorts?.count ?? 0)}`,
286
+ `Audit records ${(m.auditLog?.kinds?.length ?? 0)} kinds - ${m.auditLog?.location ?? ''}`,
287
+ ].join('\n');
288
+ }
package/src/pack/tar.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // tar 읽기. GitHub 가 주는 tarball 을 풀기 위한 최소 구현.
2
2
  // git 이 없는 기기에서도 플러그인을 받을 수 있어야 해서 필요하다.
3
3
  import { gunzipSync } from 'node:zlib';
4
+ import { resolve, sep } from 'node:path';
4
5
 
5
6
  const BLOCK = 512;
6
7
 
@@ -15,12 +16,43 @@ function octal(buf, off, len) {
15
16
  return s ? parseInt(s, 8) : 0;
16
17
  }
17
18
 
19
+ /*
20
+ * ── 작게 받아서 크게 푸는 묶음(압축 폭탄) ───────────────────────────────
21
+ *
22
+ * 받는 쪽(plugins/manage.js)은 **압축된** 크기를 64MB 로 막는다. 그런데
23
+ * 0 으로만 채운 파일은 천 배 넘게 줄어든다 — 64MB 짜리 하나가 풀면 수십 GB 다.
24
+ * gunzipSync 는 그걸 통째로 메모리에 올리므로, 화면에는 아무 말도 안 남고
25
+ * 프로세스가 그대로 죽는다. 사용자가 보는 것은 「플러그인을 깔아 줘」 라고
26
+ * 시킨 뒤 deel 이 사라진 것뿐이다 — 하던 대화까지 같이.
27
+ *
28
+ * 그래서 **푼 크기**에도 상한을 둔다. 256MB 로 잡은 까닭은 받는 상한의 네 배라서다.
29
+ * 플러그인은 글과 스크립트 묶음이라 서너 배까지는 부푼다. 그보다 더 부푸는 것은
30
+ * 플러그인이 아니라 폭탄으로 본다.
31
+ */
32
+ export const 푼것상한 = 256 * 1024 * 1024;
33
+
18
34
  /**
19
35
  * gzip 된 tar 를 풀어 파일 목록을 돌려준다.
36
+ * @param {Buffer} gz
37
+ * @param {object} o
38
+ * @param {number} o.상한 푼 크기 상한. 검사가 작은 값으로 부르려고 열어 뒀다 —
39
+ * 256MB 를 진짜로 만들어 재면 그 검사 하나가 기계를 잡아먹는다.
20
40
  * @returns {Array<{name:string, data:Buffer, mode:number}>}
21
41
  */
22
- export function untargz(gz) {
23
- const buf = gunzipSync(gz);
42
+ export function untargz(gz, { 상한 = 푼것상한 } = {}) {
43
+ let buf;
44
+ try {
45
+ buf = gunzipSync(gz, { maxOutputLength: 상한 });
46
+ } catch (err) {
47
+ // 조용히 죽는 대신 왜 안 되는지 말한다. 이 말은 install 이 그대로 화면에 올린다.
48
+ if (err?.code === 'ERR_BUFFER_TOO_LARGE') {
49
+ // MB 로만 적으면 작은 상한이 「0MB」 가 된다 — 사람에게 아무 말도 안 하는 숫자다.
50
+ const 상한말 = 상한 >= 1024 * 1024 ? `${Math.round(상한 / 1024 / 1024)}MB` : `${Math.round(상한 / 1024)}KB`;
51
+ throw new Error(`푼 크기가 상한(${상한말})을 넘습니다`
52
+ + ' — 압축만 작고 풀면 몇 GB 가 되는 묶음입니다. 저장소가 맞는지 확인하세요.');
53
+ }
54
+ throw err;
55
+ }
24
56
  const out = [];
25
57
  let pos = 0;
26
58
  let longName = null;
@@ -67,3 +99,34 @@ export function stripTop(files) {
67
99
  if (!files.every((f) => f.name.startsWith(first + '/'))) return files;
68
100
  return files.map((f) => ({ ...f, name: f.name.slice(first.length + 1) }));
69
101
  }
102
+
103
+ /**
104
+ * 이 이름이 풀 폴더 **안쪽**을 가리키나.
105
+ *
106
+ * ── 왜 있어야 하나 ──────────────────────────────────────────────────────
107
+ *
108
+ * tar 안의 이름은 **남이 적은 글자**다. 우리가 만든 것이 아니다. 그걸 그대로
109
+ * join(dest, name) 에 넣으면 `../../../..` 하나로 폴더 밖에 파일을 쓴다.
110
+ * 받는 쪽이 mkdirSync(recursive) 까지 해 주므로 없는 폴더도 만들어 가며 나간다.
111
+ *
112
+ * name: '../../../../윗동네에쓰기.txt'
113
+ * dest: C:\Users\누군가\.deel\plugins\x
114
+ * → C:\Users\윗동네에쓰기.txt
115
+ *
116
+ * 이 프로그램이 파는 문장이 「작업 폴더 밖은 안 만진다」 인데, 플러그인을
117
+ * 받는 길에는 그 문장을 지키는 코드가 한 줄도 없었다. 여기서 막는다 —
118
+ * 푸는 자리마다 따로 적으면 언젠가 한 곳이 빠진다.
119
+ *
120
+ * 절대경로·드라이브 문자도 같이 걸린다. resolve 는 절대경로를 만나면 뿌리를
121
+ * 통째로 갈아 치우므로, 그 결과가 뿌리로 시작하지 않는다.
122
+ */
123
+ export function 안쪽인가(뿌리, 이름) {
124
+ const 뿌리절대 = resolve(String(뿌리 ?? ''));
125
+ const 갈곳 = resolve(뿌리절대, String(이름 ?? ''));
126
+ return 갈곳 === 뿌리절대 || 갈곳.startsWith(뿌리절대 + sep);
127
+ }
128
+
129
+ /** 풀 폴더 밖을 가리키는 것만 골라 준다. 하나라도 있으면 그 묶음은 안 푼다. */
130
+ export function 밖을가리키는것(뿌리, files) {
131
+ return (files ?? []).filter((f) => !안쪽인가(뿌리, f?.name));
132
+ }
@@ -9,7 +9,7 @@ import {
9
9
  existsSync, mkdirSync, writeFileSync, readFileSync, rmSync,
10
10
  readdirSync, statSync,
11
11
  } from 'node:fs';
12
- import { untargz, stripTop } from '../pack/tar.js';
12
+ import { untargz, stripTop, 밖을가리키는것 } from '../pack/tar.js';
13
13
  import { makeZip } from '../pack/zip.js';
14
14
  import { allowTemporarily, isOffline, NetBlocked } from '../safety/network.js';
15
15
  import { 원시요청, 몸읽기 } from '../backend/http.js';
@@ -54,6 +54,45 @@ function run(cmd, args, opts = {}) {
54
54
  });
55
55
  }
56
56
 
57
+ /**
58
+ * 받은 묶음을 폴더에 푼다.
59
+ *
60
+ * ── 왜 따로 떼어 놨나 ───────────────────────────────────────────────────
61
+ *
62
+ * 이 몇 줄이 **남이 준 글자로 디스크에 파일을 쓰는 자리**다. 이 프로그램에서
63
+ * 제일 조심해야 하는 대목인데, fetchInto 안에 묻혀 있어서 검사가 한 번도 안
64
+ * 지나갔다. 거기까지 가려면 진짜로 네트워크에서 tarball 을 받아야 했기 때문이다.
65
+ *
66
+ * 그래서 「받는 일」 과 「푸는 일」 을 갈랐다. 푸는 일은 이제 파일 목록만 주면
67
+ * 그대로 재 볼 수 있다 — 남이 노리고 만든 묶음을 먹여 보는 것을 포함해서.
68
+ *
69
+ * ── 무엇을 막나 ─────────────────────────────────────────────────────────
70
+ *
71
+ * tar 안의 이름은 남이 적은 글자다. `../../../..` 하나면 플러그인 폴더 밖에
72
+ * 파일을 쓴다. 아래 mkdirSync 가 recursive 라 없는 폴더까지 만들어 가며 나간다.
73
+ * 이 프로그램이 파는 문장이 「작업 폴더 밖은 안 만진다」 인데, 플러그인을 받는
74
+ * 이 길에만 그 문장을 지키는 코드가 없었다.
75
+ *
76
+ * 나쁜 것만 골라 버리지 않고 **묶음째 거절한다.** 밖을 가리키는 이름이 든
77
+ * 묶음은 실수가 아니라 노린 것이고, 나머지를 풀어 줄 까닭이 없다.
78
+ * 그리고 **rmSync 앞에서** 막는다 — 거절할 묶음 때문에 이미 깔린 것을
79
+ * 지워 버리면 안 된다.
80
+ */
81
+ export function 묶음풀기(dest, files) {
82
+ if (!files?.length) return { error: '받은 묶음이 비어 있습니다' };
83
+ const 밖엣것 = 밖을가리키는것(dest, files);
84
+ if (밖엣것.length) {
85
+ return { error: `묶음 안에 플러그인 폴더 밖을 가리키는 이름이 있습니다 — 풀지 않았습니다: ${밖엣것[0].name}` };
86
+ }
87
+ rmSync(dest, { recursive: true, force: true });
88
+ for (const f of files) {
89
+ const p = join(dest, f.name);
90
+ mkdirSync(dirname(p), { recursive: true });
91
+ writeFileSync(p, f.data);
92
+ }
93
+ return { 푼것: files.length };
94
+ }
95
+
57
96
  // 받는 방법 두 가지. git 이 있으면 clone, 없으면 tarball 을 내려받아 푼다.
58
97
  async function fetchInto(spec, dest, onStep) {
59
98
  if (await has('git')) {
@@ -96,14 +135,12 @@ async function fetchInto(spec, dest, onStep) {
96
135
  for (const 닫기 of 열어둔) 닫기();
97
136
  }
98
137
  if (!gz) return { error: '받은 묶음이 너무 큽니다 (64MB 넘음) — 플러그인 저장소가 맞는지 확인하세요' };
99
- const files = stripTop(untargz(gz));
100
- if (!files.length) return { error: '받은 묶음이 비어 있습니다' };
101
- rmSync(dest, { recursive: true, force: true });
102
- for (const f of files) {
103
- const p = join(dest, f.name);
104
- mkdirSync(dirname(p), { recursive: true });
105
- writeFileSync(p, f.data);
106
- }
138
+ // 받는 크기만 막으면 압축 폭탄에 그대로 당한다 (pack/tar.js 푼것상한 머리말).
139
+ // 던지는 것을 여기서 받아 화면에 올린다 받으면 명령이 통째로 죽는다.
140
+ let 푼것;
141
+ try { 푼것 = 묶음풀기(dest, stripTop(untargz(gz))); }
142
+ catch (err) { return { error: `받은 묶음을 풀지 못했습니다 — ${String(err?.message ?? err)}` }; }
143
+ if (푼것.error) return { error: 푼것.error };
107
144
  return { how: 'tarball', branch };
108
145
  }
109
146
  return { error: '받지 못했습니다 — 저장소 주소나 가지 이름을 확인하세요' };
@@ -57,6 +57,23 @@ export const 제공자 = {
57
57
  `https://bedrock-runtime.${r}.amazonaws.com/v1`,
58
58
  `https://bedrock-runtime.${r}.amazonaws.com/openai/v1`,
59
59
  `https://bedrock-mantle.${r}.api.aws/v1`,
60
+ /*
61
+ * mantle 의 **Anthropic Messages 창구**.
62
+ *
63
+ * 위 셋은 전부 OpenAI 호환 창구다. 그 길로 가면 캐시 표식을 붙일 자리가
64
+ * 없어서(backend/cachemark.js) 서버가 알아서 잡아 주는 앞머리 말고는
65
+ * 캐시가 안 걸린다 — 대화가 자랄수록 그만큼 매번 다시 나간다.
66
+ *
67
+ * 이 창구는 Anthropic 규격이라 표식을 정식으로 받는다. 생각도
68
+ * adaptive 로 켤 수 있다. 인증은 Bedrock API 키를 Bearer 로 준다
69
+ * (문서: inference-messages-api). 그래서 detect 가 이 주소에서는
70
+ * Bearer 도 같이 두드린다.
71
+ *
72
+ * **뒤에 둔다.** 앞의 셋으로 이미 잘 쓰고 있는 사람의 설치가 이 줄
73
+ * 하나로 다른 창구로 옮겨 가면 안 된다. 골라서 쓰고 싶은 사람은
74
+ * 주소를 직접 적으면 된다 (docs/ko/models.md).
75
+ */
76
+ `https://bedrock-mantle.${r}.api.aws/anthropic/v1`,
60
77
  ];
61
78
  },
62
79