@tienne/gestalt 0.77.1 → 0.77.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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tienne/gestalt",
3
- "version": "0.77.1",
3
+ "version": "0.77.2",
4
4
  "description": "TypeScript AI Development Harness - Gestalt psychology-driven requirement clarification",
5
5
  "repository": {
6
6
  "type": "git",
@@ -517,7 +517,18 @@ ges_execute {
517
517
  action: "review_consensus",
518
518
  reviewSessionId: "<reviewSessionId>",
519
519
  reviewConsensus: {
520
- mergedIssues: [...전체 이슈 병합...],
520
+ mergedIssues: [
521
+ {
522
+ id: "...",
523
+ severity: "critical" | "high" | "warning",
524
+ category: "...",
525
+ file: "...",
526
+ line: 12, // 선택 — 파일 전반이면 생략
527
+ message: "...",
528
+ suggestion: "...",
529
+ reportedBy: "quality-reviewer" // 필수. 문자열 하나다
530
+ }
531
+ ],
521
532
  approvedBy: [...],
522
533
  blockedBy: [...],
523
534
  summary: "...",
@@ -527,6 +538,8 @@ ges_execute {
527
538
  }
528
539
  ```
529
540
 
541
+ `line`만 선택이고 나머지는 전부 필수입니다. **`reportedBy`는 배열이 아니라 에이전트 이름 문자열 하나**입니다 — 여러 에이전트가 같은 걸 짚었으면 대표 하나만 적습니다.
542
+
530
543
  엔진이 두 심급을 합쳐 판정합니다 — **결함(critical/high)이 없고 `coherent: true`여야 통과**입니다. `continuityVerdict`를 생략하면 결함 심급만으로 판정하는 기존 동작 그대로입니다.
531
544
 
532
545
  응답 해석:
@@ -0,0 +1,90 @@
1
+ /**
2
+ * MCP 도구 입력 검증을 부르는 쪽이 고칠 수 있는 형태로 돌려주는 자리.
3
+ *
4
+ * MCP SDK는 도구를 부르기 전에 등록된 zod 스키마로 인자를 검증한다. 실패하면
5
+ * 첫 이슈의 message 한 줄만 뽑아 `Invalid arguments for tool ...`로 던진다. 우리
6
+ * 핸들러는 그 전에 못 끼어든다. 그래서 고칠 수 있는 자리는 스키마 자체다.
7
+ *
8
+ * 심는 방어는 이렇다.
9
+ * - 객체나 배열을 기대하는 파라미터에 문자열이 오면 `JSON.parse`를 먼저 시도한다.
10
+ * 호스트가 JSON 문자열을 못 풀고 원본을 그대로 넘기는 일이 있는데, 그때 나오는
11
+ * `Expected object, received string`은 진짜 원인(문자열이 깨졌다)을 덮어버린다.
12
+ * - 파싱이 실패하면 파서의 원본 에러와 깨진 지점 주변을 함께 싣는다. 수만 자짜리
13
+ * 페이로드에서 위치 숫자만으론 어디가 깨졌는지 못 찾는다.
14
+ * - 타입이 어긋나면 실제로 받은 값의 샘플과 경로를 메시지에 붙인다. SDK가 첫 이슈
15
+ * message만 꺼내가므로 경로까지 message 안에 넣어야 살아남는다.
16
+ *
17
+ * **JSON 문자열 관용은 도구 파라미터(최상위 키)에만 걸린다.** `guardShape`가 shape의
18
+ * 키만 돌기 때문이다. `reviewConsensus`는 문자열로 보내도 풀리지만 그 안의
19
+ * `mergedIssues`를 문자열로 보내면 여전히 타입 에러로 떨어진다 — 대신 경로와 받은
20
+ * 값이 붙어 원인은 읽힌다. 중첩까지 넓히려면 배열 요소 스키마를 재구성해야 하는데,
21
+ * 실제로 문자열이 오는 자리는 최상위 파라미터라 거기까지 가지 않았다.
22
+ *
23
+ * **이 파일은 zod v3 내부 구조(`_def`)에 기댄다.** semver 보장 대상이 아니라 마이너
24
+ * 업그레이드에도 깨질 수 있다. 깨지면 예외 없이 조용히 안 걸린다 — 에러 메시지만 예전으로
25
+ * 돌아가고 아무도 모른다. 그래서 `tests/unit/mcp/input-guard.test.ts`가 컨테이너 종류마다
26
+ * 잎을 심어 순회가 그 잎에 닿는지 본다. zod 버전을 올리면 그 테스트를 반드시 다시 돌린다.
27
+ */
28
+ import { z } from 'zod';
29
+ /** 규칙 표를 테스트가 확인할 수 있게 내보낸다. 런타임 경로는 이 함수를 안 쓴다. */
30
+ export declare function tokenRulesForTest(): ReadonlyArray<readonly [string, number]>;
31
+ /** 패턴 계약을 테스트가 확인할 수 있게 내보낸다. 런타임 경로는 이 함수를 안 쓴다. */
32
+ export declare function secretPatternsForTest(): RegExp[];
33
+ /** 실제로 받은 값을 에러 메시지에 실을 수 있게 줄여 찍는다. */
34
+ export declare function formatReceived(value: unknown): string;
35
+ /**
36
+ * 깨진 지점 주변만 잘라 보여준다.
37
+ *
38
+ * 가리기를 먼저 하고 자른다. 순서를 뒤집으면 윈도우가 접두어를 잘라낸 조각에서 패턴이
39
+ * 안 걸린다 — 따옴표를 안 닫은 JSON 이 그 자리다. V8 이 보고하는 위치가 문자열 끝이라
40
+ * 접두어가 윈도우 앞으로 밀려난다.
41
+ *
42
+ * 깨진 지점이 자격증명 안이면 그 자리가 별표로 덮여 원인이 안 보인다. 드문 경우이고
43
+ * 파서 사유와 line, column 은 그대로 남는다 — 노출을 막는 쪽을 택했다.
44
+ */
45
+ export declare function snippetAround(text: string, position: number, radius?: number): string;
46
+ /** JSON 파싱 실패를 부르는 쪽이 바로 고칠 수 있는 한 줄로 만든다. */
47
+ export declare function describeJsonParseFailure(label: string, raw: string, error: unknown): string;
48
+ /**
49
+ * 타입이 어긋났을 때 무엇이 왔는지까지 말해주는 errorMap.
50
+ *
51
+ * SDK가 첫 이슈의 message만 꺼내 쓰므로 경로(`a.b[0].c`)도 여기 넣는다. 안 넣으면
52
+ * 어느 필드가 틀렸는지가 통째로 사라진다.
53
+ */
54
+ export declare const verboseErrorMap: z.ZodErrorMap;
55
+ /**
56
+ * 스키마 트리 전체에 errorMap을 심는다.
57
+ *
58
+ * 제자리에서 고친다. 중첩 배열 요소까지 닿아야 `mergedIssues[0].reportedBy` 같은
59
+ * 자리의 에러에도 경로와 샘플이 붙는다.
60
+ *
61
+ * 가드가 둘이고 하는 일이 다르다.
62
+ * - `seen`에 있는 스키마 인스턴스는 **노드 전체를 건너뛴다** (자식 순회까지 중단).
63
+ * 순환 참조와 공유 참조 대비다.
64
+ * - errorMap이 이미 있는 스키마는 **값 재할당만 건너뛰고** 자식 순회는 계속한다.
65
+ * 같은 shape에 두 번 걸려도 안쪽이 빠짐없이 덮인다.
66
+ */
67
+ export declare function attachErrorMap<S extends z.ZodTypeAny>(schema: S, seen?: WeakSet<object>): S;
68
+ /**
69
+ * 객체나 배열 파라미터에 JSON 문자열을 허용한다.
70
+ *
71
+ * 껍질(optional/default)은 벗겼다가 그대로 다시 씌운다. `preprocess`를 바깥에 두면
72
+ * SDK가 필수 파라미터로 잘못 노출하고 `describe()`도 날아간다.
73
+ */
74
+ export declare function tolerateJsonString<S extends z.ZodTypeAny>(schema: S, label: string): z.ZodTypeAny;
75
+ /**
76
+ * 도구 등록에 넘길 raw shape에 방어를 입힌다.
77
+ *
78
+ * 두 번 지나도 결과가 같다. 다만 구조가 그대로 남는다는 뜻은 아니다. `attachErrorMap`은
79
+ * 값이 비었을 때만 대입하므로 손대는 게 없지만 `tolerateJsonString`은 두 번째 통과에서
80
+ * `preprocess`를 한 겹 더 씌운다. 바깥 겹이 문자열을 풀면 안쪽 겹에는 문자열이 아닌 값이
81
+ * 와서 그대로 지나간다. 상한 검사와 파싱 실패 문구도 한 번만 실린다.
82
+ *
83
+ * `schemas.ts`에서 `guardObject`를 지난 스키마가 `server.ts`의 `guardedTool`에서 한 번
84
+ * 더 지나는 게 그 경로다. 등록 채널을 하나로 남기는 것이 이 설계의 전제라, 레이어를
85
+ * 갈라 그 채널을 다시 여는 쪽으로는 안 간다.
86
+ */
87
+ export declare function guardShape<S extends z.ZodRawShape>(shape: S): S;
88
+ /** 핸들러가 직접 `.parse()`하는 스키마에도 같은 방어를 입힌다. */
89
+ export declare function guardObject<S extends z.AnyZodObject>(schema: S): S;
90
+ //# sourceMappingURL=input-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"input-guard.d.ts","sourceRoot":"","sources":["../../../src/mcp/input-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAwKxB,oDAAoD;AACpD,wBAAgB,iBAAiB,IAAI,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAE5E;AAED,qDAAqD;AACrD,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAEhD;AA8CD,wCAAwC;AACxC,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAarD;AAUD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,SAAiB,GAAG,MAAM,CAQ7F;AAED,8CAA8C;AAC9C,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAe3F;AAED;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,CAAC,CAAC,WA0B/B,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,CAAC,CAAC,UAAU,EACnD,MAAM,EAAE,CAAC,EACT,IAAI,GAAE,OAAO,CAAC,MAAM,CAAiB,GACpC,CAAC,CA4EH;AAiDD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,CAAC,CAAC,UAAU,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,CAAC,CAAC,UAAU,CAwCjG;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAM/D;AAED,6CAA6C;AAC7C,wBAAgB,WAAW,CAAC,CAAC,SAAS,CAAC,CAAC,YAAY,EAAE,MAAM,EAAE,CAAC,GAAG,CAAC,CAElE"}
@@ -0,0 +1,531 @@
1
+ /**
2
+ * MCP 도구 입력 검증을 부르는 쪽이 고칠 수 있는 형태로 돌려주는 자리.
3
+ *
4
+ * MCP SDK는 도구를 부르기 전에 등록된 zod 스키마로 인자를 검증한다. 실패하면
5
+ * 첫 이슈의 message 한 줄만 뽑아 `Invalid arguments for tool ...`로 던진다. 우리
6
+ * 핸들러는 그 전에 못 끼어든다. 그래서 고칠 수 있는 자리는 스키마 자체다.
7
+ *
8
+ * 심는 방어는 이렇다.
9
+ * - 객체나 배열을 기대하는 파라미터에 문자열이 오면 `JSON.parse`를 먼저 시도한다.
10
+ * 호스트가 JSON 문자열을 못 풀고 원본을 그대로 넘기는 일이 있는데, 그때 나오는
11
+ * `Expected object, received string`은 진짜 원인(문자열이 깨졌다)을 덮어버린다.
12
+ * - 파싱이 실패하면 파서의 원본 에러와 깨진 지점 주변을 함께 싣는다. 수만 자짜리
13
+ * 페이로드에서 위치 숫자만으론 어디가 깨졌는지 못 찾는다.
14
+ * - 타입이 어긋나면 실제로 받은 값의 샘플과 경로를 메시지에 붙인다. SDK가 첫 이슈
15
+ * message만 꺼내가므로 경로까지 message 안에 넣어야 살아남는다.
16
+ *
17
+ * **JSON 문자열 관용은 도구 파라미터(최상위 키)에만 걸린다.** `guardShape`가 shape의
18
+ * 키만 돌기 때문이다. `reviewConsensus`는 문자열로 보내도 풀리지만 그 안의
19
+ * `mergedIssues`를 문자열로 보내면 여전히 타입 에러로 떨어진다 — 대신 경로와 받은
20
+ * 값이 붙어 원인은 읽힌다. 중첩까지 넓히려면 배열 요소 스키마를 재구성해야 하는데,
21
+ * 실제로 문자열이 오는 자리는 최상위 파라미터라 거기까지 가지 않았다.
22
+ *
23
+ * **이 파일은 zod v3 내부 구조(`_def`)에 기댄다.** semver 보장 대상이 아니라 마이너
24
+ * 업그레이드에도 깨질 수 있다. 깨지면 예외 없이 조용히 안 걸린다 — 에러 메시지만 예전으로
25
+ * 돌아가고 아무도 모른다. 그래서 `tests/unit/mcp/input-guard.test.ts`가 컨테이너 종류마다
26
+ * 잎을 심어 순회가 그 잎에 닿는지 본다. zod 버전을 올리면 그 테스트를 반드시 다시 돌린다.
27
+ */
28
+ import { z } from 'zod';
29
+ const Kind = z.ZodFirstPartyTypeKind;
30
+ /** 깨진 지점 앞뒤로 보여줄 글자 수. */
31
+ const SNIPPET_RADIUS = 40;
32
+ /** 에러 메시지에 실을 값 샘플의 최대 길이. */
33
+ const SAMPLE_LIMIT = 120;
34
+ // 한 줄 메시지에 실을 샘플이라 깊이 2와 앞 몇 개면 어느 필드가 틀렸는지는 읽힌다.
35
+ // 더 들어가도 어차피 SAMPLE_LIMIT 에서 잘린다.
36
+ const SAMPLE_DEPTH = 2;
37
+ const SAMPLE_ARRAY_ITEMS = 5;
38
+ const SAMPLE_OBJECT_KEYS = 10;
39
+ /**
40
+ * 이보다 긴 문자열은 파싱을 안 해보고 거절한다.
41
+ *
42
+ * Node는 싱글 스레드라 거대한 문자열 하나의 `JSON.parse`가 이벤트 루프를 붙잡으면
43
+ * 같은 서버의 다른 세션 요청까지 멈춘다. 파싱에 실패하는 자리가 아니라 파싱 자체를
44
+ * 안 하는 자리다.
45
+ */
46
+ const MAX_JSON_STRING_LENGTH = 1_000_000;
47
+ /** 문자열 대신 구조를 기대하는 스키마 — 여기에만 JSON 문자열을 허용한다. */
48
+ const STRUCTURED_KINDS = new Set([
49
+ Kind.ZodObject,
50
+ Kind.ZodArray,
51
+ Kind.ZodRecord,
52
+ Kind.ZodTuple,
53
+ ]);
54
+ /**
55
+ * 에러 문구에 실리기 전에 가릴 토큰.
56
+ *
57
+ * 이 메시지는 MCP 클라이언트로 내려가 호스트 로그에 남는다. 검증에 실패한 값이
58
+ * 사용자 콘텐츠를 담는 필드면 거기 섞인 자격증명이 로그에 영구히 남는다. 접두어는
59
+ * 남겨서 무엇이 가려졌는지는 읽히게 한다.
60
+ *
61
+ * **알려진 접두어만 가린다.** 접두어가 없는 값(AWS 시크릿 액세스 키 같은 40자 난수)도,
62
+ * 접두어가 있는데 아래 표에 없는 값(`glpat-`, `SG.`, `dop_v1_` 같은 것)도 못 잡는다.
63
+ * 완전히 막는 장치가 아니라 흔한 실수를 줄이는 자리다. 표를 채우는 건 한 줄이다.
64
+ * 경계 판정이 항목 수만큼 도므로 추가에 드는 비용도 그것뿐이다.
65
+ *
66
+ * 끄는 수단은 두지 않았다. 이 값은 에러 문구에 실려 로그로 나가는 데이터라, 가리기를
67
+ * 끄는 스위치를 두면 그게 노출 경로가 된다.
68
+ */
69
+ /**
70
+ * 접두어와 그 뒤 본문의 최소 길이.
71
+ *
72
+ * **하한을 접두어마다 따로 잡는다.** 하나로 묶으면 둘 중 하나가 깨진다 — 낮게 잡으면
73
+ * `npm_package_name` 과 `task-scheduler` 처럼 꼬리가 긴 멀쩡한 식별자가 가려진다.
74
+ * 높게 잡으면 짧은 토큰을 놓친다. 값은 각 서비스가 발급하는 길이에서 왔다.
75
+ *
76
+ * `sk-` 와 `AKIA`, `npm_` 은 영단어 안에 흔히 들어간다. 그쪽 하한이 특히 실제 발급
77
+ * 길이에 붙어 있어야 `risk-assessment` 가 안 걸린다.
78
+ */
79
+ const TOKEN_RULES = [
80
+ // Stripe — API 키 24자 이상, 제한 키와 웹훅 서명 시크릿도 같은 꼴이다. 웹훅 시크릿이
81
+ // 새면 서명을 위조해 임의 이벤트를 밀어넣을 수 있어 API 키와 위험의 종류가 다르다.
82
+ ['sk_live_', 20],
83
+ ['sk_test_', 20],
84
+ ['rk_live_', 20],
85
+ ['rk_test_', 20],
86
+ ['whsec_', 20],
87
+ // GitHub — PAT 36자, fine-grained 82자. ghu_ 와 ghr_ 은 GitHub App 쪽이다.
88
+ ['ghp_', 30],
89
+ ['gho_', 30],
90
+ ['ghs_', 30],
91
+ ['ghu_', 30],
92
+ ['ghr_', 30],
93
+ ['github_pat_', 30],
94
+ // Slack — 가변이라 낮게 잡는다. 영단어에 안 나오는 꼴이라 낮아도 안전하다.
95
+ // xapp- 은 Socket Mode 앱 레벨 토큰이다.
96
+ ['xox[baprs]-', 10],
97
+ ['xapp-', 10],
98
+ // Google API 키 — 35자
99
+ ['AIza', 30],
100
+ // 뒤에 공백류뿐 아니라 이스케이프된 개행(`\n` 두 글자)도 받는다. `formatReceived` 는
101
+ // `JSON.stringify` 를 먼저 거치므로 그 자리에 실제 개행이 안 남는다.
102
+ //
103
+ // 하한이 낮다. Bearer 뒤에 오는 토큰은 서비스마다 꼴이 달라 8자짜리 공유 시크릿을
104
+ // 그대로 보내는 API 도 있다.
105
+ //
106
+ // 그 대가로 `Bearer authentication required` 는 `Bearer *** required` 가 된다. 여섯
107
+ // 자를 넘는 뒷말이면 영어 단어라도 가려진다 — 짧은 말(`Bearer token`)만 살아남는다.
108
+ // 이 파일이 세운 우선순위(못 가리는 쪽이 잘못 가리는 쪽보다 나쁘다)를 그대로 따른
109
+ // 값이다.
110
+ ['Bearer(?:\\s|\\\\n|\\\\r)+', 6],
111
+ // OpenAI — 48자 안팎. `task-`, `risk-`, `desk-` 한가운데에 걸린다.
112
+ ['sk-', 20],
113
+ // AWS — 액세스 키 ID 는 뒤에 16자. ASIA 는 STS 임시 자격증명이라 운영에서 더 흔하다.
114
+ // `AKIActually` 같은 단어가 하한 밑으로 떨어진다.
115
+ ['AKIA', 16],
116
+ ['ASIA', 16],
117
+ // npm 토큰 — 36자. `npm_config_registry` 류 환경변수 이름이 그 아래로 떨어진다.
118
+ ['npm_', 30],
119
+ ];
120
+ /**
121
+ * 접두어를 그룹 1 로 캡처한다. 규칙마다 패턴 하나씩 만든다.
122
+ *
123
+ * **단어 경계는 두지 않는다.** `(?<![A-Za-z0-9_])` 를 붙이면 `my_ghp_REAL` 이나
124
+ * `123ghp_REAL` 처럼 접두어 앞에 단어 문자가 붙은 진짜 토큰을 놓친다. 못 가리는 쪽이
125
+ * 잘못 가리는 쪽보다 나쁘다 — 가리는 게 이 함수의 일이다. 잘못 가리는 건 접두어별
126
+ * 하한으로 막는다.
127
+ */
128
+ function tokenPatterns() {
129
+ return TOKEN_RULES.map(([prefix, minBody]) => {
130
+ // JWT 는 점으로 세 조각이다. 점을 안 받으면 헤더만 가려지고 payload 와 signature 가
131
+ // 남는데, 헤더는 추측 가능하니 남은 쪽이 사실상 자격증명 전체다. `Bearer` 뒤에만
132
+ // 허용하므로 `npm_package_name` 류 하한 보호에는 영향이 없다.
133
+ const body = prefix.startsWith('Bearer') ? '[A-Za-z0-9_.-]' : '[A-Za-z0-9_-]';
134
+ return new RegExp(`(${prefix})${body}{${minBody},}`, 'g');
135
+ });
136
+ }
137
+ /**
138
+ * 헤더부터 END 마커까지, 없으면 JSON 문자열 경계나 끝까지 삼킨다.
139
+ *
140
+ * 본문을 base64 문자 집합으로 좁히면 암호화 PEM 을 못 덮는다 — `Proc-Type: 4,ENCRYPTED`
141
+ * 와 `DEK-Info: AES-128-CBC,<salt>` 에 든 콜론과 쉼표에서 매칭이 끊긴다. END 가 선택
142
+ * 그룹이라 그 자리에서 종료되고 본문과 푸터가 그대로 남는다.
143
+ *
144
+ * **따옴표를 경계로 쓰지 않는다.** 본문에 따옴표가 있으면 거기서 일찍 끊겨 뒤가 남는다.
145
+ * `formatReceived` 는 `JSON.stringify` 를 거치므로 값 안의 따옴표가 그 자리에 실제로
146
+ * 온다. 대신 lazy 라서 END 가 있으면 거기서 멈춘다. 없으면 끝까지 삼킨다 — 헤더가
147
+ * 보였다는 건 그 값이 키라는 뜻이라 나머지를 보여줄 이유가 없다.
148
+ */
149
+ const PEM_PATTERN = /(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g;
150
+ /**
151
+ * 순서가 바꾸는 건 어느 접두어가 남는지뿐이다. 가려지는지 여부와 남는 길이는 안 바뀐다.
152
+ * `Bearer ghp_…` 처럼 규칙이 겹치는 입력에서 앞 순서는 `Bearer ghp_***`, 역순은
153
+ * `Bearer ***` 를 낸다 — 둘 다 본문은 덮인다.
154
+ *
155
+ * 본문을 base64 집합으로 좁혔던 동안에는 순서가 노출 자체를 갈랐다. 토큰 패턴의 치환
156
+ * 문자가 PEM 매칭을 끊어 뒤가 통째로 남았다 — 그 클래스를 되돌리면서 사라졌다.
157
+ */
158
+ const SECRET_PATTERNS = [PEM_PATTERN, ...tokenPatterns()];
159
+ /**
160
+ * 토큰을 가린다. 접두어는 남겨 무엇이 가려졌는지는 읽히게 한다.
161
+ *
162
+ * **모든 패턴이 접두어를 그룹 1 로 캡처한다.** 그룹이 없는 패턴을 섞으면
163
+ * `String.replace` 가 두 번째 인자로 매치 오프셋(숫자)을 넘겨, 접두어가 숫자로 바뀌고
164
+ * 치환 길이가 무너진다. 그 계약은 `tests/unit/mcp/input-guard.test.ts` 가 패턴 배열을
165
+ * 돌며 단언한다.
166
+ *
167
+ * **잘라낸 조각에 따로 걸지 않는다.** 접두어와 몸통이 서로 다른 조각에 놓이면 어느 쪽도
168
+ * 패턴에 안 걸려 토큰이 통째로 남는다. 부르는 쪽은 언제나 원본 전체를 넘긴다.
169
+ *
170
+ * `preserveLength` 는 `snippetAround` 만 쓴다. 자리가 밀리면 깨진 지점을 못 짚기 때문이다.
171
+ * 나머지 자리는 고정 길이로 덮어 별표 개수가 원문 길이를 드러내지 않게 한다.
172
+ */
173
+ function redactSecrets(text, options = {}) {
174
+ return SECRET_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, (match, group) => {
175
+ const prefix = typeof group === 'string' ? group : '';
176
+ if (!options.preserveLength)
177
+ return `${prefix}***`;
178
+ // 하한을 두지 않는다. 본문이 빈 헤더에 별표를 덧붙이면 그만큼 자리가 밀린다.
179
+ return prefix + '*'.repeat(match.length - prefix.length);
180
+ }), text);
181
+ }
182
+ /** 규칙 표를 테스트가 확인할 수 있게 내보낸다. 런타임 경로는 이 함수를 안 쓴다. */
183
+ export function tokenRulesForTest() {
184
+ return TOKEN_RULES;
185
+ }
186
+ /** 패턴 계약을 테스트가 확인할 수 있게 내보낸다. 런타임 경로는 이 함수를 안 쓴다. */
187
+ export function secretPatternsForTest() {
188
+ return [...SECRET_PATTERNS];
189
+ }
190
+ /** 한 줄 메시지에 실으므로 줄바꿈은 눈에 보이게 바꾼다. */
191
+ function escapeNewlines(text) {
192
+ return text.replace(/\r/g, '\\r').replace(/\n/g, '\\n');
193
+ }
194
+ function typeNameOf(schema) {
195
+ return schema._def.typeName;
196
+ }
197
+ function formatPath(path) {
198
+ return path.reduce((acc, segment) => {
199
+ if (typeof segment === 'number')
200
+ return `${acc}[${segment}]`;
201
+ return acc === '' ? String(segment) : `${acc}.${segment}`;
202
+ }, '');
203
+ }
204
+ /**
205
+ * 직렬화 전에 값을 얕게 잘라낸다.
206
+ *
207
+ * 메시지에 실리는 건 120자뿐인데 거대한 배열을 통째로 문자열로 만들면 그 비용을 다
208
+ * 문다. 깊이와 개수를 미리 줄여 직렬화한다.
209
+ *
210
+ * **문자열 길이는 여기서 안 줄인다.** 자르기가 가리기보다 먼저 오면 경계에 걸친 토큰이
211
+ * 패턴에 안 걸린다. 최종 길이는 `formatReceived` 가 가린 뒤에 맞춘다.
212
+ */
213
+ function shallowSample(value, depth = 0) {
214
+ if (depth > SAMPLE_DEPTH)
215
+ return '…';
216
+ if (Array.isArray(value)) {
217
+ const head = value.slice(0, SAMPLE_ARRAY_ITEMS).map((item) => shallowSample(item, depth + 1));
218
+ return value.length > SAMPLE_ARRAY_ITEMS ? [...head, '…'] : head;
219
+ }
220
+ if (value !== null && typeof value === 'object') {
221
+ const out = {};
222
+ const source = value;
223
+ for (const key of Object.keys(source).slice(0, SAMPLE_OBJECT_KEYS)) {
224
+ out[key] = shallowSample(source[key], depth + 1);
225
+ }
226
+ return out;
227
+ }
228
+ // 문자열은 자르지 않는다. 이유는 위 주석에 있다. 비용은 입력 크기에 선형이다 —
229
+ // `MAX_JSON_STRING_LENGTH` 는 `tolerateJsonString` 경로만 막으므로 여기까지는 안 온다.
230
+ return value;
231
+ }
232
+ /** 실제로 받은 값을 에러 메시지에 실을 수 있게 줄여 찍는다. */
233
+ export function formatReceived(value) {
234
+ if (typeof value === 'function')
235
+ return 'function';
236
+ let rendered;
237
+ try {
238
+ rendered = JSON.stringify(shallowSample(value)) ?? String(value);
239
+ }
240
+ catch {
241
+ rendered = String(value);
242
+ }
243
+ // 가리기를 먼저 하고 자른다. 순서를 뒤집으면 자르는 경계에 걸친 토큰의 뒤 조각이 패턴의
244
+ // 최소 길이를 못 채워 접두어와 앞 몇 글자가 그대로 남는다. 스니펫에서 겪은 것과 같은
245
+ // 구조다.
246
+ const masked = escapeNewlines(redactSecrets(rendered));
247
+ return masked.length > SAMPLE_LIMIT ? `${masked.slice(0, SAMPLE_LIMIT)}…` : masked;
248
+ }
249
+ /** JSON 파서 에러 문구에서 위치를 뽑는다. 런타임마다 문구가 달라 없을 수도 있다. */
250
+ function extractPosition(reason) {
251
+ const match = /at position (\d+)/.exec(reason);
252
+ if (!match)
253
+ return null;
254
+ const position = Number(match[1]);
255
+ return Number.isFinite(position) ? position : null;
256
+ }
257
+ /**
258
+ * 깨진 지점 주변만 잘라 보여준다.
259
+ *
260
+ * 가리기를 먼저 하고 자른다. 순서를 뒤집으면 윈도우가 접두어를 잘라낸 조각에서 패턴이
261
+ * 안 걸린다 — 따옴표를 안 닫은 JSON 이 그 자리다. V8 이 보고하는 위치가 문자열 끝이라
262
+ * 접두어가 윈도우 앞으로 밀려난다.
263
+ *
264
+ * 깨진 지점이 자격증명 안이면 그 자리가 별표로 덮여 원인이 안 보인다. 드문 경우이고
265
+ * 파서 사유와 line, column 은 그대로 남는다 — 노출을 막는 쪽을 택했다.
266
+ */
267
+ export function snippetAround(text, position, radius = SNIPPET_RADIUS) {
268
+ // 마스킹이 길이를 보존하므로 위치가 그대로 쓰인다. 줄여 쓰면 자리가 밀려 보정이 필요해지고
269
+ // 그 보정은 근사라서 깨진 지점을 못 짚는다.
270
+ const masked = redactSecrets(text, { preserveLength: true });
271
+ const start = Math.max(0, position - radius);
272
+ const end = Math.min(masked.length, position + radius);
273
+ const body = escapeNewlines(masked.slice(start, end));
274
+ return `${start > 0 ? '…' : ''}${body}${end < masked.length ? '…' : ''}`;
275
+ }
276
+ /** JSON 파싱 실패를 부르는 쪽이 바로 고칠 수 있는 한 줄로 만든다. */
277
+ export function describeJsonParseFailure(label, raw, error) {
278
+ const reason = error instanceof Error ? error.message : String(error);
279
+ // 파서 문구도 부르는 쪽 바이트에서 나온다. 최신 V8 은 깨진 지점 원문을 그 안에
280
+ // 인용하므로 가려야 한다.
281
+ //
282
+ // **인용이 하한보다 짧으면 안 가려진다.** V8 은 여섯 자쯤만 인용하므로 접두어와 그
283
+ // 몇 글자가 남는다. 하한을 내려 그걸 잡으려 했더니 `npm_package_name` 같은 멀쩡한
284
+ // 식별자가 원인 설명에서 지워졌다 — 원인을 알려주자는 목적과 반대로 간다. 조각 여섯
285
+ // 자로는 키를 복원할 수 없다. 원문 전체를 보는 두 경로(값 샘플과 스니펫)는 정확히
286
+ // 가려진다. 그쪽을 택했다.
287
+ const masked = escapeNewlines(redactSecrets(reason));
288
+ const head = `${label}: 문자열로 왔는데 JSON으로 안 풀립니다 — ${masked}`;
289
+ const position = extractPosition(reason);
290
+ if (position === null)
291
+ return `${head} (길이 ${raw.length}자)`;
292
+ return `${head}. 깨진 지점 주변: ${snippetAround(raw, position)}`;
293
+ }
294
+ /**
295
+ * 타입이 어긋났을 때 무엇이 왔는지까지 말해주는 errorMap.
296
+ *
297
+ * SDK가 첫 이슈의 message만 꺼내 쓰므로 경로(`a.b[0].c`)도 여기 넣는다. 안 넣으면
298
+ * 어느 필드가 틀렸는지가 통째로 사라진다.
299
+ */
300
+ export const verboseErrorMap = (issue, ctx) => {
301
+ const path = formatPath(issue.path ?? []);
302
+ const at = path === '' ? '' : ` at ${path}`;
303
+ if (issue.code === z.ZodIssueCode.invalid_type) {
304
+ // 값을 아예 안 보낸 경우다. 샘플 대신 무슨 타입이 필요한지를 알려준다.
305
+ if (issue.received === z.ZodParsedType.undefined) {
306
+ return { message: `Required${at} (expected: ${issue.expected})` };
307
+ }
308
+ return {
309
+ message: `Expected ${issue.expected}, received ${issue.received}${at} (received: ${formatReceived(ctx.data)})`,
310
+ };
311
+ }
312
+ // 어느 키가 남았는지는 이슈가 이미 들고 있다. 객체 전체를 찍으면 문제와 무관한
313
+ // 옆 필드 값까지 메시지와 로그에 함께 실린다.
314
+ // 이 레포 스키마에는 `.strict()` 가 없어 지금은 안 지나간다. 나중에 붙을 자리로 남겨둔다.
315
+ if (issue.code === z.ZodIssueCode.unrecognized_keys) {
316
+ return { message: `${ctx.defaultError}${at} (unrecognized: ${issue.keys.join(', ')})` };
317
+ }
318
+ // 코드를 가려 뽑지 않는다. 허용 목록으로 두면 나중에 union 이나 date 필드가 붙었을
319
+ // 때 그 자리만 조용히 샘플을 잃는다. 메시지가 이미 정해진 이슈(`.refine()` 의 커스텀
320
+ // 문구, 이 파일이 던지는 파싱 실패 문구)는 zod 가 errorMap 을 아예 안 태우므로 여기서
321
+ // 걸러낼 필요도 없다.
322
+ return { message: `${ctx.defaultError}${at} (received: ${formatReceived(ctx.data)})` };
323
+ };
324
+ /**
325
+ * 스키마 트리 전체에 errorMap을 심는다.
326
+ *
327
+ * 제자리에서 고친다. 중첩 배열 요소까지 닿아야 `mergedIssues[0].reportedBy` 같은
328
+ * 자리의 에러에도 경로와 샘플이 붙는다.
329
+ *
330
+ * 가드가 둘이고 하는 일이 다르다.
331
+ * - `seen`에 있는 스키마 인스턴스는 **노드 전체를 건너뛴다** (자식 순회까지 중단).
332
+ * 순환 참조와 공유 참조 대비다.
333
+ * - errorMap이 이미 있는 스키마는 **값 재할당만 건너뛰고** 자식 순회는 계속한다.
334
+ * 같은 shape에 두 번 걸려도 안쪽이 빠짐없이 덮인다.
335
+ */
336
+ export function attachErrorMap(schema, seen = new WeakSet()) {
337
+ // 순회가 집는 필드 이름이 어긋나면 undefined 가 온다. 여기서는 조용히 물러난다 —
338
+ // 스키마 하나 때문에 기동을 세우지 않는다. 그 어긋남은
339
+ // `tests/unit/mcp/input-guard.test.ts` 가 컨테이너 종류마다 잎을 심어 잡는다.
340
+ if (schema === undefined || schema === null)
341
+ return schema;
342
+ if (seen.has(schema))
343
+ return schema;
344
+ seen.add(schema);
345
+ const def = schema._def;
346
+ if (def.errorMap === undefined)
347
+ def.errorMap = verboseErrorMap;
348
+ const kind = typeNameOf(schema);
349
+ const raw = schema._def;
350
+ switch (kind) {
351
+ case Kind.ZodObject: {
352
+ const shape = schema.shape;
353
+ for (const key of Object.keys(shape))
354
+ attachErrorMap(shape[key], seen);
355
+ break;
356
+ }
357
+ case Kind.ZodArray:
358
+ attachErrorMap(raw['type'], seen);
359
+ break;
360
+ // 자식을 `type` 에 두는 것들. `innerType` 분기에 묶으면 undefined 가 넘어간다.
361
+ case Kind.ZodSet:
362
+ attachErrorMap(raw['valueType'], seen);
363
+ break;
364
+ case Kind.ZodPromise:
365
+ case Kind.ZodBranded:
366
+ attachErrorMap(raw['type'], seen);
367
+ break;
368
+ case Kind.ZodOptional:
369
+ case Kind.ZodNullable:
370
+ case Kind.ZodDefault:
371
+ case Kind.ZodCatch:
372
+ case Kind.ZodReadonly:
373
+ attachErrorMap(raw['innerType'], seen);
374
+ break;
375
+ case Kind.ZodEffects:
376
+ attachErrorMap(raw['schema'], seen);
377
+ break;
378
+ case Kind.ZodUnion:
379
+ case Kind.ZodDiscriminatedUnion: {
380
+ const options = raw['options'];
381
+ const list = Array.isArray(options)
382
+ ? options
383
+ : [...options.values()];
384
+ for (const option of list)
385
+ attachErrorMap(option, seen);
386
+ break;
387
+ }
388
+ case Kind.ZodIntersection:
389
+ attachErrorMap(raw['left'], seen);
390
+ attachErrorMap(raw['right'], seen);
391
+ break;
392
+ case Kind.ZodRecord:
393
+ case Kind.ZodMap:
394
+ attachErrorMap(raw['keyType'], seen);
395
+ attachErrorMap(raw['valueType'], seen);
396
+ break;
397
+ case Kind.ZodPipeline:
398
+ attachErrorMap(raw['in'], seen);
399
+ attachErrorMap(raw['out'], seen);
400
+ break;
401
+ case Kind.ZodTuple: {
402
+ for (const item of raw['items'] ?? [])
403
+ attachErrorMap(item, seen);
404
+ const rest = raw['rest'];
405
+ if (rest)
406
+ attachErrorMap(rest, seen);
407
+ break;
408
+ }
409
+ // ZodLazy 같은 재귀 스키마는 getter가 부를 때마다 새 인스턴스를 낼 수 있어
410
+ // `seen`으로 못 막는다. 여기서 안 따라가므로 그 서브트리는 방어 없이 남는다.
411
+ default:
412
+ break;
413
+ }
414
+ return schema;
415
+ }
416
+ /**
417
+ * optional, nullable, default 껍질을 벗겨 안쪽 스키마와 껍질 순서를 돌려준다.
418
+ *
419
+ * ZodEffects(`.refine()`, `.transform()`)는 안 벗긴다. 그 검사는 JSON을 푼 뒤에 그대로
420
+ * 돌아야 하므로 preprocess가 그 바깥을 감싼다.
421
+ */
422
+ function peel(schema) {
423
+ const wrappers = [];
424
+ let current = schema;
425
+ for (;;) {
426
+ const kind = typeNameOf(current);
427
+ const raw = current._def;
428
+ if (kind === Kind.ZodOptional) {
429
+ wrappers.push({ kind: 'optional' });
430
+ current = raw['innerType'];
431
+ }
432
+ else if (kind === Kind.ZodNullable) {
433
+ wrappers.push({ kind: 'nullable' });
434
+ current = raw['innerType'];
435
+ }
436
+ else if (kind === Kind.ZodDefault) {
437
+ wrappers.push({ kind: 'default', factory: raw['defaultValue'] });
438
+ current = raw['innerType'];
439
+ }
440
+ else {
441
+ return { inner: current, wrappers };
442
+ }
443
+ }
444
+ }
445
+ /**
446
+ * 이 스키마가 결국 구조를 기대하는지 본다.
447
+ *
448
+ * `.refine()`이 하나 얹히면 최상위가 ZodEffects가 된다. 그것만 보고 판정하면 그 필드는
449
+ * JSON 문자열 관용을 조용히 잃는다 — 에러도 안 나서 알아채기 어렵다.
450
+ */
451
+ function expectsStructured(schema, depth = 0) {
452
+ if (depth > 10)
453
+ return false;
454
+ const kind = typeNameOf(schema);
455
+ if (kind === Kind.ZodEffects) {
456
+ return expectsStructured(schema._def.schema, depth + 1);
457
+ }
458
+ return STRUCTURED_KINDS.has(kind);
459
+ }
460
+ /**
461
+ * 객체나 배열 파라미터에 JSON 문자열을 허용한다.
462
+ *
463
+ * 껍질(optional/default)은 벗겼다가 그대로 다시 씌운다. `preprocess`를 바깥에 두면
464
+ * SDK가 필수 파라미터로 잘못 노출하고 `describe()`도 날아간다.
465
+ */
466
+ export function tolerateJsonString(schema, label) {
467
+ const { inner, wrappers } = peel(schema);
468
+ if (!expectsStructured(inner))
469
+ return schema;
470
+ let rebuilt = z.preprocess((value, ctx) => {
471
+ if (typeof value !== 'string')
472
+ return value;
473
+ if (value.length > MAX_JSON_STRING_LENGTH) {
474
+ ctx.addIssue({
475
+ code: z.ZodIssueCode.custom,
476
+ fatal: true,
477
+ message: `${label}: 문자열이 너무 깁니다 (${value.length}자, 상한 ${MAX_JSON_STRING_LENGTH}자). 파싱을 시도하지 않았습니다.`,
478
+ });
479
+ return z.NEVER;
480
+ }
481
+ try {
482
+ return JSON.parse(value);
483
+ }
484
+ catch (error) {
485
+ // fatal이 없으면 zod가 뒤 검증을 계속 돌려 `Required at ...` 잡음을 덧붙인다.
486
+ // 부르는 쪽이 고쳐야 할 건 파싱 하나뿐이다.
487
+ ctx.addIssue({
488
+ code: z.ZodIssueCode.custom,
489
+ fatal: true,
490
+ message: describeJsonParseFailure(label, value, error),
491
+ });
492
+ return z.NEVER;
493
+ }
494
+ }, inner);
495
+ for (const wrapper of [...wrappers].reverse()) {
496
+ if (wrapper.kind === 'optional')
497
+ rebuilt = rebuilt.optional();
498
+ else if (wrapper.kind === 'nullable')
499
+ rebuilt = rebuilt.nullable();
500
+ // 팩토리를 그대로 넘긴다. 여기서 호출해 결과를 박으면 모든 요청이 같은 객체
501
+ // 인스턴스를 나눠 쓰게 된다.
502
+ else
503
+ rebuilt = rebuilt.default(wrapper.factory);
504
+ }
505
+ const description = schema._def.description;
506
+ return description === undefined ? rebuilt : rebuilt.describe(description);
507
+ }
508
+ /**
509
+ * 도구 등록에 넘길 raw shape에 방어를 입힌다.
510
+ *
511
+ * 두 번 지나도 결과가 같다. 다만 구조가 그대로 남는다는 뜻은 아니다. `attachErrorMap`은
512
+ * 값이 비었을 때만 대입하므로 손대는 게 없지만 `tolerateJsonString`은 두 번째 통과에서
513
+ * `preprocess`를 한 겹 더 씌운다. 바깥 겹이 문자열을 풀면 안쪽 겹에는 문자열이 아닌 값이
514
+ * 와서 그대로 지나간다. 상한 검사와 파싱 실패 문구도 한 번만 실린다.
515
+ *
516
+ * `schemas.ts`에서 `guardObject`를 지난 스키마가 `server.ts`의 `guardedTool`에서 한 번
517
+ * 더 지나는 게 그 경로다. 등록 채널을 하나로 남기는 것이 이 설계의 전제라, 레이어를
518
+ * 갈라 그 채널을 다시 여는 쪽으로는 안 간다.
519
+ */
520
+ export function guardShape(shape) {
521
+ const guarded = {};
522
+ for (const key of Object.keys(shape)) {
523
+ guarded[key] = tolerateJsonString(attachErrorMap(shape[key]), key);
524
+ }
525
+ return guarded;
526
+ }
527
+ /** 핸들러가 직접 `.parse()`하는 스키마에도 같은 방어를 입힌다. */
528
+ export function guardObject(schema) {
529
+ return attachErrorMap(schema.extend(guardShape(schema.shape)));
530
+ }
531
+ //# sourceMappingURL=input-guard.js.map