@mandujs/core 0.9.45 → 0.10.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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/brain/doctor/config-analyzer.ts +498 -0
  3. package/src/brain/doctor/index.ts +10 -0
  4. package/src/change/snapshot.ts +46 -1
  5. package/src/change/types.ts +13 -0
  6. package/src/config/index.ts +8 -2
  7. package/src/config/mcp-ref.ts +348 -0
  8. package/src/config/mcp-status.ts +348 -0
  9. package/src/config/metadata.test.ts +308 -0
  10. package/src/config/metadata.ts +293 -0
  11. package/src/config/symbols.ts +144 -0
  12. package/src/contract/index.ts +26 -25
  13. package/src/contract/protection.ts +364 -0
  14. package/src/error/domains.ts +265 -0
  15. package/src/error/index.ts +25 -13
  16. package/src/filling/filling.ts +88 -6
  17. package/src/guard/analyzer.ts +7 -2
  18. package/src/guard/config-guard.ts +281 -0
  19. package/src/guard/decision-memory.test.ts +293 -0
  20. package/src/guard/decision-memory.ts +532 -0
  21. package/src/guard/healing.test.ts +259 -0
  22. package/src/guard/healing.ts +874 -0
  23. package/src/guard/index.ts +119 -0
  24. package/src/guard/negotiation.test.ts +282 -0
  25. package/src/guard/negotiation.ts +975 -0
  26. package/src/guard/semantic-slots.test.ts +379 -0
  27. package/src/guard/semantic-slots.ts +796 -0
  28. package/src/index.ts +2 -0
  29. package/src/lockfile/generate.ts +259 -0
  30. package/src/lockfile/index.ts +186 -0
  31. package/src/lockfile/lockfile.test.ts +410 -0
  32. package/src/lockfile/types.ts +184 -0
  33. package/src/lockfile/validate.ts +308 -0
  34. package/src/runtime/security.ts +155 -0
  35. package/src/runtime/server.ts +320 -258
  36. package/src/utils/differ.test.ts +342 -0
  37. package/src/utils/differ.ts +482 -0
  38. package/src/utils/hasher.test.ts +326 -0
  39. package/src/utils/hasher.ts +319 -0
  40. package/src/utils/index.ts +29 -0
  41. package/src/utils/safe-io.ts +188 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Mandu Lockfile 검증 ✅
3
+ *
4
+ * Lockfile과 현재 설정의 일치 여부 검증
5
+ */
6
+
7
+ import { diffConfig } from "../utils/differ.js";
8
+ import { computeCurrentHashes, resolveMcpSources } from "./generate.js";
9
+ import {
10
+ type ManduLockfile,
11
+ type LockfileValidationResult,
12
+ type LockfileError,
13
+ type LockfileWarning,
14
+ type LockfileMode,
15
+ type LockfilePolicyOptions,
16
+ DEFAULT_POLICIES,
17
+ LOCKFILE_SCHEMA_VERSION,
18
+ BYPASS_ENV_VAR,
19
+ } from "./types.js";
20
+
21
+ // ============================================
22
+ // 검증
23
+ // ============================================
24
+
25
+ /**
26
+ * Lockfile 검증
27
+ *
28
+ * @param config 현재 설정
29
+ * @param lockfile Lockfile 데이터
30
+ * @returns 검증 결과
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * const lockfile = await readLockfile(projectRoot);
35
+ * if (lockfile) {
36
+ * const result = validateLockfile(config, lockfile);
37
+ * if (!result.valid) {
38
+ * console.error("Lockfile mismatch:", result.errors);
39
+ * }
40
+ * }
41
+ * ```
42
+ */
43
+ export function validateLockfile(
44
+ config: Record<string, unknown>,
45
+ lockfile: ManduLockfile,
46
+ mcpConfig?: Record<string, unknown> | null
47
+ ): LockfileValidationResult {
48
+ const errors: LockfileError[] = [];
49
+ const warnings: LockfileWarning[] = [];
50
+
51
+ // 현재 해시 계산
52
+ const { configHash, mcpConfigHash } = computeCurrentHashes(config, mcpConfig);
53
+ const { mcpServers } = resolveMcpSources(config, mcpConfig);
54
+
55
+ // 1. 스키마 버전 체크
56
+ if (lockfile.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {
57
+ warnings.push({
58
+ code: "LOCKFILE_OUTDATED",
59
+ message: `Lockfile schema version mismatch: expected ${LOCKFILE_SCHEMA_VERSION}, got ${lockfile.schemaVersion}`,
60
+ details: {
61
+ expected: LOCKFILE_SCHEMA_VERSION,
62
+ actual: lockfile.schemaVersion,
63
+ },
64
+ });
65
+ }
66
+
67
+ // 2. 설정 해시 비교
68
+ if (configHash !== lockfile.configHash) {
69
+ errors.push({
70
+ code: "CONFIG_HASH_MISMATCH",
71
+ message: "Configuration has changed since lockfile was generated",
72
+ details: {
73
+ expected: lockfile.configHash,
74
+ actual: configHash,
75
+ },
76
+ });
77
+ }
78
+
79
+ // 3. MCP 설정 해시 비교 (있는 경우)
80
+ if (lockfile.mcpConfigHash && mcpConfigHash !== lockfile.mcpConfigHash) {
81
+ errors.push({
82
+ code: "MCP_CONFIG_HASH_MISMATCH",
83
+ message: "MCP configuration has changed since lockfile was generated",
84
+ details: {
85
+ expected: lockfile.mcpConfigHash,
86
+ actual: mcpConfigHash,
87
+ },
88
+ });
89
+ }
90
+
91
+ // 4. MCP 서버 변경 감지
92
+ if (lockfile.mcpServers && mcpServers) {
93
+ const lockedServers = new Set(Object.keys(lockfile.mcpServers));
94
+ const currentServers = new Set(Object.keys(mcpServers));
95
+
96
+ // 추가된 서버
97
+ for (const server of currentServers) {
98
+ if (!lockedServers.has(server)) {
99
+ warnings.push({
100
+ code: "MCP_SERVER_ADDED",
101
+ message: `MCP server "${server}" was added`,
102
+ details: { server },
103
+ });
104
+ }
105
+ }
106
+
107
+ // 삭제된 서버
108
+ for (const server of lockedServers) {
109
+ if (!currentServers.has(server)) {
110
+ warnings.push({
111
+ code: "MCP_SERVER_REMOVED",
112
+ message: `MCP server "${server}" was removed`,
113
+ details: { server },
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ // 5. 스냅샷 누락 경고
120
+ if (!lockfile.snapshot) {
121
+ warnings.push({
122
+ code: "SNAPSHOT_MISSING",
123
+ message: "Lockfile does not include configuration snapshot",
124
+ });
125
+ }
126
+
127
+ // 6. Diff 계산 (오류가 있는 경우에만)
128
+ let diff;
129
+ if (errors.length > 0 && lockfile.snapshot) {
130
+ const configForDiff = mcpServers
131
+ ? { ...config, mcpServers }
132
+ : config;
133
+ diff = diffConfig(lockfile.snapshot.config, configForDiff);
134
+ }
135
+
136
+ return {
137
+ valid: errors.length === 0,
138
+ errors,
139
+ warnings,
140
+ diff,
141
+ currentHash: configHash,
142
+ lockedHash: lockfile.configHash,
143
+ };
144
+ }
145
+
146
+ // ============================================
147
+ // 정책 기반 검증
148
+ // ============================================
149
+
150
+ /**
151
+ * 환경 정책에 따른 검증 수행
152
+ */
153
+ export function validateWithPolicy(
154
+ config: Record<string, unknown>,
155
+ lockfile: ManduLockfile | null,
156
+ mode?: LockfileMode,
157
+ mcpConfig?: Record<string, unknown> | null
158
+ ): {
159
+ result: LockfileValidationResult | null;
160
+ action: "pass" | "warn" | "error" | "block";
161
+ bypassed: boolean;
162
+ } {
163
+ const resolvedMode = mode ?? detectMode();
164
+ const policy = DEFAULT_POLICIES[resolvedMode];
165
+ const bypassed = isBypassed();
166
+
167
+ // Lockfile 없는 경우
168
+ if (!lockfile) {
169
+ const action = bypassed ? "warn" : policy.onMissing;
170
+ return {
171
+ result: null,
172
+ action: action === "create" ? "warn" : action,
173
+ bypassed,
174
+ };
175
+ }
176
+
177
+ // 검증 수행
178
+ const result = validateLockfile(config, lockfile, mcpConfig);
179
+
180
+ // 통과
181
+ if (result.valid) {
182
+ return { result, action: "pass", bypassed };
183
+ }
184
+
185
+ // 불일치 시 정책 적용
186
+ const action = bypassed ? "warn" : policy.onMismatch;
187
+ return { result, action, bypassed };
188
+ }
189
+
190
+ /**
191
+ * 현재 모드 감지
192
+ */
193
+ export function detectMode(): LockfileMode {
194
+ // CI 환경
195
+ if (
196
+ process.env.CI === "true" ||
197
+ process.env.GITHUB_ACTIONS === "true" ||
198
+ process.env.GITLAB_CI === "true"
199
+ ) {
200
+ return "ci";
201
+ }
202
+
203
+ // 빌드 모드 (npm run build 등)
204
+ if (process.env.npm_lifecycle_event === "build") {
205
+ return "build";
206
+ }
207
+
208
+ // 프로덕션
209
+ if (process.env.NODE_ENV === "production") {
210
+ return "production";
211
+ }
212
+
213
+ return "development";
214
+ }
215
+
216
+ /**
217
+ * 우회 환경변수 체크
218
+ */
219
+ export function isBypassed(): boolean {
220
+ return process.env[BYPASS_ENV_VAR] === "1" || process.env[BYPASS_ENV_VAR] === "true";
221
+ }
222
+
223
+ // ============================================
224
+ // 빠른 검증
225
+ // ============================================
226
+
227
+ /**
228
+ * 해시만 빠르게 비교
229
+ */
230
+ export function quickValidate(
231
+ config: Record<string, unknown>,
232
+ lockfile: ManduLockfile,
233
+ mcpConfig?: Record<string, unknown> | null
234
+ ): boolean {
235
+ const { configHash } = computeCurrentHashes(config, mcpConfig);
236
+ return configHash === lockfile.configHash;
237
+ }
238
+
239
+ /**
240
+ * Lockfile이 최신인지 확인
241
+ */
242
+ export function isLockfileStale(
243
+ config: Record<string, unknown>,
244
+ lockfile: ManduLockfile,
245
+ mcpConfig?: Record<string, unknown> | null
246
+ ): boolean {
247
+ return !quickValidate(config, lockfile, mcpConfig);
248
+ }
249
+
250
+ // ============================================
251
+ // 검증 결과 포맷팅
252
+ // ============================================
253
+
254
+ /**
255
+ * 검증 결과를 콘솔 메시지로 변환
256
+ */
257
+ export function formatValidationResult(
258
+ result: LockfileValidationResult
259
+ ): string {
260
+ const lines: string[] = [];
261
+
262
+ if (result.valid) {
263
+ lines.push("✅ Lockfile 검증 통과");
264
+ lines.push(` 해시: ${result.currentHash}`);
265
+ } else {
266
+ lines.push("❌ Lockfile 검증 실패");
267
+ lines.push("");
268
+
269
+ for (const error of result.errors) {
270
+ lines.push(` 🔴 ${error.message}`);
271
+ if (error.details) {
272
+ lines.push(` 예상: ${error.details.expected}`);
273
+ lines.push(` 실제: ${error.details.actual}`);
274
+ }
275
+ }
276
+ }
277
+
278
+ if (result.warnings.length > 0) {
279
+ lines.push("");
280
+ lines.push(" 경고:");
281
+ for (const warning of result.warnings) {
282
+ lines.push(` ⚠️ ${warning.message}`);
283
+ }
284
+ }
285
+
286
+ return lines.join("\n");
287
+ }
288
+
289
+ /**
290
+ * 정책 액션에 따른 메시지 생성
291
+ */
292
+ export function formatPolicyAction(
293
+ action: "pass" | "warn" | "error" | "block",
294
+ bypassed: boolean
295
+ ): string {
296
+ const bypassNote = bypassed ? " (우회됨)" : "";
297
+
298
+ switch (action) {
299
+ case "pass":
300
+ return "✅ Lockfile 검증 통과";
301
+ case "warn":
302
+ return `⚠️ Lockfile 불일치 - 경고${bypassNote}`;
303
+ case "error":
304
+ return `❌ Lockfile 불일치 - 빌드 실패${bypassNote}`;
305
+ case "block":
306
+ return `🛑 Lockfile 불일치 - 서버 시작 차단${bypassNote}`;
307
+ }
308
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Runtime Security
3
+ *
4
+ * 동적 import 및 경로 접근 보안 검증
5
+ */
6
+
7
+ import path from "path";
8
+ import type { Result } from "../error/result";
9
+ import { ok, err } from "../error/result";
10
+ import { SecurityError } from "../error/domains";
11
+
12
+ /**
13
+ * 허용된 import 경로 패턴
14
+ */
15
+ const ALLOWED_IMPORT_PATTERNS = [
16
+ /^app\//, // app/ 디렉토리 (FS Routes)
17
+ /^src\/client\//, // 클라이언트 코드
18
+ /^src\/server\//, // 서버 코드
19
+ /^src\/shared\//, // 공유 코드
20
+ /^spec\//, // Spec 디렉토리 (레거시)
21
+ ];
22
+
23
+ /**
24
+ * 허용된 파일 확장자
25
+ */
26
+ const ALLOWED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs"];
27
+
28
+ /**
29
+ * 차단된 경로 패턴
30
+ */
31
+ const BLOCKED_PATTERNS = [
32
+ /node_modules/, // node_modules 직접 접근 차단
33
+ /\.env/, // 환경 변수 파일
34
+ /\.git/, // Git 디렉토리
35
+ /\.mandu\/.*\.json$/, // 설정 파일
36
+ ];
37
+
38
+ /**
39
+ * 동적 import 경로 검증
40
+ *
41
+ * @param rootDir 프로젝트 루트 디렉토리
42
+ * @param modulePath 상대 모듈 경로 (예: "app/layout.tsx")
43
+ * @returns 검증된 전체 경로 또는 에러
44
+ */
45
+ export function validateImportPath(
46
+ rootDir: string,
47
+ modulePath: string
48
+ ): Result<string> {
49
+ // 1. 경로 정규화
50
+ const normalized = path.posix.normalize(modulePath).replace(/\\/g, "/");
51
+
52
+ // 2. Path traversal 체크
53
+ if (normalized.includes("..")) {
54
+ return err(
55
+ new SecurityError(
56
+ "path_traversal",
57
+ `경로 탐색 공격 감지: ${modulePath}`,
58
+ modulePath
59
+ ).toManduError()
60
+ );
61
+ }
62
+
63
+ // 3. 차단된 패턴 체크
64
+ for (const pattern of BLOCKED_PATTERNS) {
65
+ if (pattern.test(normalized)) {
66
+ return err(
67
+ new SecurityError(
68
+ "import_violation",
69
+ `차단된 경로 접근: ${modulePath}`,
70
+ modulePath
71
+ ).toManduError()
72
+ );
73
+ }
74
+ }
75
+
76
+ // 4. 화이트리스트 검증
77
+ const isAllowed = ALLOWED_IMPORT_PATTERNS.some((pattern) =>
78
+ pattern.test(normalized)
79
+ );
80
+
81
+ if (!isAllowed) {
82
+ return err(
83
+ new SecurityError(
84
+ "import_violation",
85
+ `허용되지 않은 import 경로: ${modulePath}. 허용된 경로: app/, src/client/, src/server/, src/shared/, spec/`,
86
+ modulePath
87
+ ).toManduError()
88
+ );
89
+ }
90
+
91
+ // 5. 확장자 검증 (있는 경우만)
92
+ const ext = path.extname(normalized);
93
+ if (ext && !ALLOWED_EXTENSIONS.includes(ext)) {
94
+ return err(
95
+ new SecurityError(
96
+ "import_violation",
97
+ `허용되지 않은 파일 확장자: ${ext}`,
98
+ modulePath
99
+ ).toManduError()
100
+ );
101
+ }
102
+
103
+ // 6. 전체 경로 생성
104
+ const fullPath = path.join(rootDir, normalized);
105
+
106
+ // 7. 최종 경로가 rootDir 내에 있는지 확인
107
+ const resolvedPath = path.resolve(fullPath);
108
+ const resolvedRoot = path.resolve(rootDir);
109
+
110
+ if (!resolvedPath.startsWith(resolvedRoot + path.sep)) {
111
+ return err(
112
+ new SecurityError(
113
+ "path_traversal",
114
+ `루트 디렉토리 외부 접근 시도: ${modulePath}`,
115
+ modulePath
116
+ ).toManduError()
117
+ );
118
+ }
119
+
120
+ return ok(fullPath);
121
+ }
122
+
123
+ /**
124
+ * 안전한 동적 import
125
+ *
126
+ * @param rootDir 프로젝트 루트 디렉토리
127
+ * @param modulePath 상대 모듈 경로
128
+ * @returns 로드된 모듈 또는 null
129
+ */
130
+ export async function safeImport<T = unknown>(
131
+ rootDir: string,
132
+ modulePath: string
133
+ ): Promise<T | null> {
134
+ const validation = validateImportPath(rootDir, modulePath);
135
+
136
+ if (!validation.ok) {
137
+ console.error(`[Mandu Security] ${validation.error.message}`);
138
+ return null;
139
+ }
140
+
141
+ try {
142
+ const module = await import(validation.value);
143
+ return module as T;
144
+ } catch (error) {
145
+ console.error(`[Mandu] Failed to import: ${modulePath}`, error);
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 모듈 경로 검증 (boolean 반환)
152
+ */
153
+ export function isValidImportPath(rootDir: string, modulePath: string): boolean {
154
+ return validateImportPath(rootDir, modulePath).ok;
155
+ }