deel-local-cli 1.2.0 → 1.4.1
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 +301 -1537
- package/README.md +272 -1582
- package/bin/deel.js +73 -4
- package/package.json +8 -3
- package/src/acp/jsonrpc.js +230 -0
- package/src/acp/map.js +219 -0
- package/src/acp/serve.js +556 -0
- package/src/agent/card.js +129 -0
- package/src/agent/compact.js +10 -2
- package/src/agent/effort.js +5 -0
- package/src/agent/evidence.js +186 -0
- package/src/agent/grade.js +20 -0
- package/src/agent/loop.js +134 -13
- package/src/agent/models.js +169 -0
- package/src/agent/modes.js +47 -1
- package/src/agent/pins.js +140 -0
- package/src/agent/preset.js +113 -0
- package/src/agent/project.js +10 -4
- package/src/agent/session.js +233 -13
- package/src/agent/store.js +31 -0
- package/src/backend/adapter.js +14 -0
- package/src/commands.js +616 -52
- package/src/i18n/en.js +265 -0
- package/src/i18n/index.js +126 -0
- package/src/i18n/ko.js +252 -0
- package/src/lsp/client.js +459 -0
- package/src/lsp/diag.js +112 -0
- package/src/lsp/rpc.js +84 -0
- package/src/lsp/servers.js +218 -0
- package/src/oneshot.js +19 -0
- package/src/pack/sbom.js +218 -0
- package/src/pack/selfpack.js +19 -3
- package/src/preview/serve.js +19 -2
- package/src/repl.js +193 -8
- package/src/safety/secrets.js +205 -0
- package/src/safety/undo.js +10 -3
- package/src/tools/desc.en.js +221 -0
- package/src/tools/docs.js +252 -0
- package/src/tools/index.js +190 -6
- package/src/tools/lsp.js +327 -0
- package/src/tools/task.js +30 -2
- package/src/ui/ansi.js +45 -0
- package/src/ui/approve.js +25 -21
- package/src/ui/banner.js +245 -0
- package/src/ui/export.js +217 -0
- package/src/ui/inputbox.js +37 -7
- package/src/ui/intro.js +206 -0
- package/src/ui/level.js +11 -5
- package/src/ui/notify.js +101 -0
- package/src/ui/status.js +181 -35
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 이 자리에 깔려 있는 언어 서버 찾기.
|
|
3
|
+
*
|
|
4
|
+
* ── 절대 안 깔아 준다 ───────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 이 프로그램이 쓰이는 자리는 대개 밖으로 못 나간다. 그런 자리에서 도구가
|
|
7
|
+
* `npm i -g typescript-language-server` 를 제 마음대로 부르면, 잘 되면 미승인
|
|
8
|
+
* SW 를 들인 것이고 안 되면 몇십 초를 기다리다 실패한다. 둘 다 나쁘다.
|
|
9
|
+
*
|
|
10
|
+
* 그래서 여기서 하는 일은 **PATH 를 훑어보는 것뿐**이다. 있으면 쓰고, 없으면
|
|
11
|
+
* 없다고 말하고 Grep·Outline 으로 보내면 끝이다. 깔라는 말은 사용자에게만
|
|
12
|
+
* 한 번 하고(/lsp), 모델에게는 아예 그 도구를 안 보여 준다 — 못 쓰는 도구를
|
|
13
|
+
* 목록에 세워 두면 모델은 그걸 부르고, 실패를 받고, 다시 부른다.
|
|
14
|
+
*
|
|
15
|
+
* ── 왜 spawn 으로 안 재나 ───────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* `--version` 을 불러 보면 확실하지만, 후보가 열댓 개라 켤 때마다 프로세스를
|
|
18
|
+
* 열댓 개 띄우게 된다. 게다가 언어 서버 중에는 인자 없이 부르면 stdio 를 잡고
|
|
19
|
+
* 안 끝나는 것이 있다(rust-analyzer 가 그렇다). 켜자마자 유령이 하나 생긴다.
|
|
20
|
+
* PATH 훑기는 파일 있나 보는 것이라 값이 거의 0 이고, 틀릴 때는 '있다고 했는데
|
|
21
|
+
* 안 도는' 쪽으로만 틀린다 — 그건 켤 때 한 번 걸러진다.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, statSync } from 'node:fs';
|
|
24
|
+
import { join, delimiter, extname } from 'node:path';
|
|
25
|
+
import { walk } from '../tools/fsutil.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 언어별 후보. 앞에 있는 것부터 찾는다.
|
|
29
|
+
*
|
|
30
|
+
* 순서는 '가벼운 것 먼저' 가 아니라 **정확한 것 먼저**다. 여기서 나오는 답은
|
|
31
|
+
* 모델이 다음에 무엇을 고칠지 정하는 데 쓰인다. 틀린 자리를 짚어 주면 Grep
|
|
32
|
+
* 보다 나쁘다 — Grep 은 못 찾으면 못 찾았다고 하지, 엉뚱한 데를 안 짚는다.
|
|
33
|
+
*/
|
|
34
|
+
export const 후보 = {
|
|
35
|
+
ts: [
|
|
36
|
+
{ cmd: 'typescript-language-server', args: ['--stdio'], 깔기: 'npm i -g typescript-language-server typescript' },
|
|
37
|
+
{ cmd: 'deno', args: ['lsp'], 깔기: 'deno 는 제 lsp 를 갖고 있다' },
|
|
38
|
+
],
|
|
39
|
+
py: [
|
|
40
|
+
{ cmd: 'pyright-langserver', args: ['--stdio'], 깔기: 'npm i -g pyright' },
|
|
41
|
+
{ cmd: 'basedpyright-langserver', args: ['--stdio'], 깔기: 'pip install basedpyright' },
|
|
42
|
+
{ cmd: 'pylsp', args: [], 깔기: 'pip install python-lsp-server' },
|
|
43
|
+
{ cmd: 'jedi-language-server', args: [], 깔기: 'pip install jedi-language-server' },
|
|
44
|
+
],
|
|
45
|
+
go: [{ cmd: 'gopls', args: [], 깔기: 'go install golang.org/x/tools/gopls@latest' }],
|
|
46
|
+
rs: [{ cmd: 'rust-analyzer', args: [], 깔기: 'rustup component add rust-analyzer' }],
|
|
47
|
+
java: [{ cmd: 'jdtls', args: [], 깔기: 'eclipse.jdt.ls' }],
|
|
48
|
+
cs: [{ cmd: 'omnisharp', args: ['-lsp'], 깔기: 'dotnet tool install -g omnisharp' }],
|
|
49
|
+
cpp: [{ cmd: 'clangd', args: [], 깔기: 'LLVM 에 들어 있다' }],
|
|
50
|
+
rb: [{ cmd: 'ruby-lsp', args: [], 깔기: 'gem install ruby-lsp' }],
|
|
51
|
+
php: [{ cmd: 'intelephense', args: ['--stdio'], 깔기: 'npm i -g intelephense' }],
|
|
52
|
+
lua: [{ cmd: 'lua-language-server', args: [], 깔기: 'lua-language-server' }],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** 확장자 → 언어 열쇠. 여기 없는 확장자는 언어 서버를 안 찾는다. */
|
|
56
|
+
const 확장자갈래 = {
|
|
57
|
+
'.ts': 'ts', '.tsx': 'ts', '.mts': 'ts', '.cts': 'ts',
|
|
58
|
+
'.js': 'ts', '.jsx': 'ts', '.mjs': 'ts', '.cjs': 'ts',
|
|
59
|
+
'.py': 'py', '.pyi': 'py',
|
|
60
|
+
'.go': 'go',
|
|
61
|
+
'.rs': 'rs',
|
|
62
|
+
'.java': 'java',
|
|
63
|
+
'.cs': 'cs',
|
|
64
|
+
'.c': 'cpp', '.h': 'cpp', '.cc': 'cpp', '.cpp': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', '.hh': 'cpp',
|
|
65
|
+
'.rb': 'rb',
|
|
66
|
+
'.php': 'php',
|
|
67
|
+
'.lua': 'lua',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 이 파일은 어느 언어인가.
|
|
72
|
+
* @returns {string|null} 모르는 확장자면 null — 모르는 것은 모른다고 한다.
|
|
73
|
+
*/
|
|
74
|
+
export function 갈래(경로) {
|
|
75
|
+
if (typeof 경로 !== 'string' || !경로) return null;
|
|
76
|
+
return 확장자갈래[extname(경로).toLowerCase()] ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** LSP 가 쓰는 languageId. 열쇠와 다른 것만 적어 둔다. */
|
|
80
|
+
const 아이디 = { ts: 'typescript', py: 'python', go: 'go', rs: 'rust', java: 'java', cs: 'csharp', cpp: 'cpp', rb: 'ruby', php: 'php', lua: 'lua' };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 이 파일을 서버에게 뭐라고 소개할지.
|
|
84
|
+
*
|
|
85
|
+
* 확장자를 그대로 다시 본다. `.js` 를 typescript 라고 소개하면 서버가 그 파일에
|
|
86
|
+
* 타입 규칙을 걸어 없는 오류를 만들어 낸다 — 편집 뒤 진단에서 바로 티가 난다.
|
|
87
|
+
*/
|
|
88
|
+
export function 언어아이디(경로) {
|
|
89
|
+
const e = extname(String(경로 ?? '')).toLowerCase();
|
|
90
|
+
if (e === '.js' || e === '.mjs' || e === '.cjs') return 'javascript';
|
|
91
|
+
if (e === '.jsx') return 'javascriptreact';
|
|
92
|
+
if (e === '.tsx') return 'typescriptreact';
|
|
93
|
+
if (e === '.pyi') return 'python';
|
|
94
|
+
return 아이디[갈래(경로)] ?? 'plaintext';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* PATH 에 이 이름이 있나.
|
|
99
|
+
*
|
|
100
|
+
* 윈도우에서는 확장자를 붙여 봐야 한다. npm 이 전역으로 깐 것은 `.cmd` 라서,
|
|
101
|
+
* 이름 그대로 찾으면 깔려 있는데도 없다고 나온다. 실제로 이 자리(윈도우 + npm)가
|
|
102
|
+
* 이 프로그램이 제일 많이 도는 자리다.
|
|
103
|
+
*
|
|
104
|
+
* @param env 시험에서 갈아 끼우려고 받는다. 안 주면 지금 프로세스 것.
|
|
105
|
+
* @returns {string|null} 찾은 실제 경로
|
|
106
|
+
*/
|
|
107
|
+
export function 어디있나(이름, env = process.env) {
|
|
108
|
+
if (!이름) return null;
|
|
109
|
+
const 길들 = String(env.PATH ?? env.Path ?? '').split(delimiter).filter(Boolean);
|
|
110
|
+
/*
|
|
111
|
+
* 윈도우에서는 **확장자 붙은 것을 먼저** 본다. 이 순서가 중요하다.
|
|
112
|
+
*
|
|
113
|
+
* npm 이 전역으로 깔면 한 폴더에 둘을 같이 만든다 —
|
|
114
|
+
* typescript-language-server (sh 스크립트. WSL·git-bash 용)
|
|
115
|
+
* typescript-language-server.cmd (윈도우가 실제로 돌릴 수 있는 것)
|
|
116
|
+
*
|
|
117
|
+
* 이름 그대로를 먼저 찾으면 앞엣것이 잡히고, 그걸 띄우면 윈도우는 못 돌린다.
|
|
118
|
+
* 그런데 파일은 분명히 있으니 '깔려 있다' 고 나온다. 그래서 도구는 목록에
|
|
119
|
+
* 서고, 부르면 6ms 만에 "서버가 없습니다" 가 돌아온다 — 있다고 해 놓고
|
|
120
|
+
* 안 되는, 제일 알아채기 어려운 꼴이다.
|
|
121
|
+
*/
|
|
122
|
+
const 확장 = process.platform === 'win32'
|
|
123
|
+
? [...String(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
|
124
|
+
: [''];
|
|
125
|
+
for (const 길 of 길들) {
|
|
126
|
+
for (const e of 확장) {
|
|
127
|
+
const p = join(길, 이름 + e);
|
|
128
|
+
try {
|
|
129
|
+
// 폴더가 이름만 같은 경우가 있다. 파일인지까지 본다.
|
|
130
|
+
if (existsSync(p) && statSync(p).isFile()) return p;
|
|
131
|
+
} catch { /* 못 읽는 자리는 없는 것으로 친다 */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* 시험이 서버 하나를 박아 넣는 자리.
|
|
139
|
+
*
|
|
140
|
+
* 이게 없으면 이 파일 아래쪽(켜기·물어보기·진단·도구·정리)은 **언어 서버가
|
|
141
|
+
* 깔린 컴퓨터에서만** 시험이 돈다. 그런데 이 프로그램을 쓰는 자리는 대개
|
|
142
|
+
* 안 깔려 있다 — 하필 시험이 제일 필요한 자리에서 시험이 안 도는 셈이다.
|
|
143
|
+
*
|
|
144
|
+
* 그래서 후보 표를 갈아 끼울 수 있게 열어 두고, 시험은 LSP 를 흉내 내는
|
|
145
|
+
* 작은 node 스크립트를 박아 넣는다. 오가는 말은 진짜 규약 그대로라(길이 머리말,
|
|
146
|
+
* initialize 악수, publishDiagnostics) 흉내가 아니라 실제로 그 길을 다 밟는다.
|
|
147
|
+
*/
|
|
148
|
+
const _박은것 = new Map();
|
|
149
|
+
// 뿌리마다 한 번만 센 결과. 아래 프로젝트갈래() 가 채운다.
|
|
150
|
+
const _센것 = new Map();
|
|
151
|
+
|
|
152
|
+
export function 서버박기(갈래열쇠, 고른것) {
|
|
153
|
+
if (고른것) _박은것.set(갈래열쇠, 고른것);
|
|
154
|
+
else _박은것.delete(갈래열쇠);
|
|
155
|
+
_센것.clear(); // 박고 나면 폴더를 다시 세야 한다
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 이 언어에 쓸 수 있는 서버를 고른다.
|
|
160
|
+
* @returns {{cmd,args,이름,경로}|null}
|
|
161
|
+
*/
|
|
162
|
+
export function 고르기(갈래열쇠, env = process.env) {
|
|
163
|
+
if (_박은것.has(갈래열쇠)) return _박은것.get(갈래열쇠);
|
|
164
|
+
for (const c of 후보[갈래열쇠] ?? []) {
|
|
165
|
+
const 경로 = 어디있나(c.cmd, env);
|
|
166
|
+
if (경로) return { cmd: c.cmd, args: c.args, 이름: c.cmd, 경로 };
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 이 자리에서 쓸 수 있는 것들을 한눈에. /lsp 화면과 시험이 쓴다. */
|
|
172
|
+
export function 둘러보기(env = process.env) {
|
|
173
|
+
const 있는것 = [];
|
|
174
|
+
const 없는것 = [];
|
|
175
|
+
for (const 열쇠 of Object.keys(후보)) {
|
|
176
|
+
const 찾음 = 고르기(열쇠, env);
|
|
177
|
+
if (찾음) 있는것.push({ 갈래: 열쇠, 이름: 찾음.이름, 경로: 찾음.경로 });
|
|
178
|
+
else 없는것.push({ 갈래: 열쇠, 깔기: 후보[열쇠][0]?.깔기 ?? '' });
|
|
179
|
+
}
|
|
180
|
+
return { 있는것, 없는것 };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 이 폴더는 무슨 언어로 되어 있나.
|
|
185
|
+
*
|
|
186
|
+
* Def·Refs 를 file_path 없이 부를 때 어느 서버에게 물을지 정하는 데 쓴다.
|
|
187
|
+
* 파일이 제일 많은 갈래로 고른다 — 설정 파일 몇 개 때문에 엉뚱한 서버를
|
|
188
|
+
* 켜면 켜는 값만 물고 아무 답도 못 받는다.
|
|
189
|
+
*
|
|
190
|
+
* **쓸 수 있는 서버가 있는 갈래만** 센다. ts 파일이 제일 많아도 서버가 없으면
|
|
191
|
+
* 두 번째로 많은 py 를 고르는 것이 맞다. 없는 것을 고르고 실패하는 것보다,
|
|
192
|
+
* 있는 것을 고르고 그것만이라도 답하는 편이 낫다.
|
|
193
|
+
*
|
|
194
|
+
* 뿌리마다 한 번만 센다. 폴더 훑기는 값이 있고, 세션 중에 언어가 바뀌지 않는다.
|
|
195
|
+
*/
|
|
196
|
+
export function 프로젝트갈래(뿌리, env = process.env) {
|
|
197
|
+
if (_센것.has(뿌리)) return _센것.get(뿌리);
|
|
198
|
+
let 답 = null;
|
|
199
|
+
try {
|
|
200
|
+
const 셈 = new Map();
|
|
201
|
+
const 첫파일 = new Map();
|
|
202
|
+
for (const f of walk(뿌리, { limit: 4000 })) {
|
|
203
|
+
const g = 갈래(f.path);
|
|
204
|
+
if (!g) continue;
|
|
205
|
+
셈.set(g, (셈.get(g) ?? 0) + 1);
|
|
206
|
+
if (!첫파일.has(g)) 첫파일.set(g, f.path);
|
|
207
|
+
}
|
|
208
|
+
const 차례 = [...셈.entries()].sort((a, b) => b[1] - a[1]);
|
|
209
|
+
for (const [g] of 차례) {
|
|
210
|
+
if (고르기(g, env)) { 답 = { 갈래: g, 대표파일: 첫파일.get(g), 개수: 셈.get(g) }; break; }
|
|
211
|
+
}
|
|
212
|
+
} catch { 답 = null; }
|
|
213
|
+
_센것.set(뿌리, 답);
|
|
214
|
+
return 답;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 시험이 쓴다 — 폴더를 새로 만들고 다시 셀 때. */
|
|
218
|
+
export function 셈지우기() { _센것.clear(); }
|
package/src/oneshot.js
CHANGED
|
@@ -14,6 +14,7 @@ import { c, mark, clip } from './ui/ansi.js';
|
|
|
14
14
|
import { run } from './agent/loop.js';
|
|
15
15
|
import { Session } from './agent/session.js';
|
|
16
16
|
import { makeScope } from './safety/guard.js';
|
|
17
|
+
import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
|
|
17
18
|
import { History } from './safety/undo.js';
|
|
18
19
|
import { Audit } from './safety/audit.js';
|
|
19
20
|
import { activeProfile, load, resolveKey } from './config.js';
|
|
@@ -109,6 +110,10 @@ export async function runOnce(opts = {}) {
|
|
|
109
110
|
*/
|
|
110
111
|
const 껐다 = 일감모두끝내기();
|
|
111
112
|
if (껐다) 곁(` ${mark.ok} ${c.gray(`뒤에서 돌던 명령 ${껐다}개를 껐습니다.`)}`);
|
|
113
|
+
// 고친 뒤 진단 때문에 뒤에서 데워진 언어 서버도 같이 거둔다.
|
|
114
|
+
// 이건 아무 말 없이 한다 — 사용자가 띄우라고 한 적이 없는 것이라,
|
|
115
|
+
// 껐다는 말부터 하면 "그건 또 뭐냐" 가 된다.
|
|
116
|
+
언어서버다끄기().catch(() => {});
|
|
112
117
|
if (json) process.stdout.write(JSON.stringify(r) + '\n');
|
|
113
118
|
else if (r.text) process.stdout.write(r.text.endsWith('\n') ? r.text : r.text + '\n');
|
|
114
119
|
return r.code;
|
|
@@ -185,6 +190,9 @@ export async function runOnce(opts = {}) {
|
|
|
185
190
|
seen: new Set(),
|
|
186
191
|
skills: found.skills,
|
|
187
192
|
loadedSkills: new Set(),
|
|
193
|
+
// 고친 뒤 진단을 볼지 (lsp/diag.js). 아래 내놓기() 에서 반드시 거둔다 —
|
|
194
|
+
// 안 거두면 배치가 끝나고도 언어 서버가 폴더를 물고 남는다.
|
|
195
|
+
lsp: { 켬: true },
|
|
188
196
|
// 되물을 사람이 없으니 기본값을 그대로 돌려준다.
|
|
189
197
|
ask: async (_label, o = {}) => o?.def ?? '',
|
|
190
198
|
// 엑셀 암호를 여기서 기다리면 그대로 선다. 없다고 바로 답한다 —
|
|
@@ -290,6 +298,17 @@ export async function runOnce(opts = {}) {
|
|
|
290
298
|
// 하위 작업 안쪽이면 한 단 들여 그린다. 안 그러면 하위가 만진 파일이
|
|
291
299
|
// 부모가 만진 것과 똑같이 찍혀서, 기록으로 읽을 때 구분이 안 된다.
|
|
292
300
|
곁(`${안쪽(ev)} ${c.cyan('⏺')} ${c.bold(도구줄(ev.name, ev.args))} ${결과}`);
|
|
301
|
+
/*
|
|
302
|
+
* 비밀이 지나갔으면 여기서도 적는다.
|
|
303
|
+
*
|
|
304
|
+
* 배치는 아무도 안 보는 자리이고, 이 기록이 나중에 근거가 된다.
|
|
305
|
+
* "열쇠가 대화에 들어갔다" 를 그 기록에서 빼면 안 된다 — 나중에
|
|
306
|
+
* 사고가 났을 때 언제 새어 나갔는지 알 방법이 없어진다.
|
|
307
|
+
*/
|
|
308
|
+
if (ev.비밀) {
|
|
309
|
+
삐끗(`${안쪽(ev)} ${ev.비밀.가렸나 ? c.gray('⊘') : c.yellow('!')} `
|
|
310
|
+
+ `${c.gray(`${ev.비밀.말}${ev.비밀.가렸나 ? ' — 모델에 가려서 보냈습니다' : ' — 파일 내용은 가리지 않습니다'}`)}`);
|
|
311
|
+
}
|
|
293
312
|
break;
|
|
294
313
|
}
|
|
295
314
|
|
package/src/pack/sbom.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// 심사 담당자가 **읽지 않고 넣을 수 있는** 서류.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 필요한가 ────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `deel audit` 이 내놓는 반입심사서.txt 는 사람이 읽는 글이다. 그건 그대로
|
|
6
|
+
// 값이 있지만, 사내 반입 심사는 사람만 보는 절차가 아니다. 요즘은 보안팀이
|
|
7
|
+
// **SBOM 을 스캐너에 먹여서** 취약점 목록을 뽑는다. 사람이 읽는 글은 거기에
|
|
8
|
+
// 못 들어간다.
|
|
9
|
+
//
|
|
10
|
+
// 2026년 4월 20일 전자금융감독규정 시행세칙 개정으로 금융권 망분리 예외가
|
|
11
|
+
// 열렸다. 그 문이 열린 자리에서 제일 먼저 요구되는 것이 이 세 가지다.
|
|
12
|
+
//
|
|
13
|
+
// SBOM 무엇으로 만들어졌나 — 표준 형식(CycloneDX)으로
|
|
14
|
+
// 통신 목록 어디로 나가나 — 소스에서 찾은 자리와 정책을 함께
|
|
15
|
+
// 감사 사양 무엇이 기록되나 — 이걸 알아야 SIEM 에 넣을 수 있다
|
|
16
|
+
//
|
|
17
|
+
// ── 지어내지 않는다 ────────────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// 여기 적히는 값은 전부 selfpack.js 의 audit() 이 **소스를 실제로 훑어** 얻은
|
|
20
|
+
// 것이다. 손으로 적은 목록을 두면 반드시 어긋나고, 어긋난 심사 서류는 없느니만
|
|
21
|
+
// 못하다 — 담당자가 한 번 틀린 것을 발견하면 나머지도 안 믿는다.
|
|
22
|
+
//
|
|
23
|
+
// 감사 사양만은 손으로 적었다. 그래서 그것이 진짜와 어긋나지 않는지를
|
|
24
|
+
// 검사에서 확인한다 (test/sbom.test.js) — Audit 이 실제로 남긴 줄에 여기
|
|
25
|
+
// 적어 둔 칸이 다 있는지 본다.
|
|
26
|
+
import { randomUUID } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
/** 우리가 내놓는 SBOM 규격. 스캐너가 이 숫자를 보고 읽는 법을 정한다. */
|
|
29
|
+
export const CDX판 = '1.5';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* CycloneDX SBOM.
|
|
33
|
+
*
|
|
34
|
+
* 의존성이 0개인 물건의 SBOM 은 거의 빈 문서가 된다. 그래서 파일 하나하나를
|
|
35
|
+
* `type: "file"` 부품으로 싣고 SHA-256 을 붙인다 — 규격이 허락하는 쓰임이고,
|
|
36
|
+
* 담당자가 받은 zip 이 우리가 만든 그것인지 **기계로** 확인할 수 있게 된다.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} a selfpack.js 의 audit()
|
|
39
|
+
* @param {object} o
|
|
40
|
+
* @param {Date} o.at 만든 때 (검사가 고정할 수 있게)
|
|
41
|
+
* @param {string} o.serial 문서 일련번호 (검사가 고정할 수 있게)
|
|
42
|
+
*/
|
|
43
|
+
export function sbom(a, { at = new Date(), serial = null } = {}) {
|
|
44
|
+
const purl = `pkg:npm/${a.name}@${a.version}`;
|
|
45
|
+
const 본체 = {
|
|
46
|
+
type: 'application',
|
|
47
|
+
'bom-ref': purl,
|
|
48
|
+
name: a.name,
|
|
49
|
+
version: a.version,
|
|
50
|
+
purl,
|
|
51
|
+
licenses: a.license ? [{ license: { id: a.license } }] : [],
|
|
52
|
+
description: '로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
bomFormat: 'CycloneDX',
|
|
57
|
+
specVersion: CDX판,
|
|
58
|
+
serialNumber: `urn:uuid:${serial ?? randomUUID()}`,
|
|
59
|
+
version: 1,
|
|
60
|
+
metadata: {
|
|
61
|
+
timestamp: at.toISOString(),
|
|
62
|
+
component: 본체,
|
|
63
|
+
tools: [{ vendor: 'deel', name: 'deel pack', version: a.version }],
|
|
64
|
+
properties: [
|
|
65
|
+
{ name: 'deel:runtime', value: `node ${a.node}` },
|
|
66
|
+
{ name: 'deel:dependencies', value: String(a.deps.length) },
|
|
67
|
+
{ name: 'deel:devDependencies', value: String(a.devDeps.length) },
|
|
68
|
+
{ name: 'deel:installScripts', value: a.lifecycle.length ? a.lifecycle.join(',') : 'none' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
// 파일마다 하나씩. 담긴 것이 담겨야 할 것과 같은지 여기서 대조한다.
|
|
72
|
+
components: a.files.map((f) => ({
|
|
73
|
+
type: 'file',
|
|
74
|
+
'bom-ref': `file:${f.path}`,
|
|
75
|
+
name: f.path,
|
|
76
|
+
hashes: [{ alg: 'SHA-256', content: f.sha }],
|
|
77
|
+
properties: [{ name: 'deel:bytes', value: String(f.bytes) }],
|
|
78
|
+
})),
|
|
79
|
+
/*
|
|
80
|
+
* 0개를 0개라고 적는다.
|
|
81
|
+
*
|
|
82
|
+
* dependencies 를 아예 빼면 스캐너가 "안 적어 낸 것" 과 "없는 것" 을
|
|
83
|
+
* 구별하지 못한다. 빈 배열을 명시하면 '없다' 가 주장으로 남는다.
|
|
84
|
+
*/
|
|
85
|
+
dependencies: [{ ref: purl, dependsOn: [] }],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 나가는 길.
|
|
91
|
+
*
|
|
92
|
+
* 소스에서 찾은 자리와, 그 자리에 걸어 둔 정책을 같이 싣는다. 정책만 적으면
|
|
93
|
+
* 믿을 근거가 없고, 자리만 적으면 무슨 뜻인지 알 수 없다.
|
|
94
|
+
*/
|
|
95
|
+
export function 통신명세(a) {
|
|
96
|
+
const 자리 = (id) => (a.calls?.[id] ?? []).map((x) => `${x.file}:${x.line}`);
|
|
97
|
+
return {
|
|
98
|
+
나가는길: [
|
|
99
|
+
{
|
|
100
|
+
갈래: '모델 게이트웨이',
|
|
101
|
+
언제: '사람이 한마디 할 때마다',
|
|
102
|
+
어디로: 'deel setup 에서 정한 주소 딱 한 곳',
|
|
103
|
+
무엇이: '오간 말과, 모델이 읽은 파일 내용',
|
|
104
|
+
막는법: 'src/safety/network.js 가 요청마다 허용 목록을 확인합니다. 목록에 없으면 요청 자체를 만들지 않습니다.',
|
|
105
|
+
소스: 자리('net'),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
갈래: '웹 읽기 (WebFetch)',
|
|
109
|
+
언제: '모델이 그 도구를 부를 때만',
|
|
110
|
+
어디로: '모델이 고른 주소. 이 컴퓨터·사내망 대역은 거절합니다',
|
|
111
|
+
무엇이: '아무것도 — GET 만 쓰므로 본문을 실어 보내지 않습니다',
|
|
112
|
+
막는법: '--offline 이면 통째로 막힙니다.',
|
|
113
|
+
소스: 자리('net'),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
갈래: '플러그인 받기',
|
|
117
|
+
언제: '사람이 /plugin install 을 칠 때만',
|
|
118
|
+
어디로: 'github.com',
|
|
119
|
+
무엇이: '아무것도',
|
|
120
|
+
막는법: '--offline 이면 통째로 막힙니다.',
|
|
121
|
+
소스: 자리('net'),
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
여는포트: {
|
|
125
|
+
개수: (a.calls?.listen ?? []).length,
|
|
126
|
+
쓰임: '만든 웹을 그 자리에서 띄워 보는 /preview 하나뿐입니다. 127.0.0.1 에만 붙고 명령이 끝나면 닫습니다.',
|
|
127
|
+
소스: 자리('listen'),
|
|
128
|
+
},
|
|
129
|
+
바깥명령: {
|
|
130
|
+
개수: (a.calls?.exec ?? []).length,
|
|
131
|
+
쓰임: '사람·모델이 지시한 명령, MCP 서버 띄우기, 플러그인 받을 때의 git.',
|
|
132
|
+
소스: 자리('exec'),
|
|
133
|
+
},
|
|
134
|
+
문자열실행: {
|
|
135
|
+
개수: (a.calls?.eval ?? []).length,
|
|
136
|
+
쓰임: '없어야 정상입니다.',
|
|
137
|
+
소스: 자리('eval'),
|
|
138
|
+
},
|
|
139
|
+
// 자물쇠를 걸면 무엇이 닫히는가. 담당자가 제일 자주 묻는 것이다.
|
|
140
|
+
offline일때: [
|
|
141
|
+
'웹 읽기(WebFetch)가 막힙니다.',
|
|
142
|
+
'플러그인 받기가 막힙니다.',
|
|
143
|
+
'MCP 서버를 띄우지 않습니다 — 자식 프로세스가 어디로 나가는지 우리는 못 막습니다.',
|
|
144
|
+
'모델 게이트웨이는 그대로 씁니다. 그 주소가 이 컴퓨터 안이면 아무것도 밖으로 안 나갑니다.',
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 감사기록 사양.
|
|
151
|
+
*
|
|
152
|
+
* 이걸 안 주면 담당자가 "로그가 남긴 남나요" 만 확인하고 끝난다. 칸 이름과
|
|
153
|
+
* 뜻을 주면 그대로 SIEM 수집 규칙을 짤 수 있다 — '자율 실행을 허가할 수
|
|
154
|
+
* 있는가' 의 답이 대개 여기서 갈린다.
|
|
155
|
+
*
|
|
156
|
+
* 손으로 적은 목록이라 진짜와 어긋날 수 있다. 그래서 검사가 Audit 이 실제로
|
|
157
|
+
* 남긴 줄과 대조한다.
|
|
158
|
+
*/
|
|
159
|
+
export function 감사명세() {
|
|
160
|
+
return {
|
|
161
|
+
자리: '<작업폴더>/.deel/audit.jsonl',
|
|
162
|
+
형식: 'JSON Lines (UTF-8, 한 줄에 기록 하나)',
|
|
163
|
+
쌓이는법: '덧붙이기만 합니다. 지우거나 고치지 않습니다.',
|
|
164
|
+
보관: 'deel 이 지우지 않습니다. 보관 기간은 조직 정책대로 두시면 됩니다.',
|
|
165
|
+
줄마다: [
|
|
166
|
+
{ 칸: 'at', 뜻: '언제 (ISO 8601, UTC)' },
|
|
167
|
+
{ 칸: 'session', 뜻: '어느 대화인지. 켠 시각으로 만든 번호' },
|
|
168
|
+
{ 칸: 'kind', 뜻: '무슨 기록인지 — 아래 목록' },
|
|
169
|
+
],
|
|
170
|
+
종류: [
|
|
171
|
+
{ kind: 'turn', 뜻: '사람이 시킨 말', 칸: ['text'], 비고: '앞 500자만 남습니다' },
|
|
172
|
+
{ kind: 'tool', 뜻: '도구를 부른 것', 칸: ['tool', 'target', 'ok', 'note'], 비고: 'target 은 파일 경로·명령·찾을 말' },
|
|
173
|
+
{ kind: 'blocked', 뜻: '안전장치가 막은 것', 칸: ['why', 'what'], 비고: '막힌 명령이 그대로 남습니다' },
|
|
174
|
+
{ kind: 'undo', 뜻: '되돌린 것', 칸: ['files', 'turns'] },
|
|
175
|
+
],
|
|
176
|
+
안남기는것: [
|
|
177
|
+
'열쇠·암호. 설정의 apiKey 도, 엑셀 암호도 기록에 안 들어갑니다.',
|
|
178
|
+
'파일 내용 전체. 무엇을 만졌는지는 남고 무엇이 적혔는지는 안 남습니다.',
|
|
179
|
+
'모델이 한 답. 그건 대화 기록(.deel/sessions)에 따로 남습니다.',
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 세 가지를 한 덩이로. zip 에 이대로 들어간다.
|
|
186
|
+
*/
|
|
187
|
+
export function 심사명세(a, { at = new Date() } = {}) {
|
|
188
|
+
return {
|
|
189
|
+
만든것: a.name,
|
|
190
|
+
판: a.version,
|
|
191
|
+
라이선스: a.license,
|
|
192
|
+
실행환경: `Node ${a.node} (표준 내장 기능만)`,
|
|
193
|
+
만든때: at.toISOString(),
|
|
194
|
+
의존성: {
|
|
195
|
+
dependencies: a.deps,
|
|
196
|
+
devDependencies: a.devDeps,
|
|
197
|
+
설치스크립트: a.lifecycle,
|
|
198
|
+
소스의외부import: a.외부모듈,
|
|
199
|
+
한마디: a.deps.length === 0 && a.외부모듈.length === 0
|
|
200
|
+
? '남의 코드를 함께 들여오지 않습니다. node: 내장과 자기 파일만 부릅니다.'
|
|
201
|
+
: '외부 모듈이 있습니다 — 위 목록을 확인하세요.',
|
|
202
|
+
},
|
|
203
|
+
통신: 통신명세(a),
|
|
204
|
+
감사기록: 감사명세(),
|
|
205
|
+
파일: a.files.map((f) => ({ 경로: f.path, 바이트: f.bytes, sha256: f.sha })),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 사람이 읽을 짧은 요약. 화면에 찍는 자리에서 쓴다. */
|
|
210
|
+
export function 명세요약(명세) {
|
|
211
|
+
const m = 명세 ?? {};
|
|
212
|
+
return [
|
|
213
|
+
`SBOM 부품 ${(m.파일?.length ?? 0).toLocaleString()}개 (전부 SHA-256 붙임)`,
|
|
214
|
+
`의존성 ${(m.의존성?.dependencies?.length ?? 0)}개 · 설치 스크립트 ${(m.의존성?.설치스크립트?.length ?? 0)}개`,
|
|
215
|
+
`나가는 길 ${(m.통신?.나가는길?.length ?? 0)}갈래 · 여는 포트 ${(m.통신?.여는포트?.개수 ?? 0)}곳`,
|
|
216
|
+
`감사기록 ${(m.감사기록?.종류?.length ?? 0)}종류 · ${m.감사기록?.자리 ?? ''}`,
|
|
217
|
+
].join('\n');
|
|
218
|
+
}
|
package/src/pack/selfpack.js
CHANGED
|
@@ -11,6 +11,7 @@ import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, writeFileSy
|
|
|
11
11
|
import { join, dirname, relative } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { makeZip } from './zip.js';
|
|
14
|
+
import { sbom, 심사명세 } from './sbom.js';
|
|
14
15
|
|
|
15
16
|
export const repoRoot = () => join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
16
17
|
|
|
@@ -213,7 +214,18 @@ export function packSelf(outFile, { root = repoRoot(), at = new Date() } = {}) {
|
|
|
213
214
|
const stamp = at.toISOString().replace('T', ' ').slice(0, 19);
|
|
214
215
|
const sheet = reviewSheet(a, stamp);
|
|
215
216
|
|
|
216
|
-
|
|
217
|
+
/*
|
|
218
|
+
* 사람이 읽는 것 하나, 기계가 읽는 것 둘.
|
|
219
|
+
*
|
|
220
|
+
* 반입 심사는 사람만 보는 절차가 아니다. 보안팀은 SBOM 을 스캐너에 먹여
|
|
221
|
+
* 취약점 목록을 뽑고, 운영팀은 감사기록 사양을 보고 SIEM 수집 규칙을 짠다.
|
|
222
|
+
* 사람이 읽는 글은 그 어느 쪽에도 못 들어간다 — 그래서 셋을 다 넣는다.
|
|
223
|
+
*/
|
|
224
|
+
const entries = [
|
|
225
|
+
{ name: '반입심사서.txt', data: Buffer.from(sheet, 'utf8'), mtime: at },
|
|
226
|
+
{ name: 'sbom.cdx.json', data: Buffer.from(JSON.stringify(sbom(a, { at }), null, 2), 'utf8'), mtime: at },
|
|
227
|
+
{ name: '심사명세.json', data: Buffer.from(JSON.stringify(심사명세(a, { at }), null, 2), 'utf8'), mtime: at },
|
|
228
|
+
];
|
|
217
229
|
for (const f of a.files) {
|
|
218
230
|
entries.push({
|
|
219
231
|
name: `deel/${f.path}`,
|
|
@@ -234,8 +246,12 @@ export function packSelf(outFile, { root = repoRoot(), at = new Date() } = {}) {
|
|
|
234
246
|
' 4. 대화를 시작합니다: node deel/bin/deel.js',
|
|
235
247
|
'',
|
|
236
248
|
'심사 담당자께',
|
|
237
|
-
'
|
|
238
|
-
'
|
|
249
|
+
' 반입심사서.txt 사람이 읽는 심사 자료. 의존성·설치 스크립트·네트워크',
|
|
250
|
+
' 호출 자리·파일별 SHA-256 이 전부 적혀 있습니다.',
|
|
251
|
+
' sbom.cdx.json SBOM (CycloneDX 1.5). 스캐너에 그대로 넣으시면 됩니다.',
|
|
252
|
+
' 심사명세.json 통신 목록 · 감사기록 사양 · 파일 해시. 기계가 읽는 형식입니다.',
|
|
253
|
+
'',
|
|
254
|
+
' 세 파일 모두 소스를 훑어 자동으로 만든 것입니다. 손으로 적은 값이 아닙니다.',
|
|
239
255
|
'',
|
|
240
256
|
].join('\n'), 'utf8'),
|
|
241
257
|
mtime: at,
|
package/src/preview/serve.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* 4. 파일을 **주기만** 한다. PUT·POST·DELETE 는 받지 않는다.
|
|
23
23
|
*/
|
|
24
24
|
import { createServer } from 'node:http';
|
|
25
|
-
import { createReadStream, statSync, existsSync, readdirSync, watch } from 'node:fs';
|
|
25
|
+
import { createReadStream, statSync, existsSync, readdirSync, watch, realpathSync } from 'node:fs';
|
|
26
26
|
import { join, extname, relative, sep } from 'node:path';
|
|
27
27
|
import { spawn } from 'node:child_process';
|
|
28
28
|
|
|
@@ -181,7 +181,24 @@ export function 띄우기({ 뿌리, scope, 되살리기 = true }) {
|
|
|
181
181
|
|
|
182
182
|
if (되살리기) {
|
|
183
183
|
try {
|
|
184
|
-
|
|
184
|
+
/*
|
|
185
|
+
* 감시할 자리는 **긴 이름으로 바꿔서** 준다.
|
|
186
|
+
*
|
|
187
|
+
* 윈도우가 알려 주는 파일 이름은 긴 이름인데, 우리가 준 자리가 짧은
|
|
188
|
+
* 이름(`RUNNER~1` 같은 8.3 이름)이거나 정션 너머면 둘이 안 맞는다.
|
|
189
|
+
* 그때 libuv 는 잡을 수 있는 오류를 내지 않고 **그냥 죽는다** —
|
|
190
|
+
*
|
|
191
|
+
* Assertion failed: !_wcsnicmp(filename, dir, dirlen),
|
|
192
|
+
* file src\win\fs-event.c, line 72
|
|
193
|
+
*
|
|
194
|
+
* try 로 감싸도 소용없다. assert 는 프로세스를 통째로 끝낸다.
|
|
195
|
+
* 미리보기 한 번 켰다가 deel 이 사라지는 셈이고, 화면에는 아무
|
|
196
|
+
* 이유도 안 남는다. GitHub 의 윈도우 러너 임시 폴더가 정확히 그
|
|
197
|
+
* 모양이라 검사가 거기서만 죽었고, 그 덕에 찾았다.
|
|
198
|
+
*/
|
|
199
|
+
let 감시자리 = 뿌리;
|
|
200
|
+
try { 감시자리 = realpathSync.native(뿌리); } catch { /* 못 바꾸면 준 대로 */ }
|
|
201
|
+
감시 = watch(감시자리, { recursive: true }, () => {
|
|
185
202
|
// 저장 한 번에 이벤트가 여러 개 온다. 몰아서 한 번만 알린다.
|
|
186
203
|
clearTimeout(늦추기);
|
|
187
204
|
늦추기 = setTimeout(알리기, 120);
|