deel-local-cli 1.16.0 → 1.17.2
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.ko.md +55 -9
- package/README.md +55 -8
- package/bin/deel.js +385 -5
- package/package.json +1 -1
- package/src/acp/serve.js +39 -1
- package/src/agent/agents.js +203 -0
- package/src/agent/loop.js +209 -17
- package/src/agent/models.js +4 -0
- package/src/agent/outschema.js +351 -0
- package/src/agent/session.js +13 -2
- package/src/backend/adapter.js +38 -2
- package/src/backend/clientcert.js +164 -0
- package/src/backend/http.js +232 -26
- package/src/backend/mcp.js +194 -30
- package/src/backend/probe.js +27 -1
- package/src/backend/wire.js +10 -0
- package/src/cmdnames.js +2 -0
- package/src/commands.js +139 -2
- package/src/completion.js +34 -4
- package/src/config.js +86 -9
- package/src/configexplain.js +177 -0
- package/src/doctor.js +195 -0
- package/src/i18n/en.js +37 -0
- package/src/i18n/ja.js +37 -0
- package/src/i18n/ko.js +37 -0
- package/src/i18n/zh.js +37 -0
- package/src/lsp/client.js +2 -2
- package/src/oneshot.js +181 -4
- package/src/pack/sbom.js +77 -3
- package/src/pack/selfpack.js +1 -1
- package/src/pack/sheet.en.js +1 -1
- package/src/repl.js +74 -3
- package/src/safety/hooks.js +389 -0
- package/src/safety/policy.js +74 -0
- package/src/safety/shellenv.js +116 -0
- package/src/safety/trust.js +230 -0
- package/src/setup.js +9 -0
- package/src/stats.js +162 -0
- package/src/tools/docs.js +12 -2
- package/src/tools/hwpxwrite.js +302 -0
- package/src/tools/index.js +128 -9
- package/src/tools/jobs.js +11 -6
- package/src/tools/spawn.js +19 -1
- package/src/tools/task.js +8 -2
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 훅 — 사람이 적어 둔 명령을 **정해진 자리**에서 돌린다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 사내마다 지켜야 하는 것이 다르다. 어떤 팀은 `git push` 를 절대 못 하게
|
|
7
|
+
* 해야 하고, 어떤 팀은 파일을 고칠 때마다 사내 포맷터를 돌려야 하고, 어떤
|
|
8
|
+
* 팀은 사람이 친 말에 주민번호가 섞였는지를 먼저 봐야 한다.
|
|
9
|
+
*
|
|
10
|
+
* 이걸 전부 이 프로그램 안에 넣을 수는 없다. 넣으면 팀마다 포크를 뜨게 되고,
|
|
11
|
+
* 포크를 뜨면 그 순간부터 우리가 고치는 것이 그 팀에 안 간다. 그래서 **자리만
|
|
12
|
+
* 내준다.** 무엇을 할지는 그 팀이 제 파일에 적는다.
|
|
13
|
+
*
|
|
14
|
+
* 규칙(safety/policy.js)과는 다른 축이다. 규칙은 「이 무늬면 막는다」 는 표라서
|
|
15
|
+
* 우리가 아는 것만 잰다. 훅은 아무 프로그램이나 부를 수 있어서, 사내 DLP 나
|
|
16
|
+
* 사내 승인 서버처럼 **우리가 영영 모르는 것**을 물어볼 수 있다.
|
|
17
|
+
*
|
|
18
|
+
* ── 이건 남의 프로그램을 돌리는 일이다 ─────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* MCP 와 같은 무게로 다룬다. 다만 가르는 점이 하나 있다 — MCP 서버는 밖에서
|
|
21
|
+
* 받아 온 남의 프로그램이고, 훅은 **이 사람이 제 파일에 제 손으로 적은 명령**
|
|
22
|
+
* 이다. Bash 도구로 치는 것과 같은 무게다. 그래서 자물쇠(--offline)가 걸려도
|
|
23
|
+
* 훅은 돈다 — Bash 를 안 막으면서 훅만 막으면 말이 안 맞는다.
|
|
24
|
+
*
|
|
25
|
+
* 1) **기본은 꺼져 있다.** hooks.json 에 사람이 직접 적어야만 돈다.
|
|
26
|
+
* 2) **프로젝트 파일은 믿는 폴더에서만 읽는다.** 남의 저장소를 clone 하는
|
|
27
|
+
* 것만으로 명령이 돌면 안 된다 (safety/trust.js).
|
|
28
|
+
* 3) **감사기록에 남긴다.** 무엇이 언제 돌았고 무엇을 막았는지.
|
|
29
|
+
* 4) **DEEL_HOOKS=off 로 끈다.** 훅이 망가지면 프로그램 전체가 멈추는데,
|
|
30
|
+
* 그때 고칠 길이 훅 파일을 지우는 것뿐이면 안 된다.
|
|
31
|
+
*
|
|
32
|
+
* ── 고장 나면 막는다 ───────────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* 문지기가 쓰러져 있으면 문은 **잠긴 것이 아니다.** 그런데 흔한 규격은
|
|
35
|
+
* 「2번으로 끝나면 막고, 나머지 실패는 지나간다」 다. 그러면 훅 파일에 오타
|
|
36
|
+
* 하나(`pythno check.py`)가 나는 순간 그 문은 조용히 열린 채로 남는다 —
|
|
37
|
+
* 화면에는 여전히 「훅 3개」 라고 떠 있는 채로.
|
|
38
|
+
*
|
|
39
|
+
* 그래서 **막는 자리의 훅**(도구전·말전)은 고장 나면 막는다. 못 잰 것을
|
|
40
|
+
* 초록으로 세지 않는 것과 같은 판단이다. 지나가게 하려면 그 훅에
|
|
41
|
+
* `"고장나면": "지나가기"` 라고 **적어야** 한다 — 적힌 것만 지나간다.
|
|
42
|
+
*/
|
|
43
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
44
|
+
import { homedir } from 'node:os';
|
|
45
|
+
import { join, resolve } from 'node:path';
|
|
46
|
+
import { 돌려보기 } from '../tools/spawn.js';
|
|
47
|
+
import { 셸고르기 } from '../tools/shell.js';
|
|
48
|
+
import { 믿나 } from './trust.js';
|
|
49
|
+
|
|
50
|
+
/** 훅이 이 시간 안에 안 끝나면 죽인다. 사람이 훅마다 따로 정할 수 있다. */
|
|
51
|
+
export const 제한기본 = 15000;
|
|
52
|
+
/** 한 훅이 뱉을 수 있는 글의 최대. 넘으면 자르고 잘랐다고 말한다. */
|
|
53
|
+
export const 글최대 = 16 * 1024;
|
|
54
|
+
/** 한 자리에서 돌릴 수 있는 훅 수. 이보다 많으면 설정이 잘못된 것이다. */
|
|
55
|
+
export const 훅최대 = 32;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 훅을 걸 수 있는 자리.
|
|
59
|
+
*
|
|
60
|
+
* 영문 이름도 같이 받는다 — Claude Code 의 hooks 설정을 그대로 복사해 붙일 수
|
|
61
|
+
* 있어야 한다. MCP 설정에서 `mcpServers` 를 그대로 받는 것과 같은 이유다.
|
|
62
|
+
* 이미 그 파일을 갖고 있는 사람에게 「우리 말로 다시 적어라」 고 하면, 그
|
|
63
|
+
* 사람은 훅을 안 쓴다.
|
|
64
|
+
*/
|
|
65
|
+
export const 자리표 = Object.freeze({
|
|
66
|
+
PreToolUse: '도구전',
|
|
67
|
+
PostToolUse: '도구후',
|
|
68
|
+
UserPromptSubmit: '말전',
|
|
69
|
+
Stop: '턴끝',
|
|
70
|
+
});
|
|
71
|
+
export const 자리들 = Object.freeze(['도구전', '도구후', '말전', '턴끝']);
|
|
72
|
+
/** 막을 수 있는 자리. 나머지는 무슨 소리를 해도 못 막는다. */
|
|
73
|
+
export const 막는자리 = Object.freeze(['도구전', '말전']);
|
|
74
|
+
|
|
75
|
+
export const 프로젝트자리 = (root) => join(root, '.deel', 'hooks.json');
|
|
76
|
+
/**
|
|
77
|
+
* 이 PC 것이 놓이는 자리.
|
|
78
|
+
*
|
|
79
|
+
* `DEEL_HOME` 을 먼저 본다 — 설정도 믿는 목록도 다 그 폴더를 따라가는데
|
|
80
|
+
* (config.js · safety/trust.js) 훅만 진짜 집 폴더를 보면, USB 로 들고 다니는
|
|
81
|
+
* 설치에서 훅만 안 따라온다. 그리고 그건 아무 데도 안 찍힌다.
|
|
82
|
+
*/
|
|
83
|
+
export const 이PC자리 = (집 = homedir(), env = process.env) => (
|
|
84
|
+
env.DEEL_HOME ? join(resolve(env.DEEL_HOME), 'hooks.json') : join(집, '.deel', 'hooks.json')
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/** 사람이 `pre-tool-use` 처럼 적어도 알아듣는다. */
|
|
88
|
+
function 자리풀기(값) {
|
|
89
|
+
const s = String(값 ?? '').trim();
|
|
90
|
+
if (자리들.includes(s)) return s;
|
|
91
|
+
const 납작 = s.replace(/[-_\s]/g, '').toLowerCase();
|
|
92
|
+
for (const [영, 한] of Object.entries(자리표)) if (영.toLowerCase() === 납작) return 한;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 도구 이름 무늬를 짓는다 — **양끝을 묶는다.**
|
|
98
|
+
*
|
|
99
|
+
* 여기가 묶여 있지 않으면 `Read` 라고 적은 무늬가 `TodoWrite` 에도 걸린다.
|
|
100
|
+
* 막는 쪽이면 안 막을 것을 막고, 고치는 쪽이면 엉뚱한 것을 고친다. 둘 다
|
|
101
|
+
* 「왜 이러지」 로 며칠을 쓰게 되는 종류의 어긋남이다.
|
|
102
|
+
*
|
|
103
|
+
* 무늬가 잘못 적혀 있으면 **null 을 돌려준다.** 아무것도 안 걸리는 무늬로
|
|
104
|
+
* 삼키면 그 훅은 있으나 마나가 되는데, 화면에는 여전히 세어져 있다.
|
|
105
|
+
*/
|
|
106
|
+
export function 무늬짓기(값) {
|
|
107
|
+
const s = String(값 ?? '').trim();
|
|
108
|
+
if (!s || s === '*') return { 다냐: true, re: null };
|
|
109
|
+
try { return { 다냐: false, re: new RegExp(`^(?:${s})$`) }; } catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 이 훅이 이 도구에 걸리나. */
|
|
113
|
+
export function 걸리나(훅, 도구) {
|
|
114
|
+
if (!훅.무늬 || 훅.무늬.다냐) return true;
|
|
115
|
+
return 훅.무늬.re.test(String(도구 ?? ''));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 훅 하나를 우리 모양으로 편다.
|
|
120
|
+
*
|
|
121
|
+
* @returns {object|null} 못 알아들으면 null (부르는 쪽이 왜인지 적는다)
|
|
122
|
+
*/
|
|
123
|
+
function 한훅(자리, 무늬글, 것, 출처) {
|
|
124
|
+
const 명령 = String(것?.명령 ?? 것?.command ?? '').trim();
|
|
125
|
+
if (!명령) return null;
|
|
126
|
+
if (것?.type && 것.type !== 'command') return null; // 다른 갈래는 아직 없다
|
|
127
|
+
const 무늬 = 무늬짓기(무늬글);
|
|
128
|
+
if (!무늬) return null;
|
|
129
|
+
const 초 = Number(것?.제한초 ?? 것?.timeout ?? 0);
|
|
130
|
+
const 고장나면 = String(것?.고장나면 ?? 것?.onError ?? '').trim();
|
|
131
|
+
return {
|
|
132
|
+
자리,
|
|
133
|
+
무늬글: String(무늬글 ?? '*'),
|
|
134
|
+
무늬,
|
|
135
|
+
명령,
|
|
136
|
+
제한: Number.isFinite(초) && 초 > 0 ? Math.min(초 * 1000, 120000) : 제한기본,
|
|
137
|
+
// 적힌 것만 지나간다. 이 파일 머리말의 「고장 나면 막는다」 가 여기다.
|
|
138
|
+
지나갈까: 고장나면 === '지나가기' || 고장나면 === 'pass',
|
|
139
|
+
출처,
|
|
140
|
+
이름: String(것?.이름 ?? 것?.name ?? '').trim() || null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 한 파일에서 훅을 읽는다. 두 가지 모양을 다 받는다.
|
|
146
|
+
*
|
|
147
|
+
* 납작한 것 { "hooks": [ { "때": "도구전", "도구": "Bash", "명령": "…" } ] }
|
|
148
|
+
* Claude 것 { "hooks": { "PreToolUse": [ { "matcher": "Bash",
|
|
149
|
+
* "hooks": [ { "type": "command", "command": "…" } ] } ] } }
|
|
150
|
+
*/
|
|
151
|
+
export function 훅펴기(raw, 출처) {
|
|
152
|
+
const 훅들 = [];
|
|
153
|
+
const 버린것 = [];
|
|
154
|
+
const 것 = raw?.hooks ?? raw?.훅 ?? raw;
|
|
155
|
+
|
|
156
|
+
if (Array.isArray(것)) {
|
|
157
|
+
for (const x of 것) {
|
|
158
|
+
const 자리 = 자리풀기(x?.때 ?? x?.자리 ?? x?.event);
|
|
159
|
+
if (!자리) { 버린것.push(`모르는 자리: ${x?.때 ?? x?.event ?? '(없음)'}`); continue; }
|
|
160
|
+
const h = 한훅(자리, x?.도구 ?? x?.matcher ?? '*', x, 출처);
|
|
161
|
+
if (h) 훅들.push(h); else 버린것.push(`${자리}: 명령이나 무늬가 잘못 적혀 있습니다`);
|
|
162
|
+
}
|
|
163
|
+
return { 훅들, 버린것 };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (것 && typeof 것 === 'object') {
|
|
167
|
+
for (const [열쇠, 값] of Object.entries(것)) {
|
|
168
|
+
const 자리 = 자리풀기(열쇠);
|
|
169
|
+
if (!자리) { 버린것.push(`모르는 자리: ${열쇠}`); continue; }
|
|
170
|
+
for (const 묶음 of Array.isArray(값) ? 값 : [값]) {
|
|
171
|
+
const 무늬글 = 묶음?.matcher ?? 묶음?.도구 ?? '*';
|
|
172
|
+
const 안것 = Array.isArray(묶음?.hooks) ? 묶음.hooks : [묶음];
|
|
173
|
+
for (const x of 안것) {
|
|
174
|
+
const h = 한훅(자리, 무늬글, x, 출처);
|
|
175
|
+
if (h) 훅들.push(h); else 버린것.push(`${자리}: 명령이나 무늬가 잘못 적혀 있습니다`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { 훅들, 버린것 };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function 파일하나(경로, 출처) {
|
|
184
|
+
if (!existsSync(경로)) return { 훅들: [], 버린것: [], 있음: false, 자리: 경로 };
|
|
185
|
+
let j;
|
|
186
|
+
try { j = JSON.parse(readFileSync(경로, 'utf8')); } catch (e) {
|
|
187
|
+
return { 훅들: [], 버린것: [], 있음: true, 자리: 경로, 오류: `못 읽었습니다: ${e.message}` };
|
|
188
|
+
}
|
|
189
|
+
return { ...훅펴기(j, 출처), 있음: true, 자리: 경로 };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 이 자리에서 쓸 훅을 다 모은다.
|
|
194
|
+
*
|
|
195
|
+
* 이 PC 것이 먼저 돌고 프로젝트 것이 나중에 돈다. 순서가 값을 갖는 자리는
|
|
196
|
+
* 하나뿐이다 — 막는 훅은 **먼저 막는 것이 이긴다.** 이 PC 에 적은 것이
|
|
197
|
+
* 프로젝트 파일에 덮이면 안 되기 때문이다.
|
|
198
|
+
*
|
|
199
|
+
* @returns {{훅들, 켜짐, 왜꺼짐, 이PC, 프로젝트, 안믿음}}
|
|
200
|
+
*/
|
|
201
|
+
export function 훅읽기(root, { env = process.env, 집 = homedir(), 켜짐 = null } = {}) {
|
|
202
|
+
const 끔 = String(env.DEEL_HOOKS ?? '').trim().toLowerCase();
|
|
203
|
+
const 꺼짐 = 켜짐 === false || 끔 === 'off' || 끔 === '0' || 끔 === 'false';
|
|
204
|
+
|
|
205
|
+
const 이PC = 파일하나(이PC자리(집, env), '이 PC');
|
|
206
|
+
/*
|
|
207
|
+
* 프로젝트 파일은 **믿는 폴더에서만** 읽는다.
|
|
208
|
+
*
|
|
209
|
+
* 이게 이 파일에서 제일 중요한 한 줄이다. 없으면 `git clone` 한 번이 곧
|
|
210
|
+
* 남이 적어 둔 명령을 내 PC 에서 도는 것이 된다 — 사람이 아무것도 안 쳐도
|
|
211
|
+
* 첫 도구 호출에서 돈다. 프로젝트 설정을 믿는 폴더에서만 읽는 것과 같은
|
|
212
|
+
* 규칙이고(safety/trust.js), 여기가 그 규칙이 제일 필요한 자리다.
|
|
213
|
+
*/
|
|
214
|
+
const 믿는가 = 믿나(root, { env });
|
|
215
|
+
const 프로젝트 = 믿는가
|
|
216
|
+
? 파일하나(프로젝트자리(root), '프로젝트')
|
|
217
|
+
: { 훅들: [], 버린것: [], 있음: existsSync(프로젝트자리(root)), 자리: 프로젝트자리(root) };
|
|
218
|
+
|
|
219
|
+
const 다 = [...이PC.훅들, ...프로젝트.훅들].slice(0, 훅최대);
|
|
220
|
+
return {
|
|
221
|
+
훅들: 꺼짐 ? [] : 다,
|
|
222
|
+
켜짐: !꺼짐,
|
|
223
|
+
왜꺼짐: 꺼짐 ? (켜짐 === false ? '--no-hooks' : 'DEEL_HOOKS=off') : null,
|
|
224
|
+
이PC,
|
|
225
|
+
프로젝트,
|
|
226
|
+
// 파일은 있는데 폴더를 안 믿어서 안 읽은 경우. 화면이 이걸 말해야 한다 —
|
|
227
|
+
// 안 그러면 사람은 제가 적은 훅이 왜 안 도는지 영영 모른다.
|
|
228
|
+
안믿음: !믿는가 && 프로젝트.있음,
|
|
229
|
+
넘침: [...이PC.훅들, ...프로젝트.훅들].length > 훅최대,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 이 자리에 걸린 훅만 고른다. */
|
|
234
|
+
export function 고를것(훅들, 자리, 도구 = null) {
|
|
235
|
+
return (훅들 ?? []).filter((h) => h.자리 === 자리 && (자리 !== '도구전' && 자리 !== '도구후' ? true : 걸리나(h, 도구)));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
* ── 셸을 거친다. 여기서는 그게 맞다 ────────────────────────────────────
|
|
240
|
+
*
|
|
241
|
+
* 문서 변환(tools/convert.js)은 셸을 안 거친다. 거기 들어가는 파일 이름은
|
|
242
|
+
* 모델이 정하기 때문이다 — 따옴표 한 개가 명령이 된다.
|
|
243
|
+
*
|
|
244
|
+
* 여기는 반대다. 명령은 **사람이 제 파일에 적은 글**이고, 모델이 만든 것은
|
|
245
|
+
* 한 글자도 안 섞인다(아래 넣을것 은 전부 stdin 으로 간다). 그리고 사람이
|
|
246
|
+
* 훅에 적고 싶은 것은 대개 `npx prettier --write "$@" && git diff --exit-code`
|
|
247
|
+
* 같은 것이다. 셸을 안 거치면 그 줄을 적을 길이 없어서, 아무도 훅을 안 쓴다.
|
|
248
|
+
*
|
|
249
|
+
* 그래서 Bash 도구와 **같은 셸**을 쓴다(tools/shell.js). 같은 자리에서 같은
|
|
250
|
+
* 말이 통해야 사람이 훅을 짤 수 있다.
|
|
251
|
+
*/
|
|
252
|
+
function 셸로(명령) {
|
|
253
|
+
const 셸 = 셸고르기();
|
|
254
|
+
return { file: 셸.file, args: 셸.명령(명령), verbatim: !!셸.verbatim };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 훅 하나를 돌린다.
|
|
259
|
+
*
|
|
260
|
+
* 넘길 것은 **전부 stdin 으로 간다.** 명령줄에 끼우지 않는다 — 모델이 만든
|
|
261
|
+
* 글(파일 경로·명령·사람 말)이 명령줄에 들어가면 그 자리가 곧 주입 구멍이다.
|
|
262
|
+
*
|
|
263
|
+
* @returns {Promise<{훅, 코드, 말, 막나, 왜, ms, 잘림}>}
|
|
264
|
+
*/
|
|
265
|
+
export async function 훅돌리기(훅, 넣을것, { signal = null, 돌리개 = 돌려보기 } = {}) {
|
|
266
|
+
const t0 = Date.now();
|
|
267
|
+
const { file, args, verbatim } = 셸로(훅.명령);
|
|
268
|
+
const r = await 돌리개(file, args, {
|
|
269
|
+
timeout: 훅.제한,
|
|
270
|
+
maxBuffer: 글최대,
|
|
271
|
+
signal,
|
|
272
|
+
/*
|
|
273
|
+
* 자리는 **여기서** 붙인다.
|
|
274
|
+
*
|
|
275
|
+
* 부르는 쪽에 맡기면 길이 둘이라(자리돌리기·직접 부르기) 한쪽만 붙이는
|
|
276
|
+
* 날이 온다. 그러면 자리마다 갈래를 두는 훅 하나가 조용히 엉뚱한 갈래로
|
|
277
|
+
* 떨어지는데, 그건 훅을 짠 사람도 우리도 못 알아차린다.
|
|
278
|
+
*/
|
|
279
|
+
넣을것: `${JSON.stringify({ 자리: 훅.자리, ...(넣을것 ?? {}) })}\n`,
|
|
280
|
+
덤: { windowsVerbatimArguments: verbatim, windowsHide: true },
|
|
281
|
+
});
|
|
282
|
+
const ms = Date.now() - t0;
|
|
283
|
+
|
|
284
|
+
const 날것 = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim();
|
|
285
|
+
const 잘림 = 날것.length > 글최대;
|
|
286
|
+
const 말 = 잘림 ? `${날것.slice(0, 글최대)}\n…(훅이 뱉은 글이 길어서 여기까지만 옮겼습니다)` : 날것;
|
|
287
|
+
|
|
288
|
+
// 못 돌렸다 · 시한을 넘겼다 · 중단됐다. 셋 다 「잰 적이 없다」 는 뜻이다.
|
|
289
|
+
if (r.error) {
|
|
290
|
+
return {
|
|
291
|
+
훅, 코드: null, 말, ms, 잘림,
|
|
292
|
+
막나: 막는자리.includes(훅.자리) && !훅.지나갈까,
|
|
293
|
+
왜: `훅을 못 돌렸습니다 — ${r.error.message}`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (r.status === 0) return { 훅, 코드: 0, 말, ms, 잘림, 막나: false, 왜: null };
|
|
297
|
+
/*
|
|
298
|
+
* 2 는 「막으라」 는 뜻이다 (흔한 규격 그대로).
|
|
299
|
+
*
|
|
300
|
+
* 나머지 0 아닌 값은 「훅이 고장났다」 로 본다. 막는 자리에서는 그것도
|
|
301
|
+
* 막는다 — 이 파일 머리말의 이유다. `"고장나면": "지나가기"` 를 적어 두면
|
|
302
|
+
* 그때만 지나간다.
|
|
303
|
+
*/
|
|
304
|
+
if (r.status === 2) return { 훅, 코드: 2, 말, ms, 잘림, 막나: 막는자리.includes(훅.자리), 왜: null };
|
|
305
|
+
return {
|
|
306
|
+
훅, 코드: r.status, 말, ms, 잘림,
|
|
307
|
+
막나: 막는자리.includes(훅.자리) && !훅.지나갈까,
|
|
308
|
+
왜: `훅이 ${r.status} 로 끝났습니다`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 한 자리의 훅을 차례로 돌린다.
|
|
314
|
+
*
|
|
315
|
+
* **차례로** 도는 것이 핵심이다. 같이 돌리면 두 훅이 같은 파일을 고칠 때
|
|
316
|
+
* 결과가 매번 달라진다 — 포맷터를 훅으로 거는 것이 제일 흔한 쓰임인데
|
|
317
|
+
* 하필 그게 그 모양이다.
|
|
318
|
+
*
|
|
319
|
+
* 막는 자리에서는 **첫 막힘에서 멈춘다.** 이미 막힌 것에 남은 훅을 더
|
|
320
|
+
* 돌리는 것은 시간만 쓰는 일이고, 사람에게 보여 줄 까닭도 첫 것이 맞다.
|
|
321
|
+
*
|
|
322
|
+
* @returns {Promise<{막힘: object|null, 결과들: object[], 말들: string[]}>}
|
|
323
|
+
*/
|
|
324
|
+
export async function 자리돌리기(훅들, 자리, {
|
|
325
|
+
도구 = null, 넣을것 = {}, signal = null, audit = null, 돌리개 = 돌려보기,
|
|
326
|
+
} = {}) {
|
|
327
|
+
const 것들 = 고를것(훅들, 자리, 도구);
|
|
328
|
+
const 결과들 = [];
|
|
329
|
+
let 막힘 = null;
|
|
330
|
+
for (const h of 것들) {
|
|
331
|
+
if (signal?.aborted) break;
|
|
332
|
+
const r = await 훅돌리기(h, { 도구, ...넣을것 }, { signal, 돌리개 });
|
|
333
|
+
결과들.push(r);
|
|
334
|
+
/*
|
|
335
|
+
* 감사기록에 남긴다 — **돈 것 전부.**
|
|
336
|
+
*
|
|
337
|
+
* 막은 것만 남기면 「훅이 안 돌았다」 와 「훅이 돌았는데 통과시켰다」 를
|
|
338
|
+
* 나중에 구별할 수 없다. 사내 심사에서 물어보는 것이 정확히 그 둘의
|
|
339
|
+
* 차이다.
|
|
340
|
+
*/
|
|
341
|
+
audit?.write?.('hook', {
|
|
342
|
+
자리, 도구, 명령: h.명령, 출처: h.출처, 코드: r.코드, ms: r.ms, 막음: !!r.막나,
|
|
343
|
+
});
|
|
344
|
+
if (r.막나) { 막힘 = r; break; }
|
|
345
|
+
}
|
|
346
|
+
return { 막힘, 결과들, 말들: 결과들.map((r) => r.말).filter(Boolean) };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 막혔을 때 모델에게 할 말.
|
|
351
|
+
*
|
|
352
|
+
* 훅이 뱉은 글을 **그대로** 싣는다. 우리가 요약하면 사내 규칙의 문구가
|
|
353
|
+
* 뭉개지는데, 사람이 그 문구를 보고 담당자를 찾아가야 한다. 대신 우리가
|
|
354
|
+
* 무엇을 했는지는 우리가 적는다.
|
|
355
|
+
*/
|
|
356
|
+
export function 막힘말(r, { 보인출처 = (h) => h.출처 } = {}) {
|
|
357
|
+
const 줄 = [`${r.훅.자리} 훅이 막았습니다 (${보인출처(r.훅)}: ${r.훅.이름 ?? r.훅.명령}).`];
|
|
358
|
+
if (r.왜) 줄.push(` ${r.왜} — 문지기가 쓰러져 있으면 통과시키지 않습니다.`);
|
|
359
|
+
if (r.말) { 줄.push(''); 줄.push(r.말); }
|
|
360
|
+
줄.push('');
|
|
361
|
+
줄.push('같은 것을 다시 부르지 마세요. 다른 길을 찾거나, 왜 필요한지 사용자에게 말하세요.');
|
|
362
|
+
return 줄.join('\n');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 화면 한 장 (doctor · /status). 명령은 적되 값은 안 만든다. */
|
|
366
|
+
export function 훅줄들(r) {
|
|
367
|
+
const 줄 = [];
|
|
368
|
+
if (!r.켜짐) { 줄.push({ 상태: 'warn', 이름: '훅', 값: `꺼져 있습니다 (${r.왜꺼짐})` }); return 줄; }
|
|
369
|
+
if (r.이PC.오류) 줄.push({ 상태: 'no', 이름: '훅 · 이 PC', 값: r.이PC.자리, 덧말: r.이PC.오류 });
|
|
370
|
+
if (r.프로젝트.오류) 줄.push({ 상태: 'no', 이름: '훅 · 프로젝트', 값: r.프로젝트.자리, 덧말: r.프로젝트.오류 });
|
|
371
|
+
if (r.안믿음) {
|
|
372
|
+
줄.push({
|
|
373
|
+
상태: 'warn', 이름: '훅 · 프로젝트', 값: r.프로젝트.자리,
|
|
374
|
+
덧말: '믿는 폴더가 아니라서 안 읽습니다 — 읽게 하려면 deel trust',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
for (const 것 of [r.이PC, r.프로젝트]) {
|
|
378
|
+
for (const 왜 of 것.버린것 ?? []) 줄.push({ 상태: 'warn', 이름: '훅 · 못 알아들음', 값: 왜 });
|
|
379
|
+
}
|
|
380
|
+
if (r.넘침) 줄.push({ 상태: 'warn', 이름: '훅', 값: `${훅최대}개까지만 씁니다 — 나머지는 안 돕니다` });
|
|
381
|
+
if (!r.훅들.length) { 줄.push({ 상태: 'ok', 이름: '훅', 값: '없습니다' }); return 줄; }
|
|
382
|
+
const 셈 = {};
|
|
383
|
+
for (const h of r.훅들) 셈[h.자리] = (셈[h.자리] ?? 0) + 1;
|
|
384
|
+
줄.push({
|
|
385
|
+
상태: 'ok', 이름: '훅', 값: `${r.훅들.length}개`,
|
|
386
|
+
덧말: 자리들.filter((z) => 셈[z]).map((z) => `${z} ${셈[z]}`).join(' · '),
|
|
387
|
+
});
|
|
388
|
+
return 줄;
|
|
389
|
+
}
|
package/src/safety/policy.js
CHANGED
|
@@ -207,3 +207,77 @@ export function 규칙말(규칙들) {
|
|
|
207
207
|
if (규칙들.탈) 조각.push(규칙들.탈);
|
|
208
208
|
return 조각.join(' · ');
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
/*
|
|
212
|
+
* ── 규칙이 진짜 그렇게 도나 ─────────────────────────────────────────────
|
|
213
|
+
*
|
|
214
|
+
* 규칙은 적어 두면 조용히 돈다. 그래서 **잘못 적은 규칙은 티가 안 난다.**
|
|
215
|
+
*
|
|
216
|
+
* "deny": ["Bash(rm -rf*)"]
|
|
217
|
+
*
|
|
218
|
+
* 이건 `rm -rf /` 를 막는다. 그런데 `sudo rm -rf /` 는 안 막는다 — 무늬가
|
|
219
|
+
* 앞부터 맞아야 하기 때문이다. 적은 사람은 막힌 줄 알고 지낸다. 안 막혔다는
|
|
220
|
+
* 것은 진짜로 지워진 날에야 안다.
|
|
221
|
+
*
|
|
222
|
+
* 그래서 두 가지를 낸다.
|
|
223
|
+
*
|
|
224
|
+
* 1) `deel rules check "sudo rm -rf /"` — 이 명령을 어느 규칙이 어떻게
|
|
225
|
+
* 정하는지 그 자리에서 말한다. 규칙을 적자마자 확인할 수 있어야 한다.
|
|
226
|
+
*
|
|
227
|
+
* 2) 설정에 보기를 적어 두면 그것을 돌린다. CI 에 걸 수 있는 모양이다.
|
|
228
|
+
*
|
|
229
|
+
* "permissions": {
|
|
230
|
+
* "deny": ["Bash(*rm -rf*)"],
|
|
231
|
+
* "확인": [
|
|
232
|
+
* { "도구": "Bash", "값": "sudo rm -rf /tmp", "이래야": "deny" },
|
|
233
|
+
* { "도구": "Bash", "값": "npm run rf", "이래야": "모름" }
|
|
234
|
+
* ]
|
|
235
|
+
* }
|
|
236
|
+
*
|
|
237
|
+
* 2번이 있어야 규칙을 **고칠 때** 안전하다. 무늬 하나를 다듬다가 다른 것이
|
|
238
|
+
* 같이 풀리는 일이 실제로 흔한데, 보기를 적어 두면 그 자리에서 빨개진다.
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
/** 설정에 적어 둔 확인 보기들. 없으면 빈 배열. */
|
|
242
|
+
export function 확인목록(cfg) {
|
|
243
|
+
const 것 = cfg?.permissions?.확인 ?? cfg?.permissions?.checks;
|
|
244
|
+
if (!Array.isArray(것)) return [];
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const x of 것) {
|
|
247
|
+
if (!x || typeof x !== 'object') continue;
|
|
248
|
+
const 도구 = String(x.도구 ?? x.tool ?? 'Bash');
|
|
249
|
+
const 값 = x.값 ?? x.value;
|
|
250
|
+
const 이래야 = String(x.이래야 ?? x.expect ?? '').trim();
|
|
251
|
+
if (typeof 값 !== 'string' || !값) continue;
|
|
252
|
+
if (!['allow', 'deny', '모름'].includes(이래야)) continue;
|
|
253
|
+
out.push({ 도구, 값, 이래야 });
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 도구 이름에 맞는 인자 모양으로 값을 싼다.
|
|
260
|
+
*
|
|
261
|
+
* 걸리나() 는 도구마다 다른 칸을 본다 — Bash 는 command, 파일 도구는
|
|
262
|
+
* file_path. 확인 보기에서는 사람이 값 하나만 적으므로, 여기서 그 도구가
|
|
263
|
+
* 보는 칸에 넣어 준다. 안 그러면 보기가 늘 「안 걸림」 으로 나와서, 검사가
|
|
264
|
+
* 아무것도 안 재면서 초록으로 남는다.
|
|
265
|
+
*/
|
|
266
|
+
export function 확인인자(도구, 값) {
|
|
267
|
+
if (도구 === 'Bash') return { command: 값 };
|
|
268
|
+
if (도구 === 'WebFetch') return { url: 값 };
|
|
269
|
+
if (도구 === 'Grep') return { pattern: 값 };
|
|
270
|
+
return { file_path: 값 };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 확인 보기를 다 돌린다.
|
|
275
|
+
*
|
|
276
|
+
* @returns {Array<{도구,값,이래야,나온것,맞나,규칙,출처}>}
|
|
277
|
+
*/
|
|
278
|
+
export function 확인돌리기(규칙들, 보기들) {
|
|
279
|
+
return (보기들 ?? []).map((b) => {
|
|
280
|
+
const r = 어떻게할까(규칙들, b.도구, 확인인자(b.도구, b.값));
|
|
281
|
+
return { ...b, 나온것: r.답, 맞나: r.답 === b.이래야, 규칙: r.규칙, 출처: r.출처 };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Bash 로 띄우는 자식에게 무엇을 물려주나.
|
|
2
|
+
//
|
|
3
|
+
// ── 무엇이 새고 있었나 ─────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 여태 자식에게 넘기는 환경에서 뺀 것은 **우리 열쇠뿐**이었다
|
|
6
|
+
// (backend/mcp.js 의 열쇠뺀환경 — DEEL_API_KEY · DEEL_KEY_*).
|
|
7
|
+
// 그래서 이런 것들이 그대로 넘어갔다.
|
|
8
|
+
//
|
|
9
|
+
// OPENAI_API_KEY ANTHROPIC_API_KEY GITHUB_TOKEN
|
|
10
|
+
// AWS_SECRET_ACCESS_KEY NPM_TOKEN DB_PASSWORD
|
|
11
|
+
//
|
|
12
|
+
// 넘어간 것 자체보다 그다음이 문제다. 모델이 `env` 나 `printenv` 를 한 번
|
|
13
|
+
// 부르면 그 값들이 **도구 결과로 대화에 실려** 게이트웨이로 나가고,
|
|
14
|
+
// `.deel/sessions/*.jsonl` 로 디스크에도 남는다. 남의 회사 열쇠를 우리
|
|
15
|
+
// 게이트웨이 운영자에게 보내는 셈이다.
|
|
16
|
+
//
|
|
17
|
+
// 그리고 모델은 `env | grep -i proxy` 같은 것을 진짜로 자주 부른다 —
|
|
18
|
+
// 사내 프록시 설정을 확인하려는 아주 정상적인 행동이고, 그 한 줄에 열쇠가
|
|
19
|
+
// 딸려 나온다.
|
|
20
|
+
//
|
|
21
|
+
// ── 왜 이름 무늬로 거르나 ──────────────────────────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// 목록을 못 박는 길도 있었다. 그런데 그 목록은 **언제나 낡는다** — 오늘
|
|
24
|
+
// 없는 이름이 내일 생기고, 사내 이름(`SKT_GW_TOKEN`)은 우리가 알 수가 없다.
|
|
25
|
+
// 값을 보고 판단하는 길도 안 된다. 열쇠처럼 생긴 글자와 그냥 긴 글자를
|
|
26
|
+
// 가릴 방법이 없고, 틀리면 조용히 새거나 조용히 막힌다.
|
|
27
|
+
//
|
|
28
|
+
// 그래서 **이름의 마디**를 본다. `_` 로 나눈 마디 하나가 KEY·TOKEN·SECRET·
|
|
29
|
+
// PASSWORD·CREDENTIAL·PASSPHRASE 이면 뺀다.
|
|
30
|
+
//
|
|
31
|
+
// 마디로 보는 것이 요점이다. `*KEY*` 같은 통무늬로 하면 `MONKEY_PATCH` 와
|
|
32
|
+
// `KEYBOARD_LAYOUT` 이 같이 걸린다. 그런 오작동은 사람이 원인을 못 찾는다 —
|
|
33
|
+
// 「내 스크립트가 deel 안에서만 안 돈다」 로 끝난다.
|
|
34
|
+
//
|
|
35
|
+
// 일부러 안 넣은 것이 둘 있다.
|
|
36
|
+
//
|
|
37
|
+
// PWD 유닉스에서 지금 폴더다. 빼면 거의 모든 셸 스크립트가 부서진다.
|
|
38
|
+
// AUTH SSH_AUTH_SOCK 이 걸린다. 그건 열쇠가 아니라 통로 이름이고,
|
|
39
|
+
// 빼면 `git push` 가 안 된다.
|
|
40
|
+
//
|
|
41
|
+
// ── 그리고 뺀 것은 말한다 ──────────────────────────────────────────────
|
|
42
|
+
//
|
|
43
|
+
// 이게 이 파일에서 제일 중요한 줄이다. 사내 저장소를 쓰는 사람은 `npm ci`
|
|
44
|
+
// 에 `NPM_TOKEN` 이 필요하다. 우리가 그것을 조용히 빼면 `npm ci` 가 401 로
|
|
45
|
+
// 죽고, 화면에는 npm 의 401 만 남는다. 사람은 토큰이 만료된 줄 알고 새로
|
|
46
|
+
// 발급받으러 가고, 새로 받아도 똑같은 화면을 본다.
|
|
47
|
+
//
|
|
48
|
+
// 그래서 **무엇을 뺐는지 이름을 돌려준다.** 값은 절대 안 돌려준다.
|
|
49
|
+
// 부르는 쪽이 그 이름을 도구 결과에 적고, 되살리는 법 한 줄을 같이 적는다.
|
|
50
|
+
import { 열쇠환경인가 } from '../config.js';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 이 마디가 있으면 뺀다.
|
|
54
|
+
*
|
|
55
|
+
* 마디는 `_` 로 나눈 조각이다. `AWS_SECRET_ACCESS_KEY` → SECRET 과 KEY 가
|
|
56
|
+
* 걸리고, `KEYCLOAK_URL` → KEYCLOAK·URL 이라 안 걸린다.
|
|
57
|
+
*/
|
|
58
|
+
export const 비밀마디 = Object.freeze([
|
|
59
|
+
'KEY', 'KEYS', 'APIKEY',
|
|
60
|
+
'TOKEN', 'TOKENS',
|
|
61
|
+
'SECRET', 'SECRETS',
|
|
62
|
+
'PASSWORD', 'PASSWD', 'PASSPHRASE',
|
|
63
|
+
'CREDENTIAL', 'CREDENTIALS',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const 마디표 = new Set(비밀마디);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 이 환경변수 이름이 열쇠처럼 생겼나.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} 이름
|
|
72
|
+
* @param {{남길것?: string[]}} [설정] 사람이 되살리라고 적어 둔 이름들
|
|
73
|
+
*/
|
|
74
|
+
export function 비밀환경인가(이름, { 남길것 = [] } = {}) {
|
|
75
|
+
const n = String(이름 ?? '').toUpperCase();
|
|
76
|
+
if (!n) return false;
|
|
77
|
+
// 사람이 되살리라고 적은 것은 무늬에 걸려도 그대로 넘긴다. 사람이 제
|
|
78
|
+
// 환경을 우리보다 잘 안다 — 우리는 기본값만 정한다.
|
|
79
|
+
if (남길것.some((x) => String(x ?? '').toUpperCase() === n)) return false;
|
|
80
|
+
return n.split('_').some((조각) => 마디표.has(조각));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 설정에서 「남길 것」 을 꺼낸다.
|
|
85
|
+
*
|
|
86
|
+
* "셸환경": { "남길것": ["NPM_TOKEN", "GITHUB_TOKEN"] }
|
|
87
|
+
*
|
|
88
|
+
* 이름만 받는다. 무늬를 받으면 `*` 하나로 전부 되살아나는데, 그건 이
|
|
89
|
+
* 안전장치를 끄는 것과 같으면서 껐다는 자각이 없다.
|
|
90
|
+
*/
|
|
91
|
+
export function 남길것읽기(cfg) {
|
|
92
|
+
const 것 = cfg?.셸환경?.남길것 ?? cfg?.shellEnv?.keep;
|
|
93
|
+
return (Array.isArray(것) ? 것 : []).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 자식에게 물려줄 환경을 만든다.
|
|
98
|
+
*
|
|
99
|
+
* 우리 열쇠(DEEL_API_KEY · DEEL_KEY_*)는 언제나 뺀다 — 그건 되살릴 방법을
|
|
100
|
+
* 주지 않는다. 우리 열쇠를 자식에게 줘야 할 까닭이 없고, 그 하나가 새면
|
|
101
|
+
* 이 프로그램이 지키겠다고 한 것을 이 프로그램이 깬다.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} [env]
|
|
104
|
+
* @param {{남길것?: string[]}} [설정]
|
|
105
|
+
* @returns {{env: object, 뺀것: string[]}} 뺀것은 **이름만**. 값은 안 준다.
|
|
106
|
+
*/
|
|
107
|
+
export function 셸환경(env = process.env, { 남길것 = [] } = {}) {
|
|
108
|
+
const out = { ...env };
|
|
109
|
+
const 뺀것 = [];
|
|
110
|
+
for (const k of Object.keys(out)) {
|
|
111
|
+
if (열쇠환경인가(k)) { delete out[k]; continue; } // 우리 것은 말없이 뺀다
|
|
112
|
+
if (비밀환경인가(k, { 남길것 })) { delete out[k]; 뺀것.push(k); }
|
|
113
|
+
}
|
|
114
|
+
뺀것.sort();
|
|
115
|
+
return { env: out, 뺀것 };
|
|
116
|
+
}
|